Refine workspaces and event publishing; harden uploads and email delivery
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { createTRPCRouter, publicProcedure } from "./trpc";
|
||||
import { eventRouter } from "./routers/event";
|
||||
import { bannersRouter } from "./routers/banners";
|
||||
import { guestRouter } from "./routers/guest";
|
||||
import { groupRouter } from "./routers/group";
|
||||
import { invitesRouter } from "./routers/invites";
|
||||
@@ -15,6 +16,7 @@ export const appRouter = createTRPCRouter({
|
||||
})),
|
||||
viewer: viewerRouter,
|
||||
event: eventRouter,
|
||||
banners: bannersRouter,
|
||||
guest: guestRouter,
|
||||
photos: photosRouter,
|
||||
group: groupRouter,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { eventBanners, getDb } from "@album/database";
|
||||
import { bannerInputSchema, createBannerInputSchema, MAX_PHOTO_BYTES } from "@album/contracts";
|
||||
import { createPresignedPutUrl, createPresignedGetUrl, headObject } from "@album/storage";
|
||||
import { createTRPCRouter, protectedProcedure, loadEventAccess, requireEventPermission, EVENT_PERMISSIONS } from "../trpc";
|
||||
import { getPlatformRole } from "@/server/roles";
|
||||
import { consumeRateLimit } from "@/server/rate-limit";
|
||||
|
||||
async function authorize(userId: string, eventId: string) {
|
||||
const { access } = await loadEventAccess(userId, eventId, await getPlatformRole(userId));
|
||||
requireEventPermission(access.permissions, EVENT_PERMISSIONS.SETTINGS_MANAGE);
|
||||
}
|
||||
|
||||
export const bannersRouter = createTRPCRouter({
|
||||
create: protectedProcedure.input(createBannerInputSchema).mutation(async ({ ctx, input }) => {
|
||||
await authorize(ctx.session.user.id, input.eventId);
|
||||
const limit = await consumeRateLimit({ namespace: "banner-upload", identifier: ctx.session.user.id, limit: 20, windowMs: 600_000 });
|
||||
if (!limit.allowed) throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many banner uploads. Try again shortly." });
|
||||
const id = randomUUID();
|
||||
const originalKey = `events/${input.eventId}/banners/${id}/original`;
|
||||
await getDb().insert(eventBanners).values({ id, eventId: input.eventId, originalKey, contentType: input.contentType, byteSize: input.byteSize });
|
||||
return { bannerId: id, uploadUrl: await createPresignedPutUrl({ key: originalKey, contentType: input.contentType }) };
|
||||
}),
|
||||
complete: protectedProcedure.input(bannerInputSchema).mutation(async ({ ctx, input }) => {
|
||||
await authorize(ctx.session.user.id, input.eventId);
|
||||
const scope = and(eq(eventBanners.id, input.bannerId), eq(eventBanners.eventId, input.eventId));
|
||||
const [banner] = await getDb().select().from(eventBanners).where(scope).limit(1);
|
||||
if (!banner) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
if (banner.status !== "uploading") return { status: banner.status };
|
||||
const head = await headObject(banner.originalKey);
|
||||
const size = Number(head?.ContentLength ?? 0);
|
||||
if (!head || size <= 0 || size > MAX_PHOTO_BYTES || head.ContentType !== banner.contentType) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "The banner upload is missing, too large, or has the wrong file type." });
|
||||
}
|
||||
await getDb().update(eventBanners).set({ status: "pending", byteSize: size, updatedAt: new Date() })
|
||||
.where(and(scope, eq(eventBanners.status, "uploading")));
|
||||
return { status: "pending" };
|
||||
}),
|
||||
status: protectedProcedure.input(bannerInputSchema).query(async ({ ctx, input }) => {
|
||||
await authorize(ctx.session.user.id, input.eventId);
|
||||
const [banner] = await getDb().select().from(eventBanners)
|
||||
.where(and(eq(eventBanners.id, input.bannerId), eq(eventBanners.eventId, input.eventId))).limit(1);
|
||||
if (!banner) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
return { status: banner.status, url: banner.status === "ready" && banner.displayKey ? await createPresignedGetUrl(banner.displayKey) : null };
|
||||
}),
|
||||
});
|
||||
@@ -1,13 +1,33 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { customBannerUrl, eventBannerUrl } from "@/server/event-banner";
|
||||
import { and, count, desc, eq, isNotNull, ne, sql } from "drizzle-orm";
|
||||
import { galleryIsPublic } from "@/lib/publishing";
|
||||
import { effectiveEvent, notesAreVisible } from "@/lib/event-lifecycle";
|
||||
import { events, getDb, guests, photos, submissions } from "@album/database";
|
||||
import { eventSlugSchema } from "@album/contracts";
|
||||
import { createPresignedGetUrl } from "@album/storage";
|
||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||
|
||||
export const eventRouter = createTRPCRouter({
|
||||
community: publicProcedure.input(eventSlugSchema).query(async ({ input }) => {
|
||||
const [stored] = await getDb().select().from(events).where(eq(events.slug, input)).limit(1);
|
||||
const event = stored ? effectiveEvent(stored) : null;
|
||||
if (!event || event.status === "draft") throw new TRPCError({ code: "NOT_FOUND" });
|
||||
const noteFilter = and(eq(guests.eventId, event.id), isNotNull(guests.note), ne(guests.note, ""));
|
||||
const [notes, photoCount, submitterCount, noteCount] = await Promise.all([
|
||||
!notesAreVisible(event.notesPolicy, event.notesVisibleAt) ? [] : getDb().select({ id: guests.id, displayName: guests.displayName, note: guests.note })
|
||||
.from(guests).where(and(noteFilter, eq(guests.noteApproved, true))).orderBy(desc(guests.updatedAt)),
|
||||
event.showPhotoStats ? getDb().select({ value: count() }).from(photos)
|
||||
.where(and(eq(photos.eventId, event.id), ne(photos.processingStatus, "uploading"))) : [],
|
||||
event.showSubmitterStats ? getDb().select({ value: count() }).from(guests)
|
||||
.where(and(eq(guests.eventId, event.id), sql`((${guests.note} IS NOT NULL AND ${guests.note} <> '') OR EXISTS (SELECT 1 FROM submissions s JOIN photos p ON p.submission_id = s.id AND p.event_id = ${event.id} WHERE s.guest_id = ${guests.id} AND s.event_id = ${event.id} AND p.processing_status <> 'uploading'))`)) : [],
|
||||
event.showNoteStats ? getDb().select({ value: count() }).from(guests).where(noteFilter) : [],
|
||||
]);
|
||||
return { notes, stats: { photos: photoCount[0]?.value ?? null, submitters: submitterCount[0]?.value ?? null, notes: noteCount[0]?.value ?? null } };
|
||||
}),
|
||||
|
||||
listed: publicProcedure.query(async () => {
|
||||
return getDb()
|
||||
const listed = await getDb()
|
||||
.select({
|
||||
id: events.id,
|
||||
slug: events.slug,
|
||||
@@ -16,49 +36,78 @@ export const eventRouter = createTRPCRouter({
|
||||
startsAt: events.startsAt,
|
||||
endsAt: events.endsAt,
|
||||
galleryReleasedAt: events.galleryReleasedAt,
|
||||
galleryPolicy: events.galleryPolicy,
|
||||
bannerPhotoId: events.bannerPhotoId,
|
||||
customBannerId: events.customBannerId,
|
||||
galleryVisibleAt: events.galleryVisibleAt,
|
||||
})
|
||||
.from(events)
|
||||
.where(and(eq(events.listed, true), eq(events.status, "published")))
|
||||
.where(and(eq(events.listed, true), sql`(${events.status} = 'published' OR (${events.status} = 'draft' AND ${events.publishAt} <= now()))`, sql`(${events.publishAt} IS NULL OR ${events.publishAt} <= now())`))
|
||||
.orderBy(desc(events.startsAt), desc(events.createdAt));
|
||||
return Promise.all(listed.map(async ({ bannerPhotoId, customBannerId, ...event }) => ({
|
||||
...event,
|
||||
bannerUrl: customBannerId
|
||||
? await customBannerUrl(event.id, customBannerId)
|
||||
: galleryIsPublic(event.galleryPolicy, event.galleryReleasedAt, event.galleryVisibleAt) ? await eventBannerUrl(event.id, bannerPhotoId) : null,
|
||||
})));
|
||||
}),
|
||||
|
||||
bySlug: publicProcedure.input(eventSlugSchema).query(async ({ input }) => {
|
||||
const [event] = await getDb()
|
||||
const [stored] = await getDb()
|
||||
.select({
|
||||
id: events.id,
|
||||
slug: events.slug,
|
||||
title: events.title,
|
||||
description: events.description,
|
||||
location: events.location,
|
||||
latitude: events.latitude,
|
||||
longitude: events.longitude,
|
||||
bannerPhotoId: events.bannerPhotoId,
|
||||
customBannerId: events.customBannerId,
|
||||
startsAt: events.startsAt,
|
||||
endsAt: events.endsAt,
|
||||
status: events.status,
|
||||
listed: events.listed,
|
||||
uploadEnabled: events.uploadEnabled,
|
||||
publishAt: events.publishAt,
|
||||
submissionsOpenAt: events.submissionsOpenAt,
|
||||
submissionsCloseAt: events.submissionsCloseAt,
|
||||
completedAt: events.completedAt,
|
||||
galleryVisibleAt: events.galleryVisibleAt,
|
||||
notesVisibleAt: events.notesVisibleAt,
|
||||
notesPolicy: events.notesPolicy,
|
||||
galleryPolicy: events.galleryPolicy,
|
||||
showPhotoStats: events.showPhotoStats,
|
||||
showSubmitterStats: events.showSubmitterStats,
|
||||
showNoteStats: events.showNoteStats,
|
||||
galleryReleasedAt: events.galleryReleasedAt,
|
||||
})
|
||||
.from(events)
|
||||
.where(eq(events.slug, input))
|
||||
.limit(1);
|
||||
const event = stored ? effectiveEvent(stored) : null;
|
||||
if (!event || event.status === "draft") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
|
||||
}
|
||||
return event;
|
||||
return {
|
||||
...event,
|
||||
bannerUrl: event.customBannerId
|
||||
? await customBannerUrl(event.id, event.customBannerId)
|
||||
: galleryIsPublic(event.galleryPolicy, event.galleryReleasedAt, event.galleryVisibleAt) ? await eventBannerUrl(event.id, event.bannerPhotoId) : null,
|
||||
};
|
||||
}),
|
||||
|
||||
gallery: publicProcedure.input(eventSlugSchema).query(async ({ input }) => {
|
||||
const [event] = await getDb()
|
||||
.select({
|
||||
id: events.id,
|
||||
status: events.status,
|
||||
galleryReleasedAt: events.galleryReleasedAt,
|
||||
})
|
||||
const [stored] = await getDb()
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq(events.slug, input))
|
||||
.limit(1);
|
||||
const event = stored ? effectiveEvent(stored) : null;
|
||||
if (!event || event.status === "draft") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
|
||||
}
|
||||
if (!event.galleryReleasedAt) {
|
||||
if (!galleryIsPublic(event.galleryPolicy, event.galleryReleasedAt, event.galleryVisibleAt)) {
|
||||
return [];
|
||||
}
|
||||
const rows = await getDb()
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { events, getDb, guests, submissions } from "@album/database";
|
||||
import { ensureGuestInputSchema, startSubmissionInputSchema } from "@album/contracts";
|
||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||
import { guestCookieName, serializeCookie } from "@/server/cookies";
|
||||
import { hashToken, newToken } from "@/server/tokens";
|
||||
import { consumeRateLimit } from "@/server/rate-limit";
|
||||
import { effectiveEvent } from "@/lib/event-lifecycle";
|
||||
|
||||
async function requirePublishedEvent(slug: string) {
|
||||
const [event] = await getDb()
|
||||
const [stored] = await getDb()
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq(events.slug, slug))
|
||||
.limit(1);
|
||||
const event = stored ? effectiveEvent(stored) : null;
|
||||
if (!event || event.status === "draft") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
|
||||
}
|
||||
@@ -23,13 +26,19 @@ export const guestRouter = createTRPCRouter({
|
||||
.input(ensureGuestInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const event = await requirePublishedEvent(input.eventSlug);
|
||||
if (input.note && event.status !== "published") throw new TRPCError({ code: "FORBIDDEN", message: "Notes are closed for this event" });
|
||||
if (input.note && event.submissionsOpenAt && event.submissionsOpenAt > new Date()) throw new TRPCError({ code: "FORBIDDEN", message: "Submissions have not opened yet" });
|
||||
if (input.note) {
|
||||
const limit = await consumeRateLimit({ namespace: `notes:${event.id}`, identifier: ctx.clientIdentifier, limit: 20, windowMs: 10 * 60 * 1000 });
|
||||
if (!limit.allowed) throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many notes. Try again shortly." });
|
||||
}
|
||||
const existingToken = ctx.guestTokenForEvent(event.id);
|
||||
let guest = null;
|
||||
if (existingToken) {
|
||||
const [row] = await getDb()
|
||||
.select()
|
||||
.from(guests)
|
||||
.where(eq(guests.tokenHash, hashToken(existingToken)))
|
||||
.where(and(eq(guests.eventId, event.id), eq(guests.tokenHash, hashToken(existingToken))))
|
||||
.limit(1);
|
||||
if (row && row.eventId === event.id) guest = row;
|
||||
}
|
||||
@@ -43,9 +52,10 @@ export const guestRouter = createTRPCRouter({
|
||||
notifyWhenReady: input.notifyWhenReady ?? guest.notifyWhenReady,
|
||||
note:
|
||||
input.note === undefined ? guest.note : input.note || null,
|
||||
noteApproved: input.note === undefined || input.note === guest.note ? guest.noteApproved : event.notesPolicy === "automatic",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(guests.id, guest.id))
|
||||
.where(and(eq(guests.eventId, event.id), eq(guests.id, guest.id)))
|
||||
.returning();
|
||||
return {
|
||||
guestId: updated!.id,
|
||||
@@ -62,6 +72,7 @@ export const guestRouter = createTRPCRouter({
|
||||
email: input.email ?? null,
|
||||
notifyWhenReady: Boolean(input.notifyWhenReady && input.email),
|
||||
note: input.note || null,
|
||||
noteApproved: Boolean(input.note) && event.notesPolicy === "automatic",
|
||||
tokenHash: hashToken(token),
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,16 +15,18 @@ import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||
import { consumeRateLimit } from "@/server/rate-limit";
|
||||
import { hashToken } from "@/server/tokens";
|
||||
import { guests } from "@album/database";
|
||||
import { effectiveEvent } from "@/lib/event-lifecycle";
|
||||
|
||||
export const photosRouter = createTRPCRouter({
|
||||
create: publicProcedure
|
||||
.input(createPhotoInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [event] = await getDb()
|
||||
const [stored] = await getDb()
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq(events.slug, input.eventSlug))
|
||||
.limit(1);
|
||||
const event = stored ? effectiveEvent(stored) : null;
|
||||
if (!event || event.status === "draft") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
|
||||
}
|
||||
@@ -37,7 +39,7 @@ export const photosRouter = createTRPCRouter({
|
||||
const [submission] = await getDb()
|
||||
.select()
|
||||
.from(submissions)
|
||||
.where(eq(submissions.id, input.submissionId))
|
||||
.where(and(eq(submissions.id, input.submissionId), eq(submissions.eventId, event.id)))
|
||||
.limit(1);
|
||||
if (!submission || submission.eventId !== event.id) {
|
||||
throw new TRPCError({
|
||||
@@ -58,6 +60,7 @@ export const photosRouter = createTRPCRouter({
|
||||
.where(
|
||||
and(
|
||||
eq(guests.id, submission.guestId),
|
||||
eq(guests.eventId, event.id),
|
||||
eq(guests.tokenHash, hashToken(token)),
|
||||
),
|
||||
)
|
||||
@@ -86,7 +89,7 @@ export const photosRouter = createTRPCRouter({
|
||||
eventId: event.id,
|
||||
submissionId: submission.id,
|
||||
processingStatus: "uploading",
|
||||
visibility: "pending",
|
||||
visibility: event.galleryPolicy === "automatic" ? "public" : "pending",
|
||||
originalKey: "pending",
|
||||
contentType: input.contentType,
|
||||
byteSize: input.byteSize,
|
||||
@@ -97,7 +100,7 @@ export const photosRouter = createTRPCRouter({
|
||||
await getDb()
|
||||
.update(photos)
|
||||
.set({ originalKey: key, updatedAt: new Date() })
|
||||
.where(eq(photos.id, photo.id));
|
||||
.where(and(eq(photos.id, photo.id), eq(photos.eventId, event.id)));
|
||||
const uploadUrl = await createPresignedPutUrl({
|
||||
key,
|
||||
contentType: input.contentType,
|
||||
@@ -107,13 +110,18 @@ export const photosRouter = createTRPCRouter({
|
||||
|
||||
complete: publicProcedure
|
||||
.input(completePhotoInputSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
.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 token = ctx.guestTokenForEvent(photo.eventId);
|
||||
const [owner] = token ? await getDb().select({ id: guests.id }).from(guests)
|
||||
.innerJoin(submissions, and(eq(submissions.guestId, guests.id), eq(submissions.eventId, photo.eventId)))
|
||||
.where(and(eq(submissions.id, photo.submissionId), eq(guests.eventId, photo.eventId), eq(guests.tokenHash, hashToken(token)))).limit(1) : [];
|
||||
if (!owner) throw new TRPCError({ code: "FORBIDDEN", message: "Guest session does not match this upload" });
|
||||
if (photo.processingStatus !== "uploading") {
|
||||
return {
|
||||
photoId: photo.id,
|
||||
@@ -135,14 +143,16 @@ export const photosRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
await getDb().transaction(async (tx) => {
|
||||
await tx
|
||||
const changed = await tx
|
||||
.update(photos)
|
||||
.set({
|
||||
processingStatus: "processing",
|
||||
byteSize: size,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(photos.id, photo.id));
|
||||
.where(and(eq(photos.id, photo.id), eq(photos.eventId, photo.eventId), eq(photos.processingStatus, "uploading")))
|
||||
.returning({ id: photos.id });
|
||||
if (!changed.length) return;
|
||||
await tx.insert(photoJobs).values({
|
||||
photoId: photo.id,
|
||||
kind: "transcode",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { auditEvents, type Database } from "@album/database";
|
||||
import { auditEvents, user, guests, type Database } from "@album/database";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { getDb } from "@album/database";
|
||||
|
||||
export async function writeAudit(
|
||||
@@ -11,8 +12,17 @@ export async function writeAudit(
|
||||
subjectId: string;
|
||||
metadata?: Record<string, string | number | boolean | null>;
|
||||
},
|
||||
db: Database = getDb(),
|
||||
db: Pick<Database, "insert" | "select"> = getDb(),
|
||||
) {
|
||||
const [actor] = input.actorUserId ? await db.select({ name: user.name }).from(user).where(eq(user.id, input.actorUserId)).limit(1) : [];
|
||||
let subjectLabel = `${input.subjectType} ${input.subjectId}`;
|
||||
if (input.subjectType === "user") {
|
||||
const [subject] = await db.select({ name: user.name }).from(user).where(eq(user.id, input.subjectId)).limit(1);
|
||||
if (subject) subjectLabel = subject.name;
|
||||
} else if (input.subjectType === "guest" && input.eventId) {
|
||||
const [subject] = await db.select({ name: guests.displayName }).from(guests).where(and(eq(guests.eventId, input.eventId), eq(guests.id, input.subjectId))).limit(1);
|
||||
if (subject) subjectLabel = subject.name ?? "Anonymous guest";
|
||||
}
|
||||
await db.insert(auditEvents).values({
|
||||
groupId: input.groupId ?? null,
|
||||
eventId: input.eventId ?? null,
|
||||
@@ -20,6 +30,6 @@ export async function writeAudit(
|
||||
action: input.action,
|
||||
subjectType: input.subjectType,
|
||||
subjectId: input.subjectId,
|
||||
metadata: input.metadata ?? {},
|
||||
metadata: { ...input.metadata, subjectLabel, actorName: actor?.name ?? "System" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { eventBanners, getDb, photos } from "@album/database";
|
||||
import { createPresignedGetUrl } from "@album/storage";
|
||||
|
||||
// Resolve each time so hiding or deleting a photo also removes its banner.
|
||||
export async function eventBannerUrl(eventId: string, photoId: string | null) {
|
||||
if (!photoId) return null;
|
||||
const [photo] = await getDb().select({ displayKey: photos.displayKey })
|
||||
.from(photos).where(and(
|
||||
eq(photos.id, photoId), eq(photos.eventId, eventId),
|
||||
eq(photos.visibility, "public"), eq(photos.processingStatus, "ready"),
|
||||
)).limit(1);
|
||||
return photo?.displayKey ? createPresignedGetUrl(photo.displayKey) : null;
|
||||
}
|
||||
|
||||
export async function customBannerUrl(eventId: string, bannerId: string | null) {
|
||||
if (!bannerId) return null;
|
||||
const [banner] = await getDb().select({ displayKey: eventBanners.displayKey }).from(eventBanners)
|
||||
.where(and(eq(eventBanners.id, bannerId), eq(eventBanners.eventId, eventId), eq(eventBanners.status, "ready"))).limit(1);
|
||||
return banner?.displayKey ? createPresignedGetUrl(banner.displayKey) : null;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { events, getDb, groups, guests, groupMemberships, auditEvents, emailDeliveries } from "@album/database";
|
||||
import { processEmailDelivery } from "@album/email/queue";
|
||||
import { notifyEventGuests } from "./guest-notifications";
|
||||
|
||||
test.skipIf(process.env.NOTIFICATIONS_INTEGRATION !== "1")("Mailpit completion email is opt-in and deduplicated", async () => {
|
||||
if (process.env.EMAIL_PROVIDER !== "mailpit" || process.env.NODE_ENV === "production" || !["localhost", "127.0.0.1"].includes(process.env.SMTP_HOST ?? "")) throw new Error("This test requires local, non-production Mailpit");
|
||||
const db = getDb();
|
||||
const [group] = await db.select({ id: groups.id }).from(groups).limit(1);
|
||||
if (!group) throw new Error("Seed a development group first");
|
||||
const [event] = await db.insert(events).values({ groupId: group.id, title: "Notification integration test", slug: `mail-test-${crypto.randomUUID()}`, status: "closed", galleryPolicy: "automatic" }).returning();
|
||||
if (!event) throw new Error("Could not create fixture");
|
||||
try {
|
||||
const [member] = await db.select({ userId: groupMemberships.userId }).from(groupMemberships).where(eq(groupMemberships.groupId, group.id)).limit(1);
|
||||
if (!member) throw new Error("Seed a group member first");
|
||||
await db.insert(guests).values([
|
||||
{ eventId: event.id, email: "publishing-test@manyangles.test", notifyWhenReady: true, tokenHash: crypto.randomUUID() },
|
||||
{ eventId: event.id, email: "PUBLISHING-test@manyangles.test", notifyWhenReady: true, tokenHash: crypto.randomUUID() },
|
||||
{ eventId: event.id, email: "not-subscribed@manyangles.test", notifyWhenReady: false, tokenHash: crypto.randomUUID() },
|
||||
]);
|
||||
const results = await Promise.all([notifyEventGuests(event, member.userId), notifyEventGuests(event, member.userId)]);
|
||||
expect(results.reduce((sum, result) => sum + result.queued, 0)).toBe(1);
|
||||
await processEmailDelivery();
|
||||
const [delivery] = await db.select().from(emailDeliveries).where(eq(emailDeliveries.eventId, event.id));
|
||||
expect(delivery?.status).toBe("sent");
|
||||
expect(await notifyEventGuests(event, member.userId)).toEqual({ queued: 0 });
|
||||
const [excluded] = await db.select({ notifiedAt: guests.notifiedAt }).from(guests).where(and(eq(guests.eventId, event.id), eq(guests.notifyWhenReady, false)));
|
||||
expect(excluded?.notifiedAt).toBeNull();
|
||||
} finally {
|
||||
await db.delete(auditEvents).where(eq(auditEvents.eventId, event.id));
|
||||
await db.delete(events).where(eq(events.id, event.id));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { emailDeliveries, getDb, guests } from "@album/database";
|
||||
import { renderAlbumReadyEmail } from "@album/email";
|
||||
import { writeAudit } from "./audit";
|
||||
import { publicAppOrigin } from "./public-app-url";
|
||||
|
||||
export async function notifyEventGuests(event: { id: string; groupId: string; slug: string; title: string }, actorUserId: string) {
|
||||
return getDb().transaction(async (tx) => {
|
||||
const rows = await tx.select().from(guests).where(eq(guests.eventId, event.id));
|
||||
const previous = new Set(rows.filter((g) => g.notifiedAt || g.notificationClaimedAt).flatMap((g) => g.email ? [g.email.toLowerCase()] : []));
|
||||
const recipients = new Set(rows.filter((g) => g.notifyWhenReady && g.email && !previous.has(g.email.toLowerCase())).map((g) => g.email!.toLowerCase()));
|
||||
let queued = 0;
|
||||
for (const recipient of recipients) {
|
||||
const inserted = await tx.insert(emailDeliveries).values({ eventId: event.id, recipient,
|
||||
provider: process.env.EMAIL_PROVIDER ?? "resend",
|
||||
payload: renderAlbumReadyEmail({ to: recipient, eventTitle: event.title, galleryUrl: `${publicAppOrigin()}/e/${event.slug}` }),
|
||||
}).onConflictDoNothing().returning({ id: emailDeliveries.id });
|
||||
if (!inserted.length) continue;
|
||||
await tx.update(guests).set({ notificationClaimedAt: new Date() }).where(and(eq(guests.eventId, event.id), eq(guests.notifyWhenReady, true), sql`lower(${guests.email}) = ${recipient}`));
|
||||
queued++;
|
||||
}
|
||||
await writeAudit({ eventId: event.id, groupId: event.groupId, actorUserId, action: "guest.email.queued", subjectType: "event", subjectId: event.id, metadata: { queued } }, tx);
|
||||
return { queued };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseLocationResults } from "./location-search";
|
||||
import { updateEventInputSchema } from "@album/contracts";
|
||||
import { openStreetMapEmbedUrl } from "@/lib/maps";
|
||||
|
||||
describe("event locations", () => {
|
||||
test("maps GeoJSON longitude/latitude correctly and deduplicates matches", () => {
|
||||
const feature = { geometry: { coordinates: [-72.7, 40.9] }, properties: { name: "Raceway", city: "Riverhead" } };
|
||||
expect(parseLocationResults({ features: [feature, feature] })).toEqual([
|
||||
{ address: "Raceway, Riverhead", latitude: 40.9, longitude: -72.7 },
|
||||
]);
|
||||
});
|
||||
test("rejects impossible coordinates from the provider and event input", () => {
|
||||
expect(parseLocationResults({ features: [{ geometry: { coordinates: [200, 95] }, properties: { name: "Bad" } }] })).toEqual([]);
|
||||
expect(updateEventInputSchema.safeParse({ eventId: "7fa618fc-3fcb-4fd4-a0af-03f14124cccc", locationCoordinates: { latitude: 95, longitude: 0 } }).success).toBe(false);
|
||||
});
|
||||
test("keeps zero coordinates and uses latitude first in the OSM marker", () => {
|
||||
const url = new URL(openStreetMapEmbedUrl(0, 12));
|
||||
expect(url.searchParams.get("marker")).toBe("0,12");
|
||||
expect(url.searchParams.get("bbox")).toBe("11.992,-0.008,12.008,0.008");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from "zod";
|
||||
import { locationSuggestionSchema, type LocationSuggestion } from "@album/contracts";
|
||||
|
||||
const photonSchema = z.object({
|
||||
features: z.array(z.object({
|
||||
geometry: z.object({ coordinates: z.tuple([z.number(), z.number()]) }),
|
||||
properties: z.record(z.unknown()),
|
||||
})),
|
||||
});
|
||||
|
||||
export function parseLocationResults(payload: unknown): LocationSuggestion[] {
|
||||
const parsed = photonSchema.parse(payload);
|
||||
const seen = new Set<string>();
|
||||
return parsed.features.flatMap(({ geometry, properties }) => {
|
||||
const text = (key: string) => typeof properties[key] === "string" ? properties[key] as string : "";
|
||||
const street = [text("housenumber"), text("street")].filter(Boolean).join(" ");
|
||||
const address = [...new Set([
|
||||
text("name"), street, text("city") || text("district") || text("county"),
|
||||
text("state"), text("postcode"), text("country"),
|
||||
].filter(Boolean))].join(", ");
|
||||
const result = locationSuggestionSchema.safeParse({
|
||||
address, longitude: geometry.coordinates[0], latitude: geometry.coordinates[1],
|
||||
});
|
||||
const key = `${address}:${geometry.coordinates.join(",")}`;
|
||||
if (!address || !result.success || seen.has(key)) return [];
|
||||
seen.add(key);
|
||||
return [result.data];
|
||||
});
|
||||
}
|
||||
|
||||
export async function searchLocations(query: string) {
|
||||
const url = new URL("https://photon.komoot.io/api/");
|
||||
url.searchParams.set("q", query);
|
||||
url.searchParams.set("limit", "6");
|
||||
url.searchParams.set("lang", "en");
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: "application/json", "User-Agent": "Manyangles event location search" },
|
||||
next: { revalidate: 86_400 },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!response.ok) throw new Error("Location search unavailable");
|
||||
return parseLocationResults(await response.json());
|
||||
}
|
||||
@@ -30,7 +30,8 @@ export async function uniqueEventSlug(
|
||||
excludeEventId?: string,
|
||||
db: Database = getDb(),
|
||||
) {
|
||||
let candidate = desired.slice(0, 60) || "event";
|
||||
const base = desired.slice(0, 64).replace(/-+$/, "") || "event";
|
||||
let candidate = base;
|
||||
let suffix = 2;
|
||||
while (true) {
|
||||
const [existing] = await db
|
||||
@@ -39,7 +40,7 @@ export async function uniqueEventSlug(
|
||||
.where(eq(events.slug, candidate))
|
||||
.limit(1);
|
||||
if (!existing || existing.id === excludeEventId) return candidate;
|
||||
candidate = `${desired.slice(0, 50)}-${suffix}`;
|
||||
candidate = `${base.slice(0, 64 - String(suffix).length - 1).replace(/-+$/, "")}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { events, getDb, groups, guests, photos, photoJobs } from "@album/database";
|
||||
import { deletePrefix, getObjectBuffer, photoObjectPrefix, headObject } from "@album/storage";
|
||||
import { photosRouter } from "./api/routers/photos";
|
||||
import { eventRouter } from "./api/routers/event";
|
||||
import { guestRouter } from "./api/routers/guest";
|
||||
import type { TrpcContext } from "./api/trpc";
|
||||
|
||||
// Opt in against a migrated development database; creates and removes its own event.
|
||||
test.skipIf(process.env.PUBLISHING_INTEGRATION !== "1")("standalone notes, approval reset, hidden stats, and gallery policies", async () => {
|
||||
const db = getDb();
|
||||
if (!["localhost", "127.0.0.1"].includes(new URL(process.env.DATABASE_URL!).hostname)) throw new Error("Local database required");
|
||||
const [group] = await db.select({ id: groups.id }).from(groups).limit(1);
|
||||
if (!group) throw new Error("Seed a development group first");
|
||||
const [event] = await db.insert(events).values({ groupId: group.id, title: "Publishing test", slug: `publishing-test-${crypto.randomUUID()}`, status: "published", uploadEnabled: false }).returning();
|
||||
if (!event) throw new Error("Could not create test event");
|
||||
let token: string | null = null;
|
||||
const ctx: TrpcContext = { session: null, requestOrigin: "http://localhost:3000", clientIdentifier: "publishing-test", cookies: new Map(), activeGroupId: null,
|
||||
guestTokenForEvent: () => token, setCookies: [], appendSetCookie: (value) => { token = decodeURIComponent(value.split(";")[0]!.split("=").slice(1).join("=")); } };
|
||||
const guest = guestRouter.createCaller(ctx);
|
||||
const publicEvent = eventRouter.createCaller(ctx);
|
||||
let uploadedPhotoId: string | null = null;
|
||||
try {
|
||||
const result = await guest.ensure({ eventSlug: event.slug, note: "Private note without a photo" });
|
||||
expect((await publicEvent.community(event.slug))).toEqual({ notes: [], stats: { photos: null, submitters: null, notes: null } });
|
||||
await db.update(events).set({ notesPolicy: "automatic", showPhotoStats: true, showSubmitterStats: true, showNoteStats: true }).where(eq(events.id, event.id));
|
||||
expect((await publicEvent.community(event.slug)).notes).toHaveLength(0);
|
||||
await guest.ensure({ eventSlug: event.slug, note: "New automatic note" });
|
||||
const automatic = await publicEvent.community(event.slug);
|
||||
expect(automatic.notes).toHaveLength(1);
|
||||
expect(automatic.stats).toEqual({ photos: 0, submitters: 1, notes: 1 });
|
||||
expect(Object.keys(automatic.notes[0]!)).toEqual(["id", "displayName", "note"]);
|
||||
await db.update(events).set({ notesPolicy: "approved" }).where(eq(events.id, event.id));
|
||||
await guest.ensure({ eventSlug: event.slug, note: "Edited note requires approval" });
|
||||
expect((await publicEvent.community(event.slug)).notes).toHaveLength(0);
|
||||
await db.update(guests).set({ noteApproved: true }).where(and(eq(guests.eventId, event.id), eq(guests.id, result.guestId)));
|
||||
expect((await publicEvent.community(event.slug)).notes).toHaveLength(1);
|
||||
const future = new Date(Date.now() + 3600000);
|
||||
const past = new Date(Date.now() - 3600000);
|
||||
await db.update(events).set({ notesVisibleAt: future, galleryVisibleAt: future, publishAt: future }).where(eq(events.id, event.id));
|
||||
await expect(publicEvent.community(event.slug)).rejects.toThrow();
|
||||
await db.update(events).set({ publishAt: past }).where(eq(events.id, event.id));
|
||||
expect((await publicEvent.community(event.slug)).notes).toHaveLength(0);
|
||||
expect(await publicEvent.gallery(event.slug)).toEqual([]);
|
||||
await db.update(events).set({ notesVisibleAt: past, galleryVisibleAt: past, submissionsOpenAt: future }).where(eq(events.id, event.id));
|
||||
expect((await publicEvent.community(event.slug)).notes).toHaveLength(1);
|
||||
await expect(guest.ensure({ eventSlug: event.slug, note: "Too early" })).rejects.toThrow();
|
||||
await db.update(events).set({ submissionsOpenAt: past }).where(eq(events.id, event.id));
|
||||
const emailOnly = guestRouter.createCaller({ ...ctx, guestTokenForEvent: () => null, appendSetCookie: () => {} });
|
||||
const emailGuest = await emailOnly.ensure({ eventSlug: event.slug, email: "email-only@manyangles.test", notifyWhenReady: true });
|
||||
const [saved] = await db.select({ note: guests.note, optIn: guests.notifyWhenReady }).from(guests).where(and(eq(guests.eventId, event.id), eq(guests.id, emailGuest.guestId)));
|
||||
expect(saved).toEqual({ note: null, optIn: true });
|
||||
await db.update(events).set({ galleryVisibleAt: null }).where(eq(events.id, event.id));
|
||||
await db.update(events).set({ galleryPolicy: "automatic", uploadEnabled: true }).where(eq(events.id, event.id));
|
||||
const submission = await guest.startSubmission({ eventSlug: event.slug });
|
||||
const photoCaller = photosRouter.createCaller(ctx);
|
||||
let original: Buffer | null = null;
|
||||
if (process.env.REAL_UPLOAD_INTEGRATION === "1") {
|
||||
const [demo] = await db.select({ id: events.id }).from(events).where(eq(events.slug, "demo"));
|
||||
const [source] = await db.select({ key: photos.originalKey }).from(photos).where(and(eq(photos.eventId, demo!.id), eq(photos.processingStatus, "ready"))).limit(1);
|
||||
original = await getObjectBuffer(source!.key);
|
||||
}
|
||||
const created = await photoCaller.create({ eventSlug: event.slug, submissionId: submission.submissionId, contentType: "image/jpeg", fileName: "test.jpg", byteSize: original?.length ?? 100 });
|
||||
await expect(photosRouter.createCaller({ ...ctx, guestTokenForEvent: () => null }).complete({ photoId: created.photoId })).rejects.toThrow("Guest session");
|
||||
expect(await publicEvent.gallery(event.slug)).toEqual([]);
|
||||
if (original) {
|
||||
uploadedPhotoId = created.photoId;
|
||||
expect((await fetch(created.uploadUrl, { method: "PUT", headers: { "Content-Type": "image/jpeg" }, body: new Uint8Array(original) })).ok).toBe(true);
|
||||
await Promise.all([photoCaller.complete({ photoId: created.photoId }), photoCaller.complete({ photoId: created.photoId })]);
|
||||
expect(await db.select({ id: photoJobs.id }).from(photoJobs).where(eq(photoJobs.photoId, created.photoId))).toHaveLength(1);
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 45; attempt++) {
|
||||
const [photo] = await db.select().from(photos).where(and(eq(photos.eventId, event.id), eq(photos.id, created.photoId)));
|
||||
if (photo?.processingStatus === "failed") throw new Error("Image processing failed");
|
||||
if (photo?.processingStatus === "ready") {
|
||||
expect(photo.displayKey).toBeTruthy();
|
||||
expect(photo.thumbKey).toBeTruthy();
|
||||
expect(Number((await headObject(photo.displayKey!))?.ContentLength)).toBeLessThan(original.length);
|
||||
expect(Number((await headObject(photo.thumbKey!))?.ContentLength)).toBeLessThan(original.length);
|
||||
ready = true;
|
||||
break;
|
||||
}
|
||||
await Bun.sleep(500);
|
||||
}
|
||||
expect(ready).toBe(true);
|
||||
} else {
|
||||
// Fast policy-only test; opt in above for real storage and worker processing.
|
||||
await db.update(photos).set({ processingStatus: "ready" }).where(and(eq(photos.eventId, event.id), eq(photos.id, created.photoId)));
|
||||
}
|
||||
expect(await publicEvent.gallery(event.slug)).toHaveLength(1);
|
||||
await db.update(photos).set({ visibility: "pending" }).where(and(eq(photos.eventId, event.id), eq(photos.id, created.photoId)));
|
||||
expect(await publicEvent.gallery(event.slug)).toEqual([]);
|
||||
await db.update(photos).set({ visibility: "public" }).where(and(eq(photos.eventId, event.id), eq(photos.id, created.photoId)));
|
||||
await db.update(events).set({ galleryPolicy: "approved" }).where(eq(events.id, event.id));
|
||||
expect(await publicEvent.gallery(event.slug)).toEqual([]);
|
||||
await db.update(events).set({ galleryReleasedAt: new Date() }).where(eq(events.id, event.id));
|
||||
expect(await publicEvent.gallery(event.slug)).toHaveLength(1);
|
||||
await db.update(events).set({ notesPolicy: "never", galleryPolicy: "never", galleryReleasedAt: new Date() }).where(eq(events.id, event.id));
|
||||
expect((await publicEvent.community(event.slug)).notes).toHaveLength(0);
|
||||
expect(await publicEvent.gallery(event.slug)).toEqual([]);
|
||||
await db.update(events).set({ status: "closed" }).where(eq(events.id, event.id));
|
||||
await expect(guest.ensure({ eventSlug: event.slug, note: "Closed" })).rejects.toThrow("Notes are closed");
|
||||
await db.update(events).set({ status: "draft", publishAt: null }).where(eq(events.id, event.id));
|
||||
await expect(publicEvent.community(event.slug)).rejects.toThrow();
|
||||
} finally {
|
||||
if (uploadedPhotoId) await deletePrefix(photoObjectPrefix(event.id, uploadedPhotoId));
|
||||
await db.delete(events).where(eq(events.id, event.id));
|
||||
}
|
||||
}, 30000);
|
||||
Reference in New Issue
Block a user