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,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 };
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user