feat(api): first version of the implementation, done with opencode and multiple iterations
This commit is contained in:
parent
aec3f2ff98
commit
7d72932a16
12
.env.example
Normal file
12
.env.example
Normal 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
|
||||
|
|
@ -10,5 +10,6 @@
|
|||
"mode": "semantic",
|
||||
"ignore": ["**/lib/**", "**/legacy/**", "**/__generated__/**", "**/generated/**"]
|
||||
},
|
||||
"rules": {}
|
||||
"rules": {},
|
||||
"ignoreDependencies": ["@nestjs/schematics", "eslint-plugin-prettier"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
})
|
||||
|
|
|
|||
28
apps/api/src/common/pipes/valibot-validation.pipe.ts
Normal file
28
apps/api/src/common/pipes/valibot-validation.pipe.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
9
apps/api/src/database/database.module.ts
Normal file
9
apps/api/src/database/database.module.ts
Normal 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 {}
|
||||
43
apps/api/src/database/drizzle.service.ts
Normal file
43
apps/api/src/database/drizzle.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
74
apps/api/src/project/project.controller.spec.ts
Normal file
74
apps/api/src/project/project.controller.spec.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
37
apps/api/src/project/project.controller.ts
Normal file
37
apps/api/src/project/project.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/project/project.module.ts
Normal file
10
apps/api/src/project/project.module.ts
Normal 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 {}
|
||||
36
apps/api/src/project/project.repository.ts
Normal file
36
apps/api/src/project/project.repository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
112
apps/api/src/project/project.service.spec.ts
Normal file
112
apps/api/src/project/project.service.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
32
apps/api/src/project/project.service.ts
Normal file
32
apps/api/src/project/project.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
21
apps/api/test/__mocks__/schema.ts
Normal file
21
apps/api/test/__mocks__/schema.ts
Normal 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());
|
||||
6
apps/api/test/__mocks__/valibot.ts
Normal file
6
apps/api/test/__mocks__/valibot.ts
Normal 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,
|
||||
}));
|
||||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"incremental": false
|
||||
},
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
|
|
|
|||
5
apps/api/tsconfig.eslint.json
Normal file
5
apps/api/tsconfig.eslint.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src/**/*", "test/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
106
apps/web/app/composables/useProjectApi.ts
Normal file
106
apps/web/app/composables/useProjectApi.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -5,5 +5,6 @@ import { baseRules, } from "@workspace/tooling/eslint/base"
|
|||
export default withNuxt({
|
||||
rules: {
|
||||
...baseRules,
|
||||
"@stylistic/member-delimiter-style": "off",
|
||||
},
|
||||
},)
|
||||
|
|
|
|||
|
|
@ -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
22
docker-compose.yml
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
14
packages/schema/drizzle.config.ts
Normal file
14
packages/schema/drizzle.config.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
26
packages/schema/drizzle/0000_yielding_photon.sql
Normal file
26
packages/schema/drizzle/0000_yielding_photon.sql
Normal 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;
|
||||
179
packages/schema/drizzle/meta/0000_snapshot.json
Normal file
179
packages/schema/drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
13
packages/schema/drizzle/meta/_journal.json
Normal file
13
packages/schema/drizzle/meta/_journal.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1781334448956,
|
||||
"tag": "0000_yielding_photon",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
31
packages/schema/seed.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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];
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
4
packages/schema/src/drizzle/refs.ts
Normal file
4
packages/schema/src/drizzle/refs.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export const refs: Record<string, any> = {
|
||||
projects: undefined,
|
||||
tasks: undefined,
|
||||
};
|
||||
|
|
@ -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),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
843
pnpm-lock.yaml
843
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -15,7 +15,8 @@
|
|||
},
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
"persistent": true,
|
||||
"dependsOn": ["^build"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue