import { TRPCError } from "@trpc/server"; import { customBannerUrl, eventBannerUrl } from "@/server/event-banner"; import { searchLocations } from "@/server/location-search"; import { notifyEventGuests } from "@/server/guest-notifications"; import { renderAlbumReadyEmail, emailBrowserPreview } from "@album/email"; import { emailDeliveryOutcome } from "@album/email/webhooks"; import { galleryIsPublic } from "@/lib/publishing"; import { effectiveEvent } from "@/lib/event-lifecycle"; import { and, desc, eq, sql, inArray, like } from "drizzle-orm"; import { auditEvents, emailDeliveries, eventMemberships, events, getDb, groupMemberships, guests, photos, photoExports, submissions, user, } from "@album/database"; import { createEventInputSchema, exportPhotosInputSchema, checkEventSlugInputSchema, generateEventSlugInputSchema, locationSearchInputSchema, moderatePhotoInputSchema, moderateNoteInputSchema, moderateSubmissionInputSchema, setEventMemberInputSchema, updateEventInputSchema, } from "@album/contracts"; import { createPresignedGetUrl, deletePrefix, photoObjectPrefix, } from "@album/storage"; import { z } from "zod"; import { createTRPCRouter, EVENT_PERMISSIONS, loadEventAccess, loadGroupAccess, protectedProcedure, requireEventPermission, requireGroupPermission, } from "../trpc"; import { GROUP_PERMISSIONS } from "@/server/permissions"; import { MEMBER_VISIBILITIES, PRIVATE_VISIBILITIES } from "@/server/permissions"; import { getPlatformRole } from "@/server/roles"; import { getDeploymentSettings } from "@/server/settings"; import { grantEntitlement, resolveGroupQuota } from "@/server/entitlements"; import { redeemInviteForUser } from "@/server/invites"; import { countEventOwners, createGroupForUser, ensureEventMembership, uniqueEventSlug, } from "@/server/membership"; import { writeAudit } from "@/server/audit"; import { canTransitionVisibility } from "@/lib/photo-status"; import { slugify } from "@/lib/slug"; import { publicAppOrigin } from "@/server/public-app-url"; import { GROUP_COOKIE, serializeCookie } from "@/server/cookies"; import { hasPlatformPermission } from "@/server/roles"; import { PLATFORM_PERMISSIONS } from "@/server/permissions"; async function signedPhotoUrls(photo: { thumbKey: string | null; displayKey: string | null; originalKey: string; }) { const [thumbUrl, displayUrl, originalUrl] = await Promise.all([ photo.thumbKey ? createPresignedGetUrl(photo.thumbKey) : null, photo.displayKey ? createPresignedGetUrl(photo.displayKey) : null, createPresignedGetUrl(photo.originalKey), ]); return { thumbUrl, displayUrl, originalUrl }; } export const managerRouter = createTRPCRouter({ stats: protectedProcedure.input(z.object({ eventId: z.string().uuid() })).query(async ({ ctx, input }) => { const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id)); requireEventPermission(access.permissions, EVENT_PERMISSIONS.OVERVIEW_READ); const rows = await getDb().execute(sql`select (select count(*)::int from photos where event_id = ${input.eventId}) as photos, (select count(*)::int from photos where event_id = ${input.eventId} and visibility = 'public' and processing_status = 'ready') as approved, (select count(*)::int from photos where event_id = ${input.eventId} and visibility = 'pending') as awaiting_review, (select count(*)::int from photos where event_id = ${input.eventId} and processing_status = 'failed') as failed, (select count(*)::int from guests where event_id = ${input.eventId}) as guests, (select count(*)::int from guests where event_id = ${input.eventId} and note is not null and note <> '') as notes`); return rows[0]; }), requestExport: protectedProcedure.input(exportPhotosInputSchema).mutation(async ({ctx,input}) => { const {access} = await loadEventAccess(ctx.session.user.id,input.eventId,await getPlatformRole(ctx.session.user.id)); requireEventPermission(access.permissions,EVENT_PERMISSIONS.SETTINGS_MANAGE); requireEventPermission(access.permissions,EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ); const [job] = await getDb().insert(photoExports).values({eventId:input.eventId,requestedBy:ctx.session.user.id,filter:input.filter,expiresAt:new Date(Date.now()+86400000)}).onConflictDoNothing().returning({id:photoExports.id}); if (!job) throw new TRPCError({code:"CONFLICT",message:"An export is already queued or processing."}); await writeAudit({eventId:input.eventId,actorUserId:ctx.session.user.id,action:"photo.export.requested",subjectType:"export",subjectId:job.id,metadata:{filter:input.filter}}); return job; }), exports: protectedProcedure.input(z.object({eventId:z.string().uuid()})).query(async ({ctx,input}) => { const {access} = await loadEventAccess(ctx.session.user.id,input.eventId,await getPlatformRole(ctx.session.user.id)); requireEventPermission(access.permissions,EVENT_PERMISSIONS.SETTINGS_MANAGE); requireEventPermission(access.permissions,EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ); return getDb().select({id:photoExports.id,status:photoExports.status,filter:photoExports.filter,total:photoExports.total,processed:photoExports.processed,expiresAt:photoExports.expiresAt}).from(photoExports) .where(and(eq(photoExports.eventId,input.eventId),eq(photoExports.requestedBy,ctx.session.user.id))).orderBy(desc(photoExports.createdAt)).limit(10); }), downloadExport: protectedProcedure.input(z.object({eventId:z.string().uuid(),exportId:z.string().uuid()})).mutation(async ({ctx,input}) => { const {access} = await loadEventAccess(ctx.session.user.id,input.eventId,await getPlatformRole(ctx.session.user.id)); requireEventPermission(access.permissions,EVENT_PERMISSIONS.SETTINGS_MANAGE); requireEventPermission(access.permissions,EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ); const [job] = await getDb().select().from(photoExports).where(and(eq(photoExports.eventId,input.eventId),eq(photoExports.id,input.exportId),eq(photoExports.requestedBy,ctx.session.user.id))); if (!job || job.status !== "ready" || job.expiresAt <= new Date()) throw new TRPCError({code:"NOT_FOUND",message:"Export is unavailable or expired."}); return {url:await createPresignedGetUrl(`exports/${input.eventId}/${job.id}.zip`,Math.max(1,Math.min(300,Math.floor((job.expiresAt.getTime()-Date.now())/1000))))}; }), searchLocations: protectedProcedure.input(locationSearchInputSchema).query(async ({ ctx, input }) => { const platformRole = await getPlatformRole(ctx.session.user.id); const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, platformRole); requireEventPermission(access.permissions, EVENT_PERMISSIONS.SETTINGS_MANAGE); try { return await searchLocations(input.query); } catch { throw new TRPCError({ code: "BAD_GATEWAY", message: "Location search is unavailable. Try again, or keep a text-only location." }); } }), events: protectedProcedure.query(async ({ ctx }) => { const platformRole = await getPlatformRole(ctx.session.user.id); const memberships = await getDb() .select({ event: events, role: eventMemberships.role, }) .from(eventMemberships) .innerJoin(events, eq(eventMemberships.eventId, events.id)) .where(eq(eventMemberships.userId, ctx.session.user.id)) .orderBy(desc(events.createdAt)); if (ctx.activeGroupId) { return Promise.all(memberships .filter((row) => row.event.groupId === ctx.activeGroupId) .map(async (row) => ({ ...row.event, role: row.role, bannerUrl: row.event.customBannerId ? await customBannerUrl(row.event.id, row.event.customBannerId) : await eventBannerUrl(row.event.id, row.event.bannerPhotoId), }))); } if (platformRole && hasPlatformPermission(platformRole, PLATFORM_PERMISSIONS.EVENTS_READ)) { const all = await getDb().select().from(events).orderBy(desc(events.createdAt)); return Promise.all(all.map(async (event) => ({ ...event, role: "platform" as const, bannerUrl: event.customBannerId ? await customBannerUrl(event.id, event.customBannerId) : await eventBannerUrl(event.id, event.bannerPhotoId), }))); } return Promise.all(memberships.map(async (row) => ({ ...row.event, role: row.role, bannerUrl: row.event.customBannerId ? await customBannerUrl(row.event.id, row.event.customBannerId) : await eventBannerUrl(row.event.id, row.event.bannerPhotoId), }))); }), event: protectedProcedure .input(z.object({ eventId: z.string().uuid() })) .query(async ({ ctx, input }) => { const platformRole = await getPlatformRole(ctx.session.user.id); const { event, access } = await loadEventAccess( ctx.session.user.id, input.eventId, platformRole, ); return { ...event, bannerUrl: event.customBannerId ? await customBannerUrl(event.id, event.customBannerId) : await eventBannerUrl(event.id, event.bannerPhotoId), guestUrl: `${publicAppOrigin()}/e/${event.slug}`, permissions: access.permissions, role: access.role, }; }), createEvent: protectedProcedure .input(createEventInputSchema) .mutation(async ({ ctx, input }) => { const platformRole = await getPlatformRole(ctx.session.user.id); const settings = await getDeploymentSettings(); const isPlatformCreator = platformRole && hasPlatformPermission(platformRole, PLATFORM_PERMISSIONS.ENTITLEMENTS_MANAGE); if (settings.eventCreatePolicy === "admin_only" && !isPlatformCreator) { throw new TRPCError({ code: "FORBIDDEN", message: "Only administrators can create events", }); } if (input.inviteCode) { await redeemInviteForUser({ token: input.inviteCode, userId: ctx.session.user.id, }); } else if (settings.eventCreatePolicy === "invite" && !isPlatformCreator) { const [owned] = await getDb() .select({ id: events.id }) .from(eventMemberships) .innerJoin(events, eq(eventMemberships.eventId, events.id)) .where(eq(eventMemberships.userId, ctx.session.user.id)) .limit(1); if (!owned) { throw new TRPCError({ code: "FORBIDDEN", message: "An invite code is required to create an event", }); } } let groupId = input.groupId ?? ctx.activeGroupId; if (!groupId) { const [owned] = await getDb() .select({ groupId: groupMemberships.groupId }) .from(groupMemberships) .where( and( eq(groupMemberships.userId, ctx.session.user.id), eq(groupMemberships.role, "owner"), ), ) .limit(1); groupId = owned?.groupId ?? null; } if (groupId) { const { access } = await loadGroupAccess( ctx.session.user.id, groupId, platformRole, ); requireGroupPermission(access.permissions, GROUP_PERMISSIONS.EVENTS_CREATE); } else { const group = await createGroupForUser({ userId: ctx.session.user.id, name: `${ctx.session.user.name}'s group`, }); groupId = group.id; await grantEntitlement({ groupId, eventLimit: settings.defaultEventLimit, complimentary: false, source: "signup_default", grantedByUserId: null, }); ctx.appendSetCookie( serializeCookie(GROUP_COOKIE, groupId, { maxAge: 60 * 60 * 24 * 365 }), ); } if (!isPlatformCreator) { const quota = await resolveGroupQuota(groupId); if (!quota.canCreate) { throw new TRPCError({ code: "FORBIDDEN", message: "This group has no remaining event slots", }); } } const slug = await uniqueEventSlug(input.slug ?? slugify(input.title)); const [event] = await getDb() .insert(events) .values({ groupId, title: input.title, slug, description: input.description ?? null, startsAt: input.startsAt ?? null, endsAt: input.endsAt ?? null, status: "draft", }) .returning(); if (!event) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); await ensureEventMembership({ eventId: event.id, userId: ctx.session.user.id, role: "owner", groupId, }); await writeAudit({ groupId, eventId: event.id, actorUserId: ctx.session.user.id, action: "event.create", subjectType: "event", subjectId: event.id, }); return event; }), checkSlug: protectedProcedure.input(checkEventSlugInputSchema).query(async ({ ctx, input }) => { const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id)); requireEventPermission(access.permissions, EVENT_PERMISSIONS.SETTINGS_MANAGE); const [existing] = await getDb().select({ id: events.id }).from(events).where(eq(events.slug, input.slug)).limit(1); return { available: !existing || existing.id === input.eventId }; }), generateSlug: protectedProcedure.input(generateEventSlugInputSchema).query(async ({ ctx, input }) => { const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id)); requireEventPermission(access.permissions, EVENT_PERMISSIONS.SETTINGS_MANAGE); return { slug: await uniqueEventSlug(slugify(input.title), input.eventId) }; }), updateEvent: protectedProcedure .input(updateEventInputSchema) .mutation(async ({ ctx, input }) => { const platformRole = await getPlatformRole(ctx.session.user.id); const { event, access } = await loadEventAccess( ctx.session.user.id, input.eventId, platformRole, ); requireEventPermission(access.permissions, EVENT_PERMISSIONS.SETTINGS_MANAGE); if (input.bannerPhotoId) { const bannerUrl = await eventBannerUrl(event.id, input.bannerPhotoId); if (!bannerUrl) { throw new TRPCError({ code: "BAD_REQUEST", message: "Choose a processed, public photo from this event for the banner." }); } } if (input.customBannerId && !(await customBannerUrl(event.id, input.customBannerId))) { throw new TRPCError({ code: "BAD_REQUEST", message: "Choose a finished banner upload from this event." }); } if (input.customBannerId && input.bannerPhotoId) { throw new TRPCError({ code: "BAD_REQUEST", message: "Choose one banner source." }); } const slug = input.slug ?? event.slug; const schedule = { ...event, ...input }; if (schedule.startsAt && schedule.endsAt && schedule.endsAt <= schedule.startsAt) throw new TRPCError({ code: "BAD_REQUEST", message: "Event end must be after its start." }); if (schedule.submissionsOpenAt && schedule.submissionsCloseAt && schedule.submissionsCloseAt <= schedule.submissionsOpenAt) throw new TRPCError({ code: "BAD_REQUEST", message: "Submission closing must be after opening." }); if (slug !== event.slug) { const [existing] = await getDb().select({ id: events.id }).from(events).where(eq(events.slug, slug)).limit(1); if (existing) throw new TRPCError({ code: "CONFLICT", message: "That guest link is already taken. Choose another or generate one." }); } const [updated] = await getDb() .update(events) .set({ title: input.title ?? event.title, slug, description: input.description === undefined ? event.description : input.description, location: input.location === undefined ? event.location : input.location, latitude: input.locationCoordinates === undefined ? (input.location !== undefined && input.location !== event.location ? null : event.latitude) : input.locationCoordinates?.latitude ?? null, longitude: input.locationCoordinates === undefined ? (input.location !== undefined && input.location !== event.location ? null : event.longitude) : input.locationCoordinates?.longitude ?? null, bannerPhotoId: input.customBannerId ? null : input.bannerPhotoId === undefined ? event.bannerPhotoId : input.bannerPhotoId, customBannerId: input.customBannerId === undefined ? (input.bannerPhotoId ? null : event.customBannerId) : input.customBannerId, startsAt: input.startsAt === undefined ? event.startsAt : input.startsAt, endsAt: input.endsAt === undefined ? event.endsAt : input.endsAt, status: input.status ?? event.status, listed: input.listed ?? event.listed, uploadEnabled: input.uploadEnabled ?? event.uploadEnabled, publishAt: input.status ? null : input.publishAt === undefined ? event.publishAt : input.publishAt, submissionsOpenAt: input.submissionsOpenAt === undefined ? event.submissionsOpenAt : input.submissionsOpenAt, submissionsCloseAt: input.submissionsCloseAt === undefined ? (input.status === "published" && event.submissionsCloseAt && event.submissionsCloseAt <= new Date() ? null : event.submissionsCloseAt) : input.submissionsCloseAt, galleryVisibleAt: input.galleryVisibleAt === undefined ? event.galleryVisibleAt : input.galleryVisibleAt, notesVisibleAt: input.notesVisibleAt === undefined ? event.notesVisibleAt : input.notesVisibleAt, completedAt: input.status === "published" ? null : event.completedAt, notesPolicy: input.notesPolicy ?? event.notesPolicy, galleryPolicy: input.galleryPolicy ?? event.galleryPolicy, showPhotoStats: input.showPhotoStats ?? event.showPhotoStats, showSubmitterStats: input.showSubmitterStats ?? event.showSubmitterStats, showNoteStats: input.showNoteStats ?? event.showNoteStats, updatedAt: new Date(), }) .where(eq(events.id, event.id)) .returning().catch((error: unknown) => { const dbError = error as { code?: string; cause?: { code?: string } }; if (dbError.code === "23505" || dbError.cause?.code === "23505") { throw new TRPCError({ code: "CONFLICT", message: "That guest link was just taken. Choose another or generate one." }); } throw error; }); await writeAudit({ groupId: event.groupId, eventId: event.id, actorUserId: ctx.session.user.id, action: "event.update", subjectType: "event", subjectId: event.id, metadata: Object.fromEntries(Object.keys(input).filter((key) => key !== "eventId" && key in event && key in updated!).flatMap((key) => { const before = JSON.stringify(event[key as keyof typeof event]); const after = JSON.stringify(updated![key as keyof typeof event]); return before === after ? [] : [[`${key}.before`, before ?? null], [`${key}.after`, after ?? null]]; })), }); return updated!; }), releaseGallery: protectedProcedure .input(z.object({ eventId: z.string().uuid(), notifyGuests: z.boolean().optional() })) .mutation(async ({ ctx, input }) => { const platformRole = await getPlatformRole(ctx.session.user.id); const { event, access } = await loadEventAccess( ctx.session.user.id, input.eventId, platformRole, ); requireEventPermission(access.permissions, EVENT_PERMISSIONS.GALLERY_RELEASE); if (event.galleryPolicy === "never") throw new TRPCError({ code: "BAD_REQUEST", message: "Enable gallery publishing before releasing it." }); if ((event.publishAt && event.publishAt > new Date()) || (event.galleryVisibleAt && event.galleryVisibleAt > new Date())) throw new TRPCError({ code: "BAD_REQUEST", message: "Clear the future visibility schedule before releasing now." }); const releasedAt = event.galleryReleasedAt ?? new Date(); await getDb() .update(events) .set({ galleryReleasedAt: releasedAt, status: event.status === "draft" ? "published" : event.status, updatedAt: new Date(), }) .where(eq(events.id, event.id)); let queued = 0; if (input.notifyGuests !== false) { queued = (await notifyEventGuests(event, ctx.session.user.id)).queued; } await writeAudit({ groupId: event.groupId, eventId: event.id, actorUserId: ctx.session.user.id, action: "gallery.release", subjectType: "event", subjectId: event.id, metadata: { queued }, }); return { ok: true as const, queued }; }), photos: protectedProcedure .input(z.object({ eventId: z.string().uuid() })) .query(async ({ ctx, input }) => { const platformRole = await getPlatformRole(ctx.session.user.id); const { access } = await loadEventAccess( ctx.session.user.id, input.eventId, platformRole, ); requireEventPermission(access.permissions, EVENT_PERMISSIONS.PHOTOS_READ); const canPrivate = access.permissions.includes( EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ, ); const allowed = canPrivate ? PRIVATE_VISIBILITIES : MEMBER_VISIBILITIES; const rows = await getDb() .select({ photo: photos, displayName: guests.displayName, submissionId: submissions.id, }) .from(photos) .innerJoin(submissions, eq(photos.submissionId, submissions.id)) .innerJoin(guests, eq(submissions.guestId, guests.id)) .where(eq(photos.eventId, input.eventId)) .orderBy(desc(photos.createdAt)); return Promise.all( rows .filter((row) => allowed.includes(row.photo.visibility)) .map(async (row) => ({ ...row.photo, contributorName: row.displayName, submissionId: row.submissionId, ...(await signedPhotoUrls(row.photo)), })), ); }), moderatePhoto: protectedProcedure .input(moderatePhotoInputSchema) .mutation(async ({ ctx, input }) => { const [photo] = await getDb() .select() .from(photos) .where(eq(photos.id, input.photoId)) .limit(1); if (!photo) throw new TRPCError({ code: "NOT_FOUND" }); const platformRole = await getPlatformRole(ctx.session.user.id); const { event, access } = await loadEventAccess( ctx.session.user.id, photo.eventId, platformRole, ); requireEventPermission(access.permissions, EVENT_PERMISSIONS.PHOTOS_MODERATE); if (photo.processingStatus !== "ready") { throw new TRPCError({ code: "BAD_REQUEST", message: "Wait until processing finishes", }); } if (!canTransitionVisibility(photo.visibility, input.visibility)) { throw new TRPCError({ code: "BAD_REQUEST", message: `Cannot move a ${photo.visibility} photo to ${input.visibility}`, }); } if ( input.visibility === "private" && !access.permissions.includes(EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ) ) { throw new TRPCError({ code: "FORBIDDEN" }); } const [updated] = await getDb() .update(photos) .set({ visibility: input.visibility, updatedAt: new Date() }) .where(eq(photos.id, photo.id)) .returning(); await writeAudit({ groupId: event.groupId, eventId: event.id, actorUserId: ctx.session.user.id, action: "photo.visibility", subjectType: "photo", subjectId: photo.id, metadata: { "visibility.before": photo.visibility, "visibility.after": input.visibility }, }); return updated!; }), moderateSubmission: protectedProcedure .input(moderateSubmissionInputSchema) .mutation(async ({ ctx, input }) => { const [submission] = await getDb() .select() .from(submissions) .where(and(eq(submissions.id, input.submissionId), eq(submissions.eventId, input.eventId))) .limit(1); if (!submission) throw new TRPCError({ code: "NOT_FOUND" }); const platformRole = await getPlatformRole(ctx.session.user.id); const { event, access } = await loadEventAccess( ctx.session.user.id, submission.eventId, platformRole, ); requireEventPermission(access.permissions, EVENT_PERMISSIONS.PHOTOS_MODERATE); if (input.visibility === "private" && !access.permissions.includes(EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ)) { throw new TRPCError({ code: "FORBIDDEN" }); } return getDb().transaction(async (tx) => { const rows = await tx .select() .from(photos) .where(and(eq(photos.submissionId, submission.id), eq(photos.eventId, event.id))) .for("update"); const eligible = rows.filter(photo => (photo.visibility !== "private" || access.permissions.includes(EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ)) && canTransitionVisibility(photo.visibility, input.visibility)); if (eligible.length) { await tx.update(photos) .set({ visibility: input.visibility, updatedAt: new Date() }) .where(and(eq(photos.eventId, event.id), eq(photos.submissionId, submission.id), inArray(photos.id, eligible.map(photo => photo.id)))); } await tx.insert(auditEvents).values({ groupId: event.groupId, eventId: event.id, actorUserId: ctx.session.user.id, action: "submission.visibility", subjectType: "submission", subjectId: submission.id, metadata: { visibility: input.visibility, count: eligible.length }, }); return { ok: true as const, updatedCount: eligible.length }; }); }), deletePhoto: protectedProcedure .input(z.object({ photoId: z.string().uuid() })) .mutation(async ({ ctx, input }) => { const [photo] = await getDb() .select() .from(photos) .where(eq(photos.id, input.photoId)) .limit(1); if (!photo) throw new TRPCError({ code: "NOT_FOUND" }); const platformRole = await getPlatformRole(ctx.session.user.id); const { event, access } = await loadEventAccess( ctx.session.user.id, photo.eventId, platformRole, ); requireEventPermission(access.permissions, EVENT_PERMISSIONS.PHOTOS_DELETE); await deletePrefix(photoObjectPrefix(photo.eventId, photo.id)); await getDb().delete(photos).where(eq(photos.id, photo.id)); await writeAudit({ groupId: event.groupId, eventId: event.id, actorUserId: ctx.session.user.id, action: "photo.delete", subjectType: "photo", subjectId: photo.id, }); return { ok: true as const }; }), deleteSubmission: protectedProcedure .input(z.object({ submissionId: z.string().uuid() })) .mutation(async ({ ctx, input }) => { const [submission] = await getDb() .select() .from(submissions) .where(eq(submissions.id, input.submissionId)) .limit(1); if (!submission) throw new TRPCError({ code: "NOT_FOUND" }); const platformRole = await getPlatformRole(ctx.session.user.id); const { event, access } = await loadEventAccess( ctx.session.user.id, submission.eventId, platformRole, ); requireEventPermission(access.permissions, EVENT_PERMISSIONS.PHOTOS_DELETE); const rows = await getDb() .select() .from(photos) .where(eq(photos.submissionId, submission.id)); for (const photo of rows) { await deletePrefix(photoObjectPrefix(photo.eventId, photo.id)); } await getDb().delete(submissions).where(eq(submissions.id, submission.id)); await writeAudit({ groupId: event.groupId, eventId: event.id, actorUserId: ctx.session.user.id, action: "submission.delete", subjectType: "submission", subjectId: submission.id, metadata: { count: rows.length }, }); return { ok: true as const }; }), moderateNote: protectedProcedure.input(moderateNoteInputSchema).mutation(async ({ ctx, input }) => { const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id)); requireEventPermission(access.permissions, EVENT_PERMISSIONS.SETTINGS_MANAGE); const rows = await getDb().update(guests).set({ noteApproved: input.approved, updatedAt: new Date() }) .where(and(eq(guests.eventId, input.eventId), eq(guests.id, input.guestId))).returning({ id: guests.id }); if (!rows.length) throw new TRPCError({ code: "NOT_FOUND" }); await writeAudit({ eventId: input.eventId, actorUserId: ctx.session.user.id, action: "note.approval", subjectType: "guest", subjectId: input.guestId, metadata: { approved: input.approved } }); return { ok: true }; }), notes: protectedProcedure .input(z.object({ eventId: z.string().uuid() })) .query(async ({ ctx, input }) => { const platformRole = await getPlatformRole(ctx.session.user.id); const { access } = await loadEventAccess( ctx.session.user.id, input.eventId, platformRole, ); requireEventPermission(access.permissions, EVENT_PERMISSIONS.NOTES_READ); return getDb() .select({ id: guests.id, displayName: guests.displayName, note: guests.note, noteApproved: guests.noteApproved, createdAt: guests.createdAt, }) .from(guests) .where(eq(guests.eventId, input.eventId)) .orderBy(desc(guests.createdAt)); }), completeEvent: protectedProcedure.input(z.object({ eventId: z.string().uuid() })).mutation(async ({ ctx, input }) => { const { event, access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id)); requireEventPermission(access.permissions, EVENT_PERMISSIONS.SETTINGS_MANAGE); if (effectiveEvent(event).status === "draft") throw new TRPCError({ code: "BAD_REQUEST", message: "Publish the event before completing it." }); await getDb().update(events).set({ completedAt: new Date(), status: "closed", updatedAt: new Date() }).where(eq(events.id, event.id)); await writeAudit({ eventId: event.id, groupId: event.groupId, actorUserId: ctx.session.user.id, action: "event.complete", subjectType: "event", subjectId: event.id }); return { ok: true }; }), notifyGuests: protectedProcedure.input(z.object({ eventId: z.string().uuid() })).mutation(async ({ ctx, input }) => { const { event, access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id)); requireEventPermission(access.permissions, EVENT_PERMISSIONS.GALLERY_RELEASE); if (effectiveEvent(event).status !== "closed") throw new TRPCError({ code: "BAD_REQUEST", message: "Complete the event before sending completion emails." }); if ((event.publishAt && event.publishAt > new Date()) || !galleryIsPublic(event.galleryPolicy, event.galleryReleasedAt, event.galleryVisibleAt)) throw new TRPCError({ code: "BAD_REQUEST", message: "Make the gallery public before notifying guests." }); return notifyEventGuests(event, ctx.session.user.id); }), emailPreview: protectedProcedure.input(z.object({ eventId: z.string().uuid() })).query(async ({ ctx, input }) => { const { event, access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id)); requireEventPermission(access.permissions, EVENT_PERMISSIONS.GALLERY_RELEASE); const rendered = renderAlbumReadyEmail({ to: "preview@manyangles.test", eventTitle: event.title, galleryUrl: `${publicAppOrigin()}/e/${event.slug}` }); return { html: emailBrowserPreview(rendered.html), subject: rendered.subject }; }), emailHistory: protectedProcedure.input(z.object({ eventId: z.string().uuid() })).query(async ({ ctx, input }) => { const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id)); requireEventPermission(access.permissions, EVENT_PERMISSIONS.GALLERY_RELEASE); return getDb().select({ id: emailDeliveries.id, recipient: emailDeliveries.recipient, status: emailDeliveries.status, outcome: emailDeliveryOutcome, attempts: emailDeliveries.attempts, providerId: emailDeliveries.providerId, lastError: emailDeliveries.lastError, updatedAt: emailDeliveries.updatedAt, provider: emailDeliveries.provider, firstAttemptAt: emailDeliveries.firstAttemptAt }) .from(emailDeliveries).where(eq(emailDeliveries.eventId, input.eventId)).orderBy(desc(emailDeliveries.createdAt)).limit(100); }), retryEmail: protectedProcedure.input(z.object({ eventId: z.string().uuid(), deliveryId: z.string().uuid() })).mutation(async ({ ctx, input }) => { const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id)); requireEventPermission(access.permissions, EVENT_PERMISSIONS.GALLERY_RELEASE); const rows = await getDb().update(emailDeliveries).set({ status: "pending", nextAttemptAt: new Date(), updatedAt: new Date() }).where(and(eq(emailDeliveries.eventId, input.eventId), eq(emailDeliveries.id, input.deliveryId), eq(emailDeliveries.status, "review"), eq(emailDeliveries.provider, "resend"), sql`${emailDeliveries.firstAttemptAt} > now() - interval '23 hours'`)).returning({ id: emailDeliveries.id }); if (!rows.length) throw new TRPCError({ code: "BAD_REQUEST", message: "This delivery cannot safely retry. Check the provider delivery record; its retry window may have expired." }); await writeAudit({ eventId: input.eventId, actorUserId: ctx.session.user.id, action: "guest.email.retry", subjectType: "email", subjectId: input.deliveryId }); return { ok: true }; }), guests: protectedProcedure.input(z.object({ eventId: z.string().uuid() })).query(async ({ ctx, input }) => { const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id)); requireEventPermission(access.permissions, EVENT_PERMISSIONS.PEOPLE_READ); return getDb().select({ id: guests.id, displayName: guests.displayName, email: guests.email, notifyWhenReady: guests.notifyWhenReady, notifiedAt: guests.notifiedAt, notificationClaimedAt: guests.notificationClaimedAt, createdAt: guests.createdAt }) .from(guests).where(eq(guests.eventId, input.eventId)).orderBy(desc(guests.createdAt)); }), members: protectedProcedure .input(z.object({ eventId: z.string().uuid() })) .query(async ({ ctx, input }) => { const platformRole = await getPlatformRole(ctx.session.user.id); const { access } = await loadEventAccess( ctx.session.user.id, input.eventId, platformRole, ); requireEventPermission(access.permissions, EVENT_PERMISSIONS.PEOPLE_READ); return getDb() .select({ id: eventMemberships.id, userId: user.id, name: user.name, email: user.email, role: eventMemberships.role, }) .from(eventMemberships) .innerJoin(user, eq(eventMemberships.userId, user.id)) .where(eq(eventMemberships.eventId, input.eventId)); }), setMember: protectedProcedure .input(setEventMemberInputSchema) .mutation(async ({ ctx, input }) => { const platformRole = await getPlatformRole(ctx.session.user.id); const { event, access } = await loadEventAccess( ctx.session.user.id, input.eventId, platformRole, ); requireEventPermission(access.permissions, EVENT_PERMISSIONS.PEOPLE_MANAGE); if (input.role === "owner") { requireEventPermission(access.permissions, EVENT_PERMISSIONS.PEOPLE_GRANT_OWNER); } const [target] = input.userId ? await getDb().select().from(user).where(eq(user.id, input.userId)).limit(1) : await getDb() .select() .from(user) .where(eq(user.email, input.email ?? "")) .limit(1); if (!target) { throw new TRPCError({ code: "NOT_FOUND", message: "User must have an account before being added", }); } const [existing] = await getDb() .select() .from(eventMemberships) .where( and( eq(eventMemberships.eventId, input.eventId), eq(eventMemberships.userId, target.id), ), ) .limit(1); if (existing?.role === "owner" && input.role !== "owner") { const owners = await countEventOwners(input.eventId); if (owners <= 1) { throw new TRPCError({ code: "BAD_REQUEST", message: "An event must keep at least one owner", }); } } await ensureEventMembership({ eventId: input.eventId, userId: target.id, role: input.role, groupId: event.groupId, }); await writeAudit({ groupId: event.groupId, eventId: event.id, actorUserId: ctx.session.user.id, action: "event.member.set", subjectType: "user", subjectId: target.id, metadata: { "role.before": existing?.role ?? null, "role.after": input.role }, }); return { ok: true as const }; }), removeMember: protectedProcedure .input(z.object({ eventId: z.string().uuid(), userId: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const platformRole = await getPlatformRole(ctx.session.user.id); const { event, access } = await loadEventAccess( ctx.session.user.id, input.eventId, platformRole, ); requireEventPermission(access.permissions, EVENT_PERMISSIONS.PEOPLE_MANAGE); const [existing] = await getDb() .select() .from(eventMemberships) .where( and( eq(eventMemberships.eventId, input.eventId), eq(eventMemberships.userId, input.userId), ), ) .limit(1); if (!existing) throw new TRPCError({ code: "NOT_FOUND" }); if (existing.role === "owner") { requireEventPermission(access.permissions, EVENT_PERMISSIONS.PEOPLE_GRANT_OWNER); const owners = await countEventOwners(input.eventId); if (owners <= 1) { throw new TRPCError({ code: "BAD_REQUEST", message: "An event must keep at least one owner", }); } } await getDb() .delete(eventMemberships) .where(eq(eventMemberships.id, existing.id)); await writeAudit({ groupId: event.groupId, eventId: event.id, actorUserId: ctx.session.user.id, action: "event.member.remove", subjectType: "user", subjectId: input.userId, }); return { ok: true as const }; }), audit: protectedProcedure .input(z.object({ eventId: z.string().uuid(), page: z.number().int().min(0).max(10000).default(0), category: z.enum(["all", "event", "photo", "note", "guest", "gallery", "submission"]).default("all") })) .query(async ({ ctx, input }) => { const platformRole = await getPlatformRole(ctx.session.user.id); const { access } = await loadEventAccess( ctx.session.user.id, input.eventId, platformRole, ); requireEventPermission(access.permissions, EVENT_PERMISSIONS.AUDIT_READ); const rows = await getDb() .select({ id: auditEvents.id, actorName: user.name, action: auditEvents.action, subjectType: auditEvents.subjectType, subjectId: auditEvents.subjectId, metadata: auditEvents.metadata, createdAt: auditEvents.createdAt, actorUserId: auditEvents.actorUserId, }) .from(auditEvents) .leftJoin(user, eq(user.id, auditEvents.actorUserId)) .where(and(eq(auditEvents.eventId, input.eventId), input.category === "all" ? undefined : like(auditEvents.action, `${input.category}.%`))) .orderBy(desc(auditEvents.createdAt), desc(auditEvents.id)) .limit(50).offset(input.page * 50); const ids = (type: string) => rows.filter((row) => row.subjectType === type).map((row) => row.subjectId); const [accounts, attendees, assets] = await Promise.all([ ids("user").length ? getDb().select({ id: user.id, name: user.name }).from(user).where(inArray(user.id, ids("user"))) : [], ids("guest").length ? getDb().select({ id: guests.id, name: guests.displayName }).from(guests).where(and(eq(guests.eventId, input.eventId), inArray(guests.id, ids("guest")))) : [], ids("photo").length ? getDb().select({ id: photos.id, displayKey: photos.displayKey, visibility: photos.visibility }).from(photos).where(and(eq(photos.eventId, input.eventId), inArray(photos.id, ids("photo")))) : [], ]); return Promise.all(rows.map(async (row) => { let subjectLabel = typeof row.metadata.subjectLabel === "string" ? row.metadata.subjectLabel : `${row.subjectType} ${row.subjectId}`; let assetUrl: string | null = null; if (row.subjectType === "user") { const person = accounts.find((person) => person.id === row.subjectId); if (person) subjectLabel = person.name; } else if (row.subjectType === "guest") { const person = attendees.find((person) => person.id === row.subjectId); if (person) subjectLabel = person.name ?? "Anonymous guest"; } else if (row.subjectType === "photo") { const photo = assets.find((photo) => photo.id === row.subjectId); const allowed = access.permissions.includes(EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ) ? PRIVATE_VISIBILITIES : MEMBER_VISIBILITIES; if (access.permissions.includes(EVENT_PERMISSIONS.PHOTOS_READ) && photo?.displayKey && allowed.includes(photo.visibility)) assetUrl = await createPresignedGetUrl(photo.displayKey); } return { ...row, subjectLabel, assetUrl }; })); }), deleteEvent: protectedProcedure .input(z.object({ eventId: z.string().uuid() })) .mutation(async ({ ctx, input }) => { const platformRole = await getPlatformRole(ctx.session.user.id); const { event, access } = await loadEventAccess( ctx.session.user.id, input.eventId, platformRole, ); requireEventPermission(access.permissions, EVENT_PERMISSIONS.EVENT_DELETE); const rows = await getDb() .select({ id: photos.id, eventId: photos.eventId }) .from(photos) .where(eq(photos.eventId, event.id)); for (const photo of rows) { await deletePrefix(photoObjectPrefix(photo.eventId, photo.id)); } await getDb().delete(events).where(eq(events.id, event.id)); await writeAudit({ groupId: event.groupId, actorUserId: ctx.session.user.id, action: "event.delete", subjectType: "event", subjectId: event.id, metadata: { count: rows.length }, }); return { ok: true as const }; }), });