Enable event invite actions and add photo print sizes

This commit is contained in:
2026-09-10 13:17:36 -04:00
parent f35de3b03c
commit a2acd1d08b
4 changed files with 39 additions and 9 deletions
@@ -182,7 +182,7 @@ export function EventPeople({
</Button> </Button>
</form> </form>
) : null} ) : null}
{canManage && event.data?.groupId ? <PendingInvites groupId={event.data.groupId} eventId={eventId} /> : null} {canManage && event.data?.groupId ? <PendingInvites groupId={event.data.groupId} eventId={eventId} canRevoke /> : null}
</CardContent> </CardContent>
</Card> </Card>
); );
+18 -8
View File
@@ -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({ export const groupRouter = createTRPCRouter({
copyInviteLink: protectedProcedure.input(z.object({ groupId: z.string().uuid(), inviteId: z.string().uuid() })).mutation(async ({ ctx, input }) => { 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); 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" }); if (!invite || invite.kind !== "email") throw new TRPCError({ code: "NOT_FOUND" });
await requireInviteManagement(ctx.session.user.id, invite);
assertInviteRedeemable(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 }); 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." }); 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 }) => { 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 => { 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"); 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" }); 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); 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 }); 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; let token: string;
@@ -117,9 +126,10 @@ export const groupRouter = createTRPCRouter({
revokeInvite: protectedProcedure revokeInvite: protectedProcedure
.input(z.object({ groupId: z.string().uuid(), inviteId: z.string().uuid() })) .input(z.object({ groupId: z.string().uuid(), inviteId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => { .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 => { 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() }) 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"))) .where(and(eq(invites.id, input.inviteId), eq(invites.groupId, input.groupId), eq(invites.status, "pending")))
.returning({ id: invites.id, eventId: invites.eventId }); .returning({ id: invites.id, eventId: invites.eventId });
@@ -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 expect(workspace.resendInvite({ groupId: group!.id, inviteId: resendTarget!.id })).rejects.toThrow("Wait one minute");
await workspace.revokeInvite({ groupId: group!.id, inviteId: resendTarget!.id }); await workspace.revokeInvite({ groupId: group!.id, inviteId: resendTarget!.id });
await expect(workspace.copyInviteLink({ groupId: group!.id, inviteId: resendTarget!.id })).rejects.toThrow(); 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"); await expect(workspace.createCode({ groupId: group!.id, grantUnlimitedEvents: true })).rejects.toThrow("platform administrators");
const signs = signsRouter.createCaller(ctx(0)); const signs = signsRouter.createCaller(ctx(0));
await expect(signsRouter.createCaller(ctx(1)).prepare({ eventId: event!.id })).rejects.toThrow(); await expect(signsRouter.createCaller(ctx(1)).prepare({ eventId: event!.id })).rejects.toThrow();
+8
View File
@@ -13,6 +13,14 @@ export const SIGN_PAPERS = {
card6x4: { label: "Landscape postcard · 6 × 4 in", width: "6in", height: "4in", viewHeight: 850 * 4 / 6 }, 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 }, 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 }, 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 }, square: { label: "Square card · 5 × 5 in", width: "5in", height: "5in", viewHeight: 850 },
} as const; } as const;