- Copied as-is from learning_voice_ai_agent/services/tracker-service - 45 tests passing (vitest) - Fastify 5 + Cosmos DB + jose + Zod + @fastify/rate-limit - Modules: items, comments, votes, public - Port 4004
167 lines
5.3 KiB
TypeScript
167 lines
5.3 KiB
TypeScript
/**
|
|
* 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<string, number> = {};
|
|
const byStatus: Record<string, number> = {};
|
|
const byPriority: Record<string, number> = {};
|
|
|
|
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<TrackerItemDoc> = { ...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 };
|
|
});
|
|
}
|