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
This commit is contained in:
saravanakumardb1 2026-02-12 11:39:17 -08:00
parent b94510aeb9
commit 2738124ab9
26 changed files with 1640 additions and 0 deletions

2
services/tracker-service/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
dist/

View File

@ -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"]

View File

@ -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`)

View File

@ -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"
}
}

View File

@ -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<AuthPayload> {
const { payload } = await jwtVerify(token, getSecret());
return payload as AuthPayload;
}
export async function extractAuth(req: FastifyRequest): Promise<AuthPayload> {
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<AuthPayload> {
const payload = await extractAuth(req);
if (roles.length > 0 && (!payload.role || !roles.includes(payload.role))) {
throw new ForbiddenError("Insufficient permissions");
}
return payload;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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";

View File

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

View File

@ -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<CommentDoc[]> {
const { resources } = await container().items
.query<CommentDoc>({
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<CommentDoc | null> {
try {
const { resource } = await container().item(id, id).read<CommentDoc>();
return resource ?? null;
} catch {
return null;
}
}
export async function create(doc: CommentDoc): Promise<CommentDoc> {
const { resource } = await container().items.create(doc);
return resource as CommentDoc;
}
export async function update(
id: string,
updates: Partial<CommentDoc>,
): Promise<CommentDoc | null> {
try {
const { resource: existing } = await container().item(id, id).read<CommentDoc>();
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<boolean> {
try {
await container().item(id, id).delete();
return true;
} catch {
return false;
}
}
export async function countByItem(itemId: string): Promise<number> {
const { resources } = await container().items
.query<number>({
query: "SELECT VALUE COUNT(1) FROM c WHERE c.itemId = @itemId",
parameters: [{ name: "@itemId", value: itemId }],
})
.fetchAll();
return resources[0] ?? 0;
}

View File

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

View File

@ -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<typeof CreateCommentSchema>;
export type UpdateCommentInput = z.infer<typeof UpdateCommentSchema>;

View File

@ -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"]);
});
});

View File

@ -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<number>({
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<TrackerItemDoc>({
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<TrackerItemDoc | null> {
try {
const { resource } = await container().item(id, id).read<TrackerItemDoc>();
return resource ?? null;
} catch {
return null;
}
}
export async function create(doc: TrackerItemDoc): Promise<TrackerItemDoc> {
const { resource } = await container().items.create(doc);
return resource as TrackerItemDoc;
}
export async function update(
id: string,
updates: Partial<TrackerItemDoc>,
): Promise<TrackerItemDoc | null> {
try {
const { resource: existing } = await container().item(id, id).read<TrackerItemDoc>();
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<boolean> {
try {
await container().item(id, id).delete();
return true;
} catch {
return false;
}
}
export async function incrementCommentCount(id: string, delta: number): Promise<void> {
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<void> {
await update(id, { voteCount });
}

View File

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

View File

@ -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<string, number> = { 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<typeof CreateItemSchema>;
export type UpdateItemInput = z.infer<typeof UpdateItemSchema>;
export type ListItemsQuery = z.infer<typeof ListItemsQuerySchema>;

View File

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

View File

@ -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<string, string>;
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<string, number> = {};
const byType: Record<string, number> = {};
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 };
}
});
}

View File

@ -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<typeof PublicSubmitSchema>;
export type PublicVoteInput = z.infer<typeof PublicVoteSchema>;
export type PublicRoadmapQuery = z.infer<typeof PublicRoadmapQuerySchema>;

View File

@ -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<VoteDoc | null> {
const { resources } = await container().items
.query<VoteDoc>({
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<number> {
const { resources } = await container().items
.query<number>({
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<VoteDoc[]> {
const { resources } = await container().items
.query<VoteDoc>({
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<VoteDoc> {
const { resource } = await container().items.create(doc);
return resource as VoteDoc;
}
export async function remove(id: string): Promise<boolean> {
try {
await container().item(id, id).delete();
return true;
} catch {
return false;
}
}

View File

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

View File

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

View File

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

View File

@ -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"]
}