feat: implement authentication and database modules with relations and group management

Added new authentication strategies (JWT and GitHub OAuth), guards, and controllers. Implemented database module, schema with relations, and group management features, including CRD operations and person-to-group associations. Integrated validation and CORS configuration.
This commit is contained in:
2025-05-15 17:09:36 +02:00
parent f6f0888bd7
commit 9f99b80784
63 changed files with 2838 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
import { Module, Global } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import * as schema from './schema';
import { DatabaseService } from './database.service';
export const DATABASE_POOL = 'DATABASE_POOL';
export const DRIZZLE = 'DRIZZLE';
@Global()
@Module({
imports: [ConfigModule],
providers: [
DatabaseService,
{
provide: DATABASE_POOL,
useFactory: (databaseService: DatabaseService) => {
return databaseService.getPool();
},
inject: [DatabaseService],
},
{
provide: DRIZZLE,
useFactory: (databaseService: DatabaseService) => {
return databaseService.getDb();
},
inject: [DatabaseService],
},
],
exports: [DatabaseService, DATABASE_POOL, DRIZZLE],
})
export class DatabaseModule {}

View File

@@ -0,0 +1,97 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
import { runMigrations } from './migrations/migrate';
@Injectable()
export class DatabaseService implements OnModuleInit, OnModuleDestroy {
private readonly pool: Pool;
private readonly db: ReturnType<typeof drizzle>;
constructor(private configService: ConfigService) {
// Create the PostgreSQL pool
const connectionString = this.getDatabaseConnectionString();
this.pool = new Pool({
connectionString,
});
// Create the Drizzle ORM instance
this.db = drizzle(this.pool, { schema });
}
async onModuleInit() {
// Log database connection
console.log('Connecting to database...');
// Test the connection
try {
const client = await this.pool.connect();
try {
await client.query('SELECT NOW()');
console.log('Database connection established successfully');
} finally {
client.release();
}
} catch (error) {
console.error('Failed to connect to database:', error.message);
throw error;
}
// Run migrations in all environments
const result = await runMigrations({ migrationsFolder: './src/database/migrations' });
// In production, we want to fail if migrations fail
if (!result.success && this.configService.get('NODE_ENV') === 'production') {
throw result.error;
}
}
async onModuleDestroy() {
// Close the database connection
await this.pool.end();
console.log('Database connection closed');
}
// Get the database connection string from environment variables
private getDatabaseConnectionString(): string {
// First try to get the full DATABASE_URL
const databaseUrl = this.configService.get<string>('DATABASE_URL');
if (databaseUrl) {
return databaseUrl;
}
// If DATABASE_URL is not provided, construct it from individual variables
const password = this.configService.get<string>('POSTGRES_PASSWORD');
const username = this.configService.get<string>('POSTGRES_USER');
const host = this.configService.get<string>('POSTGRES_HOST');
const port = this.configService.get<string>('POSTGRES_PORT');
const database = this.configService.get<string>('POSTGRES_DB');
const missingVars: string[] = [];
if (!password) missingVars.push('POSTGRES_PASSWORD');
if (!username) missingVars.push('POSTGRES_USER');
if (!host) missingVars.push('POSTGRES_HOST');
if (!port) missingVars.push('POSTGRES_PORT');
if (!database) missingVars.push('POSTGRES_DB');
if (missingVars.length > 0) {
throw new Error(
`Database configuration is missing. Missing variables: ${missingVars.join(', ')}. Please check your .env file.`,
);
}
return `postgres://${username}:${password}@${host}:${port}/${database}`;
}
// Get the Drizzle ORM instance
getDb() {
return this.db;
}
// Get the PostgreSQL pool
getPool() {
return this.pool;
}
}

View File

