added repo

This commit is contained in:
2026-03-03 14:25:43 +00:00
commit 4328ada595
15 changed files with 2118 additions and 0 deletions

19
src/config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { resolve } from "node:path";
const defaultDbPath = resolve(process.cwd(), "data", "mock-task-manager.sqlite");
function parsePort(value?: string): number {
const port = Number.parseInt(value ?? "3010", 10);
if (!Number.isInteger(port) || port <= 0) {
throw new Error(`Invalid PORT value: ${value}`);
}
return port;
}
export function getConfig(env: NodeJS.ProcessEnv = process.env) {
return {
port: parsePort(env.PORT),
dbPath: env.DB_PATH?.trim() || defaultDbPath,
};
}

526
src/database.ts Normal file
View File

@@ -0,0 +1,526 @@
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { dirname } from "node:path";
import { Database } from "bun:sqlite";
export interface Organization {
id: string;
slug: string;
name: string;
plan: string;
industry: string;
createdAt: string;
}
export interface User {
id: string;
orgId: string;
fullName: string;
email: string;
role: string;
timezone: string;
}
export interface Project {
id: string;
orgId: string;
key: string;
name: string;
status: string;
ownerUserId: string;
dueDate: string | null;
}
export interface TaskRecord {
id: string;
orgId: string;
projectId: string;
assigneeUserId: string | null;
title: string;
description: string;
status: string;
priority: string;
storyPoints: number | null;
dueDate: string | null;
createdAt: string;
updatedAt: string;
}
export interface Label {
id: string;
orgId: string;
name: string;
color: string;
}
export interface CommentRecord {
id: string;
taskId: string;
authorUserId: string;
body: string;
createdAt: string;
}
const organizations: Organization[] = [
{
id: "org_acme",
slug: "acme-logistics",
name: "Acme Logistics",
plan: "enterprise",
industry: "Logistics",
createdAt: "2026-01-03T09:00:00.000Z",
},
{
id: "org_northstar",
slug: "northstar-health",
name: "Northstar Health",
plan: "growth",
industry: "Healthcare",
createdAt: "2026-01-11T10:15:00.000Z",
},
{
id: "org_summit",
slug: "summit-edu",
name: "Summit Education",
plan: "starter",
industry: "Education",
createdAt: "2026-01-20T08:30:00.000Z",
},
];
const users: User[] = [
{
id: "user_acme_1",
orgId: "org_acme",
fullName: "Maya Patel",
email: "maya@acme.example",
role: "org_admin",
timezone: "Europe/Dublin",
},
{
id: "user_acme_2",
orgId: "org_acme",
fullName: "Jonas Weber",
email: "jonas@acme.example",
role: "project_manager",
timezone: "Europe/Berlin",
},
{
id: "user_acme_3",
orgId: "org_acme",
fullName: "Elena Rossi",
email: "elena@acme.example",
role: "engineer",
timezone: "Europe/Rome",
},
{
id: "user_northstar_1",
orgId: "org_northstar",
fullName: "Samir Khan",
email: "samir@northstar.example",
role: "org_admin",
timezone: "America/New_York",
},
{
id: "user_northstar_2",
orgId: "org_northstar",
fullName: "Leah Morris",
email: "leah@northstar.example",
role: "analyst",
timezone: "America/Chicago",
},
{
id: "user_summit_1",
orgId: "org_summit",
fullName: "Owen Doyle",
email: "owen@summit.example",
role: "org_admin",
timezone: "Europe/Dublin",
},
];
const projects: Project[] = [
{
id: "proj_acme_ops",
orgId: "org_acme",
key: "OPS",
name: "Operations Platform",
status: "active",
ownerUserId: "user_acme_2",
dueDate: "2026-04-30",
},
{
id: "proj_acme_mobile",
orgId: "org_acme",
key: "MOB",
name: "Courier Mobile App",
status: "active",
ownerUserId: "user_acme_1",
dueDate: "2026-05-20",
},
{
id: "proj_northstar_portal",
orgId: "org_northstar",
key: "PAT",
name: "Patient Intake Portal",
status: "active",
ownerUserId: "user_northstar_1",
dueDate: "2026-05-07",
},
{
id: "proj_summit_reporting",
orgId: "org_summit",
key: "REP",
name: "Teacher Reporting",
status: "planning",
ownerUserId: "user_summit_1",
dueDate: "2026-06-12",
},
];
const tasks: TaskRecord[] = [
{
id: "task_1001",
orgId: "org_acme",
projectId: "proj_acme_ops",
assigneeUserId: "user_acme_3",
title: "Add delivery exception workflow",
description: "Support driver-reported delivery exceptions with customer-visible status updates.",
status: "in_progress",
priority: "high",
storyPoints: 8,
dueDate: "2026-03-10",
createdAt: "2026-03-01T09:00:00.000Z",
updatedAt: "2026-03-03T10:15:00.000Z",
},
{
id: "task_1002",
orgId: "org_acme",
projectId: "proj_acme_ops",
assigneeUserId: "user_acme_2",
title: "Reconcile warehouse inventory feed",
description: "Normalize nightly inventory import and surface failures in the admin dashboard.",
status: "todo",
priority: "urgent",
storyPoints: 5,
dueDate: "2026-03-06",
createdAt: "2026-03-01T12:30:00.000Z",
updatedAt: "2026-03-02T16:00:00.000Z",
},
{
id: "task_1003",
orgId: "org_acme",
projectId: "proj_acme_mobile",
assigneeUserId: "user_acme_3",
title: "Implement offline proof-of-delivery queue",
description: "Queue signatures and photos when couriers lose connectivity.",
status: "blocked",
priority: "high",
storyPoints: 13,
dueDate: "2026-03-18",
createdAt: "2026-02-25T08:00:00.000Z",
updatedAt: "2026-03-03T09:10:00.000Z",
},
{
id: "task_1004",
orgId: "org_acme",
projectId: "proj_acme_mobile",
assigneeUserId: null,
title: "Refresh courier onboarding copy",
description: "Rewrite first-run guidance for new regional couriers.",
status: "todo",
priority: "medium",
storyPoints: 2,
dueDate: "2026-03-21",
createdAt: "2026-03-02T14:20:00.000Z",
updatedAt: "2026-03-02T14:20:00.000Z",
},
{
id: "task_2001",
orgId: "org_northstar",
projectId: "proj_northstar_portal",
assigneeUserId: "user_northstar_2",
title: "Audit intake form validation gaps",
description: "Compare API validation with product requirements for intake submissions.",
status: "in_review",
priority: "high",
storyPoints: 3,
dueDate: "2026-03-12",
createdAt: "2026-03-01T11:00:00.000Z",
updatedAt: "2026-03-03T13:00:00.000Z",
},
{
id: "task_2002",
orgId: "org_northstar",
projectId: "proj_northstar_portal",
assigneeUserId: "user_northstar_1",
title: "Prepare HIPAA access log export",
description: "Generate downloadable audit log extracts for compliance reviews.",
status: "todo",
priority: "urgent",
storyPoints: 8,
dueDate: "2026-03-08",
createdAt: "2026-02-28T10:45:00.000Z",
updatedAt: "2026-03-02T18:30:00.000Z",
},
{
id: "task_3001",
orgId: "org_summit",
projectId: "proj_summit_reporting",
assigneeUserId: "user_summit_1",
title: "Define grade export CSV format",
description: "Align export columns with district reporting requirements.",
status: "todo",
priority: "medium",
storyPoints: 3,
dueDate: "2026-03-25",
createdAt: "2026-03-02T09:30:00.000Z",
updatedAt: "2026-03-02T09:30:00.000Z",
},
];
const labels: Label[] = [
{ id: "label_backend", orgId: "org_acme", name: "backend", color: "#0f766e" },
{ id: "label_mobile", orgId: "org_acme", name: "mobile", color: "#1d4ed8" },
{ id: "label_compliance", orgId: "org_northstar", name: "compliance", color: "#b45309" },
{ id: "label_reporting", orgId: "org_summit", name: "reporting", color: "#7c3aed" },
];
const taskLabels = [
{ taskId: "task_1001", labelId: "label_backend" },
{ taskId: "task_1002", labelId: "label_backend" },
{ taskId: "task_1003", labelId: "label_mobile" },
{ taskId: "task_2002", labelId: "label_compliance" },
{ taskId: "task_3001", labelId: "label_reporting" },
];
const comments: CommentRecord[] = [
{
id: "comment_1",
taskId: "task_1001",
authorUserId: "user_acme_2",
body: "Driver ops needs an exception code for failed building access.",
createdAt: "2026-03-03T09:30:00.000Z",
},
{
id: "comment_2",
taskId: "task_1003",
authorUserId: "user_acme_1",
body: "Blocked until we settle image retention policy for offline uploads.",
createdAt: "2026-03-03T09:40:00.000Z",
},
{
id: "comment_3",
taskId: "task_2002",
authorUserId: "user_northstar_2",
body: "Compliance requested a CSV and JSON version of the export.",
createdAt: "2026-03-02T17:15:00.000Z",
},
];
function createSchema(db: Database) {
db.exec(`
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS organizations (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
plan TEXT NOT NULL,
industry TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
full_name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
role TEXT NOT NULL,
timezone TEXT NOT NULL,
FOREIGN KEY (org_id) REFERENCES organizations(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
key TEXT NOT NULL,
name TEXT NOT NULL,
status TEXT NOT NULL,
owner_user_id TEXT NOT NULL,
due_date TEXT,
FOREIGN KEY (org_id) REFERENCES organizations(id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES users(id),
UNIQUE (org_id, key)
);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
project_id TEXT NOT NULL,
assignee_user_id TEXT,
title TEXT NOT NULL,
description TEXT NOT NULL,
status TEXT NOT NULL,
priority TEXT NOT NULL,
story_points INTEGER,
due_date TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (org_id) REFERENCES organizations(id) ON DELETE CASCADE,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (assignee_user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS labels (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT NOT NULL,
FOREIGN KEY (org_id) REFERENCES organizations(id) ON DELETE CASCADE,
UNIQUE (org_id, name)
);
CREATE TABLE IF NOT EXISTS task_labels (
task_id TEXT NOT NULL,
label_id TEXT NOT NULL,
PRIMARY KEY (task_id, label_id),
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS comments (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
author_user_id TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (author_user_id) REFERENCES users(id)
);
`);
}
export function openDatabase(dbPath: string): Database {
mkdirSync(dirname(dbPath), { recursive: true });
const db = new Database(dbPath, { create: true });
createSchema(db);
return db;
}
function insertSeedData(db: Database) {
const insertOrganization = db.query(
"INSERT INTO organizations (id, slug, name, plan, industry, created_at) VALUES (?, ?, ?, ?, ?, ?)",
);
const insertUser = db.query(
"INSERT INTO users (id, org_id, full_name, email, role, timezone) VALUES (?, ?, ?, ?, ?, ?)",
);
const insertProject = db.query(
"INSERT INTO projects (id, org_id, key, name, status, owner_user_id, due_date) VALUES (?, ?, ?, ?, ?, ?, ?)",
);
const insertTask = db.query(
"INSERT INTO tasks (id, org_id, project_id, assignee_user_id, title, description, status, priority, story_points, due_date, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
);
const insertLabel = db.query(
"INSERT INTO labels (id, org_id, name, color) VALUES (?, ?, ?, ?)",
);
const insertTaskLabel = db.query(
"INSERT INTO task_labels (task_id, label_id) VALUES (?, ?)",
);
const insertComment = db.query(
"INSERT INTO comments (id, task_id, author_user_id, body, created_at) VALUES (?, ?, ?, ?, ?)",
);
const seed = db.transaction(() => {
for (const org of organizations) {
insertOrganization.run(
org.id,
org.slug,
org.name,
org.plan,
org.industry,
org.createdAt,
);
}
for (const user of users) {
insertUser.run(
user.id,
user.orgId,
user.fullName,
user.email,
user.role,
user.timezone,
);
}
for (const project of projects) {
insertProject.run(
project.id,
project.orgId,
project.key,
project.name,
project.status,
project.ownerUserId,
project.dueDate,
);
}
for (const task of tasks) {
insertTask.run(
task.id,
task.orgId,
task.projectId,
task.assigneeUserId,
task.title,
task.description,
task.status,
task.priority,
task.storyPoints,
task.dueDate,
task.createdAt,
task.updatedAt,
);
}
for (const label of labels) {
insertLabel.run(label.id, label.orgId, label.name, label.color);
}
for (const assignment of taskLabels) {
insertTaskLabel.run(assignment.taskId, assignment.labelId);
}
for (const comment of comments) {
insertComment.run(
comment.id,
comment.taskId,
comment.authorUserId,
comment.body,
comment.createdAt,
);
}
});
seed();
}
export function ensureSeededDatabase(dbPath: string) {
const db = openDatabase(dbPath);
const count = db.query("SELECT COUNT(*) AS count FROM organizations").get() as { count: number };
if (count.count === 0) {
insertSeedData(db);
}
return db;
}
export function reseedDatabase(dbPath: string) {
mkdirSync(dirname(dbPath), { recursive: true });
if (existsSync(dbPath)) {
rmSync(dbPath);
}
const db = openDatabase(dbPath);
insertSeedData(db);
return db;
}

