Compare commits

..

No commits in common. "79e0efc71e4679adc14ef64ec7a4696c953c0f88" and "aec3f2ff980e8edd26557834d387c5332394ab65" have entirely different histories.

67 changed files with 448 additions and 2721 deletions

View file

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

View file

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

View file

@ -82,6 +82,7 @@ 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

View file

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

View file

@ -26,31 +26,27 @@
"@nestjs/core": "^11.1.26", "@nestjs/core": "^11.1.26",
"@nestjs/platform-express": "^11.1.26", "@nestjs/platform-express": "^11.1.26",
"@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.26",
"@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-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": {
@ -68,11 +64,6 @@
"**/*.(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"
}
} }
} }

View file

@ -1,11 +1,9 @@
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: [ProjectModule, DatabaseModule], imports: [],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],
}) })

View file

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

View file

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

View file

@ -1,43 +0,0 @@
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();
}
}

View file

@ -5,7 +5,6 @@ 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1 +0,0 @@
export const migrationsDir = "/mock/migrations";

View file

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

View file

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

View file

@ -3,81 +3,20 @@ 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 });
});
});
}); });

View file

@ -5,13 +5,5 @@
"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)"
]
} }

View file

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

View file

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

View file

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

View file

@ -4,8 +4,5 @@ export default defineAppConfig({
primary: "green", primary: "green",
neutral: "slate", neutral: "slate",
}, },
main: {
base: "min-h-0 flex-1 overflow-y-auto",
},
}, },
}); });

View file

