Initial commit of Vellum, an event photo product for guest uploads, host moderation, and original-quality galleries.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-07 19:36:14 -04:00
co-authored by Cursor
commit 27e2f196eb
149 changed files with 13847 additions and 0 deletions
+154
View File
@@ -0,0 +1,154 @@
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import { events, getDb, photoJobs, photos, submissions } from "@album/database";
import {
completePhotoInputSchema,
createPhotoInputSchema,
MAX_PHOTO_BYTES,
} from "@album/contracts";
import {
createPresignedPutUrl,
headObject,
originalObjectKey,
} from "@album/storage";
import { createTRPCRouter, publicProcedure } from "../trpc";
import { consumeRateLimit } from "@/server/rate-limit";
import { hashToken } from "@/server/tokens";
import { guests } from "@album/database";
export const photosRouter = createTRPCRouter({
create: publicProcedure
.input(createPhotoInputSchema)
.mutation(async ({ ctx, input }) => {
const [event] = await getDb()
.select()
.from(events)
.where(eq(events.slug, input.eventSlug))
.limit(1);
if (!event || event.status === "draft") {
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
}
if (event.status === "closed" || !event.uploadEnabled) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Uploads are closed for this event",
});
}
const [submission] = await getDb()
.select()
.from(submissions)
.where(eq(submissions.id, input.submissionId))
.limit(1);
if (!submission || submission.eventId !== event.id) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Submission not found",
});
}
const token = ctx.guestTokenForEvent(event.id);
if (!token) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Guest session is missing",
});
}
const [guest] = await getDb()
.select()
.from(guests)
.where(
and(
eq(guests.id, submission.guestId),
eq(guests.tokenHash, hashToken(token)),
),
)
.limit(1);
if (!guest) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Guest session does not match this submission",
});
}
const limit = await consumeRateLimit({
namespace: `upload:${event.id}`,
identifier: ctx.clientIdentifier,
limit: 40,
windowMs: 10 * 60 * 1000,
});
if (!limit.allowed) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Too many uploads. Try again shortly.",
});
}
const [photo] = await getDb()
.insert(photos)
.values({
eventId: event.id,
submissionId: submission.id,
processingStatus: "uploading",
visibility: "pending",
originalKey: "pending",
contentType: input.contentType,
byteSize: input.byteSize,
})
.returning();
if (!photo) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
const key = originalObjectKey(event.id, photo.id);
await getDb()
.update(photos)
.set({ originalKey: key, updatedAt: new Date() })
.where(eq(photos.id, photo.id));
const uploadUrl = await createPresignedPutUrl({
key,
contentType: input.contentType,
});
return { photoId: photo.id, uploadUrl };
}),
complete: publicProcedure
.input(completePhotoInputSchema)
.mutation(async ({ input }) => {
const [photo] = await getDb()
.select()
.from(photos)
.where(eq(photos.id, input.photoId))
.limit(1);
if (!photo) throw new TRPCError({ code: "NOT_FOUND" });
if (photo.processingStatus !== "uploading") {
return {
photoId: photo.id,
processingStatus: photo.processingStatus,
};
}
const head = await headObject(photo.originalKey);
if (!head) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Upload was not found. Try again.",
});
}
const size = Number(head.ContentLength ?? 0);
if (size <= 0 || size > MAX_PHOTO_BYTES) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "That file is empty or larger than 25 MB.",
});
}
await getDb().transaction(async (tx) => {
await tx
.update(photos)
.set({
processingStatus: "processing",
byteSize: size,
updatedAt: new Date(),
})
.where(eq(photos.id, photo.id));
await tx.insert(photoJobs).values({
photoId: photo.id,
kind: "transcode",
status: "pending",
});
});
return { photoId: photo.id, processingStatus: "processing" as const };
}),
});