Refine workspaces and event publishing; harden uploads and email delivery

This commit is contained in:
2026-09-09 15:44:00 -04:00
parent f5702caaea
commit 574f29a68e
93 changed files with 2885 additions and 535 deletions
+194 -43
View File
@@ -1,7 +1,15 @@
import { TRPCError } from "@trpc/server";
import { and, desc, eq } from "drizzle-orm";
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,
@@ -13,7 +21,11 @@ import {
} from "@album/database";
import {
createEventInputSchema,
checkEventSlugInputSchema,
generateEventSlugInputSchema,
locationSearchInputSchema,
moderatePhotoInputSchema,
moderateNoteInputSchema,
moderateSubmissionInputSchema,
setEventMemberInputSchema,
updateEventInputSchema,
@@ -23,7 +35,6 @@ import {
deletePrefix,
photoObjectPrefix,
} from "@album/storage";
import { sendAlbumReadyEmail } from "@album/email";
import { z } from "zod";
import {
createTRPCRouter,
@@ -68,6 +79,16 @@ async function signedPhotoUrls(photo: {
}
export const managerRouter = createTRPCRouter({
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()
@@ -81,15 +102,21 @@ export const managerRouter = createTRPCRouter({
.orderBy(desc(events.createdAt));
if (ctx.activeGroupId) {
return memberships
return Promise.all(memberships
.filter((row) => row.event.groupId === ctx.activeGroupId)
.map((row) => ({ ...row.event, role: row.role }));
.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 all.map((event) => ({ ...event, role: "platform" as const }));
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 memberships.map((row) => ({ ...row.event, role: row.role }));
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
@@ -103,6 +130,7 @@ export const managerRouter = createTRPCRouter({
);
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,
@@ -225,6 +253,19 @@ export const managerRouter = createTRPCRouter({
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 }) => {
@@ -235,9 +276,26 @@ export const managerRouter = createTRPCRouter({
platformRole,
);
requireEventPermission(access.permissions, EVENT_PERMISSIONS.SETTINGS_MANAGE);
const slug = input.slug
? await uniqueEventSlug(input.slug, event.id)
: event.slug;
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({
@@ -245,15 +303,41 @@ export const managerRouter = createTRPCRouter({
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();
.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,
@@ -261,6 +345,11 @@ export const managerRouter = createTRPCRouter({
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!;
}),
@@ -275,6 +364,8 @@ export const managerRouter = createTRPCRouter({
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)
@@ -284,31 +375,9 @@ export const managerRouter = createTRPCRouter({
updatedAt: new Date(),
})
.where(eq(events.id, event.id));
let notified = 0;
let queued = 0;
if (input.notifyGuests !== false) {
const waiting = await getDb()
.select()
.from(guests)
.where(
and(
eq(guests.eventId, event.id),
eq(guests.notifyWhenReady, true),
),
);
const galleryUrl = `${publicAppOrigin()}/e/${event.slug}`;
for (const guest of waiting) {
if (!guest.email || guest.notifiedAt) continue;
await sendAlbumReadyEmail({
to: guest.email,
eventTitle: event.title,
galleryUrl,
});
await getDb()
.update(guests)
.set({ notifiedAt: new Date(), updatedAt: new Date() })
.where(eq(guests.id, guest.id));
notified += 1;
}
queued = (await notifyEventGuests(event, ctx.session.user.id)).queued;
}
await writeAudit({
groupId: event.groupId,
@@ -317,9 +386,9 @@ export const managerRouter = createTRPCRouter({
action: "gallery.release",
subjectType: "event",
subjectId: event.id,
metadata: { notified },
metadata: { queued },
});
return { ok: true as const, notified };
return { ok: true as const, queued };
}),
photos: protectedProcedure
@@ -405,7 +474,7 @@ export const managerRouter = createTRPCRouter({
action: "photo.visibility",
subjectType: "photo",
subjectId: photo.id,
metadata: { visibility: input.visibility },
metadata: { "visibility.before": photo.visibility, "visibility.after": input.visibility },
});
return updated!;
}),
@@ -515,6 +584,16 @@ export const managerRouter = createTRPCRouter({
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 }) => {
@@ -530,6 +609,7 @@ export const managerRouter = createTRPCRouter({
id: guests.id,
displayName: guests.displayName,
note: guests.note,
noteApproved: guests.noteApproved,
createdAt: guests.createdAt,
})
.from(guests)
@@ -537,6 +617,53 @@ export const managerRouter = createTRPCRouter({
.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 }) => {
@@ -618,7 +745,7 @@ export const managerRouter = createTRPCRouter({
action: "event.member.set",
subjectType: "user",
subjectId: target.id,
metadata: { role: input.role },
metadata: { "role.before": existing?.role ?? null, "role.after": input.role },
});
return { ok: true as const };
}),
@@ -669,7 +796,7 @@ export const managerRouter = createTRPCRouter({
}),
audit: protectedProcedure
.input(z.object({ eventId: z.string().uuid() }))
.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(
@@ -678,9 +805,10 @@ export const managerRouter = createTRPCRouter({
platformRole,
);
requireEventPermission(access.permissions, EVENT_PERMISSIONS.AUDIT_READ);
return getDb()
const rows = await getDb()
.select({
id: auditEvents.id,
actorName: user.name,
action: auditEvents.action,
subjectType: auditEvents.subjectType,
subjectId: auditEvents.subjectId,
@@ -689,9 +817,32 @@ export const managerRouter = createTRPCRouter({
actorUserId: auditEvents.actorUserId,
})
.from(auditEvents)
.where(eq(auditEvents.eventId, input.eventId))
.orderBy(desc(auditEvents.createdAt))
.limit(100);
.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