feat(api): first version of the implementation, done with opencode and multiple iterations

This commit is contained in:
Nicolas HOARAU 2026-06-21 22:30:37 +04:00
parent aec3f2ff98
commit 7d72932a16
48 changed files with 1838 additions and 220 deletions

12
.env.example Normal file
View file

@ -0,0 +1,12 @@
# PostgreSQL
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=work_hub
# API
API_PORT=3001
# Web
WEB_PORT=3000

View file

@ -10,5 +10,6 @@
"mode": "semantic",
"ignore": ["**/lib/**", "**/legacy/**", "**/__generated__/**", "**/generated/**"]
},
"rules": {}
"rules": {},
"ignoreDependencies": ["@nestjs/schematics", "eslint-plugin-prettier"]
}

View file

@ -82,7 +82,6 @@ All commands run from the repository root.
- Key relaxed rules:
- `@typescript-eslint/no-explicit-any: off`
- `@typescript-eslint/no-floating-promises: warn`
- `@typescript-eslint/no-unsafe-argument: warn`
- Web uses `@nuxt/eslint` with stylistic config (`commaDangle: never`, `braceStyle: 1tbs`).
### TypeScript

View file

@ -1,3 +1,78 @@
import { createNestjsConfig } from "@workspace/tooling/eslint/nestjs";
import { base, baseRules } from "@workspace/tooling/eslint/base";
import { globals } from "@workspace/tooling/eslint/nestjs";
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
import { defineConfig } from "eslint/config";
export default createNestjsConfig(import.meta.dirname);
export default defineConfig(
{ ignores: ["eslint.config.mjs"] },
...base,
eslintPluginPrettierRecommended,
{
files: ["src/**/*.ts"],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: "commonjs",
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
files: ["src/**/*.ts"],
rules: {
"class-methods-use-this": "off",
"@typescript-eslint/no-floating-promises": "warn",
"@typescript-eslint/no-unsafe-argument": "warn",
"@typescript-eslint/no-unsafe-assignment": "warn",
"@typescript-eslint/no-unsafe-call": "warn",
"@typescript-eslint/no-unsafe-member-access": "warn",
"@typescript-eslint/no-unsafe-return": "warn",
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
{
files: ["src/**/*.spec.ts"],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: "commonjs",
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"@typescript-eslint/unbound-method": "off",
},
},
{
files: ["test/**/*.ts"],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: "commonjs",
parserOptions: {
project: "./tsconfig.eslint.json",
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"class-methods-use-this": "off",
"@typescript-eslint/no-floating-promises": "warn",
"@typescript-eslint/no-unsafe-argument": "warn",
"@typescript-eslint/no-unsafe-assignment": "warn",
"@typescript-eslint/no-unsafe-call": "warn",
"@typescript-eslint/no-unsafe-member-access": "warn",
"@typescript-eslint/no-unsafe-return": "warn",
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);

View file

@ -26,27 +26,31 @@
"@nestjs/core": "^11.1.26",
"@nestjs/platform-express": "^11.1.26",
"@workspace/schema": "workspace:*",
"drizzle-orm": "^0.45.2",
"pg": "^8.21.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
"rxjs": "^7.8.2",
"valibot": "^1.4.1"
},
"devDependencies": {
"@jest/globals": "^30.4.1",
"@nestjs/cli": "^11.0.23",
"@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.26",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/node": "^25.9.3",
"@types/pg": "^8.20.0",
"@types/supertest": "^7.2.0",
"@workspace/tooling": "workspace:*",
"eslint": "^10.4.1",
"eslint-plugin-prettier": "^5.5.6",
"jest": "^30.4.2",
"prettier": "^3.8.4",
"source-map-support": "^0.5.21",
"supertest": "^7.2.2",
"ts-jest": "^29.4.11",
"ts-loader": "^9.6.0",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^6.0.3"
},
"jest": {
@ -64,6 +68,10 @@
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
"testEnvironment": "node",
"moduleNameMapper": {
"^@workspace/schema$": "<rootDir>/../test/__mocks__/schema.ts",
"^valibot$": "<rootDir>/../test/__mocks__/valibot.ts"
}
}
}

View file

@ -1,9 +1,11 @@
import { Module } from "@nestjs/common";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { ProjectModule } from "./project/project.module";
import { DatabaseModule } from "./database/database.module";
@Module({
imports: [],
imports: [ProjectModule, DatabaseModule],
controllers: [AppController],
providers: [AppService],
})

View file

@ -0,0 +1,28 @@
import { PipeTransform, Injectable, BadRequestException } from "@nestjs/common";
import { safeParse, type InferOutput, type BaseSchema, type BaseIssue } from "valibot";
@Injectable()
export class ValibotValidationPipe implements PipeTransform {
constructor(private readonly schema: BaseSchema<unknown, unknown, BaseIssue<unknown>>) {}
transform(value: unknown): InferOutput<BaseSchema<unknown, unknown, BaseIssue<unknown>>> {
const result = safeParse(this.schema, value);
if (!result.success) {
const messages = result.issues.map((issue) => {
const path = issue.path?.map((p) => String(p.key)).join(".") ?? "root";
return {
path,
message: issue.message,
};
});
throw new BadRequestException({
statusCode: 400,
message: "Validation failed",
errors: messages,
});
}
return result.output;
}
}

View file

@ -0,0 +1,9 @@
import { Global, Module } from "@nestjs/common";
import { DrizzleService } from "./drizzle.service";
@Global()
@Module({
providers: [DrizzleService],
exports: [DrizzleService],
})
export class DatabaseModule {}

View file

@ -0,0 +1,43 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from "@nestjs/common";
import { Pool } from "pg";
import {
drizzle,
NodePgDatabase,
migrate,
projects,
tasks,
projectsRelations,
tasksRelations,
migrationsDir,
} from "@workspace/schema";
const DEFAULT_PG_PORT = 5432;
@Injectable()
export class DrizzleService implements OnModuleInit, OnModuleDestroy {
private pool: Pool;
public db: NodePgDatabase<Record<string, unknown>>;
constructor() {
this.pool = new Pool({
host: String(process.env.POSTGRES_HOST ?? "localhost"),
port: Number(process.env.POSTGRES_PORT ?? DEFAULT_PG_PORT),
user: String(process.env.POSTGRES_USER ?? "postgres"),
password: String(process.env.POSTGRES_PASSWORD ?? "postgres"),
database: String(process.env.POSTGRES_DB ?? "work_hub"),
});
this.db = drizzle(this.pool, {
schema: { projects, tasks, projectsRelations, tasksRelations },
});
}
async onModuleInit() {
await migrate(this.db, {
migrationsFolder: migrationsDir,
});
}
async onModuleDestroy() {
await this.pool.end();
}
}

View file

@ -0,0 +1,74 @@
import { Test, TestingModule } from "@nestjs/testing";
import { ProjectController } from "./project.controller";
import { ProjectService } from "./project.service";
import { ProjectRepository } from "./project.repository";
describe("ProjectController", () => {
let controller: ProjectController;
const mockRepository = {
create: jest.fn(),
findAll: jest.fn(),
findOne: jest.fn(),
update: jest.fn(),
remove: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ProjectController],
providers: [ProjectService, { provide: ProjectRepository, useValue: mockRepository }],
}).compile();
controller = module.get<ProjectController>(ProjectController);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("findAll", () => {
it("should return an array", async () => {
mockRepository.findAll.mockResolvedValue([{ id: "1", name: "Test" }]);
const result = await controller.findAll();
expect(Array.isArray(result)).toBe(true);
});
});
describe("findOne", () => {
it("should return an object with the given id", async () => {
const expected = { id: "1", name: "Test" };
mockRepository.findOne.mockResolvedValue(expected);
const result = await controller.findOne("1");
expect(result).toEqual(expected);
});
});
describe("create", () => {
it("should return the created body", async () => {
const body = { name: "Test Project" };
mockRepository.create.mockResolvedValue(body);
const result = await controller.create(body);
expect(result).toEqual(body);
});
});
describe("update", () => {
it("should merge id with the update body", async () => {
const body = { name: "Updated" };
mockRepository.findOne.mockResolvedValue({ id: "1" });
mockRepository.update.mockResolvedValue({ id: "1", ...body });
const result = await controller.update("1", body);
expect(result).toEqual({ id: "1", ...body });
});
});
describe("remove", () => {
it("should return the removed id", async () => {
mockRepository.findOne.mockResolvedValue({ id: "1" });
mockRepository.remove.mockResolvedValue({ id: "1" });
const result = await controller.remove("1");
expect(result).toEqual({ id: "1" });
});
});
});

