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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user