learning_ai_common_plat/services/tracker-service/src/modules/items/routes.ts
saravanakumardb1 2738124ab9 feat(services): add tracker-service (items, comments, votes, public roadmap)
- 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
2026-02-12 11:39:17 -08:00

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