Add approved asset removal controls

This commit is contained in:
2026-04-09 22:48:09 -07:00
parent 7150c67e33
commit 08c9cc4efb
5 changed files with 217 additions and 20 deletions
+38 -1
View File
@@ -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();
+48
View File
@@ -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;
});
}
}