From a2acd1d08bf5be5b3505c052297fab5a2e56fde4 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Thu, 10 Sep 2026 13:17:36 -0400 Subject: [PATCH] Enable event invite actions and add photo print sizes --- .../dashboard/events/[id]/event-people.tsx | 2 +- apps/web/src/server/api/routers/group.ts | 26 +++++++++++++------ .../web/src/server/polish.integration.test.ts | 12 +++++++++ packages/contracts/src/sign.ts | 8 ++++++ 4 files changed, 39 insertions(+), 9 deletions(-) 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;