@@ -0,0 +1,55 @@
import { exec } from 'child_process';
import * as path from 'path';
import * as fs from 'fs';
/**
* Script to generate migrations using drizzle-kit
*
* This script:
* 1. Runs drizzle-kit generate to create migration files
* 2. Ensures the migrations directory exists
* 3. Handles errors and provides feedback
*/
const main = async () => {
console.log('Generating migrations...');
// Ensure migrations directory exists
const migrationsDir = path.join(__dirname);
if (!fs.existsSync(migrationsDir)) {
fs.mkdirSync(migrationsDir, { recursive: true });
}
// Run drizzle-kit generate command
const command = 'npx drizzle-kit generate:pg';
exec(command, (error, stdout, stderr) => {
if (error) {
console.error(`Error generating migrations: ${error.message}`);
return;
}
if (stderr) {
console.error(`Migration generation stderr: ${stderr}`);
}
console.log(stdout);
console.log('Migrations generated successfully');
// List generated migration files
const files = fs.readdirSync(migrationsDir)
.filter(file => file.endsWith('.sql'));
if (files.length === 0) {
console.log('No migration files were generated. Your schema might be up to date.');
} else {
console.log('Generated migration files:');
files.forEach(file => console.log(`- ${file}`));
}
});
};
main().catch(err => {
console.error('Migration generation failed');
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,91 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { Pool } from 'pg';
import * as dotenv from 'dotenv';
import * as path from 'path';
import * as schema from '../schema';
/**
* Script to run database migrations
*
* This script:
* 1. Establishes a connection to the PostgreSQL database
* 2. Creates a Drizzle ORM instance
* 3. Runs all pending migrations
*
* It can be used:
* - As a standalone script: `node dist/database/migrations/migrate.js`
* - Integrated with NestJS application lifecycle in database.service.ts
*/
// Load environment variables
dotenv.config();
export const runMigrations = async (options?: { migrationsFolder?: string }) => {
// First try to get the full DATABASE_URL
let connectionString = process.env.DATABASE_URL;
// If DATABASE_URL is not provided, construct it from individual variables
if (!connectionString) {
const password = process.env.POSTGRES_PASSWORD || 'postgres';
const username = process.env.POSTGRES_USER || 'postgres';
const host = process.env.POSTGRES_HOST || 'localhost';
const port = Number(process.env.POSTGRES_PORT || 5432);
const database = process.env.POSTGRES_DB || 'groupmaker';
connectionString = `postgres://${username}:${password}@${host}:${port}/${database}`;
}
// Create the PostgreSQL pool
const pool = new Pool({
connectionString,
});
// Create the Drizzle ORM instance
const db = drizzle(pool, { schema });
console.log('Running migrations...');
try {
// Test the connection
const client = await pool.connect();
try {
await client.query('SELECT NOW()');
console.log('Database connection established successfully');
} finally {
client.release();
}
// Determine migrations folder path
const migrationsFolder = options?.migrationsFolder || path.join(__dirname);
console.log(`Using migrations folder: ${migrationsFolder}`);
// Run migrations
await migrate(db, { migrationsFolder });
console.log('Migrations completed successfully');
return { success: true };
} catch (error) {
console.error('Migration failed');
console.error(error);
return { success: false, error };
} finally {
await pool.end();
console.log('Database connection closed');
}
};
// Run migrations if this script is executed directly
if (require.main === module) {
runMigrations()
.then(result => {
if (!result.success) {
process.exit(1);
}
})
.catch(err => {
console.error('Migration failed');
console.error(err);
process.exit(1);
});
}

View File

@@ -0,0 +1,17 @@
/*
* Copyright (C) 2025 Yidhra Studio. - All Rights Reserved
* Updated : 25/04/2025 10:33
*
* Unauthorized copying or redistribution of this file in source and binary forms via any medium
* is strictly prohibited.
*/
import { pgSchema } from "drizzle-orm/pg-core";
/**
* Defines the PostgreSQL schema for the application.
* All database tables are created within this schema namespace.
* The schema name "bypass" is used to isolate the application's tables
* from other applications that might share the same database.
*/
export const DbSchema = pgSchema("groupmaker");

View File

@@ -0,0 +1,16 @@
import { pgEnum } from 'drizzle-orm/pg-core';
/**
* Enum for gender values
*/
export const gender = pgEnum('gender', ['MALE', 'FEMALE', 'NON_BINARY']);
/**
* Enum for oral ease level values
*/
export const oralEaseLevel = pgEnum('oralEaseLevel', ['SHY', 'RESERVED', 'COMFORTABLE']);
/**
* Enum for tag types
*/
export const tagType = pgEnum('tagType', ['PROJECT', 'PERSON']);

View File

@@ -0,0 +1,25 @@
import { pgTable, uuid, varchar, timestamp, jsonb, index } from 'drizzle-orm/pg-core';
import { projects } from './projects';
/**
* Groups table schema
*/
export const groups = pgTable('groups', {
id: uuid('id').primaryKey().defaultRandom(),
name: varchar('name', { length: 100 }).notNull(),
projectId: uuid('projectId').notNull().references(() => projects.id, { onDelete: 'cascade' }),
metadata: jsonb('metadata').default({}),
createdAt: timestamp('createdAt', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updatedAt', { withTimezone: true }).defaultNow().notNull()
}, (table) => {
return {
nameIdx: index('group_name_idx').on(table.name),
projectIdIdx: index('group_projectId_idx').on(table.projectId)
};
});
/**
* Group type definitions
*/
export type Group = typeof groups.$inferSelect;
export type NewGroup = typeof groups.$inferInsert;

View File

@@ -0,0 +1,23 @@
/**
* This file serves as the main entry point for the database schema definitions.
* It exports all schema definitions from various modules.
*/
// Export schema
export * from './db-schema';
// Export enums
export * from './enums';
// Export tables
export * from './users';
export * from './projects';
export * from './persons';
export * from './groups';
export * from './tags';
export * from './personToGroup';
export * from './personToTag';
export * from './projectToTag';
// Export relations
export * from './relations';

View File

@@ -0,0 +1,25 @@
import { pgTable, uuid, timestamp, index, uniqueIndex } from 'drizzle-orm/pg-core';
import { persons } from './persons';
import { groups } from './groups';
/**
* Person to Group relation table schema
*/
export const personToGroup = pgTable('person_to_group', {
id: uuid('id').primaryKey().defaultRandom(),
personId: uuid('personId').notNull().references(() => persons.id, { onDelete: 'cascade' }),
groupId: uuid('groupId').notNull().references(() => groups.id, { onDelete: 'cascade' }),
createdAt: timestamp('createdAt', { withTimezone: true }).defaultNow().notNull()
}, (table) => {
return {
personIdIdx: index('ptg_personId_idx').on(table.personId),
groupIdIdx: index('ptg_groupId_idx').on(table.groupId),
personGroupUniqueIdx: uniqueIndex('ptg_person_group_unique_idx').on(table.personId, table.groupId)
};
});
/**
* PersonToGroup type definitions
*/
export type PersonToGroup = typeof personToGroup.$inferSelect;
export type NewPersonToGroup = typeof personToGroup.$inferInsert;

View File

@@ -0,0 +1,25 @@
import { pgTable, uuid, timestamp, index, uniqueIndex } from 'drizzle-orm/pg-core';
import { persons } from './persons';
import { tags } from './tags';
/**
* Person to Tag relation table schema
*/
export const personToTag = pgTable('person_to_tag', {
id: uuid('id').primaryKey().defaultRandom(),
personId: uuid('personId').notNull().references(() => persons.id, { onDelete: 'cascade' }),
tagId: uuid('tagId').notNull().references(() => tags.id, { onDelete: 'cascade' }),
createdAt: timestamp('createdAt', { withTimezone: true }).defaultNow().notNull()
}, (table) => {
return {
personIdIdx: index('ptt_personId_idx').on(table.personId),
tagIdIdx: index('ptt_tagId_idx').on(table.tagId),
personTagUniqueIdx: uniqueIndex('ptt_person_tag_unique_idx').on(table.personId, table.tagId)
};
});
/**
* PersonToTag type definitions
*/
export type PersonToTag = typeof personToTag.$inferSelect;
export type NewPersonToTag = typeof personToTag.$inferInsert;

View File

@@ -0,0 +1,35 @@
import { pgTable, uuid, varchar, smallint, boolean, timestamp, jsonb, index } from 'drizzle-orm/pg-core';
import { projects } from './projects';
import { gender, oralEaseLevel } from './enums';
/**
* Persons table schema
*/
export const persons = pgTable('persons', {
id: uuid('id').primaryKey().defaultRandom(),
firstName: varchar('firstName', { length: 50 }).notNull(),
lastName: varchar('lastName', { length: 50 }).notNull(),
gender: gender('gender').notNull(),
technicalLevel: smallint('technicalLevel').notNull(),
hasTechnicalTraining: boolean('hasTechnicalTraining').notNull().default(false),
frenchSpeakingLevel: smallint('frenchSpeakingLevel').notNull(),
oralEaseLevel: oralEaseLevel('oralEaseLevel').notNull(),
age: smallint('age'),
projectId: uuid('projectId').notNull().references(() => projects.id, { onDelete: 'cascade' }),
attributes: jsonb('attributes').default({}),
createdAt: timestamp('createdAt', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updatedAt', { withTimezone: true }).defaultNow().notNull()
}, (table) => {
return {
firstNameIdx: index('person_firstName_idx').on(table.firstName),
lastNameIdx: index('person_lastName_idx').on(table.lastName),
projectIdIdx: index('person_projectId_idx').on(table.projectId),
nameCompositeIdx: index('person_name_composite_idx').on(table.firstName, table.lastName)
};
});
/**
* Person type definitions
*/
export type Person = typeof persons.$inferSelect;
export type NewPerson = typeof persons.$inferInsert;

View File

@@ -0,0 +1,25 @@
import { pgTable, uuid, timestamp, index, uniqueIndex } from 'drizzle-orm/pg-core';
import { projects } from './projects';
import { tags } from './tags';
/**
* Project to Tag relation table schema
*/
export const projectToTag = pgTable('project_to_tag', {
id: uuid('id').primaryKey().defaultRandom(),
projectId: uuid('projectId').notNull().references(() => projects.id, { onDelete: 'cascade' }),
tagId: uuid('tagId').notNull().references(() => tags.id, { onDelete: 'cascade' }),
createdAt: timestamp('createdAt', { withTimezone: true }).defaultNow().notNull()
}, (table) => {
return {
projectIdIdx: index('pjt_projectId_idx').on(table.projectId),
tagIdIdx: index('pjt_tagId_idx').on(table.tagId),
projectTagUniqueIdx: uniqueIndex('pjt_project_tag_unique_idx').on(table.projectId, table.tagId)
};
});
/**
* ProjectToTag type definitions
*/
export type ProjectToTag = typeof projectToTag.$inferSelect;
export type NewProjectToTag = typeof projectToTag.$inferInsert;

View File

@@ -0,0 +1,27 @@
import { pgTable, uuid, varchar, text, timestamp, jsonb, index } from 'drizzle-orm/pg-core';
import { users } from './users';
/**
* Projects table schema
*/
export const projects = pgTable('projects', {
id: uuid('id').primaryKey().defaultRandom(),
name: varchar('name', { length: 100 }).notNull(),
description: text('description'),
ownerId: uuid('ownerId').notNull().references(() => users.id, { onDelete: 'cascade' }),
settings: jsonb('settings').default({}),
createdAt: timestamp('createdAt', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updatedAt', { withTimezone: true }).defaultNow().notNull()
}, (table) => {
return {
nameIdx: index('project_name_idx').on(table.name),
ownerIdIdx: index('project_ownerId_idx').on(table.ownerId),
createdAtIdx: index('project_createdAt_idx').on(table.createdAt)
};
});
/**
* Project type definitions
*/
export type Project = typeof projects.$inferSelect;
export type NewProject = typeof projects.$inferInsert;

View File

@@ -0,0 +1,102 @@
import { relations } from 'drizzle-orm';
import { users } from './users';
import { projects } from './projects';
import { persons } from './persons';
import { groups } from './groups';
import { tags } from './tags';
import { personToGroup } from './personToGroup';
import { personToTag } from './personToTag';
import { projectToTag } from './projectToTag';
/**
* Define relations for users table
*/
export const usersRelations = relations(users, ({ many }) => ({
projects: many(projects),
}));
/**
* Define relations for projects table
*/
export const projectsRelations = relations(projects, ({ one, many }) => ({
owner: one(users, {
fields: [projects.ownerId],
references: [users.id],
}),
persons: many(persons),
groups: many(groups),
projectToTags: many(projectToTag),
}));
/**
* Define relations for persons table
*/
export const personsRelations = relations(persons, ({ one, many }) => ({
project: one(projects, {
fields: [persons.projectId],
references: [projects.id],
}),
personToGroups: many(personToGroup),
personToTags: many(personToTag),
}));
/**
* Define relations for groups table
*/
export const groupsRelations = relations(groups, ({ one, many }) => ({
project: one(projects, {
fields: [groups.projectId],
references: [projects.id],
}),
personToGroups: many(personToGroup),
}));
/**
* Define relations for tags table
*/
export const tagsRelations = relations(tags, ({ many }) => ({
personToTags: many(personToTag),
projectToTags: many(projectToTag),
}));
/**
* Define relations for personToGroup table
*/
export const personToGroupRelations = relations(personToGroup, ({ one }) => ({
person: one(persons, {
fields: [personToGroup.personId],
references: [persons.id],
}),
group: one(groups, {
fields: [personToGroup.groupId],
references: [groups.id],
}),
}));
/**
* Define relations for personToTag table
*/
export const personToTagRelations = relations(personToTag, ({ one }) => ({
person: one(persons, {
fields: [personToTag.personId],
references: [persons.id],
}),
tag: one(tags, {
fields: [personToTag.tagId],
references: [tags.id],
}),
}));
/**
* Define relations for projectToTag table
*/
export const projectToTagRelations = relations(projectToTag, ({ one }) => ({
project: one(projects, {
fields: [projectToTag.projectId],
references: [projects.id],
}),
tag: one(tags, {
fields: [projectToTag.tagId],
references: [tags.id],
}),
}));

View File

@@ -0,0 +1,25 @@
import { pgTable, uuid, varchar, timestamp, index } from 'drizzle-orm/pg-core';
import { tagType } from './enums';
/**
* Tags table schema
*/
export const tags = pgTable('tags', {
id: uuid('id').primaryKey().defaultRandom(),
name: varchar('name', { length: 50 }).notNull(),
color: varchar('color', { length: 7 }).notNull(),
type: tagType('type').notNull(),
createdAt: timestamp('createdAt', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updatedAt', { withTimezone: true }).defaultNow().notNull()
}, (table) => {
return {
nameIdx: index('tag_name_idx').on(table.name),
typeIdx: index('tag_type_idx').on(table.type)
};
});
/**
* Tag type definitions
*/
export type Tag = typeof tags.$inferSelect;
export type NewTag = typeof tags.$inferInsert;

View File

@@ -0,0 +1,27 @@
import { pgTable, uuid, varchar, text, timestamp, jsonb, index } from 'drizzle-orm/pg-core';
import { DbSchema } from './db-schema';
/**
* Users table schema
*/
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(), // UUIDv7 for chronological order
name: varchar('name', { length: 100 }).notNull(),
avatar: text('avatar'), // URL from Github API
githubId: varchar('githubId', { length: 50 }).notNull().unique(),
gdprTimestamp: timestamp('gdprTimestamp', { withTimezone: true }),
createdAt: timestamp('createdAt', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updatedAt', { withTimezone: true }).defaultNow().notNull(),
metadata: jsonb('metadata').default({})
}, (table) => {
return {
githubIdIdx: index('githubId_idx').on(table.githubId),
createdAtIdx: index('createdAt_idx').on(table.createdAt)
};
});
/**
* User type definitions
*/
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;