@ -1,34 +1,77 @@
<script setup> <script setup>
const { t } = useI18n();
const i18nHead = useLocaleHead();
useHead({ useHead({
meta: [
{ name: "viewport", content: "width=device-width, initial-scale=1", },
],
link: [
{ rel: "icon", href: "/favicon.ico", },
],
htmlAttrs: { htmlAttrs: {
lang: () => i18nHead.value.htmlAttrs.lang, lang: "en",
}, },
meta: [{ name: "viewport", content: "width=device-width, initial-scale=1" }], },)
link: [{ rel: "icon", href: "/favicon.ico" }],
}); const title = "Nuxt Starter Template"
const description = "A production-ready starter template powered by Nuxt UI. Build beautiful, accessible, and performant applications in minutes, not hours."
useSeoMeta({ useSeoMeta({
title: () => t("app.title"), title,
description: () => t("app.description"), description,
ogTitle: () => t("app.title"), ogTitle: title,
ogDescription: () => t("app.description"), ogDescription: 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>
<div class="flex flex-col min-h-screen"> <UHeader>
<LazyAppHeader hydrate-on-interaction /> <template #left>
<NuxtLink to="/">
<AppLogo class="w-auto h-6 shrink-0" />
</NuxtLink>
<UMain class="px-6"> <TemplateMenu />
<NuxtPage /> </template>
</UMain>
<LazyAppFooter hydrate-on-interaction /> <template #right>
</div> <UColorModeButton />
<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>

View file

@ -1,19 +0,0 @@
<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>

View file

@ -1,33 +0,0 @@
<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>

View file

@ -1,56 +0,0 @@
<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>

View file

@ -2,55 +2,48 @@
<UDropdownMenu <UDropdownMenu
v-slot="{ open }" v-slot="{ open }"
:modal="false" :modal="false"
:items="[ :items="[{
{ label: 'Starter',
label: 'Starter', to: 'https://starter-template.nuxt.dev/',
to: 'https://starter-template.nuxt.dev/', color: 'primary',
color: 'primary', checked: true,
checked: true, type: 'checkbox',
type: 'checkbox', }, {
}, label: 'Landing',
{ to: 'https://landing-template.nuxt.dev/',
label: 'Landing', }, {
to: 'https://landing-template.nuxt.dev/', label: 'Docs',
}, to: 'https://docs-template.nuxt.dev/',
{ }, {
label: 'Docs', label: 'SaaS',
to: 'https://docs-template.nuxt.dev/', to: 'https://saas-template.nuxt.dev/',
}, }, {
{ label: 'Dashboard',
label: 'SaaS', to: 'https://dashboard-template.nuxt.dev/',
to: 'https://saas-template.nuxt.dev/', }, {
}, label: 'Chat',
{ to: 'https://chat-template.nuxt.dev/',
label: 'Dashboard', }, {
to: 'https://dashboard-template.nuxt.dev/', label: 'Portfolio',
}, to: 'https://portfolio-template.nuxt.dev/',
{ }, {
label: 'Chat', label: 'Changelog',
to: 'https://chat-template.nuxt.dev/', to: 'https://changelog-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-1.5 font-semibold rounded-full truncate" class="-mb-[6px] 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>

View file

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

View file

@ -1,20 +1,16 @@
<script setup lang="ts">
const { t } = useI18n();
</script>
<template> <template>
<div> <div>
<UPageHero <UPageHero
:title="t('home.heroTitle')" title="Nuxt Starter Template"
:description="t('home.heroDescription')" description="A production-ready starter template powered by Nuxt UI. Build beautiful, accessible, and performant applications in minutes, not hours."
:links="[{ :links="[{
label: t('home.getStarted'), label: 'Get started',
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: t('home.useTemplate'), label: 'Use this template',
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',
@ -26,48 +22,48 @@ const { t } = useI18n();
<UPageSection <UPageSection
id="features" id="features"
:title="t('home.featuresTitle')" title="Everything you need to build modern Nuxt apps"
:description="t('home.featuresDescription')" description="Start with a solid foundation. This template includes all the essentials for building production-ready applications with Nuxt UI's powerful component system."
:features="[{ :features="[{
icon: 'i-lucide-rocket', icon: 'i-lucide-rocket',
title: t('home.feature1Title'), title: 'Production-ready from day one',
description: t('home.feature1Description'), description: 'Pre-configured with TypeScript, ESLint, Tailwind CSS, and all the best practices. Focus on building features, not setting up tooling.',
}, { }, {
icon: 'i-lucide-palette', icon: 'i-lucide-palette',
title: t('home.feature2Title'), title: 'Beautiful by default',
description: t('home.feature2Description'), description: 'Leveraging Nuxt UI\'s design system with automatic dark mode, consistent spacing, and polished components that look great out of the box.',
}, { }, {
icon: 'i-lucide-zap', icon: 'i-lucide-zap',
title: t('home.feature3Title'), title: 'Lightning fast',
description: t('home.feature3Description'), description: 'Optimized for performance with SSR/SSG support, automatic code splitting, and edge-ready deployment. Your users will love the speed.',
}, { }, {
icon: 'i-lucide-blocks', icon: 'i-lucide-blocks',
title: t('home.feature4Title'), title: '100+ components included',
description: t('home.feature4Description'), description: 'Access Nuxt UI\'s comprehensive component library. From forms to navigation, everything is accessible, responsive, and customizable.',
}, { }, {
icon: 'i-lucide-code-2', icon: 'i-lucide-code-2',
title: t('home.feature5Title'), title: 'Developer experience first',
description: t('home.feature5Description'), description: 'Auto-imports, hot module replacement, and TypeScript support. Write less boilerplate and ship more features.',
}, { }, {
icon: 'i-lucide-shield-check', icon: 'i-lucide-shield-check',
title: t('home.feature6Title'), title: 'Built for scale',
description: t('home.feature6Description'), description: 'Enterprise-ready architecture with proper error handling, SEO optimization, and security best practices built-in.',
}]" }]"
/> />
<UPageSection> <UPageSection>
<UPageCTA <UPageCTA
:title="t('home.ctaTitle')" title="Ready to build your next Nuxt app?"
:description="t('home.ctaDescription')" description="Join thousands of developers building with Nuxt and Nuxt UI. Get this template and start shipping today."
variant="subtle" variant="subtle"
:links="[{ :links="[{
label: t('home.startBuilding'), label: 'Start building',
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: t('home.viewOnGitHub'), label: 'View on GitHub',
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',

View file

@ -1,73 +0,0 @@
<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);
const config = useRuntimeConfig();
const apiBase = config.public.apiBaseUrl as string;
onMounted(async () => {
const res = await fetch(`${apiBase}/projects/${route.params.id}`);
if (res.ok) {
project.value = await res.json();
} else {
notFound.value = true;
}
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>

View file

@ -1,146 +0,0 @@
<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>

View file

@ -1,36 +0,0 @@
<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>

View file

@ -5,10 +5,5 @@ import { baseRules, } from "@workspace/tooling/eslint/base"
export default withNuxt({ export default withNuxt({
rules: { rules: {
...baseRules, ...baseRules,
"@stylistic/member-delimiter-style": "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",
}, },
},) },)

View file

@ -1,7 +0,0 @@
export default defineI18nConfig(() => ({
legacy: false,
locale: "en",
fallbackLocale: "en",
silentFallbackWarn: true,
silentTranslationWarn: true,
}));

View file

@ -1,68 +0,0 @@
{
"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"
}
}

View file

@ -1,68 +0,0 @@
{
"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"
}
}

View file

@ -1,21 +1,15 @@
// 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", "@nuxtjs/i18n", "@nuxt/a11y", "@nuxt/hints", "@nuxt/image", "@vueuse/nuxt"], modules: ["@nuxt/eslint", "@nuxt/ui"],
devtools: { devtools: {
enabled: true, enabled: true,
timeline: {
enabled: true,
},
}, },
css: ["~/assets/css/main.css"], css: ["~/assets/css/main.css"],
runtimeConfig: { routeRules: {
public: { "/": { prerender: true },
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001",
},
}, },
compatibilityDate: "2025-01-15", compatibilityDate: "2025-01-15",
@ -28,21 +22,4 @@ 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",
},
}); });

View file

@ -19,6 +19,7 @@
"@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:*",

View file

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

View file

@ -5,23 +5,21 @@ The previous context remains: a unified technical layer for full CRUD operations
## Goals / Non-Goals ## Goals / Non-Goals
**Goals:** **Goals:**
* To expose a stable, RESTful endpoint (`POST /api/v1/projects`) for project creation.
- 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 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.
- 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.
- **Local Development**: The entire stack (API, Web, DB) must be orchestrated via Docker Compose to guarantee a reproducible and persistent development environment.
**Non-Goals:** **Non-Goals:**
* Implementing granular ownership/permission checks within the creation payload itself; this should be handled by middleware/services post-creation.
- 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.
- Handling advanced lifecycle events (e.g., project suspension) via the creation endpoint—these should use dedicated endpoints.
## Decisions ## 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. * **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. * **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. * **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. * **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 ## Risks / Trade-offs

View file

@ -4,18 +4,16 @@ Current project management lacks a unified layer for full CRUD operations. The a
## What Changes ## What Changes
- **New Capability**: Full Project Lifecycle Management will be implemented: Create (C), Read (R), Update (U), Delete (D). * **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. * **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. * **Data Initialization**: Implement optional seeding mechanisms to populate initial dummy data or test scenarios upon deployment/setup.
## Capabilities ## Capabilities
### New Capabilities ### New Capabilities
- `project-full-lifecycle`: Governing all CRUD operations and the associated schema management (migrations) for the Project resource. - `project-full-lifecycle`: Governing all CRUD operations and the associated schema management (migrations) for the Project resource.
### Modified Capabilities ### Modified Capabilities
<!-- No existing capabilities require requirement changes at this time. --> <!-- No existing capabilities require requirement changes at this time. -->
## Impact ## Impact

View file

@ -1,28 +1,22 @@
## ADDED Requirements ## ADDED Requirements
### Requirement: Project Lifecycle Management (CRUD) ### 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). 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 #### Scenario: Successful Project Creation
- **WHEN** valid payload {name, ownerId, templateType} is submitted to POST /projects - **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. - **THEN** a unique Project resource record is created with status 'ACTIVE', and all related metadata are initialized correctly.
### Requirement: Schema Migration Enforcement ### 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. 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 #### Scenario: Applying New Schema
- **WHEN** deploying a change requiring a new column (`last_updated`) on `projects` table - **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). - **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 ### 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. 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 #### Scenario: Running Seed Scripts
- **WHEN** running the setup script `pnpm run db:seed` in a test environment with existing data - **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. - **THEN** any record matching seed criteria (e.g., default user) is updated rather than duplicated, and the script logs successful updates/inserts.

View file

@ -1,19 +1,19 @@
## 1. Database & Infrastructure Setup (Mandatory First Step) ## 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. - [ ] 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. - [ ] 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. - [ ] 1.3 **CLI Utility**: Implement a dedicated command/utility (e.g., `db:setup`) that orchestrates the execution of migrations and seeding, ensuring idempotency.
## 2. API Layer Implementation (API) ## 2. 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. - [ ] 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. - [ ] 2.2 Implement necessary request validation using **Schema-Driven Pipes** that import their structure from `@workspace/schema`, ensuring consistency between API and Web layers.
## 3. Web Layer Implementation (Web) ## 3. 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. - [ ] 3.1 Update `apps/web` store/composables to call the new API endpoints and manage state correctly following successful write or delete operations.
## 4. Testing & Tooling ## 4. Testing & Tooling
- [x] 4.1 Write comprehensive unit tests covering the Controller, the Service, and the Repository layers. - [ ] 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. - [ ] 4.2 Update E2E test suites to verify complete CRUD workflows via HTTP calls and UI interactions, ensuring the service layer is correctly tested against a containerized DB.

View file

@ -7,8 +7,7 @@
"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:*",

View file

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

View file

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

View file

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

View file

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

View file

@ -6,7 +6,6 @@ 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,
@ -15,15 +14,6 @@ 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",

View file

@ -10,43 +10,23 @@
"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 --declaration --emitDeclarationOnly --outDir dist && rolldown -c rolldown.config.ts", "build": "tsc",
"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"
} }
} }

