create-tigra 1.1.0 → 2.0.1

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.
Files changed (243) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +80 -87
  3. package/bin/create-tigra.js +259 -308
  4. package/package.json +49 -41
  5. package/template/_claude/QUICK_REFERENCE.md +193 -0
  6. package/template/_claude/README.md +53 -0
  7. package/template/_claude/commands/create-client.md +881 -0
  8. package/template/_claude/commands/create-server.md +383 -0
  9. package/template/_claude/rules/client/01-project-structure.md +133 -0
  10. package/template/_claude/rules/client/02-components-and-types.md +146 -0
  11. package/template/_claude/rules/client/03-data-and-state.md +156 -0
  12. package/template/_claude/rules/client/04-design-system.md +185 -0
  13. package/template/_claude/rules/client/05-security.md +55 -0
  14. package/template/_claude/rules/client/06-ux-checklist.md +81 -0
  15. package/template/_claude/rules/client/core.md +42 -0
  16. package/template/_claude/rules/global/core.md +77 -0
  17. package/template/_claude/rules/server/core.md +50 -0
  18. package/template/_claude/rules/server/database.md +124 -0
  19. package/template/_claude/rules/server/project-conventions.md +150 -0
  20. package/template/_claude/rules/server/response-handling.md +144 -0
  21. package/template/client/.env.example +5 -0
  22. package/template/client/README.md +36 -0
  23. package/template/client/components.json +23 -0
  24. package/template/client/eslint.config.mjs +18 -0
  25. package/template/client/next.config.ts +34 -0
  26. package/template/client/package.json +44 -0
  27. package/template/client/postcss.config.mjs +7 -0
  28. package/template/client/src/app/(auth)/layout.tsx +18 -0
  29. package/template/client/src/app/(auth)/login/page.tsx +13 -0
  30. package/template/client/src/app/(auth)/register/page.tsx +13 -0
  31. package/template/client/src/app/(main)/dashboard/page.tsx +22 -0
  32. package/template/client/src/app/(main)/layout.tsx +11 -0
  33. package/template/client/src/app/error.tsx +27 -0
  34. package/template/client/src/app/favicon.ico +0 -0
  35. package/template/client/src/app/globals.css +145 -0
  36. package/template/client/src/app/layout.tsx +36 -0
  37. package/template/client/src/app/loading.tsx +11 -0
  38. package/template/client/src/app/not-found.tsx +23 -0
  39. package/template/client/src/app/page.tsx +45 -0
  40. package/template/client/src/app/providers.tsx +43 -0
  41. package/template/client/src/components/common/ConfirmDialog.tsx +56 -0
  42. package/template/client/src/components/common/EmptyState.tsx +31 -0
  43. package/template/client/src/components/common/LoadingSpinner.tsx +30 -0
  44. package/template/client/src/components/common/Pagination.tsx +55 -0
  45. package/template/client/src/components/layout/Footer.tsx +17 -0
  46. package/template/client/src/components/layout/Header.tsx +173 -0
  47. package/template/client/src/components/layout/MainLayout.tsx +18 -0
  48. package/template/client/src/components/ui/alert-dialog.tsx +196 -0
  49. package/template/client/src/components/ui/badge.tsx +48 -0
  50. package/template/client/src/components/ui/button.tsx +64 -0
  51. package/template/client/src/components/ui/card.tsx +92 -0
  52. package/template/client/src/components/ui/input.tsx +21 -0
  53. package/template/client/src/components/ui/label.tsx +24 -0
  54. package/template/client/src/components/ui/select.tsx +190 -0
  55. package/template/client/src/components/ui/skeleton.tsx +13 -0
  56. package/template/client/src/components/ui/table.tsx +116 -0
  57. package/template/client/src/features/auth/components/AuthInitializer.tsx +55 -0
  58. package/template/client/src/features/auth/components/LoginForm.tsx +107 -0
  59. package/template/client/src/features/auth/components/RegisterForm.tsx +178 -0
  60. package/template/client/src/features/auth/hooks/useAuth.ts +84 -0
  61. package/template/client/src/features/auth/services/auth.service.ts +52 -0
  62. package/template/client/src/features/auth/store/authSlice.ts +38 -0
  63. package/template/client/src/features/auth/types/auth.types.ts +32 -0
  64. package/template/client/src/hooks/useDebounce.ts +14 -0
  65. package/template/client/src/hooks/useLocalStorage.ts +55 -0
  66. package/template/client/src/hooks/useMediaQuery.ts +27 -0
  67. package/template/client/src/lib/api/api.types.ts +34 -0
  68. package/template/client/src/lib/api/axios.config.ts +98 -0
  69. package/template/client/src/lib/constants/api-endpoints.ts +18 -0
  70. package/template/client/src/lib/constants/app.constants.ts +12 -0
  71. package/template/client/src/lib/constants/routes.ts +9 -0
  72. package/template/client/src/lib/utils/error.ts +32 -0
  73. package/template/client/src/lib/utils/format.ts +37 -0
  74. package/template/client/src/lib/utils/security.ts +34 -0
  75. package/template/client/src/lib/utils.ts +6 -0
  76. package/template/client/src/middleware.ts +57 -0
  77. package/template/client/src/store/hooks.ts +7 -0
  78. package/template/client/src/store/index.ts +12 -0
  79. package/template/client/src/types/index.ts +3 -0
  80. package/template/client/tsconfig.json +34 -0
  81. package/template/gitignore +34 -0
  82. package/template/server/.dockerignore +66 -0
  83. package/template/server/.env.example +96 -69
  84. package/template/server/.env.production.example +90 -0
  85. package/template/server/Dockerfile +94 -0
  86. package/template/server/docker-compose.yml +82 -111
  87. package/template/server/docs/logging.md +62 -0
  88. package/template/server/eslint.config.mjs +17 -0
  89. package/template/server/package.json +68 -81
  90. package/template/server/phpmyadmin-config.php +26 -0
  91. package/template/server/postman_collection.json +666 -0
  92. package/template/server/prisma/schema.prisma +77 -93
  93. package/template/server/prisma/seed.ts +46 -142
  94. package/template/server/scripts/flush-redis.ts +41 -0
  95. package/template/server/src/app.ts +243 -71
  96. package/template/server/src/config/env.ts +67 -94
  97. package/template/server/src/libs/auth.ts +88 -0
  98. package/template/server/src/libs/cleanup.ts +35 -0
  99. package/template/server/src/libs/cookies.ts +46 -0
  100. package/template/server/src/libs/logger.ts +33 -60
  101. package/template/server/src/libs/monitoring.ts +205 -0
  102. package/template/server/src/libs/password.ts +38 -0
  103. package/template/server/src/libs/prisma.ts +68 -0
  104. package/template/server/src/libs/redis.ts +60 -79
  105. package/template/server/src/libs/requestLogger.ts +66 -0
  106. package/template/server/src/libs/storage/file-storage.service.ts +211 -0
  107. package/template/server/src/libs/storage/file-validator.ts +97 -0
  108. package/template/server/src/libs/storage/filename-sanitizer.ts +71 -0
  109. package/template/server/src/libs/storage/image-optimizer.service.ts +144 -0
  110. package/template/server/src/modules/auth/__tests__/auth.service.test.ts +365 -0
  111. package/template/server/src/modules/auth/auth.controller.ts +90 -141
  112. package/template/server/src/modules/auth/auth.repo.ts +120 -218
  113. package/template/server/src/modules/auth/auth.routes.ts +96 -83
  114. package/template/server/src/modules/auth/auth.schemas.ts +35 -137
  115. package/template/server/src/modules/auth/auth.service.ts +286 -329
  116. package/template/server/src/modules/auth/session.repo.ts +110 -0
  117. package/template/server/src/modules/users/users.controller.ts +120 -0
  118. package/template/server/src/modules/users/users.repo.ts +77 -0
  119. package/template/server/src/modules/users/users.routes.ts +89 -0
  120. package/template/server/src/modules/users/users.schemas.ts +21 -0
  121. package/template/server/src/modules/users/users.service.ts +169 -0
  122. package/template/server/src/server.ts +58 -139
  123. package/template/server/src/shared/errors/AppError.ts +21 -0
  124. package/template/server/src/shared/errors/errors.ts +43 -0
  125. package/template/server/src/shared/responses/paginatedResponse.ts +38 -0
  126. package/template/server/src/shared/responses/successResponse.ts +17 -0
  127. package/template/server/src/shared/schemas/pagination.schema.ts +12 -0
  128. package/template/server/src/shared/types/index.ts +26 -0
  129. package/template/server/src/test/setup.ts +74 -38
  130. package/template/server/tsconfig.json +27 -89
  131. package/template/server/uploads/avatars/.gitkeep +1 -0
  132. package/template/server/vitest.config.ts +43 -98
  133. package/template/.agent/rules/client/01-project-structure.md +0 -326
  134. package/template/.agent/rules/client/02-component-patterns.md +0 -249
  135. package/template/.agent/rules/client/03-typescript-rules.md +0 -226
  136. package/template/.agent/rules/client/04-state-management.md +0 -474
  137. package/template/.agent/rules/client/05-api-integration.md +0 -129
  138. package/template/.agent/rules/client/06-forms-validation.md +0 -129
  139. package/template/.agent/rules/client/07-common-patterns.md +0 -150
  140. package/template/.agent/rules/client/08-color-system.md +0 -93
  141. package/template/.agent/rules/client/09-security-rules.md +0 -97
  142. package/template/.agent/rules/client/10-testing-strategy.md +0 -370
  143. package/template/.agent/rules/global/ai-edit-safety.md +0 -38
  144. package/template/.agent/rules/server/01-db-and-migrations.md +0 -242
  145. package/template/.agent/rules/server/02-general-rules.md +0 -111
  146. package/template/.agent/rules/server/03-migrations.md +0 -20
  147. package/template/.agent/rules/server/04-pagination.md +0 -130
  148. package/template/.agent/rules/server/05-project-conventions.md +0 -71
  149. package/template/.agent/rules/server/06-response-handling.md +0 -173
  150. package/template/.agent/rules/server/07-testing-strategy.md +0 -506
  151. package/template/.agent/rules/server/08-observability.md +0 -180
  152. package/template/.agent/rules/server/10-background-jobs-v2.md +0 -185
  153. package/template/.agent/rules/server/11-rate-limiting-v2.md +0 -210
  154. package/template/.agent/rules/server/12-performance-optimization.md +0 -567
  155. package/template/.claude/rules/client-01-project-structure.md +0 -327
  156. package/template/.claude/rules/client-02-component-patterns.md +0 -250
  157. package/template/.claude/rules/client-03-typescript-rules.md +0 -227
  158. package/template/.claude/rules/client-04-state-management.md +0 -475
  159. package/template/.claude/rules/client-05-api-integration.md +0 -130
  160. package/template/.claude/rules/client-06-forms-validation.md +0 -130
  161. package/template/.claude/rules/client-07-common-patterns.md +0 -151
  162. package/template/.claude/rules/client-08-color-system.md +0 -94
  163. package/template/.claude/rules/client-09-security-rules.md +0 -98
  164. package/template/.claude/rules/client-10-testing-strategy.md +0 -371
  165. package/template/.claude/rules/global-ai-edit-safety.md +0 -39
  166. package/template/.claude/rules/server-01-db-and-migrations.md +0 -243
  167. package/template/.claude/rules/server-02-general-rules.md +0 -112
  168. package/template/.claude/rules/server-03-migrations.md +0 -21
  169. package/template/.claude/rules/server-04-pagination.md +0 -131
  170. package/template/.claude/rules/server-05-project-conventions.md +0 -72
  171. package/template/.claude/rules/server-06-response-handling.md +0 -174
  172. package/template/.claude/rules/server-07-testing-strategy.md +0 -507
  173. package/template/.claude/rules/server-08-observability.md +0 -181
  174. package/template/.claude/rules/server-10-background-jobs-v2.md +0 -186
  175. package/template/.claude/rules/server-11-rate-limiting-v2.md +0 -211
  176. package/template/.claude/rules/server-12-performance-optimization.md +0 -568
  177. package/template/.cursor/rules/client-01-project-structure.mdc +0 -327
  178. package/template/.cursor/rules/client-02-component-patterns.mdc +0 -250
  179. package/template/.cursor/rules/client-03-typescript-rules.mdc +0 -227
  180. package/template/.cursor/rules/client-04-state-management.mdc +0 -475
  181. package/template/.cursor/rules/client-05-api-integration.mdc +0 -130
  182. package/template/.cursor/rules/client-06-forms-validation.mdc +0 -130
  183. package/template/.cursor/rules/client-07-common-patterns.mdc +0 -151
  184. package/template/.cursor/rules/client-08-color-system.mdc +0 -94
  185. package/template/.cursor/rules/client-09-security-rules.mdc +0 -98
  186. package/template/.cursor/rules/client-10-testing-strategy.mdc +0 -371
  187. package/template/.cursor/rules/global-ai-edit-safety.mdc +0 -39
  188. package/template/.cursor/rules/server-01-db-and-migrations.mdc +0 -243
  189. package/template/.cursor/rules/server-02-general-rules.mdc +0 -112
  190. package/template/.cursor/rules/server-03-migrations.mdc +0 -21
  191. package/template/.cursor/rules/server-04-pagination.mdc +0 -131
  192. package/template/.cursor/rules/server-05-project-conventions.mdc +0 -72
  193. package/template/.cursor/rules/server-06-response-handling.mdc +0 -174
  194. package/template/.cursor/rules/server-07-testing-strategy.mdc +0 -507
  195. package/template/.cursor/rules/server-08-observability.mdc +0 -181
  196. package/template/.cursor/rules/server-09-api-documentation-v2.mdc +0 -169
  197. package/template/.cursor/rules/server-10-background-jobs-v2.mdc +0 -186
  198. package/template/.cursor/rules/server-11-rate-limiting-v2.mdc +0 -211
  199. package/template/.cursor/rules/server-12-performance-optimization.mdc +0 -568
  200. package/template/CLAUDE.md +0 -207
  201. package/template/server/.tsc-aliasrc.json +0 -13
  202. package/template/server/IMPORT_FIX_CHECKLIST.md +0 -98
  203. package/template/server/IMPORT_FIX_COMPLETE.md +0 -89
  204. package/template/server/README.md +0 -183
  205. package/template/server/REMAINING_IMPORT_FIXES.md +0 -150
  206. package/template/server/SECURITY.md +0 -190
  207. package/template/server/Tigra-API.postman_collection.json +0 -733
  208. package/template/server/biome.json +0 -42
  209. package/template/server/scripts/fix-all-imports.ps1 +0 -52
  210. package/template/server/scripts/fix-imports-reference.ps1 +0 -16
  211. package/template/server/scripts/fix-imports.mjs +0 -55
  212. package/template/server/scripts/setup-env.js +0 -50
  213. package/template/server/scripts/wait-for-db.js +0 -60
  214. package/template/server/src/hooks/request-timing.hook.ts +0 -26
  215. package/template/server/src/libs/auth/authenticate.middleware.ts +0 -22
  216. package/template/server/src/libs/auth/rbac.middleware.test.ts +0 -134
  217. package/template/server/src/libs/auth/rbac.middleware.ts +0 -147
  218. package/template/server/src/libs/db.ts +0 -76
  219. package/template/server/src/libs/error-handler.ts +0 -89
  220. package/template/server/src/libs/queue.ts +0 -79
  221. package/template/server/src/modules/admin/admin.controller.ts +0 -122
  222. package/template/server/src/modules/admin/admin.routes.ts +0 -62
  223. package/template/server/src/modules/admin/admin.schemas.ts +0 -35
  224. package/template/server/src/modules/admin/admin.service.ts +0 -167
  225. package/template/server/src/modules/auth/auth.integration.test.ts +0 -150
  226. package/template/server/src/modules/auth/auth.service.test.ts +0 -119
  227. package/template/server/src/modules/auth/auth.types.ts +0 -97
  228. package/template/server/src/modules/resources/resources.controller.ts +0 -218
  229. package/template/server/src/modules/resources/resources.repo.ts +0 -253
  230. package/template/server/src/modules/resources/resources.routes.ts +0 -116
  231. package/template/server/src/modules/resources/resources.schemas.ts +0 -146
  232. package/template/server/src/modules/resources/resources.service.ts +0 -218
  233. package/template/server/src/modules/resources/resources.types.ts +0 -73
  234. package/template/server/src/plugins/rate-limit.plugin.ts +0 -21
  235. package/template/server/src/plugins/security.plugin.ts +0 -21
  236. package/template/server/src/routes/health.routes.ts +0 -31
  237. package/template/server/src/types/fastify.d.ts +0 -36
  238. package/template/server/src/utils/errors.ts +0 -108
  239. package/template/server/src/utils/pagination.ts +0 -120
  240. package/template/server/src/utils/response.ts +0 -110
  241. package/template/server/src/workers/file.worker.ts +0 -106
  242. package/template/server/tsconfig.build.json +0 -30
  243. 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();