Add permission-scoped MCP, readiness checks, and management UI improvements

This commit is contained in:
2026-09-11 18:12:48 -04:00
parent 32e56b1c34
commit 815640d919
40 changed files with 845 additions and 57 deletions
+39 -21
View File
@@ -81,6 +81,18 @@ async function signedPhotoUrls(photo: {
}
export const managerRouter = createTRPCRouter({
stats: protectedProcedure.input(z.object({ eventId: z.string().uuid() })).query(async ({ ctx, input }) => {
const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions, EVENT_PERMISSIONS.OVERVIEW_READ);
const rows = await getDb().execute(sql`select
(select count(*)::int from photos where event_id = ${input.eventId}) as photos,
(select count(*)::int from photos where event_id = ${input.eventId} and visibility = 'public' and processing_status = 'ready') as approved,
(select count(*)::int from photos where event_id = ${input.eventId} and visibility = 'pending') as awaiting_review,
(select count(*)::int from photos where event_id = ${input.eventId} and processing_status = 'failed') as failed,
(select count(*)::int from guests where event_id = ${input.eventId}) as guests,
(select count(*)::int from guests where event_id = ${input.eventId} and note is not null and note <> '') as notes`);
return rows[0];
}),
requestExport: protectedProcedure.input(exportPhotosInputSchema).mutation(async ({ctx,input}) => {
const {access} = await loadEventAccess(ctx.session.user.id,input.eventId,await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions,EVENT_PERMISSIONS.SETTINGS_MANAGE);
@@ -511,7 +523,7 @@ export const managerRouter = createTRPCRouter({
const [submission] = await getDb()
.select()
.from(submissions)
.where(eq(submissions.id, input.submissionId))
.where(and(eq(submissions.id, input.submissionId), eq(submissions.eventId, input.eventId)))
.limit(1);
if (!submission) throw new TRPCError({ code: "NOT_FOUND" });
const platformRole = await getPlatformRole(ctx.session.user.id);
@@ -521,28 +533,34 @@ export const managerRouter = createTRPCRouter({
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));
if (input.visibility === "private" && !access.permissions.includes(EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ)) {
throw new TRPCError({ code: "FORBIDDEN" });
}
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 getDb().transaction(async (tx) => {
const rows = await tx
.select()
.from(photos)
.where(and(eq(photos.submissionId, submission.id), eq(photos.eventId, event.id)))
.for("update");
const eligible = rows.filter(photo =>
(photo.visibility !== "private" || access.permissions.includes(EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ)) &&
canTransitionVisibility(photo.visibility, input.visibility));
if (eligible.length) {
await tx.update(photos)
.set({ visibility: input.visibility, updatedAt: new Date() })
.where(and(eq(photos.eventId, event.id), eq(photos.submissionId, submission.id), inArray(photos.id, eligible.map(photo => photo.id))));
}
await tx.insert(auditEvents).values({
groupId: event.groupId,
eventId: event.id,
actorUserId: ctx.session.user.id,
action: "submission.visibility",
subjectType: "submission",
subjectId: submission.id,
metadata: { visibility: input.visibility, count: eligible.length },
});
return { ok: true as const, updatedCount: eligible.length };
});
return { ok: true as const };
}),
deletePhoto: protectedProcedure