create-tigra 1.0.7 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +80 -87
- package/bin/create-tigra.js +242 -309
- package/package.json +49 -41
- package/template/_claude/QUICK_REFERENCE.md +193 -0
- package/template/_claude/README.md +53 -0
- package/template/_claude/commands/create-client.md +881 -0
- package/template/_claude/commands/create-server.md +383 -0
- package/template/_claude/rules/client/01-project-structure.md +133 -0
- package/template/_claude/rules/client/02-components-and-types.md +146 -0
- package/template/_claude/rules/client/03-data-and-state.md +156 -0
- package/template/_claude/rules/client/04-design-system.md +185 -0
- package/template/_claude/rules/client/05-security.md +55 -0
- package/template/_claude/rules/client/06-ux-checklist.md +81 -0
- package/template/_claude/rules/client/core.md +42 -0
- package/template/_claude/rules/global/core.md +77 -0
- package/template/_claude/rules/server/core.md +50 -0
- package/template/_claude/rules/server/database.md +124 -0
- package/template/_claude/rules/server/project-conventions.md +150 -0
- package/template/_claude/rules/server/response-handling.md +144 -0
- package/template/client/.env.example +5 -0
- package/template/client/README.md +36 -0
- package/template/client/components.json +23 -0
- package/template/client/eslint.config.mjs +18 -0
- package/template/client/next.config.ts +34 -0
- package/template/client/package.json +44 -0
- package/template/client/postcss.config.mjs +7 -0
- package/template/client/src/app/(auth)/layout.tsx +18 -0
- package/template/client/src/app/(auth)/login/page.tsx +13 -0
- package/template/client/src/app/(auth)/register/page.tsx +13 -0
- package/template/client/src/app/(main)/dashboard/page.tsx +22 -0
- package/template/client/src/app/(main)/layout.tsx +11 -0
- package/template/client/src/app/error.tsx +27 -0
- package/template/client/src/app/favicon.ico +0 -0
- package/template/client/src/app/globals.css +145 -0
- package/template/client/src/app/layout.tsx +36 -0
- package/template/client/src/app/loading.tsx +11 -0
- package/template/client/src/app/not-found.tsx +23 -0
- package/template/client/src/app/page.tsx +45 -0
- package/template/client/src/app/providers.tsx +43 -0
- package/template/client/src/components/common/ConfirmDialog.tsx +56 -0
- package/template/client/src/components/common/EmptyState.tsx +31 -0
- package/template/client/src/components/common/LoadingSpinner.tsx +30 -0
- package/template/client/src/components/common/Pagination.tsx +55 -0
- package/template/client/src/components/layout/Footer.tsx +17 -0
- package/template/client/src/components/layout/Header.tsx +173 -0
- package/template/client/src/components/layout/MainLayout.tsx +18 -0
- package/template/client/src/components/ui/alert-dialog.tsx +196 -0
- package/template/client/src/components/ui/badge.tsx +48 -0
- package/template/client/src/components/ui/button.tsx +64 -0
- package/template/client/src/components/ui/card.tsx +92 -0
- package/template/client/src/components/ui/input.tsx +21 -0
- package/template/client/src/components/ui/label.tsx +24 -0
- package/template/client/src/components/ui/select.tsx +190 -0
- package/template/client/src/components/ui/skeleton.tsx +13 -0
- package/template/client/src/components/ui/table.tsx +116 -0
- package/template/client/src/features/auth/components/AuthInitializer.tsx +55 -0
- package/template/client/src/features/auth/components/LoginForm.tsx +107 -0
- package/template/client/src/features/auth/components/RegisterForm.tsx +178 -0
- package/template/client/src/features/auth/hooks/useAuth.ts +84 -0
- package/template/client/src/features/auth/services/auth.service.ts +52 -0
- package/template/client/src/features/auth/store/authSlice.ts +38 -0
- package/template/client/src/features/auth/types/auth.types.ts +32 -0
- package/template/client/src/hooks/useDebounce.ts +14 -0
- package/template/client/src/hooks/useLocalStorage.ts +55 -0
- package/template/client/src/hooks/useMediaQuery.ts +27 -0
- package/template/client/src/lib/api/api.types.ts +34 -0
- package/template/client/src/lib/api/axios.config.ts +98 -0
- package/template/client/src/lib/constants/api-endpoints.ts +18 -0
- package/template/client/src/lib/constants/app.constants.ts +12 -0
- package/template/client/src/lib/constants/routes.ts +9 -0
- package/template/client/src/lib/utils/error.ts +32 -0
- package/template/client/src/lib/utils/format.ts +37 -0
- package/template/client/src/lib/utils/security.ts +34 -0
- package/template/client/src/lib/utils.ts +6 -0
- package/template/client/src/middleware.ts +57 -0
- package/template/client/src/store/hooks.ts +7 -0
- package/template/client/src/store/index.ts +12 -0
- package/template/client/src/types/index.ts +3 -0
- package/template/client/tsconfig.json +34 -0
- package/template/gitignore +34 -0
- package/template/server/.dockerignore +66 -0
- package/template/server/.env.example +96 -69
- package/template/server/.env.production.example +90 -0
- package/template/server/Dockerfile +94 -0
- package/template/server/docker-compose.yml +80 -111
- package/template/server/docs/logging.md +62 -0
- package/template/server/eslint.config.mjs +17 -0
- package/template/server/package.json +68 -81
- package/template/server/phpmyadmin-config.php +26 -0
- package/template/server/postman_collection.json +666 -0
- package/template/server/prisma/schema.prisma +77 -93
- package/template/server/prisma/seed.ts +46 -142
- package/template/server/scripts/flush-redis.ts +41 -0
- package/template/server/src/app.ts +243 -71
- package/template/server/src/config/env.ts +67 -94
- package/template/server/src/libs/auth.ts +88 -0
- package/template/server/src/libs/cleanup.ts +35 -0
- package/template/server/src/libs/cookies.ts +46 -0
- package/template/server/src/libs/logger.ts +33 -60
- package/template/server/src/libs/monitoring.ts +205 -0
- package/template/server/src/libs/password.ts +38 -0
- package/template/server/src/libs/prisma.ts +68 -0
- package/template/server/src/libs/redis.ts +60 -79
- package/template/server/src/libs/requestLogger.ts +66 -0
- package/template/server/src/libs/storage/file-storage.service.ts +211 -0
- package/template/server/src/libs/storage/file-validator.ts +97 -0
- package/template/server/src/libs/storage/filename-sanitizer.ts +71 -0
- package/template/server/src/libs/storage/image-optimizer.service.ts +144 -0
- package/template/server/src/modules/auth/__tests__/auth.service.test.ts +365 -0
- package/template/server/src/modules/auth/auth.controller.ts +90 -141
- package/template/server/src/modules/auth/auth.repo.ts +120 -218
- package/template/server/src/modules/auth/auth.routes.ts +96 -83
- package/template/server/src/modules/auth/auth.schemas.ts +35 -137
- package/template/server/src/modules/auth/auth.service.ts +286 -329
- package/template/server/src/modules/auth/session.repo.ts +110 -0
- package/template/server/src/modules/users/users.controller.ts +120 -0
- package/template/server/src/modules/users/users.repo.ts +77 -0
- package/template/server/src/modules/users/users.routes.ts +89 -0
- package/template/server/src/modules/users/users.schemas.ts +21 -0
- package/template/server/src/modules/users/users.service.ts +169 -0
- package/template/server/src/server.ts +58 -139
- package/template/server/src/shared/errors/AppError.ts +21 -0
- package/template/server/src/shared/errors/errors.ts +43 -0
- package/template/server/src/shared/responses/paginatedResponse.ts +38 -0
- package/template/server/src/shared/responses/successResponse.ts +17 -0
- package/template/server/src/shared/schemas/pagination.schema.ts +12 -0
- package/template/server/src/shared/types/index.ts +26 -0
- package/template/server/src/test/setup.ts +74 -38
- package/template/server/tsconfig.json +27 -89
- package/template/server/uploads/avatars/.gitkeep +1 -0
- package/template/server/vitest.config.ts +43 -98
- package/template/.agent/rules/client/01-project-structure.md +0 -326
- package/template/.agent/rules/client/02-component-patterns.md +0 -249
- package/template/.agent/rules/client/03-typescript-rules.md +0 -226
- package/template/.agent/rules/client/04-state-management.md +0 -474
- package/template/.agent/rules/client/05-api-integration.md +0 -129
- package/template/.agent/rules/client/06-forms-validation.md +0 -129
- package/template/.agent/rules/client/07-common-patterns.md +0 -150
- package/template/.agent/rules/client/08-color-system.md +0 -93
- package/template/.agent/rules/client/09-security-rules.md +0 -97
- package/template/.agent/rules/client/10-testing-strategy.md +0 -370
- package/template/.agent/rules/global/ai-edit-safety.md +0 -38
- package/template/.agent/rules/server/01-db-and-migrations.md +0 -242
- package/template/.agent/rules/server/02-general-rules.md +0 -111
- package/template/.agent/rules/server/03-migrations.md +0 -20
- package/template/.agent/rules/server/04-pagination.md +0 -130
- package/template/.agent/rules/server/05-project-conventions.md +0 -71
- package/template/.agent/rules/server/06-response-handling.md +0 -173
- package/template/.agent/rules/server/07-testing-strategy.md +0 -506
- package/template/.agent/rules/server/08-observability.md +0 -180
- package/template/.agent/rules/server/10-background-jobs-v2.md +0 -185
- package/template/.agent/rules/server/11-rate-limiting-v2.md +0 -210
- package/template/.agent/rules/server/12-performance-optimization.md +0 -567
- package/template/.claude/rules/client-01-project-structure.md +0 -327
- package/template/.claude/rules/client-02-component-patterns.md +0 -250
- package/template/.claude/rules/client-03-typescript-rules.md +0 -227
- package/template/.claude/rules/client-04-state-management.md +0 -475
- package/template/.claude/rules/client-05-api-integration.md +0 -130
- package/template/.claude/rules/client-06-forms-validation.md +0 -130
- package/template/.claude/rules/client-07-common-patterns.md +0 -151
- package/template/.claude/rules/client-08-color-system.md +0 -94
- package/template/.claude/rules/client-09-security-rules.md +0 -98
- package/template/.claude/rules/client-10-testing-strategy.md +0 -371
- package/template/.claude/rules/global-ai-edit-safety.md +0 -39
- package/template/.claude/rules/server-01-db-and-migrations.md +0 -243
- package/template/.claude/rules/server-02-general-rules.md +0 -112
- package/template/.claude/rules/server-03-migrations.md +0 -21
- package/template/.claude/rules/server-04-pagination.md +0 -131
- package/template/.claude/rules/server-05-project-conventions.md +0 -72
- package/template/.claude/rules/server-06-response-handling.md +0 -174
- package/template/.claude/rules/server-07-testing-strategy.md +0 -507
- package/template/.claude/rules/server-08-observability.md +0 -181
- package/template/.claude/rules/server-10-background-jobs-v2.md +0 -186
- package/template/.claude/rules/server-11-rate-limiting-v2.md +0 -211
- package/template/.claude/rules/server-12-performance-optimization.md +0 -568
- package/template/.cursor/rules/client-01-project-structure.mdc +0 -327
- package/template/.cursor/rules/client-02-component-patterns.mdc +0 -250
- package/template/.cursor/rules/client-03-typescript-rules.mdc +0 -227
- package/template/.cursor/rules/client-04-state-management.mdc +0 -475
- package/template/.cursor/rules/client-05-api-integration.mdc +0 -130
- package/template/.cursor/rules/client-06-forms-validation.mdc +0 -130
- package/template/.cursor/rules/client-07-common-patterns.mdc +0 -151
- package/template/.cursor/rules/client-08-color-system.mdc +0 -94
- package/template/.cursor/rules/client-09-security-rules.mdc +0 -98
- package/template/.cursor/rules/client-10-testing-strategy.mdc +0 -371
- package/template/.cursor/rules/global-ai-edit-safety.mdc +0 -39
- package/template/.cursor/rules/server-01-db-and-migrations.mdc +0 -243
- package/template/.cursor/rules/server-02-general-rules.mdc +0 -112
- package/template/.cursor/rules/server-03-migrations.mdc +0 -21
- package/template/.cursor/rules/server-04-pagination.mdc +0 -131
- package/template/.cursor/rules/server-05-project-conventions.mdc +0 -72
- package/template/.cursor/rules/server-06-response-handling.mdc +0 -174
- package/template/.cursor/rules/server-07-testing-strategy.mdc +0 -507
- package/template/.cursor/rules/server-08-observability.mdc +0 -181
- package/template/.cursor/rules/server-09-api-documentation-v2.mdc +0 -169
- package/template/.cursor/rules/server-10-background-jobs-v2.mdc +0 -186
- package/template/.cursor/rules/server-11-rate-limiting-v2.mdc +0 -211
- package/template/.cursor/rules/server-12-performance-optimization.mdc +0 -568
- package/template/CLAUDE.md +0 -207
- package/template/server/.tsc-aliasrc.json +0 -12
- package/template/server/README.md +0 -183
- package/template/server/SECURITY.md +0 -190
- package/template/server/Tigra-API.postman_collection.json +0 -733
- package/template/server/biome.json +0 -42
- package/template/server/scripts/setup-env.js +0 -50
- package/template/server/scripts/wait-for-db.js +0 -60
- package/template/server/src/hooks/request-timing.hook.ts +0 -26
- package/template/server/src/libs/auth/authenticate.middleware.ts +0 -22
- package/template/server/src/libs/auth/rbac.middleware.test.ts +0 -134
- package/template/server/src/libs/auth/rbac.middleware.ts +0 -147
- package/template/server/src/libs/db.ts +0 -76
- package/template/server/src/libs/error-handler.ts +0 -89
- package/template/server/src/libs/queue.ts +0 -79
- package/template/server/src/modules/admin/admin.controller.ts +0 -122
- package/template/server/src/modules/admin/admin.routes.ts +0 -62
- package/template/server/src/modules/admin/admin.schemas.ts +0 -35
- package/template/server/src/modules/admin/admin.service.ts +0 -167
- package/template/server/src/modules/auth/auth.integration.test.ts +0 -150
- package/template/server/src/modules/auth/auth.service.test.ts +0 -119
- package/template/server/src/modules/auth/auth.types.ts +0 -97
- package/template/server/src/modules/resources/resources.controller.ts +0 -218
- package/template/server/src/modules/resources/resources.repo.ts +0 -253
- package/template/server/src/modules/resources/resources.routes.ts +0 -116
- package/template/server/src/modules/resources/resources.schemas.ts +0 -146
- package/template/server/src/modules/resources/resources.service.ts +0 -218
- package/template/server/src/modules/resources/resources.types.ts +0 -73
- package/template/server/src/plugins/rate-limit.plugin.ts +0 -21
- package/template/server/src/plugins/security.plugin.ts +0 -21
- package/template/server/src/routes/health.routes.ts +0 -31
- package/template/server/src/types/fastify.d.ts +0 -36
- package/template/server/src/utils/errors.ts +0 -108
- package/template/server/src/utils/pagination.ts +0 -120
- package/template/server/src/utils/response.ts +0 -110
- package/template/server/src/workers/file.worker.ts +0 -106
- package/template/server/tsconfig.build.json +0 -30
- package/template/server/tsconfig.test.json +0 -22
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { prisma } from '@libs/prisma.js';
|
|
2
|
+
import type { Session } from '@prisma/client';
|
|
3
|
+
|
|
4
|
+
export class SessionRepository {
|
|
5
|
+
/**
|
|
6
|
+
* Create a new session
|
|
7
|
+
*/
|
|
8
|
+
async createSession(data: {
|
|
9
|
+
userId: string;
|
|
10
|
+
deviceInfo?: string;
|
|
11
|
+
ipAddress?: string;
|
|
12
|
+
expiresAt: Date;
|
|
13
|
+
}): Promise<Session> {
|
|
14
|
+
return prisma.session.create({
|
|
15
|
+
data: {
|
|
16
|
+
userId: data.userId,
|
|
17
|
+
deviceInfo: data.deviceInfo,
|
|
18
|
+
ipAddress: data.ipAddress,
|
|
19
|
+
expiresAt: data.expiresAt,
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get all active sessions for a user
|
|
26
|
+
*/
|
|
27
|
+
async getUserSessions(userId: string): Promise<Session[]> {
|
|
28
|
+
return prisma.session.findMany({
|
|
29
|
+
where: {
|
|
30
|
+
userId,
|
|
31
|
+
expiresAt: {
|
|
32
|
+
gt: new Date(),
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
orderBy: {
|
|
36
|
+
lastActiveAt: 'desc',
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Update session last active timestamp
|
|
43
|
+
*/
|
|
44
|
+
async updateLastActive(sessionId: string): Promise<Session> {
|
|
45
|
+
return prisma.session.update({
|
|
46
|
+
where: { id: sessionId },
|
|
47
|
+
data: {
|
|
48
|
+
lastActiveAt: new Date(),
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Delete a specific session
|
|
55
|
+
*/
|
|
56
|
+
async deleteSession(sessionId: string): Promise<void> {
|
|
57
|
+
await prisma.session.delete({
|
|
58
|
+
where: { id: sessionId },
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Delete all sessions for a user
|
|
64
|
+
*/
|
|
65
|
+
async deleteAllUserSessions(userId: string): Promise<number> {
|
|
66
|
+
const result = await prisma.session.deleteMany({
|
|
67
|
+
where: { userId },
|
|
68
|
+
});
|
|
69
|
+
return result.count;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Delete expired sessions (for cleanup cron job)
|
|
74
|
+
*/
|
|
75
|
+
async deleteExpiredSessions(): Promise<number> {
|
|
76
|
+
const result = await prisma.session.deleteMany({
|
|
77
|
+
where: {
|
|
78
|
+
expiresAt: {
|
|
79
|
+
lt: new Date(),
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
return result.count;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get session by ID
|
|
88
|
+
*/
|
|
89
|
+
async getSessionById(sessionId: string): Promise<Session | null> {
|
|
90
|
+
return prisma.session.findUnique({
|
|
91
|
+
where: { id: sessionId },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Count active sessions for a user
|
|
97
|
+
*/
|
|
98
|
+
async countUserSessions(userId: string): Promise<number> {
|
|
99
|
+
return prisma.session.count({
|
|
100
|
+
where: {
|
|
101
|
+
userId,
|
|
102
|
+
expiresAt: {
|
|
103
|
+
gt: new Date(),
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const sessionRepository = new SessionRepository();
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Users Controller
|
|
3
|
+
*
|
|
4
|
+
* Request handlers for user endpoints.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { usersService } from './users.service.js';
|
|
10
|
+
import { validateImageFile, validateFileSize } from '@libs/storage/file-validator.js';
|
|
11
|
+
import { successResponse } from '@shared/responses/successResponse.js';
|
|
12
|
+
import type { GetUserAvatarParams } from './users.schemas.js';
|
|
13
|
+
import type { AuthenticatedRequest } from '@shared/types/index.js';
|
|
14
|
+
import { logger } from '@libs/logger.js';
|
|
15
|
+
import { BadRequestError } from '@shared/errors/errors.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Users Controller Class
|
|
19
|
+
*
|
|
20
|
+
* Handles HTTP requests for user operations.
|
|
21
|
+
*/
|
|
22
|
+
class UsersController {
|
|
23
|
+
/**
|
|
24
|
+
* Upload avatar handler
|
|
25
|
+
*
|
|
26
|
+
* POST /api/v1/users/avatar
|
|
27
|
+
* Requires: multipart/form-data with file field
|
|
28
|
+
* Auth: Required (JWT)
|
|
29
|
+
*
|
|
30
|
+
* @param request - Fastify request (authenticated)
|
|
31
|
+
* @param reply - Fastify reply
|
|
32
|
+
*/
|
|
33
|
+
async uploadAvatar(request: AuthenticatedRequest, reply: FastifyReply): Promise<void> {
|
|
34
|
+
// Get uploaded file from multipart request
|
|
35
|
+
const data = await request.file();
|
|
36
|
+
|
|
37
|
+
if (!data) {
|
|
38
|
+
throw new BadRequestError('No file uploaded', 'NO_FILE');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Validate file (MIME type, extension)
|
|
42
|
+
validateImageFile(data);
|
|
43
|
+
|
|
44
|
+
// Read file buffer
|
|
45
|
+
const buffer = await data.toBuffer();
|
|
46
|
+
|
|
47
|
+
// Validate file size
|
|
48
|
+
validateFileSize(buffer);
|
|
49
|
+
|
|
50
|
+
// Get authenticated user ID
|
|
51
|
+
const userId = request.user.userId;
|
|
52
|
+
|
|
53
|
+
logger.info({
|
|
54
|
+
msg: 'Avatar upload request',
|
|
55
|
+
userId,
|
|
56
|
+
filename: data.filename,
|
|
57
|
+
mimetype: data.mimetype,
|
|
58
|
+
size: buffer.length,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Call service to process and save avatar
|
|
62
|
+
const updatedUser = await usersService.uploadAvatar(userId, buffer, data.filename);
|
|
63
|
+
|
|
64
|
+
return reply.send(
|
|
65
|
+
successResponse('Avatar uploaded successfully', {
|
|
66
|
+
avatarUrl: updatedUser.avatarUrl,
|
|
67
|
+
})
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Delete avatar handler
|
|
73
|
+
*
|
|
74
|
+
* DELETE /api/v1/users/avatar
|
|
75
|
+
* Auth: Required (JWT)
|
|
76
|
+
*
|
|
77
|
+
* @param request - Fastify request (authenticated)
|
|
78
|
+
* @param reply - Fastify reply
|
|
79
|
+
*/
|
|
80
|
+
async deleteAvatar(request: AuthenticatedRequest, reply: FastifyReply): Promise<void> {
|
|
81
|
+
// Get authenticated user ID
|
|
82
|
+
const userId = request.user.userId;
|
|
83
|
+
|
|
84
|
+
logger.info({ msg: 'Avatar delete request', userId });
|
|
85
|
+
|
|
86
|
+
// Call service to delete avatar
|
|
87
|
+
await usersService.deleteAvatar(userId);
|
|
88
|
+
|
|
89
|
+
return reply.send(successResponse('Avatar deleted successfully', null));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get avatar handler
|
|
94
|
+
*
|
|
95
|
+
* GET /api/v1/users/:userId/avatar
|
|
96
|
+
* Auth: Not required (public endpoint)
|
|
97
|
+
*
|
|
98
|
+
* Returns the avatar file directly or 404 if not found.
|
|
99
|
+
*
|
|
100
|
+
* @param request - Fastify request
|
|
101
|
+
* @param reply - Fastify reply
|
|
102
|
+
*/
|
|
103
|
+
async getAvatar(
|
|
104
|
+
request: FastifyRequest<{ Params: GetUserAvatarParams }>,
|
|
105
|
+
reply: FastifyReply
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
const { userId } = request.params;
|
|
108
|
+
|
|
109
|
+
logger.info({ msg: 'Avatar fetch request', userId });
|
|
110
|
+
|
|
111
|
+
// Get avatar file path
|
|
112
|
+
const { path: filePath } = await usersService.getAvatar(userId);
|
|
113
|
+
|
|
114
|
+
// Send file directly
|
|
115
|
+
return reply.sendFile(path.basename(filePath), path.dirname(filePath));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Export singleton instance
|
|
120
|
+
export const usersController = new UsersController();
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Users Repository
|
|
3
|
+
*
|
|
4
|
+
* Data access layer for user operations.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { prisma } from '@libs/prisma.js';
|
|
8
|
+
import type { UserRole } from '@shared/types/index.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* User selection fields (excludes sensitive data)
|
|
12
|
+
*/
|
|
13
|
+
const userSelect = {
|
|
14
|
+
id: true,
|
|
15
|
+
email: true,
|
|
16
|
+
firstName: true,
|
|
17
|
+
lastName: true,
|
|
18
|
+
role: true,
|
|
19
|
+
avatarUrl: true,
|
|
20
|
+
isActive: true,
|
|
21
|
+
createdAt: true,
|
|
22
|
+
updatedAt: true,
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export type SafeUser = {
|
|
26
|
+
id: string;
|
|
27
|
+
email: string;
|
|
28
|
+
firstName: string;
|
|
29
|
+
lastName: string;
|
|
30
|
+
role: UserRole;
|
|
31
|
+
avatarUrl: string | null;
|
|
32
|
+
isActive: boolean;
|
|
33
|
+
createdAt: Date;
|
|
34
|
+
updatedAt: Date;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Users Repository Class
|
|
39
|
+
*
|
|
40
|
+
* Handles all database operations for users.
|
|
41
|
+
*/
|
|
42
|
+
class UsersRepository {
|
|
43
|
+
/**
|
|
44
|
+
* Gets a user by ID
|
|
45
|
+
*/
|
|
46
|
+
async getUserById(userId: string): Promise<SafeUser | null> {
|
|
47
|
+
return prisma.user.findUnique({
|
|
48
|
+
where: { id: userId, deletedAt: null },
|
|
49
|
+
select: userSelect,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Updates a user's avatar URL
|
|
55
|
+
*/
|
|
56
|
+
async updateUserAvatar(userId: string, avatarUrl: string): Promise<SafeUser> {
|
|
57
|
+
return prisma.user.update({
|
|
58
|
+
where: { id: userId },
|
|
59
|
+
data: { avatarUrl },
|
|
60
|
+
select: userSelect,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Clears a user's avatar URL
|
|
66
|
+
*/
|
|
67
|
+
async clearUserAvatar(userId: string): Promise<SafeUser> {
|
|
68
|
+
return prisma.user.update({
|
|
69
|
+
where: { id: userId },
|
|
70
|
+
data: { avatarUrl: null },
|
|
71
|
+
select: userSelect,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Export singleton instance
|
|
77
|
+
export const usersRepository = new UsersRepository();
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Users Routes
|
|
3
|
+
*
|
|
4
|
+
* Defines HTTP routes for user operations.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { FastifyInstance } from 'fastify';
|
|
8
|
+
import { usersController } from './users.controller.js';
|
|
9
|
+
import { GetUserAvatarSchema } from './users.schemas.js';
|
|
10
|
+
import { authenticate } from '@libs/auth.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Users routes plugin
|
|
14
|
+
*
|
|
15
|
+
* Endpoints:
|
|
16
|
+
* - POST /users/avatar - Upload avatar (authenticated)
|
|
17
|
+
* - DELETE /users/avatar - Delete avatar (authenticated)
|
|
18
|
+
* - GET /users/:userId/avatar - Get avatar (public)
|
|
19
|
+
*
|
|
20
|
+
* @param fastify - Fastify instance
|
|
21
|
+
*/
|
|
22
|
+
export async function usersRoutes(fastify: FastifyInstance): Promise<void> {
|
|
23
|
+
/**
|
|
24
|
+
* Upload avatar
|
|
25
|
+
*
|
|
26
|
+
* POST /api/v1/users/avatar
|
|
27
|
+
* Content-Type: multipart/form-data
|
|
28
|
+
* Body: { file: File }
|
|
29
|
+
* Auth: Required
|
|
30
|
+
* Rate limit: 5 requests per minute
|
|
31
|
+
*/
|
|
32
|
+
fastify.post(
|
|
33
|
+
'/users/avatar',
|
|
34
|
+
{
|
|
35
|
+
preValidation: [authenticate],
|
|
36
|
+
config: {
|
|
37
|
+
rateLimit: {
|
|
38
|
+
max: 5,
|
|
39
|
+
timeWindow: '1 minute',
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
usersController.uploadAvatar.bind(usersController)
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Delete avatar
|
|
48
|
+
*
|
|
49
|
+
* DELETE /api/v1/users/avatar
|
|
50
|
+
* Auth: Required
|
|
51
|
+
* Rate limit: 10 requests per minute
|
|
52
|
+
*/
|
|
53
|
+
fastify.delete(
|
|
54
|
+
'/users/avatar',
|
|
55
|
+
{
|
|
56
|
+
preValidation: [authenticate],
|
|
57
|
+
config: {
|
|
58
|
+
rateLimit: {
|
|
59
|
+
max: 10,
|
|
60
|
+
timeWindow: '1 minute',
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
usersController.deleteAvatar.bind(usersController)
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get user avatar
|
|
69
|
+
*
|
|
70
|
+
* GET /api/v1/users/:userId/avatar
|
|
71
|
+
* Auth: Not required (public)
|
|
72
|
+
* Rate limit: 100 requests per minute per IP
|
|
73
|
+
*/
|
|
74
|
+
fastify.get<{ Params: { userId: string } }>(
|
|
75
|
+
'/users/:userId/avatar',
|
|
76
|
+
{
|
|
77
|
+
schema: {
|
|
78
|
+
params: GetUserAvatarSchema,
|
|
79
|
+
},
|
|
80
|
+
config: {
|
|
81
|
+
rateLimit: {
|
|
82
|
+
max: 100,
|
|
83
|
+
timeWindow: '1 minute',
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
usersController.getAvatar.bind(usersController)
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Users Module Zod Schemas
|
|
3
|
+
*
|
|
4
|
+
* Validation schemas for user endpoints.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Schema for getting a user's avatar
|
|
11
|
+
*
|
|
12
|
+
* Validates userId parameter in URL
|
|
13
|
+
*/
|
|
14
|
+
export const GetUserAvatarSchema = z.object({
|
|
15
|
+
userId: z.string().uuid({ message: 'Invalid user ID format' }),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Type inference from schemas
|
|
20
|
+
*/
|
|
21
|
+
export type GetUserAvatarParams = z.infer<typeof GetUserAvatarSchema>;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Users Service
|
|
3
|
+
*
|
|
4
|
+
* Business logic for user operations.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { usersRepository } from './users.repo.js';
|
|
8
|
+
import type { SafeUser } from './users.repo.js';
|
|
9
|
+
import { fileStorageService } from '@libs/storage/file-storage.service.js';
|
|
10
|
+
import { imageOptimizerService } from '@libs/storage/image-optimizer.service.js';
|
|
11
|
+
import { NotFoundError, BadRequestError } from '@shared/errors/errors.js';
|
|
12
|
+
import { logger } from '@libs/logger.js';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Users Service Class
|
|
17
|
+
*
|
|
18
|
+
* Handles business logic for user avatar operations.
|
|
19
|
+
*/
|
|
20
|
+
class UsersService {
|
|
21
|
+
/**
|
|
22
|
+
* Uploads and sets a user's avatar
|
|
23
|
+
*
|
|
24
|
+
* Process:
|
|
25
|
+
* 1. Get user from database (need first/last name for SEO filename)
|
|
26
|
+
* 2. Optimize image (resize, compress, convert to WebP)
|
|
27
|
+
* 3. Generate SEO-friendly filename
|
|
28
|
+
* 4. Save to user-specific directory
|
|
29
|
+
* 5. Update user record with avatar URL
|
|
30
|
+
*
|
|
31
|
+
* @param userId - User's unique ID
|
|
32
|
+
* @param fileBuffer - Uploaded image buffer
|
|
33
|
+
* @param originalFilename - Original filename (for logging)
|
|
34
|
+
* @returns Updated user with new avatar URL
|
|
35
|
+
* @throws NotFoundError if user not found
|
|
36
|
+
*/
|
|
37
|
+
async uploadAvatar(
|
|
38
|
+
userId: string,
|
|
39
|
+
fileBuffer: Buffer,
|
|
40
|
+
originalFilename: string
|
|
41
|
+
): Promise<SafeUser> {
|
|
42
|
+
// Get current user
|
|
43
|
+
const user = await usersRepository.getUserById(userId);
|
|
44
|
+
if (!user) {
|
|
45
|
+
throw new NotFoundError('User not found', 'USER_NOT_FOUND');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
logger.info({
|
|
49
|
+
msg: 'Processing avatar upload',
|
|
50
|
+
userId,
|
|
51
|
+
originalFilename,
|
|
52
|
+
fileSize: fileBuffer.length,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Optimize image (resize, compress, convert to WebP)
|
|
56
|
+
const optimizedBuffer = await imageOptimizerService.optimizeAvatar(fileBuffer);
|
|
57
|
+
|
|
58
|
+
// Save to user-specific directory with SEO-friendly filename
|
|
59
|
+
const { filename, url } = await fileStorageService.saveAvatar(
|
|
60
|
+
userId,
|
|
61
|
+
optimizedBuffer,
|
|
62
|
+
user.firstName,
|
|
63
|
+
user.lastName
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
logger.info({
|
|
67
|
+
msg: 'Avatar file saved',
|
|
68
|
+
userId,
|
|
69
|
+
filename,
|
|
70
|
+
url,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Update user record with avatar URL
|
|
74
|
+
const updatedUser = await usersRepository.updateUserAvatar(userId, url);
|
|
75
|
+
|
|
76
|
+
logger.info({ msg: 'Avatar upload complete', userId, avatarUrl: url });
|
|
77
|
+
|
|
78
|
+
return updatedUser;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Deletes a user's avatar
|
|
83
|
+
*
|
|
84
|
+
* Process:
|
|
85
|
+
* 1. Get user from database
|
|
86
|
+
* 2. Verify user has an avatar
|
|
87
|
+
* 3. Delete avatar directory from file system
|
|
88
|
+
* 4. Clear avatar URL in database
|
|
89
|
+
*
|
|
90
|
+
* @param userId - User's unique ID
|
|
91
|
+
* @throws NotFoundError if user not found
|
|
92
|
+
* @throws BadRequestError if user has no avatar to delete
|
|
93
|
+
*/
|
|
94
|
+
async deleteAvatar(userId: string): Promise<void> {
|
|
95
|
+
// Get current user
|
|
96
|
+
const user = await usersRepository.getUserById(userId);
|
|
97
|
+
if (!user) {
|
|
98
|
+
throw new NotFoundError('User not found', 'USER_NOT_FOUND');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Check if user has an avatar
|
|
102
|
+
if (!user.avatarUrl) {
|
|
103
|
+
throw new BadRequestError('User has no avatar to delete', 'NO_AVATAR');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
logger.info({ msg: 'Deleting user avatar', userId, avatarUrl: user.avatarUrl });
|
|
107
|
+
|
|
108
|
+
// Delete avatar directory and all contents
|
|
109
|
+
await fileStorageService.deleteAvatar(userId);
|
|
110
|
+
|
|
111
|
+
// Clear avatar URL in database
|
|
112
|
+
await usersRepository.clearUserAvatar(userId);
|
|
113
|
+
|
|
114
|
+
logger.info({ msg: 'Avatar deleted successfully', userId });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Gets a user's avatar file path
|
|
119
|
+
*
|
|
120
|
+
* Process:
|
|
121
|
+
* 1. Get user from database
|
|
122
|
+
* 2. Verify user has an avatar
|
|
123
|
+
* 3. Extract filename from avatar URL
|
|
124
|
+
* 4. Build and verify file path
|
|
125
|
+
*
|
|
126
|
+
* @param userId - User's unique ID
|
|
127
|
+
* @returns Absolute file path and public URL
|
|
128
|
+
* @throws NotFoundError if user not found or has no avatar
|
|
129
|
+
*/
|
|
130
|
+
async getAvatar(userId: string): Promise<{ path: string; url: string }> {
|
|
131
|
+
// Get user
|
|
132
|
+
const user = await usersRepository.getUserById(userId);
|
|
133
|
+
if (!user) {
|
|
134
|
+
throw new NotFoundError('User not found', 'USER_NOT_FOUND');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Check if user has an avatar
|
|
138
|
+
if (!user.avatarUrl) {
|
|
139
|
+
throw new NotFoundError('User has no avatar', 'NO_AVATAR');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Extract filename from URL (last segment)
|
|
143
|
+
// URL format: /uploads/avatars/{userId}/{filename}
|
|
144
|
+
const filename = path.basename(user.avatarUrl);
|
|
145
|
+
|
|
146
|
+
// Build full file path
|
|
147
|
+
const filePath = fileStorageService.getAvatarPath(userId, filename);
|
|
148
|
+
|
|
149
|
+
// Verify file exists on disk
|
|
150
|
+
const exists = await fileStorageService.fileExists(filePath);
|
|
151
|
+
if (!exists) {
|
|
152
|
+
logger.error({
|
|
153
|
+
msg: 'Avatar file missing from disk',
|
|
154
|
+
userId,
|
|
155
|
+
avatarUrl: user.avatarUrl,
|
|
156
|
+
filePath,
|
|
157
|
+
});
|
|
158
|
+
throw new NotFoundError('Avatar file not found on disk', 'AVATAR_FILE_NOT_FOUND');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
path: filePath,
|
|
163
|
+
url: user.avatarUrl,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Export singleton instance
|
|
169
|
+
export const usersService = new UsersService();
|