Add tsoa create endpoints for orgs users and projects

This commit is contained in:
2026-03-03 16:06:30 +00:00
parent 8e223bfbec
commit 2d2aacf2c0
12 changed files with 803 additions and 63 deletions

View File

@@ -56,6 +56,7 @@ The data is intentionally uneven across orgs so consumers can test:
### Organizations
- `POST /orgs`
- `GET /orgs`
- `GET /orgs/:orgId`
- `GET /orgs/:orgId/projects`
@@ -63,8 +64,10 @@ The data is intentionally uneven across orgs so consumers can test:
### Users and Projects
- `POST /users`
- `GET /users`
- `GET /users?orgId=org_acme`
- `POST /projects`
- `GET /projects`
- `GET /projects?orgId=org_northstar`

View File

@@ -1,7 +1,26 @@
import type { Request as ExRequest } from "express";
import { Controller, Get, Path, Query, Request, Response, Route, Tags } from "tsoa";
import { getOrganizationSummary, listOrganizations, listProjects, listTasks } from "../repository";
import {
Body,
Controller,
Get,
Path,
Post,
Query,
Request,
Response,
Route,
SuccessResponse,
Tags,
} from "tsoa";
import {
createOrganization,
getOrganizationSummary,
listOrganizations,
listProjects,
listTasks,
} from "../repository";
import type {
CreateOrganizationRequest,
ErrorResponse,
OrganizationEnvelope,
OrganizationsEnvelope,
@@ -9,7 +28,7 @@ import type {
TasksEnvelope,
} from "../models";
import { HttpError } from "../httpErrors";
import { getDatabase } from "./shared";
import { getDatabase, normalizeCreateOrganization } from "./shared";
@Route("orgs")
@Tags("Organizations")
@@ -19,6 +38,26 @@ export class OrganizationsController extends Controller {
return { organizations: listOrganizations(getDatabase(request)) };
}
@Post()
@SuccessResponse("201", "Created")
@Response<ErrorResponse>(400, "Invalid payload")
public createOrganization(
@Request() request: ExRequest,
@Body() body: CreateOrganizationRequest,
): OrganizationEnvelope {
this.setStatus(201);
const organization = createOrganization(
getDatabase(request),
normalizeCreateOrganization(body),
);
if (!organization) {
throw new HttpError(500, "Failed to create organization.");
}
return { organization };
}
@Get("{orgId}")
@Response<ErrorResponse>(404, "Organization not found")
public getOrganization(

View File

@@ -1,12 +1,13 @@
import type { Request as ExRequest } from "express";
import { Get, Query, Request, Route, Tags } from "tsoa";
import { listProjects } from "../repository";
import type { ProjectsEnvelope } from "../models";
import { getDatabase } from "./shared";
import { Body, Controller, Get, Post, Query, Request, Response, Route, SuccessResponse, Tags } from "tsoa";
import { createProject, listProjects } from "../repository";
import type { CreateProjectRequest, ErrorResponse, ProjectEnvelope, ProjectsEnvelope } from "../models";
import { HttpError } from "../httpErrors";
import { getDatabase, normalizeCreateProject } from "./shared";
@Route("projects")
@Tags("Projects")
export class ProjectsController {
export class ProjectsController extends Controller {
@Get()
public listProjects(
@Request() request: ExRequest,
@@ -14,4 +15,20 @@ export class ProjectsController {
): ProjectsEnvelope {
return { projects: listProjects(getDatabase(request), orgId) };
}
@Post()
@SuccessResponse("201", "Created")
@Response<ErrorResponse>(400, "Invalid payload")
public createProject(
@Request() request: ExRequest,
@Body() body: CreateProjectRequest,
): ProjectEnvelope {
this.setStatus(201);
const project = createProject(getDatabase(request), normalizeCreateProject(body));
if (!project) {
throw new HttpError(500, "Failed to create project.");
}
return { project };
}
}

View File

@@ -1,12 +1,13 @@
import type { Request as ExRequest } from "express";
import { Get, Query, Request, Route, Tags } from "tsoa";
import { listUsers } from "../repository";
import type { UsersEnvelope } from "../models";
import { getDatabase } from "./shared";
import { Body, Controller, Get, Post, Query, Request, Response, Route, SuccessResponse, Tags } from "tsoa";
import { createUser, listUsers } from "../repository";
import type { CreateUserRequest, ErrorResponse, UserEnvelope, UsersEnvelope } from "../models";
import { HttpError } from "../httpErrors";
import { getDatabase, normalizeCreateUser } from "./shared";
@Route("users")
@Tags("Users")
export class UsersController {
export class UsersController extends Controller {
@Get()
public listUsers(
@Request() request: ExRequest,
@@ -14,4 +15,20 @@ export class UsersController {
): UsersEnvelope {
return { users: listUsers(getDatabase(request), orgId) };
}
@Post()
@SuccessResponse("201", "Created")
@Response<ErrorResponse>(400, "Invalid payload")
public createUser(
@Request() request: ExRequest,
@Body() body: CreateUserRequest,
): UserEnvelope {
this.setStatus(201);
const user = createUser(getDatabase(request), normalizeCreateUser(body));
if (!user) {
throw new HttpError(500, "Failed to create user.");
}
return { user };
}
}

View File

@@ -1,7 +1,13 @@
import type { Database } from "bun:sqlite";
import type { Request as ExRequest } from "express";
import { HttpError } from "../httpErrors";
import type { CreateTaskRequest, UpdateTaskRequest } from "../models";
import type {
CreateOrganizationRequest,
CreateProjectRequest,
CreateTaskRequest,
CreateUserRequest,
UpdateTaskRequest,
} from "../models";
export function getDatabase(request: ExRequest): Database {
return request.app.locals.db as Database;
@@ -11,6 +17,14 @@ function optionalText(value: unknown) {
return typeof value === "string" ? value.trim() : undefined;
}
function requiredText(value: unknown, fieldName: string) {
if (typeof value !== "string" || value.trim() === "") {
throw new HttpError(400, `${fieldName} is required.`);
}
return value.trim();
}
function nullableText(value: unknown) {
if (typeof value === "string") {
return value.trim();
@@ -28,22 +42,12 @@ function nullableInteger(value: unknown) {
}
export function normalizeCreateTask(body: CreateTaskRequest): CreateTaskRequest {
if (typeof body.orgId !== "string" || body.orgId.trim() === "") {
throw new HttpError(400, "orgId is required.");
}
if (typeof body.projectId !== "string" || body.projectId.trim() === "") {
throw new HttpError(400, "projectId is required.");
}
if (typeof body.title !== "string" || body.title.trim() === "") {
throw new HttpError(400, "title is required.");
}
return {
orgId: body.orgId.trim(),
projectId: body.projectId.trim(),
orgId: requiredText(body.orgId, "orgId"),
projectId: requiredText(body.projectId, "projectId"),
assigneeUserId:
typeof body.assigneeUserId === "string" ? body.assigneeUserId.trim() : null,
title: body.title.trim(),
title: requiredText(body.title, "title"),
description: optionalText(body.description),
status: optionalText(body.status),
priority: optionalText(body.priority),
@@ -57,6 +61,43 @@ export function normalizeCreateTask(body: CreateTaskRequest): CreateTaskRequest
};
}
export function normalizeCreateOrganization(
body: CreateOrganizationRequest,
): CreateOrganizationRequest {
return {
slug: requiredText(body.slug, "slug"),
name: requiredText(body.name, "name"),
plan: requiredText(body.plan, "plan"),
industry: requiredText(body.industry, "industry"),
};
}
export function normalizeCreateUser(body: CreateUserRequest): CreateUserRequest {
return {
orgId: requiredText(body.orgId, "orgId"),
fullName: requiredText(body.fullName, "fullName"),
email: requiredText(body.email, "email"),
role: requiredText(body.role, "role"),
timezone: requiredText(body.timezone, "timezone"),
};
}
export function normalizeCreateProject(body: CreateProjectRequest): CreateProjectRequest {
return {
orgId: requiredText(body.orgId, "orgId"),
key: requiredText(body.key, "key"),
name: requiredText(body.name, "name"),
status: requiredText(body.status, "status"),
ownerUserId: requiredText(body.ownerUserId, "ownerUserId"),
dueDate:
typeof body.dueDate === "string"
? body.dueDate.trim()
: body.dueDate === null
? null
: undefined,
};
}
export function normalizeUpdateTask(body: UpdateTaskRequest): UpdateTaskRequest {
return {
title: optionalText(body.title),

View File

@@ -41,6 +41,40 @@ const models: TsoaRoute.Models = {
"additionalProperties": true,
},
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
"UserEnvelope": {
"dataType": "refObject",
"properties": {
"user": {"ref":"User","required":true},
},
"additionalProperties": true,
},
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
"Record_string.unknown_": {
"dataType": "refAlias",
"type": {"dataType":"nestedObjectLiteral","nestedProperties":{},"additionalProperties":{"dataType":"any"},"validators":{}},
},
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
"ErrorResponse": {
"dataType": "refObject",
"properties": {
"error": {"dataType":"string","required":true},
"details": {"ref":"Record_string.unknown_"},
},
"additionalProperties": true,
},
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
"CreateUserRequest": {
"dataType": "refObject",
"properties": {
"orgId": {"dataType":"string","required":true},
"fullName": {"dataType":"string","required":true},
"email": {"dataType":"string","required":true},
"role": {"dataType":"string","required":true},
"timezone": {"dataType":"string","required":true},
},
"additionalProperties": true,
},
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
"TaskListItem": {
"dataType": "refObject",
"properties": {
@@ -125,20 +159,6 @@ const models: TsoaRoute.Models = {
"additionalProperties": true,
},
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
"Record_string.unknown_": {
"dataType": "refAlias",
"type": {"dataType":"nestedObjectLiteral","nestedProperties":{},"additionalProperties":{"dataType":"any"},"validators":{}},
},
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
"ErrorResponse": {
"dataType": "refObject",
"properties": {
"error": {"dataType":"string","required":true},
"details": {"ref":"Record_string.unknown_"},
},
"additionalProperties": true,
},
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
"CreateTaskRequest": {
"dataType": "refObject",
"properties": {
@@ -193,6 +213,27 @@ const models: TsoaRoute.Models = {
"additionalProperties": true,
},
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
"ProjectEnvelope": {
"dataType": "refObject",
"properties": {
"project": {"ref":"Project","required":true},
},
"additionalProperties": true,
},
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
"CreateProjectRequest": {
"dataType": "refObject",
"properties": {
"orgId": {"dataType":"string","required":true},
"key": {"dataType":"string","required":true},
"name": {"dataType":"string","required":true},
"status": {"dataType":"string","required":true},
"ownerUserId": {"dataType":"string","required":true},
"dueDate": {"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}]},
},
"additionalProperties": true,
},
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
"OrganizationSummaryListItem": {
"dataType": "refObject",
"properties": {
@@ -252,6 +293,17 @@ const models: TsoaRoute.Models = {
"additionalProperties": true,
},
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
"CreateOrganizationRequest": {
"dataType": "refObject",
"properties": {
"slug": {"dataType":"string","required":true},
"name": {"dataType":"string","required":true},
"plan": {"dataType":"string","required":true},
"industry": {"dataType":"string","required":true},
},
"additionalProperties": true,
},
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
"HealthResponse": {
"dataType": "refObject",
"properties": {
@@ -308,6 +360,37 @@ export function RegisterRoutes(app: Router) {
}
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
const argsUsersController_createUser: Record<string, TsoaRoute.ParameterSchema> = {
request: {"in":"request","name":"request","required":true,"dataType":"object"},
body: {"in":"body","name":"body","required":true,"ref":"CreateUserRequest"},
};
app.post('/users',
...(fetchMiddlewares<RequestHandler>(UsersController)),
...(fetchMiddlewares<RequestHandler>(UsersController.prototype.createUser)),
async function UsersController_createUser(request: ExRequest, response: ExResponse, next: any) {
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
let validatedArgs: any[] = [];
try {
validatedArgs = templateService.getValidatedArgs({ args: argsUsersController_createUser, request, response });
const controller = new UsersController();
await templateService.apiHandler({
methodName: 'createUser',
controller,
response,
next,
validatedArgs,
successStatus: 201,
});
} catch (err) {
return next(err);
}
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
const argsTasksController_listTasks: Record<string, TsoaRoute.ParameterSchema> = {
request: {"in":"request","name":"request","required":true,"dataType":"object"},
orgId: {"in":"query","name":"orgId","dataType":"string"},
@@ -468,6 +551,37 @@ export function RegisterRoutes(app: Router) {
}
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
const argsProjectsController_createProject: Record<string, TsoaRoute.ParameterSchema> = {
request: {"in":"request","name":"request","required":true,"dataType":"object"},
body: {"in":"body","name":"body","required":true,"ref":"CreateProjectRequest"},
};
app.post('/projects',
...(fetchMiddlewares<RequestHandler>(ProjectsController)),
...(fetchMiddlewares<RequestHandler>(ProjectsController.prototype.createProject)),
async function ProjectsController_createProject(request: ExRequest, response: ExResponse, next: any) {
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
let validatedArgs: any[] = [];
try {
validatedArgs = templateService.getValidatedArgs({ args: argsProjectsController_createProject, request, response });
const controller = new ProjectsController();
await templateService.apiHandler({
methodName: 'createProject',
controller,
response,
next,
validatedArgs,
successStatus: 201,
});
} catch (err) {
return next(err);
}
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
const argsOrganizationsController_listOrganizations: Record<string, TsoaRoute.ParameterSchema> = {
request: {"in":"request","name":"request","required":true,"dataType":"object"},
};
@@ -498,6 +612,37 @@ export function RegisterRoutes(app: Router) {
}
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
const argsOrganizationsController_createOrganization: Record<string, TsoaRoute.ParameterSchema> = {
request: {"in":"request","name":"request","required":true,"dataType":"object"},
body: {"in":"body","name":"body","required":true,"ref":"CreateOrganizationRequest"},
};
app.post('/orgs',
...(fetchMiddlewares<RequestHandler>(OrganizationsController)),
...(fetchMiddlewares<RequestHandler>(OrganizationsController.prototype.createOrganization)),
async function OrganizationsController_createOrganization(request: ExRequest, response: ExResponse, next: any) {
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
let validatedArgs: any[] = [];
try {
validatedArgs = templateService.getValidatedArgs({ args: argsOrganizationsController_createOrganization, request, response });
const controller = new OrganizationsController();
await templateService.apiHandler({
methodName: 'createOrganization',
controller,
response,
next,
validatedArgs,
successStatus: 201,
});
} catch (err) {
return next(err);
}
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
const argsOrganizationsController_getOrganization: Record<string, TsoaRoute.ParameterSchema> = {
request: {"in":"request","name":"request","required":true,"dataType":"object"},
orgId: {"in":"path","name":"orgId","required":true,"dataType":"string"},

View File

@@ -54,6 +54,67 @@
"type": "object",
"additionalProperties": true
},
"UserEnvelope": {
"properties": {
"user": {
"$ref": "#/components/schemas/User"
}
},
"required": [
"user"
],
"type": "object",
"additionalProperties": true
},
"Record_string.unknown_": {
"properties": {},
"additionalProperties": {},
"type": "object",
"description": "Construct a type with a set of properties K of type T"
},
"ErrorResponse": {
"properties": {
"error": {
"type": "string"
},
"details": {
"$ref": "#/components/schemas/Record_string.unknown_"
}
},
"required": [
"error"
],
"type": "object",
"additionalProperties": true
},
"CreateUserRequest": {
"properties": {
"orgId": {
"type": "string"
},
"fullName": {
"type": "string"
},
"email": {
"type": "string"
},
"role": {
"type": "string"
},
"timezone": {
"type": "string"
}
},
"required": [
"orgId",
"fullName",
"email",
"role",
"timezone"
],
"type": "object",
"additionalProperties": true
},
"TaskListItem": {
"properties": {
"id": {
@@ -289,27 +350,6 @@
"type": "object",
"additionalProperties": true
},
"Record_string.unknown_": {
"properties": {},
"additionalProperties": {},
"type": "object",
"description": "Construct a type with a set of properties K of type T"
},
"ErrorResponse": {
"properties": {
"error": {
"type": "string"
},
"details": {
"$ref": "#/components/schemas/Record_string.unknown_"
}
},
"required": [
"error"
],
"type": "object",
"additionalProperties": true
},
"CreateTaskRequest": {
"properties": {
"orgId": {
@@ -445,6 +485,50 @@
"type": "object",
"additionalProperties": true
},
"ProjectEnvelope": {
"properties": {
"project": {
"$ref": "#/components/schemas/Project"
}
},
"required": [
"project"
],
"type": "object",
"additionalProperties": true
},
"CreateProjectRequest": {
"properties": {
"orgId": {
"type": "string"
},
"key": {
"type": "string"
},
"name": {
"type": "string"
},
"status": {
"type": "string"
},
"ownerUserId": {
"type": "string"
},
"dueDate": {
"type": "string",
"nullable": true
}
},
"required": [
"orgId",
"key",
"name",
"status",
"ownerUserId"
],
"type": "object",
"additionalProperties": true
},
"OrganizationSummaryListItem": {
"properties": {
"id": {
@@ -595,6 +679,30 @@
"type": "object",
"additionalProperties": true
},
"CreateOrganizationRequest": {
"properties": {
"slug": {
"type": "string"
},
"name": {
"type": "string"
},
"plan": {
"type": "string"
},
"industry": {
"type": "string"
}
},
"required": [
"slug",
"name",
"plan",
"industry"
],
"type": "object",
"additionalProperties": true
},
"HealthResponse": {
"properties": {
"status": {
@@ -648,6 +756,46 @@
}
}
]
},
"post": {
"operationId": "CreateUser",
"responses": {
"201": {
"description": "Created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserEnvelope"
}
}
}
},
"400": {
"description": "Invalid payload",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
},
"tags": [
"Users"
],
"security": [],
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateUserRequest"
}
}
}
}
}
},
"/tasks": {
@@ -872,6 +1020,46 @@
}
}
]
},
"post": {
"operationId": "CreateProject",
"responses": {
"201": {
"description": "Created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProjectEnvelope"
}
}
}
},
"400": {
"description": "Invalid payload",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
},
"tags": [
"Projects"
],
"security": [],
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateProjectRequest"
}
}
}
}
}
},
"/orgs": {
@@ -894,6 +1082,46 @@
],
"security": [],
"parameters": []
},
"post": {
"operationId": "CreateOrganization",
"responses": {
"201": {
"description": "Created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OrganizationEnvelope"
}
}
}
},
"400": {
"description": "Invalid payload",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
},
"tags": [
"Organizations"
],
"security": [],
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateOrganizationRequest"
}
}
}
}
}
},
"/orgs/{orgId}": {

View File

@@ -99,10 +99,18 @@ export interface UsersEnvelope {
users: User[];
}
export interface UserEnvelope {
user: User;
}
export interface ProjectsEnvelope {
projects: Project[];
}
export interface ProjectEnvelope {
project: Project;
}
export interface TasksEnvelope {
tasks: TaskListItem[];
}
@@ -123,6 +131,30 @@ export interface CreateTaskRequest {
dueDate?: string | null;
}
export interface CreateOrganizationRequest {
slug: string;
name: string;
plan: string;
industry: string;
}
export interface CreateUserRequest {
orgId: string;
fullName: string;
email: string;
role: string;
timezone: string;
}
export interface CreateProjectRequest {
orgId: string;
key: string;
name: string;
status: string;
ownerUserId: string;
dueDate?: string | null;
}
export interface UpdateTaskRequest {
title?: string;
description?: string;

View File

@@ -28,6 +28,44 @@ function toCamelCaseTask(row: Record<string, unknown>) {
};
}
function getUser(db: Database, userId: string) {
return db
.query(`
SELECT
id,
org_id AS orgId,
full_name AS fullName,
email,
role,
timezone
FROM users
WHERE id = ?
`)
.get(userId);
}
function getProject(db: Database, projectId: string) {
return db
.query(`
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
WHERE p.id = ?
GROUP BY p.id
`)
.get(projectId);
}
export function listOrganizations(db: Database) {
return db
.query(`
@@ -92,6 +130,25 @@ export function getOrganizationSummary(db: Database, orgId: string) {
};
}
export interface CreateOrganizationInput {
slug: string;
name: string;
plan: string;
industry: string;
}
export function createOrganization(db: Database, input: CreateOrganizationInput) {
const id = `org_${Math.random().toString(36).slice(2, 10)}`;
const now = new Date().toISOString();
db.query(`
INSERT INTO organizations (id, slug, name, plan, industry, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(id, input.slug, input.name, input.plan, input.industry, now);
return getOrganizationSummary(db, id);
}
export function listUsers(db: Database, orgId?: string | null) {
if (orgId) {
return db
@@ -125,6 +182,24 @@ export function listUsers(db: Database, orgId?: string | null) {
.all();
}
export interface CreateUserInput {
orgId: string;
fullName: string;
email: string;
role: string;
timezone: string;
}
export function createUser(db: Database, input: CreateUserInput) {
const id = `user_${Math.random().toString(36).slice(2, 10)}`;
db.query(`
INSERT INTO users (id, org_id, full_name, email, role, timezone)
VALUES (?, ?, ?, ?, ?, ?)
`).run(id, input.orgId, input.fullName, input.email, input.role, input.timezone);
return getUser(db, id);
}
export function listProjects(db: Database, orgId?: string | null) {
const sql = `
SELECT
@@ -148,6 +223,33 @@ export function listProjects(db: Database, orgId?: string | null) {
return orgId ? db.query(sql).all(orgId) : db.query(sql).all();
}
export interface CreateProjectInput {
orgId: string;
key: string;
name: string;
status: string;
ownerUserId: string;
dueDate?: string | null;
}
export function createProject(db: Database, input: CreateProjectInput) {
const id = `proj_${Math.random().toString(36).slice(2, 10)}`;
db.query(`
INSERT INTO projects (id, org_id, key, name, status, owner_user_id, due_date)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
id,
input.orgId,
input.key,
input.name,
input.status,
input.ownerUserId,
input.dueDate ?? null,
);
return getProject(db, id);
}
export function listTasks(db: Database, filters: TaskFilters = {}) {
const conditions: string[] = [];
const values: string[] = [];

View File

@@ -83,6 +83,76 @@ describe("mock task API", () => {
expect(response.body.organizations).toHaveLength(3);
});
it("creates an organization through the HTTP contract", async () => {
const app = createTestApp();
const response = await sendRequest(app, {
method: "POST",
url: "/orgs",
body: {
slug: "globex-systems",
name: "Globex Systems",
plan: "growth",
industry: "Manufacturing",
},
headers: {
"content-type": "application/json",
},
});
expect(response.status).toBe(201);
expect(response.body.organization.slug).toBe("globex-systems");
expect(response.body.organization.name).toBe("Globex Systems");
});
it("creates a user through the HTTP contract", async () => {
const app = createTestApp();
const response = await sendRequest(app, {
method: "POST",
url: "/users",
body: {
orgId: "org_acme",
fullName: "Iris Quinn",
email: "iris@acme.example",
role: "engineer",
timezone: "Europe/Dublin",
},
headers: {
"content-type": "application/json",
},
});
expect(response.status).toBe(201);
expect(response.body.user.orgId).toBe("org_acme");
expect(response.body.user.email).toBe("iris@acme.example");
});
it("creates a project through the HTTP contract", async () => {
const app = createTestApp();
const response = await sendRequest(app, {
method: "POST",
url: "/projects",
body: {
orgId: "org_acme",
key: "BIZ",
name: "Business Ops Dashboard",
status: "planning",
ownerUserId: "user_acme_1",
dueDate: "2026-06-30",
},
headers: {
"content-type": "application/json",
},
});
expect(response.status).toBe(201);
expect(response.body.project.orgId).toBe("org_acme");
expect(response.body.project.key).toBe("BIZ");
expect(response.body.project.ownerUserId).toBe("user_acme_1");
});
it("filters tasks by org and status", async () => {
const app = createTestApp();

View File

@@ -3,7 +3,17 @@ import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { reseedDatabase } from "../src/database";
import { createTask, getOrganizationSummary, getTaskDetail, listOrganizations, listTasks, updateTask } from "../src/repository";
import {
createOrganization,
createProject,
createTask,
createUser,
getOrganizationSummary,
getTaskDetail,
listOrganizations,
listTasks,
updateTask,
} from "../src/repository";
let tempDir: string | null = null;
@@ -62,6 +72,36 @@ describe("mock task repository", () => {
expect(updated?.assigneeUserId).toBeNull();
});
it("creates organization, user, and project records", () => {
const db = createDb();
const organization = createOrganization(db, {
slug: "zenith-labs",
name: "Zenith Labs",
plan: "starter",
industry: "Biotech",
});
const user = createUser(db, {
orgId: organization!.id as string,
fullName: "Nora Finch",
email: "nora@zenith.example",
role: "org_admin",
timezone: "Europe/Dublin",
});
const project = createProject(db, {
orgId: organization!.id as string,
key: "RND",
name: "Research Dashboard",
status: "planning",
ownerUserId: user!.id as string,
dueDate: "2026-09-01",
});
expect(organization?.slug).toBe("zenith-labs");
expect(user?.orgId).toBe(organization?.id);
expect(project?.ownerUserId).toBe(user?.id);
});
it("returns org summary status counts", () => {
const db = createDb();

View File

@@ -60,12 +60,15 @@ describe("tsoa swagger document", () => {
expect(operations.sort()).toEqual([
"GET /health",
"POST /orgs",
"GET /orgs",
"GET /orgs/{orgId}",
"GET /orgs/{orgId}/projects",
"GET /orgs/{orgId}/tasks",
"GET /users",
"POST /users",
"GET /projects",
"POST /projects",
"GET /tasks",
"POST /tasks",
"GET /tasks/{taskId}",
@@ -84,5 +87,8 @@ describe("tsoa swagger document", () => {
expect(schemas.CreateTaskRequest).toBeDefined();
expect(schemas.UpdateTaskRequest).toBeDefined();
expect(schemas.CreateOrganizationRequest).toBeDefined();
expect(schemas.CreateUserRequest).toBeDefined();
expect(schemas.CreateProjectRequest).toBeDefined();
});
});