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:
parent
b94510aeb9
commit
2738124ab9
2
services/tracker-service/.gitignore
vendored
Normal file
2
services/tracker-service/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
16
services/tracker-service/Dockerfile
Normal file
16
services/tracker-service/Dockerfile
Normal 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"]
|
||||
57
services/tracker-service/README.md
Normal file
57
services/tracker-service/README.md
Normal 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`)
|
||||
31
services/tracker-service/package.json
Normal file
31
services/tracker-service/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
51
services/tracker-service/src/lib/auth.ts
Normal file
51
services/tracker-service/src/lib/auth.ts
Normal 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;
|
||||
}
|
||||
23
services/tracker-service/src/lib/config.ts
Normal file
23
services/tracker-service/src/lib/config.ts
Normal 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);
|
||||
24
services/tracker-service/src/lib/cosmos.ts
Normal file
24
services/tracker-service/src/lib/cosmos.ts
Normal 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);
|
||||
}
|
||||
59
services/tracker-service/src/lib/errors.test.ts
Normal file
59
services/tracker-service/src/lib/errors.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
43
services/tracker-service/src/lib/errors.ts
Normal file
43
services/tracker-service/src/lib/errors.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
6
services/tracker-service/src/lib/product-config.ts
Normal file
6
services/tracker-service/src/lib/product-config.ts
Normal 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";
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
68
services/tracker-service/src/modules/comments/repository.ts
Normal file
68
services/tracker-service/src/modules/comments/repository.ts
Normal 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;
|
||||
}
|
||||
94
services/tracker-service/src/modules/comments/routes.ts
Normal file
94
services/tracker-service/src/modules/comments/routes.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
27
services/tracker-service/src/modules/comments/types.ts
Normal file
27
services/tracker-service/src/modules/comments/types.ts
Normal 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>;
|
||||
134
services/tracker-service/src/modules/items/items.test.ts
Normal file
134
services/tracker-service/src/modules/items/items.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
126
services/tracker-service/src/modules/items/repository.ts
Normal file
126
services/tracker-service/src/modules/items/repository.ts
Normal 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 });
|
||||
}
|
||||
166
services/tracker-service/src/modules/items/routes.ts
Normal file
166
services/tracker-service/src/modules/items/routes.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
88
services/tracker-service/src/modules/items/types.ts
Normal file
88
services/tracker-service/src/modules/items/types.ts
Normal 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>;
|
||||
150
services/tracker-service/src/modules/public/public.test.ts
Normal file
150
services/tracker-service/src/modules/public/public.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
171
services/tracker-service/src/modules/public/routes.ts
Normal file
171
services/tracker-service/src/modules/public/routes.ts
Normal 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 };
|
||||
}
|
||||
});
|
||||
}
|
||||
35
services/tracker-service/src/modules/public/types.ts
Normal file
35
services/tracker-service/src/modules/public/types.ts
Normal 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>;
|
||||
57
services/tracker-service/src/modules/votes/repository.ts
Normal file
57
services/tracker-service/src/modules/votes/repository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
59
services/tracker-service/src/modules/votes/routes.ts
Normal file
59
services/tracker-service/src/modules/votes/routes.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
11
services/tracker-service/src/modules/votes/types.ts
Normal file
11
services/tracker-service/src/modules/votes/types.ts
Normal 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;
|
||||
}
|
||||
83
services/tracker-service/src/server.ts
Normal file
83
services/tracker-service/src/server.ts
Normal 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);
|
||||
}
|
||||
19
services/tracker-service/tsconfig.json
Normal file
19
services/tracker-service/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user