tr]:last:border-b-0",
48 | className
49 | )}
50 | {...props}
51 | />
52 | )
53 | }
54 |
55 | function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
56 | return (
57 |
65 | )
66 | }
67 |
68 | function TableHead({ className, ...props }: React.ComponentProps<"th">) {
69 | return (
70 | [role=checkbox]]:translate-y-[2px]",
74 | className
75 | )}
76 | {...props}
77 | />
78 | )
79 | }
80 |
81 | function TableCell({ className, ...props }: React.ComponentProps<"td">) {
82 | return (
83 | | [role=checkbox]]:translate-y-[2px]",
87 | className
88 | )}
89 | {...props}
90 | />
91 | )
92 | }
93 |
94 | function TableCaption({
95 | className,
96 | ...props
97 | }: React.ComponentProps<"caption">) {
98 | return (
99 |
104 | )
105 | }
106 |
107 | export {
108 | Table,
109 | TableHeader,
110 | TableBody,
111 | TableFooter,
112 | TableHead,
113 | TableRow,
114 | TableCell,
115 | TableCaption,
116 | }
117 |
--------------------------------------------------------------------------------
/backend/src/app.module.ts:
--------------------------------------------------------------------------------
1 | import { BullModule } from '@nestjs/bullmq';
2 | import { CacheModule } from '@nestjs/cache-manager';
3 | import { Global, Module } from '@nestjs/common';
4 | import { ConfigModule } from '@nestjs/config';
5 | import { APP_FILTER, APP_GUARD, APP_PIPE } from '@nestjs/core';
6 | import { ThrottlerModule } from '@nestjs/throttler';
7 | import { SentryModule } from '@sentry/nestjs/setup';
8 | import { AppController } from './app.controller';
9 | import { AppService } from './app.service';
10 | import { AuthModule } from './features/auth/auth.module';
11 | import { UnhandledExceptionsFilter } from './core/filters/unhandled-exceptions.filter';
12 | import { JwtAuthGuard } from './core/guards/jwt-auth.guard';
13 | import { RolesGuard } from './core/guards/roles.guard';
14 | import { ThrottlerBehindProxyGuard } from './core/guards/throttler-behind-proxy.guard';
15 | import { TrimStringsPipe } from './core/pipes/trim-strings.pipe';
16 | import { MediaModule } from './features/media/media.module';
17 | import { DemoModule } from './features/demo/demo.module';
18 | import { ApplicationModule } from './features/application/application.module';
19 | import { CqrsModule } from '@nestjs/cqrs';
20 | import { CustomerModule } from './features/customer/customer.module';
21 | import { CountryModule } from './features/country/country.module';
22 | import { LoggerModule } from './core/logger/logger.module';
23 | import { ScheduleModule } from '@nestjs/schedule';
24 |
25 | @Global()
26 | @Module({
27 | imports: [
28 | // -- Libraries
29 | LoggerModule,
30 | SentryModule.forRoot(),
31 | ConfigModule.forRoot({
32 | isGlobal: true,
33 | }),
34 | ScheduleModule.forRoot(),
35 | CacheModule.register({
36 | isGlobal: true,
37 | }),
38 | BullModule.forRoot({
39 | connection: {
40 | host: process.env.APP_REDIS_HOST,
41 | port: parseInt(process.env.APP_REDIS_PORT ?? '6379'),
42 | },
43 | }),
44 | ThrottlerModule.forRoot({
45 | errorMessage: 'ThrottlerException: Too Many Requests',
46 | throttlers: [
47 | {
48 | name: 'long',
49 | ttl: 1 * 60 * 1000, // 1 minute
50 | limit: 500,
51 | blockDuration: 1 * 60 * 1000, // 1 minute
52 | },
53 | ],
54 | }),
55 | CqrsModule.forRoot(),
56 |
57 | // -- Business Modules
58 | ApplicationModule,
59 | CountryModule,
60 | DemoModule,
61 | AuthModule,
62 | MediaModule,
63 | CustomerModule,
64 | ],
65 | controllers: [AppController],
66 | providers: [
67 | AppService,
68 |
69 | // Guards
70 | {
71 | provide: APP_GUARD,
72 | useClass: JwtAuthGuard,
73 | },
74 | {
75 | provide: APP_GUARD,
76 | useClass: RolesGuard,
77 | },
78 | {
79 | provide: APP_GUARD,
80 | useClass: ThrottlerBehindProxyGuard,
81 | },
82 |
83 | // Filters
84 | {
85 | provide: APP_FILTER,
86 | useClass: UnhandledExceptionsFilter,
87 | },
88 |
89 | // Pipes
90 | {
91 | provide: APP_PIPE,
92 | useClass: TrimStringsPipe,
93 | },
94 | ],
95 | exports: [],
96 | })
97 | export class AppModule {}
98 |
--------------------------------------------------------------------------------
/frontend/src/lib/handle-api-errors.ts:
--------------------------------------------------------------------------------
1 | import { UseFormReturn, FieldValues, Path } from "react-hook-form";
2 | import { toast } from "react-toastify";
3 |
4 | interface Violation {
5 | path: string;
6 | message: string;
7 | }
8 |
9 | interface ApiErrorResponse {
10 | data?: {
11 | message?: string;
12 | violations?: Violation[];
13 | };
14 | message?: string;
15 | }
16 |
17 | interface ApiError {
18 | response?: ApiErrorResponse;
19 | message?: string;
20 | }
21 |
22 | type HandleApiErrorsProps = {
23 | error: unknown;
24 | form?: UseFormReturn;
25 | prefix?: string;
26 | };
27 |
28 | const DEFAULT_ERROR_MESSAGE =
29 | "Une erreur est survenue, veuillez réessayer dans quelques minutes.";
30 |
31 | // Utilities to extract data from the error
32 | const getErrorData = (error: unknown) => {
33 | if (typeof error !== "object" || !error) return null;
34 |
35 | const apiError = error as ApiError;
36 | return apiError.response?.data || null;
37 | };
38 |
39 | const getErrorMessage = (error: unknown) => {
40 | if (typeof error !== "object" || !error) return null;
41 |
42 | const apiError = error as ApiError;
43 | return apiError.response?.data?.message || apiError.message || null;
44 | };
45 |
46 | const getViolations = (error: unknown): Violation[] => {
47 | const errorData = getErrorData(error);
48 | return errorData?.violations || [];
49 | };
50 |
51 | // Handle validation errors for forms
52 | const handleFormViolations = (
53 | violations: Violation[],
54 | form: UseFormReturn,
55 | prefix?: string
56 | ) => {
57 | violations.forEach((violation) => {
58 | let fieldPath = violation.path;
59 | if (prefix) {
60 | fieldPath = `${prefix}.${fieldPath}`;
61 | }
62 |
63 | // Check if the field exists in the form
64 | const fieldValue = form.getValues(fieldPath as Path);
65 | if (fieldValue !== undefined) {
66 | form.setError(
67 | fieldPath as Path,
68 | { message: violation.message },
69 | { shouldFocus: true }
70 | );
71 | }
72 | });
73 | };
74 |
75 | const handleApiErrors = ({
76 | error,
77 | form,
78 | prefix,
79 | }: HandleApiErrorsProps) => {
80 | const violations = getViolations(error);
81 | const errorMessage = getErrorMessage(error);
82 |
83 | // If we have validation violations
84 | if (violations.length > 0) {
85 | if (form) {
86 | // Display the main message and set errors on the fields
87 | toast.error(errorMessage || "Validation error");
88 | handleFormViolations(violations, form, prefix);
89 | } else {
90 | // Display all violations in a single message
91 | const violationMessages = violations.map(
92 | (v) => `${v.path}: ${v.message}`
93 | );
94 | toast.error(violationMessages.join("\n"));
95 | }
96 | return;
97 | }
98 |
99 | // Display the simple error message or the default message
100 | toast.error(errorMessage ?? DEFAULT_ERROR_MESSAGE);
101 | };
102 |
103 | export { handleApiErrors };
104 |
--------------------------------------------------------------------------------
/frontend/src/app/(workspaces)/admin-area/_components/nav-documents.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | IconDots,
5 | IconFolder,
6 | IconShare3,
7 | IconTrash,
8 | type Icon,
9 | } from "@tabler/icons-react";
10 |
11 | import {
12 | DropdownMenu,
13 | DropdownMenuContent,
14 | DropdownMenuItem,
15 | DropdownMenuSeparator,
16 | DropdownMenuTrigger,
17 | } from "@/components/ui/dropdown-menu";
18 | import {
19 | SidebarGroup,
20 | SidebarGroupLabel,
21 | SidebarMenu,
22 | SidebarMenuAction,
23 | SidebarMenuButton,
24 | SidebarMenuItem,
25 | useSidebar,
26 | } from "@/components/ui/sidebar";
27 | import Link from "next/link";
28 |
29 | export function NavDocuments({
30 | items,
31 | }: {
32 | items: {
33 | title: string;
34 | url: string;
35 | icon: Icon;
36 | }[];
37 | }) {
38 | const { isMobile, setOpenMobile } = useSidebar();
39 |
40 | const handleLinkClick = () => {
41 | if (isMobile) {
42 | setOpenMobile(false);
43 | }
44 | };
45 |
46 | return (
47 |
48 | Documents
49 |
50 | {items.map((item) => (
51 |
52 |
53 |
54 |
55 |
56 | {item.title}
57 |
58 |
59 |
60 |
61 |
62 |
66 |
67 | More
68 |
69 |
70 |
75 |
76 |
77 | Open
78 |
79 |
80 |
81 | Share
82 |
83 |
84 |
85 |
86 | Delete
87 |
88 |
89 |
90 |
91 | ))}
92 |
93 |
94 |
95 | More
96 |
97 |
98 |
99 |
100 | );
101 | }
102 |
--------------------------------------------------------------------------------
/compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | # ===================================================================================================================================================================================
3 | backend:
4 | image: lunisoft/terracapital:backend
5 | build:
6 | context: ./backend
7 | environment:
8 | - NODE_ENV=production
9 | - FORCE_COLOR=1
10 | - DISPLAY=${X11_DISPLAY}
11 | - APP_PORT=${APP_PORT}
12 | - APP_BASE_URL=${APP_BASE_URL}
13 | - APP_DATABASE_CONNECTION_URL=${APP_DATABASE_CONNECTION_URL}
14 | - APP_REDIS_HOST=${APP_REDIS_HOST}
15 | - APP_REDIS_PORT=${APP_REDIS_PORT}
16 | - APP_JWT_PRIVATE_KEY=${APP_JWT_PRIVATE_KEY}
17 | - APP_JWT_PUBLIC_KEY=${APP_JWT_PUBLIC_KEY}
18 | - API_S3_ENDPOINT=${API_S3_ENDPOINT}
19 | - API_S3_ACCESS_KEY=${API_S3_ACCESS_KEY}
20 | - API_S3_SECRET_KEY=${API_S3_SECRET_KEY}
21 | - API_S3_BUCKET=${API_S3_BUCKET}
22 | - API_GOOGLE_CLIENT_ID=${API_GOOGLE_CLIENT_ID}
23 | - API_GOOGLE_CLIENT_SECRET=${API_GOOGLE_CLIENT_SECRET}
24 | - APP_PLAYWRIGHT_HEADLESS=${APP_PLAYWRIGHT_HEADLESS}
25 | - API_BREVO_API_KEY=${API_BREVO_API_KEY}
26 | volumes:
27 | - ./backend/logs:/usr/src/app/logs
28 | depends_on:
29 | - postgres
30 | - redis
31 | tty: true
32 | restart: unless-stopped
33 | # ===================================================================================================================================================================================
34 | frontend:
35 | image: lunisoft/terracapital:frontend
36 | build:
37 | context: ./frontend
38 | environment:
39 | - NODE_ENV=production
40 | restart: unless-stopped
41 | # ===================================================================================================================================================================================
42 | caddy:
43 | image: lunisoft/terracapital:caddy
44 | build:
45 | context: ./caddy
46 | ports:
47 | - mode: host
48 | protocol: tcp
49 | published: 80
50 | target: 80
51 | - mode: host
52 | protocol: tcp
53 | published: 443
54 | target: 443
55 | volumes:
56 | - ./caddy/logs:/var/log/caddy:z
57 | - caddy_data:/data
58 | - caddy_config:/config
59 | depends_on:
60 | - backend
61 | - frontend
62 | restart: unless-stopped
63 | # ===================================================================================================================================================================================
64 | redis:
65 | image: redis:8.2.2
66 | command: redis-server --save 60 1 --loglevel warning
67 | volumes:
68 | - redis_data:/data
69 | restart: unless-stopped
70 | # ===================================================================================================================================================================================
71 | postgres:
72 | image: postgres:17.2
73 | ports:
74 | - "127.0.0.1:5432:5432"
75 | environment:
76 | - POSTGRES_USER=lunisoft
77 | - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
78 | - POSTGRES_DB=${PROJECT_NAME}
79 | volumes:
80 | - postgres_data:/var/lib/postgresql/data
81 | restart: unless-stopped
82 |
83 | volumes:
84 | postgres_data:
85 | redis_data:
86 | caddy_data:
87 | caddy_config:
88 |
--------------------------------------------------------------------------------
/backend/src/features/cli/seeders/users.seeder.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, Logger } from '@nestjs/common';
2 | import { DatabaseService } from '../../application/services/database.service';
3 | import { AuthService } from '../../auth/auth.service';
4 |
5 | interface TestUser {
6 | email: string;
7 | password: string;
8 | role: 'ADMIN' | 'CUSTOMER';
9 | }
10 |
11 | @Injectable()
12 | export class UsersSeeder {
13 | private readonly logger = new Logger(UsersSeeder.name);
14 |
15 | constructor(
16 | private readonly db: DatabaseService,
17 | private readonly authService: AuthService,
18 | ) {}
19 |
20 | async seed(): Promise {
21 | this.logger.debug('Starting users seed...');
22 |
23 | const testUsers: TestUser[] = [
24 | {
25 | email: 'contact@lunisoft.fr',
26 | password: 'password',
27 | role: 'ADMIN',
28 | },
29 | {
30 | email: 'customer@lunisoft.fr',
31 | password: 'password',
32 | role: 'CUSTOMER',
33 | },
34 | ];
35 |
36 | let created = 0;
37 | let skipped = 0;
38 |
39 | for (const userData of testUsers) {
40 | try {
41 | if (userData.role === 'ADMIN') {
42 | await this.seedAdmin(userData);
43 | } else {
44 | await this.seedCustomer(userData);
45 | }
46 | created++;
47 | this.logger.debug(`Created ${userData.role}: ${userData.email}`);
48 | } catch (error) {
49 | if (error.code === 'P2002') {
50 | skipped++;
51 | this.logger.debug(`Skipped existing user: ${userData.email}`);
52 | } else {
53 | this.logger.error(
54 | `Failed to seed user ${userData.email}: ${error.message}`,
55 | );
56 | skipped++;
57 | }
58 | }
59 | }
60 |
61 | this.logger.debug(
62 | `Users seed completed: ${created} created, ${skipped} skipped`,
63 | );
64 | }
65 |
66 | private async seedAdmin(userData: TestUser): Promise {
67 | const hashedPassword = await this.authService.hashPassword({
68 | password: userData.password,
69 | });
70 |
71 | await this.db.prisma.account.create({
72 | data: {
73 | role: 'ADMIN',
74 | email: userData.email,
75 | password: hashedPassword,
76 | admin: {
77 | create: {},
78 | },
79 | },
80 | });
81 | }
82 |
83 | private async seedCustomer(userData: TestUser): Promise {
84 | const hashedPassword = await this.authService.hashPassword({
85 | password: userData.password,
86 | });
87 |
88 | await this.db.prisma.account.create({
89 | data: {
90 | role: 'CUSTOMER',
91 | email: userData.email,
92 | password: hashedPassword,
93 | customer: {
94 | create: {},
95 | },
96 | },
97 | });
98 | }
99 |
100 | getTestCredentials(): void {
101 | this.logger.debug('\n=== Test User Credentials ===');
102 | this.logger.debug('Admin Account:');
103 | this.logger.debug(' Email: contact@lunisoft.fr');
104 | this.logger.debug(' Password: password');
105 | this.logger.debug('\nCustomer Accounts:');
106 | this.logger.debug(' Email: customer@lunisoft.fr');
107 | this.logger.debug(' Password: password');
108 | this.logger.debug('=============================\n');
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | PROJECT_NAME="ultimate-typescript-starter-kit"
2 | X11_DISPLAY="host.docker.internal:0"
3 | POSTGRES_PASSWORD="ChangeMe"
4 |
5 | # -----------------
6 | # Backend
7 | # -----------------
8 | APP_PORT=3000
9 | APP_BASE_URL=http://localhost
10 | APP_DATABASE_CONNECTION_URL="postgresql://lunisoft:ChangeMe@postgres:5432/ultimate-typescript-starter-kit?schema=public"
11 | APP_REDIS_HOST="redis"
12 | APP_REDIS_PORT="6379"
13 | APP_JWT_PRIVATE_KEY="LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2pBZ0VBQW9JQkFRREVDK2xTVjdsRXlkcVUKbDlVcmQ4emp3QXlFZ1BuR0NBUU1QZVFXQlVQWXhPREhsZTI5cjlIVDdoRUtPaDlIL0hMOUhrYVFZL3ZUTFRGWgpJL3BiQW4wMjVaYUNLSFFQU0xjSlpuNG4xVFdVbnJCWnM5cGxqak5CQUUyRmh6UVh5WGxGcE42SndiVkc4VGxzCmd4UmRnWSsrM0ZycXg4ZzQzbUdnVjdNQUh6Snk1MG5zTTVkSkdvZ2ZuaFpQa3JzRVNHY1RybmZEWXI1bFdEclIKVVlmYkQ0TThRSWpORHo0Z1VkYm9xYmJaenNIREV6My9NVFl5Y1hTWFFJSnBZZGwyL29wYUNjLy9NWnBpUmZVUQpOUTNMTEQ4OUlyd2RHRGRsZURsQmo4YVhybDZFemdWYnpIQzR4R3VtOFpaT3FXMklQTnBvb0taa1BuSVZWWDNSCmIzY2xRMmlkQWdNQkFBRUNnZ0VBVHJoSHRvQXlEUHlPaitjTnVqZ1BKZzVxR0ZTZnR0Um1KN0k4WVZrNDNwUmoKZTlEb2x5ZS90ZjBjaTRJK0tFNG1zQnVWa3dvS3hzZVpUcVZqTkdNaCswYWlNbDVqQi9ZWFJTZUtGWjJIdDhjbQpvY1pWdGp5c3VQZVJxVUhhZVlpMWNQRWNTSzFuQ0hiNUsyalE1eUVNb3NOaG9HK2JKcmFvOWRUeE0rWFRBSTdJCnRTK0EzdWhTMXg2TlpDM1Ayd2dWcHBPMWdmbllKTVQ0eVdISmlWdHpGYW56d1RUWG1EUkdCTzZldzJZbytVbTgKVU9JZmJJL3RYZy84Q3FtZ3c2K0tkbkdlYVpVWllJMTRETmxNdEcxdEIvVmpOVENZZU1DYnA0OEVPV25OR3VNMgptUGppNFNUb0w4SHJocHFoUHAzMkFMSUlNc0hCN0ZBUW0yQTJPS2pBWXdLQmdRRDZtNGl3clVKQzZCVXpQSE8xCmRqSDBQV0k5ZmkvVU9hbFZtUkgxbXI4OE1lem5VSmF4K2tDVzdnem9haFBxbG1DS2pjUmFFUHNvemEvN1psZEsKK0lJM0RJaWRzdTEydWo5UkxvOXo4RDdPQXAxVWFLd21TZVdLcEhBeVg4d2ZkZ2t0L3FZTFlNZTd0VkZKSExtMQpDVjdCMlk2dDFzKzNrTGJ2bm4vd2ZjOVcyd0tCZ1FESVE5Uk1XSjhuZlFkTFB2Ry9LemkxamtXd2czNkh0MXAxCnNYbHpENzhEV3g1RjNzYi9NVjRzT1ZwRHBUWDY2b28rMy9kZ0d2Tll3QXJNaUVrVWNuTHlNU3UzalROLzc0bnIKZ3VISnpFYVUweXBtNHdsSk93cnBwRzF3bzIvK2tEajhPMGxlcnp4VWlEZGJXV2JkL1h0VFJyVVVDQlIvcHcvVQprQno4QjZQcjV3S0JnUUNra3N0Ykt3eWVuNFo4bFRCdmRHVXR2Ym5zSkJnSXlLMFpWMkpoNWZPNzloVmJlcUxiCjBqbmtaQVA2Qk45N2FMR1JpN1BzYWNabWIxMG9QWGNKOXRTY2poQ1JiMVZlYU1UMzdSbXJ5NU9TK2tpVGpBR3gKUzBvQW1DaE9ESGNpR2dQQlByK1FMVWc5VHI5SXdpSjZidUxaYnFPeUthVlRLU2ZaaUQ4QWtiNDlqUUtCZ0VwQQpjL2QycUZQdzFJSityUTF2VGhCcTFzWHlpemh3c0JhUkhmR2VkZmtka0tUaFM3RVVzZEQ5MXN6YjlaNjUxVllvCm5rVEEyVmNmcFNGZXFwSHRPVmM1Q2ZkOVlBbmdXNmU1bUZQRTdLcURmT1kyNlp1QVM3U0RKWnlzekhwN0tOWEUKZVppa3FsN0JQcDBkRWJuZklSbW9UcjFGbmF3UzJoaTY4alF6OVFBakFvR0FEU3RMNTBYbVhETWU1TENtMy9Fawpab0FZZW9uNkFHVmY1c2xhNE40eWFkZ1VnakdCRDRlQjB2RjlSRG56TU5IQW5oZnFGM0lOVnVGMWFPK2pGSXlLCjdDOWJnSmNQd0xuMXFPWWRFbHFSN1pUMnlLOWR5OTVHazNxaUEwZEVRUjdIc3pxY0NKTFpqbjd0eVd4a0dBbXUKUi9hSEJ5dkR2WWxwb3BkUFpJZ2VFVVE9Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K"
14 | APP_JWT_PUBLIC_KEY="LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF4QXZwVWxlNVJNbmFsSmZWSzNmTQo0OEFNaElENXhnZ0VERDNrRmdWRDJNVGd4NVh0dmEvUjArNFJDam9mUi94eS9SNUdrR1A3MHkweFdTUDZXd0o5Ck51V1dnaWgwRDBpM0NXWitKOVUxbEo2d1diUGFaWTR6UVFCTmhZYzBGOGw1UmFUZWljRzFSdkU1YklNVVhZR1AKdnR4YTZzZklPTjVob0ZlekFCOHljdWRKN0RPWFNScUlINTRXVDVLN0JFaG5FNjUzdzJLK1pWZzYwVkdIMncrRApQRUNJelE4K0lGSFc2S20yMmM3Qnd4TTkvekUyTW5GMGwwQ0NhV0haZHY2S1dnblAvekdhWWtYMUVEVU55eXcvClBTSzhIUmczWlhnNVFZL0dsNjVlaE00Rlc4eHd1TVJycHZHV1RxbHRpRHphYUtDbVpENXlGVlY5MFc5M0pVTm8KblFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg=="
15 | API_S3_ENDPOINT=
16 | API_S3_ACCESS_KEY=
17 | API_S3_SECRET_KEY=
18 | API_S3_BUCKET=
19 | API_GOOGLE_CLIENT_ID=
20 | API_GOOGLE_CLIENT_SECRET=
21 | APP_PLAYWRIGHT_HEADLESS="true"
22 | API_BREVO_API_KEY=
23 |
24 |
--------------------------------------------------------------------------------
/backend/src/features/application/services/pdf.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
2 | import { Browser, BrowserContext, chromium, devices } from 'playwright';
3 |
4 | @Injectable()
5 | export class PdfService implements OnModuleDestroy {
6 | private logger = new Logger(PdfService.name);
7 | private browser?: Browser;
8 | private context?: BrowserContext;
9 |
10 | /**
11 | * Get the browser instance
12 | * If the browser is not initialized, it will create a new one
13 | */
14 | getBrowser = async () => {
15 | if (!this.browser) {
16 | this.browser = await chromium.launch({
17 | headless: true, // If true, hide the browser, if false, show the browser
18 | });
19 | }
20 |
21 | return this.browser;
22 | };
23 |
24 | /**
25 | * Get the context instance
26 | * If the context is not initialized, it will create a new one.
27 | * If the browser is not initialized yet, it will create a new one too.
28 | * You can use the context to create pages.
29 | */
30 | getContext = async () => {
31 | if (!this.context) {
32 | const browser = await this.getBrowser();
33 | this.context = await browser.newContext({
34 | ...devices['Desktop Chrome'],
35 | viewport: {
36 | width: 1920,
37 | height: 1080,
38 | },
39 | });
40 | }
41 |
42 | return this.context;
43 | };
44 |
45 | /**
46 | * Generate a PDF from an HTML string
47 | * @param html - The HTML string to generate a PDF from
48 | */
49 | htmlToPdf = async ({
50 | html,
51 | footerHtml,
52 | }: {
53 | html: string;
54 | footerHtml?: string;
55 | }) => {
56 | // Launch browser
57 | const context = await this.getContext();
58 | const page = await context.newPage();
59 |
60 | // Set content and wait for loading
61 | await page.setContent(html, { waitUntil: 'load' });
62 |
63 | // Wait for fonts to load
64 | await page.evaluate(async () => {
65 | await document.fonts.ready;
66 | });
67 |
68 | // Generate PDF
69 | const pdfBuffer = await page.pdf({
70 | format: 'A4',
71 | margin: {
72 | top: '20px',
73 | right: '20px',
74 | bottom: '60px', // Increased bottom margin to accommodate footer
75 | left: '20px',
76 | },
77 | printBackground: true,
78 | displayHeaderFooter: footerHtml ? true : false,
79 | footerTemplate: footerHtml,
80 | headerTemplate: '', // Empty header template to avoid default header
81 | });
82 |
83 | // Close page
84 | await page.close();
85 |
86 | return pdfBuffer;
87 | };
88 |
89 | /**
90 | * Ensure Playwright resources are released when the Nest module is destroyed
91 | * (e.g., on hot-reload or graceful shutdown)
92 | */
93 | async onModuleDestroy() {
94 | try {
95 | if (this.context) {
96 | await this.context.close();
97 | this.context = undefined;
98 | }
99 | } catch (error) {
100 | this.logger.warn(`Error closing Playwright context - ${error.message}`);
101 | }
102 | try {
103 | if (this.browser) {
104 | await this.browser.close();
105 | this.browser = undefined;
106 | }
107 | } catch (error) {
108 | this.logger.warn(`Error closing Playwright browser - ${error.message}`);
109 | }
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/backend/src/features/customer/controllers/customer.customer.controller.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Body,
3 | Controller,
4 | Get,
5 | HttpException,
6 | HttpStatus,
7 | Patch,
8 | Req,
9 | UnauthorizedException,
10 | UsePipes,
11 | } from '@nestjs/common';
12 | import { type Request } from 'express';
13 | import { Roles } from 'src/core/decorators/roles.decorator';
14 | import { ZodValidationPipe } from 'src/core/pipes/zod-validation.pipe';
15 | import { DatabaseService } from 'src/features/application/services/database.service';
16 | import { CountryService } from 'src/features/country/country.service';
17 | import {
18 | CustomerCustomerInformationsDto,
19 | customerCustomerInformationsSchema,
20 | } from '../validators/customer.customer.validators';
21 |
22 | @Controller()
23 | @Roles(['CUSTOMER'])
24 | export class CustomerCustomerController {
25 | constructor(
26 | private db: DatabaseService,
27 | private countryService: CountryService,
28 | ) {}
29 |
30 | @Get('/customer/informations')
31 | async getCustomerInformations(@Req() req: Request) {
32 | const user = req.user;
33 |
34 | if (!user) {
35 | throw new UnauthorizedException();
36 | }
37 |
38 | const customerInformatons = await this.db.prisma.customer.findFirst({
39 | include: {
40 | city: true,
41 | },
42 | where: {
43 | accountId: user.accountId,
44 | },
45 | });
46 |
47 | if (!customerInformatons) {
48 | throw new HttpException(
49 | "You don't have any information",
50 | HttpStatus.BAD_REQUEST,
51 | );
52 | }
53 |
54 | return {
55 | countryCode: customerInformatons.countryCode,
56 | city: customerInformatons.city,
57 | };
58 | }
59 |
60 | @Patch('/customer/informations')
61 | @UsePipes(new ZodValidationPipe(customerCustomerInformationsSchema))
62 | async saveCustomerInformations(
63 | @Req() req: Request,
64 | @Body() body: CustomerCustomerInformationsDto['body'],
65 | ) {
66 | const user = req.user;
67 |
68 | if (!user) {
69 | throw new UnauthorizedException();
70 | }
71 |
72 | const customerInformatons = await this.db.prisma.customer.findFirst({
73 | where: {
74 | accountId: user.accountId,
75 | },
76 | });
77 |
78 | if (!customerInformatons) {
79 | throw new HttpException(
80 | "You don't have any information",
81 | HttpStatus.BAD_REQUEST,
82 | );
83 | }
84 |
85 | // Check if the provided country exists
86 | const country = this.countryService.getCountryByIso2(body.countryCode);
87 |
88 | if (!country) {
89 | throw new HttpException(
90 | "This country doesn't exist",
91 | HttpStatus.BAD_REQUEST,
92 | );
93 | }
94 |
95 | // Check if the provided city exists
96 | const city = await this.db.prisma.city.findUnique({
97 | where: {
98 | id: body.city,
99 | },
100 | });
101 |
102 | if (!city) {
103 | throw new HttpException(
104 | "This city doesn't exist",
105 | HttpStatus.BAD_REQUEST,
106 | );
107 | }
108 |
109 | await this.db.prisma.customer.update({
110 | where: {
111 | id: customerInformatons.id,
112 | },
113 | data: {
114 | countryCode: body.countryCode,
115 | cityId: body.city,
116 | },
117 | });
118 |
119 | return {
120 | countryCode: body.countryCode,
121 | cityId: body.city,
122 | };
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 | import { cn } from "@/lib/utils";
5 | import { LoadingSpinner } from "@/components/ui/loading-spinner";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
15 | outline:
16 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost:
20 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2 has-[>svg]:px-3",
25 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
26 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
27 | icon: "size-9",
28 | "icon-sm": "size-8",
29 | "icon-lg": "size-10",
30 | },
31 | },
32 | defaultVariants: {
33 | variant: "default",
34 | size: "default",
35 | },
36 | }
37 | );
38 |
39 | function Button({
40 | className,
41 | variant,
42 | size,
43 | asChild = false,
44 | loading = false,
45 | children,
46 | ...props
47 | }: React.ComponentProps<"button"> &
48 | VariantProps & {
49 | asChild?: boolean;
50 | loading?: boolean;
51 | }) {
52 | const Comp = asChild ? Slot : "button";
53 |
54 | // Determine spinner color based on variant
55 | const getSpinnerColor = () => {
56 | switch (variant) {
57 | case "default":
58 | case "destructive":
59 | return "bg-white";
60 | case "outline":
61 | case "ghost":
62 | return "bg-foreground";
63 | case "secondary":
64 | return "bg-secondary-foreground";
65 | case "link":
66 | return "bg-primary";
67 | default:
68 | return "bg-white";
69 | }
70 | };
71 |
72 | const spinnerColor = getSpinnerColor();
73 |
74 | return (
75 |
81 | {/* if loading, show loading spinner */}
82 | {loading ? (
83 | <>
84 | {children}
85 |
86 |
87 |
88 |
89 | >
90 | ) : (
91 | // if not loading, show children
92 | <>{children}>
93 | )}
94 |
95 | );
96 | }
97 |
98 | export { Button, buttonVariants };
99 |
--------------------------------------------------------------------------------
/frontend/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server";
2 | import type { NextRequest } from "next/server";
3 | import { getServerSession } from "./lib/luni-auth/luni-auth.server";
4 |
5 | // =============================================================================
6 | // Types
7 | // =============================================================================
8 |
9 | type MiddlewareFactory = (
10 | request: NextRequest
11 | ) => Promise | NextResponse | null;
12 |
13 | // =============================================================================
14 | // Middleware Chain Helper
15 | // =============================================================================
16 |
17 | /**
18 | * Chains multiple middlewares together and merges their responses
19 | * @param middlewares - Array of middleware functions to chain
20 | * @returns Combined middleware function
21 | */
22 | function chain(middlewares: MiddlewareFactory[]) {
23 | return async (request: NextRequest): Promise => {
24 | for (const middleware of middlewares) {
25 | const response = await middleware(request);
26 | if (response) return response;
27 | }
28 |
29 | return NextResponse.next();
30 | };
31 | }
32 |
33 | // =============================================================================
34 | // Middleware Functions
35 | // =============================================================================
36 |
37 | /**
38 | * Protects admin and customer areas, handles authentication redirects
39 | */
40 | async function authenticationMiddleware(
41 | request: NextRequest
42 | ): Promise {
43 | const { pathname } = request.nextUrl;
44 |
45 | const isSigninPage = pathname.startsWith("/auth/signin");
46 | const isAdminArea = pathname.startsWith("/admin-area");
47 | const isCustomerArea = pathname.startsWith("/customer-area");
48 | const isCustomerSignupPage = pathname.startsWith("/auth/customer/signup");
49 |
50 | // Skip session check if not in a protected area or auth page
51 | if (
52 | !isSigninPage &&
53 | !isAdminArea &&
54 | !isCustomerArea &&
55 | !isCustomerSignupPage
56 | ) {
57 | return null;
58 | }
59 |
60 | const session = await getServerSession();
61 | const isAuthenticated = session.status === "authenticated";
62 | const isAdmin = session.data?.role.includes("ADMIN");
63 | const isCustomer = session.data?.role.includes("CUSTOMER");
64 |
65 | // Admin area: redirect if not admin
66 | if (isAdminArea && (!isAuthenticated || !isAdmin)) {
67 | return NextResponse.redirect(new URL("/auth/signin", request.url));
68 | }
69 |
70 | // Customer area: redirect if not customer
71 | if (isCustomerArea && (!isAuthenticated || !isCustomer)) {
72 | return NextResponse.redirect(new URL("/auth/signin", request.url));
73 | }
74 |
75 | // Auth pages: redirect if already authenticated
76 | if (isSigninPage || isCustomerSignupPage) {
77 | if (isAuthenticated && isAdmin) {
78 | return NextResponse.redirect(new URL("/admin-area", request.url));
79 | }
80 | if (isAuthenticated && isCustomer) {
81 | return NextResponse.redirect(new URL("/", request.url));
82 | }
83 | }
84 |
85 | return null;
86 | }
87 |
88 | // =============================================================================
89 | // Main Middleware Export
90 | // =============================================================================
91 |
92 | export const middleware = chain([authenticationMiddleware]);
93 |
94 | export const config = {
95 | matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
96 | };
97 |
--------------------------------------------------------------------------------
/backend/src/features/application/services/database.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
2 | import { PrismaClient, Prisma } from '../../../generated/prisma/client';
3 | import { S3Service } from './s3.service';
4 | import { PrismaPg } from '@prisma/adapter-pg';
5 | import { Pool } from 'pg';
6 | import { ConfigService } from '@nestjs/config';
7 |
8 | export type PrismaTransactionClient = Omit<
9 | typeof DatabaseService.prototype.prisma,
10 | '$extends' | '$transaction' | '$disconnect' | '$connect' | '$on' | '$use'
11 | >;
12 |
13 | @Injectable()
14 | export class DatabaseService implements OnModuleInit, OnModuleDestroy {
15 | public prisma: ReturnType;
16 | private pool: Pool;
17 | private adapter: PrismaPg;
18 |
19 | constructor(
20 | private configService: ConfigService,
21 | private s3Service: S3Service,
22 | ) {
23 | const connectionString = this.configService.get(
24 | 'APP_DATABASE_CONNECTION_URL',
25 | );
26 |
27 | this.pool = new Pool({ connectionString });
28 | this.adapter = new PrismaPg(this.pool);
29 | this.prisma = this.createPrismaClient();
30 | }
31 |
32 | async onModuleInit() {
33 | await this.prisma.$connect();
34 | }
35 |
36 | async onModuleDestroy() {
37 | await this.prisma.$disconnect();
38 | await this.pool.end();
39 | }
40 |
41 | private createPrismaClient() {
42 | return new PrismaClient({ adapter: this.adapter })
43 | .$extends({
44 | model: {
45 | $allModels: {
46 | async findManyAndCount(
47 | this: Model,
48 | args: Prisma.Exact>,
49 | ): Promise<{
50 | data: Prisma.Result;
51 | count: number;
52 | }> {
53 | type FindManyArgs = Prisma.Args;
54 | type CountArgs = Prisma.Args;
55 |
56 | const modelDelegate = this as unknown as {
57 | findMany(
58 | a: FindManyArgs,
59 | ): Promise>;
60 | count(a: CountArgs): Promise;
61 | };
62 |
63 | type WhereType = CountArgs extends { where: infer W } ? W : never;
64 |
65 | const [data, count] = await Promise.all([
66 | modelDelegate.findMany(args as FindManyArgs),
67 | modelDelegate.count({
68 | where: (args as FindManyArgs).where as WhereType,
69 | } as CountArgs),
70 | ]);
71 |
72 | return { data, count };
73 | },
74 | },
75 | },
76 | })
77 | .$extends({
78 | query: {
79 | media: {
80 | delete: async ({ args, query }) => {
81 | // -- Fetch the media record to get the key
82 | const media = await this.prisma.media.findUnique({
83 | where: { id: args.where.id },
84 | });
85 |
86 | // -- Run the query and throw an error if the query fails
87 | const queryResult = await query(args);
88 |
89 | // -- The record was deleted successfully...
90 | if (media) {
91 | // Delete the file from S3
92 | await this.s3Service.deleteFile({ key: media.key });
93 | }
94 |
95 | return queryResult;
96 | },
97 | },
98 | },
99 | });
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
|