921 lines
43 KiB
TypeScript
921 lines
43 KiB
TypeScript
import { TRPCError } from "@trpc/server";
|
|
import { customBannerUrl, eventBannerUrl } from "@/server/event-banner";
|
|
import { searchLocations } from "@/server/location-search";
|
|
import { notifyEventGuests } from "@/server/guest-notifications";
|
|
import { renderAlbumReadyEmail, emailBrowserPreview } from "@album/email";
|
|
import { emailDeliveryOutcome } from "@album/email/webhooks";
|
|
import { galleryIsPublic } from "@/lib/publishing";
|
|
import { effectiveEvent } from "@/lib/event-lifecycle";
|
|
import { and, desc, eq, sql, inArray, like } from "drizzle-orm";
|
|
import {
|
|
auditEvents,
|
|
emailDeliveries,
|
|
eventMemberships,
|
|
events,
|
|
getDb,
|
|
groupMemberships,
|
|
guests,
|
|
photos,
|
|
photoExports,
|
|
submissions,
|
|
user,
|
|
} from "@album/database";
|
|
import {
|
|
createEventInputSchema,
|
|
exportPhotosInputSchema,
|
|
checkEventSlugInputSchema,
|
|
generateEventSlugInputSchema,
|
|
locationSearchInputSchema,
|
|
moderatePhotoInputSchema,
|
|
moderateNoteInputSchema,
|
|
moderateSubmissionInputSchema,
|
|
setEventMemberInputSchema,
|
|
updateEventInputSchema,
|
|
} from "@album/contracts";
|
|
import {
|
|
createPresignedGetUrl,
|
|
deletePrefix,
|
|
photoObjectPrefix,
|
|
} from "@album/storage";
|
|
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({
|
|
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);
|
|
requireEventPermission(access.permissions,EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ);
|
|
const [job] = await getDb().insert(photoExports).values({eventId:input.eventId,requestedBy:ctx.session.user.id,filter:input.filter,expiresAt:new Date(Date.now()+86400000)}).onConflictDoNothing().returning({id:photoExports.id});
|
|
if (!job) throw new TRPCError({code:"CONFLICT",message:"An export is already queued or processing."});
|
|
await writeAudit({eventId:input.eventId,actorUserId:ctx.session.user.id,action:"photo.export.requested",subjectType:"export",subjectId:job.id,metadata:{filter:input.filter}});
|
|
return job;
|
|
}),
|
|
exports: 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.SETTINGS_MANAGE);
|
|
requireEventPermission(access.permissions,EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ);
|
|
return getDb().select({id:photoExports.id,status:photoExports.status,filter:photoExports.filter,total:photoExports.total,processed:photoExports.processed,expiresAt:photoExports.expiresAt}).from(photoExports)
|
|
.where(and(eq(photoExports.eventId,input.eventId),eq(photoExports.requestedBy,ctx.session.user.id))).orderBy(desc(photoExports.createdAt)).limit(10);
|
|
}),
|
|
downloadExport: protectedProcedure.input(z.object({eventId:z.string().uuid(),exportId:z.string().uuid()})).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);
|
|
requireEventPermission(access.permissions,EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ);
|
|
const [job] = await getDb().select().from(photoExports).where(and(eq(photoExports.eventId,input.eventId),eq(photoExports.id,input.exportId),eq(photoExports.requestedBy,ctx.session.user.id)));
|
|
if (!job || job.status !== "ready" || job.expiresAt <= new Date()) throw new TRPCError({code:"NOT_FOUND",message:"Export is unavailable or expired."});
|
|
return {url:await createPresignedGetUrl(`exports/${input.eventId}/${job.id}.zip`,Math.max(1,Math.min(300,Math.floor((job.expiresAt.getTime()-Date.now())/1000))))};
|
|
}),
|
|
searchLocations: protectedProcedure.input(locationSearchInputSchema).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.SETTINGS_MANAGE);
|
|
try {
|
|
return await searchLocations(input.query);
|
|
} catch {
|
|
throw new TRPCError({ code: "BAD_GATEWAY", message: "Location search is unavailable. Try again, or keep a text-only location." });
|
|
}
|
|
}),
|
|
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 Promise.all(memberships
|
|
.filter((row) => row.event.groupId === ctx.activeGroupId)
|
|
.map(async (row) => ({ ...row.event, role: row.role,
|
|
bannerUrl: row.event.customBannerId ? await customBannerUrl(row.event.id, row.event.customBannerId) : await eventBannerUrl(row.event.id, row.event.bannerPhotoId),
|
|
})));
|
|
}
|
|
if (platformRole && hasPlatformPermission(platformRole, PLATFORM_PERMISSIONS.EVENTS_READ)) {
|
|
const all = await getDb().select().from(events).orderBy(desc(events.createdAt));
|
|
return Promise.all(all.map(async (event) => ({ ...event, role: "platform" as const,
|
|
bannerUrl: event.customBannerId ? await customBannerUrl(event.id, event.customBannerId) : await eventBannerUrl(event.id, event.bannerPhotoId),
|
|
})));
|
|
}
|
|
return Promise.all(memberships.map(async (row) => ({ ...row.event, role: row.role,
|
|
bannerUrl: row.event.customBannerId ? await customBannerUrl(row.event.id, row.event.customBannerId) : await eventBannerUrl(row.event.id, row.event.bannerPhotoId),
|
|
})));
|
|
}),
|
|
|
|
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,
|
|
bannerUrl: event.customBannerId ? await customBannerUrl(event.id, event.customBannerId) : await eventBannerUrl(event.id, event.bannerPhotoId),
|
|
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;
|
|
}),
|
|
|
|
checkSlug: protectedProcedure.input(checkEventSlugInputSchema).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.SETTINGS_MANAGE);
|
|
const [existing] = await getDb().select({ id: events.id }).from(events).where(eq(events.slug, input.slug)).limit(1);
|
|
return { available: !existing || existing.id === input.eventId };
|
|
}),
|
|
|
|
generateSlug: protectedProcedure.input(generateEventSlugInputSchema).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.SETTINGS_MANAGE);
|
|
return { slug: await uniqueEventSlug(slugify(input.title), input.eventId) };
|
|
}),
|
|
|
|
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);
|
|
if (input.bannerPhotoId) {
|
|
const bannerUrl = await eventBannerUrl(event.id, input.bannerPhotoId);
|
|
if (!bannerUrl) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Choose a processed, public photo from this event for the banner." });
|
|
}
|
|
}
|
|
if (input.customBannerId && !(await customBannerUrl(event.id, input.customBannerId))) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Choose a finished banner upload from this event." });
|
|
}
|
|
if (input.customBannerId && input.bannerPhotoId) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Choose one banner source." });
|
|
}
|
|
const slug = input.slug ?? event.slug;
|
|
const schedule = { ...event, ...input };
|
|
if (schedule.startsAt && schedule.endsAt && schedule.endsAt <= schedule.startsAt) throw new TRPCError({ code: "BAD_REQUEST", message: "Event end must be after its start." });
|
|
if (schedule.submissionsOpenAt && schedule.submissionsCloseAt && schedule.submissionsCloseAt <= schedule.submissionsOpenAt) throw new TRPCError({ code: "BAD_REQUEST", message: "Submission closing must be after opening." });
|
|
if (slug !== event.slug) {
|
|
const [existing] = await getDb().select({ id: events.id }).from(events).where(eq(events.slug, slug)).limit(1);
|
|
if (existing) throw new TRPCError({ code: "CONFLICT", message: "That guest link is already taken. Choose another or generate one." });
|
|
}
|
|
const [updated] = await getDb()
|
|
.update(events)
|
|
.set({
|
|
title: input.title ?? event.title,
|
|
slug,
|
|
description:
|
|
input.description === undefined ? event.description : input.description,
|
|
location: input.location === undefined ? event.location : input.location,
|
|
latitude: input.locationCoordinates === undefined
|
|
? (input.location !== undefined && input.location !== event.location ? null : event.latitude)
|
|
: input.locationCoordinates?.latitude ?? null,
|
|
longitude: input.locationCoordinates === undefined
|
|
? (input.location !== undefined && input.location !== event.location ? null : event.longitude)
|
|
: input.locationCoordinates?.longitude ?? null,
|
|
bannerPhotoId: input.customBannerId ? null : input.bannerPhotoId === undefined ? event.bannerPhotoId : input.bannerPhotoId,
|
|
customBannerId: input.customBannerId === undefined ? (input.bannerPhotoId ? null : event.customBannerId) : input.customBannerId,
|
|
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,
|
|
publishAt: input.status ? null : input.publishAt === undefined ? event.publishAt : input.publishAt,
|
|
submissionsOpenAt: input.submissionsOpenAt === undefined ? event.submissionsOpenAt : input.submissionsOpenAt,
|
|
submissionsCloseAt: input.submissionsCloseAt === undefined ? (input.status === "published" && event.submissionsCloseAt && event.submissionsCloseAt <= new Date() ? null : event.submissionsCloseAt) : input.submissionsCloseAt,
|
|
galleryVisibleAt: input.galleryVisibleAt === undefined ? event.galleryVisibleAt : input.galleryVisibleAt,
|
|
notesVisibleAt: input.notesVisibleAt === undefined ? event.notesVisibleAt : input.notesVisibleAt,
|
|
completedAt: input.status === "published" ? null : event.completedAt,
|
|
notesPolicy: input.notesPolicy ?? event.notesPolicy,
|
|
galleryPolicy: input.galleryPolicy ?? event.galleryPolicy,
|
|
showPhotoStats: input.showPhotoStats ?? event.showPhotoStats,
|
|
showSubmitterStats: input.showSubmitterStats ?? event.showSubmitterStats,
|
|
showNoteStats: input.showNoteStats ?? event.showNoteStats,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(events.id, event.id))
|
|
.returning().catch((error: unknown) => {
|
|
const dbError = error as { code?: string; cause?: { code?: string } };
|
|
if (dbError.code === "23505" || dbError.cause?.code === "23505") {
|
|
throw new TRPCError({ code: "CONFLICT", message: "That guest link was just taken. Choose another or generate one." });
|
|
}
|
|
throw error;
|
|
});
|
|
await writeAudit({
|
|
groupId: event.groupId,
|
|
eventId: event.id,
|
|
actorUserId: ctx.session.user.id,
|
|
action: "event.update",
|
|
subjectType: "event",
|
|
subjectId: event.id,
|
|
metadata: Object.fromEntries(Object.keys(input).filter((key) => key !== "eventId" && key in event && key in updated!).flatMap((key) => {
|
|
const before = JSON.stringify(event[key as keyof typeof event]);
|
|
const after = JSON.stringify(updated![key as keyof typeof event]);
|
|
return before === after ? [] : [[`${key}.before`, before ?? null], [`${key}.after`, after ?? null]];
|
|
})),
|
|
});
|
|
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);
|
|
if (event.galleryPolicy === "never") throw new TRPCError({ code: "BAD_REQUEST", message: "Enable gallery publishing before releasing it." });
|
|
if ((event.publishAt && event.publishAt > new Date()) || (event.galleryVisibleAt && event.galleryVisibleAt > new Date())) throw new TRPCError({ code: "BAD_REQUEST", message: "Clear the future visibility schedule before releasing now." });
|
|
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 queued = 0;
|
|
if (input.notifyGuests !== false) {
|
|
queued = (await notifyEventGuests(event, ctx.session.user.id)).queued;
|
|
}
|
|
await writeAudit({
|
|
groupId: event.groupId,
|
|
eventId: event.id,
|
|
actorUserId: ctx.session.user.id,
|
|
action: "gallery.release",
|
|
subjectType: "event",
|
|
subjectId: event.id,
|
|
metadata: { queued },
|
|
});
|
|
return { ok: true as const, queued };
|
|
}),
|
|
|
|
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.before": photo.visibility, "visibility.after": input.visibility },
|
|
});
|
|
return updated!;
|
|
}),
|
|
|
|
moderateSubmission: protectedProcedure
|
|
.input(moderateSubmissionInputSchema)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const [submission] = await getDb()
|
|
.select()
|
|
.from(submissions)
|
|
.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);
|
|
const { event, access } = await loadEventAccess(
|
|
ctx.session.user.id,
|
|
submission.eventId,
|
|
platformRole,
|
|
);
|
|
requireEventPermission(access.permissions, EVENT_PERMISSIONS.PHOTOS_MODERATE);
|
|
if (input.visibility === "private" && !access.permissions.includes(EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ)) {
|
|
throw new TRPCError({ code: "FORBIDDEN" });
|
|
}
|
|
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 };
|
|
});
|
|
}),
|
|
|
|
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 };
|
|
}),
|
|
|
|
moderateNote: protectedProcedure.input(moderateNoteInputSchema).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);
|
|
const rows = await getDb().update(guests).set({ noteApproved: input.approved, updatedAt: new Date() })
|
|
.where(and(eq(guests.eventId, input.eventId), eq(guests.id, input.guestId))).returning({ id: guests.id });
|
|
if (!rows.length) throw new TRPCError({ code: "NOT_FOUND" });
|
|
await writeAudit({ eventId: input.eventId, actorUserId: ctx.session.user.id, action: "note.approval", subjectType: "guest", subjectId: input.guestId, metadata: { approved: input.approved } });
|
|
return { ok: true };
|
|
}),
|
|
|
|
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,
|
|
noteApproved: guests.noteApproved,
|
|
createdAt: guests.createdAt,
|
|
})
|
|
.from(guests)
|
|
.where(eq(guests.eventId, input.eventId))
|
|
.orderBy(desc(guests.createdAt));
|
|
}),
|
|
|
|
completeEvent: protectedProcedure.input(z.object({ eventId: z.string().uuid() })).mutation(async ({ ctx, input }) => {
|
|
const { event, access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id));
|
|
requireEventPermission(access.permissions, EVENT_PERMISSIONS.SETTINGS_MANAGE);
|
|
if (effectiveEvent(event).status === "draft") throw new TRPCError({ code: "BAD_REQUEST", message: "Publish the event before completing it." });
|
|
await getDb().update(events).set({ completedAt: new Date(), status: "closed", updatedAt: new Date() }).where(eq(events.id, event.id));
|
|
await writeAudit({ eventId: event.id, groupId: event.groupId, actorUserId: ctx.session.user.id, action: "event.complete", subjectType: "event", subjectId: event.id });
|
|
return { ok: true };
|
|
}),
|
|
|
|
notifyGuests: protectedProcedure.input(z.object({ eventId: z.string().uuid() })).mutation(async ({ ctx, input }) => {
|
|
const { event, access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id));
|
|
requireEventPermission(access.permissions, EVENT_PERMISSIONS.GALLERY_RELEASE);
|
|
if (effectiveEvent(event).status !== "closed") throw new TRPCError({ code: "BAD_REQUEST", message: "Complete the event before sending completion emails." });
|
|
if ((event.publishAt && event.publishAt > new Date()) || !galleryIsPublic(event.galleryPolicy, event.galleryReleasedAt, event.galleryVisibleAt)) throw new TRPCError({ code: "BAD_REQUEST", message: "Make the gallery public before notifying guests." });
|
|
return notifyEventGuests(event, ctx.session.user.id);
|
|
}),
|
|
|
|
emailPreview: protectedProcedure.input(z.object({ eventId: z.string().uuid() })).query(async ({ ctx, input }) => {
|
|
const { event, access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id));
|
|
requireEventPermission(access.permissions, EVENT_PERMISSIONS.GALLERY_RELEASE);
|
|
const rendered = renderAlbumReadyEmail({ to: "preview@manyangles.test", eventTitle: event.title, galleryUrl: `${publicAppOrigin()}/e/${event.slug}` });
|
|
return { html: emailBrowserPreview(rendered.html), subject: rendered.subject };
|
|
}),
|
|
|
|
emailHistory: 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.GALLERY_RELEASE);
|
|
return getDb().select({ id: emailDeliveries.id, recipient: emailDeliveries.recipient, status: emailDeliveries.status, outcome: emailDeliveryOutcome, attempts: emailDeliveries.attempts, providerId: emailDeliveries.providerId, lastError: emailDeliveries.lastError, updatedAt: emailDeliveries.updatedAt, provider: emailDeliveries.provider, firstAttemptAt: emailDeliveries.firstAttemptAt })
|
|
.from(emailDeliveries).where(eq(emailDeliveries.eventId, input.eventId)).orderBy(desc(emailDeliveries.createdAt)).limit(100);
|
|
}),
|
|
|
|
retryEmail: protectedProcedure.input(z.object({ eventId: z.string().uuid(), deliveryId: z.string().uuid() })).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.GALLERY_RELEASE);
|
|
const rows = await getDb().update(emailDeliveries).set({ status: "pending", nextAttemptAt: new Date(), updatedAt: new Date() }).where(and(eq(emailDeliveries.eventId, input.eventId), eq(emailDeliveries.id, input.deliveryId), eq(emailDeliveries.status, "review"), eq(emailDeliveries.provider, "resend"), sql`${emailDeliveries.firstAttemptAt} > now() - interval '23 hours'`)).returning({ id: emailDeliveries.id });
|
|
if (!rows.length) throw new TRPCError({ code: "BAD_REQUEST", message: "This delivery cannot safely retry. Check the provider delivery record; its retry window may have expired." });
|
|
await writeAudit({ eventId: input.eventId, actorUserId: ctx.session.user.id, action: "guest.email.retry", subjectType: "email", subjectId: input.deliveryId });
|
|
return { ok: true };
|
|
}),
|
|
|
|
guests: 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.PEOPLE_READ);
|
|
return getDb().select({ id: guests.id, displayName: guests.displayName, email: guests.email, notifyWhenReady: guests.notifyWhenReady, notifiedAt: guests.notifiedAt, notificationClaimedAt: guests.notificationClaimedAt, 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.before": existing?.role ?? null, "role.after": 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(), page: z.number().int().min(0).max(10000).default(0), category: z.enum(["all", "event", "photo", "note", "guest", "gallery", "submission"]).default("all") }))
|
|
.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);
|
|
const rows = await getDb()
|
|
.select({
|
|
id: auditEvents.id,
|
|
actorName: user.name,
|
|
action: auditEvents.action,
|
|
subjectType: auditEvents.subjectType,
|
|
subjectId: auditEvents.subjectId,
|
|
metadata: auditEvents.metadata,
|
|
createdAt: auditEvents.createdAt,
|
|
actorUserId: auditEvents.actorUserId,
|
|
})
|
|
.from(auditEvents)
|
|
.leftJoin(user, eq(user.id, auditEvents.actorUserId))
|
|
.where(and(eq(auditEvents.eventId, input.eventId), input.category === "all" ? undefined : like(auditEvents.action, `${input.category}.%`)))
|
|
.orderBy(desc(auditEvents.createdAt), desc(auditEvents.id))
|
|
.limit(50).offset(input.page * 50);
|
|
const ids = (type: string) => rows.filter((row) => row.subjectType === type).map((row) => row.subjectId);
|
|
const [accounts, attendees, assets] = await Promise.all([
|
|
ids("user").length ? getDb().select({ id: user.id, name: user.name }).from(user).where(inArray(user.id, ids("user"))) : [],
|
|
ids("guest").length ? getDb().select({ id: guests.id, name: guests.displayName }).from(guests).where(and(eq(guests.eventId, input.eventId), inArray(guests.id, ids("guest")))) : [],
|
|
ids("photo").length ? getDb().select({ id: photos.id, displayKey: photos.displayKey, visibility: photos.visibility }).from(photos).where(and(eq(photos.eventId, input.eventId), inArray(photos.id, ids("photo")))) : [],
|
|
]);
|
|
return Promise.all(rows.map(async (row) => {
|
|
let subjectLabel = typeof row.metadata.subjectLabel === "string" ? row.metadata.subjectLabel : `${row.subjectType} ${row.subjectId}`;
|
|
let assetUrl: string | null = null;
|
|
if (row.subjectType === "user") {
|
|
const person = accounts.find((person) => person.id === row.subjectId);
|
|
if (person) subjectLabel = person.name;
|
|
} else if (row.subjectType === "guest") {
|
|
const person = attendees.find((person) => person.id === row.subjectId);
|
|
if (person) subjectLabel = person.name ?? "Anonymous guest";
|
|
} else if (row.subjectType === "photo") {
|
|
const photo = assets.find((photo) => photo.id === row.subjectId);
|
|
const allowed = access.permissions.includes(EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ) ? PRIVATE_VISIBILITIES : MEMBER_VISIBILITIES;
|
|
if (access.permissions.includes(EVENT_PERMISSIONS.PHOTOS_READ) && photo?.displayKey && allowed.includes(photo.visibility)) assetUrl = await createPresignedGetUrl(photo.displayKey);
|
|
}
|
|
return { ...row, subjectLabel, assetUrl };
|
|
}));
|
|
}),
|
|
|
|
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 };
|
|
}),
|
|
});
|