View file

@ -0,0 +1,37 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, ParseUUIDPipe } from "@nestjs/common";
import { ProjectService } from "./project.service";
import { projectInsert, projectUpdate, type ProjectInsertDTO, type ProjectUpdateDTO } from "@workspace/schema";
import { ValibotValidationPipe } from "../common/pipes/valibot-validation.pipe";
@Controller("projects")
export class ProjectController {
constructor(private readonly projectService: ProjectService) {}
@Post()
create(@Body(new ValibotValidationPipe(projectInsert)) body: ProjectInsertDTO) {
return this.projectService.create(body);
}
@Get()
findAll() {
return this.projectService.findAll();
}
@Get(":id")
findOne(@Param("id", ParseUUIDPipe) id: string) {
return this.projectService.findOne(id);
}
@Patch(":id")
update(
@Param("id", ParseUUIDPipe) id: string,
@Body(new ValibotValidationPipe(projectUpdate)) body: ProjectUpdateDTO,
) {
return this.projectService.update(id, body);
}
@Delete(":id")
remove(@Param("id", ParseUUIDPipe) id: string) {
return this.projectService.remove(id);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { ProjectService } from "./project.service";
import { ProjectController } from "./project.controller";
import { ProjectRepository } from "./project.repository";
@Module({
controllers: [ProjectController],
providers: [ProjectService, ProjectRepository],
})
export class ProjectModule {}

View file

@ -0,0 +1,36 @@
import { Injectable } from "@nestjs/common";
import { projects, eq, ProjectInsertDTO, ProjectUpdateDTO } from "@workspace/schema";
import { DrizzleService } from "../database/drizzle.service";
@Injectable()
export class ProjectRepository {
constructor(private readonly drizzle: DrizzleService) {}
private get db() {
return this.drizzle.db;
}
async create(body: ProjectInsertDTO) {
const [project] = await this.db.insert(projects).values(body).returning();
return project;
}
findAll() {
return this.db.select().from(projects);
}
async findOne(id: string) {
const [project] = await this.db.select().from(projects).where(eq(projects.id, id));
return project;
}
async update(id: string, body: ProjectUpdateDTO) {
const [project] = await this.db.update(projects).set(body).where(eq(projects.id, id)).returning();
return project;
}
async remove(id: string) {
const [project] = await this.db.delete(projects).where(eq(projects.id, id)).returning();
return project;
}
}

View file

@ -0,0 +1,112 @@
import { Test, TestingModule } from "@nestjs/testing";
import { NotFoundException } from "@nestjs/common";
import { ProjectService } from "./project.service";
import { ProjectRepository } from "./project.repository";
type MockProject = { id: string; name: string };
describe("ProjectService", () => {
let service: ProjectService;
const mockFindAll = jest.fn<Promise<MockProject[]>, []>();
const mockFindOne = jest.fn<Promise<MockProject | undefined>, [string]>();
const mockCreate = jest.fn<Promise<MockProject>, [Record<string, unknown>]>();
const mockUpdate = jest.fn<Promise<MockProject>, [string, Record<string, unknown>]>();
const mockRemove = jest.fn<Promise<MockProject>, [string]>();
beforeEach(async () => {
mockFindAll.mockReset();
mockFindOne.mockReset();
mockCreate.mockReset();
mockUpdate.mockReset();
mockRemove.mockReset();
const module: TestingModule = await Test.createTestingModule({
providers: [
ProjectService,
{
provide: ProjectRepository,
useValue: {
findAll: mockFindAll,
findOne: mockFindOne,
create: mockCreate,
update: mockUpdate,
remove: mockRemove,
},
},
],
}).compile();
service = module.get<ProjectService>(ProjectService);
});
it("should be defined", () => {
expect(service).toBeDefined();
});
describe("findAll", () => {
it("should return all projects", async () => {
const expected = [{ id: "1", name: "Test" }];
mockFindAll.mockResolvedValue(expected);
const result = await service.findAll();
expect(result).toEqual(expected);
expect(mockFindAll).toHaveBeenCalled();
});
});
describe("findOne", () => {
it("should return a project by id", async () => {
const expected = { id: "1", name: "Test" };
mockFindOne.mockResolvedValue(expected);
const result = await service.findOne("1");
expect(result).toEqual(expected);
expect(mockFindOne).toHaveBeenCalledWith("1");
});
it("should throw NotFoundException when project not found", async () => {
mockFindOne.mockResolvedValue(undefined);
await expect(service.findOne("nonexistent")).rejects.toThrow(NotFoundException);
});
});
describe("create", () => {
it("should create and return a project", async () => {
const body = { name: "New Project" };
const expected = { id: "1", name: "New Project" };
mockCreate.mockResolvedValue(expected);
const result = await service.create(body);
expect(result).toEqual(expected);
expect(mockCreate).toHaveBeenCalledWith(body);
});
});
describe("update", () => {
it("should update and return a project", async () => {
const body = { name: "Updated" };
const expected = { id: "1", name: "Updated" };
mockFindOne.mockResolvedValue({ id: "1" } as MockProject);
mockUpdate.mockResolvedValue(expected);
const result = await service.update("1", body);
expect(result).toEqual(expected);
expect(mockFindOne).toHaveBeenCalledWith("1");
expect(mockUpdate).toHaveBeenCalledWith("1", body);
});
});
describe("remove", () => {
it("should delete and return a project", async () => {
mockFindOne.mockResolvedValue({ id: "1" } as MockProject);
mockRemove.mockResolvedValue({ id: "1" } as MockProject);
const result = await service.remove("1");
expect(result).toEqual({ id: "1" });
expect(mockFindOne).toHaveBeenCalledWith("1");
expect(mockRemove).toHaveBeenCalledWith("1");
});
});
});

View file

@ -0,0 +1,32 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import type { ProjectInsertDTO, ProjectUpdateDTO } from "@workspace/schema";
import { ProjectRepository } from "./project.repository";
@Injectable()
export class ProjectService {
constructor(private readonly projectRepository: ProjectRepository) {}
create(body: ProjectInsertDTO) {
return this.projectRepository.create(body);
}
findAll() {
return this.projectRepository.findAll();
}
async findOne(id: string) {
const project = await this.projectRepository.findOne(id);
if (!project) throw new NotFoundException();
return project;
}
async update(id: string, body: ProjectUpdateDTO) {
await this.findOne(id);
return this.projectRepository.update(id, body);
}
async remove(id: string) {
await this.findOne(id);
return this.projectRepository.remove(id);
}
}

View file

@ -0,0 +1,21 @@
import { jest } from "@jest/globals";
const noopSchema = {
type: "object",
entries: {},
} as const;
const mockTable = {};
class MockNodePgDatabase {}
export const projectInsert = noopSchema;
export const projectUpdate = noopSchema;
export const projects = mockTable;
export const tasks = mockTable;
export const projectsRelations = {};
export const tasksRelations = {};
export const eq = jest.fn(() => ({}));
export const drizzle = jest.fn(() => new MockNodePgDatabase());
export const NodePgDatabase = MockNodePgDatabase;
export const migrate = jest.fn(() => Promise.resolve());

View file

@ -0,0 +1,6 @@
import { jest } from "@jest/globals";
export const safeParse = jest.fn((_schema: unknown, input: unknown) => ({
success: true as const,
output: input,
}));

View file

@ -3,20 +3,81 @@ import { INestApplication } from "@nestjs/common";
import request from "supertest";
import { App } from "supertest/types";
import { AppModule } from "./../src/app.module";
import { ProjectRepository } from "./../src/project/project.repository";
describe("AppController (e2e)", () => {
let app: INestApplication<App>;
const TEST_UUID = "550e8400-e29b-41d4-a716-446655440000";
const mockRepository = {
create: jest.fn(),
findAll: jest.fn(),
findOne: jest.fn(),
update: jest.fn(),
remove: jest.fn(),
};
beforeEach(async () => {
mockRepository.findAll.mockResolvedValue([{ id: TEST_UUID, name: "Test" }]);
mockRepository.findOne.mockResolvedValue({ id: TEST_UUID, name: "Test" });
mockRepository.create.mockResolvedValue({ id: TEST_UUID, name: "E2E Project" });
mockRepository.update.mockResolvedValue({ id: TEST_UUID, name: "Updated E2E" });
mockRepository.remove.mockResolvedValue({ id: TEST_UUID });
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
})
.overrideProvider(ProjectRepository)
.useValue(mockRepository)
.compile();
app = moduleFixture.createNestApplication();
await app.init();
});
afterEach(async () => {
await app.close();
});
it("/ (GET)", () => {
return request(app.getHttpServer()).get("/").expect(200).expect("Hello World!");
});
describe("/projects", () => {
it("GET /projects returns an array", () => {
return request(app.getHttpServer())
.get("/projects")
.expect(200)
.expect([{ id: TEST_UUID, name: "Test" }]);
});
it("POST /projects creates a project", () => {
const body = { name: "E2E Project", status: "active" };
return request(app.getHttpServer()).post("/projects").send(body).expect(201);
});
it("GET /projects/:id returns a project by id", () => {
return request(app.getHttpServer())
.get(`/projects/${TEST_UUID}`)
.expect(200)
.expect({ id: TEST_UUID, name: "Test" });
});
it("PATCH /projects/:id updates a project", () => {
const body = { name: "Updated E2E" };
mockRepository.findOne.mockResolvedValue({ id: TEST_UUID, name: "Updated E2E" });
mockRepository.update.mockResolvedValue({ id: TEST_UUID, name: "Updated E2E" });
return request(app.getHttpServer())
.patch(`/projects/${TEST_UUID}`)
.send(body)
.expect(200)
.expect({ id: TEST_UUID, name: "Updated E2E" });
});
it("DELETE /projects/:id deletes a project", () => {
mockRepository.findOne.mockResolvedValue({ id: TEST_UUID });
return request(app.getHttpServer()).delete(`/projects/${TEST_UUID}`).expect(200).expect({ id: TEST_UUID });
});
});
});

