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