View file

@ -1,17 +0,0 @@
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",
});

View file

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

View file

@ -1,8 +1,9 @@
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];

View file

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

View file

@ -1,11 +1,8 @@
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 { createInsertSchema, createSelectSchema, createUpdateSchema } from "drizzle-valibot"; import { PROJECT_STATUSES } from "./enums";
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",
@ -19,18 +16,3 @@ 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>;

View file

@ -1,9 +1,7 @@
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 { createInsertSchema, createSelectSchema, createUpdateSchema } from "drizzle-valibot"; import { TASK_STATUSES, PRIORITIES } from "./enums";
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(", ");
@ -29,22 +27,3 @@ 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,
});

View file

@ -1,30 +1,17 @@
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,
projectSelect,
projectInsert,
projectUpdate,
type Project,
type ProjectInsertDTO,
type ProjectUpdateDTO,
} from "./drizzle/projects";
export { projectsRelations, tasksRelations } from "./drizzle/relations";
export {
tasks, tasks,
taskSelect, projectsRelations,
taskInsert, tasksRelations,
taskUpdate, } from "./drizzle/index";
type Task,
} from "./drizzle/tasks"; export { projectSelect, projectInsert, projectUpdate, taskSelect, taskInsert, taskUpdate } from "./valibot";
import type { InferOutput } from "valibot";
import { projectSelect, taskSelect } from "./valibot";
export type Project = InferOutput<typeof projectSelect>;
export type Task = InferOutput<typeof taskSelect>;

View file

@ -1,6 +0,0 @@
import { fileURLToPath } from "url";
import { dirname, resolve } from "path";
const __dirname = dirname(fileURLToPath(import.meta.url));
export const migrationsDir = resolve(__dirname, "../drizzle");

View file

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

View file

@ -2,8 +2,7 @@
"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"]

View file

@ -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,
}, },

View file

@ -14,6 +14,7 @@
"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"

File diff suppressed because it is too large Load diff

View file

@ -11,12 +11,11 @@
"dependsOn": ["^lint"] "dependsOn": ["^lint"]
}, },
"check-types": { "check-types": {
"dependsOn": ["^build", "^check-types"] "dependsOn": ["^check-types"]
}, },
"dev": { "dev": {
"cache": false, "cache": false,
"persistent": true, "persistent": true
"dependsOn": ["^build"]
} }
} }
} }