Add tsoa create endpoints for orgs users and projects
This commit is contained in:
@@ -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`
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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}": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user