From 2d2aacf2c0921b8149677e6f1332c9fb8c70be43 Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Tue, 3 Mar 2026 16:06:30 +0000 Subject: [PATCH] Add tsoa create endpoints for orgs users and projects --- README.md | 3 + src/controllers/OrganizationsController.ts | 45 +++- src/controllers/ProjectsController.ts | 27 ++- src/controllers/UsersController.ts | 27 ++- src/controllers/shared.ts | 69 ++++-- src/generated/routes.ts | 173 +++++++++++-- src/generated/swagger.json | 270 +++++++++++++++++++-- src/models.ts | 32 +++ src/repository.ts | 102 ++++++++ tests/api.test.ts | 70 ++++++ tests/repository.test.ts | 42 +++- tests/routes.test.ts | 6 + 12 files changed, 803 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 496f70d..e6ab1fa 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/src/controllers/OrganizationsController.ts b/src/controllers/OrganizationsController.ts index 88deb14..9031297 100644 --- a/src/controllers/OrganizationsController.ts +++ b/src/controllers/OrganizationsController.ts @@ -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(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(404, "Organization not found") public getOrganization( diff --git a/src/controllers/ProjectsController.ts b/src/controllers/ProjectsController.ts index 111dabf..8854e26 100644 --- a/src/controllers/ProjectsController.ts +++ b/src/controllers/ProjectsController.ts @@ -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(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 }; + } } diff --git a/src/controllers/UsersController.ts b/src/controllers/UsersController.ts index dc2107c..f0f53dd 100644 --- a/src/controllers/UsersController.ts +++ b/src/controllers/UsersController.ts @@ -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(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 }; + } } diff --git a/src/controllers/shared.ts b/src/controllers/shared.ts index e2c2868..3236935 100644 --- a/src/controllers/shared.ts +++ b/src/controllers/shared.ts @@ -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), diff --git a/src/generated/routes.ts b/src/generated/routes.ts index 68630c1..244e143 100644 --- a/src/generated/routes.ts +++ b/src/generated/routes.ts @@ -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 = { + request: {"in":"request","name":"request","required":true,"dataType":"object"}, + body: {"in":"body","name":"body","required":true,"ref":"CreateUserRequest"}, + }; + app.post('/users', + ...(fetchMiddlewares(UsersController)), + ...(fetchMiddlewares(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 = { 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 = { + request: {"in":"request","name":"request","required":true,"dataType":"object"}, + body: {"in":"body","name":"body","required":true,"ref":"CreateProjectRequest"}, + }; + app.post('/projects', + ...(fetchMiddlewares(ProjectsController)), + ...(fetchMiddlewares(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 = { 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 = { + request: {"in":"request","name":"request","required":true,"dataType":"object"}, + body: {"in":"body","name":"body","required":true,"ref":"CreateOrganizationRequest"}, + }; + app.post('/orgs', + ...(fetchMiddlewares(OrganizationsController)), + ...(fetchMiddlewares(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 = { request: {"in":"request","name":"request","required":true,"dataType":"object"}, orgId: {"in":"path","name":"orgId","required":true,"dataType":"string"}, diff --git a/src/generated/swagger.json b/src/generated/swagger.json index 8356ff0..4d1b704 100644 --- a/src/generated/swagger.json +++ b/src/generated/swagger.json @@ -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}": { diff --git a/src/models.ts b/src/models.ts index 0298e96..d2ea760 100644 --- a/src/models.ts +++ b/src/models.ts @@ -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; diff --git a/src/repository.ts b/src/repository.ts index 56fd0e1..2aa5d9a 100644 --- a/src/repository.ts +++ b/src/repository.ts @@ -28,6 +28,44 @@ function toCamelCaseTask(row: Record) { }; } +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[] = []; diff --git a/tests/api.test.ts b/tests/api.test.ts index 301f0e4..bea42c5 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -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(); diff --git a/tests/repository.test.ts b/tests/repository.test.ts index 3045da7..8b47e8d 100644 --- a/tests/repository.test.ts +++ b/tests/repository.test.ts @@ -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(); diff --git a/tests/routes.test.ts b/tests/routes.test.ts index eaf051b..bd7351a 100644 --- a/tests/routes.test.ts +++ b/tests/routes.test.ts @@ -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(); }); });