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:
@@ -0,0 +1,26 @@
|
||||
import { createTRPCRouter, publicProcedure } from "./trpc";
|
||||
import { eventRouter } from "./routers/event";
|
||||
import { guestRouter } from "./routers/guest";
|
||||
import { groupRouter } from "./routers/group";
|
||||
import { invitesRouter } from "./routers/invites";
|
||||
import { managerRouter } from "./routers/manager";
|
||||
import { photosRouter } from "./routers/photos";
|
||||
import { platformRouter } from "./routers/platform";
|
||||
import { viewerRouter } from "./routers/viewer";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
health: publicProcedure.query(() => ({
|
||||
status: "ok" as const,
|
||||
service: "album-trpc",
|
||||
})),
|
||||
viewer: viewerRouter,
|
||||
event: eventRouter,
|
||||
guest: guestRouter,
|
||||
photos: photosRouter,
|
||||
group: groupRouter,
|
||||
manager: managerRouter,
|
||||
platform: platformRouter,
|
||||
invites: invitesRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { events, getDb } from "@album/database";
|
||||
import type { EventRole, GroupRole, PlatformRole } from "@album/contracts";
|
||||
import { auth } from "@/server/auth";
|
||||
import { requestClientIdentifier } from "@/server/rate-limit";
|
||||
import {
|
||||
GROUP_COOKIE,
|
||||
guestCookieName,
|
||||
parseCookieHeader,
|
||||
} from "@/server/cookies";
|
||||
import {
|
||||
EVENT_PERMISSIONS,
|
||||
type EventPermission,
|
||||
type GroupPermission,
|
||||
type PlatformPermission,
|
||||
} from "@/server/permissions";
|
||||
import {
|
||||
getEventMembership,
|
||||
getGroupMembership,
|
||||
getPlatformRole,
|
||||
hasEventPermission,
|
||||
hasGroupPermission,
|
||||
hasPlatformPermission,
|
||||
resolveEventAccess,
|
||||
resolveGroupAccess,
|
||||
} from "@/server/roles";
|
||||
|
||||
export async function createTRPCContext(options: {
|
||||
headers: Headers;
|
||||
requestOrigin: string;
|
||||
}) {
|
||||
const session = await auth.api.getSession({ headers: options.headers });
|
||||
const cookies = parseCookieHeader(options.headers.get("cookie"));
|
||||
const setCookies: string[] = [];
|
||||
return {
|
||||
session,
|
||||
requestOrigin: options.requestOrigin,
|
||||
clientIdentifier: requestClientIdentifier(
|
||||
new Request(options.requestOrigin, { headers: options.headers }),
|
||||
),
|
||||
cookies,
|
||||
activeGroupId: cookies.get(GROUP_COOKIE) ?? null,
|
||||
guestTokenForEvent: (eventId: string) =>
|
||||
cookies.get(guestCookieName(eventId)) ?? null,
|
||||
appendSetCookie: (cookie: string) => {
|
||||
setCookies.push(cookie);
|
||||
},
|
||||
setCookies,
|
||||
};
|
||||
}
|
||||
|
||||
export type TrpcContext = Awaited<ReturnType<typeof createTRPCContext>>;
|
||||
|
||||
const t = initTRPC.context<TrpcContext>().create({
|
||||
transformer: superjson,
|
||||
});
|
||||
|
||||
export const createTRPCRouter = t.router;
|
||||
export const publicProcedure = t.procedure;
|
||||
|
||||
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
|
||||
if (!ctx.session) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
return next({ ctx: { ...ctx, session: ctx.session } });
|
||||
});
|
||||
|
||||
export const platformProcedure = protectedProcedure.use(async ({ ctx, next }) => {
|
||||
const platformRole = await getPlatformRole(ctx.session.user.id);
|
||||
if (!platformRole) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Platform access required" });
|
||||
}
|
||||
return next({ ctx: { ...ctx, platformRole } });
|
||||
});
|
||||
|
||||
export function platformPermissionProcedure(permission: PlatformPermission) {
|
||||
return platformProcedure.use(({ ctx, next }) => {
|
||||
if (!hasPlatformPermission(ctx.platformRole, permission)) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Missing platform permission",
|
||||
});
|
||||
}
|
||||
return next({ ctx });
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadGroupAccess(
|
||||
userId: string,
|
||||
groupId: string,
|
||||
platformRole: PlatformRole | null,
|
||||
) {
|
||||
const membership = await getGroupMembership(groupId, userId);
|
||||
const access = resolveGroupAccess({
|
||||
membershipRole: (membership?.role as GroupRole | undefined) ?? null,
|
||||
platformRole,
|
||||
});
|
||||
if (!access) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Not a member of this group" });
|
||||
}
|
||||
return { membership, access };
|
||||
}
|
||||
|
||||
export async function loadEventAccess(
|
||||
userId: string,
|
||||
eventId: string,
|
||||
platformRole: PlatformRole | null,
|
||||
) {
|
||||
const [event] = await getDb()
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq(events.id, eventId))
|
||||
.limit(1);
|
||||
if (!event) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
|
||||
}
|
||||
const membership = await getEventMembership(eventId, userId);
|
||||
const access = resolveEventAccess({
|
||||
membershipRole: (membership?.role as EventRole | undefined) ?? null,
|
||||
platformRole,
|
||||
});
|
||||
if (!access) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Not a member of this event" });
|
||||
}
|
||||
return { event, membership, access };
|
||||
}
|
||||
|
||||
export function requireEventPermission(
|
||||
permissions: EventPermission[],
|
||||
permission: EventPermission,
|
||||
) {
|
||||
if (!hasEventPermission(permissions, permission)) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Missing event permission",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function requireGroupPermission(
|
||||
permissions: GroupPermission[],
|
||||
permission: GroupPermission,
|
||||
) {
|
||||
if (!hasGroupPermission(permissions, permission)) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Missing group permission",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { EVENT_PERMISSIONS };
|
||||
@@ -0,0 +1,25 @@
|
||||
import { auditEvents, type Database } from "@album/database";
|
||||
import { getDb } from "@album/database";
|
||||
|
||||
export async function writeAudit(
|
||||
input: {
|
||||
groupId?: string | null;
|
||||
eventId?: string | null;
|
||||
actorUserId?: string | null;
|
||||
action: string;
|
||||
subjectType: string;
|
||||
subjectId: string;
|
||||
metadata?: Record<string, string | number | boolean | null>;
|
||||
},
|
||||
db: Database = getDb(),
|
||||
) {
|
||||
await db.insert(auditEvents).values({
|
||||
groupId: input.groupId ?? null,
|
||||
eventId: input.eventId ?? null,
|
||||
actorUserId: input.actorUserId ?? null,
|
||||
action: input.action,
|
||||
subjectType: input.subjectType,
|
||||
subjectId: input.subjectId,
|
||||
metadata: input.metadata ?? {},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { getDb } from "@album/database";
|
||||
import {
|
||||
sendAccountVerificationEmail,
|
||||
sendPasswordResetEmail,
|
||||
} from "@album/email";
|
||||
|
||||
const baseURL =
|
||||
process.env.BETTER_AUTH_URL ??
|
||||
process.env.NEXT_PUBLIC_APP_URL ??
|
||||
"http://localhost:3000";
|
||||
|
||||
export const auth = betterAuth({
|
||||
appName: "Vellum",
|
||||
baseURL,
|
||||
secret: process.env.BETTER_AUTH_SECRET,
|
||||
advanced: {
|
||||
cookiePrefix: "album",
|
||||
},
|
||||
database: drizzleAdapter(getDb(), {
|
||||
provider: "pg",
|
||||
}),
|
||||
user: {
|
||||
changeEmail: {
|
||||
enabled: true,
|
||||
},
|
||||
deleteUser: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
minPasswordLength: process.env.NODE_ENV === "production" ? 10 : 5,
|
||||
autoSignIn: true,
|
||||
resetPasswordTokenExpiresIn: 60 * 60,
|
||||
revokeSessionsOnPasswordReset: true,
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
await sendPasswordResetEmail({
|
||||
to: user.email,
|
||||
name: user.name,
|
||||
resetUrl: url,
|
||||
});
|
||||
},
|
||||
},
|
||||
emailVerification: {
|
||||
sendOnSignUp: true,
|
||||
autoSignInAfterVerification: true,
|
||||
sendVerificationEmail: async ({ user, url }) => {
|
||||
await sendAccountVerificationEmail({
|
||||
to: user.email,
|
||||
name: user.name,
|
||||
verificationUrl: url,
|
||||
});
|
||||
},
|
||||
},
|
||||
trustedOrigins: [baseURL],
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7,
|
||||
updateAge: 60 * 60 * 24,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
export const GROUP_COOKIE = "album_group_id";
|
||||
export const GUEST_COOKIE_PREFIX = "album_guest_";
|
||||
|
||||
export function parseCookieHeader(header: string | null) {
|
||||
const map = new Map<string, string>();
|
||||
if (!header) return map;
|
||||
for (const part of header.split(";")) {
|
||||
const index = part.indexOf("=");
|
||||
if (index === -1) continue;
|
||||
const name = part.slice(0, index).trim();
|
||||
const value = part.slice(index + 1).trim();
|
||||
if (name) map.set(name, decodeURIComponent(value));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function guestCookieName(eventId: string) {
|
||||
return `${GUEST_COOKIE_PREFIX}${eventId}`;
|
||||
}
|
||||
|
||||
export function serializeCookie(
|
||||
name: string,
|
||||
value: string,
|
||||
options: {
|
||||
httpOnly?: boolean;
|
||||
maxAge?: number;
|
||||
path?: string;
|
||||
sameSite?: "Lax" | "Strict" | "None";
|
||||
secure?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const parts = [
|
||||
`${name}=${encodeURIComponent(value)}`,
|
||||
`Path=${options.path ?? "/"}`,
|
||||
`SameSite=${options.sameSite ?? "Lax"}`,
|
||||
];
|
||||
if (options.httpOnly !== false) parts.push("HttpOnly");
|
||||
if (options.maxAge !== undefined) parts.push(`Max-Age=${options.maxAge}`);
|
||||
if (options.secure) parts.push("Secure");
|
||||
return parts.join("; ");
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import { entitlements, events, getDb, type Database } from "@album/database";
|
||||
|
||||
export function isEntitlementActive(
|
||||
row: { expiresAt: Date | null },
|
||||
now = new Date(),
|
||||
) {
|
||||
return !row.expiresAt || row.expiresAt > now;
|
||||
}
|
||||
|
||||
export async function resolveGroupQuota(
|
||||
groupId: string,
|
||||
db: Database = getDb(),
|
||||
) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(entitlements)
|
||||
.where(eq(entitlements.groupId, groupId));
|
||||
const active = rows.filter((row) => isEntitlementActive(row));
|
||||
const unlimited = active.some((row) => row.eventLimit === null);
|
||||
const complimentary = active.some((row) => row.complimentary);
|
||||
const numericLimits = active
|
||||
.map((row) => row.eventLimit)
|
||||
.filter((value): value is number => value !== null);
|
||||
const eventLimit = unlimited
|
||||
? null
|
||||
: numericLimits.length > 0
|
||||
? Math.max(...numericLimits)
|
||||
: 0;
|
||||
const [usedRow] = await db
|
||||
.select({ value: count() })
|
||||
.from(events)
|
||||
.where(eq(events.groupId, groupId));
|
||||
const used = Number(usedRow?.value ?? 0);
|
||||
return {
|
||||
unlimited,
|
||||
eventLimit,
|
||||
used,
|
||||
remaining: unlimited
|
||||
? null
|
||||
: Math.max(0, (eventLimit ?? 0) - used),
|
||||
complimentary,
|
||||
canCreate: unlimited || used < (eventLimit ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export async function grantEntitlement(
|
||||
input: {
|
||||
groupId: string;
|
||||
eventLimit: number | null;
|
||||
expiresAt?: Date | null;
|
||||
complimentary?: boolean;
|
||||
source: "signup_default" | "invite" | "code" | "platform";
|
||||
grantedByUserId?: string | null;
|
||||
},
|
||||
db: Database = getDb(),
|
||||
) {
|
||||
const [row] = await db
|
||||
.insert(entitlements)
|
||||
.values({
|
||||
groupId: input.groupId,
|
||||
eventLimit: input.eventLimit,
|
||||
expiresAt: input.expiresAt ?? null,
|
||||
complimentary: input.complimentary ?? false,
|
||||
source: input.source,
|
||||
grantedByUserId: input.grantedByUserId ?? null,
|
||||
})
|
||||
.returning();
|
||||
return row!;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
events,
|
||||
getDb,
|
||||
invites,
|
||||
user,
|
||||
type Database,
|
||||
} from "@album/database";
|
||||
import { writeAudit } from "@/server/audit";
|
||||
import { grantEntitlement } from "@/server/entitlements";
|
||||
import {
|
||||
createGroupForUser,
|
||||
ensureEventMembership,
|
||||
ensureGroupMembership,
|
||||
} from "@/server/membership";
|
||||
import { hashToken } from "@/server/tokens";
|
||||
|
||||
export async function findInviteByToken(token: string, db: Database = getDb()) {
|
||||
const tokenHash = hashToken(token);
|
||||
const [invite] = await db
|
||||
.select()
|
||||
.from(invites)
|
||||
.where(eq(invites.tokenHash, tokenHash))
|
||||
.limit(1);
|
||||
return invite ?? null;
|
||||
}
|
||||
|
||||
export function assertInviteRedeemable(invite: typeof invites.$inferSelect) {
|
||||
if (invite.status === "revoked") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invite was revoked" });
|
||||
}
|
||||
if (invite.status === "expired") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invite expired" });
|
||||
}
|
||||
if (invite.expiresAt && invite.expiresAt < new Date()) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invite expired" });
|
||||
}
|
||||
if (!invite.reusable && invite.status === "accepted") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invite already used",
|
||||
});
|
||||
}
|
||||
if (invite.usedCount >= invite.maxUses) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invite has no remaining uses",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function redeemInviteForUser(
|
||||
input: { token: string; userId: string },
|
||||
db: Database = getDb(),
|
||||
) {
|
||||
const invite = await findInviteByToken(input.token, db);
|
||||
if (!invite) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" });
|
||||
}
|
||||
assertInviteRedeemable(invite);
|
||||
|
||||
const [actor] = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(eq(user.id, input.userId))
|
||||
.limit(1);
|
||||
if (!actor) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
let groupId = invite.groupId;
|
||||
if (
|
||||
!groupId &&
|
||||
(invite.grantUnlimitedEvents || invite.grantEventLimit || invite.grantComplimentary)
|
||||
) {
|
||||
const group = await createGroupForUser(
|
||||
{ userId: input.userId, name: `${actor.name}'s group` },
|
||||
db,
|
||||
);
|
||||
groupId = group.id;
|
||||
}
|
||||
|
||||
if (groupId && invite.groupRole) {
|
||||
await ensureGroupMembership(
|
||||
{ groupId, userId: input.userId, role: invite.groupRole },
|
||||
db,
|
||||
);
|
||||
} else if (groupId) {
|
||||
await ensureGroupMembership(
|
||||
{ groupId, userId: input.userId, role: "member" },
|
||||
db,
|
||||
);
|
||||
}
|
||||
|
||||
if (invite.eventId && invite.eventRole && groupId) {
|
||||
await ensureEventMembership(
|
||||
{
|
||||
eventId: invite.eventId,
|
||||
userId: input.userId,
|
||||
role: invite.eventRole,
|
||||
groupId,
|
||||
},
|
||||
db,
|
||||
);
|
||||
} else if (invite.eventId && invite.eventRole) {
|
||||
const [event] = await db
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq(events.id, invite.eventId))
|
||||
.limit(1);
|
||||
if (event) {
|
||||
await ensureEventMembership(
|
||||
{
|
||||
eventId: event.id,
|
||||
userId: input.userId,
|
||||
role: invite.eventRole,
|
||||
groupId: event.groupId,
|
||||
},
|
||||
db,
|
||||
);
|
||||
groupId = event.groupId;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
groupId &&
|
||||
(invite.grantUnlimitedEvents ||
|
||||
invite.grantEventLimit !== null ||
|
||||
invite.grantComplimentary)
|
||||
) {
|
||||
await grantEntitlement(
|
||||
{
|
||||
groupId,
|
||||
eventLimit: invite.grantUnlimitedEvents ? null : invite.grantEventLimit,
|
||||
complimentary: invite.grantComplimentary,
|
||||
source: invite.kind === "code" ? "code" : "invite",
|
||||
grantedByUserId: invite.createdByUserId,
|
||||
},
|
||||
db,
|
||||
);
|
||||
}
|
||||
|
||||
const usedCount = invite.usedCount + 1;
|
||||
const exhausted = usedCount >= invite.maxUses;
|
||||
await db
|
||||
.update(invites)
|
||||
.set({
|
||||
usedCount,
|
||||
status: exhausted && !invite.reusable ? "accepted" : invite.status,
|
||||
acceptedByUserId: input.userId,
|
||||
acceptedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invites.id, invite.id));
|
||||
|
||||
await writeAudit(
|
||||
{
|
||||
groupId,
|
||||
eventId: invite.eventId,
|
||||
actorUserId: input.userId,
|
||||
action: "invite.redeem",
|
||||
subjectType: "invite",
|
||||
subjectId: invite.id,
|
||||
metadata: { kind: invite.kind },
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
return { groupId, eventId: invite.eventId, inviteId: invite.id };
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { and, count, eq } from "drizzle-orm";
|
||||
import {
|
||||
eventMemberships,
|
||||
events,
|
||||
getDb,
|
||||
groupMemberships,
|
||||
groups,
|
||||
type Database,
|
||||
} from "@album/database";
|
||||
import type { EventRole, GroupRole } from "@album/contracts";
|
||||
import { slugify } from "@/lib/slug";
|
||||
|
||||
export async function uniqueGroupSlug(desired: string, db: Database = getDb()) {
|
||||
let candidate = desired.slice(0, 60) || "group";
|
||||
let suffix = 2;
|
||||
while (true) {
|
||||
const [existing] = await db
|
||||
.select({ id: groups.id })
|
||||
.from(groups)
|
||||
.where(eq(groups.slug, candidate))
|
||||
.limit(1);
|
||||
if (!existing) return candidate;
|
||||
candidate = `${desired.slice(0, 50)}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function uniqueEventSlug(
|
||||
desired: string,
|
||||
excludeEventId?: string,
|
||||
db: Database = getDb(),
|
||||
) {
|
||||
let candidate = desired.slice(0, 60) || "event";
|
||||
let suffix = 2;
|
||||
while (true) {
|
||||
const [existing] = await db
|
||||
.select({ id: events.id })
|
||||
.from(events)
|
||||
.where(eq(events.slug, candidate))
|
||||
.limit(1);
|
||||
if (!existing || existing.id === excludeEventId) return candidate;
|
||||
candidate = `${desired.slice(0, 50)}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createGroupForUser(
|
||||
input: { userId: string; name: string },
|
||||
db: Database = getDb(),
|
||||
) {
|
||||
const slug = await uniqueGroupSlug(slugify(input.name), db);
|
||||
const [group] = await db
|
||||
.insert(groups)
|
||||
.values({
|
||||
name: input.name,
|
||||
slug,
|
||||
createdByUserId: input.userId,
|
||||
})
|
||||
.returning();
|
||||
if (!group) throw new Error("Could not create group");
|
||||
await db.insert(groupMemberships).values({
|
||||
groupId: group.id,
|
||||
userId: input.userId,
|
||||
role: "owner",
|
||||
});
|
||||
return group;
|
||||
}
|
||||
|
||||
export async function ensureGroupMembership(
|
||||
input: { groupId: string; userId: string; role: GroupRole },
|
||||
db: Database = getDb(),
|
||||
) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(groupMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(groupMemberships.groupId, input.groupId),
|
||||
eq(groupMemberships.userId, input.userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (existing) return existing;
|
||||
const [created] = await db
|
||||
.insert(groupMemberships)
|
||||
.values(input)
|
||||
.returning();
|
||||
return created!;
|
||||
}
|
||||
|
||||
export async function ensureEventMembership(
|
||||
input: { eventId: string; userId: string; role: EventRole; groupId: string },
|
||||
db: Database = getDb(),
|
||||
) {
|
||||
await ensureGroupMembership(
|
||||
{ groupId: input.groupId, userId: input.userId, role: "member" },
|
||||
db,
|
||||
);
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(eventMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(eventMemberships.eventId, input.eventId),
|
||||
eq(eventMemberships.userId, input.userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (existing) {
|
||||
if (existing.role === input.role) return existing;
|
||||
const [updated] = await db
|
||||
.update(eventMemberships)
|
||||
.set({ role: input.role, updatedAt: new Date() })
|
||||
.where(eq(eventMemberships.id, existing.id))
|
||||
.returning();
|
||||
return updated!;
|
||||
}
|
||||
const [created] = await db
|
||||
.insert(eventMemberships)
|
||||
.values({
|
||||
eventId: input.eventId,
|
||||
userId: input.userId,
|
||||
role: input.role,
|
||||
})
|
||||
.returning();
|
||||
return created!;
|
||||
}
|
||||
|
||||
export async function countEventOwners(eventId: string, db: Database = getDb()) {
|
||||
const [row] = await db
|
||||
.select({ value: count() })
|
||||
.from(eventMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(eventMemberships.eventId, eventId),
|
||||
eq(eventMemberships.role, "owner"),
|
||||
),
|
||||
);
|
||||
return Number(row?.value ?? 0);
|
||||
}
|
||||
|
||||
export async function countGroupOwners(groupId: string, db: Database = getDb()) {
|
||||
const [row] = await db
|
||||
.select({ value: count() })
|
||||
.from(groupMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(groupMemberships.groupId, groupId),
|
||||
eq(groupMemberships.role, "owner"),
|
||||
),
|
||||
);
|
||||
return Number(row?.value ?? 0);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
EVENT_ROLES,
|
||||
PLATFORM_ROLES,
|
||||
eventPermissionsForRole,
|
||||
platformEventPermissions,
|
||||
} from "../server/permissions";
|
||||
import { EVENT_PERMISSIONS } from "../server/permissions";
|
||||
|
||||
describe("event roles", () => {
|
||||
test("owners can delete events and grant owner", () => {
|
||||
const permissions = eventPermissionsForRole("owner");
|
||||
expect(permissions).toContain(EVENT_PERMISSIONS.EVENT_DELETE);
|
||||
expect(permissions).toContain(EVENT_PERMISSIONS.PEOPLE_GRANT_OWNER);
|
||||
});
|
||||
|
||||
test("managers cannot delete events or grant owner", () => {
|
||||
const permissions = eventPermissionsForRole("manager");
|
||||
expect(permissions).not.toContain(EVENT_PERMISSIONS.EVENT_DELETE);
|
||||
expect(permissions).not.toContain(EVENT_PERMISSIONS.PEOPLE_GRANT_OWNER);
|
||||
expect(permissions).toContain(EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ);
|
||||
});
|
||||
|
||||
test("moderators cannot see private photos", () => {
|
||||
const permissions = eventPermissionsForRole("moderator");
|
||||
expect(permissions).not.toContain(EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ);
|
||||
expect(permissions).toContain(EVENT_PERMISSIONS.PHOTOS_MODERATE);
|
||||
});
|
||||
|
||||
test("viewers are read-only", () => {
|
||||
const permissions = eventPermissionsForRole("viewer");
|
||||
expect(permissions).not.toContain(EVENT_PERMISSIONS.PHOTOS_MODERATE);
|
||||
expect(permissions).toContain(EVENT_PERMISSIONS.PHOTOS_READ);
|
||||
});
|
||||
});
|
||||
|
||||
describe("platform roles", () => {
|
||||
test("admin cannot grant super admin", () => {
|
||||
expect(PLATFORM_ROLES.admin.permissions).not.toContain(
|
||||
"platform.roles.grant_super_admin",
|
||||
);
|
||||
});
|
||||
|
||||
test("platform viewer gets read-only event access", () => {
|
||||
const permissions = platformEventPermissions("viewer");
|
||||
expect(permissions).toContain(EVENT_PERMISSIONS.PHOTOS_READ);
|
||||
expect(permissions).not.toContain(EVENT_PERMISSIONS.PHOTOS_MODERATE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("role catalogs exist", () => {
|
||||
test("four event roles", () => {
|
||||
expect(Object.keys(EVENT_ROLES)).toEqual([
|
||||
"owner",
|
||||
"manager",
|
||||
"moderator",
|
||||
"viewer",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { EventRole, PhotoVisibility, PlatformRole } from "@album/contracts";
|
||||
|
||||
export const EVENT_PERMISSIONS = {
|
||||
OVERVIEW_READ: "overview.read",
|
||||
PHOTOS_READ: "photos.read",
|
||||
PHOTOS_MODERATE: "photos.moderate",
|
||||
PHOTOS_PRIVATE_READ: "photos.private.read",
|
||||
PHOTOS_DELETE: "photos.delete",
|
||||
SETTINGS_READ: "settings.read",
|
||||
SETTINGS_MANAGE: "settings.manage",
|
||||
PEOPLE_READ: "people.read",
|
||||
PEOPLE_MANAGE: "people.manage",
|
||||
PEOPLE_GRANT_OWNER: "people.grant_owner",
|
||||
EVENT_DELETE: "event.delete",
|
||||
NOTES_READ: "notes.read",
|
||||
GALLERY_RELEASE: "gallery.release",
|
||||
AUDIT_READ: "audit.read",
|
||||
} as const;
|
||||
|
||||
export type EventPermission =
|
||||
(typeof EVENT_PERMISSIONS)[keyof typeof EVENT_PERMISSIONS];
|
||||
|
||||
export const GROUP_PERMISSIONS = {
|
||||
GROUP_READ: "group.read",
|
||||
GROUP_MANAGE: "group.manage",
|
||||
EVENTS_CREATE: "events.create",
|
||||
PEOPLE_MANAGE: "group.people.manage",
|
||||
} as const;
|
||||
|
||||
export type GroupPermission =
|
||||
(typeof GROUP_PERMISSIONS)[keyof typeof GROUP_PERMISSIONS];
|
||||
|
||||
export const PLATFORM_PERMISSIONS = {
|
||||
SETTINGS_MANAGE: "platform.settings.manage",
|
||||
USERS_MANAGE: "platform.users.manage",
|
||||
GRANT_SUPER_ADMIN: "platform.roles.grant_super_admin",
|
||||
GRANT_ADMIN: "platform.roles.grant_admin",
|
||||
EVENTS_READ: "platform.events.read",
|
||||
EVENTS_MODERATE: "platform.events.moderate",
|
||||
ENTITLEMENTS_MANAGE: "platform.entitlements.manage",
|
||||
AUDIT_READ: "platform.audit.read",
|
||||
GROUPS_ACCESS: "platform.groups.access",
|
||||
} as const;
|
||||
|
||||
export type PlatformPermission =
|
||||
(typeof PLATFORM_PERMISSIONS)[keyof typeof PLATFORM_PERMISSIONS];
|
||||
|
||||
const allEventPermissions = Object.values(EVENT_PERMISSIONS);
|
||||
|
||||
export const EVENT_ROLES = {
|
||||
owner: {
|
||||
label: "Owner",
|
||||
description: "Full event access, including people and deletion.",
|
||||
permissions: allEventPermissions,
|
||||
},
|
||||
manager: {
|
||||
label: "Manager",
|
||||
description: "Run the event without transferring ownership or deleting it.",
|
||||
permissions: allEventPermissions.filter(
|
||||
(permission) =>
|
||||
permission !== EVENT_PERMISSIONS.EVENT_DELETE &&
|
||||
permission !== EVENT_PERMISSIONS.PEOPLE_GRANT_OWNER,
|
||||
),
|
||||
},
|
||||
moderator: {
|
||||
label: "Moderator",
|
||||
description: "Review photos and notes. Cannot change settings or people.",
|
||||
permissions: [
|
||||
EVENT_PERMISSIONS.OVERVIEW_READ,
|
||||
EVENT_PERMISSIONS.PHOTOS_READ,
|
||||
EVENT_PERMISSIONS.PHOTOS_MODERATE,
|
||||
EVENT_PERMISSIONS.PHOTOS_DELETE,
|
||||
EVENT_PERMISSIONS.NOTES_READ,
|
||||
],
|
||||
},
|
||||
viewer: {
|
||||
label: "Viewer",
|
||||
description: "See event photos, including hidden ones.",
|
||||
permissions: [
|
||||
EVENT_PERMISSIONS.OVERVIEW_READ,
|
||||
EVENT_PERMISSIONS.PHOTOS_READ,
|
||||
EVENT_PERMISSIONS.NOTES_READ,
|
||||
],
|
||||
},
|
||||
} as const satisfies Record<
|
||||
EventRole,
|
||||
{
|
||||
label: string;
|
||||
description: string;
|
||||
permissions: readonly EventPermission[];
|
||||
}
|
||||
>;
|
||||
|
||||
export const GROUP_ROLES = {
|
||||
owner: {
|
||||
label: "Owner",
|
||||
permissions: Object.values(GROUP_PERMISSIONS),
|
||||
},
|
||||
member: {
|
||||
label: "Member",
|
||||
permissions: [GROUP_PERMISSIONS.GROUP_READ],
|
||||
},
|
||||
} as const;
|
||||
|
||||
const allPlatformPermissions = Object.values(PLATFORM_PERMISSIONS);
|
||||
|
||||
export const PLATFORM_ROLES = {
|
||||
super_admin: {
|
||||
label: "Super admin",
|
||||
permissions: allPlatformPermissions,
|
||||
},
|
||||
admin: {
|
||||
label: "Admin",
|
||||
permissions: allPlatformPermissions.filter(
|
||||
(permission) => permission !== PLATFORM_PERMISSIONS.GRANT_SUPER_ADMIN,
|
||||
),
|
||||
},
|
||||
moderator: {
|
||||
label: "Platform moderator",
|
||||
permissions: [
|
||||
PLATFORM_PERMISSIONS.EVENTS_READ,
|
||||
PLATFORM_PERMISSIONS.EVENTS_MODERATE,
|
||||
PLATFORM_PERMISSIONS.AUDIT_READ,
|
||||
PLATFORM_PERMISSIONS.GROUPS_ACCESS,
|
||||
],
|
||||
},
|
||||
viewer: {
|
||||
label: "Platform viewer",
|
||||
permissions: [
|
||||
PLATFORM_PERMISSIONS.EVENTS_READ,
|
||||
PLATFORM_PERMISSIONS.AUDIT_READ,
|
||||
PLATFORM_PERMISSIONS.GROUPS_ACCESS,
|
||||
],
|
||||
},
|
||||
} as const satisfies Record<
|
||||
PlatformRole,
|
||||
{
|
||||
label: string;
|
||||
permissions: readonly PlatformPermission[];
|
||||
}
|
||||
>;
|
||||
|
||||
export function eventPermissionsForRole(role: EventRole): EventPermission[] {
|
||||
return [...EVENT_ROLES[role].permissions];
|
||||
}
|
||||
|
||||
export function groupPermissionsForRole(
|
||||
role: "owner" | "member",
|
||||
): GroupPermission[] {
|
||||
return [...GROUP_ROLES[role].permissions];
|
||||
}
|
||||
|
||||
export function platformPermissionsForRole(
|
||||
role: PlatformRole,
|
||||
): PlatformPermission[] {
|
||||
return [...PLATFORM_ROLES[role].permissions];
|
||||
}
|
||||
|
||||
export function platformEventPermissions(
|
||||
role: PlatformRole,
|
||||
): EventPermission[] {
|
||||
if (role === "super_admin" || role === "admin") return allEventPermissions;
|
||||
if (role === "moderator") {
|
||||
return [
|
||||
EVENT_PERMISSIONS.OVERVIEW_READ,
|
||||
EVENT_PERMISSIONS.PHOTOS_READ,
|
||||
EVENT_PERMISSIONS.PHOTOS_MODERATE,
|
||||
EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ,
|
||||
EVENT_PERMISSIONS.PHOTOS_DELETE,
|
||||
EVENT_PERMISSIONS.NOTES_READ,
|
||||
EVENT_PERMISSIONS.AUDIT_READ,
|
||||
];
|
||||
}
|
||||
return [
|
||||
EVENT_PERMISSIONS.OVERVIEW_READ,
|
||||
EVENT_PERMISSIONS.PHOTOS_READ,
|
||||
EVENT_PERMISSIONS.NOTES_READ,
|
||||
EVENT_PERMISSIONS.AUDIT_READ,
|
||||
EVENT_PERMISSIONS.SETTINGS_READ,
|
||||
EVENT_PERMISSIONS.PEOPLE_READ,
|
||||
];
|
||||
}
|
||||
|
||||
export function platformGroupPermissions(
|
||||
role: PlatformRole,
|
||||
): GroupPermission[] {
|
||||
if (role === "super_admin" || role === "admin") {
|
||||
return Object.values(GROUP_PERMISSIONS);
|
||||
}
|
||||
if (role === "moderator" || role === "viewer") {
|
||||
return [GROUP_PERMISSIONS.GROUP_READ];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export const PUBLIC_VISIBILITIES: PhotoVisibility[] = ["public"];
|
||||
export const MEMBER_VISIBILITIES: PhotoVisibility[] = [
|
||||
"pending",
|
||||
"public",
|
||||
"hidden",
|
||||
"rejected",
|
||||
];
|
||||
export const PRIVATE_VISIBILITIES: PhotoVisibility[] = [
|
||||
"pending",
|
||||
"public",
|
||||
"hidden",
|
||||
"private",
|
||||
"rejected",
|
||||
];
|
||||
@@ -0,0 +1,60 @@
|
||||
const WILDCARD_HOSTNAMES = new Set(["0.0.0.0", "::", "[::]"]);
|
||||
const LOCAL_HOSTNAMES = new Set([...WILDCARD_HOSTNAMES, "localhost"]);
|
||||
|
||||
function isWildcardHostname(hostname: string) {
|
||||
return WILDCARD_HOSTNAMES.has(hostname);
|
||||
}
|
||||
|
||||
function isLocalHostname(hostname: string) {
|
||||
return (
|
||||
LOCAL_HOSTNAMES.has(hostname) ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname.startsWith("127.")
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePublicAppOrigin(input: {
|
||||
configuredUrl?: string;
|
||||
authUrl?: string;
|
||||
requestUrl?: string;
|
||||
production: boolean;
|
||||
}) {
|
||||
const configured = input.configuredUrl?.trim() || input.authUrl?.trim();
|
||||
if (!configured) {
|
||||
if (input.production) {
|
||||
throw new Error("NEXT_PUBLIC_APP_URL is required in production");
|
||||
}
|
||||
if (input.requestUrl) {
|
||||
const requestOrigin = new URL(input.requestUrl);
|
||||
if (!isWildcardHostname(requestOrigin.hostname)) {
|
||||
return requestOrigin.origin;
|
||||
}
|
||||
}
|
||||
return "http://localhost:3000";
|
||||
}
|
||||
|
||||
const url = new URL(configured);
|
||||
if (isWildcardHostname(url.hostname)) {
|
||||
throw new Error(
|
||||
`NEXT_PUBLIC_APP_URL resolved to a wildcard bind address (${url.hostname})`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
input.production &&
|
||||
(url.protocol !== "https:" || isLocalHostname(url.hostname))
|
||||
) {
|
||||
throw new Error(
|
||||
"NEXT_PUBLIC_APP_URL must be a public HTTPS origin in production",
|
||||
);
|
||||
}
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
export function publicAppOrigin(requestUrl?: string) {
|
||||
return resolvePublicAppOrigin({
|
||||
configuredUrl: process.env.NEXT_PUBLIC_APP_URL,
|
||||
authUrl: process.env.BETTER_AUTH_URL,
|
||||
requestUrl,
|
||||
production: process.env.NODE_ENV === "production",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { getDb } from "@album/database";
|
||||
|
||||
function rateLimitSecret() {
|
||||
const secret =
|
||||
process.env.RATE_LIMIT_SECRET ?? process.env.BETTER_AUTH_SECRET;
|
||||
if (!secret && process.env.NODE_ENV === "production") {
|
||||
throw new Error("RATE_LIMIT_SECRET is required in production");
|
||||
}
|
||||
return secret ?? "album-development-rate-limit";
|
||||
}
|
||||
|
||||
export function requestClientIdentifier(request: Request) {
|
||||
return (
|
||||
request.headers.get("x-real-ip") ??
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
"unknown"
|
||||
);
|
||||
}
|
||||
|
||||
export async function consumeRateLimit(input: {
|
||||
namespace: string;
|
||||
identifier: string;
|
||||
limit: number;
|
||||
windowMs: number;
|
||||
}) {
|
||||
const key = createHmac("sha256", rateLimitSecret())
|
||||
.update(`${input.namespace}:${input.identifier}`)
|
||||
.digest("hex");
|
||||
const resetAt = new Date(Date.now() + input.windowMs);
|
||||
const rows = await getDb().execute<{
|
||||
count: number;
|
||||
reset_at: Date;
|
||||
}>(sql`
|
||||
INSERT INTO rate_limit_buckets ("key", "count", "reset_at")
|
||||
VALUES (${key}, 1, ${resetAt.toISOString()}::timestamptz)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"count" = CASE
|
||||
WHEN rate_limit_buckets."reset_at" <= now() THEN 1
|
||||
ELSE rate_limit_buckets."count" + 1
|
||||
END,
|
||||
"reset_at" = CASE
|
||||
WHEN rate_limit_buckets."reset_at" <= now() THEN EXCLUDED."reset_at"
|
||||
ELSE rate_limit_buckets."reset_at"
|
||||
END,
|
||||
"updated_at" = now()
|
||||
RETURNING "count", "reset_at"
|
||||
`);
|
||||
const row = rows[0];
|
||||
if (!row) throw new Error("Could not evaluate rate limit");
|
||||
return {
|
||||
allowed: Number(row.count) <= input.limit,
|
||||
retryAfterSeconds: Math.max(
|
||||
Math.ceil((new Date(row.reset_at).getTime() - Date.now()) / 1_000),
|
||||
1,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
eventMemberships,
|
||||
getDb,
|
||||
groupMemberships,
|
||||
platformAdministrators,
|
||||
type Database,
|
||||
} from "@album/database";
|
||||
import type { EventRole, GroupRole, PlatformRole } from "@album/contracts";
|
||||
import {
|
||||
EVENT_PERMISSIONS,
|
||||
type EventPermission,
|
||||
type GroupPermission,
|
||||
type PlatformPermission,
|
||||
eventPermissionsForRole,
|
||||
groupPermissionsForRole,
|
||||
platformEventPermissions,
|
||||
platformGroupPermissions,
|
||||
platformPermissionsForRole,
|
||||
} from "./permissions";
|
||||
|
||||
export async function getPlatformRole(
|
||||
userId: string,
|
||||
db: Database = getDb(),
|
||||
): Promise<PlatformRole | null> {
|
||||
const [row] = await db
|
||||
.select({ role: platformAdministrators.role })
|
||||
.from(platformAdministrators)
|
||||
.where(eq(platformAdministrators.userId, userId))
|
||||
.limit(1);
|
||||
return row?.role ?? null;
|
||||
}
|
||||
|
||||
export async function getGroupMembership(
|
||||
groupId: string,
|
||||
userId: string,
|
||||
db: Database = getDb(),
|
||||
) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(groupMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(groupMemberships.groupId, groupId),
|
||||
eq(groupMemberships.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function getEventMembership(
|
||||
eventId: string,
|
||||
userId: string,
|
||||
db: Database = getDb(),
|
||||
) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(eventMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(eventMemberships.eventId, eventId),
|
||||
eq(eventMemberships.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export function resolveGroupAccess(input: {
|
||||
membershipRole: GroupRole | null;
|
||||
platformRole: PlatformRole | null;
|
||||
}): { role: GroupRole | "platform"; permissions: GroupPermission[] } | null {
|
||||
if (input.platformRole) {
|
||||
return {
|
||||
role: "platform",
|
||||
permissions: platformGroupPermissions(input.platformRole),
|
||||
};
|
||||
}
|
||||
if (!input.membershipRole) return null;
|
||||
return {
|
||||
role: input.membershipRole,
|
||||
permissions: groupPermissionsForRole(input.membershipRole),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveEventAccess(input: {
|
||||
membershipRole: EventRole | null;
|
||||
platformRole: PlatformRole | null;
|
||||
}): { role: EventRole | "platform"; permissions: EventPermission[] } | null {
|
||||
if (input.platformRole) {
|
||||
return {
|
||||
role: "platform",
|
||||
permissions: platformEventPermissions(input.platformRole),
|
||||
};
|
||||
}
|
||||
if (!input.membershipRole) return null;
|
||||
return {
|
||||
role: input.membershipRole,
|
||||
permissions: eventPermissionsForRole(input.membershipRole),
|
||||
};
|
||||
}
|
||||
|
||||
export function hasEventPermission(
|
||||
permissions: EventPermission[],
|
||||
permission: EventPermission,
|
||||
) {
|
||||
return permissions.includes(permission);
|
||||
}
|
||||
|
||||
export function hasGroupPermission(
|
||||
permissions: GroupPermission[],
|
||||
permission: GroupPermission,
|
||||
) {
|
||||
return permissions.includes(permission);
|
||||
}
|
||||
|
||||
export function hasPlatformPermission(
|
||||
role: PlatformRole | null,
|
||||
permission: PlatformPermission,
|
||||
) {
|
||||
if (!role) return false;
|
||||
return platformPermissionsForRole(role).includes(permission);
|
||||
}
|
||||
|
||||
export { EVENT_PERMISSIONS };
|
||||
@@ -0,0 +1,91 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { hashPassword } from "better-auth/crypto";
|
||||
import { account, closeDb, getDb, user } from "@album/database";
|
||||
import { auth } from "./auth";
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
throw new Error("The example credential seed must not run in production");
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
async function seedCredentialUser(input: {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}) {
|
||||
let [seededUser] = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(eq(user.email, input.email))
|
||||
.limit(1);
|
||||
if (!seededUser) {
|
||||
const created = await auth.api.signUpEmail({
|
||||
body: {
|
||||
name: input.name,
|
||||
email: input.email,
|
||||
password: `${input.password}-temporary`,
|
||||
},
|
||||
});
|
||||
[seededUser] = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(eq(user.id, created.user.id))
|
||||
.limit(1);
|
||||
}
|
||||
if (!seededUser) {
|
||||
throw new Error(`Could not seed ${input.email}`);
|
||||
}
|
||||
const password = await hashPassword(input.password);
|
||||
const [credential] = await db
|
||||
.update(account)
|
||||
.set({ password, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(account.userId, seededUser.id),
|
||||
eq(account.providerId, "credential"),
|
||||
),
|
||||
)
|
||||
.returning({ id: account.id });
|
||||
if (!credential) {
|
||||
throw new Error(`Credential account is missing for ${input.email}`);
|
||||
}
|
||||
const [updated] = await db
|
||||
.update(user)
|
||||
.set({
|
||||
name: input.name,
|
||||
emailVerified: true,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(user.id, seededUser.id))
|
||||
.returning();
|
||||
return updated!;
|
||||
}
|
||||
|
||||
await seedCredentialUser({
|
||||
name: "Platform Admin",
|
||||
email: "admin@example.com",
|
||||
password: "admin",
|
||||
});
|
||||
await seedCredentialUser({
|
||||
name: "Maya Host",
|
||||
email: "host@example.com",
|
||||
password: "host",
|
||||
});
|
||||
await seedCredentialUser({
|
||||
name: "Jonah Partner",
|
||||
email: "partner@example.com",
|
||||
password: "host",
|
||||
});
|
||||
await seedCredentialUser({
|
||||
name: "Aunt Manager",
|
||||
email: "manager@example.com",
|
||||
password: "host",
|
||||
});
|
||||
|
||||
console.info("Example credentials are ready:");
|
||||
console.info(" Admin: admin@example.com / admin");
|
||||
console.info(" Owner: host@example.com / host");
|
||||
console.info(" Partner: partner@example.com / host");
|
||||
console.info(" Manager: manager@example.com / host");
|
||||
await closeDb();
|
||||
@@ -0,0 +1,25 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { deploymentSettings, getDb } from "@album/database";
|
||||
|
||||
export async function getDeploymentSettings() {
|
||||
const db = getDb();
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(deploymentSettings)
|
||||
.where(eq(deploymentSettings.id, "default"))
|
||||
.limit(1);
|
||||
if (existing) return existing;
|
||||
const [created] = await db
|
||||
.insert(deploymentSettings)
|
||||
.values({ id: "default" })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
if (created) return created;
|
||||
const [again] = await db
|
||||
.select()
|
||||
.from(deploymentSettings)
|
||||
.where(eq(deploymentSettings.id, "default"))
|
||||
.limit(1);
|
||||
if (!again) throw new Error("deployment_settings row is missing");
|
||||
return again;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
export function hashToken(token: string) {
|
||||
return createHash("sha256").update(token.trim().toLowerCase()).digest("hex");
|
||||
}
|
||||
|
||||
export function newToken(bytes = 24) {
|
||||
return randomBytes(bytes).toString("base64url");
|
||||
}
|
||||
|
||||
export function newInviteCode() {
|
||||
return `VELLUM-${randomBytes(5).toString("hex").toUpperCase()}`;
|
||||
}
|
||||
Reference in New Issue
Block a user