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;