diff --git a/.env.example b/.env.example new file mode 100644 --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# Database +POSTGRES_USER=cv +POSTGRES_PASSWORD=cv +POSTGRES_DB=cv +DB_PORT=5432 +DATABASE_URL=postgresql://cv:cv@db:5432/cv + +# Server +SERVER_PORT=3000 +JWT_SECRET=your-super-secret-jwt-key-here +JWT_ACCESS_TOKEN_EXPIRY=15m +JWT_REFRESH_TOKEN_EXPIRY=7d + +# Client +CLIENT_PORT=5173 +VITE_SERVER_URL=http://localhost:3000 + +# Docs +DOCS_PORT=3001 +VITE_CLIENT_URL=http://localhost:5173 + +# Prisma +PRISMA_ENABLE_TRACING=false +VITE_DOCS_URL=http://localhost:3001 diff --git a/apps/server/prisma/models/cv.prisma b/apps/server/prisma/models/cv.prisma new file mode 100644 --- /dev/null +++ b/apps/server/prisma/models/cv.prisma @@ -0,0 +1,29 @@ +model CVTemplate { + id String @id @default(cuid()) + name String + description String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // CVs using this template + cvs CV[] + + @@map("cv_templates") +} + +model CV { + id String @id @default(cuid()) + userId String + templateId String + title String + introduction String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + template CVTemplate @relation(fields: [templateId], references: [id], onDelete: Restrict) + applications Application[] + + @@map("cvs") +} diff --git a/apps/server/prisma/models/job-experience.prisma b/apps/server/prisma/models/job-experience.prisma new file mode 100644 --- /dev/null +++ b/apps/server/prisma/models/job-experience.prisma @@ -0,0 +1,91 @@ +model Skill { + id String @id @default(cuid()) + name String @unique + description String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Job experiences that use this skill + jobExperiences UserJobExperience[] + + // Vacancies requiring this skill + vacancies Vacancy[] + + // Education entries that use this skill + educations Education[] + + @@map("skills") +} + +model Company { + id String @id @default(cuid()) + name String @unique + description String? + website String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Job experiences at this company + jobExperiences UserJobExperience[] + + // Vacancies at this company + vacancies Vacancy[] + + @@map("companies") +} + +model Role { + id String @id @default(cuid()) + name String @unique + description String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Job experiences with this role + jobExperiences UserJobExperience[] + + // Vacancies with this role + vacancies Vacancy[] + + @@map("roles") +} + +model Level { + id String @id @default(cuid()) + name String @unique + description String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Job experiences at this level + jobExperiences UserJobExperience[] + + // Vacancies at this level + vacancies Vacancy[] + + @@map("levels") +} + +model UserJobExperience { + id String @id @default(cuid()) + userId String + companyId String + roleId String + levelId String + startDate DateTime + endDate DateTime? + description String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + level Level @relation(fields: [levelId], references: [id], onDelete: Cascade) + + // Skills used in this job experience + skills Skill[] + + @@map("user_job_experiences") +} diff --git a/apps/server/prisma/models/organization.prisma b/apps/server/prisma/models/organization.prisma new file mode 100644 --- /dev/null +++ b/apps/server/prisma/models/organization.prisma @@ -0,0 +1,43 @@ +model Organization { + id String @id @default(cuid()) + name String @unique + description String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Users in this organization + memberships Membership[] + + @@map("organizations") +} + +model OrganizationRole { + id String @id @default(cuid()) + name String @unique + description String? + color String @default("#6366f1") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Memberships with this role + memberships Membership[] + + @@map("organization_roles") +} + +model Membership { + id String @id @default(cuid()) + userId String + organizationId String + organizationRoleId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + role OrganizationRole @relation(fields: [organizationRoleId], references: [id], onDelete: Cascade) + + @@unique([userId, organizationId]) + @@map("memberships") +} diff --git a/apps/server/prisma/models/user.prisma b/apps/server/prisma/models/user.prisma new file mode 100644 --- /dev/null +++ b/apps/server/prisma/models/user.prisma @@ -0,0 +1,61 @@ +model User { + id String @id @default(cuid()) + email String @unique + name String + password String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Job experiences + jobExperiences UserJobExperience[] + + // Organizations + memberships Membership[] + + // Vacancies owned by user + ownedVacancies Vacancy[] @relation("VacancyOwner") + + // CVs + cvs CV[] + + // Applications + applications Application[] + + // Education history + educationHistory Education[] + + @@map("users") +} + +model Institution { + id String @id @default(cuid()) + name String @unique + description String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Education entries at this institution + educationEntries Education[] + + @@map("institutions") +} + +model Education { + id String @id @default(cuid()) + userId String + institutionId String + degree String + fieldOfStudy String? + startDate DateTime + endDate DateTime? + description String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + institution Institution @relation(fields: [institutionId], references: [id], onDelete: Cascade) + skills Skill[] + + @@map("educations") +} diff --git a/apps/server/prisma/models/vacancy.prisma b/apps/server/prisma/models/vacancy.prisma new file mode 100644 --- /dev/null +++ b/apps/server/prisma/models/vacancy.prisma @@ -0,0 +1,78 @@ +model JobType { + id String @id @default(cuid()) + name String @unique + description String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Vacancies with this job type + vacancies Vacancy[] + + @@map("job_types") +} + +model ApplicationStatus { + id String @id @default(cuid()) + name String @unique + description String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Applications with this status + applications Application[] + + @@map("application_statuses") +} + +model Vacancy { + id String @id @default(cuid()) + ownerId String + title String + companyId String + roleId String + levelId String? + jobTypeId String? + description String? + requirements String? + location String? + minSalary Int? + maxSalary Int? + applicationUrl String? + deadline DateTime? + isActive Boolean @default(true) + isPublic Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + owner User @relation("VacancyOwner", fields: [ownerId], references: [id], onDelete: Cascade) + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + level Level? @relation(fields: [levelId], references: [id], onDelete: Cascade) + jobType JobType? @relation(fields: [jobTypeId], references: [id], onDelete: Cascade) + skills Skill[] + applications Application[] + + @@map("vacancies") +} + +model Application { + id String @id @default(cuid()) + userId String + vacancyId String + cvId String? + coverLetter String? + statusId String + appliedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + vacancy Vacancy @relation(fields: [vacancyId], references: [id], onDelete: Cascade) + cv CV? @relation(fields: [cvId], references: [id], onDelete: SetNull) + status ApplicationStatus @relation(fields: [statusId], references: [id], onDelete: Restrict) + + @@unique([userId, vacancyId]) + @@map("applications") +} diff --git a/apps/server/src/config/config.module.ts b/apps/server/src/config/config.module.ts new file mode 100644 --- /dev/null +++ b/apps/server/src/config/config.module.ts @@ -0,0 +1,13 @@ +import { Global, Module } from "@nestjs/common"; +import { JwtConfigService } from "./jwt.config"; + +/** + * Global configuration module + * Provides configuration services throughout the application + */ +@Global() +@Module({ + providers: [JwtConfigService], + exports: [JwtConfigService], +}) +export class AppConfigModule {} diff --git a/apps/server/src/config/env.validation.ts b/apps/server/src/config/env.validation.ts new file mode 100644 --- /dev/null +++ b/apps/server/src/config/env.validation.ts @@ -0,0 +1,46 @@ +import * as Joi from "joi"; + +// Custom validator for JWT expiry format (e.g., "15m", "7d", "1h") +const jwtExpirySchema = Joi.string() + .pattern(/^(\d+)([smhd])$/) + .messages({ + "string.pattern.base": + 'JWT expiry must be in format: number + unit (s=seconds, m=minutes, h=hours, d=days). Example: "15m", "7d"', + }); + +export const envValidationSchema = Joi.object({ + // Database Configuration + POSTGRES_USER: Joi.string().required(), + POSTGRES_PASSWORD: Joi.string().required(), + POSTGRES_DB: Joi.string().required(), + DATABASE_URL: Joi.string().uri().required(), + + // Server Configuration + PORT: Joi.number().default(3000), + SERVER_PORT: Joi.number().default(3000), + NODE_ENV: Joi.string() + .valid("development", "production", "test") + .default("development"), + + // JWT Configuration + JWT_SECRET: Joi.string().min(16).required().messages({ + "string.min": "JWT_SECRET must be at least 16 characters long for security", + "any.required": "JWT_SECRET is required", + }), + JWT_ACCESS_TOKEN_EXPIRY: jwtExpirySchema.default("15m"), + JWT_REFRESH_TOKEN_EXPIRY: jwtExpirySchema.default("7d"), + + // Prisma Configuration + PRISMA_ENABLE_TRACING: Joi.boolean().default(false), + + // Client Configuration (optional for server) + CLIENT_PORT: Joi.number().optional(), + VITE_SERVER_URL: Joi.string().uri().optional(), + GRAPHQL_SCHEMA_URL: Joi.string().uri().optional(), + + // UI Configuration (optional for server) + UI_PORT: Joi.number().optional(), + + // Database Port + DB_PORT: Joi.number().default(5432), +}); diff --git a/apps/server/src/config/jwt.config.ts b/apps/server/src/config/jwt.config.ts new file mode 100644 --- /dev/null +++ b/apps/server/src/config/jwt.config.ts @@ -0,0 +1,77 @@ +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +/** + * JWT Configuration Service + * Handles parsing and providing JWT-related configuration values + */ +@Injectable() +export class JwtConfigService { + constructor(private configService: ConfigService) {} + + /** + * Gets the access token expiry string (e.g., "15m") + */ + getAccessTokenExpiry(): string { + return this.configService.getOrThrow("JWT_ACCESS_TOKEN_EXPIRY"); + } + + /** + * Gets the refresh token expiry string (e.g., "7d") + */ + getRefreshTokenExpiry(): string { + return this.configService.getOrThrow("JWT_REFRESH_TOKEN_EXPIRY"); + } + + /** + * Gets the JWT secret + */ + getSecret(): string { + return this.configService.getOrThrow("JWT_SECRET"); + } + + /** + * Calculates expiry date from JWT expiry string format + * Format: number + unit (s=seconds, m=minutes, h=hours, d=days) + * Examples: "15m", "7d", "1h" + */ + calculateExpiryDate(expiryString: string): Date { + const expires_at = new Date(); + + // Parse expiry string (e.g., "15m", "1h", "7d") + // Format is already validated by Joi schema, but we handle edge cases safely + const match = expiryString.match(/^(\d+)([smhd])$/); + if (!(match?.[1] && match[2])) { + // This should never happen due to Joi validation, but provides type safety + expires_at.setMinutes(expires_at.getMinutes() + 15); + return expires_at; + } + + const value = Number.parseInt(match[1], 10); + const unit = match[2]; + + switch (unit) { + case "s": + expires_at.setSeconds(expires_at.getSeconds() + value); + break; + case "m": + expires_at.setMinutes(expires_at.getMinutes() + value); + break; + case "h": + expires_at.setHours(expires_at.getHours() + value); + break; + case "d": + expires_at.setDate(expires_at.getDate() + value); + break; + } + + return expires_at; + } + + /** + * Calculates the access token expiry date + */ + calculateAccessTokenExpiryDate(): Date { + return this.calculateExpiryDate(this.getAccessTokenExpiry()); + } +} diff --git a/apps/client/src/features/user/queries/my-skills.graphql b/apps/client/src/features/user/queries/my-skills.graphql new file mode 100644 --- /dev/null +++ b/apps/client/src/features/user/queries/my-skills.graphql @@ -0,0 +1,16 @@ +query MySkills { + me { + experience { + edges { + node { + id + skills { + id + name + description + } + } + } + } + } +}