All files / src/server/services ItemPurgeService.ts

100% Statements 23/23
100% Branches 4/4
100% Functions 8/8
100% Lines 21/21

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))));
  }
}