From 3f3c9671f54243b8f1396ed9cf22aab74198d461 Mon Sep 17 00:00:00 2001 From: Replit Agent Date: Tue, 7 Jul 2026 09:14:07 +0000 Subject: [PATCH] Add functionality to manage visit photos and albums for protocols Introduce new database tables for photo albums and photos, add API endpoints for CRUD operations on albums and photos, and implement frontend components for managing photo albums. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: ad3facca-9626-4e19-95c0-125e6d3bc7e2 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/1OXOhLE Replit-Helium-Checkpoint-Created: true --- artifacts/api-server/src/lib/objectAuthz.ts | 15 +- artifacts/api-server/src/routes/protocol.ts | 298 +++++++++++++ artifacts/tx-os/src/locales/ar.json | 14 + artifacts/tx-os/src/locales/en.json | 14 + artifacts/tx-os/src/pages/protocol.tsx | 442 ++++++++++++++++++++ lib/db/src/schema/protocol.ts | 75 ++++ 6 files changed, 857 insertions(+), 1 deletion(-) diff --git a/artifacts/api-server/src/lib/objectAuthz.ts b/artifacts/api-server/src/lib/objectAuthz.ts index 498870b9..d3671382 100644 --- a/artifacts/api-server/src/lib/objectAuthz.ts +++ b/artifacts/api-server/src/lib/objectAuthz.ts @@ -5,10 +5,11 @@ import { servicesTable, executiveMeetingFontSettingsTable, executiveMeetingPdfArchivesTable, + protocolPhotosTable, rolesTable, } from "@workspace/db"; import { eq, inArray, sql } from "drizzle-orm"; -import { getEffectiveRoleIds } from "../middlewares/auth"; +import { getEffectiveRoleIds, userHasPermission } from "../middlewares/auth"; import { getVisibleAppsForUser } from "./appsVisibility"; // Roles that gate read access to the Executive Meetings module. @@ -152,6 +153,18 @@ export async function canUserReadObjectPath( const rows = (meeting as unknown as { rows?: unknown[] }).rows ?? []; if (rows.length > 0) return hasExecutiveAccess; + // 7. Protocol visit photos — gated by the protocol read permission + // (`protocol.access`), the same scoped permission that makes the + // Protocol tile visible. Never gated by role name (module convention). + const photo = await db + .select({ id: protocolPhotosTable.id }) + .from(protocolPhotosTable) + .where(eq(protocolPhotosTable.objectPath, objectPath)) + .limit(1); + if (photo.length > 0) { + return isAdmin || (await userHasPermission(userId, "protocol.access")); + } + // Orphan path — no entity references it. Treat as not-found for // every role (including admin). This is what stops object // enumeration: even with admin credentials, you cannot probe diff --git a/artifacts/api-server/src/routes/protocol.ts b/artifacts/api-server/src/routes/protocol.ts index d70bf6d3..9fbabc82 100644 --- a/artifacts/api-server/src/routes/protocol.ts +++ b/artifacts/api-server/src/routes/protocol.ts @@ -9,6 +9,8 @@ import { protocolExternalMeetingsTable, protocolGiftsTable, protocolGiftIssuesTable, + protocolPhotoAlbumsTable, + protocolPhotosTable, protocolAuditLogsTable, rolesTable, permissionsTable, @@ -21,9 +23,11 @@ import { getEffectiveRoleIds, userHasPermission, } from "../middlewares/auth"; +import { ObjectStorageService } from "../lib/objectStorage"; import type { Request, Response, NextFunction } from "express"; const router: IRouter = Router(); +const objectStorage = new ObjectStorageService(); router.param("id", (req, res, next, value) => { if (!/^\d+$/.test(String(value))) { @@ -1848,4 +1852,298 @@ router.get( }, ); +// --------------------------------------------------------------------------- +// Visit photo albums (تصوير) — album + photo CRUD. +// +// Reads are gated by requireProtocolAccess; every mutation by requireMutate +// (both also enforce the user is active). Photos reference private object- +// storage paths served through GET /api/storage/objects/*, authorized per +// entity in lib/objectAuthz.ts. +// --------------------------------------------------------------------------- +const albumCreateSchema = z.object({ + name: z.string().trim().min(1).max(300), + externalMeetingId: z.number().int().positive().nullable().optional(), +}); +const albumPatchSchema = z + .object({ + name: z.string().trim().min(1).max(300).optional(), + externalMeetingId: z.number().int().positive().nullable().optional(), + }) + .refine((v) => Object.keys(v).length > 0, { + message: "no fields to update", + }); +const photoCreateSchema = z.object({ + objectPath: z.string().trim().min(1).max(500), +}); + +async function externalMeetingExists(id: number): Promise { + const [m] = await db + .select({ id: protocolExternalMeetingsTable.id }) + .from(protocolExternalMeetingsTable) + .where(eq(protocolExternalMeetingsTable.id, id)); + return !!m; +} + +router.get( + "/protocol/photo-albums", + requireProtocolAccess, + async (req: Request, res: Response) => { + const meetingIdRaw = req.query.meetingId; + const meetingId = + typeof meetingIdRaw === "string" && /^\d+$/.test(meetingIdRaw) + ? Number(meetingIdRaw) + : undefined; + const rows = await db + .select({ + id: protocolPhotoAlbumsTable.id, + name: protocolPhotoAlbumsTable.name, + externalMeetingId: protocolPhotoAlbumsTable.externalMeetingId, + meetingTitle: protocolExternalMeetingsTable.title, + createdBy: protocolPhotoAlbumsTable.createdBy, + createdAt: protocolPhotoAlbumsTable.createdAt, + updatedAt: protocolPhotoAlbumsTable.updatedAt, + photoCount: sql`count(${protocolPhotosTable.id})::int`, + coverPath: sql< + string | null + >`(array_agg(${protocolPhotosTable.objectPath} order by ${protocolPhotosTable.sortOrder} asc, ${protocolPhotosTable.id} asc) filter (where ${protocolPhotosTable.id} is not null))[1]`, + }) + .from(protocolPhotoAlbumsTable) + .leftJoin( + protocolExternalMeetingsTable, + eq( + protocolExternalMeetingsTable.id, + protocolPhotoAlbumsTable.externalMeetingId, + ), + ) + .leftJoin( + protocolPhotosTable, + eq(protocolPhotosTable.albumId, protocolPhotoAlbumsTable.id), + ) + .where( + meetingId !== undefined + ? eq(protocolPhotoAlbumsTable.externalMeetingId, meetingId) + : undefined, + ) + .groupBy( + protocolPhotoAlbumsTable.id, + protocolExternalMeetingsTable.title, + ) + .orderBy(desc(protocolPhotoAlbumsTable.createdAt)); + res.json(rows); + }, +); + +router.post( + "/protocol/photo-albums", + requireMutate, + async (req: Request, res: Response) => { + const body = parseBody(res, albumCreateSchema, req.body); + if (!body) return; + if ( + body.externalMeetingId != null && + !(await externalMeetingExists(body.externalMeetingId)) + ) { + res + .status(400) + .json({ error: "external_meeting not found", code: "validation" }); + return; + } + const [row] = await db + .insert(protocolPhotoAlbumsTable) + .values({ + name: body.name, + externalMeetingId: body.externalMeetingId ?? null, + createdBy: req.session.userId!, + }) + .returning(); + await logAudit(db, { + action: "photo_album.create", + entityType: "photo_album", + entityId: row.id, + newValue: row, + performedBy: req.session.userId!, + }); + res.status(201).json(row); + }, +); + +router.patch( + "/protocol/photo-albums/:id", + requireMutate, + async (req: Request, res: Response) => { + const id = Number(req.params.id); + const body = parseBody(res, albumPatchSchema, req.body); + if (!body) return; + const [existing] = await db + .select() + .from(protocolPhotoAlbumsTable) + .where(eq(protocolPhotoAlbumsTable.id, id)); + if (!existing) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + if ( + body.externalMeetingId != null && + !(await externalMeetingExists(body.externalMeetingId)) + ) { + res + .status(400) + .json({ error: "external_meeting not found", code: "validation" }); + return; + } + const [row] = await db + .update(protocolPhotoAlbumsTable) + .set({ + ...(body.name !== undefined ? { name: body.name } : {}), + ...(body.externalMeetingId !== undefined + ? { externalMeetingId: body.externalMeetingId } + : {}), + }) + .where(eq(protocolPhotoAlbumsTable.id, id)) + .returning(); + await logAudit(db, { + action: "photo_album.update", + entityType: "photo_album", + entityId: id, + oldValue: existing, + newValue: row, + performedBy: req.session.userId!, + }); + res.json(row); + }, +); + +router.delete( + "/protocol/photo-albums/:id", + requireMutate, + async (req: Request, res: Response) => { + const id = Number(req.params.id); + const [existing] = await db + .select() + .from(protocolPhotoAlbumsTable) + .where(eq(protocolPhotoAlbumsTable.id, id)); + if (!existing) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + // Photos cascade-delete via the FK; audit the album deletion. + await db + .delete(protocolPhotoAlbumsTable) + .where(eq(protocolPhotoAlbumsTable.id, id)); + await logAudit(db, { + action: "photo_album.delete", + entityType: "photo_album", + entityId: id, + oldValue: existing, + performedBy: req.session.userId!, + }); + res.status(204).end(); + }, +); + +router.get( + "/protocol/photo-albums/:id/photos", + requireProtocolAccess, + async (req: Request, res: Response) => { + const albumId = Number(req.params.id); + const [album] = await db + .select({ id: protocolPhotoAlbumsTable.id }) + .from(protocolPhotoAlbumsTable) + .where(eq(protocolPhotoAlbumsTable.id, albumId)); + if (!album) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + const rows = await db + .select() + .from(protocolPhotosTable) + .where(eq(protocolPhotosTable.albumId, albumId)) + .orderBy(asc(protocolPhotosTable.sortOrder), asc(protocolPhotosTable.id)); + res.json(rows); + }, +); + +router.post( + "/protocol/photo-albums/:id/photos", + requireMutate, + async (req: Request, res: Response) => { + const albumId = Number(req.params.id); + const body = parseBody(res, photoCreateSchema, req.body); + if (!body) return; + const [album] = await db + .select({ id: protocolPhotoAlbumsTable.id }) + .from(protocolPhotoAlbumsTable) + .where(eq(protocolPhotoAlbumsTable.id, albumId)); + if (!album) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + // Normalize any signed URL / legacy form back to the canonical + // /objects/ path and reject anything that is not a private object. + const objectPath = objectStorage.normalizeObjectEntityPath(body.objectPath); + if (!objectPath.startsWith("/objects/")) { + res.status(400).json({ error: "invalid objectPath", code: "validation" }); + return; + } + // Verify the object was actually uploaded before storing a reference to + // it — this rejects dangling/guessed paths. + try { + await objectStorage.getObjectEntityFile(objectPath); + } catch { + res.status(400).json({ error: "object not found", code: "validation" }); + return; + } + const [agg] = await db + .select({ + maxOrder: sql< + number + >`coalesce(max(${protocolPhotosTable.sortOrder}), -1)::int`, + }) + .from(protocolPhotosTable) + .where(eq(protocolPhotosTable.albumId, albumId)); + const [row] = await db + .insert(protocolPhotosTable) + .values({ + albumId, + objectPath, + sortOrder: (agg?.maxOrder ?? -1) + 1, + uploadedBy: req.session.userId!, + }) + .returning(); + await logAudit(db, { + action: "photo.create", + entityType: "photo", + entityId: row.id, + newValue: row, + performedBy: req.session.userId!, + }); + res.status(201).json(row); + }, +); + +router.delete( + "/protocol/photos/:id", + requireMutate, + async (req: Request, res: Response) => { + const id = Number(req.params.id); + const [existing] = await db + .select() + .from(protocolPhotosTable) + .where(eq(protocolPhotosTable.id, id)); + if (!existing) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + await db.delete(protocolPhotosTable).where(eq(protocolPhotosTable.id, id)); + await logAudit(db, { + action: "photo.delete", + entityType: "photo", + entityId: id, + oldValue: existing, + performedBy: req.session.userId!, + }); + res.status(204).end(); + }, +); + export default router; diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index dff3d7fc..09d6336d 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -1671,6 +1671,7 @@ "external": "اللقاءات الخارجية", "gifts": "الإهداءات", "issues": "طلبات الصرف", + "photos": "تصوير", "reports": "التقارير", "audit": "سجل النشاط", "rooms": "القاعات" @@ -1766,6 +1767,19 @@ "occasion": "المناسبة", "quantity": "الكمية" }, + "photos": { + "newAlbum": "ألبوم جديد", + "editAlbum": "تعديل الألبوم", + "albumName": "اسم الألبوم", + "linkMeeting": "ربط بلقاء خارجي", + "noMeeting": "بدون ربط", + "addPhotos": "إضافة صور", + "uploading": "جارٍ الرفع", + "emptyAlbums": "لا توجد ألبومات.", + "emptyPhotos": "لا توجد صور في هذا الألبوم.", + "confirmDeleteAlbum": "سيتم حذف الألبوم وجميع صوره. لا يمكن التراجع.", + "confirmDeletePhoto": "سيتم حذف هذه الصورة. لا يمكن التراجع." + }, "reports": { "byRoom": "الحجوزات حسب القاعة", "byGift": "الصرف حسب الصنف", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index f40b8492..9595eb57 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -1544,6 +1544,7 @@ "external": "External Meetings", "gifts": "Dedications", "issues": "Issuance Requests", + "photos": "Photos", "reports": "Reports", "audit": "Activity Log", "rooms": "Rooms" @@ -1639,6 +1640,19 @@ "occasion": "Occasion", "quantity": "Quantity" }, + "photos": { + "newAlbum": "New album", + "editAlbum": "Edit album", + "albumName": "Album name", + "linkMeeting": "Link to external meeting", + "noMeeting": "No link", + "addPhotos": "Add photos", + "uploading": "Uploading", + "emptyAlbums": "No albums yet.", + "emptyPhotos": "No photos in this album.", + "confirmDeleteAlbum": "This deletes the album and all its photos. This cannot be undone.", + "confirmDeletePhoto": "This photo will be deleted. This cannot be undone." + }, "reports": { "byRoom": "Bookings by room", "byGift": "Issuance by item", diff --git a/artifacts/tx-os/src/pages/protocol.tsx b/artifacts/tx-os/src/pages/protocol.tsx index 29357cc6..df738ed8 100644 --- a/artifacts/tx-os/src/pages/protocol.tsx +++ b/artifacts/tx-os/src/pages/protocol.tsx @@ -22,6 +22,10 @@ import { List as ListIcon, Link2, Copy, + Camera, + Upload, + Loader2, + Image as ImageIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -54,6 +58,7 @@ import { import { AppDock } from "@/components/app-dock"; import { useToast } from "@/hooks/use-toast"; import { apiJson, ApiError } from "@/lib/api-json"; +import { useUpload, type UploadResponse } from "@workspace/object-storage-web"; import { cn } from "@/lib/utils"; const API = `${import.meta.env.BASE_URL}api`; @@ -185,6 +190,7 @@ type TabKey = | "external" | "gifts" | "issues" + | "photos" | "reports" | "audit"; @@ -196,6 +202,7 @@ const TAB_SLUGS: Record = { external: "external-meetings", gifts: "gifts", issues: "gift-issues", + photos: "photos", reports: "reports", audit: "audit", }; @@ -501,6 +508,7 @@ export default function ProtocolPage() { { key: "external", label: t("protocol.tabs.external"), icon: Handshake }, { key: "gifts", label: t("protocol.tabs.gifts"), icon: Gift }, { key: "issues", label: t("protocol.tabs.issues"), icon: PackageCheck }, + { key: "photos", label: t("protocol.tabs.photos"), icon: Camera }, { key: "reports", label: t("protocol.tabs.reports"), icon: BarChart3 }, ...(c?.canViewAudit ? [{ key: "audit" as TabKey, label: t("protocol.tabs.audit"), icon: ScrollText }] @@ -1077,6 +1085,15 @@ export default function ProtocolPage() { )} + {tab === "photos" && ( + + )} + {tab === "reports" && ( )} @@ -1939,3 +1956,428 @@ function IssueDialog({ ); } + +// --------------------------------------------------------------------------- +// Visit photos (تصوير) — album grid + album detail with upload gallery. +// --------------------------------------------------------------------------- +type Album = { + id: number; + name: string; + externalMeetingId: number | null; + meetingTitle: string | null; + createdBy: number | null; + createdAt: string; + updatedAt: string; + photoCount: number; + coverPath: string | null; +}; +type Photo = { + id: number; + albumId: number; + objectPath: string; + sortOrder: number; + uploadedBy: number | null; + createdAt: string; +}; + +// Build the authorized image URL from a stored `/objects/` path. +const photoSrc = (objectPath: string) => `${API}/storage${objectPath}`; + +function PhotosView({ + canMutate, + isAr, + externalMeetings, + onApiError, +}: { + canMutate: boolean; + isAr: boolean; + externalMeetings: ExternalMeeting[]; + onApiError: (e: unknown) => void; +}) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const [openAlbumId, setOpenAlbumId] = useState(null); + const [albumDialog, setAlbumDialog] = useState<{ + open: boolean; + edit?: Album; + }>({ open: false }); + const [deleteAlbum, setDeleteAlbum] = useState(null); + const [deletePhoto, setDeletePhoto] = useState(null); + const BackIcon = isAr ? ArrowRight : ArrowLeft; + + const albums = useQuery({ + queryKey: ["protocol", "photo-albums"], + queryFn: () => apiJson(`${API}/protocol/photo-albums`), + }); + const photos = useQuery({ + queryKey: ["protocol", "photos", openAlbumId], + queryFn: () => + apiJson(`${API}/protocol/photo-albums/${openAlbumId}/photos`), + enabled: openAlbumId !== null, + }); + + const openAlbum = + openAlbumId !== null + ? (albums.data ?? []).find((a) => a.id === openAlbumId) + : undefined; + + const { uploadFile, isUploading, progress } = useUpload({ + basePath: `${API}/storage`, + onError: onApiError, + }); + + const finalize = useMutation({ + mutationFn: (objectPath: string) => + apiJson(`${API}/protocol/photo-albums/${openAlbumId}/photos`, { + method: "POST", + body: JSON.stringify({ objectPath }), + }), + onError: onApiError, + }); + + const handleFiles = async (files: FileList | null) => { + if (!files || openAlbumId === null) return; + for (const file of Array.from(files)) { + const resp: UploadResponse | null = await uploadFile(file); + if (resp) await finalize.mutateAsync(resp.objectPath); + } + await qc.invalidateQueries({ queryKey: ["protocol", "photos", openAlbumId] }); + await qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] }); + }; + + const delAlbumMut = useMutation({ + mutationFn: (id: number) => + apiJson(`${API}/protocol/photo-albums/${id}`, { method: "DELETE" }), + onSuccess: () => { + setDeleteAlbum(null); + qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] }); + }, + onError: onApiError, + }); + const delPhotoMut = useMutation({ + mutationFn: (id: number) => + apiJson(`${API}/protocol/photos/${id}`, { method: "DELETE" }), + onSuccess: () => { + setDeletePhoto(null); + qc.invalidateQueries({ queryKey: ["protocol", "photos", openAlbumId] }); + qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] }); + }, + onError: onApiError, + }); + + // --- Album detail view ----------------------------------------------------- + if (openAlbum) { + return ( +
+
+
+ +
+

