Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 | 3x 3x 9x 9x 8x 9x 1x 8x 8x 8x 8x 8x 8x 8x 7x 7x 13x 7x 10x 2x 13x | import type { Database } from "@/lib/db";
import type { Item } from "@/types/domain";
import { withTransaction } from "@/lib/transactions";
import { lockCollectionCurators } from "@/server/services/purge-lock";
import { del } from "@vercel/blob";
import { deleteVariants, variantPrefix } from "@/lib/photo-variants";
import { ItemRepository } from "@/server/repositories/implementations/ItemRepository";
import { PhotoRepository } from "@/server/repositories/implementations/PhotoRepository";
import { CollectionPreferenceItemRepository } from "@/server/repositories/implementations/CollectionPreferenceItemRepository";
import { ItemDispositionRepository } from "@/server/repositories/implementations/ItemDispositionRepository";
import { SortResultItemRepository } from "@/server/repositories/implementations/SortResultItemRepository";
import { AuditLogRepository } from "@/server/repositories/implementations/AuditLogRepository";
/**
* Thrown when purge targets an item that is referenced by any sort_result_item
* (draft or finalized). The route maps it to 409 — the owner must first detach
* the item from its draft results (POST /api/items/[id]/remove-from-sorts);
* finalized results are immutable, so a finalized membership is a hard block.
*/
export class ItemInResultError extends Error {
constructor() {
super(
"Item is part of a sort result — remove it from sorts first " +
"(POST /api/items/[id]/remove-from-sorts), or it is in a finalized " +
"result and cannot be purged",
);
this.name = "ItemInResultError";
}
}
/**
* Permanent owner-decided removal of an item. The route has already resolved the
* item and gated the caller as the collection owner; this service performs the
* destructive work.
*
* 409 guard FIRST (pre-transaction): an item in ANY sort result is non-purgeable.
* Then, children-first hard delete in ONE transaction (preferences + photos carry
* the items FK, which has no ON DELETE CASCADE, so they must go before the item).
* Blobs are cleaned up best-effort AFTER commit — orphaned blobs on a `del`
* failure are inert and sweepable; a blob failure must never roll back a
* committed DB delete.
*
* Invariant: inside the transaction, EVERY write goes through a tx-bound repo
* (`new XRepository(tx)`), never an injected/`this.db` repo — a non-tx write
* would not roll back with the transaction.
*/
export class ItemPurgeService {
constructor(
private db: Database,
private sortResultItemRepo: SortResultItemRepository,
) {}
static fromDb(db: Database): ItemPurgeService {
return new ItemPurgeService(db, new SortResultItemRepository(db));
}
async purge(item: Item, actorId: string): Promise<void> {
if (await this.sortResultItemRepo.existsByItem(item.id)) {
throw new ItemInResultError();
}
const photos = await withTransaction(this.db, async (tx) => {
await lockCollectionCurators(tx, item.collectionId);
const preferencesDeleted = await new CollectionPreferenceItemRepository(tx).deleteByItem(item.id);
// The item_dispositions FK has no ON DELETE CASCADE, so a leftover
// disposition would block the item hard-delete with a 23503.
await new ItemDispositionRepository(tx).deleteByItem(item.id);
const keys = await new PhotoRepository(tx).hardDeleteByItem(item.id);
await new ItemRepository(tx).hardDelete(item.id);
// Tx-bound by construction — a committed purge always has exactly one audit
// row; if this insert throws, the whole purge rolls back.
await new AuditLogRepository(tx).record({
actorId,
action: "item.purge",
targetType: "item",
targetId: item.id,
collectionId: item.collectionId,
metadata: { photosDeleted: keys.length, preferencesDeleted },
});
return keys;
});
// Best-effort, post-commit. Only http(s) blob keys are real blob URLs
// (matches the photo DELETE route's guard); a `del` rejection is logged, not
// fatal — the DB delete already committed.
const results = await Promise.allSettled(
photos.filter((p) => p.blobKey.startsWith("http")).map((p) => del(p.blobKey)),
);
for (const r of results) {
if (r.status === "rejected") {
console.error("ItemPurgeService: blob del failed (orphaned, sweepable)", r.reason);
}
}
// Best-effort: sweep each photo's persisted resized variants. Without this
// every purge leaks its variants forever (deleteVariants never throws).
await Promise.all(photos.map((p) => deleteVariants(variantPrefix(item.collectionId, p.id))));
}
}
|