223
src/index.ts Normal file
View File

@@ -0,0 +1,223 @@
import type { Database } from "bun:sqlite";
import { getConfig } from "./config";
import { ensureSeededDatabase } from "./database";
import { openApiDocument } from "./openapi";
import {
createTask,
getOrganizationSummary,
getTaskDetail,
listOrganizations,
listProjects,
listTasks,
listUsers,
updateTask,
} from "./repository";
function json(body: unknown, init: ResponseInit = {}) {
const headers = new Headers(init.headers);
headers.set("content-type", "application/json; charset=utf-8");
headers.set("access-control-allow-origin", "*");
headers.set("access-control-allow-methods", "GET,POST,PATCH,OPTIONS");
headers.set("access-control-allow-headers", "content-type");
return new Response(JSON.stringify(body, null, 2), { ...init, headers });
}
function notFound(message: string) {
return json({ error: message }, { status: 404 });
}
function badRequest(message: string) {
return json({ error: message }, { status: 400 });
}
async function readJson(request: Request) {
try {
return await request.json();
} catch {
return null;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function validateCreateTask(body: unknown) {
if (!isRecord(body)) {
return { error: "Request body must be a JSON object." };
}
if (typeof body.orgId !== "string" || body.orgId.trim() === "") {
return { error: "orgId is required." };
}
if (typeof body.projectId !== "string" || body.projectId.trim() === "") {
return { error: "projectId is required." };
}
if (typeof body.title !== "string" || body.title.trim() === "") {
return { error: "title is required." };
}
return {
value: {
orgId: body.orgId.trim(),
projectId: body.projectId.trim(),
assigneeUserId:
typeof body.assigneeUserId === "string" ? body.assigneeUserId.trim() : null,
title: body.title.trim(),
description:
typeof body.description === "string" ? body.description.trim() : undefined,
status: typeof body.status === "string" ? body.status.trim() : undefined,
priority: typeof body.priority === "string" ? body.priority.trim() : undefined,
storyPoints:
typeof body.storyPoints === "number" ? Math.trunc(body.storyPoints) : undefined,
dueDate: typeof body.dueDate === "string" ? body.dueDate : null,
},
};
}
function validateUpdateTask(body: unknown) {
if (!isRecord(body)) {
return { error: "Request body must be a JSON object." };
}
return {
value: {
title: typeof body.title === "string" ? body.title.trim() : undefined,
description:
typeof body.description === "string" ? body.description.trim() : undefined,
status: typeof body.status === "string" ? body.status.trim() : undefined,
priority: typeof body.priority === "string" ? body.priority.trim() : undefined,
assigneeUserId:
typeof body.assigneeUserId === "string"
? body.assigneeUserId.trim()
: body.assigneeUserId === null
? null
: undefined,
storyPoints:
typeof body.storyPoints === "number"
? Math.trunc(body.storyPoints)
: body.storyPoints === null
? null
: undefined,
dueDate:
typeof body.dueDate === "string"
? body.dueDate
: body.dueDate === null
? null
: undefined,
},
};
}
export function createServer(db: Database) {
return {
port: getConfig().port,
fetch: async (request: Request) => {
if (request.method === "OPTIONS") {
return json({ ok: true });
}
const url = new URL(request.url);
const parts = url.pathname.split("/").filter(Boolean);
if (request.method === "GET" && url.pathname === "/health") {
return json({ status: "ok" });
}
if (
request.method === "GET" &&
(url.pathname === "/openapi.json" ||
url.pathname === "/swagger.json" ||
url.pathname === "/api-docs")
) {
return json(openApiDocument);
}
if (request.method === "GET" && url.pathname === "/orgs") {
return json({ organizations: listOrganizations(db) });
}
if (request.method === "GET" && url.pathname === "/users") {
return json({ users: listUsers(db, url.searchParams.get("orgId")) });
}
if (request.method === "GET" && url.pathname === "/projects") {
return json({ projects: listProjects(db, url.searchParams.get("orgId")) });
}
if (request.method === "GET" && url.pathname === "/tasks") {
return json({
tasks: listTasks(db, {
orgId: url.searchParams.get("orgId"),
projectId: url.searchParams.get("projectId"),
status: url.searchParams.get("status"),
priority: url.searchParams.get("priority"),
assigneeUserId: url.searchParams.get("assigneeUserId"),
}),
});
}
if (request.method === "POST" && url.pathname === "/tasks") {
const payload = validateCreateTask(await readJson(request));
if ("error" in payload) {
return badRequest(payload.error);
}
const task = createTask(db, payload.value);
return json({ task }, { status: 201 });
}
if (parts[0] === "orgs" && parts.length === 2 && request.method === "GET") {
const summary = getOrganizationSummary(db, parts[1]!);
return summary ? json({ organization: summary }) : notFound("Organization not found.");
}
if (parts[0] === "orgs" && parts[2] === "projects" && request.method === "GET") {
return json({ projects: listProjects(db, parts[1]!) });
}
if (parts[0] === "orgs" && parts[2] === "tasks" && request.method === "GET") {
return json({
tasks: listTasks(db, {
orgId: parts[1]!,
projectId: url.searchParams.get("projectId"),
status: url.searchParams.get("status"),
priority: url.searchParams.get("priority"),
assigneeUserId: url.searchParams.get("assigneeUserId"),
}),
});
}
if (parts[0] === "tasks" && parts.length === 2 && request.method === "GET") {
const task = getTaskDetail(db, parts[1]!);
return task ? json({ task }) : notFound("Task not found.");
}
if (parts[0] === "tasks" && parts.length === 2 && request.method === "PATCH") {
const payload = validateUpdateTask(await readJson(request));
if ("error" in payload) {
return badRequest(payload.error);
}
const task = updateTask(db, parts[1]!, payload.value);
return task ? json({ task }) : notFound("Task not found.");
}
return notFound("Route not found.");
},
};
}
function main() {
const config = getConfig();
const db = ensureSeededDatabase(config.dbPath);
Bun.serve(createServer(db));
console.info("mock-task-api:listening", {
port: config.port,
dbPath: config.dbPath,
});
}
if (import.meta.main) {
main();
}

571
src/openapi.ts Normal file
View File

@@ -0,0 +1,571 @@
const taskProperties = {
id: { type: "string" },
orgId: { type: "string" },
projectId: { type: "string" },
assigneeUserId: { type: ["string", "null"] },
title: { type: "string" },
description: { type: "string" },
status: { type: "string", enum: ["todo", "in_progress", "in_review", "blocked", "done"] },
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
storyPoints: { type: ["integer", "null"] },
dueDate: { type: ["string", "null"], format: "date" },
createdAt: { type: "string", format: "date-time" },
updatedAt: { type: "string", format: "date-time" },
projectName: { type: "string" },
projectKey: { type: "string" },
assigneeName: { type: ["string", "null"] },
} as const;
export const openApiDocument = {
openapi: "3.1.0",
info: {
title: "POC Mock Task API",
version: "1.0.0",
description:
"Mock multi-tenant task manager API for orgs, projects, users, and task workflows.",
},
servers: [
{
url: "http://localhost:3010",
description: "Local development server",
},
],
tags: [
{ name: "Health" },
{ name: "Organizations" },
{ name: "Users" },
{ name: "Projects" },
{ name: "Tasks" },
],
paths: {
"/health": {
get: {
tags: ["Health"],
summary: "Check service health",
responses: {
"200": {
description: "Service is healthy",
content: {
"application/json": {
schema: {
type: "object",
required: ["status"],
properties: {
status: { type: "string", enum: ["ok"] },
},
},
},
},
},
},
},
},
"/orgs": {
get: {
tags: ["Organizations"],
summary: "List organizations",
responses: {
"200": {
description: "Organizations with summary counts",
content: {
"application/json": {
schema: {
type: "object",
required: ["organizations"],
properties: {
organizations: {
type: "array",
items: { $ref: "#/components/schemas/OrganizationSummaryListItem" },
},
},
},
},
},
},
},
},
},
"/orgs/{orgId}": {
get: {
tags: ["Organizations"],
summary: "Get organization detail",
parameters: [
{
name: "orgId",
in: "path",
required: true,
schema: { type: "string" },
},
],
responses: {
"200": {
description: "Organization detail and task status breakdown",
content: {
"application/json": {
schema: {
type: "object",
required: ["organization"],
properties: {
organization: { $ref: "#/components/schemas/OrganizationDetail" },
},
},
},
},
},
"404": {
description: "Organization not found",
},
},
},
},
"/orgs/{orgId}/projects": {
get: {
tags: ["Projects"],
summary: "List projects for an organization",
parameters: [
{
name: "orgId",
in: "path",
required: true,
schema: { type: "string" },
},
],
responses: {
"200": {
description: "Projects for the org",
content: {
"application/json": {
schema: {
type: "object",
required: ["projects"],
properties: {
projects: {
type: "array",
items: { $ref: "#/components/schemas/Project" },
},
},
},
},
},
},
},
},
},
"/orgs/{orgId}/tasks": {
get: {
tags: ["Tasks"],
summary: "List tasks for an organization",
parameters: [
{
name: "orgId",
in: "path",
required: true,
schema: { type: "string" },
},
{
name: "projectId",
in: "query",
schema: { type: "string" },
},
{
name: "status",
in: "query",
schema: { type: "string" },
},
{
name: "priority",
in: "query",
schema: { type: "string" },
},
{
name: "assigneeUserId",
in: "query",
schema: { type: "string" },
},
],
responses: {
"200": {
description: "Filtered org task list",
content: {
"application/json": {
schema: {
type: "object",
required: ["tasks"],
properties: {
tasks: {
type: "array",
items: { $ref: "#/components/schemas/TaskListItem" },
},
},
},
},
},
},
},
},
},
"/users": {
get: {
tags: ["Users"],
summary: "List users",
parameters: [
{
name: "orgId",
in: "query",
schema: { type: "string" },
},
],
responses: {
"200": {
description: "User list",
content: {
"application/json": {
schema: {
type: "object",
required: ["users"],
properties: {
users: {
type: "array",
items: { $ref: "#/components/schemas/User" },
},
},
},
},
},
},
},
},
},
"/projects": {
get: {
tags: ["Projects"],
summary: "List projects",
parameters: [
{
name: "orgId",
in: "query",
schema: { type: "string" },
},
],
responses: {
"200": {
description: "Project list",
content: {
"application/json": {
schema: {
type: "object",
required: ["projects"],
properties: {
projects: {
type: "array",
items: { $ref: "#/components/schemas/Project" },
},
},
},
},
},
},
},
},
},
"/tasks": {
get: {
tags: ["Tasks"],
summary: "List tasks",
parameters: [
{ name: "orgId", in: "query", schema: { type: "string" } },
{ name: "projectId", in: "query", schema: { type: "string" } },
{ name: "status", in: "query", schema: { type: "string" } },
{ name: "priority", in: "query", schema: { type: "string" } },
{ name: "assigneeUserId", in: "query", schema: { type: "string" } },
],
responses: {
"200": {
description: "Filtered task list",
content: {
"application/json": {
schema: {
type: "object",
required: ["tasks"],
properties: {
tasks: {
type: "array",
items: { $ref: "#/components/schemas/TaskListItem" },
},
},
},
},
},
},
},
},
post: {
tags: ["Tasks"],
summary: "Create a task",
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CreateTaskRequest" },
},
},
},
responses: {
"201": {
description: "Created task detail",
content: {
"application/json": {
schema: {
type: "object",
required: ["task"],
properties: {
task: { $ref: "#/components/schemas/TaskDetail" },
},
},
},
},
},
"400": {
description: "Invalid payload",
},
},
},
},
"/tasks/{taskId}": {
get: {
tags: ["Tasks"],
summary: "Get task detail",
parameters: [
{
name: "taskId",
in: "path",
required: true,
schema: { type: "string" },
},
],
responses: {
"200": {
description: "Task detail with labels and comments",
content: {
"application/json": {
schema: {
type: "object",
required: ["task"],
properties: {
task: { $ref: "#/components/schemas/TaskDetail" },
},
},
},
},
},
"404": {
description: "Task not found",
},
},
},
patch: {
tags: ["Tasks"],
summary: "Update a task",
parameters: [
{
name: "taskId",
in: "path",
required: true,
schema: { type: "string" },
},
],
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/UpdateTaskRequest" },
},
},
},
responses: {
"200": {
description: "Updated task detail",
content: {
"application/json": {
schema: {
type: "object",
required: ["task"],
properties: {
task: { $ref: "#/components/schemas/TaskDetail" },
},
},
},
},
},
"404": {
description: "Task not found",
},
},
},
},
"/openapi.json": {
get: {
tags: ["Health"],
summary: "Get OpenAPI document",
responses: {
"200": {
description: "OpenAPI specification",
},
},
},
},
"/swagger.json": {
get: {
tags: ["Health"],
summary: "Get OpenAPI document via swagger alias",
responses: {
"200": {
description: "OpenAPI specification",
},
},
},
},
"/api-docs": {
get: {
tags: ["Health"],
summary: "Get OpenAPI document via api-docs alias",
responses: {
"200": {
description: "OpenAPI specification",
},
},
},
},
},
components: {
schemas: {
OrganizationSummaryListItem: {
type: "object",
properties: {
id: { type: "string" },
slug: { type: "string" },
name: { type: "string" },
plan: { type: "string" },
industry: { type: "string" },
createdAt: { type: "string", format: "date-time" },
projectCount: { type: "integer" },
taskCount: { type: "integer" },
openTaskCount: { type: "integer" },
},
},
OrganizationDetail: {
allOf: [
{ $ref: "#/components/schemas/OrganizationSummaryListItem" },
{
type: "object",
properties: {
userCount: { type: "integer" },
statusBreakdown: {
type: "array",
items: {
type: "object",
properties: {
status: { type: "string" },
count: { type: "integer" },
},
},
},
},
},
],
},
User: {
type: "object",
properties: {
id: { type: "string" },
orgId: { type: "string" },
fullName: { type: "string" },
email: { type: "string", format: "email" },
role: { type: "string" },
timezone: { type: "string" },
},
},
Project: {
type: "object",
properties: {
id: { type: "string" },
orgId: { type: "string" },
key: { type: "string" },
name: { type: "string" },
status: { type: "string" },
ownerUserId: { type: "string" },
ownerName: { type: ["string", "null"] },
dueDate: { type: ["string", "null"], format: "date" },
taskCount: { type: "integer" },
},
},
TaskListItem: {
type: "object",
properties: taskProperties,
},
Label: {
type: "object",
properties: {
id: { type: "string" },
name: { type: "string" },
color: { type: "string" },
},
},
Comment: {
type: "object",
properties: {
id: { type: "string" },
authorUserId: { type: "string" },
authorName: { type: "string" },
body: { type: "string" },
createdAt: { type: "string", format: "date-time" },
},
},
TaskDetail: {
allOf: [
{ $ref: "#/components/schemas/TaskListItem" },
{
type: "object",
properties: {
labels: {
type: "array",
items: { $ref: "#/components/schemas/Label" },
},
comments: {
type: "array",
items: { $ref: "#/components/schemas/Comment" },
},
},
},
],
},
CreateTaskRequest: {
type: "object",
required: ["orgId", "projectId", "title"],
properties: {
orgId: { type: "string" },
projectId: { type: "string" },
assigneeUserId: { type: ["string", "null"] },
title: { type: "string" },
description: { type: "string" },
status: { type: "string" },
priority: { type: "string" },
storyPoints: { type: ["integer", "null"] },
dueDate: { type: ["string", "null"], format: "date" },
},
},
UpdateTaskRequest: {
type: "object",
properties: {
title: { type: "string" },
description: { type: "string" },
status: { type: "string" },
priority: { type: "string" },
assigneeUserId: { type: ["string", "null"] },
storyPoints: { type: ["integer", "null"] },
dueDate: { type: ["string", "null"], format: "date" },
},
},
},
},
} as const;

