All files / src/server/api items.ts

95% Statements 19/20
100% Branches 6/6
92.3% Functions 12/13
94.73% Lines 18/19

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 101 102 103 104 105 106 107 108 109 110 111 112 113 114                  25x                             25x 175x 25x 25x                               25x 28x     123x     113x               57x       30x       7x 7x                                 35x       3x       16x 16x                           2x            
import { z } from "zod";
import type { Item } from "@/types";
import type { Database } from "@/lib/db";
import {
  ItemRepository,
  type FindByCollectionOptions,
} from "@/server/repositories/implementations/ItemRepository";
 
// Same length caps as UpdateItemSchema, so create can't store what an edit would refuse.
const CreateItemSchema = z.object({
  name: z.string().min(1).max(200),
  brand: z.string().max(200).optional(),
  style: z.string().max(200).optional(),
  color: z.string().max(200).optional(),
  material: z.string().max(200).optional(),
  category: z.string().max(200).optional(),
  status: z.string().optional(),
  notes: z.string().max(5000).optional(),
  story: z.string().max(2000, "Story is too long").optional(),
});
 
// Strict: an unknown key is a 400, never a silent 200 (spec ยง3). Text clears persist as
// null; a blank string is a clear. Measures are numbers on the wire, numeric strings in
// the DB (drizzle `numeric`). zod 4's z.number() already rejects NaN/Infinity.
const clearableText = (max: number) =>
  z.string().trim().max(max).transform((s) => (s === "" ? null : s)).nullable().optional();
const measure = z.number().nonnegative().max(100_000).nullable().optional();
const UpdateItemSchema = z.strictObject({
  name: z.string().trim().min(1).max(200).optional(),
  brand: clearableText(200),
  style: clearableText(200),
  color: clearableText(200),
  material: clearableText(200),
  category: clearableText(200),
  status: z.string().optional(),
  notes: clearableText(5000),
  story: clearableText(2000),
  height: measure,
  width: measure,
  depth: measure,
  weight: measure,
  unit: z.enum(["imperial", "metric"]).optional(),
});
const toNumeric = (n: number | null | undefined) =>
  n === undefined ? undefined : n === null ? null : String(n);
 
export class ItemController {
  constructor(private itemRepo: ItemRepository) {}
 
  static fromDb(db: Database): ItemController {
    return new ItemController(new ItemRepository(db));
  }
 
  // `options.orderBy: "number"` for a stable grid order; omit it for an unordered read.
  async listItems(
    collectionId: string,
    options?: FindByCollectionOptions,
  ): Promise<Item[]> {
    return this.itemRepo.findByCollection(collectionId, options);
  }
 
  async listArchived(collectionId: string): Promise<Item[]> {
    return this.itemRepo.findArchivedByCollection(collectionId);
  }
 
  async createItem(collectionId: string, data: unknown): Promise<Item> {
    const parsed = CreateItemSchema.parse(data);
    return this.itemRepo.create({
      collectionId,
      name: parsed.name,
      brand: parsed.brand,
      style: parsed.style,
      color: parsed.color,
      material: parsed.material,
      category: parsed.category,
      status: parsed.status,
      notes: parsed.notes,
      story: parsed.story,
    });
  }
 
  async getItem(itemId: string): Promise<Item | null> {
    // Returns null for an absent/soft-deleted item so the route maps it to 404
    // (a thrown "not found" was previously caught as a generic 500).
    return this.itemRepo.findById(itemId);
  }
 
  async getByCollectionAndNumber(collectionId: string, number: number): Promise<Item | null> {
    return this.itemRepo.findByCollectionAndNumber(collectionId, number);
  }
 
  async updateItem(itemId: string, data: unknown): Promise<Item> {
    const { height, width, depth, weight, ...rest } = UpdateItemSchema.parse(data);
    return this.itemRepo.update(itemId, {
      ...rest,
      height: toNumeric(height),
      width: toNumeric(width),
      depth: toNumeric(depth),
      weight: toNumeric(weight),
    });
  }
 
  async deleteItem(itemId: string): Promise<Item> {
    return this.itemRepo.softDelete(itemId);
  }
 
  async restoreItem(itemId: string): Promise<Item> {
    return this.itemRepo.update(itemId, {
      deletedAt: null,
      updatedAt: new Date(),
    });
  }
}