From 08c9cc4efbda29c91442630e02ec539296c6f226 Mon Sep 17 00:00:00 2001 From: vance Date: Thu, 9 Apr 2026 22:48:09 -0700 Subject: [PATCH] Add approved asset removal controls --- apps/admin/src/app/App.tsx | 99 +++++++++++++++++++++++------ apps/admin/src/app/app.css | 32 ++++++++++ apps/admin/src/features/live/api.ts | 19 ++++++ services/api/src/server.ts | 39 +++++++++++- services/api/src/state-store.ts | 48 ++++++++++++++ 5 files changed, 217 insertions(+), 20 deletions(-) diff --git a/apps/admin/src/app/App.tsx b/apps/admin/src/app/App.tsx index 5d7b960..d1f8d5a 100644 --- a/apps/admin/src/app/App.tsx +++ b/apps/admin/src/app/App.tsx @@ -33,6 +33,7 @@ import { createAdminUpload, activateSafeCue, createCue, + deleteAsset, deleteCue, fireCue, generateCue, @@ -1009,6 +1010,40 @@ export const App = () => { } }; + const handleDeleteApprovedAsset = async (asset: PhotoAsset) => { + const submission = submissionMap.get(asset.submissionId); + const assetLabel = getAssetPrimaryLabel(asset, submission); + const confirmed = window.confirm( + submission?.source === "library_import" + ? `Remove "${assetLabel}" from the approved bank? The library file will stay on disk and can be re-imported later if needed.` + : `Delete "${assetLabel}" from the system? This removes it from the approved bank and deletes its stored media files.` + ); + if (!confirmed) { + return; + } + + try { + if (submission?.source === "library_import") { + await moderateAsset(asset.id, { + decision: "archive_only", + note: "Removed from approved bank by operator." + }); + } else { + await deleteAsset(asset.id); + } + setSelectedAssetIds((current) => current.filter((assetId) => assetId !== asset.id)); + setMetadataAssetId((current) => (current === asset.id ? null : current)); + await refresh(false); + setStatus( + submission?.source === "library_import" + ? `Removed ${assetLabel} from the approved bank.` + : `Deleted ${assetLabel} from the approved bank and storage.` + ); + } catch (error) { + setStatus(error instanceof Error ? error.message : "Could not remove image from approved bank."); + } + }; + const setBlackout = (blackout: boolean) => { setCueState((current) => ({ ...current, @@ -1872,6 +1907,16 @@ export const App = () => { + {metadataAsset?.moderationStatus === "approved" ? ( + + ) : null} ) : ( @@ -1889,29 +1934,45 @@ export const App = () => { const isSelected = selectedAssetIds.includes(asset.id); const isAnchor = selectedAssetIds[0] === asset.id; return ( - +
+
+ {isAnchor ? Anchor : isSelected ? Selected : } + {formatSubmissionSource(submission?.source)} +
+ {assetLabel} +

+ {asset.orientation ?? "pending orientation"} + {asset.qualityFlags?.tooSmall ? " / low-res" : ""} +

+ {assetDetail ? {assetDetail} : null} +
+ + + ); })} {filteredApprovedAssets.length === 0 ?

Approved images will appear here after import or moderation.

: null} diff --git a/apps/admin/src/app/app.css b/apps/admin/src/app/app.css index 67598c8..362f805 100644 --- a/apps/admin/src/app/app.css +++ b/apps/admin/src/app/app.css @@ -878,6 +878,18 @@ select:focus-visible { text-align: left; } +.bank-item__select { + display: block; + width: 100%; + height: 100%; + padding: 0; + border: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + .bank-item--selected { border-color: rgba(136, 180, 215, 0.28); } @@ -891,6 +903,26 @@ select:focus-visible { height: 100%; } +.bank-item__delete { + position: absolute; + top: 6px; + right: 6px; + z-index: 2; + padding: 4px 6px; + font-size: var(--type-3xs); + line-height: 1; + opacity: 0; + pointer-events: none; + transition: opacity 140ms ease; +} + +.bank-item:hover .bank-item__delete, +.bank-item:focus-within .bank-item__delete, +.bank-item--editing .bank-item__delete { + opacity: 1; + pointer-events: auto; +} + .bank-item__overlay { position: absolute; inset: auto 0 0 0; diff --git a/apps/admin/src/features/live/api.ts b/apps/admin/src/features/live/api.ts index 6f0ae10..8d56aa5 100644 --- a/apps/admin/src/features/live/api.ts +++ b/apps/admin/src/features/live/api.ts @@ -122,3 +122,22 @@ export const deleteCue = async (cueId: string) => { throw new Error(`Request failed for /api/cues/${cueId}.`); } }; + +export const deleteAsset = async (assetId: string) => { + const response = await fetch(`/api/assets/${assetId}`, { + method: "DELETE" + }); + + if (!response.ok) { + let message = `Request failed for /api/assets/${assetId}.`; + try { + const payload = (await response.json()) as { message?: string }; + if (payload.message) { + message = payload.message; + } + } catch { + // ignore non-json errors + } + throw new Error(message); + } +}; diff --git a/services/api/src/server.ts b/services/api/src/server.ts index 643607e..8f64d4b 100644 --- a/services/api/src/server.ts +++ b/services/api/src/server.ts @@ -1,5 +1,5 @@ import { watch } from "node:fs"; -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import cors from "@fastify/cors"; import multipart from "@fastify/multipart"; @@ -133,6 +133,20 @@ const storeUploadedFile = async (filePart: ParsedMultipartFile, assetId: string) return `/uploads/${relativePath}`; }; +const resolveUploadKeyPath = (uploadKey: string | undefined) => { + if (!uploadKey || !uploadKey.startsWith("/uploads/")) { + return null; + } + + const relativePath = uploadKey.slice("/uploads/".length); + const absolutePath = path.resolve(config.storageDir, relativePath); + if (!absolutePath.startsWith(path.resolve(config.storageDir))) { + return null; + } + + return absolutePath; +}; + const createSubmissionFromMultipart = async ( store: StateStore, request: FastifyRequest, @@ -371,6 +385,29 @@ export const buildServer = async () => { return reply.status(204).send(); }); + app.delete<{ Params: { assetId: string } }>("/api/assets/:assetId", async (request, reply) => { + const currentState = await store.read(); + const asset = currentState.photoAssets.find((entry) => entry.id === request.params.assetId); + if (!asset) { + return reply.status(404).send({ message: "Asset not found." }); + } + + const submission = currentState.submissions.find((entry) => entry.id === asset.submissionId); + if (submission?.source === "library_import") { + return reply.status(400).send({ message: "Library assets should be removed from the approved bank without deleting the source file." }); + } + + await store.deleteAsset(request.params.assetId); + + const uploadPaths = [asset.originalKey, asset.thumbKey, asset.previewKey, asset.renderKey] + .map((key) => resolveUploadKeyPath(key)) + .filter((entry): entry is string => Boolean(entry)); + + await Promise.all(uploadPaths.map((filePath) => rm(filePath, { force: true }))); + + return reply.status(204).send(); + }); + app.post<{ Params: { cueId: string } }>("/api/cues/:cueId/fire", async (request, reply) => { await store.logCueEvent("cue_fired", { cueId: request.params.cueId }); return reply.status(204).send(); diff --git a/services/api/src/state-store.ts b/services/api/src/state-store.ts index 2c22e7e..7ff56bf 100644 --- a/services/api/src/state-store.ts +++ b/services/api/src/state-store.ts @@ -580,6 +580,8 @@ export class StateStore { submission.status = payload.decision === "approved" ? "approved_all" + : payload.decision === "archive_only" + ? "archived" : payload.decision === "rejected" ? "rejected" : "pending_moderation"; @@ -711,4 +713,50 @@ export class StateStore { return state; }); } + + async deleteAsset(assetId: string) { + return this.update((state) => { + const asset = state.photoAssets.find((entry) => entry.id === assetId); + if (!asset) { + throw new Error("Asset not found."); + } + + const submission = state.submissions.find((entry) => entry.id === asset.submissionId); + if (submission?.source === "library_import") { + throw new Error("Library assets must be removed from the approved bank without deleting the source file."); + } + + state.photoAssets = state.photoAssets.filter((entry) => entry.id !== assetId); + state.collections = state.collections.map((collection) => ({ + ...collection, + assetIds: collection.assetIds.filter((entry) => entry !== assetId), + coverAssetId: collection.coverAssetId === assetId ? undefined : collection.coverAssetId + })); + state.cues = normalizeCueOrder( + state.cues.map((cue) => ({ + ...cue, + assetIds: cue.assetIds?.filter((entry) => entry !== assetId) + })) + ); + state.moderationDecisions = state.moderationDecisions.filter((decision) => decision.assetId !== assetId); + + const remainingSubmissionAssets = state.photoAssets.filter((entry) => entry.submissionId === asset.submissionId); + if (remainingSubmissionAssets.length === 0 && submission) { + state.submissions = state.submissions.filter((entry) => entry.id !== submission.id); + state.consents = state.consents.filter((entry) => entry.id !== submission.consentId); + state.sessionEvents = state.sessionEvents.filter((event) => { + const eventAssetId = typeof event.payload.assetId === "string" ? event.payload.assetId : null; + const eventSubmissionId = typeof event.payload.submissionId === "string" ? event.payload.submissionId : null; + return eventAssetId !== assetId && eventSubmissionId !== submission.id; + }); + } else { + state.sessionEvents = state.sessionEvents.filter((event) => { + const eventAssetId = typeof event.payload.assetId === "string" ? event.payload.assetId : null; + return eventAssetId !== assetId; + }); + } + + return state; + }); + } }