/** * Tracker items REST endpoints. * * GET /items — list/filter/search items * POST /items — create item * GET /items/:id — get single item * PUT /items/:id — update item * PATCH /items/:id/status — quick status transition * DELETE /items/:id — delete item * GET /items/stats — aggregate counts by type/status/priority */ import type { FastifyInstance } from "fastify"; import { DEFAULT_PRODUCT_ID } from "../../lib/product-config.js"; import { BadRequestError, NotFoundError } from "../../lib/errors.js"; import { extractAuth } from "../../lib/auth.js"; import * as repo from "./repository.js"; import { CreateItemSchema, UpdateItemSchema, UpdateStatusSchema, ListItemsQuerySchema, PRIORITY_ORDER, type TrackerItemDoc, } from "./types.js"; export async function itemRoutes(app: FastifyInstance) { // Stats — must be registered before :id param route app.get("/items/stats", async (req) => { const auth = await extractAuth(req); const { productId } = req.query as { productId?: string }; const pid = productId || DEFAULT_PRODUCT_ID; const { items } = await repo.list({ productId: pid, limit: 1000, offset: 0, sortBy: "createdAt", sortOrder: "desc", }); const byType: Record = {}; const byStatus: Record = {}; const byPriority: Record = {}; for (const item of items) { byType[item.type] = (byType[item.type] || 0) + 1; byStatus[item.status] = (byStatus[item.status] || 0) + 1; byPriority[item.priority] = (byPriority[item.priority] || 0) + 1; } return { productId: pid, total: items.length, byType, byStatus, byPriority, requestedBy: auth.sub, }; }); // List items app.get("/items", async (req) => { await extractAuth(req); const parsed = ListItemsQuerySchema.safeParse(req.query); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; ")); } const query = parsed.data; if (!query.productId) query.productId = DEFAULT_PRODUCT_ID; const { items, total } = await repo.list(query); return { items, total, limit: query.limit, offset: query.offset }; }); // Get item app.get("/items/:id", async (req) => { await extractAuth(req); const { id } = req.params as { id: string }; const item = await repo.getById(id); if (!item) throw new NotFoundError("Item not found"); return item; }); // Create item app.post("/items", async (req, reply) => { const auth = await extractAuth(req); const parsed = CreateItemSchema.safeParse(req.body); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; ")); } const input = parsed.data; const pid = input.productId || DEFAULT_PRODUCT_ID; const now = new Date().toISOString(); const doc: TrackerItemDoc = { id: `trk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, productId: pid, type: input.type, status: "open", priority: input.priority, title: input.title, description: input.description, labels: input.labels, assignee: input.assignee, reportedBy: auth.sub, source: input.source, visibility: input.visibility, voteCount: 0, commentCount: 0, priorityOrder: PRIORITY_ORDER[input.priority] ?? 2, targetRelease: input.targetRelease, createdAt: now, updatedAt: now, }; const created = await repo.create(doc); reply.code(201); return created; }); // Update item app.put("/items/:id", async (req) => { await extractAuth(req); const { id } = req.params as { id: string }; const existing = await repo.getById(id); if (!existing) throw new NotFoundError("Item not found"); const parsed = UpdateItemSchema.safeParse(req.body); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; ")); } const updates: Partial = { ...parsed.data }; if (parsed.data.priority) { updates.priorityOrder = PRIORITY_ORDER[parsed.data.priority] ?? 2; } const updated = await repo.update(id, updates); if (!updated) throw new NotFoundError("Item update failed"); return updated; }); // Quick status transition app.patch("/items/:id/status", async (req) => { await extractAuth(req); const { id } = req.params as { id: string }; const existing = await repo.getById(id); if (!existing) throw new NotFoundError("Item not found"); const parsed = UpdateStatusSchema.safeParse(req.body); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; ")); } const updated = await repo.update(id, { status: parsed.data.status }); if (!updated) throw new NotFoundError("Status update failed"); return updated; }); // Delete item app.delete("/items/:id", async (req) => { await extractAuth(req); const { id } = req.params as { id: string }; const existing = await repo.getById(id); if (!existing) throw new NotFoundError("Item not found"); await repo.remove(id); return { success: true }; }); }