+ {openAlbum.name} +

+ {openAlbum.meetingTitle && ( +
+ + {openAlbum.meetingTitle} +
+ )} +
+
+ {canMutate && ( + + )} +
+ + {photos.data && photos.data.length === 0 && ( + + )} +
+ {(photos.data ?? []).map((p) => ( +
+ + + + {canMutate && ( + + )} +
+ ))} +
+ + {albumDialog.open && ( + setAlbumDialog({ open: false })} + onSaved={() => { + setAlbumDialog({ open: false }); + qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] }); + }} + onError={onApiError} + /> + )} + !o && setDeletePhoto(null)} + > + + + {t("common.confirmDelete")} + + {t("protocol.photos.confirmDeletePhoto")} + + + + {t("common.cancel")} + deletePhoto && delPhotoMut.mutate(deletePhoto.id)} + > + {t("common.delete")} + + + + +
+ ); + } + + // --- Album grid view ------------------------------------------------------- + return ( +
+
+

+ {t("protocol.tabs.photos")} +

+ {canMutate && ( + + )} +
+ + {albums.data && albums.data.length === 0 && ( + + )} +
+ {(albums.data ?? []).map((a) => ( +
+ +
+ + {a.meetingTitle && ( +
+ + {a.meetingTitle} +
+ )} + {canMutate && ( +
+ + +
+ )} +
+
+ ))} +
+ + {albumDialog.open && ( + setAlbumDialog({ open: false })} + onSaved={() => { + setAlbumDialog({ open: false }); + qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] }); + }} + onError={onApiError} + /> + )} + !o && setDeleteAlbum(null)} + > + + + {t("common.confirmDelete")} + + {t("protocol.photos.confirmDeleteAlbum")} + + + + {t("common.cancel")} + deleteAlbum && delAlbumMut.mutate(deleteAlbum.id)} + > + {t("common.delete")} + + + + +
+ ); +} + +function AlbumDialog({ + edit, + externalMeetings, + onClose, + onSaved, + onError, +}: { + edit?: Album; + externalMeetings: ExternalMeeting[]; + onClose: () => void; + onSaved: () => void; + onError: (e: unknown) => void; +}) { + const { t } = useTranslation(); + const [name, setName] = useState(edit?.name ?? ""); + const [meetingId, setMeetingId] = useState( + edit?.externalMeetingId != null ? String(edit.externalMeetingId) : "none", + ); + + const save = useMutation({ + mutationFn: () => { + const payload = { + name: name.trim(), + externalMeetingId: meetingId === "none" ? null : Number(meetingId), + }; + return edit + ? apiJson(`${API}/protocol/photo-albums/${edit.id}`, { + method: "PATCH", + body: JSON.stringify(payload), + }) + : apiJson(`${API}/protocol/photo-albums`, { + method: "POST", + body: JSON.stringify(payload), + }); + }, + onSuccess: onSaved, + onError, + }); + + return ( + save.mutate()} + submitLabel={t("common.save")} + saving={save.isPending} + > +
+ + setName(e.target.value)} + required + /> +
+
+ + +
+
+ ); +} diff --git a/lib/db/src/schema/protocol.ts b/lib/db/src/schema/protocol.ts index d90d484b..6f7eab7a 100644 --- a/lib/db/src/schema/protocol.ts +++ b/lib/db/src/schema/protocol.ts @@ -241,6 +241,67 @@ export const protocolGiftIssuesTable = pgTable( }), ); +// --------------------------------------------------------------------------- +// Visit photo albums (صور الزيارات) — "تصوير" tab. +// +// An album groups photographs of a visit. It may optionally be linked to an +// external protocol meeting (اللقاءات الخارجية); the link is `set null` so +// deleting the meeting never removes the album. The album cover is derived at +// query time from the first photo (by sortOrder), so there is no circular +// album <-> photo foreign key to keep in sync. +// --------------------------------------------------------------------------- +export const protocolPhotoAlbumsTable = pgTable( + "protocol_photo_albums", + { + id: serial("id").primaryKey(), + name: varchar("name", { length: 300 }).notNull(), + externalMeetingId: integer("external_meeting_id").references( + () => protocolExternalMeetingsTable.id, + { onDelete: "set null" }, + ), + createdBy: integer("created_by").references(() => usersTable.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (t) => ({ + meetingIdx: index("protocol_albums_meeting_idx").on(t.externalMeetingId), + }), +); + +// --------------------------------------------------------------------------- +// Photos inside an album. Each photo references a private object-storage path +// (`/objects/`) served through the authorized object endpoint. Deleting an +// album cascades to its photos. +// --------------------------------------------------------------------------- +export const protocolPhotosTable = pgTable( + "protocol_photos", + { + id: serial("id").primaryKey(), + albumId: integer("album_id") + .notNull() + .references(() => protocolPhotoAlbumsTable.id, { onDelete: "cascade" }), + objectPath: varchar("object_path", { length: 500 }).notNull(), + sortOrder: integer("sort_order").notNull().default(0), + uploadedBy: integer("uploaded_by").references(() => usersTable.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + albumIdx: index("protocol_photos_album_idx").on(t.albumId), + pathIdx: index("protocol_photos_path_idx").on(t.objectPath), + }), +); + // --------------------------------------------------------------------------- // Audit log (scoped to the protocol module only) // --------------------------------------------------------------------------- @@ -306,4 +367,18 @@ export type InsertProtocolGiftIssue = z.infer< >; export type ProtocolGiftIssue = typeof protocolGiftIssuesTable.$inferSelect; +export const insertProtocolPhotoAlbumSchema = createInsertSchema( + protocolPhotoAlbumsTable, +).omit({ id: true, createdAt: true, updatedAt: true }); +export type InsertProtocolPhotoAlbum = z.infer< + typeof insertProtocolPhotoAlbumSchema +>; +export type ProtocolPhotoAlbum = typeof protocolPhotoAlbumsTable.$inferSelect; + +export const insertProtocolPhotoSchema = createInsertSchema( + protocolPhotosTable, +).omit({ id: true, createdAt: true }); +export type InsertProtocolPhoto = z.infer; +export type ProtocolPhoto = typeof protocolPhotosTable.$inferSelect; + export type ProtocolAuditLog = typeof protocolAuditLogsTable.$inferSelect;