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
This commit is contained in:
Replit Agent
2026-07-07 09:14:07 +00:00
parent 9dd8c32d6d
commit 3f3c9671f5
6 changed files with 857 additions and 1 deletions
+14 -1
View File
@@ -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
+298
View File
@@ -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<boolean> {
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<number>`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/<id> 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;
+14
View File
@@ -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": "الصرف حسب الصنف",
+14
View File
@@ -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",
+442
View File
@@ -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<TabKey, string> = {
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() {
</section>
)}
{tab === "photos" && (
<PhotosView
canMutate={!!c?.canMutate}
isAr={isAr}
externalMeetings={external.data ?? []}
onApiError={onApiError}
/>
)}
{tab === "reports" && (
<ReportsView data={reports.data} nameOf={nameOf} t={t} />
)}
@@ -1939,3 +1956,428 @@ function IssueDialog({
</DialogShell>
);
}
// ---------------------------------------------------------------------------
// 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/<id>` 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<number | null>(null);
const [albumDialog, setAlbumDialog] = useState<{
open: boolean;
edit?: Album;
}>({ open: false });
const [deleteAlbum, setDeleteAlbum] = useState<Album | null>(null);
const [deletePhoto, setDeletePhoto] = useState<Photo | null>(null);
const BackIcon = isAr ? ArrowRight : ArrowLeft;
const albums = useQuery({
queryKey: ["protocol", "photo-albums"],
queryFn: () => apiJson<Album[]>(`${API}/protocol/photo-albums`),
});
const photos = useQuery({
queryKey: ["protocol", "photos", openAlbumId],
queryFn: () =>
apiJson<Photo[]>(`${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 (
<section className="space-y-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Button
size="sm"
variant="ghost"
onClick={() => setOpenAlbumId(null)}
>
<BackIcon size={16} />
</Button>
<div className="min-w-0">
<h2 className="font-semibold text-slate-700 truncate">
{openAlbum.name}
</h2>
{openAlbum.meetingTitle && (
<div className="text-xs text-slate-400 truncate">
<Link2 size={11} className="inline me-1" />
{openAlbum.meetingTitle}
</div>
)}
</div>
</div>
{canMutate && (
<label className="cursor-pointer shrink-0">
<input
type="file"
accept="image/*"
multiple
disabled={isUploading}
className="hidden"
onChange={(e) => {
void handleFiles(e.target.files);
e.currentTarget.value = "";
}}
/>
<span className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-primary text-primary-foreground text-sm hover:opacity-90 transition">
{isUploading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Upload size={16} />
)}
{isUploading
? `${t("protocol.photos.uploading")} ${progress}%`
: t("protocol.photos.addPhotos")}
</span>
</label>
)}
</div>
{photos.data && photos.data.length === 0 && (
<EmptyState text={t("protocol.photos.emptyPhotos")} />
)}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{(photos.data ?? []).map((p) => (
<div
key={p.id}
className="group relative aspect-square rounded-xl overflow-hidden border border-slate-200 bg-slate-100"
>
<a href={photoSrc(p.objectPath)} target="_blank" rel="noreferrer">
<img
src={photoSrc(p.objectPath)}
alt=""
loading="lazy"
className="w-full h-full object-cover"
/>
</a>
{canMutate && (
<button
type="button"
onClick={() => setDeletePhoto(p)}
className="absolute top-1 end-1 p-1.5 rounded-lg bg-black/50 text-white opacity-0 group-hover:opacity-100 transition"
>
<Trash2 size={14} />
</button>
)}
</div>
))}
</div>
{albumDialog.open && (
<AlbumDialog
edit={albumDialog.edit}
externalMeetings={externalMeetings}
onClose={() => setAlbumDialog({ open: false })}
onSaved={() => {
setAlbumDialog({ open: false });
qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] });
}}
onError={onApiError}
/>
)}
<AlertDialog
open={deletePhoto !== null}
onOpenChange={(o) => !o && setDeletePhoto(null)}
>
<AlertDialogContent dir={isAr ? "rtl" : "ltr"}>
<AlertDialogHeader>
<AlertDialogTitle>{t("common.confirmDelete")}</AlertDialogTitle>
<AlertDialogDescription>
{t("protocol.photos.confirmDeletePhoto")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-rose-600 hover:bg-rose-700"
onClick={() => deletePhoto && delPhotoMut.mutate(deletePhoto.id)}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
// --- Album grid view -------------------------------------------------------
return (
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-slate-700">
{t("protocol.tabs.photos")}
</h2>
{canMutate && (
<Button size="sm" onClick={() => setAlbumDialog({ open: true })}>
<Plus size={16} className="me-1" />
{t("protocol.photos.newAlbum")}
</Button>
)}
</div>
{albums.data && albums.data.length === 0 && (
<EmptyState text={t("protocol.photos.emptyAlbums")} />
)}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{(albums.data ?? []).map((a) => (
<div
key={a.id}
className="bg-white rounded-xl border border-slate-200 overflow-hidden"
>
<button
type="button"
onClick={() => setOpenAlbumId(a.id)}
className="block w-full aspect-square bg-slate-100 relative"
>
{a.coverPath ? (
<img
src={photoSrc(a.coverPath)}
alt=""
loading="lazy"
className="w-full h-full object-cover"
/>
) : (
<span className="absolute inset-0 flex items-center justify-center text-slate-300">
<ImageIcon size={32} />
</span>
)}
<span className="absolute bottom-1 end-1 text-[11px] px-1.5 py-0.5 rounded-full bg-black/55 text-white">
{a.photoCount}
</span>
</button>
<div className="p-2">
<button
type="button"
onClick={() => setOpenAlbumId(a.id)}
className="block w-full text-start font-medium text-sm text-slate-800 truncate"
>
{a.name}
</button>
{a.meetingTitle && (
<div className="text-[11px] text-slate-400 truncate">
<Link2 size={10} className="inline me-1" />
{a.meetingTitle}
</div>
)}
{canMutate && (
<div className="flex gap-1 mt-1">
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={() => setAlbumDialog({ open: true, edit: a })}
>
<Pencil size={13} />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={() => setDeleteAlbum(a)}
>
<Trash2 size={13} className="text-rose-500" />
</Button>
</div>
)}
</div>
</div>
))}
</div>
{albumDialog.open && (
<AlbumDialog
edit={albumDialog.edit}
externalMeetings={externalMeetings}
onClose={() => setAlbumDialog({ open: false })}
onSaved={() => {
setAlbumDialog({ open: false });
qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] });
}}
onError={onApiError}
/>
)}
<AlertDialog
open={deleteAlbum !== null}
onOpenChange={(o) => !o && setDeleteAlbum(null)}
>
<AlertDialogContent dir={isAr ? "rtl" : "ltr"}>
<AlertDialogHeader>
<AlertDialogTitle>{t("common.confirmDelete")}</AlertDialogTitle>
<AlertDialogDescription>
{t("protocol.photos.confirmDeleteAlbum")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-rose-600 hover:bg-rose-700"
onClick={() => deleteAlbum && delAlbumMut.mutate(deleteAlbum.id)}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
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<string>(
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 (
<DialogShell
title={edit ? t("protocol.photos.editAlbum") : t("protocol.photos.newAlbum")}
onClose={onClose}
onSubmit={() => save.mutate()}
submitLabel={t("common.save")}
saving={save.isPending}
>
<div>
<Label>{t("protocol.photos.albumName")}</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div>
<Label>{t("protocol.photos.linkMeeting")}</Label>
<Select value={meetingId} onValueChange={setMeetingId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t("protocol.photos.noMeeting")}</SelectItem>
{externalMeetings.map((m) => (
<SelectItem key={m.id} value={String(m.id)}>
{m.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</DialogShell>
);
}
+75
View File
@@ -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/<id>`) 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<typeof insertProtocolPhotoSchema>;
export type ProtocolPhoto = typeof protocolPhotosTable.$inferSelect;
export type ProtocolAuditLog = typeof protocolAuditLogsTable.$inferSelect;