View file

@ -5,5 +5,12 @@
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
},
"moduleNameMapper": {
"^@workspace/schema$": "<rootDir>/__mocks__/schema.ts",
"^valibot$": "<rootDir>/__mocks__/valibot.ts"
},
"transformIgnorePatterns": [
"node_modules/(?!@workspace)"
]
}

View file

@ -1,4 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"incremental": false
},
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View file

@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["src/**/*", "test/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -2,7 +2,10 @@
"extends": "@workspace/tooling/tsconfig/nestjs.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"incremental": true,
"types": ["jest"]
}
}
"types": ["jest", "node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}

View file

@ -0,0 +1,106 @@
import type { Project } from "@workspace/schema";
interface ApiResponse<T> {
data: T | null;
error: string | null;
}
function getBaseUrl(): string {
const config = useRuntimeConfig();
return config.public.apiBaseUrl as string;
}
async function request<T>(path: string, options?: RequestInit): Promise<ApiResponse<T>> {
try {
const res = await fetch(`${getBaseUrl()}${path}`, {
headers: { "Content-Type": "application/json" },
...options,
});
if (!res.ok) {
const body = await res.json().catch(() => null);
return { data: null, error: body?.message ?? `HTTP ${res.status}` };
}
const data = (await res.json()) as T;
return { data, error: null };
} catch (err) {
return { data: null, error: err instanceof Error ? err.message : "Unknown error" };
}
}
export function useProjectApi() {
const NOT_FOUND = -1;
const projects = ref<Project[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
async function fetchProjects(): Promise<void> {
loading.value = true;
error.value = null;
const { data, error: err } = await request<Project[]>("/projects");
if (data) projects.value = data;
else error.value = err;
loading.value = false;
}
async function createProject(body: Record<string, unknown>): Promise<Project | null> {
loading.value = true;
error.value = null;
const { data, error: err } = await request<Project>("/projects", {
method: "POST",
body: JSON.stringify(body),
});
if (data) {
projects.value.push(data);
} else {
error.value = err;
}
loading.value = false;
return data;
}
async function updateProject(id: string, body: Record<string, unknown>): Promise<Project | null> {
loading.value = true;
error.value = null;
const { data, error: err } = await request<Project>(`/projects/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
});
if (data) {
const idx = projects.value.findIndex(p => p.id === id);
if (idx !== NOT_FOUND) projects.value[idx] = data;
} else {
error.value = err;
}
loading.value = false;
return data;
}
async function deleteProject(id: string): Promise<boolean> {
loading.value = true;
error.value = null;
const { error: err } = await request(`/projects/${id}`, { method: "DELETE" });
if (err) {
error.value = err;
} else {
projects.value = projects.value.filter(p => p.id !== id);
}
loading.value = false;
return !err;
}
return { projects, loading, error, fetchProjects, createProject, updateProject, deleteProject };
}

View file

@ -5,5 +5,6 @@ import { baseRules, } from "@workspace/tooling/eslint/base"
export default withNuxt({
rules: {
...baseRules,
"@stylistic/member-delimiter-style": "off",
},
},)

View file

@ -8,6 +8,12 @@ export default defineNuxtConfig({
css: ["~/assets/css/main.css"],
runtimeConfig: {
public: {
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001",
},
},
routeRules: {
"/": { prerender: true },
},

22
docker-compose.yml Normal file
View file

@ -0,0 +1,22 @@
services:
postgres:
image: postgres:16-alpine
container_name: work-hub-postgres
restart: unless-stopped
ports:
- "${POSTGRES_PORT:-5432}:5432"
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-work_hub}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-work_hub}"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
driver: local

View file

@ -1,19 +1,19 @@
## 1. Database & Infrastructure Setup (Mandatory First Step)
- [ ] 1.1 **Dockerization**: Create a root `docker-compose.yml` (`work-hub-server/docker-compose.yml`) that declares a PostgreSQL service and named volumes for persistence. Do NOT include API or Web app containers — they run natively during development.
- [ ] 1.2 **Persistence**: Define and map persistent volumes in the root `docker-compose.yml` to ensure project data survives container restarts.
- [ ] 1.3 **CLI Utility**: Implement a dedicated command/utility (e.g., `db:setup`) that orchestrates the execution of migrations and seeding, ensuring idempotency.
- [x] 1.1 **Dockerization**: Create a root `docker-compose.yml` (`work-hub-server/docker-compose.yml`) that declares a PostgreSQL service and named volumes for persistence. Do NOT include API or Web app containers — they run natively during development.
- [x] 1.2 **Persistence**: Define and map persistent volumes in the root `docker-compose.yml` to ensure project data survives container restarts.
- [x] 1.3 **CLI Utility**: Implement a dedicated command/utility (e.g., `db:setup`) that orchestrates the execution of migrations and seeding, ensuring idempotency.
## 2. API Layer Implementation (API)
- [ ] 2.1 **Scaffolding**: Use the Nest CLI (`nest generate resource`) to create the basic resource structure for Project (Controller, Service, Module). This ensures adherence to module structure, dependency injection patterns, and common boilerplate across all APIs in the monorepo.
- [ ] 2.2 Implement necessary request validation using **Schema-Driven Pipes** that import their structure from `@workspace/schema`, ensuring consistency between API and Web layers.
- [x] 2.1 **Scaffolding**: Use the Nest CLI (`nest generate resource`) to create the basic resource structure for Project (Controller, Service, Module). This ensures adherence to module structure, dependency injection patterns, and common boilerplate across all APIs in the monorepo.
- [x] 2.2 Implement necessary request validation using **Schema-Driven Pipes** that import their structure from `@workspace/schema`, ensuring consistency between API and Web layers.
## 3. Web Layer Implementation (Web)
- [ ] 3.1 Update `apps/web` store/composables to call the new API endpoints and manage state correctly following successful write or delete operations.
- [x] 3.1 Update `apps/web` store/composables to call the new API endpoints and manage state correctly following successful write or delete operations.
## 4. Testing & Tooling
- [ ] 4.1 Write comprehensive unit tests covering the Controller, the Service, and the Repository layers.
- [ ] 4.2 Update E2E test suites to verify complete CRUD workflows via HTTP calls and UI interactions, ensuring the service layer is correctly tested against a containerized DB.
- [x] 4.1 Write comprehensive unit tests covering the Controller, the Service, and the Repository layers.
- [x] 4.2 Update E2E test suites to verify complete CRUD workflows via HTTP calls and UI interactions, ensuring the service layer is correctly tested against a containerized DB.

View file

@ -7,7 +7,8 @@
"lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,md}\"",
"check-types": "turbo run check-types"
"check-types": "turbo run check-types",
"db:setup": "pnpm --filter=@workspace/schema db:generate && pnpm --filter=@workspace/schema db:migrate && pnpm --filter=@workspace/schema db:seed"
},
"devDependencies": {
"@workspace/tooling": "workspace:*",

View file

@ -0,0 +1,14 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/drizzle/index.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
host: process.env.POSTGRES_HOST ?? "localhost",
port: Number(process.env.POSTGRES_PORT ?? 5432),
user: process.env.POSTGRES_USER ?? "postgres",
password: process.env.POSTGRES_PASSWORD ?? "postgres",
database: process.env.POSTGRES_DB ?? "work_hub",
},
});

View file

@ -0,0 +1,26 @@
CREATE TABLE "projects" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"description" text,
"status" text DEFAULT 'active' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "project_status_check" CHECK ("projects"."status" IN ('active', 'archived', 'completed'))
);
--> statement-breakpoint
CREATE TABLE "tasks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"project_id" uuid NOT NULL,
"title" text NOT NULL,
"description" text,
"status" text DEFAULT 'todo' NOT NULL,
"priority" text DEFAULT 'medium' NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"due_date" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "task_status_check" CHECK ("tasks"."status" IN ('todo', 'in_progress', 'in_review', 'done')),
CONSTRAINT "task_priority_check" CHECK ("tasks"."priority" IN ('low', 'medium', 'high', 'urgent'))
);
--> statement-breakpoint
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;

View file

@ -0,0 +1,179 @@
{
"id": "9fa00e9c-266b-43c1-8f18-2a454e3fbf99",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.projects": {
"name": "projects",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'active'"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"project_status_check": {
"name": "project_status_check",
"value": "\"projects\".\"status\" IN ('active', 'archived', 'completed')"
}
},
"isRLSEnabled": false
},
"public.tasks": {
"name": "tasks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"project_id": {
"name": "project_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'todo'"
},
"priority": {
"name": "priority",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'medium'"
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"due_date": {
"name": "due_date",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"tasks_project_id_projects_id_fk": {
"name": "tasks_project_id_projects_id_fk",
"tableFrom": "tasks",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"task_status_check": {
"name": "task_status_check",
"value": "\"tasks\".\"status\" IN ('todo', 'in_progress', 'in_review', 'done')"
},
"task_priority_check": {
"name": "task_priority_check",
"value": "\"tasks\".\"priority\" IN ('low', 'medium', 'high', 'urgent')"
}
},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1781334448956,
"tag": "0000_yielding_photon",
"breakpoints": true
}
]
}

View file

@ -6,6 +6,7 @@ export default defineConfig(
{ ignores: ["eslint.config.mjs", "dist"] },
...base,
{
files: ["src/**/*.ts"],
languageOptions: {
parserOptions: {
projectService: true,
@ -14,6 +15,15 @@ export default defineConfig(
},
},
{
files: ["seed.ts"],
languageOptions: {
parserOptions: {
projectService: false,
},
},
},
{
files: ["src/**/*.ts"],
rules: {
"@typescript-eslint/no-floating-promises": "warn",
"@typescript-eslint/no-unsafe-argument": "warn",

View file

@ -13,20 +13,29 @@
}
},
"scripts": {
"build": "tsc",
"build": "tsup src/index.ts --format esm --dts --clean --external drizzle-orm --external drizzle-valibot --external valibot",
"dev": "tsup src/index.ts --format esm --dts --clean --external drizzle-orm --external drizzle-valibot --external valibot --watch",
"clean": "rm -rf dist",
"check-types": "tsc --noEmit",
"lint": "eslint \"src/**/*.ts\" --fix"
"lint": "eslint \"src/**/*.ts\" --fix",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:seed": "tsx seed.ts"
},
"dependencies": {
"drizzle-orm": "^0.45.2",
"drizzle-valibot": "^0.4.2",
"pg": "^8.21.0",
"valibot": "^1.4.1"
},
"devDependencies": {
"@types/node": "^25.9.3",
"@types/pg": "^8.20.0",
"@workspace/tooling": "workspace:*",
"drizzle-kit": "^0.31.10",
"eslint": "^10.4.1",
"tsup": "^8.5.1",
"tsx": "^4.22.4",
"typescript": "^6.0.3"
}
}

31
packages/schema/seed.ts Normal file
View file

@ -0,0 +1,31 @@
import { drizzle } from "drizzle-orm/node-postgres";
import pkg from "pg";
const { Pool } = pkg;
import { projects } from "./src/drizzle/projects";
async function seed() {
const pool = new Pool({
host: process.env.POSTGRES_HOST ?? "localhost",
port: Number(process.env.POSTGRES_PORT ?? 5432),
user: process.env.POSTGRES_USER ?? "postgres",
password: process.env.POSTGRES_PASSWORD ?? "postgres",
database: process.env.POSTGRES_DB ?? "work_hub",
});
const db = drizzle(pool);
console.log("Seeding database...");
await db.insert(projects).values([
{ name: "Sample Project", description: "A sample project for development", status: "active" },
{ name: "Archived Project", description: "An archived test project", status: "archived" },
]);
console.log("Seed complete.");
await pool.end();
}
seed().catch((err) => {
console.error("Seed failed:", err);
process.exit(1);
});

View file

@ -1,9 +0,0 @@
export const PROJECT_STATUSES = ["active", "archived", "completed"] as const;
export const TASK_STATUSES = ["todo", "in_progress", "in_review", "done"] as const;
export const PRIORITIES = ["low", "medium", "high", "urgent"] as const;
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
export type TaskStatus = (typeof TASK_STATUSES)[number];
export type Priority = (typeof PRIORITIES)[number];

View file

@ -1,4 +0,0 @@
export { PROJECT_STATUSES, TASK_STATUSES, PRIORITIES } from "./enums";
export { projects } from "./projects";
export { tasks } from "./tasks";
export { projectsRelations, tasksRelations } from "./relations";

View file

@ -1,8 +1,13 @@
import { pgTable, uuid, text, timestamp, check } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { PROJECT_STATUSES } from "./enums";
import { createInsertSchema, createSelectSchema, createUpdateSchema } from "drizzle-valibot";
import { InferOutput, picklist, pipe } from "valibot";
export const PROJECT_STATUSES = ["active", "archived", "completed"] as const;
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
const projectStatusValues = PROJECT_STATUSES.map((s) => `'${s}'`).join(", ");
const createProjectStatusSchema = (schema: any) => pipe(schema, picklist([...PROJECT_STATUSES])) as any;
export const projects = pgTable(
"projects",
@ -16,3 +21,18 @@ export const projects = pgTable(
},
(table) => [check("project_status_check", sql`${table.status} IN (${sql.raw(projectStatusValues)})`)],
);
export const projectSelect = createSelectSchema(projects, {
status: createProjectStatusSchema,
});
export type Project = InferOutput<typeof projectSelect>;
export const projectInsert = createInsertSchema(projects, {
status: createProjectStatusSchema,
});
export type ProjectInsertDTO = InferOutput<typeof projectInsert>;
export const projectUpdate = createUpdateSchema(projects, {
status: createProjectStatusSchema,
});
export type ProjectUpdateDTO = InferOutput<typeof projectUpdate>;

View file

@ -0,0 +1,4 @@
export const refs: Record<string, any> = {
projects: undefined,
tasks: undefined,
};

View file

@ -1,7 +1,11 @@
import { relations } from "drizzle-orm";
import { refs } from "./refs";
import { projects } from "./projects";
import { tasks } from "./tasks";
refs.projects = projects;
refs.tasks = tasks;
export const projectsRelations = relations(projects, ({ many }) => ({
tasks: many(tasks),
}));

View file

@ -1,7 +1,14 @@
import { pgTable, uuid, text, integer, timestamp, check } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { TASK_STATUSES, PRIORITIES } from "./enums";
import { projects } from "./projects";
import { createInsertSchema, createSelectSchema, createUpdateSchema } from "drizzle-valibot";
import { InferOutput, picklist, pipe } from "valibot";
import { refs } from "./refs";
export const TASK_STATUSES = ["todo", "in_progress", "in_review", "done"] as const;
export type TaskStatus = (typeof TASK_STATUSES)[number];
export const PRIORITIES = ["low", "medium", "high", "urgent"] as const;
export type Priority = (typeof PRIORITIES)[number];
const taskStatusValues = TASK_STATUSES.map((s) => `'${s}'`).join(", ");
const priorityValues = PRIORITIES.map((p) => `'${p}'`).join(", ");
@ -12,7 +19,8 @@ export const tasks = pgTable(
id: uuid("id").primaryKey().defaultRandom(),
projectId: uuid("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
.references(() => refs.projects.id, { onDelete: "cascade" }),
title: text("title").notNull(),
description: text("description"),
status: text("status").notNull().default("todo"),
@ -27,3 +35,22 @@ export const tasks = pgTable(
check("task_priority_check", sql`${table.priority} IN (${sql.raw(priorityValues)})`),
],
);
const createTaskStatusSchema = (schema: any) => pipe(schema, picklist([...TASK_STATUSES])) as any;
const createPrioritySchema = (schema: any) => pipe(schema, picklist([...PRIORITIES])) as any;
export const taskSelect = createSelectSchema(tasks, {
status: createTaskStatusSchema,
priority: createPrioritySchema,
});
export type Task = InferOutput<typeof taskSelect>;
export const taskInsert = createInsertSchema(tasks, {
status: createTaskStatusSchema,
priority: createPrioritySchema,
});
export const taskUpdate = createUpdateSchema(tasks, {
status: createTaskStatusSchema,
priority: createPrioritySchema,
});

View file

@ -1,17 +1,37 @@
import { fileURLToPath } from "url";
import { dirname, resolve } from "path";
const __dirname = dirname(fileURLToPath(import.meta.url));
export const migrationsDir = resolve(__dirname, "../drizzle");
export { eq } from "drizzle-orm";
export { drizzle, NodePgDatabase } from "drizzle-orm/node-postgres";
export { migrate } from "drizzle-orm/node-postgres/migrator";
export {
PROJECT_STATUSES,
TASK_STATUSES,
PRIORITIES,
projects,
tasks,
projectSelect,
projectInsert,
projectUpdate,
type Project,
type ProjectStatus,
type ProjectInsertDTO,
type ProjectUpdateDTO,
} from "./drizzle/projects";
export {
projectsRelations,
tasksRelations,
} from "./drizzle/index";
export { projectSelect, projectInsert, projectUpdate, taskSelect, taskInsert, taskUpdate } from "./valibot";
import type { InferOutput } from "valibot";
import { projectSelect, taskSelect } from "./valibot";
export type Project = InferOutput<typeof projectSelect>;
export type Task = InferOutput<typeof taskSelect>;
} from "./drizzle/relations";
export {
TASK_STATUSES,
PRIORITIES,
tasks,
taskSelect,
taskInsert,
taskUpdate,
type Task,
type TaskStatus,
type Priority,
} from "./drizzle/tasks";

View file

@ -1,34 +0,0 @@
import { createInsertSchema, createSelectSchema, createUpdateSchema } from "drizzle-valibot";
import { pipe, picklist } from "valibot";
import { projects, tasks, PROJECT_STATUSES, TASK_STATUSES, PRIORITIES } from "./drizzle/index";
const createProjectStatusSchema = (schema: any) => pipe(schema, picklist([...PROJECT_STATUSES])) as any;
const createTaskStatusSchema = (schema: any) => pipe(schema, picklist([...TASK_STATUSES])) as any;
const createPrioritySchema = (schema: any) => pipe(schema, picklist([...PRIORITIES])) as any;
export const projectSelect = createSelectSchema(projects, {
status: createProjectStatusSchema,
});
export const taskSelect = createSelectSchema(tasks, {
status: createTaskStatusSchema,
priority: createPrioritySchema,
});
export const projectInsert = createInsertSchema(projects, {
status: createProjectStatusSchema,
});
export const projectUpdate = createUpdateSchema(projects, {
status: createProjectStatusSchema,
});
export const taskInsert = createInsertSchema(tasks, {
status: createTaskStatusSchema,
priority: createPrioritySchema,
});
export const taskUpdate = createUpdateSchema(tasks, {
status: createTaskStatusSchema,
priority: createPrioritySchema,
});

View file

@ -2,7 +2,8 @@
"extends": "@workspace/tooling/tsconfig/library.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
"rootDir": "./src",
"ignoreDeprecations": "6.0"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]

View file

@ -33,7 +33,7 @@ export function createNestjsConfig(tsconfigRootDir, overrides = {}) {
rules: {
"class-methods-use-this": "off",
"@typescript-eslint/no-floating-promises": "warn",
"@typescript-eslint/no-unsafe-argument": "warn",
// "@typescript-eslint/no-unsafe-argument": "warn",
"prettier/prettier": ["error", { endOfLine: "auto" }],
...overrides,
},

View file

@ -14,7 +14,6 @@
"dependencies": {
"@eslint/js": "^10.0.1",
"@stylistic/eslint-plugin": "^5.10.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
"globals": "^17.6.0",
"typescript-eslint": "^8.61.0"

File diff suppressed because it is too large Load diff

View file

@ -15,7 +15,8 @@
},
"dev": {
"cache": false,
"persistent": true
"persistent": true,
"dependsOn": ["^build"]
}
}
}