346
src/repository.ts Normal file
View File

@@ -0,0 +1,346 @@
import type { Database } from "bun:sqlite";
interface TaskFilters {
orgId?: string | null;
projectId?: string | null;
status?: string | null;
priority?: string | null;
assigneeUserId?: string | null;
}
function toCamelCaseTask(row: Record<string, unknown>) {
return {
id: row.id,
orgId: row.org_id,
projectId: row.project_id,
assigneeUserId: row.assignee_user_id,
title: row.title,
description: row.description,
status: row.status,
priority: row.priority,
storyPoints: row.story_points,
dueDate: row.due_date,
createdAt: row.created_at,
updatedAt: row.updated_at,
projectName: row.project_name,
projectKey: row.project_key,
assigneeName: row.assignee_name,
};
}
export function listOrganizations(db: Database) {
return db
.query(`
SELECT
o.id,
o.slug,
o.name,
o.plan,
o.industry,
o.created_at AS createdAt,
COUNT(DISTINCT p.id) AS projectCount,
COUNT(DISTINCT t.id) AS taskCount,
COUNT(DISTINCT CASE WHEN t.status != 'done' THEN t.id END) AS openTaskCount
FROM organizations o
LEFT JOIN projects p ON p.org_id = o.id
LEFT JOIN tasks t ON t.org_id = o.id
GROUP BY o.id
ORDER BY o.name
`)
.all();
}
export function getOrganizationSummary(db: Database, orgId: string) {
const org = db
.query(`
SELECT
o.id,
o.slug,
o.name,
o.plan,
o.industry,
o.created_at AS createdAt,
COUNT(DISTINCT u.id) AS userCount,
COUNT(DISTINCT p.id) AS projectCount,
COUNT(DISTINCT t.id) AS taskCount
FROM organizations o
LEFT JOIN users u ON u.org_id = o.id
LEFT JOIN projects p ON p.org_id = o.id
LEFT JOIN tasks t ON t.org_id = o.id
WHERE o.id = ?
GROUP BY o.id
`)
.get(orgId);
if (!org) {
return null;
}
const statusBreakdown = db
.query(`
SELECT status, COUNT(*) AS count
FROM tasks
WHERE org_id = ?
GROUP BY status
ORDER BY count DESC, status ASC
`)
.all(orgId);
return {
...org,
statusBreakdown,
};
}
export function listUsers(db: Database, orgId?: string | null) {
if (orgId) {
return db
.query(`
SELECT
id,
org_id AS orgId,
full_name AS fullName,
email,
role,
timezone
FROM users
WHERE org_id = ?
ORDER BY full_name
`)
.all(orgId);
}
return db
.query(`
SELECT
id,
org_id AS orgId,
full_name AS fullName,
email,
role,
timezone
FROM users
ORDER BY org_id, full_name
`)
.all();
}
export function listProjects(db: Database, orgId?: string | null) {
const sql = `
SELECT
p.id,
p.org_id AS orgId,
p.key,
p.name,
p.status,
p.owner_user_id AS ownerUserId,
p.due_date AS dueDate,
u.full_name AS ownerName,
COUNT(t.id) AS taskCount
FROM projects p
LEFT JOIN users u ON u.id = p.owner_user_id
LEFT JOIN tasks t ON t.project_id = p.id
${orgId ? "WHERE p.org_id = ?" : ""}
GROUP BY p.id
ORDER BY p.name
`;
return orgId ? db.query(sql).all(orgId) : db.query(sql).all();
}
export function listTasks(db: Database, filters: TaskFilters = {}) {
const conditions: string[] = [];
const values: string[] = [];
if (filters.orgId) {
conditions.push("t.org_id = ?");
values.push(filters.orgId);
}
if (filters.projectId) {
conditions.push("t.project_id = ?");
values.push(filters.projectId);
}
if (filters.status) {
conditions.push("t.status = ?");
values.push(filters.status);
}
if (filters.priority) {
conditions.push("t.priority = ?");
values.push(filters.priority);
}
if (filters.assigneeUserId) {
conditions.push("t.assignee_user_id = ?");
values.push(filters.assigneeUserId);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const rows = db
.query(`
SELECT
t.*,
p.name AS project_name,
p.key AS project_key,
u.full_name AS assignee_name
FROM tasks t
JOIN projects p ON p.id = t.project_id
LEFT JOIN users u ON u.id = t.assignee_user_id
${whereClause}
ORDER BY
CASE t.priority
WHEN 'urgent' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
ELSE 4
END,
t.due_date ASC,
t.updated_at DESC
`)
.all(...values) as Record<string, unknown>[];
return rows.map(toCamelCaseTask);
}
export function getTaskDetail(db: Database, taskId: string) {
const row = db
.query(`
SELECT
t.*,
p.name AS project_name,
p.key AS project_key,
u.full_name AS assignee_name
FROM tasks t
JOIN projects p ON p.id = t.project_id
LEFT JOIN users u ON u.id = t.assignee_user_id
WHERE t.id = ?
`)
.get(taskId) as Record<string, unknown> | null;
if (!row) {
return null;
}
const labels = db
.query(`
SELECT l.id, l.name, l.color
FROM task_labels tl
JOIN labels l ON l.id = tl.label_id
WHERE tl.task_id = ?
ORDER BY l.name
`)
.all(taskId);
const comments = db
.query(`
SELECT
c.id,
c.body,
c.created_at AS createdAt,
c.author_user_id AS authorUserId,
u.full_name AS authorName
FROM comments c
JOIN users u ON u.id = c.author_user_id
WHERE c.task_id = ?
ORDER BY c.created_at ASC
`)
.all(taskId);
return {
...toCamelCaseTask(row),
labels,
comments,
};
}
export interface CreateTaskInput {
orgId: string;
projectId: string;
assigneeUserId?: string | null;
title: string;
description?: string;
status?: string;
priority?: string;
storyPoints?: number | null;
dueDate?: string | null;
}
export function createTask(db: Database, input: CreateTaskInput) {
const now = new Date().toISOString();
const id = `task_${Math.random().toString(36).slice(2, 10)}`;
db.query(`
INSERT INTO tasks (
id, org_id, project_id, assignee_user_id, title, description, status, priority, story_points, due_date, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
input.orgId,
input.projectId,
input.assigneeUserId ?? null,
input.title,
input.description ?? "",
input.status ?? "todo",
input.priority ?? "medium",
input.storyPoints ?? null,
input.dueDate ?? null,
now,
now,
);
return getTaskDetail(db, id);
}
export interface UpdateTaskInput {
title?: string;
description?: string;
status?: string;
priority?: string;
assigneeUserId?: string | null;
storyPoints?: number | null;
dueDate?: string | null;
}
export function updateTask(db: Database, taskId: string, input: UpdateTaskInput) {
const existing = db.query("SELECT id FROM tasks WHERE id = ?").get(taskId);
if (!existing) {
return null;
}
const fields: string[] = [];
const values: Array<string | number | null> = [];
if (input.title !== undefined) {
fields.push("title = ?");
values.push(input.title);
}
if (input.description !== undefined) {
fields.push("description = ?");
values.push(input.description);
}
if (input.status !== undefined) {
fields.push("status = ?");
values.push(input.status);
}
if (input.priority !== undefined) {
fields.push("priority = ?");
values.push(input.priority);
}
if (input.assigneeUserId !== undefined) {
fields.push("assignee_user_id = ?");
values.push(input.assigneeUserId);
}
if (input.storyPoints !== undefined) {
fields.push("story_points = ?");
values.push(input.storyPoints);
}
if (input.dueDate !== undefined) {
fields.push("due_date = ?");
values.push(input.dueDate);
}
fields.push("updated_at = ?");
values.push(new Date().toISOString());
values.push(taskId);
db.query(`UPDATE tasks SET ${fields.join(", ")} WHERE id = ?`).run(...values);
return getTaskDetail(db, taskId);
}