diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e9e87ef --- /dev/null +++ b/.env.example @@ -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 diff --git a/.fallowrc.json b/.fallowrc.json index 116f517..2ea47a1 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -10,5 +10,6 @@ "mode": "semantic", "ignore": ["**/lib/**", "**/legacy/**", "**/__generated__/**", "**/generated/**"] }, - "rules": {} + "rules": {}, + "ignoreDependencies": ["@nestjs/schematics", "eslint-plugin-prettier"] } diff --git a/AGENTS.md b/AGENTS.md index 9831100..7deac64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/apps/api/eslint.config.mjs b/apps/api/eslint.config.mjs index bb2c064..eabfb7b 100644 --- a/apps/api/eslint.config.mjs +++ b/apps/api/eslint.config.mjs @@ -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" }], + }, + }, +); diff --git a/apps/api/package.json b/apps/api/package.json index 01065a5..545f0e0 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -23,30 +23,35 @@ }, "dependencies": { "@nestjs/common": "^11.1.26", - "@nestjs/core": "^11.1.26", - "@nestjs/platform-express": "^11.1.26", + "@nestjs/core": "^11.1.27", + "@nestjs/platform-express": "^11.1.27", "@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", + "@nestjs/testing": "^11.1.27", "@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-config-prettier": "^10.1.8", + "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 +69,11 @@ "**/*.(t|j)s" ], "coverageDirectory": "../coverage", - "testEnvironment": "node" + "testEnvironment": "node", + "moduleNameMapper": { + "^@workspace/schema$": "/../test/__mocks__/schema.ts", + "^@workspace/schema/migrations$": "/../test/__mocks__/schema-migrations.ts", + "^valibot$": "/../test/__mocks__/valibot.ts" + } } } diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index e93d824..c1b0d87 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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], }) diff --git a/apps/api/src/common/pipes/valibot-validation.pipe.ts b/apps/api/src/common/pipes/valibot-validation.pipe.ts new file mode 100644 index 0000000..01ebced --- /dev/null +++ b/apps/api/src/common/pipes/valibot-validation.pipe.ts @@ -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>) {} + + transform(value: unknown): InferOutput>> { + 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; + } +} diff --git a/apps/api/src/database/database.module.ts b/apps/api/src/database/database.module.ts new file mode 100644 index 0000000..185bc03 --- /dev/null +++ b/apps/api/src/database/database.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from "@nestjs/common"; +import { DrizzleService } from "./drizzle.service"; + +@Global() +@Module({ + providers: [DrizzleService], + exports: [DrizzleService], +}) +export class DatabaseModule {} diff --git a/apps/api/src/database/drizzle.service.ts b/apps/api/src/database/drizzle.service.ts new file mode 100644 index 0000000..7dd3501 --- /dev/null +++ b/apps/api/src/database/drizzle.service.ts @@ -0,0 +1,43 @@ +import { Injectable, OnModuleInit, OnModuleDestroy } from "@nestjs/common"; +import { Pool } from "pg"; +import { migrationsDir } from "@workspace/schema/migrations"; +import { + drizzle, + NodePgDatabase, + migrate, + projects, + tasks, + projectsRelations, + tasksRelations, +} from "@workspace/schema"; + +const DEFAULT_PG_PORT = 5432; + +@Injectable() +export class DrizzleService implements OnModuleInit, OnModuleDestroy { + private pool: Pool; + public db: NodePgDatabase>; + + 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(); + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 1962fc9..fc87c15 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -5,6 +5,7 @@ const DEFAULT_PORT = 3001; async function bootstrap() { const app = await NestFactory.create(AppModule); + app.enableCors(); await app.listen(process.env.PORT ?? DEFAULT_PORT); } // eslint-disable-next-line @typescript-eslint/no-floating-promises diff --git a/apps/api/src/project/project.controller.spec.ts b/apps/api/src/project/project.controller.spec.ts new file mode 100644 index 0000000..362c7a5 --- /dev/null +++ b/apps/api/src/project/project.controller.spec.ts @@ -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); + }); + + 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" }); + }); + }); +}); diff --git a/apps/api/src/project/project.controller.ts b/apps/api/src/project/project.controller.ts new file mode 100644 index 0000000..be96c3e --- /dev/null +++ b/apps/api/src/project/project.controller.ts @@ -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); + } +} diff --git a/apps/api/src/project/project.module.ts b/apps/api/src/project/project.module.ts new file mode 100644 index 0000000..23b2795 --- /dev/null +++ b/apps/api/src/project/project.module.ts @@ -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 {} diff --git a/apps/api/src/project/project.repository.ts b/apps/api/src/project/project.repository.ts new file mode 100644 index 0000000..5804c73 --- /dev/null +++ b/apps/api/src/project/project.repository.ts @@ -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; + } +} diff --git a/apps/api/src/project/project.service.spec.ts b/apps/api/src/project/project.service.spec.ts new file mode 100644 index 0000000..a83139d --- /dev/null +++ b/apps/api/src/project/project.service.spec.ts @@ -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, []>(); + const mockFindOne = jest.fn, [string]>(); + const mockCreate = jest.fn, [Record]>(); + const mockUpdate = jest.fn, [string, Record]>(); + const mockRemove = jest.fn, [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); + }); + + 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"); + }); + }); +}); diff --git a/apps/api/src/project/project.service.ts b/apps/api/src/project/project.service.ts new file mode 100644 index 0000000..c0f43d4 --- /dev/null +++ b/apps/api/src/project/project.service.ts @@ -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); + } +} diff --git a/apps/api/test/__mocks__/schema-migrations.ts b/apps/api/test/__mocks__/schema-migrations.ts new file mode 100644 index 0000000..0a77362 --- /dev/null +++ b/apps/api/test/__mocks__/schema-migrations.ts @@ -0,0 +1 @@ +export const migrationsDir = "/mock/migrations"; diff --git a/apps/api/test/__mocks__/schema.ts b/apps/api/test/__mocks__/schema.ts new file mode 100644 index 0000000..bef01d0 --- /dev/null +++ b/apps/api/test/__mocks__/schema.ts @@ -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()); diff --git a/apps/api/test/__mocks__/valibot.ts b/apps/api/test/__mocks__/valibot.ts new file mode 100644 index 0000000..7e23e27 --- /dev/null +++ b/apps/api/test/__mocks__/valibot.ts @@ -0,0 +1,6 @@ +import { jest } from "@jest/globals"; + +export const safeParse = jest.fn((_schema: unknown, input: unknown) => ({ + success: true as const, + output: input, +})); diff --git a/apps/api/test/app.e2e-spec.ts b/apps/api/test/app.e2e-spec.ts index fa8410c..05380c0 100644 --- a/apps/api/test/app.e2e-spec.ts +++ b/apps/api/test/app.e2e-spec.ts @@ -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; + 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 }); + }); + }); }); diff --git a/apps/api/test/jest-e2e.json b/apps/api/test/jest-e2e.json index e9d912f..6b6e73b 100644 --- a/apps/api/test/jest-e2e.json +++ b/apps/api/test/jest-e2e.json @@ -5,5 +5,13 @@ "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" - } + }, + "moduleNameMapper": { + "^@workspace/schema$": "/__mocks__/schema.ts", + "^@workspace/schema/migrations$": "/__mocks__/schema-migrations.ts", + "^valibot$": "/__mocks__/valibot.ts" + }, + "transformIgnorePatterns": [ + "node_modules/(?!@workspace)" + ] } diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json index 64f86c6..935f99b 100644 --- a/apps/api/tsconfig.build.json +++ b/apps/api/tsconfig.build.json @@ -1,4 +1,7 @@ { "extends": "./tsconfig.json", + "compilerOptions": { + "incremental": false + }, "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] } diff --git a/apps/api/tsconfig.eslint.json b/apps/api/tsconfig.eslint.json new file mode 100644 index 0000000..5a71663 --- /dev/null +++ b/apps/api/tsconfig.eslint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index cf4a8a9..db68174 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -2,7 +2,10 @@ "extends": "@workspace/tooling/tsconfig/nestjs.json", "compilerOptions": { "outDir": "./dist", - "incremental": true, - "types": ["jest"] - } -} + "rootDir": "./src", + "incremental": false, + "types": ["jest", "node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} \ No newline at end of file diff --git a/apps/web/app/app.config.ts b/apps/web/app/app.config.ts index 7420448..65f1645 100644 --- a/apps/web/app/app.config.ts +++ b/apps/web/app/app.config.ts @@ -4,5 +4,8 @@ export default defineAppConfig({ primary: "green", neutral: "slate", }, + main: { + base: "min-h-0 flex-1 overflow-y-auto", + }, }, }); diff --git a/apps/web/app/app.vue b/apps/web/app/app.vue index 534b6be..ab6f820 100644 --- a/apps/web/app/app.vue +++ b/apps/web/app/app.vue @@ -1,77 +1,34 @@ diff --git a/apps/web/app/components/AppFooter.vue b/apps/web/app/components/AppFooter.vue new file mode 100644 index 0000000..3a4693e --- /dev/null +++ b/apps/web/app/components/AppFooter.vue @@ -0,0 +1,19 @@ + diff --git a/apps/web/app/components/AppHeader.vue b/apps/web/app/components/AppHeader.vue new file mode 100644 index 0000000..4feec65 --- /dev/null +++ b/apps/web/app/components/AppHeader.vue @@ -0,0 +1,33 @@ + + + diff --git a/apps/web/app/components/ProjectForm.vue b/apps/web/app/components/ProjectForm.vue new file mode 100644 index 0000000..586460d --- /dev/null +++ b/apps/web/app/components/ProjectForm.vue @@ -0,0 +1,56 @@ + + + diff --git a/apps/web/app/components/TemplateMenu.vue b/apps/web/app/components/TemplateMenu.vue index 2443992..c8018d4 100644 --- a/apps/web/app/components/TemplateMenu.vue +++ b/apps/web/app/components/TemplateMenu.vue @@ -2,48 +2,55 @@ + size="xs"> + }" /> diff --git a/apps/web/app/composables/useProjectApi.ts b/apps/web/app/composables/useProjectApi.ts new file mode 100644 index 0000000..fd35ace --- /dev/null +++ b/apps/web/app/composables/useProjectApi.ts @@ -0,0 +1,77 @@ +import type { Project, ProjectInsertDTO, ProjectUpdateDTO } from "@workspace/schema"; + +export function useProjectApi() { + const config = useRuntimeConfig(); + const apiBase = config.public.apiBaseUrl as string; + const NOT_FOUND = -1; + + const projects = ref([]); + const loading = ref(false); + const error = ref(null); + + async function fetchProjects(): Promise { + loading.value = true; + error.value = null; + try { + projects.value = await $fetch(`${apiBase}/projects`); + } catch (err) { + error.value = err instanceof Error ? err.message : "Unknown error"; + } finally { + loading.value = false; + } + } + + async function createProject(body: Partial): Promise { + loading.value = true; + error.value = null; + try { + const created = await $fetch(`${apiBase}/projects`, { + method: "POST", + body, + }); + projects.value.push(created); + return created; + } catch (err) { + error.value = err instanceof Error ? err.message : "Unknown error"; + return null; + } finally { + loading.value = false; + } + } + + async function updateProject(id: string, body: Partial): Promise { + loading.value = true; + error.value = null; + try { + const updated = await $fetch(`${apiBase}/projects/${id}`, { + method: "PATCH", + body, + }); + const idx = projects.value.findIndex((p) => p.id === id); + if (idx !== NOT_FOUND) projects.value[idx] = updated; + return updated; + } catch (err) { + error.value = err instanceof Error ? err.message : "Unknown error"; + return null; + } finally { + loading.value = false; + } + } + + async function deleteProject(id: string): Promise { + loading.value = true; + error.value = null; + try { + await $fetch(`${apiBase}/projects/${id}`, { method: "DELETE" }); + projects.value = projects.value.filter((p) => p.id !== id); + return true; + } catch (err) { + error.value = err instanceof Error ? err.message : "Unknown error"; + return false; + } finally { + loading.value = false; + } + } + + return { projects, loading, error, fetchProjects, createProject, updateProject, deleteProject }; +} diff --git a/apps/web/app/pages/index.vue b/apps/web/app/pages/index.vue index b0b8e2b..cfd6870 100644 --- a/apps/web/app/pages/index.vue +++ b/apps/web/app/pages/index.vue @@ -1,16 +1,20 @@ + +