diff --git a/apps/web/src/app/dashboard/events/[id]/event-people.tsx b/apps/web/src/app/dashboard/events/[id]/event-people.tsx
index 2ab6778..ae2cc9d 100644
--- a/apps/web/src/app/dashboard/events/[id]/event-people.tsx
+++ b/apps/web/src/app/dashboard/events/[id]/event-people.tsx
@@ -182,7 +182,7 @@ export function EventPeople({
) : null}
- {canManage && event.data?.groupId ? : null}
+ {canManage && event.data?.groupId ? : null}
);
diff --git a/apps/web/src/server/api/routers/group.ts b/apps/web/src/server/api/routers/group.ts
index a95f503..77cf438 100644
--- a/apps/web/src/server/api/routers/group.ts
+++ b/apps/web/src/server/api/routers/group.ts
@@ -57,12 +57,22 @@ async function validateInviteGrants(userId: string, input: {
}
}
+async function requireInviteManagement(userId: string, invite: typeof invites.$inferSelect) {
+ if (!invite.groupId) throw new TRPCError({ code: "NOT_FOUND" });
+ if (invite.eventId && (!invite.groupRole || invite.groupRole === "member") &&
+ !invite.grantUnlimitedEvents && invite.grantEventLimit == null && !invite.grantComplimentary) {
+ await validateInviteGrants(userId, { groupId: invite.groupId, eventId: invite.eventId, eventRole: invite.eventRole ?? undefined });
+ return;
+ }
+ const { access } = await loadGroupAccess(userId, invite.groupId, await getPlatformRole(userId));
+ requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
+}
+
export const groupRouter = createTRPCRouter({
copyInviteLink: protectedProcedure.input(z.object({ groupId: z.string().uuid(), inviteId: z.string().uuid() })).mutation(async ({ ctx, input }) => {
- const { access } = await loadGroupAccess(ctx.session.user.id, input.groupId, await getPlatformRole(ctx.session.user.id));
- requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
const [invite] = await getDb().select().from(invites).where(and(eq(invites.id, input.inviteId), eq(invites.groupId, input.groupId))).limit(1);
if (!invite || invite.kind !== "email") throw new TRPCError({ code: "NOT_FOUND" });
+ await requireInviteManagement(ctx.session.user.id, invite);
assertInviteRedeemable(invite);
await validateInviteGrants(ctx.session.user.id, { groupId: input.groupId, eventId: invite.eventId ?? undefined, eventRole: invite.eventRole ?? undefined, grantUnlimitedEvents: invite.grantUnlimitedEvents, grantEventLimit: invite.grantEventLimit, grantComplimentary: invite.grantComplimentary });
if (!invite.tokenEncrypted) throw new TRPCError({ code: "BAD_REQUEST", message: "This older invitation needs an explicit regeneration before its link can be copied." });
@@ -75,13 +85,12 @@ export const groupRouter = createTRPCRouter({
}
}),
resendInvite: protectedProcedure.input(z.object({ groupId: z.string().uuid(), inviteId: z.string().uuid(), regenerate: z.boolean().default(false) })).mutation(async ({ ctx, input }) => {
- const { access } = await loadGroupAccess(ctx.session.user.id, input.groupId, await getPlatformRole(ctx.session.user.id));
- requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
- const limit = await consumeRateLimit({ namespace: "invite-resend", identifier: `${input.groupId}:${input.inviteId}`, limit: 1, windowMs: 60000 });
- if (!limit.allowed) throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Wait one minute before resending this invite." });
await getDb().transaction(async tx => {
const [invite] = await tx.select().from(invites).where(and(eq(invites.id, input.inviteId), eq(invites.groupId, input.groupId))).limit(1).for("update");
if (!invite || invite.kind !== "email" || !invite.email) throw new TRPCError({ code: "NOT_FOUND" });
+ await requireInviteManagement(ctx.session.user.id, invite);
+ const limit = await consumeRateLimit({ namespace: "invite-resend", identifier: `${input.groupId}:${input.inviteId}`, limit: 1, windowMs: 60000 });
+ if (!limit.allowed) throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Wait one minute before resending this invite." });
assertInviteRedeemable(invite);
await validateInviteGrants(ctx.session.user.id, { groupId: input.groupId, eventId: invite.eventId ?? undefined, eventRole: invite.eventRole ?? undefined, grantUnlimitedEvents: invite.grantUnlimitedEvents, grantEventLimit: invite.grantEventLimit, grantComplimentary: invite.grantComplimentary });
let token: string;
@@ -117,9 +126,10 @@ export const groupRouter = createTRPCRouter({
revokeInvite: protectedProcedure
.input(z.object({ groupId: z.string().uuid(), inviteId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
- const { access } = await loadGroupAccess(ctx.session.user.id, input.groupId, await getPlatformRole(ctx.session.user.id));
- requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
await getDb().transaction(async tx => {
+ const [target] = await tx.select().from(invites).where(and(eq(invites.id, input.inviteId), eq(invites.groupId, input.groupId))).limit(1).for("update");
+ if (!target) throw new TRPCError({ code: "NOT_FOUND" });
+ await requireInviteManagement(ctx.session.user.id, target);
const [invite] = await tx.update(invites).set({ status: "revoked", updatedAt: new Date() })
.where(and(eq(invites.id, input.inviteId), eq(invites.groupId, input.groupId), eq(invites.status, "pending")))
.returning({ id: invites.id, eventId: invites.eventId });
diff --git a/apps/web/src/server/polish.integration.test.ts b/apps/web/src/server/polish.integration.test.ts
index 3e172df..aeafbbe 100644
--- a/apps/web/src/server/polish.integration.test.ts
+++ b/apps/web/src/server/polish.integration.test.ts
@@ -79,6 +79,18 @@ test.skipIf(process.env.POLISH_INTEGRATION !== "1")("invitation isolation, atomi
await expect(workspace.resendInvite({ groupId: group!.id, inviteId: resendTarget!.id })).rejects.toThrow("Wait one minute");
await workspace.revokeInvite({ groupId: group!.id, inviteId: resendTarget!.id });
await expect(workspace.copyInviteLink({ groupId: group!.id, inviteId: resendTarget!.id })).rejects.toThrow();
+ await db.insert(eventMemberships).values({ eventId: event!.id, userId: people[1]!.id, role: "manager" });
+ const eventManager = groupRouter.createCaller(ctx(1));
+ const eventToken = crypto.randomUUID();
+ const [eventInvite] = await db.insert(invites).values({ groupId: group!.id, eventId: event!.id, kind: "email", email: people[1]!.email, groupRole: "member", eventRole: "manager", tokenHash: hashToken(eventToken), tokenEncrypted: encryptInviteToken(eventToken) }).returning();
+ expect(new URL((await eventManager.copyInviteLink({ groupId: group!.id, inviteId: eventInvite!.id })).url).pathname).toBe(`/invitations/${eventToken}`);
+ await eventManager.resendInvite({ groupId: group!.id, inviteId: eventInvite!.id });
+ await db.update(invites).set({ groupRole: "owner" }).where(eq(invites.id, eventInvite!.id));
+ await expect(eventManager.copyInviteLink({ groupId: group!.id, inviteId: eventInvite!.id })).rejects.toThrow();
+ await expect(eventManager.revokeInvite({ groupId: group!.id, inviteId: eventInvite!.id })).rejects.toThrow();
+ await db.update(invites).set({ groupRole: "member" }).where(eq(invites.id, eventInvite!.id));
+ await eventManager.revokeInvite({ groupId: group!.id, inviteId: eventInvite!.id });
+ await db.delete(eventMemberships).where(and(eq(eventMemberships.eventId, event!.id), eq(eventMemberships.userId, people[1]!.id)));
await expect(workspace.createCode({ groupId: group!.id, grantUnlimitedEvents: true })).rejects.toThrow("platform administrators");
const signs = signsRouter.createCaller(ctx(0));
await expect(signsRouter.createCaller(ctx(1)).prepare({ eventId: event!.id })).rejects.toThrow();
diff --git a/packages/contracts/src/sign.ts b/packages/contracts/src/sign.ts
index fcf78ed..46946c0 100644
--- a/packages/contracts/src/sign.ts
+++ b/packages/contracts/src/sign.ts
@@ -13,6 +13,14 @@ export const SIGN_PAPERS = {
card6x4: { label: "Landscape postcard · 6 × 4 in", width: "6in", height: "4in", viewHeight: 850 * 4 / 6 },
card5x7: { label: "Table card · 5 × 7 in", width: "5in", height: "7in", viewHeight: 1190 },
card7x5: { label: "Landscape table card · 7 × 5 in", width: "7in", height: "5in", viewHeight: 850 * 5 / 7 },
+ print8x10: { label: "Photo print · 8 × 10 in", width: "8in", height: "10in", viewHeight: 850 * 10 / 8 },
+ print10x8: { label: "Landscape photo print · 10 × 8 in", width: "10in", height: "8in", viewHeight: 850 * 8 / 10 },
+ square4: { label: "Square print · 4 × 4 in", width: "4in", height: "4in", viewHeight: 850 },
+ trueDigital: { label: "True Digital · 4 × 5.3 in", width: "4in", height: "5.3in", viewHeight: 850 * 5.3 / 4 },
+ trueDigitalLandscape: { label: "True Digital landscape · 5.3 × 4 in", width: "5.3in", height: "4in", viewHeight: 850 * 4 / 5.3 },
+ print6x8: { label: "Photo print · 6 × 8 in", width: "6in", height: "8in", viewHeight: 850 * 8 / 6 },
+ print8x6: { label: "Landscape photo print · 8 × 6 in", width: "8in", height: "6in", viewHeight: 850 * 6 / 8 },
+ square8: { label: "Square print · 8 × 8 in", width: "8in", height: "8in", viewHeight: 850 },
square: { label: "Square card · 5 × 5 in", width: "5in", height: "5in", viewHeight: 850 },
} as const;