Compare commits
14 commits
main
...
feat/creat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98c282834c | ||
|
|
0febf5fdb0 | ||
|
|
a4f909289c | ||
|
|
79e0efc71e | ||
|
|
58dc4e194e | ||
|
|
edcee7cd80 | ||
|
|
24c6cffaa8 | ||
|
|
a10773f76a | ||
|
|
abdec720e1 | ||
|
|
d826ee6a50 | ||
|
|
568b04ca27 | ||
|
|
32aed2af26 | ||
|
|
7d72932a16 | ||
|
|
aec3f2ff98 |
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",
|
"mode": "semantic",
|
||||||
"ignore": ["**/lib/**", "**/legacy/**", "**/__generated__/**", "**/generated/**"]
|
"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:
|
- Key relaxed rules:
|
||||||
- `@typescript-eslint/no-explicit-any: off`
|
- `@typescript-eslint/no-explicit-any: off`
|
||||||
- `@typescript-eslint/no-floating-promises: warn`
|
- `@typescript-eslint/no-floating-promises: warn`
|
||||||
- `@typescript-eslint/no-unsafe-argument: warn`
|
|
||||||
- Web uses `@nuxt/eslint` with stylistic config (`commaDangle: never`, `braceStyle: 1tbs`).
|
- Web uses `@nuxt/eslint` with stylistic config (`commaDangle: never`, `braceStyle: 1tbs`).
|
||||||
|
|
||||||
### TypeScript
|
### 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" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -23,30 +23,35 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.1.26",
|
"@nestjs/common": "^11.1.26",
|
||||||
"@nestjs/core": "^11.1.26",
|
"@nestjs/core": "^11.1.27",
|
||||||
"@nestjs/platform-express": "^11.1.26",
|
"@nestjs/platform-express": "^11.1.27",
|
||||||
"@workspace/schema": "workspace:*",
|
"@workspace/schema": "workspace:*",
|
||||||
|
"drizzle-orm": "^0.45.2",
|
||||||
|
"pg": "^8.21.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2"
|
"rxjs": "^7.8.2",
|
||||||
|
"valibot": "^1.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@jest/globals": "^30.4.1",
|
||||||
"@nestjs/cli": "^11.0.23",
|
"@nestjs/cli": "^11.0.23",
|
||||||
"@nestjs/schematics": "^11.1.0",
|
"@nestjs/schematics": "^11.1.0",
|
||||||
"@nestjs/testing": "^11.1.26",
|
"@nestjs/testing": "^11.1.27",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^25.9.3",
|
"@types/node": "^25.9.3",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/supertest": "^7.2.0",
|
"@types/supertest": "^7.2.0",
|
||||||
"@workspace/tooling": "workspace:*",
|
"@workspace/tooling": "workspace:*",
|
||||||
"eslint": "^10.4.1",
|
"eslint": "^10.4.1",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-prettier": "^5.5.6",
|
||||||
"jest": "^30.4.2",
|
"jest": "^30.4.2",
|
||||||
"prettier": "^3.8.4",
|
"prettier": "^3.8.4",
|
||||||
"source-map-support": "^0.5.21",
|
|
||||||
"supertest": "^7.2.2",
|
"supertest": "^7.2.2",
|
||||||
"ts-jest": "^29.4.11",
|
"ts-jest": "^29.4.11",
|
||||||
"ts-loader": "^9.6.0",
|
"ts-loader": "^9.6.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsconfig-paths": "^4.2.0",
|
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
|
|
@ -64,6 +69,11 @@
|
||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
],
|
],
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node"
|
"testEnvironment": "node",
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^@workspace/schema$": "<rootDir>/../test/__mocks__/schema.ts",
|
||||||
|
"^@workspace/schema/migrations$": "<rootDir>/../test/__mocks__/schema-migrations.ts",
|
||||||
|
"^valibot$": "<rootDir>/../test/__mocks__/valibot.ts"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { AppController } from "./app.controller";
|
import { AppController } from "./app.controller";
|
||||||
import { AppService } from "./app.service";
|
import { AppService } from "./app.service";
|
||||||
|
import { ProjectModule } from "./project/project.module";
|
||||||
|
import { DatabaseModule } from "./database/database.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
imports: [ProjectModule, DatabaseModule],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
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 { 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<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ const DEFAULT_PORT = 3001;
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
app.enableCors();
|
||||||
await app.listen(process.env.PORT ?? DEFAULT_PORT);
|
await app.listen(process.env.PORT ?? DEFAULT_PORT);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
|
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/api/test/__mocks__/schema-migrations.ts
Normal file
1
apps/api/test/__mocks__/schema-migrations.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export const migrationsDir = "/mock/migrations";
|
||||||
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 request from "supertest";
|
||||||
import { App } from "supertest/types";
|
import { App } from "supertest/types";
|
||||||
import { AppModule } from "./../src/app.module";
|
import { AppModule } from "./../src/app.module";
|
||||||
|
import { ProjectRepository } from "./../src/project/project.repository";
|
||||||
|
|
||||||
describe("AppController (e2e)", () => {
|
describe("AppController (e2e)", () => {
|
||||||
let app: INestApplication<App>;
|
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 () => {
|
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({
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
imports: [AppModule],
|
imports: [AppModule],
|
||||||
}).compile();
|
})
|
||||||
|
.overrideProvider(ProjectRepository)
|
||||||
|
.useValue(mockRepository)
|
||||||
|
.compile();
|
||||||
|
|
||||||
app = moduleFixture.createNestApplication();
|
app = moduleFixture.createNestApplication();
|
||||||
await app.init();
|
await app.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("/ (GET)", () => {
|
it("/ (GET)", () => {
|
||||||
return request(app.getHttpServer()).get("/").expect(200).expect("Hello World!");
|
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,13 @@
|
||||||
"testRegex": ".e2e-spec.ts$",
|
"testRegex": ".e2e-spec.ts$",
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
}
|
},
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^@workspace/schema$": "<rootDir>/__mocks__/schema.ts",
|
||||||
|
"^@workspace/schema/migrations$": "<rootDir>/__mocks__/schema-migrations.ts",
|
||||||
|
"^valibot$": "<rootDir>/__mocks__/valibot.ts"
|
||||||
|
},
|
||||||
|
"transformIgnorePatterns": [
|
||||||
|
"node_modules/(?!@workspace)"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"incremental": false
|
||||||
|
},
|
||||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
"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",
|
"extends": "@workspace/tooling/tsconfig/nestjs.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"incremental": true,
|
"rootDir": "./src",
|
||||||
"types": ["jest"]
|
"incremental": false,
|
||||||
}
|
"types": ["jest", "node"]
|
||||||
}
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "test"]
|
||||||
|
}
|
||||||
|
|
@ -4,5 +4,8 @@ export default defineAppConfig({
|
||||||
primary: "green",
|
primary: "green",
|
||||||
neutral: "slate",
|
neutral: "slate",
|
||||||
},
|
},
|
||||||
|
main: {
|
||||||
|
base: "min-h-0 flex-1 overflow-y-auto",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,77 +1,34 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
useHead({
|
const { t } = useI18n();
|
||||||
meta: [
|
const i18nHead = useLocaleHead();
|
||||||
{ name: "viewport", content: "width=device-width, initial-scale=1", },
|
|
||||||
],
|
|
||||||
link: [
|
|
||||||
{ rel: "icon", href: "/favicon.ico", },
|
|
||||||
],
|
|
||||||
htmlAttrs: {
|
|
||||||
lang: "en",
|
|
||||||
},
|
|
||||||
},)
|
|
||||||
|
|
||||||
const title = "Nuxt Starter Template"
|
useHead({
|
||||||
const description = "A production-ready starter template powered by Nuxt UI. Build beautiful, accessible, and performant applications in minutes, not hours."
|
htmlAttrs: {
|
||||||
|
lang: () => i18nHead.value.htmlAttrs.lang,
|
||||||
|
},
|
||||||
|
meta: [{ name: "viewport", content: "width=device-width, initial-scale=1" }],
|
||||||
|
link: [{ rel: "icon", href: "/favicon.ico" }],
|
||||||
|
});
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
title,
|
title: () => t("app.title"),
|
||||||
description,
|
description: () => t("app.description"),
|
||||||
ogTitle: title,
|
ogTitle: () => t("app.title"),
|
||||||
ogDescription: description,
|
ogDescription: () => t("app.description"),
|
||||||
ogImage: "https://ui.nuxt.com/assets/templates/nuxt/starter-light.png",
|
|
||||||
twitterCard: "summary_large_image",
|
twitterCard: "summary_large_image",
|
||||||
},)
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UApp>
|
<UApp>
|
||||||
<UHeader>
|
<div class="flex flex-col min-h-screen">
|
||||||
<template #left>
|
<LazyAppHeader hydrate-on-interaction />
|
||||||
<NuxtLink to="/">
|
|
||||||
<AppLogo class="w-auto h-6 shrink-0" />
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
<TemplateMenu />
|
<UMain class="px-6">
|
||||||
</template>
|
<NuxtPage />
|
||||||
|
</UMain>
|
||||||
|
|
||||||
<template #right>
|
<LazyAppFooter hydrate-on-interaction />
|
||||||
<UColorModeButton />
|
</div>
|
||||||
|
|
||||||
<UButton
|
|
||||||
to="https://github.com/nuxt-ui-templates/starter"
|
|
||||||
target="_blank"
|
|
||||||
icon="i-simple-icons-github"
|
|
||||||
aria-label="GitHub"
|
|
||||||
color="neutral"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</UHeader>
|
|
||||||
|
|
||||||
<UMain>
|
|
||||||
<NuxtPage />
|
|
||||||
</UMain>
|
|
||||||
|
|
||||||
<USeparator icon="i-simple-icons-nuxtdotjs" />
|
|
||||||
|
|
||||||
<UFooter>
|
|
||||||
<template #left>
|
|
||||||
<p class="text-sm text-muted">
|
|
||||||
Built with Nuxt UI • © {{ new Date().getFullYear() }}
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #right>
|
|
||||||
<UButton
|
|
||||||
to="https://github.com/nuxt-ui-templates/starter"
|
|
||||||
target="_blank"
|
|
||||||
icon="i-simple-icons-github"
|
|
||||||
aria-label="GitHub"
|
|
||||||
color="neutral"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</UFooter>
|
|
||||||
</UApp>
|
</UApp>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
19
apps/web/app/components/AppFooter.vue
Normal file
19
apps/web/app/components/AppFooter.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<template>
|
||||||
|
<USeparator icon="i-simple-icons-nuxtdotjs" />
|
||||||
|
|
||||||
|
<UFooter data-allow-mismatch="children">
|
||||||
|
<template #left>
|
||||||
|
<p class="text-sm text-muted">{{ $t('app.title') }} • © {{ new Date().getFullYear() }}</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #right>
|
||||||
|
<UButton
|
||||||
|
to="https://github.com/nuxt-ui-templates/starter"
|
||||||
|
target="_blank"
|
||||||
|
icon="i-simple-icons-github"
|
||||||
|
aria-label="GitHub"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost" />
|
||||||
|
</template>
|
||||||
|
</UFooter>
|
||||||
|
</template>
|
||||||
33
apps/web/app/components/AppHeader.vue
Normal file
33
apps/web/app/components/AppHeader.vue
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { en, fr } from "@nuxt/ui/locale";
|
||||||
|
|
||||||
|
const { locale, setLocale, t } = useI18n();
|
||||||
|
|
||||||
|
function handleLocaleChange(value: string) {
|
||||||
|
setLocale(value as "en" | "fr");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UHeader>
|
||||||
|
<template #left>
|
||||||
|
<NuxtLink to="/" :aria-label="t('nav.home')">
|
||||||
|
<AppLogo class="w-auto h-6 shrink-0" />
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<UButton to="/projects" :label="t('nav.projects')" color="neutral" variant="ghost" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #right>
|
||||||
|
<ULocaleSelect
|
||||||
|
:model-value="locale"
|
||||||
|
:locales="[en, fr]"
|
||||||
|
size="sm"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
@update:model-value="handleLocaleChange"
|
||||||
|
/>
|
||||||
|
<UColorModeButton />
|
||||||
|
</template>
|
||||||
|
</UHeader>
|
||||||
|
</template>
|
||||||
56
apps/web/app/components/ProjectForm.vue
Normal file
56
apps/web/app/components/ProjectForm.vue
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { PROJECT_STATUSES, type ProjectStatus } from "@workspace/schema/constants";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
status?: ProjectStatus;
|
||||||
|
saving?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [body: { name: string; description?: string; status: string }];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const name = ref(props.name);
|
||||||
|
const description = ref(props.description);
|
||||||
|
const status = ref(props.status);
|
||||||
|
|
||||||
|
function onSubmit() {
|
||||||
|
emit("submit", {
|
||||||
|
name: name.value,
|
||||||
|
description: description.value || undefined,
|
||||||
|
status: status.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form class="space-y-6" @submit.prevent="onSubmit">
|
||||||
|
<UFormField :label="t('form.name')" required>
|
||||||
|
<UInput v-model="name" :placeholder="t('form.namePlaceholder')" class="w-full" required />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField :label="t('form.description')">
|
||||||
|
<UTextarea v-model="description" :placeholder="t('form.descriptionPlaceholder')" class="w-full" :rows="3" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField :label="t('form.status')">
|
||||||
|
<USelect v-model="status" :items="PROJECT_STATUSES.map((s) => ({ label: s, value: s }))" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<div class="flex gap-3 justify-end">
|
||||||
|
<UButton :label="t('form.cancel')" icon="i-lucide-x" color="neutral" variant="outline" to="/projects" />
|
||||||
|
<UButton type="submit" :label="t('form.save')" icon="i-lucide-save" color="primary" :loading="saving" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
@ -2,48 +2,55 @@
|
||||||
<UDropdownMenu
|
<UDropdownMenu
|
||||||
v-slot="{ open }"
|
v-slot="{ open }"
|
||||||
:modal="false"
|
:modal="false"
|
||||||
:items="[{
|
:items="[
|
||||||
label: 'Starter',
|
{
|
||||||
to: 'https://starter-template.nuxt.dev/',
|
label: 'Starter',
|
||||||
color: 'primary',
|
to: 'https://starter-template.nuxt.dev/',
|
||||||
checked: true,
|
color: 'primary',
|
||||||
type: 'checkbox',
|
checked: true,
|
||||||
}, {
|
type: 'checkbox',
|
||||||
label: 'Landing',
|
},
|
||||||
to: 'https://landing-template.nuxt.dev/',
|
{
|
||||||
}, {
|
label: 'Landing',
|
||||||
label: 'Docs',
|
to: 'https://landing-template.nuxt.dev/',
|
||||||
to: 'https://docs-template.nuxt.dev/',
|
},
|
||||||
}, {
|
{
|
||||||
label: 'SaaS',
|
label: 'Docs',
|
||||||
to: 'https://saas-template.nuxt.dev/',
|
to: 'https://docs-template.nuxt.dev/',
|
||||||
}, {
|
},
|
||||||
label: 'Dashboard',
|
{
|
||||||
to: 'https://dashboard-template.nuxt.dev/',
|
label: 'SaaS',
|
||||||
}, {
|
to: 'https://saas-template.nuxt.dev/',
|
||||||
label: 'Chat',
|
},
|
||||||
to: 'https://chat-template.nuxt.dev/',
|
{
|
||||||
}, {
|
label: 'Dashboard',
|
||||||
label: 'Portfolio',
|
to: 'https://dashboard-template.nuxt.dev/',
|
||||||
to: 'https://portfolio-template.nuxt.dev/',
|
},
|
||||||
}, {
|
{
|
||||||
label: 'Changelog',
|
label: 'Chat',
|
||||||
to: 'https://changelog-template.nuxt.dev/',
|
to: 'https://chat-template.nuxt.dev/',
|
||||||
}]"
|
},
|
||||||
|
{
|
||||||
|
label: 'Portfolio',
|
||||||
|
to: 'https://portfolio-template.nuxt.dev/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Changelog',
|
||||||
|
to: 'https://changelog-template.nuxt.dev/',
|
||||||
|
},
|
||||||
|
]"
|
||||||
:content="{ align: 'start' }"
|
:content="{ align: 'start' }"
|
||||||
:ui="{ content: 'min-w-fit' }"
|
:ui="{ content: 'min-w-fit' }"
|
||||||
size="xs"
|
size="xs">
|
||||||
>
|
|
||||||
<UButton
|
<UButton
|
||||||
label="Starter"
|
label="Starter"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
trailing-icon="i-lucide-chevron-down"
|
trailing-icon="i-lucide-chevron-down"
|
||||||
size="xs"
|
size="xs"
|
||||||
class="-mb-[6px] font-semibold rounded-full truncate"
|
class="-mb-1.5 font-semibold rounded-full truncate"
|
||||||
:class="[open && 'bg-primary/15']"
|
:class="[open && 'bg-primary/15']"
|
||||||
:ui="{
|
:ui="{
|
||||||
trailingIcon: ['transition-transform duration-200', open ? 'rotate-180' : undefined].filter(Boolean).join(' '),
|
trailingIcon: ['transition-transform duration-200', open ? 'rotate-180' : undefined].filter(Boolean).join(' '),
|
||||||
}"
|
}" />
|
||||||
/>
|
|
||||||
</UDropdownMenu>
|
</UDropdownMenu>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
77
apps/web/app/composables/useProjectApi.ts
Normal file
77
apps/web/app/composables/useProjectApi.ts
Normal file
|
|
@ -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<Project[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
async function fetchProjects(): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
projects.value = await $fetch<Project[]>(`${apiBase}/projects`);
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProject(body: Partial<ProjectInsertDTO>): Promise<Project | null> {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const created = await $fetch<Project>(`${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<ProjectUpdateDTO>): Promise<Project | null> {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const updated = await $fetch<Project>(`${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<boolean> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,20 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<UPageHero
|
<UPageHero
|
||||||
title="Nuxt Starter Template"
|
:title="t('home.heroTitle')"
|
||||||
description="A production-ready starter template powered by Nuxt UI. Build beautiful, accessible, and performant applications in minutes, not hours."
|
:description="t('home.heroDescription')"
|
||||||
:links="[{
|
:links="[{
|
||||||
label: 'Get started',
|
label: t('home.getStarted'),
|
||||||
to: 'https://ui.nuxt.com/docs/getting-started/installation/nuxt',
|
to: 'https://ui.nuxt.com/docs/getting-started/installation/nuxt',
|
||||||
target: '_blank',
|
target: '_blank',
|
||||||
trailingIcon: 'i-lucide-arrow-right',
|
trailingIcon: 'i-lucide-arrow-right',
|
||||||
size: 'xl',
|
size: 'xl',
|
||||||
}, {
|
}, {
|
||||||
label: 'Use this template',
|
label: t('home.useTemplate'),
|
||||||
to: 'https://github.com/nuxt-ui-templates/starter',
|
to: 'https://github.com/nuxt-ui-templates/starter',
|
||||||
target: '_blank',
|
target: '_blank',
|
||||||
icon: 'i-simple-icons-github',
|
icon: 'i-simple-icons-github',
|
||||||
|
|
@ -22,48 +26,48 @@
|
||||||
|
|
||||||
<UPageSection
|
<UPageSection
|
||||||
id="features"
|
id="features"
|
||||||
title="Everything you need to build modern Nuxt apps"
|
:title="t('home.featuresTitle')"
|
||||||
description="Start with a solid foundation. This template includes all the essentials for building production-ready applications with Nuxt UI's powerful component system."
|
:description="t('home.featuresDescription')"
|
||||||
:features="[{
|
:features="[{
|
||||||
icon: 'i-lucide-rocket',
|
icon: 'i-lucide-rocket',
|
||||||
title: 'Production-ready from day one',
|
title: t('home.feature1Title'),
|
||||||
description: 'Pre-configured with TypeScript, ESLint, Tailwind CSS, and all the best practices. Focus on building features, not setting up tooling.',
|
description: t('home.feature1Description'),
|
||||||
}, {
|
}, {
|
||||||
icon: 'i-lucide-palette',
|
icon: 'i-lucide-palette',
|
||||||
title: 'Beautiful by default',
|
title: t('home.feature2Title'),
|
||||||
description: 'Leveraging Nuxt UI\'s design system with automatic dark mode, consistent spacing, and polished components that look great out of the box.',
|
description: t('home.feature2Description'),
|
||||||
}, {
|
}, {
|
||||||
icon: 'i-lucide-zap',
|
icon: 'i-lucide-zap',
|
||||||
title: 'Lightning fast',
|
title: t('home.feature3Title'),
|
||||||
description: 'Optimized for performance with SSR/SSG support, automatic code splitting, and edge-ready deployment. Your users will love the speed.',
|
description: t('home.feature3Description'),
|
||||||
}, {
|
}, {
|
||||||
icon: 'i-lucide-blocks',
|
icon: 'i-lucide-blocks',
|
||||||
title: '100+ components included',
|
title: t('home.feature4Title'),
|
||||||
description: 'Access Nuxt UI\'s comprehensive component library. From forms to navigation, everything is accessible, responsive, and customizable.',
|
description: t('home.feature4Description'),
|
||||||
}, {
|
}, {
|
||||||
icon: 'i-lucide-code-2',
|
icon: 'i-lucide-code-2',
|
||||||
title: 'Developer experience first',
|
title: t('home.feature5Title'),
|
||||||
description: 'Auto-imports, hot module replacement, and TypeScript support. Write less boilerplate and ship more features.',
|
description: t('home.feature5Description'),
|
||||||
}, {
|
}, {
|
||||||
icon: 'i-lucide-shield-check',
|
icon: 'i-lucide-shield-check',
|
||||||
title: 'Built for scale',
|
title: t('home.feature6Title'),
|
||||||
description: 'Enterprise-ready architecture with proper error handling, SEO optimization, and security best practices built-in.',
|
description: t('home.feature6Description'),
|
||||||
}]"
|
}]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UPageSection>
|
<UPageSection>
|
||||||
<UPageCTA
|
<UPageCTA
|
||||||
title="Ready to build your next Nuxt app?"
|
:title="t('home.ctaTitle')"
|
||||||
description="Join thousands of developers building with Nuxt and Nuxt UI. Get this template and start shipping today."
|
:description="t('home.ctaDescription')"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
:links="[{
|
:links="[{
|
||||||
label: 'Start building',
|
label: t('home.startBuilding'),
|
||||||
to: 'https://ui.nuxt.com/docs/getting-started/installation/nuxt',
|
to: 'https://ui.nuxt.com/docs/getting-started/installation/nuxt',
|
||||||
target: '_blank',
|
target: '_blank',
|
||||||
trailingIcon: 'i-lucide-arrow-right',
|
trailingIcon: 'i-lucide-arrow-right',
|
||||||
color: 'neutral',
|
color: 'neutral',
|
||||||
}, {
|
}, {
|
||||||
label: 'View on GitHub',
|
label: t('home.viewOnGitHub'),
|
||||||
to: 'https://github.com/nuxt-ui-templates/starter',
|
to: 'https://github.com/nuxt-ui-templates/starter',
|
||||||
target: '_blank',
|
target: '_blank',
|
||||||
icon: 'i-simple-icons-github',
|
icon: 'i-simple-icons-github',
|
||||||
|
|
|
||||||
70
apps/web/app/pages/projects/[id]/edit.vue
Normal file
70
apps/web/app/pages/projects/[id]/edit.vue
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Project } from "@workspace/schema";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
title: "Edit Project",
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const { updateProject } = useProjectApi();
|
||||||
|
|
||||||
|
const saving = ref(false);
|
||||||
|
const project = ref<Project | null>(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const notFound = ref(false);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
project.value = await $fetch<Project>(`${useRuntimeConfig().public.apiBaseUrl as string}/projects/${route.params.id}`);
|
||||||
|
} catch {
|
||||||
|
notFound.value = true;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSubmit(body: { name: string; description?: string; status: string }) {
|
||||||
|
saving.value = true;
|
||||||
|
const updated = await updateProject(route.params.id as string, body);
|
||||||
|
saving.value = false;
|
||||||
|
if (updated) router.push("/projects");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UPage>
|
||||||
|
<UPageHeader :title="t('projects.edit')" :description="t('projects.editDescription')">
|
||||||
|
<template #right>
|
||||||
|
<UButton
|
||||||
|
:label="t('form.back')"
|
||||||
|
icon="i-lucide-chevron-left"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
to="/projects" />
|
||||||
|
</template>
|
||||||
|
</UPageHeader>
|
||||||
|
|
||||||
|
<UPageBody>
|
||||||
|
<UAlert
|
||||||
|
v-if="notFound"
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-alert-triangle"
|
||||||
|
:title="t('projects.notFound')" />
|
||||||
|
|
||||||
|
<UProgress v-else-if="loading" />
|
||||||
|
|
||||||
|
<UCard v-else-if="project">
|
||||||
|
<ProjectForm
|
||||||
|
:name="project.name"
|
||||||
|
:description="project.description ?? undefined"
|
||||||
|
:status="project.status"
|
||||||
|
:saving
|
||||||
|
@submit="handleSubmit" />
|
||||||
|
</UCard>
|
||||||
|
</UPageBody>
|
||||||
|
</UPage>
|
||||||
|
</template>
|
||||||
146
apps/web/app/pages/projects/index.vue
Normal file
146
apps/web/app/pages/projects/index.vue
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import type { TableColumn } from "@nuxt/ui";
|
||||||
|
import type { Project } from "@workspace/schema";
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
title: "Projects",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const pageTitle = computed(() => t("projects.title"));
|
||||||
|
const pageDescription = computed(() => t("projects.description"));
|
||||||
|
const newProjectLabel = computed(() => t("projects.new"));
|
||||||
|
const emptyText = computed(() => t("projects.empty"));
|
||||||
|
const deleteTitle = computed(() => t("projects.delete.title"));
|
||||||
|
const deleteConfirm = computed(() => t("projects.delete.confirm"));
|
||||||
|
const deleteCancel = computed(() => t("projects.delete.cancel"));
|
||||||
|
const deleteDelete = computed(() => t("projects.delete.delete"));
|
||||||
|
const colName = computed(() => t("projects.columns.name"));
|
||||||
|
const colStatus = computed(() => t("projects.columns.status"));
|
||||||
|
const colDescription = computed(() => t("projects.columns.description"));
|
||||||
|
const colCreated = computed(() => t("projects.columns.created"));
|
||||||
|
const colActions = computed(() => t("projects.columns.actions"));
|
||||||
|
|
||||||
|
const { projects, loading, error, fetchProjects, deleteProject } = useProjectApi();
|
||||||
|
|
||||||
|
const deleting = ref<string | null>(null);
|
||||||
|
const confirmDelete = ref(false);
|
||||||
|
|
||||||
|
onMounted(() => fetchProjects());
|
||||||
|
|
||||||
|
function statusColor(status: string) {
|
||||||
|
if (status === "active") return "success" as const;
|
||||||
|
if (status === "archived") return "neutral" as const;
|
||||||
|
if (status === "completed") return "info" as const;
|
||||||
|
return "neutral" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmAndDelete(id: string) {
|
||||||
|
deleting.value = id;
|
||||||
|
await deleteProject(id);
|
||||||
|
deleting.value = null;
|
||||||
|
confirmDelete.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumn<Project>[] = [
|
||||||
|
{ accessorKey: "name", header: colName.value },
|
||||||
|
{ accessorKey: "status", header: colStatus.value },
|
||||||
|
{
|
||||||
|
accessorKey: "description",
|
||||||
|
header: colDescription.value,
|
||||||
|
cell: ({ row }) => row.getValue("description") ?? "—",
|
||||||
|
},
|
||||||
|
{ accessorKey: "createdAt", header: colCreated.value },
|
||||||
|
{ id: "actions", header: colActions.value },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UPage>
|
||||||
|
<UPageHeader
|
||||||
|
:title="pageTitle"
|
||||||
|
:description="pageDescription"
|
||||||
|
:links="[{ label: newProjectLabel, icon: 'i-lucide-plus', to: '/projects/new', color: 'primary' }]" />
|
||||||
|
|
||||||
|
<UPageBody>
|
||||||
|
<LazyUAlert v-if="error" color="error" variant="soft" icon="i-lucide-alert-circle" :title="error" class="mb-4" />
|
||||||
|
|
||||||
|
<UCard>
|
||||||
|
<UTable :columns="columns" :data="projects" :loading="loading" class="flex-1">
|
||||||
|
<template #status-cell="{ row }">
|
||||||
|
<UBadge
|
||||||
|
:label="row.getValue('status')"
|
||||||
|
:color="statusColor(row.getValue('status') as string)"
|
||||||
|
variant="subtle"
|
||||||
|
class="capitalize" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #createdAt-cell="{ row }">
|
||||||
|
<span class="text-muted">{{ new Date(row.getValue("createdAt") as string).toLocaleDateString() }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #actions-cell="{ row }">
|
||||||
|
<div class="flex justify-end gap-1">
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-pencil"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:to="`/projects/${row.original.id}/edit`"
|
||||||
|
aria-label="Edit" />
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-trash-2"
|
||||||
|
color="error"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:loading="deleting === row.original.id"
|
||||||
|
aria-label="Delete"
|
||||||
|
@click="
|
||||||
|
confirmDelete = true;
|
||||||
|
deleting = row.original.id;
|
||||||
|
" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #empty>
|
||||||
|
<div class="py-12 text-center text-muted">
|
||||||
|
<UIcon name="i-lucide-folder-open" class="size-8 mx-auto mb-2" />
|
||||||
|
<p>{{ emptyText }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UTable>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<UModal v-model:open="confirmDelete">
|
||||||
|
<template #header>
|
||||||
|
<h3 class="text-lg font-semibold">{{ deleteTitle }}</h3>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body>
|
||||||
|
<p class="text-sm text-muted">{{ deleteConfirm }}</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex gap-3 justify-end">
|
||||||
|
<UButton
|
||||||
|
:label="deleteCancel"
|
||||||
|
icon="i-lucide-x"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
@click="
|
||||||
|
confirmDelete = false;
|
||||||
|
deleting = null;
|
||||||
|
" />
|
||||||
|
<UButton
|
||||||
|
:label="deleteDelete"
|
||||||
|
icon="i-lucide-trash-2"
|
||||||
|
color="error"
|
||||||
|
@click="deleting && confirmAndDelete(deleting)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</UPageBody>
|
||||||
|
</UPage>
|
||||||
|
</template>
|
||||||
36
apps/web/app/pages/projects/new.vue
Normal file
36
apps/web/app/pages/projects/new.vue
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
title: "New Project",
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { createProject } = useProjectApi();
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
async function handleSubmit(body: { name: string; description?: string; status: string }) {
|
||||||
|
saving.value = true;
|
||||||
|
const project = await createProject(body);
|
||||||
|
saving.value = false;
|
||||||
|
if (project) router.push("/projects");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UPage>
|
||||||
|
<UPageHeader
|
||||||
|
:title="t('projects.create')"
|
||||||
|
:description="t('projects.createDescription')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UPageBody>
|
||||||
|
<UCard>
|
||||||
|
<ProjectForm
|
||||||
|
:saving
|
||||||
|
@submit="handleSubmit"
|
||||||
|
/>
|
||||||
|
</UCard>
|
||||||
|
</UPageBody>
|
||||||
|
</UPage>
|
||||||
|
</template>
|
||||||
|
|
@ -1,9 +1,17 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
import withNuxt from "./.nuxt/eslint.config.mjs"
|
import withNuxt from "./.nuxt/eslint.config.mjs";
|
||||||
import { baseRules, } from "@workspace/tooling/eslint/base"
|
import { baseRules, } from "@workspace/tooling/eslint/base";
|
||||||
|
|
||||||
export default withNuxt({
|
export default withNuxt({
|
||||||
rules: {
|
rules: {
|
||||||
...baseRules,
|
...baseRules,
|
||||||
|
"@stylistic/member-delimiter-style": "off",
|
||||||
|
"@stylistic/arrow-parens": "off",
|
||||||
|
"vue/max-attributes-per-line": "off",
|
||||||
|
"vue/singleline-html-element-content-newline": "off",
|
||||||
|
"vue/html-closing-bracket-newline": "off",
|
||||||
|
"vue/no-multiple-template-root": "off",
|
||||||
|
"max-lines-per-function": "off",
|
||||||
|
"max-statements": "off",
|
||||||
},
|
},
|
||||||
},)
|
},);
|
||||||
|
|
|
||||||
7
apps/web/i18n/i18n.config.ts
Normal file
7
apps/web/i18n/i18n.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export default defineI18nConfig(() => ({
|
||||||
|
legacy: false,
|
||||||
|
locale: "en",
|
||||||
|
fallbackLocale: "en",
|
||||||
|
silentFallbackWarn: true,
|
||||||
|
silentTranslationWarn: true,
|
||||||
|
}));
|
||||||
68
apps/web/i18n/locales/en.json
Normal file
68
apps/web/i18n/locales/en.json
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"title": "Work Hub",
|
||||||
|
"description": "Project management made simple."
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"home": "Home",
|
||||||
|
"projects": "Projects"
|
||||||
|
},
|
||||||
|
"projects": {
|
||||||
|
"title": "Projects",
|
||||||
|
"description": "Manage your projects",
|
||||||
|
"new": "New project",
|
||||||
|
"edit": "Edit Project",
|
||||||
|
"editDescription": "Update project details",
|
||||||
|
"create": "New Project",
|
||||||
|
"createDescription": "Create a new project",
|
||||||
|
"columns": {
|
||||||
|
"name": "Name",
|
||||||
|
"status": "Status",
|
||||||
|
"description": "Description",
|
||||||
|
"created": "Created",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"title": "Delete project",
|
||||||
|
"confirm": "Are you sure you want to delete this project? This action cannot be undone.",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete"
|
||||||
|
},
|
||||||
|
"notFound": "Project not found",
|
||||||
|
"empty": "No projects yet."
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "Project name",
|
||||||
|
"description": "Description",
|
||||||
|
"descriptionPlaceholder": "Optional description",
|
||||||
|
"status": "Status",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"back": "Back"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"heroTitle": "Nuxt Starter Template",
|
||||||
|
"heroDescription": "A production-ready starter template powered by Nuxt UI. Build beautiful, accessible, and performant applications in minutes, not hours.",
|
||||||
|
"getStarted": "Get started",
|
||||||
|
"useTemplate": "Use this template",
|
||||||
|
"featuresTitle": "Everything you need to build modern Nuxt apps",
|
||||||
|
"featuresDescription": "Start with a solid foundation. This template includes all the essentials for building production-ready applications with Nuxt UI's powerful component system.",
|
||||||
|
"feature1Title": "Production-ready from day one",
|
||||||
|
"feature1Description": "Pre-configured with TypeScript, ESLint, Tailwind CSS, and all the best practices. Focus on building features, not setting up tooling.",
|
||||||
|
"feature2Title": "Beautiful by default",
|
||||||
|
"feature2Description": "Leveraging Nuxt UI's design system with automatic dark mode, consistent spacing, and polished components that look great out of the box.",
|
||||||
|
"feature3Title": "Lightning fast",
|
||||||
|
"feature3Description": "Optimized for performance with SSR/SSG support, automatic code splitting, and edge-ready deployment. Your users will love the speed.",
|
||||||
|
"feature4Title": "100+ components included",
|
||||||
|
"feature4Description": "Access Nuxt UI's comprehensive component library. From forms to navigation, everything is accessible, responsive, and customizable.",
|
||||||
|
"feature5Title": "Developer experience first",
|
||||||
|
"feature5Description": "Auto-imports, hot module replacement, and TypeScript support. Write less boilerplate and ship more features.",
|
||||||
|
"feature6Title": "Built for scale",
|
||||||
|
"feature6Description": "Enterprise-ready architecture with proper error handling, SEO optimization, and security best practices built-in.",
|
||||||
|
"ctaTitle": "Ready to build your next Nuxt app?",
|
||||||
|
"ctaDescription": "Join thousands of developers building with Nuxt and Nuxt UI. Get this template and start shipping today.",
|
||||||
|
"startBuilding": "Start building",
|
||||||
|
"viewOnGitHub": "View on GitHub"
|
||||||
|
}
|
||||||
|
}
|
||||||
68
apps/web/i18n/locales/fr.json
Normal file
68
apps/web/i18n/locales/fr.json
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"title": "Work Hub",
|
||||||
|
"description": "La gestion de projets simplifiée."
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"home": "Accueil",
|
||||||
|
"projects": "Projets"
|
||||||
|
},
|
||||||
|
"projects": {
|
||||||
|
"title": "Projets",
|
||||||
|
"description": "Gérez vos projets",
|
||||||
|
"new": "Nouveau projet",
|
||||||
|
"edit": "Modifier le projet",
|
||||||
|
"editDescription": "Mettre à jour les détails du projet",
|
||||||
|
"create": "Nouveau projet",
|
||||||
|
"createDescription": "Créer un nouveau projet",
|
||||||
|
"columns": {
|
||||||
|
"name": "Nom",
|
||||||
|
"status": "Statut",
|
||||||
|
"description": "Description",
|
||||||
|
"created": "Créé le",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"title": "Supprimer le projet",
|
||||||
|
"confirm": "Êtes-vous sûr de vouloir supprimer ce projet ? Cette action est irréversible.",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"delete": "Supprimer"
|
||||||
|
},
|
||||||
|
"notFound": "Projet introuvable",
|
||||||
|
"empty": "Aucun projet pour le moment."
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"name": "Nom",
|
||||||
|
"namePlaceholder": "Nom du projet",
|
||||||
|
"description": "Description",
|
||||||
|
"descriptionPlaceholder": "Description facultative",
|
||||||
|
"status": "Statut",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"back": "Retour"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"heroTitle": "Nuxt Starter Template",
|
||||||
|
"heroDescription": "Un modèle de démarrage prêt pour la production propulsé par Nuxt UI. Créez des applications belles, accessibles et performantes en quelques minutes.",
|
||||||
|
"getStarted": "Commencer",
|
||||||
|
"useTemplate": "Utiliser ce modèle",
|
||||||
|
"featuresTitle": "Tout ce dont vous avez besoin pour créer des apps Nuxt modernes",
|
||||||
|
"featuresDescription": "Partez sur des bases solides. Ce modèle inclut tous les essentiels pour créer des applications prêtes pour la production avec le système de composants puissant de Nuxt UI.",
|
||||||
|
"feature1Title": "Prêt pour la production dès le premier jour",
|
||||||
|
"feature1Description": "Préconfiguré avec TypeScript, ESLint, Tailwind CSS et toutes les bonnes pratiques. Concentrez-vous sur les fonctionnalités, pas sur la configuration.",
|
||||||
|
"feature2Title": "Beau par défaut",
|
||||||
|
"feature2Description": "Tirez parti du système de design de Nuxt UI avec le mode sombre automatique, une espacement cohérent et des composants soignés qui sont superbes dès la sortie de la boîte.",
|
||||||
|
"feature3Title": "Rapide comme l'éclair",
|
||||||
|
"feature3Description": "Optimisé pour la performance avec le support SSR/SSG, la séparation automatique du code et un déploiement edge-ready. Vos utilisateurs adoreront la vitesse.",
|
||||||
|
"feature4Title": "100+ composants inclus",
|
||||||
|
"feature4Description": "Accédez à la bibliothèque complète de composants de Nuxt UI. Des formulaires à la navigation, tout est accessible, responsive et personnalisable.",
|
||||||
|
"feature5Title": "L'expérience développeur avant tout",
|
||||||
|
"feature5Description": "Auto-imports, remplacement de modules à chaud et support TypeScript. Écrivez moins de code passe-partout et livrez plus de fonctionnalités.",
|
||||||
|
"feature6Title": "Conçu pour passer à l'échelle",
|
||||||
|
"feature6Description": "Architecture prête pour l'entreprise avec une gestion des erreurs appropriée, une optimisation SEO et des bonnes pratiques de sécurité intégrées.",
|
||||||
|
"ctaTitle": "Prêt à créer votre prochaine app Nuxt ?",
|
||||||
|
"ctaDescription": "Rejoignez des milliers de développeurs qui construisent avec Nuxt et Nuxt UI. Obtenez ce modèle et commencez à livrer dès aujourd'hui.",
|
||||||
|
"startBuilding": "Commencer à développer",
|
||||||
|
"viewOnGitHub": "Voir sur GitHub"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,21 @@
|
||||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
modules: ["@nuxt/eslint", "@nuxt/ui"],
|
modules: ["@nuxt/eslint", "@nuxt/ui", "@nuxtjs/i18n", "@nuxt/a11y", "@nuxt/hints", "@nuxt/image", "@vueuse/nuxt"],
|
||||||
|
|
||||||
devtools: {
|
devtools: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
||||||
|
timeline: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
css: ["~/assets/css/main.css"],
|
css: ["~/assets/css/main.css"],
|
||||||
|
|
||||||
routeRules: {
|
runtimeConfig: {
|
||||||
"/": { prerender: true },
|
public: {
|
||||||
|
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
compatibilityDate: "2025-01-15",
|
compatibilityDate: "2025-01-15",
|
||||||
|
|
@ -22,4 +28,21 @@ export default defineNuxtConfig({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
i18n: {
|
||||||
|
baseUrl: process.env.NUXT_PUBLIC_SITE_URL ?? "http://localhost:3000",
|
||||||
|
locales: [
|
||||||
|
{ code: "en", language: "en", name: "English", file: "en.json" },
|
||||||
|
{ code: "fr", language: "fr", name: "Français", file: "fr.json" },
|
||||||
|
],
|
||||||
|
defaultLocale: "en",
|
||||||
|
strategy: "no_prefix",
|
||||||
|
langDir: "locales",
|
||||||
|
detectBrowserLanguage: {
|
||||||
|
useCookie: true,
|
||||||
|
cookieKey: "i18n_redirected",
|
||||||
|
redirectOn: "root",
|
||||||
|
},
|
||||||
|
vueI18n: "i18n.config.ts",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@
|
||||||
"@nuxt/image": "2.0.0",
|
"@nuxt/image": "2.0.0",
|
||||||
"@nuxt/test-utils": "4.0.3",
|
"@nuxt/test-utils": "4.0.3",
|
||||||
"@nuxt/ui": "^4.8.2",
|
"@nuxt/ui": "^4.8.2",
|
||||||
"@nuxtjs/eslint-module": "4.1.0",
|
|
||||||
"@nuxtjs/i18n": "10.4.0",
|
"@nuxtjs/i18n": "10.4.0",
|
||||||
"@vueuse/nuxt": "14.3.0",
|
"@vueuse/nuxt": "14.3.0",
|
||||||
"@workspace/schema": "workspace:*",
|
"@workspace/schema": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
allowBuilds:
|
|
||||||
'@parcel/watcher': false
|
|
||||||
'@tailwindcss/oxide': false
|
|
||||||
esbuild: false
|
|
||||||
sharp: true
|
|
||||||
unrs-resolver: false
|
|
||||||
vue-demi: false
|
|
||||||
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
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
schema: spec-driven
|
||||||
|
created: 2026-06-12
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The previous context remains: a unified technical layer for full CRUD operations is required for the `Project` resource across API and Web UI layers. The goal is to establish a canonical method for initializing a Project resource that can be consumed both programmatically (via API) and through the standard user interface. This requires centralizing validation and persistence logic.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- To expose a stable, RESTful endpoint (`POST /api/v1/projects`) for project creation.
|
||||||
|
- To ensure data consistency: any project created via the API must behave identically to one created via the Web UI (and vice versa).
|
||||||
|
- To establish `project-creation` as the single source of truth requirement for project setup.
|
||||||
|
- **Local Development**: The entire stack (API, Web, DB) must be orchestrated via Docker Compose to guarantee a reproducible and persistent development environment.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Implementing granular ownership/permission checks within the creation payload itself; this should be handled by middleware/services post-creation.
|
||||||
|
- Handling advanced lifecycle events (e.g., project suspension) via the creation endpoint—these should use dedicated endpoints.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- **Architecture**: The implementation must use a clear separation of concerns: Controllers handle HTTP concerns (routing, validation), Services encapsulate business logic, and Repositories handle data access. NestJS's module system enforces this structure naturally.
|
||||||
|
- **Database Technology & Setup**: PostgreSQL is mandated as the standard database engine for persistence. The root of the monorepo MUST have a `docker-compose.yml` dedicated to infrastructure services (Postgres, volumes). API and Web apps run natively via `pnpm dev` — no containerization for application code in development.
|
||||||
|
- **Lifecycle Management**: Database migrations (schema versioning) must be executed _manually_ via a dedicated CLI tool. This is critical for controlling both local and production deployment processes.
|
||||||
|
- **Input Validation Standard (NEW)**: Input validation for all API endpoints MUST be handled at the Controller level using **Schema-Driven Pipes**. All schemas used (`Project` DTOs, etc.) must be imported from and adhere to the contract defined in the shared `@workspace/schema` package.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
[Risk] The migration tooling might not cleanly support optional data seeding after a schema change.
|
||||||
|
[Mitigation] We must provide clear setup steps in the final documentation (`tasks.md`) to ensure developers run migrations first, then seed scripts separately (and idempotently).
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Current project management lacks a unified layer for full CRUD operations. The ability to create, read, update, and delete projects must be available consistently across both the internal API and the public Web UI. Furthermore, robust data lifecycle management—including database migrations (schema changes) and repeatable initial population via seeding—is required to ensure data integrity and development ease.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **New Capability**: Full Project Lifecycle Management will be implemented: Create (C), Read (R), Update (U), Delete (D).
|
||||||
|
- **Technical Change**: The system must incorporate a dedicated, idempotent database migration mechanism for the `Project` entity.
|
||||||
|
- **Data Initialization**: Implement optional seeding mechanisms to populate initial dummy data or test scenarios upon deployment/setup.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `project-full-lifecycle`: Governing all CRUD operations and the associated schema management (migrations) for the Project resource.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
<!-- No existing capabilities require requirement changes at this time. -->
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
This change affects the core persistence layer, requiring database migrations (`Project` table/schema), service logic updates in both API and Web layers to handle full CRUD operations, and introducing new tooling/hooks for schema versioning (migrations) and initial data population (seeding scripts).
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Project Lifecycle Management (CRUD)
|
||||||
|
|
||||||
|
The system SHALL provide full CRUD functionality for the `Project` resource, managed by a central service layer. All operations must be atomic and transactionally consistent across API and Web paths. This includes validation on creation (name uniqueness).
|
||||||
|
|
||||||
|
#### Scenario: Successful Project Creation
|
||||||
|
|
||||||
|
- **WHEN** valid payload {name, ownerId, templateType} is submitted to POST /projects
|
||||||
|
- **THEN** a unique Project resource record is created with status 'ACTIVE', and all related metadata are initialized correctly.
|
||||||
|
|
||||||
|
### Requirement: Schema Migration Enforcement
|
||||||
|
|
||||||
|
The system SHALL enforce database schema changes via versioned migrations (e.g., using an established ORM migration tool). The deployment process MUST fail if the current schema does not match the expected state defined by the latest migration script.
|
||||||
|
|
||||||
|
#### Scenario: Applying New Schema
|
||||||
|
|
||||||
|
- **WHEN** deploying a change requiring a new column (`last_updated`) on `projects` table
|
||||||
|
- **THEN** the deployment pipeline runs the required migration, adding the column without breaking existing application runtime logic (e.g., providing a default value or nullability).
|
||||||
|
|
||||||
|
### Requirement: Optional Data Seeding
|
||||||
|
|
||||||
|
The system SHALL support optional data seeding scripts for development and testing environments. These scripts MUST be idempotent to allow multiple safe executions.
|
||||||
|
|
||||||
|
#### Scenario: Running Seed Scripts
|
||||||
|
|
||||||
|
- **WHEN** running the setup script `pnpm run db:seed` in a test environment with existing data
|
||||||
|
- **THEN** any record matching seed criteria (e.g., default user) is updated rather than duplicated, and the script logs successful updates/inserts.
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
## 1. Database & Infrastructure Setup (Mandatory First Step)
|
||||||
|
|
||||||
|
- [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)
|
||||||
|
|
||||||
|
- [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)
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
- [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.
|
||||||
32
openspec/specs/project-full-lifecycle/spec.md
Normal file
32
openspec/specs/project-full-lifecycle/spec.md
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
TBD — Project full-lifecycle management covers the creation, persistence, retrieval, update, and deletion of `Project` resources across the stack (schema → database → API → web).
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Project Lifecycle Management (CRUD)
|
||||||
|
|
||||||
|
The system SHALL provide full CRUD functionality for the `Project` resource, managed by a central service layer. All operations must be atomic and transactionally consistent across API and Web paths. This includes validation on creation (name uniqueness).
|
||||||
|
|
||||||
|
#### Scenario: Successful Project Creation
|
||||||
|
|
||||||
|
- **WHEN** valid payload {name, ownerId, templateType} is submitted to POST /projects
|
||||||
|
- **THEN** a unique Project resource record is created with status 'ACTIVE', and all related metadata are initialized correctly.
|
||||||
|
|
||||||
|
### Requirement: Schema Migration Enforcement
|
||||||
|
|
||||||
|
The system SHALL enforce database schema changes via versioned migrations (e.g., using an established ORM migration tool). The deployment process MUST fail if the current schema does not match the expected state defined by the latest migration script.
|
||||||
|
|
||||||
|
#### Scenario: Applying New Schema
|
||||||
|
|
||||||
|
- **WHEN** deploying a change requiring a new column (`last_updated`) on `projects` table
|
||||||
|
- **THEN** the deployment pipeline runs the required migration, adding the column without breaking existing application runtime logic (e.g., providing a default value or nullability).
|
||||||
|
|
||||||
|
### Requirement: Optional Data Seeding
|
||||||
|
|
||||||
|
The system SHALL support optional data seeding scripts for development and testing environments. These scripts MUST be idempotent to allow multiple safe executions.
|
||||||
|
|
||||||
|
#### Scenario: Running Seed Scripts
|
||||||
|
|
||||||
|
- **WHEN** running the setup script `pnpm run db:seed` in a test environment with existing data
|
||||||
|
- **THEN** any record matching seed criteria (e.g., default user) is updated rather than duplicated, and the script logs successful updates/inserts.
|
||||||
|
|
@ -7,7 +7,8 @@
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
||||||
"format:check": "prettier --check \"**/*.{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": {
|
"devDependencies": {
|
||||||
"@workspace/tooling": "workspace:*",
|
"@workspace/tooling": "workspace:*",
|
||||||
|
|
@ -15,8 +16,7 @@
|
||||||
"turbo": "^2.9.18",
|
"turbo": "^2.9.18",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"pnpm": {},
|
"packageManager": "pnpm@11.5.2",
|
||||||
"packageManager": "pnpm@9.0.0",
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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"] },
|
{ ignores: ["eslint.config.mjs", "dist"] },
|
||||||
...base,
|
...base,
|
||||||
{
|
{
|
||||||
|
files: ["src/**/*.ts"],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
projectService: true,
|
projectService: true,
|
||||||
|
|
@ -14,6 +15,15 @@ export default defineConfig(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
files: ["seed.ts"],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["src/**/*.ts"],
|
||||||
rules: {
|
rules: {
|
||||||
"@typescript-eslint/no-floating-promises": "warn",
|
"@typescript-eslint/no-floating-promises": "warn",
|
||||||
"@typescript-eslint/no-unsafe-argument": "warn",
|
"@typescript-eslint/no-unsafe-argument": "warn",
|
||||||
|
|
|
||||||
|
|
@ -10,23 +10,43 @@
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./dist/index.js",
|
"import": "./dist/index.js",
|
||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./constants": {
|
||||||
|
"types": "./src/constants.ts",
|
||||||
|
"import": "./src/constants.ts",
|
||||||
|
"default": "./src/constants.ts"
|
||||||
|
},
|
||||||
|
"./migrations": {
|
||||||
|
"types": "./dist/migrations.d.ts",
|
||||||
|
"import": "./dist/migrations.js",
|
||||||
|
"default": "./dist/migrations.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc --declaration --emitDeclarationOnly --outDir dist && rolldown -c rolldown.config.ts",
|
||||||
|
"dev": "rolldown -c rolldown.config.ts --watch",
|
||||||
"clean": "rm -rf dist",
|
"clean": "rm -rf dist",
|
||||||
"check-types": "tsc --noEmit",
|
"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": {
|
"dependencies": {
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"drizzle-valibot": "^0.4.2",
|
"drizzle-valibot": "^0.4.2",
|
||||||
|
"pg": "^8.21.0",
|
||||||
"valibot": "^1.4.1"
|
"valibot": "^1.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.9.3",
|
"@types/node": "^25.9.3",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@workspace/tooling": "workspace:*",
|
"@workspace/tooling": "workspace:*",
|
||||||
|
"drizzle-kit": "^0.31.10",
|
||||||
"eslint": "^10.4.1",
|
"eslint": "^10.4.1",
|
||||||
|
"rolldown": "^1.1.2",
|
||||||
|
"tsup": "^8.5.1",
|
||||||
|
"tsx": "^4.22.4",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
17
packages/schema/rolldown.config.ts
Normal file
17
packages/schema/rolldown.config.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { defineConfig } from "rolldown";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
input: ["src/index.ts", "src/migrations.ts"],
|
||||||
|
output: {
|
||||||
|
format: "esm",
|
||||||
|
dir: "dist",
|
||||||
|
},
|
||||||
|
external: (id: string) =>
|
||||||
|
id === "drizzle-orm" ||
|
||||||
|
id.startsWith("drizzle-orm/") ||
|
||||||
|
id === "drizzle-valibot" ||
|
||||||
|
id.startsWith("drizzle-valibot/") ||
|
||||||
|
id === "valibot" ||
|
||||||
|
id.startsWith("valibot/"),
|
||||||
|
platform: "node",
|
||||||
|
});
|
||||||
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 +1,8 @@
|
||||||
export const PROJECT_STATUSES = ["active", "archived", "completed"] as const;
|
export const PROJECT_STATUSES = ["active", "archived", "completed"] as const;
|
||||||
|
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
|
||||||
|
|
||||||
export const TASK_STATUSES = ["todo", "in_progress", "in_review", "done"] as const;
|
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 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];
|
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,11 @@
|
||||||
import { pgTable, uuid, text, timestamp, check } from "drizzle-orm/pg-core";
|
import { pgTable, uuid, text, timestamp, check } from "drizzle-orm/pg-core";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { PROJECT_STATUSES } from "./enums";
|
import { createInsertSchema, createSelectSchema, createUpdateSchema } from "drizzle-valibot";
|
||||||
|
import { InferOutput, picklist, pipe } from "valibot";
|
||||||
|
import { PROJECT_STATUSES } from "../constants";
|
||||||
|
|
||||||
const projectStatusValues = PROJECT_STATUSES.map((s) => `'${s}'`).join(", ");
|
const projectStatusValues = PROJECT_STATUSES.map((s) => `'${s}'`).join(", ");
|
||||||
|
const createProjectStatusSchema = (schema: any) => pipe(schema, picklist([...PROJECT_STATUSES])) as any;
|
||||||
|
|
||||||
export const projects = pgTable(
|
export const projects = pgTable(
|
||||||
"projects",
|
"projects",
|
||||||
|
|
@ -16,3 +19,18 @@ export const projects = pgTable(
|
||||||
},
|
},
|
||||||
(table) => [check("project_status_check", sql`${table.status} IN (${sql.raw(projectStatusValues)})`)],
|
(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>;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { pgTable, uuid, text, integer, timestamp, check } from "drizzle-orm/pg-core";
|
import { pgTable, uuid, text, integer, timestamp, check } from "drizzle-orm/pg-core";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { TASK_STATUSES, PRIORITIES } from "./enums";
|
import { createInsertSchema, createSelectSchema, createUpdateSchema } from "drizzle-valibot";
|
||||||
|
import { InferOutput, picklist, pipe } from "valibot";
|
||||||
import { projects } from "./projects";
|
import { projects } from "./projects";
|
||||||
|
import { TASK_STATUSES, PRIORITIES } from "../constants";
|
||||||
|
|
||||||
const taskStatusValues = TASK_STATUSES.map((s) => `'${s}'`).join(", ");
|
const taskStatusValues = TASK_STATUSES.map((s) => `'${s}'`).join(", ");
|
||||||
const priorityValues = PRIORITIES.map((p) => `'${p}'`).join(", ");
|
const priorityValues = PRIORITIES.map((p) => `'${p}'`).join(", ");
|
||||||
|
|
@ -27,3 +29,22 @@ export const tasks = pgTable(
|
||||||
check("task_priority_check", sql`${table.priority} IN (${sql.raw(priorityValues)})`),
|
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,24 @@
|
||||||
export {
|
export {
|
||||||
PROJECT_STATUSES,
|
PROJECT_STATUSES,
|
||||||
|
type ProjectStatus,
|
||||||
TASK_STATUSES,
|
TASK_STATUSES,
|
||||||
|
type TaskStatus,
|
||||||
PRIORITIES,
|
PRIORITIES,
|
||||||
|
type Priority,
|
||||||
|
} from "./constants";
|
||||||
|
|
||||||
|
export { eq } from "drizzle-orm";
|
||||||
|
export { drizzle, NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
|
export { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||||
|
|
||||||
|
export {
|
||||||
projects,
|
projects,
|
||||||
tasks,
|
projectSelect,
|
||||||
projectsRelations,
|
projectInsert,
|
||||||
tasksRelations,
|
projectUpdate,
|
||||||
} from "./drizzle/index";
|
type Project,
|
||||||
|
type ProjectInsertDTO,
|
||||||
export { projectSelect, projectInsert, projectUpdate, taskSelect, taskInsert, taskUpdate } from "./valibot";
|
type ProjectUpdateDTO,
|
||||||
|
} from "./drizzle/projects";
|
||||||
import type { InferOutput } from "valibot";
|
export { projectsRelations, tasksRelations } from "./drizzle/relations";
|
||||||
import { projectSelect, taskSelect } from "./valibot";
|
export { tasks, taskSelect, taskInsert, taskUpdate, type Task } from "./drizzle/tasks";
|
||||||
|
|
||||||
export type Project = InferOutput<typeof projectSelect>;
|
|
||||||
export type Task = InferOutput<typeof taskSelect>;
|
|
||||||
|
|
|
||||||
6
packages/schema/src/migrations.ts
Normal file
6
packages/schema/src/migrations.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { dirname, resolve } from "path";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export const migrationsDir = resolve(__dirname, "../drizzle");
|
||||||
|
|
@ -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",
|
"extends": "@workspace/tooling/tsconfig/library.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src"
|
"rootDir": "./src",
|
||||||
|
"ignoreDeprecations": "6.0"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ export function createNestjsConfig(tsconfigRootDir, overrides = {}) {
|
||||||
rules: {
|
rules: {
|
||||||
"class-methods-use-this": "off",
|
"class-methods-use-this": "off",
|
||||||
"@typescript-eslint/no-floating-promises": "warn",
|
"@typescript-eslint/no-floating-promises": "warn",
|
||||||
"@typescript-eslint/no-unsafe-argument": "warn",
|
// "@typescript-eslint/no-unsafe-argument": "warn",
|
||||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||||
...overrides,
|
...overrides,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@stylistic/eslint-plugin": "^5.10.0",
|
"@stylistic/eslint-plugin": "^5.10.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
|
||||||
"eslint-plugin-prettier": "^5.5.6",
|
"eslint-plugin-prettier": "^5.5.6",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"typescript-eslint": "^8.61.0"
|
"typescript-eslint": "^8.61.0"
|
||||||
|
|
|
||||||
4086
pnpm-lock.yaml
4086
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +1,16 @@
|
||||||
packages:
|
packages:
|
||||||
- "apps/*"
|
- "apps/*"
|
||||||
- "packages/*"
|
- "packages/*"
|
||||||
|
|
||||||
|
allowBuilds:
|
||||||
|
"@parcel/watcher": false
|
||||||
|
"@tailwindcss/oxide": false
|
||||||
|
esbuild: false
|
||||||
|
sharp: true
|
||||||
|
unrs-resolver: false
|
||||||
|
vue-demi: false
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
multer: ^2.2.0
|
||||||
|
js-yaml: ^4.2.0
|
||||||
|
esbuild: ^0.28.1
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,12 @@
|
||||||
"dependsOn": ["^lint"]
|
"dependsOn": ["^lint"]
|
||||||
},
|
},
|
||||||
"check-types": {
|
"check-types": {
|
||||||
"dependsOn": ["^check-types"]
|
"dependsOn": ["^build", "^check-types"]
|
||||||
},
|
},
|
||||||
"dev": {
|
"dev": {
|
||||||
"cache": false,
|
"cache": false,
|
||||||
"persistent": true
|
"persistent": true,
|
||||||
|
"dependsOn": ["^build"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue