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
+102
View File
@@ -0,0 +1,102 @@
import { TRPCError } from "@trpc/server";
import { and, desc, eq } from "drizzle-orm";
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({
listed: publicProcedure.query(async () => {
return getDb()
.select({
id: events.id,
slug: events.slug,
title: events.title,
description: events.description,
startsAt: events.startsAt,
endsAt: events.endsAt,
galleryReleasedAt: events.galleryReleasedAt,
})
.from(events)
.where(and(eq(events.listed, true), eq(events.status, "published")))
.orderBy(desc(events.startsAt), desc(events.createdAt));
}),
bySlug: publicProcedure.input(eventSlugSchema).query(async ({ input }) => {
const [event] = await getDb()
.select({
id: events.id,
slug: events.slug,
title: events.title,
description: events.description,
startsAt: events.startsAt,
endsAt: events.endsAt,
status: events.status,
listed: events.listed,
uploadEnabled: events.uploadEnabled,
galleryReleasedAt: events.galleryReleasedAt,
})
.from(events)
.where(eq(events.slug, input))
.limit(1);
if (!event || event.status === "draft") {
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
}
return event;
}),
gallery: publicProcedure.input(eventSlugSchema).query(async ({ input }) => {
const [event] = await getDb()
.select({
id: events.id,
status: events.status,
galleryReleasedAt: events.galleryReleasedAt,
})
.from(events)
.where(eq(events.slug, input))
.limit(1);
if (!event || event.status === "draft") {
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
}
if (!event.galleryReleasedAt) {
return [];
}
const rows = await getDb()
.select({
id: photos.id,
width: photos.width,
height: photos.height,
createdAt: photos.createdAt,
thumbKey: photos.thumbKey,
displayKey: photos.displayKey,
displayName: guests.displayName,
})
.from(photos)
.innerJoin(submissions, eq(photos.submissionId, submissions.id))
.innerJoin(guests, eq(submissions.guestId, guests.id))
.where(
and(
eq(photos.eventId, event.id),
eq(photos.visibility, "public"),
eq(photos.processingStatus, "ready"),
),
)
.orderBy(desc(photos.createdAt));
return Promise.all(
rows.map(async (photo) => ({
id: photo.id,
contributorName: photo.displayName,
width: photo.width,
height: photo.height,
createdAt: photo.createdAt,
thumbUrl: photo.thumbKey
? await createPresignedGetUrl(photo.thumbKey)
: null,
displayUrl: photo.displayKey
? await createPresignedGetUrl(photo.displayKey)
: null,
})),
);
}),
});
+327
View File
@@ -0,0 +1,327 @@
import { TRPCError } from "@trpc/server";
import { and, desc, eq } from "drizzle-orm";
import {
auditEvents,
eventMemberships,
events,
getDb,
groupMemberships,
groups,
user,
} from "@album/database";
import {
createEmailInviteInputSchema,
createInviteCodeInputSchema,
setGroupMemberInputSchema,
} from "@album/contracts";
import { z } from "zod";
import {
createTRPCRouter,
loadGroupAccess,
protectedProcedure,
requireGroupPermission,
} from "../trpc";
import { GROUP_PERMISSIONS } from "@/server/permissions";
import { getPlatformRole } from "@/server/roles";
import { resolveGroupQuota } from "@/server/entitlements";
import { countGroupOwners } from "@/server/membership";
import { writeAudit } from "@/server/audit";
import { hashToken, newInviteCode, newToken } from "@/server/tokens";
import { sendStaffInviteEmail } from "@album/email";
import { publicAppOrigin } from "@/server/public-app-url";
import { GROUP_COOKIE, serializeCookie } from "@/server/cookies";
import { invites } from "@album/database";
export const groupRouter = createTRPCRouter({
list: protectedProcedure.query(async ({ ctx }) => {
return getDb()
.select({
id: groups.id,
name: groups.name,
slug: groups.slug,
role: groupMemberships.role,
createdAt: groups.createdAt,
})
.from(groupMemberships)
.innerJoin(groups, eq(groupMemberships.groupId, groups.id))
.where(eq(groupMemberships.userId, ctx.session.user.id))
.orderBy(desc(groups.createdAt));
}),
get: protectedProcedure
.input(z.object({ groupId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const { access } = await loadGroupAccess(
ctx.session.user.id,
input.groupId,
platformRole,
);
const [group] = await getDb()
.select()
.from(groups)
.where(eq(groups.id, input.groupId))
.limit(1);
if (!group) throw new TRPCError({ code: "NOT_FOUND" });
const quota = await resolveGroupQuota(group.id);
return { ...group, quota, permissions: access.permissions, role: access.role };
}),
members: protectedProcedure
.input(z.object({ groupId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
await loadGroupAccess(ctx.session.user.id, input.groupId, platformRole);
return getDb()
.select({
id: groupMemberships.id,
userId: user.id,
name: user.name,
email: user.email,
role: groupMemberships.role,
})
.from(groupMemberships)
.innerJoin(user, eq(groupMemberships.userId, user.id))
.where(eq(groupMemberships.groupId, input.groupId));
}),
setMember: protectedProcedure
.input(setGroupMemberInputSchema)
.mutation(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const { access } = await loadGroupAccess(
ctx.session.user.id,
input.groupId,
platformRole,
);
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
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(groupMemberships)
.where(
and(
eq(groupMemberships.groupId, input.groupId),
eq(groupMemberships.userId, target.id),
),
)
.limit(1);
if (existing?.role === "owner" && input.role !== "owner") {
const owners = await countGroupOwners(input.groupId);
if (owners <= 1) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "A group must keep at least one owner",
});
}
}
if (existing) {
await getDb()
.update(groupMemberships)
.set({ role: input.role, updatedAt: new Date() })
.where(eq(groupMemberships.id, existing.id));
} else {
await getDb().insert(groupMemberships).values({
groupId: input.groupId,
userId: target.id,
role: input.role,
});
}
await writeAudit({
groupId: input.groupId,
actorUserId: ctx.session.user.id,
action: "group.member.set",
subjectType: "user",
subjectId: target.id,
metadata: { role: input.role },
});
return { ok: true as const };
}),
removeMember: protectedProcedure
.input(z.object({ groupId: z.string().uuid(), userId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const { access } = await loadGroupAccess(
ctx.session.user.id,
input.groupId,
platformRole,
);
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
const [existing] = await getDb()
.select()
.from(groupMemberships)
.where(
and(
eq(groupMemberships.groupId, input.groupId),
eq(groupMemberships.userId, input.userId),
),
)
.limit(1);
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
if (existing.role === "owner") {
const owners = await countGroupOwners(input.groupId);
if (owners <= 1) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "A group must keep at least one owner",
});
}
}
const groupEvents = await getDb()
.select({ id: events.id })
.from(events)
.where(eq(events.groupId, input.groupId));
for (const event of groupEvents) {
await getDb()
.delete(eventMemberships)
.where(
and(
eq(eventMemberships.eventId, event.id),
eq(eventMemberships.userId, input.userId),
),
);
}
await getDb()
.delete(groupMemberships)
.where(eq(groupMemberships.id, existing.id));
await writeAudit({
groupId: input.groupId,
actorUserId: ctx.session.user.id,
action: "group.member.remove",
subjectType: "user",
subjectId: input.userId,
});
return { ok: true as const };
}),
inviteEmail: protectedProcedure
.input(createEmailInviteInputSchema)
.mutation(async ({ ctx, input }) => {
if (!input.groupId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "groupId is required" });
}
const platformRole = await getPlatformRole(ctx.session.user.id);
const { access } = await loadGroupAccess(
ctx.session.user.id,
input.groupId,
platformRole,
);
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
const token = newToken();
await getDb().insert(invites).values({
kind: "email",
email: input.email,
tokenHash: hashToken(token),
groupId: input.groupId,
eventId: input.eventId ?? null,
groupRole: input.groupRole ?? "member",
eventRole: input.eventRole ?? null,
grantUnlimitedEvents: input.grantUnlimitedEvents ?? false,
grantEventLimit: input.grantEventLimit ?? null,
grantComplimentary: input.grantComplimentary ?? false,
createdByUserId: ctx.session.user.id,
expiresAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
});
const url = `${publicAppOrigin()}/invitations/${token}`;
await sendStaffInviteEmail({
to: input.email,
inviterName: ctx.session.user.name,
inviteUrl: url,
});
await writeAudit({
groupId: input.groupId,
eventId: input.eventId,
actorUserId: ctx.session.user.id,
action: "invite.email.create",
subjectType: "invite",
subjectId: input.groupId,
});
return { ok: true as const };
}),
createCode: protectedProcedure
.input(createInviteCodeInputSchema)
.mutation(async ({ ctx, input }) => {
if (!input.groupId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "groupId is required" });
}
const platformRole = await getPlatformRole(ctx.session.user.id);
const { access } = await loadGroupAccess(
ctx.session.user.id,
input.groupId,
platformRole,
);
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
const code = newInviteCode();
await getDb().insert(invites).values({
kind: "code",
tokenHash: hashToken(code),
reusable: input.reusable ?? false,
maxUses: input.maxUses ?? 1,
groupId: input.groupId,
eventId: input.eventId ?? null,
groupRole: input.groupRole ?? "member",
eventRole: input.eventRole ?? null,
grantUnlimitedEvents: input.grantUnlimitedEvents ?? false,
grantEventLimit: input.grantEventLimit ?? null,
grantComplimentary: input.grantComplimentary ?? false,
createdByUserId: ctx.session.user.id,
expiresAt: input.expiresAt ?? null,
});
await writeAudit({
groupId: input.groupId,
actorUserId: ctx.session.user.id,
action: "invite.code.create",
subjectType: "invite",
subjectId: input.groupId,
});
return { code };
}),
audit: protectedProcedure
.input(z.object({ groupId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
await loadGroupAccess(ctx.session.user.id, input.groupId, platformRole);
return getDb()
.select({
id: auditEvents.id,
action: auditEvents.action,
subjectType: auditEvents.subjectType,
subjectId: auditEvents.subjectId,
metadata: auditEvents.metadata,
createdAt: auditEvents.createdAt,
eventId: auditEvents.eventId,
})
.from(auditEvents)
.where(eq(auditEvents.groupId, input.groupId))
.orderBy(desc(auditEvents.createdAt))
.limit(100);
}),
select: protectedProcedure
.input(z.object({ groupId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
await loadGroupAccess(ctx.session.user.id, input.groupId, platformRole);
ctx.appendSetCookie(
serializeCookie(GROUP_COOKIE, input.groupId, {
maxAge: 60 * 60 * 24 * 365,
}),
);
return { ok: true as const };
}),
});
+115
View File
@@ -0,0 +1,115 @@
import { TRPCError } from "@trpc/server";
import { 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";
async function requirePublishedEvent(slug: string) {
const [event] = await getDb()
.select()
.from(events)
.where(eq(events.slug, slug))
.limit(1);
if (!event || event.status === "draft") {
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
}
return event;
}
export const guestRouter = createTRPCRouter({
ensure: publicProcedure
.input(ensureGuestInputSchema)
.mutation(async ({ ctx, input }) => {
const event = await requirePublishedEvent(input.eventSlug);
const existingToken = ctx.guestTokenForEvent(event.id);
let guest = null;
if (existingToken) {
const [row] = await getDb()
.select()
.from(guests)
.where(eq(guests.tokenHash, hashToken(existingToken)))
.limit(1);
if (row && row.eventId === event.id) guest = row;
}
if (guest) {
const [updated] = await getDb()
.update(guests)
.set({
displayName: input.displayName ?? guest.displayName,
email: input.email ?? guest.email,
notifyWhenReady: input.notifyWhenReady ?? guest.notifyWhenReady,
note:
input.note === undefined ? guest.note : input.note || null,
updatedAt: new Date(),
})
.where(eq(guests.id, guest.id))
.returning();
return {
guestId: updated!.id,
eventId: event.id,
};
}
const token = newToken();
const [created] = await getDb()
.insert(guests)
.values({
eventId: event.id,
displayName: input.displayName ?? null,
email: input.email ?? null,
notifyWhenReady: Boolean(input.notifyWhenReady && input.email),
note: input.note || null,
tokenHash: hashToken(token),
})
.returning();
if (!created) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
ctx.appendSetCookie(
serializeCookie(guestCookieName(event.id), token, {
maxAge: 60 * 60 * 24 * 365,
}),
);
return { guestId: created.id, eventId: event.id };
}),
startSubmission: publicProcedure
.input(startSubmissionInputSchema)
.mutation(async ({ ctx, input }) => {
const event = await requirePublishedEvent(input.eventSlug);
if (event.status === "closed" || !event.uploadEnabled) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Uploads are closed for this event",
});
}
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(eq(guests.tokenHash, hashToken(token)))
.limit(1);
if (!guest || guest.eventId !== event.id) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Guest session is missing",
});
}
const [submission] = await getDb()
.insert(submissions)
.values({
eventId: event.id,
guestId: guest.id,
})
.returning();
if (!submission) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
return { submissionId: submission.id, guestId: guest.id };
}),
});
@@ -0,0 +1,39 @@
import { redeemInviteInputSchema } from "@album/contracts";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { findInviteByToken, redeemInviteForUser } from "@/server/invites";
import { TRPCError } from "@trpc/server";
import { getDeploymentSettings } from "@/server/settings";
export const invitesRouter = createTRPCRouter({
preview: publicProcedure.input(redeemInviteInputSchema).query(async ({ input }) => {
const invite = await findInviteByToken(input.token);
if (!invite) {
throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" });
}
return {
kind: invite.kind,
status: invite.status,
groupRole: invite.groupRole,
eventRole: invite.eventRole,
expiresAt: invite.expiresAt,
remaining: Math.max(0, invite.maxUses - invite.usedCount),
};
}),
redeem: protectedProcedure
.input(redeemInviteInputSchema)
.mutation(async ({ ctx, input }) => {
return redeemInviteForUser({
token: input.token,
userId: ctx.session.user.id,
});
}),
signupAllowed: publicProcedure.query(async () => {
const settings = await getDeploymentSettings();
return {
openSignup: settings.openSignup,
eventCreatePolicy: settings.eventCreatePolicy,
};
}),
});
+725
View File
@@ -0,0 +1,725 @@
import { TRPCError } from "@trpc/server";
import { and, desc, eq } from "drizzle-orm";
import {
auditEvents,
eventMemberships,
events,
getDb,
groupMemberships,
guests,
photos,
submissions,
user,
} from "@album/database";
import {
createEventInputSchema,
moderatePhotoInputSchema,
moderateSubmissionInputSchema,
setEventMemberInputSchema,
updateEventInputSchema,
} from "@album/contracts";
import {
createPresignedGetUrl,
deletePrefix,
photoObjectPrefix,
} from "@album/storage";
import { sendAlbumReadyEmail } from "@album/email";
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({
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 memberships
.filter((row) => row.event.groupId === ctx.activeGroupId)
.map((row) => ({ ...row.event, role: row.role }));
}
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 memberships.map((row) => ({ ...row.event, role: row.role }));
}),
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,
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;
}),
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);
const slug = input.slug
? await uniqueEventSlug(input.slug, event.id)
: event.slug;
const [updated] = await getDb()
.update(events)
.set({
title: input.title ?? event.title,
slug,
description:
input.description === undefined ? event.description : input.description,
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,
updatedAt: new Date(),
})
.where(eq(events.id, event.id))
.returning();
await writeAudit({
groupId: event.groupId,
eventId: event.id,
actorUserId: ctx.session.user.id,
action: "event.update",
subjectType: "event",
subjectId: event.id,
});
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);
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 notified = 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;
}
}
await writeAudit({
groupId: event.groupId,
eventId: event.id,
actorUserId: ctx.session.user.id,
action: "gallery.release",
subjectType: "event",
subjectId: event.id,
metadata: { notified },
});
return { ok: true as const, notified };
}),
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: input.visibility },
});
return updated!;
}),
moderateSubmission: protectedProcedure
.input(moderateSubmissionInputSchema)
.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_MODERATE);
const rows = await getDb()
.select()
.from(photos)
.where(eq(photos.submissionId, submission.id));
for (const photo of rows) {
if (photo.processingStatus !== "ready") continue;
if (!canTransitionVisibility(photo.visibility, input.visibility)) continue;
await getDb()
.update(photos)
.set({ visibility: input.visibility, updatedAt: new Date() })
.where(eq(photos.id, photo.id));
}
await writeAudit({
groupId: event.groupId,
eventId: event.id,
actorUserId: ctx.session.user.id,
action: "submission.visibility",
subjectType: "submission",
subjectId: submission.id,
metadata: { visibility: input.visibility, count: rows.length },
});
return { ok: true as const };
}),
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 };
}),
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,
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: 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() }))
.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);
return getDb()
.select({
id: auditEvents.id,
action: auditEvents.action,
subjectType: auditEvents.subjectType,
subjectId: auditEvents.subjectId,
metadata: auditEvents.metadata,
createdAt: auditEvents.createdAt,
actorUserId: auditEvents.actorUserId,
})
.from(auditEvents)
.where(eq(auditEvents.eventId, input.eventId))
.orderBy(desc(auditEvents.createdAt))
.limit(100);
}),
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 };
}),
});
+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 };
}),
});
+247
View File
@@ -0,0 +1,247 @@
import { TRPCError } from "@trpc/server";
import { desc, eq, ilike, or } from "drizzle-orm";
import {
auditEvents,
deploymentSettings,
events,
getDb,
groups,
platformAdministrators,
user,
} from "@album/database";
import {
createInviteCodeInputSchema,
grantEntitlementInputSchema,
setPlatformRoleInputSchema,
updateDeploymentSettingsInputSchema,
} from "@album/contracts";
import { z } from "zod";
import {
createTRPCRouter,
platformPermissionProcedure,
platformProcedure,
} from "../trpc";
import { PLATFORM_PERMISSIONS } from "@/server/permissions";
import { hasPlatformPermission } from "@/server/roles";
import { getDeploymentSettings } from "@/server/settings";
import { grantEntitlement, resolveGroupQuota } from "@/server/entitlements";
import { writeAudit } from "@/server/audit";
import { hashToken, newInviteCode } from "@/server/tokens";
import { invites } from "@album/database";
export const platformRouter = createTRPCRouter({
settings: platformPermissionProcedure(PLATFORM_PERMISSIONS.SETTINGS_MANAGE).query(
async () => getDeploymentSettings(),
),
updateSettings: platformPermissionProcedure(
PLATFORM_PERMISSIONS.SETTINGS_MANAGE,
)
.input(updateDeploymentSettingsInputSchema)
.mutation(async ({ ctx, input }) => {
const current = await getDeploymentSettings();
const [updated] = await getDb()
.update(deploymentSettings)
.set({
openSignup: input.openSignup ?? current.openSignup,
eventCreatePolicy: input.eventCreatePolicy ?? current.eventCreatePolicy,
defaultEventLimit: input.defaultEventLimit ?? current.defaultEventLimit,
updatedAt: new Date(),
})
.where(eq(deploymentSettings.id, "default"))
.returning();
await writeAudit({
actorUserId: ctx.session.user.id,
action: "deployment.settings",
subjectType: "deployment",
subjectId: "default",
});
return updated!;
}),
users: platformPermissionProcedure(PLATFORM_PERMISSIONS.USERS_MANAGE)
.input(z.object({ query: z.string().trim().max(120).optional() }).optional())
.query(async ({ input }) => {
const q = input?.query?.trim();
const rows = await getDb()
.select({
id: user.id,
name: user.name,
email: user.email,
createdAt: user.createdAt,
})
.from(user)
.where(
q
? or(ilike(user.email, `%${q}%`), ilike(user.name, `%${q}%`))
: undefined,
)
.orderBy(desc(user.createdAt))
.limit(50);
const admins = await getDb().select().from(platformAdministrators);
const roleByUser = new Map(admins.map((row) => [row.userId, row.role]));
return rows.map((row) => ({
...row,
platformRole: roleByUser.get(row.id) ?? null,
}));
}),
setPlatformRole: platformPermissionProcedure(PLATFORM_PERMISSIONS.USERS_MANAGE)
.input(setPlatformRoleInputSchema)
.mutation(async ({ ctx, input }) => {
if (input.role === "super_admin") {
if (!hasPlatformPermission(ctx.platformRole, PLATFORM_PERMISSIONS.GRANT_SUPER_ADMIN)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only a super admin can grant super admin",
});
}
}
if (input.role === "admin") {
const allowed =
ctx.platformRole === "super_admin" || ctx.platformRole === "admin";
if (!allowed) {
throw new TRPCError({ code: "FORBIDDEN" });
}
}
const [existing] = await getDb()
.select()
.from(platformAdministrators)
.where(eq(platformAdministrators.userId, input.userId))
.limit(1);
if (!input.role) {
if (existing) {
await getDb()
.delete(platformAdministrators)
.where(eq(platformAdministrators.id, existing.id));
}
} else if (existing) {
await getDb()
.update(platformAdministrators)
.set({ role: input.role, updatedAt: new Date() })
.where(eq(platformAdministrators.id, existing.id));
} else {
await getDb().insert(platformAdministrators).values({
userId: input.userId,
role: input.role,
});
}
await writeAudit({
actorUserId: ctx.session.user.id,
action: "platform.role.set",
subjectType: "user",
subjectId: input.userId,
metadata: { role: input.role },
});
return { ok: true as const };
}),
groups: platformPermissionProcedure(PLATFORM_PERMISSIONS.EVENTS_READ).query(
async () => {
const rows = await getDb()
.select()
.from(groups)
.orderBy(desc(groups.createdAt));
return Promise.all(
rows.map(async (group) => ({
...group,
quota: await resolveGroupQuota(group.id),
})),
);
},
),
events: platformPermissionProcedure(PLATFORM_PERMISSIONS.EVENTS_READ).query(
async () => {
return getDb()
.select({
id: events.id,
title: events.title,
slug: events.slug,
status: events.status,
listed: events.listed,
groupId: events.groupId,
groupName: groups.name,
createdAt: events.createdAt,
})
.from(events)
.innerJoin(groups, eq(events.groupId, groups.id))
.orderBy(desc(events.createdAt));
},
),
grantEntitlement: platformPermissionProcedure(
PLATFORM_PERMISSIONS.ENTITLEMENTS_MANAGE,
)
.input(grantEntitlementInputSchema)
.mutation(async ({ ctx, input }) => {
const row = await grantEntitlement({
groupId: input.groupId,
eventLimit: input.eventLimit === undefined ? null : input.eventLimit,
expiresAt: input.expiresAt,
complimentary: input.complimentary ?? true,
source: "platform",
grantedByUserId: ctx.session.user.id,
});
await writeAudit({
groupId: input.groupId,
actorUserId: ctx.session.user.id,
action: "entitlement.grant",
subjectType: "entitlement",
subjectId: row.id,
metadata: {
unlimited: input.eventLimit == null,
complimentary: input.complimentary ?? true,
},
});
return row;
}),
createCode: platformPermissionProcedure(PLATFORM_PERMISSIONS.ENTITLEMENTS_MANAGE)
.input(createInviteCodeInputSchema)
.mutation(async ({ ctx, input }) => {
const code = newInviteCode();
await getDb().insert(invites).values({
kind: "code",
tokenHash: hashToken(code),
reusable: input.reusable ?? true,
maxUses: input.maxUses ?? 100,
groupId: input.groupId ?? null,
eventId: input.eventId ?? null,
groupRole: input.groupRole ?? "owner",
eventRole: input.eventRole ?? "owner",
grantUnlimitedEvents: input.grantUnlimitedEvents ?? false,
grantEventLimit: input.grantEventLimit ?? 1,
grantComplimentary: input.grantComplimentary ?? true,
createdByUserId: ctx.session.user.id,
expiresAt: input.expiresAt ?? null,
});
await writeAudit({
actorUserId: ctx.session.user.id,
action: "invite.code.create",
subjectType: "invite",
subjectId: "platform",
});
return { code };
}),
audit: platformPermissionProcedure(PLATFORM_PERMISSIONS.AUDIT_READ).query(
async () => {
return getDb()
.select({
id: auditEvents.id,
action: auditEvents.action,
subjectType: auditEvents.subjectType,
subjectId: auditEvents.subjectId,
metadata: auditEvents.metadata,
createdAt: auditEvents.createdAt,
groupId: auditEvents.groupId,
eventId: auditEvents.eventId,
actorUserId: auditEvents.actorUserId,
})
.from(auditEvents)
.orderBy(desc(auditEvents.createdAt))
.limit(200);
},
),
});
+92
View File
@@ -0,0 +1,92 @@
import { and, desc, eq } from "drizzle-orm";
import {
eventMemberships,
events,
getDb,
groupMemberships,
groups,
} from "@album/database";
import { GROUP_COOKIE, serializeCookie } from "@/server/cookies";
import { createTRPCRouter, publicProcedure, protectedProcedure } from "../trpc";
import { getPlatformRole } from "@/server/roles";
import { getDeploymentSettings } from "@/server/settings";
import { z } from "zod";
export const viewerRouter = createTRPCRouter({
me: publicProcedure.query(async ({ ctx }) => {
const settings = await getDeploymentSettings();
if (!ctx.session) {
return {
session: null,
platformRole: null,
groups: [] as {
id: string;
name: string;
slug: string;
role: "owner" | "member";
}[],
activeGroupId: ctx.activeGroupId,
openSignup: settings.openSignup,
eventCreatePolicy: settings.eventCreatePolicy,
};
}
const platformRole = await getPlatformRole(ctx.session.user.id);
const memberships = await getDb()
.select({
id: groups.id,
name: groups.name,
slug: groups.slug,
role: groupMemberships.role,
})
.from(groupMemberships)
.innerJoin(groups, eq(groupMemberships.groupId, groups.id))
.where(eq(groupMemberships.userId, ctx.session.user.id))
.orderBy(desc(groups.createdAt));
return {
session: ctx.session,
platformRole,
groups: memberships,
activeGroupId: ctx.activeGroupId,
openSignup: settings.openSignup,
eventCreatePolicy: settings.eventCreatePolicy,
};
}),
setActiveGroup: protectedProcedure
.input(z.object({ groupId: z.string().uuid().nullable() }))
.mutation(async ({ ctx, input }) => {
ctx.appendSetCookie(
serializeCookie(GROUP_COOKIE, input.groupId ?? "", {
maxAge: input.groupId ? 60 * 60 * 24 * 365 : 0,
}),
);
return { ok: true as const, groupId: input.groupId };
}),
eventAccess: protectedProcedure
.input(z.object({ eventId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const [membership] = await getDb()
.select()
.from(eventMemberships)
.where(
and(
eq(eventMemberships.eventId, input.eventId),
eq(eventMemberships.userId, ctx.session.user.id),
),
)
.limit(1);
const [event] = await getDb()
.select({ id: events.id, groupId: events.groupId })
.from(events)
.where(eq(events.id, input.eventId))
.limit(1);
return {
platformRole,
eventRole: membership?.role ?? null,
groupId: event?.groupId ?? null,
};
}),
});