+24
.env.example
+24
.env.example
···
1
+
# Database
2
+
POSTGRES_USER=cv
3
+
POSTGRES_PASSWORD=cv
4
+
POSTGRES_DB=cv
5
+
DB_PORT=5432
6
+
DATABASE_URL=postgresql://cv:cv@db:5432/cv
7
+
8
+
# Server
9
+
SERVER_PORT=3000
10
+
JWT_SECRET=your-super-secret-jwt-key-here
11
+
JWT_ACCESS_TOKEN_EXPIRY=15m
12
+
JWT_REFRESH_TOKEN_EXPIRY=7d
13
+
14
+
# Client
15
+
CLIENT_PORT=5173
16
+
VITE_SERVER_URL=http://localhost:3000
17
+
18
+
# Docs
19
+
DOCS_PORT=3001
20
+
VITE_CLIENT_URL=http://localhost:5173
21
+
22
+
# Prisma
23
+
PRISMA_ENABLE_TRACING=false
24
+
VITE_DOCS_URL=http://localhost:3001
+16
apps/client/src/features/user/queries/my-skills.graphql
+16
apps/client/src/features/user/queries/my-skills.graphql
+29
apps/server/prisma/models/cv.prisma
+29
apps/server/prisma/models/cv.prisma
···
1
+
model CVTemplate {
2
+
id String @id @default(cuid())
3
+
name String
4
+
description String?
5
+
createdAt DateTime @default(now())
6
+
updatedAt DateTime @updatedAt
7
+
8
+
// CVs using this template
9
+
cvs CV[]
10
+
11
+
@@map("cv_templates")
12
+
}
13
+
14
+
model CV {
15
+
id String @id @default(cuid())
16
+
userId String
17
+
templateId String
18
+
title String
19
+
introduction String?
20
+
createdAt DateTime @default(now())
21
+
updatedAt DateTime @updatedAt
22
+
23
+
// Relations
24
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
25
+
template CVTemplate @relation(fields: [templateId], references: [id], onDelete: Restrict)
26
+
applications Application[]
27
+
28
+
@@map("cvs")
29
+
}
+91
apps/server/prisma/models/job-experience.prisma
+91
apps/server/prisma/models/job-experience.prisma
···
1
+
model Skill {
2
+
id String @id @default(cuid())
3
+
name String @unique
4
+
description String?
5
+
createdAt DateTime @default(now())
6
+
updatedAt DateTime @updatedAt
7
+
8
+
// Job experiences that use this skill
9
+
jobExperiences UserJobExperience[]
10
+
11
+
// Vacancies requiring this skill
12
+
vacancies Vacancy[]
13
+
14
+
// Education entries that use this skill
15
+
educations Education[]
16
+
17
+
@@map("skills")
18
+
}
19
+
20
+
model Company {
21
+
id String @id @default(cuid())
22
+
name String @unique
23
+
description String?
24
+
website String?
25
+
createdAt DateTime @default(now())
26
+
updatedAt DateTime @updatedAt
27
+
28
+
// Job experiences at this company
29
+
jobExperiences UserJobExperience[]
30
+
31
+
// Vacancies at this company
32
+
vacancies Vacancy[]
33
+
34
+
@@map("companies")
35
+
}
36
+
37
+
model Role {
38
+
id String @id @default(cuid())
39
+
name String @unique
40
+
description String?
41
+
createdAt DateTime @default(now())
42
+
updatedAt DateTime @updatedAt
43
+
44
+
// Job experiences with this role
45
+
jobExperiences UserJobExperience[]
46
+
47
+
// Vacancies with this role
48
+
vacancies Vacancy[]
49
+
50
+
@@map("roles")
51
+
}
52
+
53
+
model Level {
54
+
id String @id @default(cuid())
55
+
name String @unique
56
+
description String?
57
+
createdAt DateTime @default(now())
58
+
updatedAt DateTime @updatedAt
59
+
60
+
// Job experiences at this level
61
+
jobExperiences UserJobExperience[]
62
+
63
+
// Vacancies at this level
64
+
vacancies Vacancy[]
65
+
66
+
@@map("levels")
67
+
}
68
+
69
+
model UserJobExperience {
70
+
id String @id @default(cuid())
71
+
userId String
72
+
companyId String
73
+
roleId String
74
+
levelId String
75
+
startDate DateTime
76
+
endDate DateTime?
77
+
description String?
78
+
createdAt DateTime @default(now())
79
+
updatedAt DateTime @updatedAt
80
+
81
+
// Relations
82
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
83
+
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
84
+
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
85
+
level Level @relation(fields: [levelId], references: [id], onDelete: Cascade)
86
+
87
+
// Skills used in this job experience
88
+
skills Skill[]
89
+
90
+
@@map("user_job_experiences")
91
+
}
+43
apps/server/prisma/models/organization.prisma
+43
apps/server/prisma/models/organization.prisma
···
1
+
model Organization {
2
+
id String @id @default(cuid())
3
+
name String @unique
4
+
description String?
5
+
createdAt DateTime @default(now())
6
+
updatedAt DateTime @updatedAt
7
+
8
+
// Users in this organization
9
+
memberships Membership[]
10
+
11
+
@@map("organizations")
12
+
}
13
+
14
+
model OrganizationRole {
15
+
id String @id @default(cuid())
16
+
name String @unique
17
+
description String?
18
+
color String @default("#6366f1")
19
+
createdAt DateTime @default(now())
20
+
updatedAt DateTime @updatedAt
21
+
22
+
// Memberships with this role
23
+
memberships Membership[]
24
+
25
+
@@map("organization_roles")
26
+
}
27
+
28
+
model Membership {
29
+
id String @id @default(cuid())
30
+
userId String
31
+
organizationId String
32
+
organizationRoleId String
33
+
createdAt DateTime @default(now())
34
+
updatedAt DateTime @updatedAt
35
+
36
+
// Relations
37
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
38
+
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
39
+
role OrganizationRole @relation(fields: [organizationRoleId], references: [id], onDelete: Cascade)
40
+
41
+
@@unique([userId, organizationId])
42
+
@@map("memberships")
43
+
}
+61
apps/server/prisma/models/user.prisma
+61
apps/server/prisma/models/user.prisma
···
1
+
model User {
2
+
id String @id @default(cuid())
3
+
email String @unique
4
+
name String
5
+
password String
6
+
createdAt DateTime @default(now())
7
+
updatedAt DateTime @updatedAt
8
+
9
+
// Job experiences
10
+
jobExperiences UserJobExperience[]
11
+
12
+
// Organizations
13
+
memberships Membership[]
14
+
15
+
// Vacancies owned by user
16
+
ownedVacancies Vacancy[] @relation("VacancyOwner")
17
+
18
+
// CVs
19
+
cvs CV[]
20
+
21
+
// Applications
22
+
applications Application[]
23
+
24
+
// Education history
25
+
educationHistory Education[]
26
+
27
+
@@map("users")
28
+
}
29
+
30
+
model Institution {
31
+
id String @id @default(cuid())
32
+
name String @unique
33
+
description String?
34
+
createdAt DateTime @default(now())
35
+
updatedAt DateTime @updatedAt
36
+
37
+
// Education entries at this institution
38
+
educationEntries Education[]
39
+
40
+
@@map("institutions")
41
+
}
42
+
43
+
model Education {
44
+
id String @id @default(cuid())
45
+
userId String
46
+
institutionId String
47
+
degree String
48
+
fieldOfStudy String?
49
+
startDate DateTime
50
+
endDate DateTime?
51
+
description String?
52
+
createdAt DateTime @default(now())
53
+
updatedAt DateTime @updatedAt
54
+
55
+
// Relations
56
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
57
+
institution Institution @relation(fields: [institutionId], references: [id], onDelete: Cascade)
58
+
skills Skill[]
59
+
60
+
@@map("educations")
61
+
}
+78
apps/server/prisma/models/vacancy.prisma
+78
apps/server/prisma/models/vacancy.prisma
···
1
+
model JobType {
2
+
id String @id @default(cuid())
3
+
name String @unique
4
+
description String?
5
+
createdAt DateTime @default(now())
6
+
updatedAt DateTime @updatedAt
7
+
8
+
// Vacancies with this job type
9
+
vacancies Vacancy[]
10
+
11
+
@@map("job_types")
12
+
}
13
+
14
+
model ApplicationStatus {
15
+
id String @id @default(cuid())
16
+
name String @unique
17
+
description String?
18
+
createdAt DateTime @default(now())
19
+
updatedAt DateTime @updatedAt
20
+
21
+
// Applications with this status
22
+
applications Application[]
23
+
24
+
@@map("application_statuses")
25
+
}
26
+
27
+
model Vacancy {
28
+
id String @id @default(cuid())
29
+
ownerId String
30
+
title String
31
+
companyId String
32
+
roleId String
33
+
levelId String?
34
+
jobTypeId String?
35
+
description String?
36
+
requirements String?
37
+
location String?
38
+
minSalary Int?
39
+
maxSalary Int?
40
+
applicationUrl String?
41
+
deadline DateTime?
42
+
isActive Boolean @default(true)
43
+
isPublic Boolean @default(false)
44
+
createdAt DateTime @default(now())
45
+
updatedAt DateTime @updatedAt
46
+
47
+
// Relations
48
+
owner User @relation("VacancyOwner", fields: [ownerId], references: [id], onDelete: Cascade)
49
+
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
50
+
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
51
+
level Level? @relation(fields: [levelId], references: [id], onDelete: Cascade)
52
+
jobType JobType? @relation(fields: [jobTypeId], references: [id], onDelete: Cascade)
53
+
skills Skill[]
54
+
applications Application[]
55
+
56
+
@@map("vacancies")
57
+
}
58
+
59
+
model Application {
60
+
id String @id @default(cuid())
61
+
userId String
62
+
vacancyId String
63
+
cvId String?
64
+
coverLetter String?
65
+
statusId String
66
+
appliedAt DateTime @default(now())
67
+
createdAt DateTime @default(now())
68
+
updatedAt DateTime @updatedAt
69
+
70
+
// Relations
71
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
72
+
vacancy Vacancy @relation(fields: [vacancyId], references: [id], onDelete: Cascade)
73
+
cv CV? @relation(fields: [cvId], references: [id], onDelete: SetNull)
74
+
status ApplicationStatus @relation(fields: [statusId], references: [id], onDelete: Restrict)
75
+
76
+
@@unique([userId, vacancyId])
77
+
@@map("applications")
78
+
}
+13
apps/server/src/config/config.module.ts
+13
apps/server/src/config/config.module.ts
···
1
+
import { Global, Module } from "@nestjs/common";
2
+
import { JwtConfigService } from "./jwt.config";
3
+
4
+
/**
5
+
* Global configuration module
6
+
* Provides configuration services throughout the application
7
+
*/
8
+
@Global()
9
+
@Module({
10
+
providers: [JwtConfigService],
11
+
exports: [JwtConfigService],
12
+
})
13
+
export class AppConfigModule {}
+46
apps/server/src/config/env.validation.ts
+46
apps/server/src/config/env.validation.ts
···
1
+
import * as Joi from "joi";
2
+
3
+
// Custom validator for JWT expiry format (e.g., "15m", "7d", "1h")
4
+
const jwtExpirySchema = Joi.string()
5
+
.pattern(/^(\d+)([smhd])$/)
6
+
.messages({
7
+
"string.pattern.base":
8
+
'JWT expiry must be in format: number + unit (s=seconds, m=minutes, h=hours, d=days). Example: "15m", "7d"',
9
+
});
10
+
11
+
export const envValidationSchema = Joi.object({
12
+
// Database Configuration
13
+
POSTGRES_USER: Joi.string().required(),
14
+
POSTGRES_PASSWORD: Joi.string().required(),
15
+
POSTGRES_DB: Joi.string().required(),
16
+
DATABASE_URL: Joi.string().uri().required(),
17
+
18
+
// Server Configuration
19
+
PORT: Joi.number().default(3000),
20
+
SERVER_PORT: Joi.number().default(3000),
21
+
NODE_ENV: Joi.string()
22
+
.valid("development", "production", "test")
23
+
.default("development"),
24
+
25
+
// JWT Configuration
26
+
JWT_SECRET: Joi.string().min(16).required().messages({
27
+
"string.min": "JWT_SECRET must be at least 16 characters long for security",
28
+
"any.required": "JWT_SECRET is required",
29
+
}),
30
+
JWT_ACCESS_TOKEN_EXPIRY: jwtExpirySchema.default("15m"),
31
+
JWT_REFRESH_TOKEN_EXPIRY: jwtExpirySchema.default("7d"),
32
+
33
+
// Prisma Configuration
34
+
PRISMA_ENABLE_TRACING: Joi.boolean().default(false),
35
+
36
+
// Client Configuration (optional for server)
37
+
CLIENT_PORT: Joi.number().optional(),
38
+
VITE_SERVER_URL: Joi.string().uri().optional(),
39
+
GRAPHQL_SCHEMA_URL: Joi.string().uri().optional(),
40
+
41
+
// UI Configuration (optional for server)
42
+
UI_PORT: Joi.number().optional(),
43
+
44
+
// Database Port
45
+
DB_PORT: Joi.number().default(5432),
46
+
});
+77
apps/server/src/config/jwt.config.ts
+77
apps/server/src/config/jwt.config.ts
···
1
+
import { Injectable } from "@nestjs/common";
2
+
import { ConfigService } from "@nestjs/config";
3
+
4
+
/**
5
+
* JWT Configuration Service
6
+
* Handles parsing and providing JWT-related configuration values
7
+
*/
8
+
@Injectable()
9
+
export class JwtConfigService {
10
+
constructor(private configService: ConfigService) {}
11
+
12
+
/**
13
+
* Gets the access token expiry string (e.g., "15m")
14
+
*/
15
+
getAccessTokenExpiry(): string {
16
+
return this.configService.getOrThrow<string>("JWT_ACCESS_TOKEN_EXPIRY");
17
+
}
18
+
19
+
/**
20
+
* Gets the refresh token expiry string (e.g., "7d")
21
+
*/
22
+
getRefreshTokenExpiry(): string {
23
+
return this.configService.getOrThrow<string>("JWT_REFRESH_TOKEN_EXPIRY");
24
+
}
25
+
26
+
/**
27
+
* Gets the JWT secret
28
+
*/
29
+
getSecret(): string {
30
+
return this.configService.getOrThrow<string>("JWT_SECRET");
31
+
}
32
+
33
+
/**
34
+
* Calculates expiry date from JWT expiry string format
35
+
* Format: number + unit (s=seconds, m=minutes, h=hours, d=days)
36
+
* Examples: "15m", "7d", "1h"
37
+
*/
38
+
calculateExpiryDate(expiryString: string): Date {
39
+
const expires_at = new Date();
40
+
41
+
// Parse expiry string (e.g., "15m", "1h", "7d")
42
+
// Format is already validated by Joi schema, but we handle edge cases safely
43
+
const match = expiryString.match(/^(\d+)([smhd])$/);
44
+
if (!(match?.[1] && match[2])) {
45
+
// This should never happen due to Joi validation, but provides type safety
46
+
expires_at.setMinutes(expires_at.getMinutes() + 15);
47
+
return expires_at;
48
+
}
49
+
50
+
const value = Number.parseInt(match[1], 10);
51
+
const unit = match[2];
52
+
53
+
switch (unit) {
54
+
case "s":
55
+
expires_at.setSeconds(expires_at.getSeconds() + value);
56
+
break;
57
+
case "m":
58
+
expires_at.setMinutes(expires_at.getMinutes() + value);
59
+
break;
60
+
case "h":
61
+
expires_at.setHours(expires_at.getHours() + value);
62
+
break;
63
+
case "d":
64
+
expires_at.setDate(expires_at.getDate() + value);
65
+
break;
66
+
}
67
+
68
+
return expires_at;
69
+
}
70
+
71
+
/**
72
+
* Calculates the access token expiry date
73
+
*/
74
+
calculateAccessTokenExpiryDate(): Date {
75
+
return this.calculateExpiryDate(this.getAccessTokenExpiry());
76
+
}
77
+
}