All files / src/server/api dispositions.ts

100% Statements 19/19
100% Branches 4/4
100% Functions 8/8
100% Lines 17/17

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              92x             4x 4x           87x 87x       87x               17x             17x 15x 15x 15x   15x 15x 12x                       13x 13x       63x      
import { z } from "zod";
import type { Database } from "@/lib/db";
import type { ItemDisposition, Disposition } from "@/types";
import { withTransaction, lockCollectionCurator } from "@/lib/transactions";
import { ItemDispositionRepository } from "@/server/repositories/implementations/ItemDispositionRepository";
import { CollectionPreferenceItemRepository } from "@/server/repositories/implementations/CollectionPreferenceItemRepository";
 
const SetDispositionSchema = z.object({
  itemId: z.string().min(1),
  disposition: z.enum(["set_aside", "deferred"]),
});
 
export class PreferenceExistsError extends Error {
  constructor() {
    super("Item already has a preference; cannot set a disposition on a triaged item");
    this.name = "PreferenceExistsError";
  }
}
 
export class DispositionController {
  constructor(
    private dispositionRepo: ItemDispositionRepository,
    private db: Database,
  ) {}
 
  static fromDb(db: Database): DispositionController {
    return new DispositionController(new ItemDispositionRepository(db), db);
  }
 
  async setDisposition(
    collectionId: string,
    curatorId: string,
    data: unknown,
  ): Promise<ItemDisposition> {
    const { itemId, disposition } = SetDispositionSchema.parse(data);
    // duv4: check-then-act (findByItem → upsert) on the plain handle raced the
    // triage path — an item could end up BOTH preference'd (by a concurrent
    // recompute) and disposed, the "impossible" state. Run inside a transaction
    // holding the SAME shared (collection,curator) advisory lock the decisions
    // route takes, and RE-CHECK findByItem under the lock so the preference guard
    // can't be invalidated between the read and the upsert.
    return withTransaction(this.db, async (tx) => {
      await lockCollectionCurator(tx, collectionId, curatorId);
      const preferenceRepo = new CollectionPreferenceItemRepository(tx);
      const dispositionRepo = new ItemDispositionRepository(tx);
      // disposed <=> untriaged: refuse if the item already has a preference for this curator.
      const existing = await preferenceRepo.findByItem(itemId, curatorId);
      if (existing) throw new PreferenceExistsError();
      return dispositionRepo.upsert({ collectionId, curatorId, itemId, disposition });
    }, {
      op: "set_disposition",
      ctx: { collectionId, curatorId, itemId },
    });
  }
 
  async listDispositions(
    collectionId: string,
    curatorId: string,
    disposition?: Disposition,
  ): Promise<ItemDisposition[]> {
    const all = await this.dispositionRepo.findByCollectionAndCurator(collectionId, curatorId);
    return disposition ? all.filter((d) => d.disposition === disposition) : all;
  }
 
  async bringBack(itemId: string, curatorId: string): Promise<number> {
    return this.dispositionRepo.delete(itemId, curatorId);
  }
}