From 2738124ab9a5aa845cbbc1f20acc63c14876e95e Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 12 Feb 2026 11:39:17 -0800 Subject: [PATCH] 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 --- services/tracker-service/.gitignore | 2 + services/tracker-service/Dockerfile | 16 ++ services/tracker-service/README.md | 57 ++++++ services/tracker-service/package.json | 31 ++++ services/tracker-service/src/lib/auth.ts | 51 ++++++ services/tracker-service/src/lib/config.ts | 23 +++ services/tracker-service/src/lib/cosmos.ts | 24 +++ .../tracker-service/src/lib/errors.test.ts | 59 ++++++ services/tracker-service/src/lib/errors.ts | 43 +++++ .../tracker-service/src/lib/product-config.ts | 6 + .../src/modules/comments/comments.test.ts | 40 ++++ .../src/modules/comments/repository.ts | 68 +++++++ .../src/modules/comments/routes.ts | 94 ++++++++++ .../src/modules/comments/types.ts | 27 +++ .../src/modules/items/items.test.ts | 134 ++++++++++++++ .../src/modules/items/repository.ts | 126 +++++++++++++ .../src/modules/items/routes.ts | 166 +++++++++++++++++ .../src/modules/items/types.ts | 88 +++++++++ .../src/modules/public/public.test.ts | 150 +++++++++++++++ .../src/modules/public/routes.ts | 171 ++++++++++++++++++ .../src/modules/public/types.ts | 35 ++++ .../src/modules/votes/repository.ts | 57 ++++++ .../src/modules/votes/routes.ts | 59 ++++++ .../src/modules/votes/types.ts | 11 ++ services/tracker-service/src/server.ts | 83 +++++++++ services/tracker-service/tsconfig.json | 19 ++ 26 files changed, 1640 insertions(+) create mode 100644 services/tracker-service/.gitignore create mode 100644 services/tracker-service/Dockerfile create mode 100644 services/tracker-service/README.md create mode 100644 services/tracker-service/package.json create mode 100644 services/tracker-service/src/lib/auth.ts create mode 100644 services/tracker-service/src/lib/config.ts create mode 100644 services/tracker-service/src/lib/cosmos.ts create mode 100644 services/tracker-service/src/lib/errors.test.ts create mode 100644 services/tracker-service/src/lib/errors.ts create mode 100644 services/tracker-service/src/lib/product-config.ts create mode 100644 services/tracker-service/src/modules/comments/comments.test.ts create mode 100644 services/tracker-service/src/modules/comments/repository.ts create mode 100644 services/tracker-service/src/modules/comments/routes.ts create mode 100644 services/tracker-service/src/modules/comments/types.ts create mode 100644 services/tracker-service/src/modules/items/items.test.ts create mode 100644 services/tracker-service/src/modules/items/repository.ts create mode 100644 services/tracker-service/src/modules/items/routes.ts create mode 100644 services/tracker-service/src/modules/items/types.ts create mode 100644 services/tracker-service/src/modules/public/public.test.ts create mode 100644 services/tracker-service/src/modules/public/routes.ts create mode 100644 services/tracker-service/src/modules/public/types.ts create mode 100644 services/tracker-service/src/modules/votes/repository.ts create mode 100644 services/tracker-service/src/modules/votes/routes.ts create mode 100644 services/tracker-service/src/modules/votes/types.ts create mode 100644 services/tracker-service/src/server.ts create mode 100644 services/tracker-service/tsconfig.json diff --git a/services/tracker-service/.gitignore b/services/tracker-service/.gitignore new file mode 100644 index 00000000..b9470778 --- /dev/null +++ b/services/tracker-service/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/services/tracker-service/Dockerfile b/services/tracker-service/Dockerfile new file mode 100644 index 00000000..f2c18aa8 --- /dev/null +++ b/services/tracker-service/Dockerfile @@ -0,0 +1,16 @@ +FROM node:22-alpine AS builder +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci +COPY tsconfig.json ./ +COPY src/ src/ +RUN npm run build + +FROM node:22-alpine +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev +COPY --from=builder /app/dist ./dist +ENV NODE_ENV=production +EXPOSE 4004 +CMD ["node", "dist/server.js"] diff --git a/services/tracker-service/README.md b/services/tracker-service/README.md new file mode 100644 index 00000000..e068519e --- /dev/null +++ b/services/tracker-service/README.md @@ -0,0 +1,57 @@ +# Tracker Service + +Product-agnostic issue tracker for feature requests, bugs, and task management. +Built with Fastify + TypeScript + Azure Cosmos DB. + +## Port + +`4004` (configurable via `PORT` env var) + +## Modules + +- **items** — CRUD for tracker items (bugs, features, tasks) with filtering, pagination, and stats +- **comments** — Threaded discussion on items +- **votes** — Upvote toggle (1 per user per item) + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/items` | List/filter/search items | +| POST | `/items` | Create item | +| GET | `/items/stats` | Aggregate counts by type/status/priority | +| 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/:itemId/comments` | List comments | +| POST | `/items/:itemId/comments` | Add comment | +| PUT | `/items/:itemId/comments/:id` | Edit comment | +| DELETE | `/items/:itemId/comments/:id` | Delete comment | +| POST | `/items/:itemId/vote` | Toggle upvote | +| GET | `/items/:itemId/votes` | List voters | +| GET | `/health` | Health check | + +## Setup + +```bash +cp .env .env # fill in values +npm install +npm run dev # starts with tsx watch on port 4004 +``` + +## Testing + +```bash +npm test # vitest run (29 tests) +``` + +## Environment Variables + +See `.env` for required variables: +- `COSMOS_ENDPOINT` — Azure Cosmos DB endpoint +- `COSMOS_KEY` — Cosmos DB primary key +- `COSMOS_DATABASE` — Database name +- `JWT_SECRET` — Shared secret for JWT verification (from platform-service) +- `DEFAULT_PRODUCT_ID` — Default product scope (e.g., `lysnrai`) +- `PORT` — Server port (default `4004`) diff --git a/services/tracker-service/package.json b/services/tracker-service/package.json new file mode 100644 index 00000000..ab585edc --- /dev/null +++ b/services/tracker-service/package.json @@ -0,0 +1,31 @@ +{ + "name": "@lysnrai/tracker-service", + "version": "0.1.0", + "private": true, + "description": "Tracker Service — feature requests, bugs, tasks management (product-agnostic)", + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc", + "start": "node dist/server.js", + "test": "vitest run", + "test:watch": "vitest", + "lint": "eslint src/" + }, + "dependencies": { + "@azure/cosmos": "^4.2.0", + "@fastify/cors": "^10.0.2", + "@fastify/rate-limit": "^10.3.0", + "@fastify/swagger": "^9.4.2", + "fastify": "^5.2.1", + "fastify-metrics": "^10.3.0", + "jose": "^6.0.8", + "zod": "^3.24.2" + }, + "devDependencies": { + "@types/node": "^22.12.0", + "tsx": "^4.19.2", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + } +} diff --git a/services/tracker-service/src/lib/auth.ts b/services/tracker-service/src/lib/auth.ts new file mode 100644 index 00000000..81635ac7 --- /dev/null +++ b/services/tracker-service/src/lib/auth.ts @@ -0,0 +1,51 @@ +/** + * JWT auth middleware — validates tokens issued by platform-service. + * Shares the same JWT_SECRET so it can verify without network calls. + */ + +import { jwtVerify } from "jose"; +import type { FastifyRequest } from "fastify"; +import { UnauthorizedError, ForbiddenError } from "./errors.js"; + +export interface AuthPayload { + sub: string; + email?: string; + role?: string; + productId?: string; + type?: string; +} + +function getSecret(): Uint8Array { + const secret = process.env.JWT_SECRET; + if (!secret) throw new Error("JWT_SECRET must be set"); + return new TextEncoder().encode(secret); +} + +export async function verifyToken(token: string): Promise { + const { payload } = await jwtVerify(token, getSecret()); + return payload as AuthPayload; +} + +export async function extractAuth(req: FastifyRequest): Promise { + const auth = req.headers.authorization; + if (!auth?.startsWith("Bearer ")) throw new UnauthorizedError(); + const token = auth.slice(7); + try { + const payload = await verifyToken(token); + if (payload.type !== "access") throw new Error("Not an access token"); + return payload; + } catch { + throw new UnauthorizedError("Invalid or expired token"); + } +} + +export async function requireRole( + req: FastifyRequest, + ...roles: string[] +): Promise { + const payload = await extractAuth(req); + if (roles.length > 0 && (!payload.role || !roles.includes(payload.role))) { + throw new ForbiddenError("Insufficient permissions"); + } + return payload; +} diff --git a/services/tracker-service/src/lib/config.ts b/services/tracker-service/src/lib/config.ts new file mode 100644 index 00000000..06ceee09 --- /dev/null +++ b/services/tracker-service/src/lib/config.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +const envSchema = z.object({ + // Server + PORT: z.coerce.number().default(4004), + HOST: z.string().default("0.0.0.0"), + NODE_ENV: z.enum(["development", "production", "test"]).default("development"), + CORS_ORIGIN: z.string().optional(), + SERVICE_NAME: z.string().default("tracker-service"), + + // Database + COSMOS_ENDPOINT: z.string().min(1, "COSMOS_ENDPOINT is required"), + COSMOS_KEY: z.string().min(1, "COSMOS_KEY is required"), + COSMOS_DATABASE: z.string().default("lysnrai"), + + // Auth + JWT_SECRET: z.string().min(1, "JWT_SECRET is required"), + + // Product + DEFAULT_PRODUCT_ID: z.string().default("lysnrai"), +}); + +export const config = envSchema.parse(process.env); diff --git a/services/tracker-service/src/lib/cosmos.ts b/services/tracker-service/src/lib/cosmos.ts new file mode 100644 index 00000000..633f3f5e --- /dev/null +++ b/services/tracker-service/src/lib/cosmos.ts @@ -0,0 +1,24 @@ +/** + * Shared Cosmos DB client for the Tracker Service. + */ + +import { CosmosClient, Container } from "@azure/cosmos"; + +let client: CosmosClient | null = null; + +function getClient(): CosmosClient { + if (!client) { + const endpoint = process.env.COSMOS_ENDPOINT; + const key = process.env.COSMOS_KEY; + if (!endpoint || !key) { + throw new Error("COSMOS_ENDPOINT and COSMOS_KEY must be set"); + } + client = new CosmosClient({ endpoint, key }); + } + return client; +} + +export function getContainer(name: string): Container { + const database = process.env.COSMOS_DATABASE || "lysnrai"; + return getClient().database(database).container(name); +} diff --git a/services/tracker-service/src/lib/errors.test.ts b/services/tracker-service/src/lib/errors.test.ts new file mode 100644 index 00000000..b0866e44 --- /dev/null +++ b/services/tracker-service/src/lib/errors.test.ts @@ -0,0 +1,59 @@ +/** + * Error classes unit tests. + */ + +import { describe, it, expect } from "vitest"; +import { + ServiceError, + NotFoundError, + BadRequestError, + UnauthorizedError, + ForbiddenError, + ConflictError, +} from "./errors.js"; + +describe("ServiceError", () => { + it("creates error with status code", () => { + const err = new ServiceError(418, "I'm a teapot"); + expect(err.statusCode).toBe(418); + expect(err.message).toBe("I'm a teapot"); + expect(err.name).toBe("ServiceError"); + }); +}); + +describe("typed errors", () => { + it("NotFoundError is 404", () => { + const err = new NotFoundError(); + expect(err.statusCode).toBe(404); + expect(err.message).toBe("Not found"); + }); + + it("BadRequestError is 400", () => { + const err = new BadRequestError("Invalid input"); + expect(err.statusCode).toBe(400); + expect(err.message).toBe("Invalid input"); + }); + + it("UnauthorizedError is 401", () => { + const err = new UnauthorizedError(); + expect(err.statusCode).toBe(401); + }); + + it("ForbiddenError is 403", () => { + const err = new ForbiddenError(); + expect(err.statusCode).toBe(403); + }); + + it("ConflictError is 409", () => { + const err = new ConflictError(); + expect(err.statusCode).toBe(409); + }); + + it("all extend ServiceError", () => { + expect(new NotFoundError()).toBeInstanceOf(ServiceError); + expect(new BadRequestError()).toBeInstanceOf(ServiceError); + expect(new UnauthorizedError()).toBeInstanceOf(ServiceError); + expect(new ForbiddenError()).toBeInstanceOf(ServiceError); + expect(new ConflictError()).toBeInstanceOf(ServiceError); + }); +}); diff --git a/services/tracker-service/src/lib/errors.ts b/services/tracker-service/src/lib/errors.ts new file mode 100644 index 00000000..520f604d --- /dev/null +++ b/services/tracker-service/src/lib/errors.ts @@ -0,0 +1,43 @@ +/** + * Typed service errors for consistent HTTP error responses. + */ + +export class ServiceError extends Error { + constructor( + public statusCode: number, + message: string, + ) { + super(message); + this.name = "ServiceError"; + } +} + +export class NotFoundError extends ServiceError { + constructor(message = "Not found") { + super(404, message); + } +} + +export class BadRequestError extends ServiceError { + constructor(message = "Bad request") { + super(400, message); + } +} + +export class UnauthorizedError extends ServiceError { + constructor(message = "Unauthorized") { + super(401, message); + } +} + +export class ForbiddenError extends ServiceError { + constructor(message = "Forbidden") { + super(403, message); + } +} + +export class ConflictError extends ServiceError { + constructor(message = "Conflict") { + super(409, message); + } +} diff --git a/services/tracker-service/src/lib/product-config.ts b/services/tracker-service/src/lib/product-config.ts new file mode 100644 index 00000000..5dba54f8 --- /dev/null +++ b/services/tracker-service/src/lib/product-config.ts @@ -0,0 +1,6 @@ +/** + * Default product identity — used when productId is not specified in requests. + * The tracker service is product-agnostic; every document carries its own productId. + */ + +export const DEFAULT_PRODUCT_ID = process.env.DEFAULT_PRODUCT_ID || "lysnrai"; diff --git a/services/tracker-service/src/modules/comments/comments.test.ts b/services/tracker-service/src/modules/comments/comments.test.ts new file mode 100644 index 00000000..697a4f53 --- /dev/null +++ b/services/tracker-service/src/modules/comments/comments.test.ts @@ -0,0 +1,40 @@ +/** + * Comments module unit tests — validates schema parsing. + */ + +import { describe, it, expect } from "vitest"; +import { CreateCommentSchema, UpdateCommentSchema } from "./types.js"; + +describe("CreateCommentSchema", () => { + it("accepts valid comment", () => { + const result = CreateCommentSchema.safeParse({ body: "This is a comment" }); + expect(result.success).toBe(true); + }); + + it("rejects empty body", () => { + const result = CreateCommentSchema.safeParse({ body: "" }); + expect(result.success).toBe(false); + }); + + it("rejects missing body", () => { + const result = CreateCommentSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it("rejects body exceeding 10000 chars", () => { + const result = CreateCommentSchema.safeParse({ body: "a".repeat(10001) }); + expect(result.success).toBe(false); + }); +}); + +describe("UpdateCommentSchema", () => { + it("accepts valid update", () => { + const result = UpdateCommentSchema.safeParse({ body: "Updated comment" }); + expect(result.success).toBe(true); + }); + + it("rejects empty body", () => { + const result = UpdateCommentSchema.safeParse({ body: "" }); + expect(result.success).toBe(false); + }); +}); diff --git a/services/tracker-service/src/modules/comments/repository.ts b/services/tracker-service/src/modules/comments/repository.ts new file mode 100644 index 00000000..1ff8b201 --- /dev/null +++ b/services/tracker-service/src/modules/comments/repository.ts @@ -0,0 +1,68 @@ +/** + * Comments repository — Cosmos DB CRUD. + */ + +import { getContainer } from "../../lib/cosmos.js"; +import type { CommentDoc } from "./types.js"; + +function container() { + return getContainer("tracker_comments"); +} + +export async function listByItem(itemId: string): Promise { + const { resources } = await container().items + .query({ + query: "SELECT * FROM c WHERE c.itemId = @itemId ORDER BY c.createdAt ASC", + parameters: [{ name: "@itemId", value: itemId }], + }) + .fetchAll(); + return resources; +} + +export async function getById(id: string): Promise { + try { + const { resource } = await container().item(id, id).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function create(doc: CommentDoc): Promise { + const { resource } = await container().items.create(doc); + return resource as CommentDoc; +} + +export async function update( + id: string, + updates: Partial, +): Promise { + try { + const { resource: existing } = await container().item(id, id).read(); + if (!existing) return null; + const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() }; + const { resource } = await container().item(id, id).replace(merged); + return resource as CommentDoc; + } catch { + return null; + } +} + +export async function remove(id: string): Promise { + try { + await container().item(id, id).delete(); + return true; + } catch { + return false; + } +} + +export async function countByItem(itemId: string): Promise { + const { resources } = await container().items + .query({ + query: "SELECT VALUE COUNT(1) FROM c WHERE c.itemId = @itemId", + parameters: [{ name: "@itemId", value: itemId }], + }) + .fetchAll(); + return resources[0] ?? 0; +} diff --git a/services/tracker-service/src/modules/comments/routes.ts b/services/tracker-service/src/modules/comments/routes.ts new file mode 100644 index 00000000..a6c0e906 --- /dev/null +++ b/services/tracker-service/src/modules/comments/routes.ts @@ -0,0 +1,94 @@ +/** + * Comments REST endpoints. + * + * GET /items/:itemId/comments — list comments for an item + * POST /items/:itemId/comments — add comment + * PUT /items/:itemId/comments/:id — edit comment (author only) + * DELETE /items/:itemId/comments/:id — delete comment (author or admin) + */ + +import type { FastifyInstance } from "fastify"; +import { BadRequestError, NotFoundError, ForbiddenError } from "../../lib/errors.js"; +import { extractAuth } from "../../lib/auth.js"; +import * as repo from "./repository.js"; +import * as itemRepo from "../items/repository.js"; +import { CreateCommentSchema, UpdateCommentSchema, type CommentDoc } from "./types.js"; + +export async function commentRoutes(app: FastifyInstance) { + // List comments + app.get("/items/:itemId/comments", async (req) => { + await extractAuth(req); + const { itemId } = req.params as { itemId: string }; + const item = await itemRepo.getById(itemId); + if (!item) throw new NotFoundError("Item not found"); + const comments = await repo.listByItem(itemId); + return { comments, count: comments.length }; + }); + + // Add comment + app.post("/items/:itemId/comments", async (req, reply) => { + const auth = await extractAuth(req); + const { itemId } = req.params as { itemId: string }; + const item = await itemRepo.getById(itemId); + if (!item) throw new NotFoundError("Item not found"); + + const parsed = CreateCommentSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; ")); + } + + const now = new Date().toISOString(); + const doc: CommentDoc = { + id: `cmt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + itemId, + productId: item.productId, + authorId: auth.sub, + authorEmail: auth.email ?? null, + body: parsed.data.body, + createdAt: now, + updatedAt: now, + }; + + const created = await repo.create(doc); + await itemRepo.incrementCommentCount(itemId, 1); + reply.code(201); + return created; + }); + + // Edit comment + app.put("/items/:itemId/comments/:id", async (req) => { + const auth = await extractAuth(req); + const { itemId, id } = req.params as { itemId: string; id: string }; + + const comment = await repo.getById(id); + if (!comment || comment.itemId !== itemId) throw new NotFoundError("Comment not found"); + if (comment.authorId !== auth.sub && auth.role !== "admin") { + throw new ForbiddenError("Only the author or admin can edit this comment"); + } + + const parsed = UpdateCommentSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; ")); + } + + const updated = await repo.update(id, { body: parsed.data.body }); + if (!updated) throw new NotFoundError("Comment update failed"); + return updated; + }); + + // Delete comment + app.delete("/items/:itemId/comments/:id", async (req) => { + const auth = await extractAuth(req); + const { itemId, id } = req.params as { itemId: string; id: string }; + + const comment = await repo.getById(id); + if (!comment || comment.itemId !== itemId) throw new NotFoundError("Comment not found"); + if (comment.authorId !== auth.sub && auth.role !== "admin") { + throw new ForbiddenError("Only the author or admin can delete this comment"); + } + + await repo.remove(id); + await itemRepo.incrementCommentCount(itemId, -1); + return { success: true }; + }); +} diff --git a/services/tracker-service/src/modules/comments/types.ts b/services/tracker-service/src/modules/comments/types.ts new file mode 100644 index 00000000..e8888683 --- /dev/null +++ b/services/tracker-service/src/modules/comments/types.ts @@ -0,0 +1,27 @@ +/** + * Comment types — threaded discussion on tracker items. + */ + +import { z } from "zod"; + +export interface CommentDoc { + id: string; + itemId: string; + productId: string; + authorId: string; + authorEmail: string | null; + body: string; + createdAt: string; + updatedAt: string; +} + +export const CreateCommentSchema = z.object({ + body: z.string().min(1).max(10000), +}); + +export const UpdateCommentSchema = z.object({ + body: z.string().min(1).max(10000), +}); + +export type CreateCommentInput = z.infer; +export type UpdateCommentInput = z.infer; diff --git a/services/tracker-service/src/modules/items/items.test.ts b/services/tracker-service/src/modules/items/items.test.ts new file mode 100644 index 00000000..d4354b2f --- /dev/null +++ b/services/tracker-service/src/modules/items/items.test.ts @@ -0,0 +1,134 @@ +/** + * Items module unit tests — validates schema parsing and type guards. + */ + +import { describe, it, expect } from "vitest"; +import { + CreateItemSchema, + UpdateItemSchema, + UpdateStatusSchema, + ListItemsQuerySchema, + ITEM_TYPES, + ITEM_STATUSES, + ITEM_PRIORITIES, +} from "./types.js"; + +describe("CreateItemSchema", () => { + it("accepts valid bug input", () => { + const result = CreateItemSchema.safeParse({ + type: "bug", + title: "App crashes on startup", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("bug"); + expect(result.data.priority).toBe("medium"); + expect(result.data.labels).toEqual([]); + expect(result.data.source).toBe("internal"); + } + }); + + it("accepts valid feature request with all fields", () => { + const result = CreateItemSchema.safeParse({ + type: "feature", + priority: "high", + title: "Add dark mode", + description: "Users want dark mode support", + labels: ["ui", "theme"], + assignee: "usr_123", + targetRelease: "1.2.0", + }); + expect(result.success).toBe(true); + }); + + it("rejects missing title", () => { + const result = CreateItemSchema.safeParse({ type: "bug" }); + expect(result.success).toBe(false); + }); + + it("rejects invalid type", () => { + const result = CreateItemSchema.safeParse({ type: "epic", title: "Test" }); + expect(result.success).toBe(false); + }); + + it("rejects invalid priority", () => { + const result = CreateItemSchema.safeParse({ + type: "task", + title: "Test", + priority: "urgent", + }); + expect(result.success).toBe(false); + }); +}); + +describe("UpdateItemSchema", () => { + it("accepts partial updates", () => { + const result = UpdateItemSchema.safeParse({ priority: "critical" }); + expect(result.success).toBe(true); + }); + + it("accepts empty object", () => { + const result = UpdateItemSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it("rejects invalid priority", () => { + const result = UpdateItemSchema.safeParse({ priority: "urgent" }); + expect(result.success).toBe(false); + }); +}); + +describe("UpdateStatusSchema", () => { + it("accepts valid status", () => { + for (const status of ITEM_STATUSES) { + const result = UpdateStatusSchema.safeParse({ status }); + expect(result.success).toBe(true); + } + }); + + it("rejects invalid status", () => { + const result = UpdateStatusSchema.safeParse({ status: "archived" }); + expect(result.success).toBe(false); + }); +}); + +describe("ListItemsQuerySchema", () => { + it("provides defaults for empty query", () => { + const result = ListItemsQuerySchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.sortBy).toBe("createdAt"); + expect(result.data.sortOrder).toBe("desc"); + expect(result.data.limit).toBe(50); + expect(result.data.offset).toBe(0); + } + }); + + it("coerces string numbers", () => { + const result = ListItemsQuerySchema.safeParse({ limit: "25", offset: "10" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(25); + expect(result.data.offset).toBe(10); + } + }); + + it("rejects limit > 100", () => { + const result = ListItemsQuerySchema.safeParse({ limit: 200 }); + expect(result.success).toBe(false); + }); +}); + +describe("type constants", () => { + it("has expected item types", () => { + expect(ITEM_TYPES).toEqual(["bug", "feature", "task"]); + }); + + it("has expected statuses", () => { + expect(ITEM_STATUSES).toEqual(["open", "in_progress", "done", "closed", "wont_fix"]); + }); + + it("has expected priorities", () => { + expect(ITEM_PRIORITIES).toEqual(["critical", "high", "medium", "low"]); + }); +}); diff --git a/services/tracker-service/src/modules/items/repository.ts b/services/tracker-service/src/modules/items/repository.ts new file mode 100644 index 00000000..9e7877ac --- /dev/null +++ b/services/tracker-service/src/modules/items/repository.ts @@ -0,0 +1,126 @@ +/** + * Tracker items repository — Cosmos DB CRUD. + */ + +import { getContainer } from "../../lib/cosmos.js"; +import type { TrackerItemDoc, ListItemsQuery } from "./types.js"; + +function container() { + return getContainer("tracker_items"); +} + +export async function list(query: ListItemsQuery): Promise<{ items: TrackerItemDoc[]; total: number }> { + const conditions: string[] = []; + const params: { name: string; value: string | number }[] = []; + + if (query.productId) { + conditions.push("c.productId = @productId"); + params.push({ name: "@productId", value: query.productId }); + } + if (query.type) { + conditions.push("c.type = @type"); + params.push({ name: "@type", value: query.type }); + } + if (query.status) { + conditions.push("c.status = @status"); + params.push({ name: "@status", value: query.status }); + } + if (query.priority) { + conditions.push("c.priority = @priority"); + params.push({ name: "@priority", value: query.priority }); + } + if (query.assignee) { + conditions.push("c.assignee = @assignee"); + params.push({ name: "@assignee", value: query.assignee }); + } + if (query.label) { + conditions.push("ARRAY_CONTAINS(c.labels, @label)"); + params.push({ name: "@label", value: query.label }); + } + if (query.visibility) { + conditions.push("c.visibility = @visibility"); + params.push({ name: "@visibility", value: query.visibility }); + } + if (query.q) { + conditions.push("(CONTAINS(LOWER(c.title), LOWER(@q)) OR CONTAINS(LOWER(c.description), LOWER(@q)))"); + params.push({ name: "@q", value: query.q }); + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + // Priority sort needs special handling — map to numeric + const sortField = query.sortBy === "priority" ? "c.priorityOrder" : `c.${query.sortBy}`; + const orderDir = query.sortOrder.toUpperCase(); + + // Count query + const countResult = await container().items + .query({ + query: `SELECT VALUE COUNT(1) FROM c ${where}`, + parameters: params, + }) + .fetchAll(); + const total = countResult.resources[0] ?? 0; + + // Data query with pagination + const { resources } = await container().items + .query({ + query: `SELECT * FROM c ${where} ORDER BY ${sortField} ${orderDir} OFFSET @offset LIMIT @limit`, + parameters: [ + ...params, + { name: "@offset", value: query.offset }, + { name: "@limit", value: query.limit }, + ], + }) + .fetchAll(); + + return { items: resources, total }; +} + +export async function getById(id: string): Promise { + try { + const { resource } = await container().item(id, id).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function create(doc: TrackerItemDoc): Promise { + const { resource } = await container().items.create(doc); + return resource as TrackerItemDoc; +} + +export async function update( + id: string, + updates: Partial, +): Promise { + try { + const { resource: existing } = await container().item(id, id).read(); + if (!existing) return null; + const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() }; + const { resource } = await container().item(id, id).replace(merged); + return resource as TrackerItemDoc; + } catch { + return null; + } +} + +export async function remove(id: string): Promise { + try { + await container().item(id, id).delete(); + return true; + } catch { + return false; + } +} + +export async function incrementCommentCount(id: string, delta: number): Promise { + const item = await getById(id); + if (item) { + await update(id, { commentCount: Math.max(0, item.commentCount + delta) }); + } +} + +export async function updateVoteCount(id: string, voteCount: number): Promise { + await update(id, { voteCount }); +} diff --git a/services/tracker-service/src/modules/items/routes.ts b/services/tracker-service/src/modules/items/routes.ts new file mode 100644 index 00000000..8cafdf0f --- /dev/null +++ b/services/tracker-service/src/modules/items/routes.ts @@ -0,0 +1,166 @@ +/** + * 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 }; + }); +} diff --git a/services/tracker-service/src/modules/items/types.ts b/services/tracker-service/src/modules/items/types.ts new file mode 100644 index 00000000..896f1e39 --- /dev/null +++ b/services/tracker-service/src/modules/items/types.ts @@ -0,0 +1,88 @@ +/** + * Tracker item types — bugs, feature requests, tasks. + * Product-agnostic: every item carries a productId for multi-product support. + */ + +import { z } from "zod"; + +export const ITEM_TYPES = ["bug", "feature", "task"] as const; +export const ITEM_STATUSES = ["open", "in_progress", "done", "closed", "wont_fix"] as const; +export const ITEM_PRIORITIES = ["critical", "high", "medium", "low"] as const; +export const ITEM_SOURCES = ["internal", "user_submitted", "auto_detected"] as const; +export const ITEM_VISIBILITIES = ["internal", "public"] as const; + +export type ItemType = (typeof ITEM_TYPES)[number]; +export type ItemStatus = (typeof ITEM_STATUSES)[number]; +export type ItemPriority = (typeof ITEM_PRIORITIES)[number]; +export type ItemSource = (typeof ITEM_SOURCES)[number]; +export type ItemVisibility = (typeof ITEM_VISIBILITIES)[number]; + +export const PRIORITY_ORDER: Record = { critical: 0, high: 1, medium: 2, low: 3 }; + +export interface TrackerItemDoc { + id: string; + productId: string; + type: ItemType; + status: ItemStatus; + priority: ItemPriority; + title: string; + description: string; + labels: string[]; + assignee: string | null; + reportedBy: string; + source: ItemSource; + visibility: ItemVisibility; + voteCount: number; + commentCount: number; + priorityOrder: number; + targetRelease: string | null; + createdAt: string; + updatedAt: string; +} + +export const CreateItemSchema = z.object({ + productId: z.string().min(1).optional(), + type: z.enum(ITEM_TYPES), + priority: z.enum(ITEM_PRIORITIES).default("medium"), + title: z.string().min(1).max(500), + description: z.string().default(""), + labels: z.array(z.string()).default([]), + assignee: z.string().nullable().default(null), + source: z.enum(ITEM_SOURCES).default("internal"), + visibility: z.enum(ITEM_VISIBILITIES).default("internal"), + targetRelease: z.string().nullable().default(null), +}); + +export const UpdateItemSchema = z.object({ + type: z.enum(ITEM_TYPES).optional(), + priority: z.enum(ITEM_PRIORITIES).optional(), + title: z.string().min(1).max(500).optional(), + description: z.string().optional(), + labels: z.array(z.string()).optional(), + assignee: z.string().nullable().optional(), + visibility: z.enum(ITEM_VISIBILITIES).optional(), + targetRelease: z.string().nullable().optional(), +}); + +export const UpdateStatusSchema = z.object({ + status: z.enum(ITEM_STATUSES), +}); + +export const ListItemsQuerySchema = z.object({ + productId: z.string().optional(), + type: z.enum(ITEM_TYPES).optional(), + status: z.enum(ITEM_STATUSES).optional(), + priority: z.enum(ITEM_PRIORITIES).optional(), + label: z.string().optional(), + assignee: z.string().optional(), + visibility: z.enum(ITEM_VISIBILITIES).optional(), + q: z.string().optional(), + sortBy: z.enum(["createdAt", "updatedAt", "voteCount", "priority"]).default("createdAt"), + sortOrder: z.enum(["asc", "desc"]).default("desc"), + limit: z.coerce.number().min(1).max(100).default(50), + offset: z.coerce.number().min(0).default(0), +}); + +export type CreateItemInput = z.infer; +export type UpdateItemInput = z.infer; +export type ListItemsQuery = z.infer; diff --git a/services/tracker-service/src/modules/public/public.test.ts b/services/tracker-service/src/modules/public/public.test.ts new file mode 100644 index 00000000..b67f57d9 --- /dev/null +++ b/services/tracker-service/src/modules/public/public.test.ts @@ -0,0 +1,150 @@ +/** + * Tests for public roadmap schemas. + */ + +import { describe, it, expect } from "vitest"; +import { PublicSubmitSchema, PublicVoteSchema, PublicRoadmapQuerySchema } from "./types.js"; + +describe("PublicSubmitSchema", () => { + it("accepts valid submission with all fields", () => { + const result = PublicSubmitSchema.safeParse({ + type: "feature", + title: "Add dark mode", + description: "Would love a dark theme for the dashboard", + email: "user@example.com", + name: "Jane Doe", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("feature"); + expect(result.data.priority).toBe("medium"); + expect(result.data.email).toBe("user@example.com"); + } + }); + + it("defaults type to feature and priority to medium", () => { + const result = PublicSubmitSchema.safeParse({ + title: "Something cool", + email: "test@test.com", + name: "Test User", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("feature"); + expect(result.data.priority).toBe("medium"); + expect(result.data.description).toBe(""); + } + }); + + it("rejects missing email", () => { + const result = PublicSubmitSchema.safeParse({ + title: "Missing email", + name: "Test", + }); + expect(result.success).toBe(false); + }); + + it("rejects invalid email", () => { + const result = PublicSubmitSchema.safeParse({ + title: "Bad email", + email: "not-an-email", + name: "Test", + }); + expect(result.success).toBe(false); + }); + + it("rejects missing name", () => { + const result = PublicSubmitSchema.safeParse({ + title: "Missing name", + email: "test@test.com", + }); + expect(result.success).toBe(false); + }); + + it("rejects empty title", () => { + const result = PublicSubmitSchema.safeParse({ + title: "", + email: "test@test.com", + name: "Test", + }); + expect(result.success).toBe(false); + }); + + it("accepts bug type", () => { + const result = PublicSubmitSchema.safeParse({ + type: "bug", + title: "Button broken", + email: "test@test.com", + name: "Tester", + }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.type).toBe("bug"); + }); + + it("caps description at 5000 chars", () => { + const result = PublicSubmitSchema.safeParse({ + title: "Long desc", + description: "a".repeat(5001), + email: "test@test.com", + name: "Test", + }); + expect(result.success).toBe(false); + }); +}); + +describe("PublicVoteSchema", () => { + it("accepts valid email", () => { + const result = PublicVoteSchema.safeParse({ email: "voter@example.com" }); + expect(result.success).toBe(true); + }); + + it("rejects missing email", () => { + const result = PublicVoteSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it("rejects invalid email", () => { + const result = PublicVoteSchema.safeParse({ email: "bad" }); + expect(result.success).toBe(false); + }); +}); + +describe("PublicRoadmapQuerySchema", () => { + it("applies defaults for empty query", () => { + const result = PublicRoadmapQuerySchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.sortBy).toBe("voteCount"); + expect(result.data.sortOrder).toBe("desc"); + expect(result.data.limit).toBe(50); + expect(result.data.offset).toBe(0); + } + }); + + it("accepts type filter", () => { + const result = PublicRoadmapQuerySchema.safeParse({ type: "feature" }); + expect(result.success).toBe(true); + }); + + it("restricts status to open/in_progress/done (no closed/wont_fix)", () => { + expect(PublicRoadmapQuerySchema.safeParse({ status: "open" }).success).toBe(true); + expect(PublicRoadmapQuerySchema.safeParse({ status: "in_progress" }).success).toBe(true); + expect(PublicRoadmapQuerySchema.safeParse({ status: "done" }).success).toBe(true); + expect(PublicRoadmapQuerySchema.safeParse({ status: "closed" }).success).toBe(false); + expect(PublicRoadmapQuerySchema.safeParse({ status: "wont_fix" }).success).toBe(false); + }); + + it("accepts search query", () => { + const result = PublicRoadmapQuerySchema.safeParse({ q: "dark mode" }); + expect(result.success).toBe(true); + }); + + it("coerces string limit/offset to numbers", () => { + const result = PublicRoadmapQuerySchema.safeParse({ limit: "20", offset: "10" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(20); + expect(result.data.offset).toBe(10); + } + }); +}); diff --git a/services/tracker-service/src/modules/public/routes.ts b/services/tracker-service/src/modules/public/routes.ts new file mode 100644 index 00000000..f77747f1 --- /dev/null +++ b/services/tracker-service/src/modules/public/routes.ts @@ -0,0 +1,171 @@ +/** + * Public roadmap REST endpoints — no authentication required. + * + * GET /public/roadmap — list public items (visible on roadmap) + * GET /public/roadmap/stats — aggregate counts for public items + * POST /public/submit — submit a feature request / bug (requires email) + * POST /public/items/:id/vote — toggle upvote by email + * GET /public/items/:id — get single public item detail + */ + +import type { FastifyInstance } from "fastify"; +import rateLimit from "@fastify/rate-limit"; +import { DEFAULT_PRODUCT_ID } from "../../lib/product-config.js"; +import { BadRequestError, NotFoundError } from "../../lib/errors.js"; +import * as itemRepo from "../items/repository.js"; +import * as voteRepo from "../votes/repository.js"; +import { PRIORITY_ORDER, type TrackerItemDoc } from "../items/types.js"; +import { + PublicSubmitSchema, + PublicVoteSchema, + PublicRoadmapQuerySchema, +} from "./types.js"; + +export async function publicRoutes(app: FastifyInstance) { + // Rate limiting for all public routes — generous default (60/min per IP) + await app.register(rateLimit, { + max: 60, + timeWindow: "1 minute", + keyGenerator: (req) => req.ip, + }); + + // Public roadmap list — only visibility=public, excludes closed/wont_fix + app.get("/public/roadmap", async (req) => { + const raw = req.query as Record; + const parsed = PublicRoadmapQuerySchema.safeParse(raw); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; ")); + } + const query = parsed.data; + + return itemRepo.list({ + productId: query.productId || DEFAULT_PRODUCT_ID, + visibility: "public", + type: query.type, + status: query.status, + q: query.q, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + limit: query.limit, + offset: query.offset, + }); + }); + + // Public roadmap stats + app.get("/public/roadmap/stats", async (req) => { + const { productId } = req.query as { productId?: string }; + const pid = productId || DEFAULT_PRODUCT_ID; + + const { items } = await itemRepo.list({ + productId: pid, + visibility: "public", + limit: 1000, + offset: 0, + sortBy: "createdAt", + sortOrder: "desc", + }); + + const byStatus: Record = {}; + const byType: Record = {}; + let totalVotes = 0; + + for (const item of items) { + byStatus[item.status] = (byStatus[item.status] || 0) + 1; + byType[item.type] = (byType[item.type] || 0) + 1; + totalVotes += item.voteCount; + } + + return { total: items.length, byStatus, byType, totalVotes }; + }); + + // Get single public item detail + app.get("/public/items/:id", async (req) => { + const { id } = req.params as { id: string }; + const item = await itemRepo.getById(id); + if (!item || item.visibility !== "public") { + throw new NotFoundError("Item not found"); + } + return item; + }); + + // Public submission — stricter rate limit: 10/min per IP + app.post("/public/submit", { config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, async (req, reply) => { + const parsed = PublicSubmitSchema.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: [], + assignee: null, + reportedBy: `${input.name} <${input.email}>`, + source: "user_submitted", + visibility: "internal", + voteCount: 1, + commentCount: 0, + priorityOrder: PRIORITY_ORDER[input.priority] ?? 2, + targetRelease: null, + createdAt: now, + updatedAt: now, + }; + + const created = await itemRepo.create(doc); + + // Auto-vote for the submitter + await voteRepo.create({ + id: `vote_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + itemId: created.id, + productId: pid, + userId: `email:${input.email}`, + createdAt: now, + }); + + reply.code(201); + return { id: created.id, title: created.title, status: created.status }; + }); + + // Public vote (by email) — moderate rate limit: 30/min per IP + app.post("/public/items/:id/vote", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req) => { + const { id } = req.params as { id: string }; + const parsed = PublicVoteSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; ")); + } + + const item = await itemRepo.getById(id); + if (!item || item.visibility !== "public") { + throw new NotFoundError("Item not found"); + } + + const voterId = `email:${parsed.data.email}`; + const existing = await voteRepo.getByItemAndUser(id, voterId); + + if (existing) { + await voteRepo.remove(existing.id); + const newCount = await voteRepo.countByItem(id); + await itemRepo.updateVoteCount(id, newCount); + return { voted: false, voteCount: newCount }; + } else { + await voteRepo.create({ + id: `vote_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + itemId: id, + productId: item.productId, + userId: voterId, + createdAt: new Date().toISOString(), + }); + const newCount = await voteRepo.countByItem(id); + await itemRepo.updateVoteCount(id, newCount); + return { voted: true, voteCount: newCount }; + } + }); +} diff --git a/services/tracker-service/src/modules/public/types.ts b/services/tracker-service/src/modules/public/types.ts new file mode 100644 index 00000000..7ae47bdd --- /dev/null +++ b/services/tracker-service/src/modules/public/types.ts @@ -0,0 +1,35 @@ +/** + * Public roadmap types — schemas for unauthenticated endpoints. + */ + +import { z } from "zod"; +import { ITEM_TYPES, ITEM_PRIORITIES } from "../items/types.js"; + +export const PublicSubmitSchema = z.object({ + productId: z.string().min(1).optional(), + type: z.enum(ITEM_TYPES).default("feature"), + priority: z.enum(ITEM_PRIORITIES).default("medium"), + title: z.string().min(1).max(500), + description: z.string().max(5000).default(""), + email: z.string().email(), + name: z.string().min(1).max(200), +}); + +export const PublicVoteSchema = z.object({ + email: z.string().email(), +}); + +export const PublicRoadmapQuerySchema = z.object({ + productId: z.string().optional(), + type: z.enum(ITEM_TYPES).optional(), + status: z.enum(["open", "in_progress", "done"] as const).optional(), + q: z.string().optional(), + sortBy: z.enum(["createdAt", "voteCount"]).default("voteCount"), + sortOrder: z.enum(["asc", "desc"]).default("desc"), + limit: z.coerce.number().min(1).max(100).default(50), + offset: z.coerce.number().min(0).default(0), +}); + +export type PublicSubmitInput = z.infer; +export type PublicVoteInput = z.infer; +export type PublicRoadmapQuery = z.infer; diff --git a/services/tracker-service/src/modules/votes/repository.ts b/services/tracker-service/src/modules/votes/repository.ts new file mode 100644 index 00000000..3450522d --- /dev/null +++ b/services/tracker-service/src/modules/votes/repository.ts @@ -0,0 +1,57 @@ +/** + * Votes repository — Cosmos DB CRUD. + */ + +import { getContainer } from "../../lib/cosmos.js"; +import type { VoteDoc } from "./types.js"; + +function container() { + return getContainer("tracker_votes"); +} + +export async function getByItemAndUser(itemId: string, userId: string): Promise { + const { resources } = await container().items + .query({ + query: "SELECT * FROM c WHERE c.itemId = @itemId AND c.userId = @userId", + parameters: [ + { name: "@itemId", value: itemId }, + { name: "@userId", value: userId }, + ], + }) + .fetchAll(); + return resources[0] ?? null; +} + +export async function countByItem(itemId: string): Promise { + const { resources } = await container().items + .query({ + query: "SELECT VALUE COUNT(1) FROM c WHERE c.itemId = @itemId", + parameters: [{ name: "@itemId", value: itemId }], + }) + .fetchAll(); + return resources[0] ?? 0; +} + +export async function listByItem(itemId: string): Promise { + const { resources } = await container().items + .query({ + query: "SELECT * FROM c WHERE c.itemId = @itemId ORDER BY c.createdAt DESC", + parameters: [{ name: "@itemId", value: itemId }], + }) + .fetchAll(); + return resources; +} + +export async function create(doc: VoteDoc): Promise { + const { resource } = await container().items.create(doc); + return resource as VoteDoc; +} + +export async function remove(id: string): Promise { + try { + await container().item(id, id).delete(); + return true; + } catch { + return false; + } +} diff --git a/services/tracker-service/src/modules/votes/routes.ts b/services/tracker-service/src/modules/votes/routes.ts new file mode 100644 index 00000000..39b65590 --- /dev/null +++ b/services/tracker-service/src/modules/votes/routes.ts @@ -0,0 +1,59 @@ +/** + * Votes REST endpoints. + * + * POST /items/:itemId/vote — toggle upvote (add if not voted, remove if already voted) + * GET /items/:itemId/votes — list voters for an item + */ + +import type { FastifyInstance } from "fastify"; +import { NotFoundError } from "../../lib/errors.js"; +import { extractAuth } from "../../lib/auth.js"; +import * as repo from "./repository.js"; +import * as itemRepo from "../items/repository.js"; +import type { VoteDoc } from "./types.js"; + +export async function voteRoutes(app: FastifyInstance) { + // Toggle vote + app.post("/items/:itemId/vote", async (req) => { + const auth = await extractAuth(req); + const { itemId } = req.params as { itemId: string }; + + const item = await itemRepo.getById(itemId); + if (!item) throw new NotFoundError("Item not found"); + + const existing = await repo.getByItemAndUser(itemId, auth.sub); + + if (existing) { + // Remove vote + await repo.remove(existing.id); + const newCount = await repo.countByItem(itemId); + await itemRepo.updateVoteCount(itemId, newCount); + return { voted: false, voteCount: newCount }; + } else { + // Add vote + const doc: VoteDoc = { + id: `vote_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + itemId, + productId: item.productId, + userId: auth.sub, + createdAt: new Date().toISOString(), + }; + await repo.create(doc); + const newCount = await repo.countByItem(itemId); + await itemRepo.updateVoteCount(itemId, newCount); + return { voted: true, voteCount: newCount }; + } + }); + + // List voters + app.get("/items/:itemId/votes", async (req) => { + await extractAuth(req); + const { itemId } = req.params as { itemId: string }; + + const item = await itemRepo.getById(itemId); + if (!item) throw new NotFoundError("Item not found"); + + const votes = await repo.listByItem(itemId); + return { votes, count: votes.length }; + }); +} diff --git a/services/tracker-service/src/modules/votes/types.ts b/services/tracker-service/src/modules/votes/types.ts new file mode 100644 index 00000000..e97733a8 --- /dev/null +++ b/services/tracker-service/src/modules/votes/types.ts @@ -0,0 +1,11 @@ +/** + * Vote types — upvotes on tracker items (1 per user per item). + */ + +export interface VoteDoc { + id: string; + itemId: string; + productId: string; + userId: string; + createdAt: string; +} diff --git a/services/tracker-service/src/server.ts b/services/tracker-service/src/server.ts new file mode 100644 index 00000000..384069b2 --- /dev/null +++ b/services/tracker-service/src/server.ts @@ -0,0 +1,83 @@ +/** + * Tracker Service — Fastify server entry point. + * + * Modules: items, comments, votes. + * Port: 4004 (configurable via PORT env var). + * Product-agnostic: all data scoped by productId. + */ + +import { randomUUID } from "node:crypto"; +import Fastify from "fastify"; +import cors from "@fastify/cors"; +import swagger from "@fastify/swagger"; +import metricsPlugin from "fastify-metrics"; +import { ServiceError } from "./lib/errors.js"; +import { itemRoutes } from "./modules/items/routes.js"; +import { commentRoutes } from "./modules/comments/routes.js"; +import { voteRoutes } from "./modules/votes/routes.js"; +import { publicRoutes } from "./modules/public/routes.js"; +import { config } from "./lib/config.js"; + +const PORT = config.PORT; +const HOST = config.HOST; + +const app = Fastify({ logger: true }); + +// CORS — restrict to specific origins in production via CORS_ORIGIN (comma-separated) +const corsOrigin = config.CORS_ORIGIN; +await app.register(cors, { + origin: corsOrigin ? corsOrigin.split(",").map((o) => o.trim()) : true, +}); + +// OpenAPI spec auto-generation (GET /api/docs/json) +await app.register(swagger, { + openapi: { + info: { title: "Tracker Service", version: "0.1.0", description: "Feature requests, bugs, tasks — product-agnostic" }, + servers: [{ url: `http://localhost:${PORT}` }], + }, +}); + +// Prometheus metrics +await app.register(metricsPlugin, { endpoint: "/metrics" }); + +// x-request-id: propagate incoming header or generate a new one +app.addHook("onRequest", async (req, reply) => { + const requestId = (req.headers["x-request-id"] as string) || randomUUID(); + req.headers["x-request-id"] = requestId; + reply.header("x-request-id", requestId); + req.log = req.log.child({ requestId }); +}); + +// Health check +app.get("/health", async (req) => ({ + status: "ok", + service: "tracker-service", + version: "0.1.0", + timestamp: new Date().toISOString(), + requestId: req.headers["x-request-id"], +})); + +// Custom error handler +app.setErrorHandler((error, _req, reply) => { + if (error instanceof ServiceError) { + reply.code(error.statusCode).send({ error: error.message }); + return; + } + app.log.error(error); + reply.code(500).send({ error: "Internal server error" }); +}); + +// Register route modules +await app.register(itemRoutes, { prefix: "/api" }); +await app.register(commentRoutes, { prefix: "/api" }); +await app.register(voteRoutes, { prefix: "/api" }); +await app.register(publicRoutes, { prefix: "/api" }); + +// Start +try { + await app.listen({ port: PORT, host: HOST }); + app.log.info(`Tracker Service listening on ${HOST}:${PORT}`); +} catch (err) { + app.log.error(err); + process.exit(1); +} diff --git a/services/tracker-service/tsconfig.json b/services/tracker-service/tsconfig.json new file mode 100644 index 00000000..d155b3cc --- /dev/null +++ b/services/tracker-service/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +}