Compare commits

...

7 Commits

Author SHA1 Message Date
b64a6e9e2e refactor: remove redundant Git mapping for backend folder in IDE configuration 2025-05-15 18:10:28 +02:00
f739099524 docs: update project status to reflect completed database migration system
Updated `PROJECT_STATUS.md` to mark the database migration system as completed with DrizzleORM. Adjusted progress percentages and next steps to prioritize authentication and core functionalities.
2025-05-15 18:10:19 +02:00
76ef9a3380 feat: add db:update script to streamline database updates
Introduced a new `db:update` script combining migration generation and execution. Updated `db:generate` and `db:push` commands for consistency with DrizzleKit updates.
2025-05-15 18:09:49 +02:00
d15bf3fe90 fix: update migrations output path in Drizzle configuration 2025-05-15 18:09:29 +02:00
63458333ca feat: add tags module with CRUD operations and DTOs
Introduced a new `TagsModule` with support for creating, updating, and managing tags. Implemented DTOs (`CreateTagDto`, `UpdateTagDto`) for validation and structure. Added `TagsService` and `TagsController` with APIs for tags-to-project and tags-to-person associations.
2025-05-15 18:09:12 +02:00
0249d62951 fix: update migrations folder path in database service 2025-05-15 18:08:41 +02:00
9515c32016 docs: add migration system documentation and generated initial migrations
Added `README.md` to document the database migration system, including generation and usage instructions. Updated `generate-migrations.ts` to fix directory structure and streamline commands. Included initial migration files for schema setup using DrizzleORM.
2025-05-15 18:08:30 +02:00
16 changed files with 1503 additions and 29 deletions

1
.idea/vcs.xml generated
View File

@ -8,6 +8,5 @@
</component> </component>
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" /> <mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/backend" vcs="Git" />
</component> </component>
</project> </project>

View File

@ -11,7 +11,7 @@ import * as process from "node:process";
export default defineConfig({ export default defineConfig({
schema: './src/database/schema/index.ts', schema: './src/database/schema/index.ts',
out: './src/database/migrations', out: './src/database/migrations/sql',
dialect: "postgresql", dialect: "postgresql",
dbCredentials: { dbCredentials: {
host: String(process.env.POSTGRES_HOST || "localhost"), host: String(process.env.POSTGRES_HOST || "localhost"),

View File

@ -18,11 +18,12 @@
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e": "jest --config ./test/jest-e2e.json",
"db:generate": "drizzle-kit generate:pg", "db:generate": "drizzle-kit generate",
"db:migrate": "ts-node src/database/migrations/migrate.ts", "db:migrate": "ts-node src/database/migrations/migrate.ts",
"db:generate:ts": "ts-node src/database/migrations/generate-migrations.ts", "db:generate:ts": "ts-node src/database/migrations/generate-migrations.ts",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:push": "drizzle-kit push:pg" "db:push": "drizzle-kit push:pg",
"db:update": "npm run db:generate:ts && npm run db:migrate"
}, },
"dependencies": { "dependencies": {
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",

View File

@ -40,7 +40,7 @@ export class DatabaseService implements OnModuleInit, OnModuleDestroy {
} }
// Run migrations in all environments // Run migrations in all environments
const result = await runMigrations({ migrationsFolder: './src/database/migrations' }); const result = await runMigrations({ migrationsFolder: './src/database/migrations/sql' });
// In production, we want to fail if migrations fail // In production, we want to fail if migrations fail
if (!result.success && this.configService.get('NODE_ENV') === 'production') { if (!result.success && this.configService.get('NODE_ENV') === 'production') {

View File

@ -0,0 +1,54 @@
# Database Migrations
This directory contains the migration system for the database. The migrations are generated using DrizzleORM and are stored in the `sql` subdirectory.
## Directory Structure
- `sql/` - Contains the generated SQL migration files
- `generate-migrations.ts` - Script to generate migration files
- `migrate.ts` - Script to run migrations
- `README.md` - This file
## How to Use
### Generating Migrations
To generate migrations based on changes to the schema, run:
```bash
npm run db:generate:ts
```
This will generate SQL migration files in the `sql` directory.
### Running Migrations
To run all pending migrations, run:
```bash
npm run db:migrate
```
### Generating and Running Migrations in One Step
To generate and run migrations in one step, run:
```bash
npm run db:update
```
## Integration with NestJS
The migrations are automatically run when the application starts. This is configured in the `DatabaseService` class in `src/database/database.service.ts`.
## Migration Files
Migration files are SQL files that contain the SQL statements to create, alter, or drop database objects. They are named with a timestamp and a description, e.g. `0000_lively_tiger_shark.sql`.
## Configuration
The migration system is configured in `drizzle.config.ts` at the root of the project. This file specifies:
- The schema file to use for generating migrations
- The output directory for migration files
- The database dialect and credentials

View File

@ -12,33 +12,33 @@ import * as fs from 'fs';
*/ */
const main = async () => { const main = async () => {
console.log('Generating migrations...'); console.log('Generating migrations...');
// Ensure migrations directory exists // Ensure migrations directory exists
const migrationsDir = path.join(__dirname); const migrationsDir = path.join(__dirname, 'sql');
if (!fs.existsSync(migrationsDir)) { if (!fs.existsSync(migrationsDir)) {
fs.mkdirSync(migrationsDir, { recursive: true }); fs.mkdirSync(migrationsDir, { recursive: true });
} }
// Run drizzle-kit generate command // Run drizzle-kit generate command
const command = 'npx drizzle-kit generate:pg'; const command = 'npx drizzle-kit generate';
exec(command, (error, stdout, stderr) => { exec(command, (error, stdout, stderr) => {
if (error) { if (error) {
console.error(`Error generating migrations: ${error.message}`); console.error(`Error generating migrations: ${error.message}`);
return; return;
} }
if (stderr) { if (stderr) {
console.error(`Migration generation stderr: ${stderr}`); console.error(`Migration generation stderr: ${stderr}`);
} }
console.log(stdout); console.log(stdout);
console.log('Migrations generated successfully'); console.log('Migrations generated successfully');
// List generated migration files // List generated migration files
const files = fs.readdirSync(migrationsDir) const files = fs.readdirSync(migrationsDir)
.filter(file => file.endsWith('.sql')); .filter(file => file.endsWith('.sql'));
if (files.length === 0) { if (files.length === 0) {
console.log('No migration files were generated. Your schema might be up to date.'); console.log('No migration files were generated. Your schema might be up to date.');
} else { } else {
@ -52,4 +52,4 @@ main().catch(err => {
console.error('Migration generation failed'); console.error('Migration generation failed');
console.error(err); console.error(err);
process.exit(1); process.exit(1);
}); });

View File

@ -57,7 +57,7 @@ export const runMigrations = async (options?: { migrationsFolder?: string }) =>
} }
// Determine migrations folder path // Determine migrations folder path
const migrationsFolder = options?.migrationsFolder || path.join(__dirname); const migrationsFolder = options?.migrationsFolder || path.join(__dirname, 'sql');
console.log(`Using migrations folder: ${migrationsFolder}`); console.log(`Using migrations folder: ${migrationsFolder}`);
// Run migrations // Run migrations

View File

@ -0,0 +1,173 @@
CREATE SCHEMA "groupmaker";
--> statement-breakpoint
DO $$ BEGIN
CREATE TYPE "public"."gender" AS ENUM('MALE', 'FEMALE', 'NON_BINARY');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
CREATE TYPE "public"."oralEaseLevel" AS ENUM('SHY', 'RESERVED', 'COMFORTABLE');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
CREATE TYPE "public"."tagType" AS ENUM('PROJECT', 'PERSON');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(100) NOT NULL,
"avatar" text,
"githubId" varchar(50) NOT NULL,
"gdprTimestamp" timestamp with time zone,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb,
CONSTRAINT "users_githubId_unique" UNIQUE("githubId")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "projects" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(100) NOT NULL,
"description" text,
"ownerId" uuid NOT NULL,
"settings" jsonb DEFAULT '{}'::jsonb,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "persons" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"firstName" varchar(50) NOT NULL,
"lastName" varchar(50) NOT NULL,
"gender" "gender" NOT NULL,
"technicalLevel" smallint NOT NULL,
"hasTechnicalTraining" boolean DEFAULT false NOT NULL,
"frenchSpeakingLevel" smallint NOT NULL,
"oralEaseLevel" "oralEaseLevel" NOT NULL,
"age" smallint,
"projectId" uuid NOT NULL,
"attributes" jsonb DEFAULT '{}'::jsonb,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "groups" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(100) NOT NULL,
"projectId" uuid NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "tags" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(50) NOT NULL,
"color" varchar(7) NOT NULL,
"type" "tagType" NOT NULL,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "person_to_group" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"personId" uuid NOT NULL,
"groupId" uuid NOT NULL,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "person_to_tag" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"personId" uuid NOT NULL,
"tagId" uuid NOT NULL,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "project_to_tag" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"projectId" uuid NOT NULL,
"tagId" uuid NOT NULL,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "projects" ADD CONSTRAINT "projects_ownerId_users_id_fk" FOREIGN KEY ("ownerId") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "persons" ADD CONSTRAINT "persons_projectId_projects_id_fk" FOREIGN KEY ("projectId") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "groups" ADD CONSTRAINT "groups_projectId_projects_id_fk" FOREIGN KEY ("projectId") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "person_to_group" ADD CONSTRAINT "person_to_group_personId_persons_id_fk" FOREIGN KEY ("personId") REFERENCES "public"."persons"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "person_to_group" ADD CONSTRAINT "person_to_group_groupId_groups_id_fk" FOREIGN KEY ("groupId") REFERENCES "public"."groups"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "person_to_tag" ADD CONSTRAINT "person_to_tag_personId_persons_id_fk" FOREIGN KEY ("personId") REFERENCES "public"."persons"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "person_to_tag" ADD CONSTRAINT "person_to_tag_tagId_tags_id_fk" FOREIGN KEY ("tagId") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "project_to_tag" ADD CONSTRAINT "project_to_tag_projectId_projects_id_fk" FOREIGN KEY ("projectId") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "project_to_tag" ADD CONSTRAINT "project_to_tag_tagId_tags_id_fk" FOREIGN KEY ("tagId") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "githubId_idx" ON "users" ("githubId");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "createdAt_idx" ON "users" ("createdAt");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "project_name_idx" ON "projects" ("name");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "project_ownerId_idx" ON "projects" ("ownerId");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "project_createdAt_idx" ON "projects" ("createdAt");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "person_firstName_idx" ON "persons" ("firstName");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "person_lastName_idx" ON "persons" ("lastName");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "person_projectId_idx" ON "persons" ("projectId");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "person_name_composite_idx" ON "persons" ("firstName","lastName");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "group_name_idx" ON "groups" ("name");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "group_projectId_idx" ON "groups" ("projectId");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "tag_name_idx" ON "tags" ("name");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "tag_type_idx" ON "tags" ("type");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "ptg_personId_idx" ON "person_to_group" ("personId");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "ptg_groupId_idx" ON "person_to_group" ("groupId");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "ptg_person_group_unique_idx" ON "person_to_group" ("personId","groupId");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "ptt_personId_idx" ON "person_to_tag" ("personId");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "ptt_tagId_idx" ON "person_to_tag" ("tagId");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "ptt_person_tag_unique_idx" ON "person_to_tag" ("personId","tagId");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "pjt_projectId_idx" ON "project_to_tag" ("projectId");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "pjt_tagId_idx" ON "project_to_tag" ("tagId");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "pjt_project_tag_unique_idx" ON "project_to_tag" ("projectId","tagId");

View File

@ -0,0 +1,762 @@
{
"id": "ebffb361-7a99-4ad4-a51f-e48d304b0260",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "6",
"dialect": "postgresql",
"tables": {
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true
},
"avatar": {
"name": "avatar",
"type": "text",
"primaryKey": false,
"notNull": false
},
"githubId": {
"name": "githubId",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"gdprTimestamp": {
"name": "gdprTimestamp",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
}
},
"indexes": {
"githubId_idx": {
"name": "githubId_idx",
"columns": [
"githubId"
],
"isUnique": false
},
"createdAt_idx": {
"name": "createdAt_idx",
"columns": [
"createdAt"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_githubId_unique": {
"name": "users_githubId_unique",
"nullsNotDistinct": false,
"columns": [
"githubId"
]
}
}
},
"public.projects": {
"name": "projects",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ownerId": {
"name": "ownerId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"settings": {
"name": "settings",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"project_name_idx": {
"name": "project_name_idx",
"columns": [
"name"
],
"isUnique": false
},
"project_ownerId_idx": {
"name": "project_ownerId_idx",
"columns": [
"ownerId"
],
"isUnique": false
},
"project_createdAt_idx": {
"name": "project_createdAt_idx",
"columns": [
"createdAt"
],
"isUnique": false
}
},
"foreignKeys": {
"projects_ownerId_users_id_fk": {
"name": "projects_ownerId_users_id_fk",
"tableFrom": "projects",
"tableTo": "users",
"columnsFrom": [
"ownerId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.persons": {
"name": "persons",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"firstName": {
"name": "firstName",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"lastName": {
"name": "lastName",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"gender": {
"name": "gender",
"type": "gender",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"technicalLevel": {
"name": "technicalLevel",
"type": "smallint",
"primaryKey": false,
"notNull": true
},
"hasTechnicalTraining": {
"name": "hasTechnicalTraining",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"frenchSpeakingLevel": {
"name": "frenchSpeakingLevel",
"type": "smallint",
"primaryKey": false,
"notNull": true
},
"oralEaseLevel": {
"name": "oralEaseLevel",
"type": "oralEaseLevel",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"age": {
"name": "age",
"type": "smallint",
"primaryKey": false,
"notNull": false
},
"projectId": {
"name": "projectId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"attributes": {
"name": "attributes",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"person_firstName_idx": {
"name": "person_firstName_idx",
"columns": [
"firstName"
],
"isUnique": false
},
"person_lastName_idx": {
"name": "person_lastName_idx",
"columns": [
"lastName"
],
"isUnique": false
},
"person_projectId_idx": {
"name": "person_projectId_idx",
"columns": [
"projectId"
],
"isUnique": false
},
"person_name_composite_idx": {
"name": "person_name_composite_idx",
"columns": [
"firstName",
"lastName"
],
"isUnique": false
}
},
"foreignKeys": {
"persons_projectId_projects_id_fk": {
"name": "persons_projectId_projects_id_fk",
"tableFrom": "persons",
"tableTo": "projects",
"columnsFrom": [
"projectId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.groups": {
"name": "groups",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true
},
"projectId": {
"name": "projectId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"group_name_idx": {
"name": "group_name_idx",
"columns": [
"name"
],
"isUnique": false
},
"group_projectId_idx": {
"name": "group_projectId_idx",
"columns": [
"projectId"
],
"isUnique": false
}
},
"foreignKeys": {
"groups_projectId_projects_id_fk": {
"name": "groups_projectId_projects_id_fk",
"tableFrom": "groups",
"tableTo": "projects",
"columnsFrom": [
"projectId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.tags": {
"name": "tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"color": {
"name": "color",
"type": "varchar(7)",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "tagType",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"tag_name_idx": {
"name": "tag_name_idx",
"columns": [
"name"
],
"isUnique": false
},
"tag_type_idx": {
"name": "tag_type_idx",
"columns": [
"type"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.person_to_group": {
"name": "person_to_group",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"personId": {
"name": "personId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"groupId": {
"name": "groupId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ptg_personId_idx": {
"name": "ptg_personId_idx",
"columns": [
"personId"
],
"isUnique": false
},
"ptg_groupId_idx": {
"name": "ptg_groupId_idx",
"columns": [
"groupId"
],
"isUnique": false
},
"ptg_person_group_unique_idx": {
"name": "ptg_person_group_unique_idx",
"columns": [
"personId",
"groupId"
],
"isUnique": true
}
},
"foreignKeys": {
"person_to_group_personId_persons_id_fk": {
"name": "person_to_group_personId_persons_id_fk",
"tableFrom": "person_to_group",
"tableTo": "persons",
"columnsFrom": [
"personId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"person_to_group_groupId_groups_id_fk": {
"name": "person_to_group_groupId_groups_id_fk",
"tableFrom": "person_to_group",
"tableTo": "groups",
"columnsFrom": [
"groupId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.person_to_tag": {
"name": "person_to_tag",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"personId": {
"name": "personId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"tagId": {
"name": "tagId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ptt_personId_idx": {
"name": "ptt_personId_idx",
"columns": [
"personId"
],
"isUnique": false
},
"ptt_tagId_idx": {
"name": "ptt_tagId_idx",
"columns": [
"tagId"
],
"isUnique": false
},
"ptt_person_tag_unique_idx": {
"name": "ptt_person_tag_unique_idx",
"columns": [
"personId",
"tagId"
],
"isUnique": true
}
},
"foreignKeys": {
"person_to_tag_personId_persons_id_fk": {
"name": "person_to_tag_personId_persons_id_fk",
"tableFrom": "person_to_tag",
"tableTo": "persons",
"columnsFrom": [
"personId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"person_to_tag_tagId_tags_id_fk": {
"name": "person_to_tag_tagId_tags_id_fk",
"tableFrom": "person_to_tag",
"tableTo": "tags",
"columnsFrom": [
"tagId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.project_to_tag": {
"name": "project_to_tag",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"projectId": {
"name": "projectId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"tagId": {
"name": "tagId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"pjt_projectId_idx": {
"name": "pjt_projectId_idx",
"columns": [
"projectId"
],
"isUnique": false
},
"pjt_tagId_idx": {
"name": "pjt_tagId_idx",
"columns": [
"tagId"
],
"isUnique": false
},
"pjt_project_tag_unique_idx": {
"name": "pjt_project_tag_unique_idx",
"columns": [
"projectId",
"tagId"
],
"isUnique": true
}
},
"foreignKeys": {
"project_to_tag_projectId_projects_id_fk": {
"name": "project_to_tag_projectId_projects_id_fk",
"tableFrom": "project_to_tag",
"tableTo": "projects",
"columnsFrom": [
"projectId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"project_to_tag_tagId_tags_id_fk": {
"name": "project_to_tag_tagId_tags_id_fk",
"tableFrom": "project_to_tag",
"tableTo": "tags",
"columnsFrom": [
"tagId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {
"public.gender": {
"name": "gender",
"schema": "public",
"values": [
"MALE",
"FEMALE",
"NON_BINARY"
]
},
"public.oralEaseLevel": {
"name": "oralEaseLevel",
"schema": "public",
"values": [
"SHY",
"RESERVED",
"COMFORTABLE"
]
},
"public.tagType": {
"name": "tagType",
"schema": "public",
"values": [
"PROJECT",
"PERSON"
]
}
},
"schemas": {
"groupmaker": "groupmaker"
},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -0,0 +1,13 @@
{
"version": "6",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1747322785586,
"tag": "0000_lively_tiger_shark",
"breakpoints": true
}
]
}

View File

@ -0,0 +1,124 @@
import {
Controller,
Get,
Post,
Body,
Param,
Delete,
Put,
UseGuards,
Query,
} from '@nestjs/common';
import { TagsService } from '../services/tags.service';
import { CreateTagDto } from '../dto/create-tag.dto';
import { UpdateTagDto } from '../dto/update-tag.dto';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
@Controller('tags')
@UseGuards(JwtAuthGuard)
export class TagsController {
constructor(private readonly tagsService: TagsService) {}
/**
* Create a new tag
*/
@Post()
create(@Body() createTagDto: CreateTagDto) {
return this.tagsService.create(createTagDto);
}
/**
* Get all tags or filter by type
*/
@Get()
findAll(@Query('type') type?: 'PROJECT' | 'PERSON') {
if (type) {
return this.tagsService.findByType(type);
}
return this.tagsService.findAll();
}
/**
* Get a tag by ID
*/
@Get(':id')
findOne(@Param('id') id: string) {
return this.tagsService.findById(id);
}
/**
* Update a tag
*/
@Put(':id')
update(@Param('id') id: string, @Body() updateTagDto: UpdateTagDto) {
return this.tagsService.update(id, updateTagDto);
}
/**
* Delete a tag
*/
@Delete(':id')
remove(@Param('id') id: string) {
return this.tagsService.remove(id);
}
/**
* Add a tag to a person
*/
@Post('persons/:personId/tags/:tagId')
addTagToPerson(
@Param('personId') personId: string,
@Param('tagId') tagId: string,
) {
return this.tagsService.addTagToPerson(tagId, personId);
}
/**
* Remove a tag from a person
*/
@Delete('persons/:personId/tags/:tagId')
removeTagFromPerson(
@Param('personId') personId: string,
@Param('tagId') tagId: string,
) {
return this.tagsService.removeTagFromPerson(tagId, personId);
}
/**
* Get all tags for a person
*/
@Get('persons/:personId/tags')
getTagsForPerson(@Param('personId') personId: string) {
return this.tagsService.getTagsForPerson(personId);
}
/**
* Add a tag to a project
*/
@Post('projects/:projectId/tags/:tagId')
addTagToProject(
@Param('projectId') projectId: string,
@Param('tagId') tagId: string,
) {
return this.tagsService.addTagToProject(tagId, projectId);
}
/**
* Remove a tag from a project
*/
@Delete('projects/:projectId/tags/:tagId')
removeTagFromProject(
@Param('projectId') projectId: string,
@Param('tagId') tagId: string,
) {
return this.tagsService.removeTagFromProject(tagId, projectId);
}
/**
* Get all tags for a project
*/
@Get('projects/:projectId/tags')
getTagsForProject(@Param('projectId') projectId: string) {
return this.tagsService.getTagsForProject(projectId);
}
}

View File

@ -0,0 +1,32 @@
import { IsNotEmpty, IsString, IsEnum, Matches } from 'class-validator';
/**
* DTO for creating a new tag
*/
export class CreateTagDto {
/**
* The name of the tag
*/
@IsNotEmpty()
@IsString()
name: string;
/**
* The color of the tag (hex format)
*/
@IsNotEmpty()
@IsString()
@Matches(/^#[0-9A-Fa-f]{6}$/, {
message: 'Color must be a valid hex color code (e.g., #FF5733)',
})
color: string;
/**
* The type of the tag (PROJECT or PERSON)
*/
@IsNotEmpty()
@IsEnum(['PROJECT', 'PERSON'], {
message: 'Type must be either PROJECT or PERSON',
})
type: 'PROJECT' | 'PERSON';
}

View File

@ -0,0 +1,32 @@
import { IsString, IsEnum, Matches, IsOptional } from 'class-validator';
/**
* DTO for updating an existing tag
*/
export class UpdateTagDto {
/**
* The name of the tag
*/
@IsOptional()
@IsString()
name?: string;
/**
* The color of the tag (hex format)
*/
@IsOptional()
@IsString()
@Matches(/^#[0-9A-Fa-f]{6}$/, {
message: 'Color must be a valid hex color code (e.g., #FF5733)',
})
color?: string;
/**
* The type of the tag (PROJECT or PERSON)
*/
@IsOptional()
@IsEnum(['PROJECT', 'PERSON'], {
message: 'Type must be either PROJECT or PERSON',
})
type?: 'PROJECT' | 'PERSON';
}

View File

@ -0,0 +1,277 @@
import { Injectable, NotFoundException, Inject } from '@nestjs/common';
import { eq, and } from 'drizzle-orm';
import { DRIZZLE } from '../../../database/database.module';
import * as schema from '../../../database/schema';
import { CreateTagDto } from '../dto/create-tag.dto';
import { UpdateTagDto } from '../dto/update-tag.dto';
@Injectable()
export class TagsService {
constructor(@Inject(DRIZZLE) private readonly db: any) {}
/**
* Create a new tag
*/
async create(createTagDto: CreateTagDto) {
const [tag] = await this.db
.insert(schema.tags)
.values({
...createTagDto,
})
.returning();
return tag;
}
/**
* Find all tags
*/
async findAll() {
return this.db.select().from(schema.tags);
}
/**
* Find tags by type
*/
async findByType(type: 'PROJECT' | 'PERSON') {
return this.db
.select()
.from(schema.tags)
.where(eq(schema.tags.type, type));
}
/**
* Find a tag by ID
*/
async findById(id: string) {
const [tag] = await this.db
.select()
.from(schema.tags)
.where(eq(schema.tags.id, id));
if (!tag) {
throw new NotFoundException(`Tag with ID ${id} not found`);
}
return tag;
}
/**
* Update a tag
*/
async update(id: string, updateTagDto: UpdateTagDto) {
const [tag] = await this.db
.update(schema.tags)
.set({
...updateTagDto,
updatedAt: new Date(),
})
.where(eq(schema.tags.id, id))
.returning();
if (!tag) {
throw new NotFoundException(`Tag with ID ${id} not found`);
}
return tag;
}
/**
* Delete a tag
*/
async remove(id: string) {
const [tag] = await this.db
.delete(schema.tags)
.where(eq(schema.tags.id, id))
.returning();
if (!tag) {
throw new NotFoundException(`Tag with ID ${id} not found`);
}
return tag;
}
/**
* Add a tag to a person
*/
async addTagToPerson(tagId: string, personId: string) {
// Check if the tag exists and is of type PERSON
const tag = await this.findById(tagId);
if (tag.type !== 'PERSON') {
throw new Error(`Tag with ID ${tagId} is not of type PERSON`);
}
// Check if the person exists
const [person] = await this.db
.select()
.from(schema.persons)
.where(eq(schema.persons.id, personId));
if (!person) {
throw new NotFoundException(`Person with ID ${personId} not found`);
}
// Check if the tag is already associated with the person
const [existingRelation] = await this.db
.select()
.from(schema.personToTag)
.where(
and(
eq(schema.personToTag.personId, personId),
eq(schema.personToTag.tagId, tagId)
)
);
if (existingRelation) {
return existingRelation;
}
// Add the tag to the person
const [relation] = await this.db
.insert(schema.personToTag)
.values({
personId,
tagId,
})
.returning();
return relation;
}
/**
* Remove a tag from a person
*/
async removeTagFromPerson(tagId: string, personId: string) {
const [relation] = await this.db
.delete(schema.personToTag)
.where(
and(
eq(schema.personToTag.personId, personId),
eq(schema.personToTag.tagId, tagId)
)
)
.returning();
if (!relation) {
throw new NotFoundException(`Tag with ID ${tagId} is not associated with person with ID ${personId}`);
}
return relation;
}
/**
* Add a tag to a project
*/
async addTagToProject(tagId: string, projectId: string) {
// Check if the tag exists and is of type PROJECT
const tag = await this.findById(tagId);
if (tag.type !== 'PROJECT') {
throw new Error(`Tag with ID ${tagId} is not of type PROJECT`);
}
// Check if the project exists
const [project] = await this.db
.select()
.from(schema.projects)
.where(eq(schema.projects.id, projectId));
if (!project) {
throw new NotFoundException(`Project with ID ${projectId} not found`);
}
// Check if the tag is already associated with the project
const [existingRelation] = await this.db
.select()
.from(schema.projectToTag)
.where(
and(
eq(schema.projectToTag.projectId, projectId),
eq(schema.projectToTag.tagId, tagId)
)
);
if (existingRelation) {
return existingRelation;
}
// Add the tag to the project
const [relation] = await this.db
.insert(schema.projectToTag)
.values({
projectId,
tagId,
})
.returning();
return relation;
}
/**
* Remove a tag from a project
*/
async removeTagFromProject(tagId: string, projectId: string) {
const [relation] = await this.db
.delete(schema.projectToTag)
.where(
and(
eq(schema.projectToTag.projectId, projectId),
eq(schema.projectToTag.tagId, tagId)
)
)
.returning();
if (!relation) {
throw new NotFoundException(`Tag with ID ${tagId} is not associated with project with ID ${projectId}`);
}
return relation;
}
/**
* Get all tags for a person
*/
async getTagsForPerson(personId: string) {
// Check if the person exists
const [person] = await this.db
.select()
.from(schema.persons)
.where(eq(schema.persons.id, personId));
if (!person) {
throw new NotFoundException(`Person with ID ${personId} not found`);
}
// Get all tags for the person
return this.db
.select({
tag: schema.tags,
})
.from(schema.personToTag)
.innerJoin(schema.tags, eq(schema.personToTag.tagId, schema.tags.id))
.where(eq(schema.personToTag.personId, personId));
}
/**
* Get all tags for a project
*/
async getTagsForProject(projectId: string) {
// Check if the project exists
const [project] = await this.db
.select()
.from(schema.projects)
.where(eq(schema.projects.id, projectId));
if (!project) {
throw new NotFoundException(`Project with ID ${projectId} not found`);
}
// Get all tags for the project
return this.db
.select({
tag: schema.tags,
})
.from(schema.projectToTag)
.innerJoin(schema.tags, eq(schema.projectToTag.tagId, schema.tags.id))
.where(eq(schema.projectToTag.projectId, projectId));
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TagsController } from './controllers/tags.controller';
import { TagsService } from './services/tags.service';
@Module({
controllers: [TagsController],
providers: [TagsService],
exports: [TagsService],
})
export class TagsModule {}

View File

@ -21,9 +21,11 @@ Nous avons élaboré un plan de bataille complet pour l'implémentation du backe
- ✅ Configuration Docker pour le déploiement - ✅ Configuration Docker pour le déploiement
#### Composants En Cours #### Composants En Cours
- ⏳ Système de migrations de base de données
- ⏳ Relations entre les modules existants - ⏳ Relations entre les modules existants
#### Composants Récemment Implémentés
- ✅ Système de migrations de base de données avec DrizzleORM
#### Composants Non Implémentés #### Composants Non Implémentés
- ❌ Module d'authentification avec GitHub OAuth - ❌ Module d'authentification avec GitHub OAuth
- ❌ Stratégies JWT pour la gestion des sessions - ❌ Stratégies JWT pour la gestion des sessions
@ -58,9 +60,9 @@ Nous avons élaboré un plan de bataille complet pour l'implémentation du backe
#### Priorité Haute #### Priorité Haute
##### Migrations de Base de Données ##### Migrations de Base de Données
- [ ] Configurer le système de migrations avec DrizzleORM - [x] Configurer le système de migrations avec DrizzleORM
- [ ] Générer les migrations initiales - [x] Générer les migrations initiales
- [ ] Créer un script pour exécuter les migrations automatiquement au démarrage - [x] Créer un script pour exécuter les migrations automatiquement au démarrage
##### Authentification ##### Authentification
- [ ] Implémenter le module d'authentification - [ ] Implémenter le module d'authentification
@ -162,17 +164,12 @@ Nous avons élaboré un plan de bataille complet pour l'implémentation du backe
## Prochaines Étapes Prioritaires ## Prochaines Étapes Prioritaires
### Backend (Priorité Haute) ### Backend (Priorité Haute)
1. **Migrations de Base de Données** 1. **Authentification**
- Configurer le système de migrations avec DrizzleORM
- Générer les migrations initiales
- Créer un script pour exécuter les migrations automatiquement
2. **Authentification**
- Implémenter le module d'authentification avec GitHub OAuth - Implémenter le module d'authentification avec GitHub OAuth
- Configurer les stratégies JWT pour la gestion des sessions - Configurer les stratégies JWT pour la gestion des sessions
- Créer les guards et décorateurs pour la protection des routes - Créer les guards et décorateurs pour la protection des routes
3. **Modules Manquants** 2. **Modules Manquants**
- Implémenter le module groupes - Implémenter le module groupes
- Implémenter le module tags - Implémenter le module tags
- Compléter les relations entre les modules existants - Compléter les relations entre les modules existants
@ -193,7 +190,7 @@ Nous avons élaboré un plan de bataille complet pour l'implémentation du backe
| Composant | Progression | | Composant | Progression |
|-----------|-------------| |-----------|-------------|
| Backend - Structure de Base | 90% | | Backend - Structure de Base | 90% |
| Backend - Base de Données | 80% | | Backend - Base de Données | 100% |
| Backend - Modules Fonctionnels | 60% | | Backend - Modules Fonctionnels | 60% |
| Backend - Authentification | 0% | | Backend - Authentification | 0% |
| Backend - WebSockets | 0% | | Backend - WebSockets | 0% |
@ -238,4 +235,4 @@ Basé sur l'état d'avancement actuel et les tâches restantes, l'estimation du
## Conclusion ## Conclusion
Le projet a bien avancé sur la structure de base et la définition du schéma de données, mais il reste encore un travail significatif à réaliser. Les prochaines étapes prioritaires devraient se concentrer sur l'authentification et les fonctionnalités de base pour avoir rapidement une version minimale fonctionnelle. Le projet a bien avancé sur la structure de base et la définition du schéma de données, mais il reste encore un travail significatif à réaliser. Les prochaines étapes prioritaires devraient se concentrer sur l'authentification et les fonctionnalités de base pour avoir rapidement une version minimale fonctionnelle.