create-tigra 2.1.4 → 2.2.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.
Files changed (28) hide show
  1. package/package.json +1 -1
  2. package/template/_claude/rules/server/project-conventions.md +64 -0
  3. package/template/server/.env.example +23 -0
  4. package/template/server/.env.example.production +21 -0
  5. package/template/server/package.json +1 -0
  6. package/template/server/postman/collection.json +415 -0
  7. package/template/server/postman/environment.json +31 -0
  8. package/template/server/src/app.ts +40 -10
  9. package/template/server/src/config/env.ts +9 -0
  10. package/template/server/src/config/rate-limit.config.ts +114 -0
  11. package/template/server/src/jobs/cleanup-deleted-accounts.job.ts +83 -0
  12. package/template/server/src/{libs/cleanup.ts → jobs/cleanup-expired-auth.job.ts} +10 -4
  13. package/template/server/src/jobs/index.ts +20 -0
  14. package/template/server/src/libs/__tests__/http.test.ts +414 -0
  15. package/template/server/src/libs/http.ts +66 -0
  16. package/template/server/src/libs/ip-block.ts +145 -0
  17. package/template/server/src/libs/storage/file-validator.ts +4 -3
  18. package/template/server/src/libs/storage/image-optimizer.service.ts +1 -1
  19. package/template/server/src/modules/admin/admin.controller.ts +41 -0
  20. package/template/server/src/modules/admin/admin.routes.ts +45 -0
  21. package/template/server/src/modules/auth/auth.routes.ts +10 -30
  22. package/template/server/src/modules/users/users.controller.ts +70 -1
  23. package/template/server/src/modules/users/users.repo.ts +27 -0
  24. package/template/server/src/modules/users/users.routes.ts +86 -16
  25. package/template/server/src/modules/users/users.schemas.ts +38 -0
  26. package/template/server/src/modules/users/users.service.ts +110 -2
  27. package/template/server/tsconfig.json +2 -1
  28. package/template/server/uploads/avatars/.gitkeep +0 -1
@@ -0,0 +1,66 @@
1
+ import axios, {
2
+ type AxiosInstance,
3
+ type InternalAxiosRequestConfig,
4
+ type AxiosResponse,
5
+ isAxiosError,
6
+ } from 'axios';
7
+ import { logger } from '@libs/logger.js';
8
+ import { InternalError } from '@shared/errors/errors.js';
9
+
10
+ const TIMEOUT_MS = 30_000;
11
+
12
+ const httpClient: AxiosInstance = axios.create({
13
+ timeout: TIMEOUT_MS,
14
+ headers: {
15
+ 'Content-Type': 'application/json',
16
+ Accept: 'application/json',
17
+ },
18
+ });
19
+
20
+ // Request interceptor — log every outbound call
21
+ httpClient.interceptors.request.use(
22
+ (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
23
+ logger.debug(
24
+ { method: config.method?.toUpperCase(), url: config.url, baseURL: config.baseURL },
25
+ '[HTTP] Outbound request',
26
+ );
27
+ return config;
28
+ },
29
+ (error: unknown): never => {
30
+ logger.error({ err: error }, '[HTTP] Request setup failed');
31
+ throw new InternalError('Outbound HTTP request could not be constructed');
32
+ },
33
+ );
34
+
35
+ // Response interceptor — log responses and convert AxiosError → InternalError
36
+ httpClient.interceptors.response.use(
37
+ (response: AxiosResponse): AxiosResponse => {
38
+ logger.debug(
39
+ {
40
+ method: response.config.method?.toUpperCase(),
41
+ url: response.config.url,
42
+ status: response.status,
43
+ },
44
+ '[HTTP] Outbound response',
45
+ );
46
+ return response;
47
+ },
48
+ (error: unknown): never => {
49
+ if (isAxiosError(error)) {
50
+ logger.error(
51
+ {
52
+ method: error.config?.method?.toUpperCase(),
53
+ url: error.config?.url,
54
+ status: error.response?.status,
55
+ message: error.message,
56
+ },
57
+ '[HTTP] Outbound request failed',
58
+ );
59
+ } else {
60
+ logger.error({ err: error }, '[HTTP] Unexpected outbound error');
61
+ }
62
+ throw new InternalError('Outbound HTTP request failed');
63
+ },
64
+ );
65
+
66
+ export { httpClient };
@@ -0,0 +1,145 @@
1
+ /**
2
+ * IP Blocking Service
3
+ *
4
+ * Redis-backed IP blocking with two tiers:
5
+ * - Permanent blocks: Redis SET (admin-managed via API)
6
+ * - Auto-blocks: Redis ZSET with expiry timestamps (triggered by excessive rate-limit violations)
7
+ *
8
+ * Design decisions:
9
+ * - Fails open: if Redis is down, requests are NOT blocked (availability > security for rate limiting)
10
+ * - O(1) lookups: both SISMEMBER and ZSCORE are constant-time operations
11
+ * - Lazy cleanup: expired auto-blocks are removed on check, no separate cleanup job needed
12
+ */
13
+
14
+ import { getRedis } from '@libs/redis.js';
15
+ import { logger } from '@libs/logger.js';
16
+
17
+ // Redis keys
18
+ const BLOCKED_IPS_KEY = 'blocked_ips';
19
+ const AUTO_BLOCKED_KEY = 'auto_blocked_ips';
20
+ const VIOLATION_PREFIX = 'rl_violations:';
21
+
22
+ // Auto-block thresholds
23
+ const AUTO_BLOCK_THRESHOLD = 10; // violations before auto-block
24
+ const AUTO_BLOCK_WINDOW_SECONDS = 300; // 5-minute sliding window
25
+ const AUTO_BLOCK_DURATION_SECONDS = 3600; // block for 1 hour
26
+
27
+ /**
28
+ * Check if an IP is blocked (permanent or auto-blocked).
29
+ *
30
+ * @param ip - IP address to check
31
+ * @returns true if blocked, false otherwise (including Redis failures)
32
+ */
33
+ export async function isIpBlocked(ip: string): Promise<boolean> {
34
+ try {
35
+ const redis = getRedis();
36
+
37
+ // Check permanent block list
38
+ const permanent = await redis.sismember(BLOCKED_IPS_KEY, ip);
39
+ if (permanent === 1) return true;
40
+
41
+ // Check auto-block list (score = expiry Unix timestamp)
42
+ const score = await redis.zscore(AUTO_BLOCKED_KEY, ip);
43
+ if (score) {
44
+ const expiresAt = Number(score);
45
+ if (expiresAt > Date.now() / 1000) return true;
46
+
47
+ // Expired — clean up lazily
48
+ await redis.zrem(AUTO_BLOCKED_KEY, ip);
49
+ }
50
+
51
+ return false;
52
+ } catch {
53
+ // Fail open: if Redis is down, don't block
54
+ logger.warn('[IP-BLOCK] Redis unavailable, skipping IP block check');
55
+ return false;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Add an IP to the permanent block list (admin-managed).
61
+ *
62
+ * @param ip - IP address to block
63
+ */
64
+ export async function blockIp(ip: string): Promise<void> {
65
+ const redis = getRedis();
66
+ await redis.sadd(BLOCKED_IPS_KEY, ip);
67
+ logger.info({ ip }, '[IP-BLOCK] IP permanently blocked');
68
+ }
69
+
70
+ /**
71
+ * Remove an IP from the permanent block list.
72
+ *
73
+ * @param ip - IP address to unblock
74
+ */
75
+ export async function unblockIp(ip: string): Promise<void> {
76
+ const redis = getRedis();
77
+ await redis.srem(BLOCKED_IPS_KEY, ip);
78
+ // Also remove from auto-block list if present
79
+ await redis.zrem(AUTO_BLOCKED_KEY, ip);
80
+ logger.info({ ip }, '[IP-BLOCK] IP unblocked');
81
+ }
82
+
83
+ /**
84
+ * List all currently blocked IPs (permanent + active auto-blocks).
85
+ *
86
+ * @returns Object with permanent and autoBlocked arrays
87
+ */
88
+ export async function getBlockedIps(): Promise<{
89
+ permanent: string[];
90
+ autoBlocked: string[];
91
+ }> {
92
+ const redis = getRedis();
93
+ const nowSeconds = Date.now() / 1000;
94
+
95
+ const permanent = await redis.smembers(BLOCKED_IPS_KEY);
96
+
97
+ // Get auto-blocked IPs that haven't expired yet
98
+ const autoBlockedWithScores = await redis.zrangebyscore(
99
+ AUTO_BLOCKED_KEY,
100
+ nowSeconds,
101
+ '+inf',
102
+ );
103
+
104
+ return { permanent, autoBlocked: autoBlockedWithScores };
105
+ }
106
+
107
+ /**
108
+ * Record a rate-limit violation for an IP.
109
+ *
110
+ * If the IP exceeds AUTO_BLOCK_THRESHOLD violations within
111
+ * AUTO_BLOCK_WINDOW_SECONDS, it gets auto-blocked for
112
+ * AUTO_BLOCK_DURATION_SECONDS.
113
+ *
114
+ * Called from the rate-limit `onExceeded` callback.
115
+ *
116
+ * @param ip - IP address that violated rate limit
117
+ */
118
+ export async function recordRateLimitViolation(ip: string): Promise<void> {
119
+ try {
120
+ const redis = getRedis();
121
+ const key = `${VIOLATION_PREFIX}${ip}`;
122
+
123
+ const count = await redis.incr(key);
124
+
125
+ // Set TTL on first violation (sliding window)
126
+ if (count === 1) {
127
+ await redis.expire(key, AUTO_BLOCK_WINDOW_SECONDS);
128
+ }
129
+
130
+ if (count >= AUTO_BLOCK_THRESHOLD) {
131
+ // Auto-block: add to ZSET with expiry timestamp as score
132
+ const expiresAt = Math.floor(Date.now() / 1000) + AUTO_BLOCK_DURATION_SECONDS;
133
+ await redis.zadd(AUTO_BLOCKED_KEY, expiresAt, ip);
134
+ await redis.del(key); // Reset violation counter
135
+
136
+ logger.warn(
137
+ { ip, violations: count, blockedForSeconds: AUTO_BLOCK_DURATION_SECONDS },
138
+ '[IP-BLOCK] Auto-blocked IP due to excessive rate-limit violations',
139
+ );
140
+ }
141
+ } catch {
142
+ // Non-critical: don't break the request if violation tracking fails
143
+ logger.warn('[IP-BLOCK] Failed to record rate-limit violation');
144
+ }
145
+ }
@@ -6,15 +6,16 @@
6
6
 
7
7
  import type { MultipartFile } from '@fastify/multipart';
8
8
  import { ValidationError } from '@shared/errors/errors.js';
9
+ import { env } from '@config/env.js';
9
10
  import path from 'path';
10
11
 
11
12
  /**
12
13
  * File upload constants and constraints
13
14
  */
14
15
  export const FILE_UPLOAD_CONSTANTS = {
15
- MAX_FILE_SIZE: 5 * 1024 * 1024, // 5MB in bytes
16
- ALLOWED_MIME_TYPES: ['image/jpeg', 'image/png', 'image/webp'] as const,
17
- ALLOWED_EXTENSIONS: ['.jpg', '.jpeg', '.png', '.webp'] as const,
16
+ MAX_FILE_SIZE: env.MAX_FILE_SIZE_MB * 1024 * 1024, // ENV-configurable (default 10MB)
17
+ ALLOWED_MIME_TYPES: ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif'] as const,
18
+ ALLOWED_EXTENSIONS: ['.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif'] as const,
18
19
  AVATAR_MAX_DIMENSION: 512, // pixels
19
20
  } as const;
20
21
 
@@ -120,7 +120,7 @@ class ImageOptimizerService {
120
120
  }
121
121
 
122
122
  // Check if format is supported
123
- const supportedFormats = ['jpeg', 'png', 'webp', 'gif'];
123
+ const supportedFormats = ['jpeg', 'png', 'webp', 'gif', 'heif'];
124
124
  if (!supportedFormats.includes(metadata.format)) {
125
125
  throw new ValidationError(
126
126
  `Image format '${metadata.format}' is not supported`,
@@ -0,0 +1,41 @@
1
+ import type { FastifyRequest, FastifyReply } from 'fastify';
2
+ import { z } from 'zod';
3
+ import { successResponse } from '@shared/responses/successResponse.js';
4
+ import { ValidationError } from '@shared/errors/errors.js';
5
+ import { blockIp, unblockIp, getBlockedIps } from '@libs/ip-block.js';
6
+
7
+ const blockIpSchema = z.object({
8
+ ip: z.string().ip({ message: 'Invalid IP address' }),
9
+ });
10
+
11
+ type BlockIpBody = z.infer<typeof blockIpSchema>;
12
+ type UnblockIpParams = { ip: string };
13
+
14
+ export async function listBlockedIps(
15
+ _request: FastifyRequest,
16
+ reply: FastifyReply,
17
+ ): Promise<void> {
18
+ const { permanent, autoBlocked } = await getBlockedIps();
19
+ reply.send(successResponse('Blocked IPs retrieved', { permanent, autoBlocked }));
20
+ }
21
+
22
+ export async function blockIpHandler(
23
+ request: FastifyRequest<{ Body: BlockIpBody }>,
24
+ reply: FastifyReply,
25
+ ): Promise<void> {
26
+ const parsed = blockIpSchema.safeParse(request.body);
27
+ if (!parsed.success) {
28
+ throw new ValidationError(parsed.error.issues[0]?.message ?? 'Invalid IP address', 'INVALID_IP');
29
+ }
30
+ await blockIp(parsed.data.ip);
31
+ reply.status(201).send(successResponse('IP blocked successfully', { ip: parsed.data.ip }));
32
+ }
33
+
34
+ export async function unblockIpHandler(
35
+ request: FastifyRequest<{ Params: UnblockIpParams }>,
36
+ reply: FastifyReply,
37
+ ): Promise<void> {
38
+ const { ip } = request.params;
39
+ await unblockIp(ip);
40
+ reply.send(successResponse('IP unblocked successfully', { ip }));
41
+ }
@@ -0,0 +1,45 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { authenticate, authorize } from '@libs/auth.js';
3
+ import { RATE_LIMITS } from '@config/rate-limit.config.js';
4
+ import * as adminController from './admin.controller.js';
5
+
6
+ export async function adminRoutes(fastify: FastifyInstance): Promise<void> {
7
+ // All admin routes require authentication + ADMIN role
8
+ fastify.addHook('preValidation', authenticate);
9
+ fastify.addHook('preValidation', authorize('ADMIN'));
10
+
11
+ /**
12
+ * List blocked IPs
13
+ *
14
+ * GET /api/v1/admin/blocked-ips
15
+ * Auth: Required (ADMIN)
16
+ * Returns: { permanent: string[], autoBlocked: string[] }
17
+ */
18
+ fastify.get('/admin/blocked-ips', {
19
+ config: { rateLimit: RATE_LIMITS.ADMIN_DEFAULT },
20
+ handler: adminController.listBlockedIps,
21
+ });
22
+
23
+ /**
24
+ * Block an IP address (permanent)
25
+ *
26
+ * POST /api/v1/admin/blocked-ips
27
+ * Auth: Required (ADMIN)
28
+ * Body: { ip: string }
29
+ */
30
+ fastify.post('/admin/blocked-ips', {
31
+ config: { rateLimit: RATE_LIMITS.ADMIN_DEFAULT },
32
+ handler: adminController.blockIpHandler,
33
+ });
34
+
35
+ /**
36
+ * Unblock an IP address
37
+ *
38
+ * DELETE /api/v1/admin/blocked-ips/:ip
39
+ * Auth: Required (ADMIN)
40
+ */
41
+ fastify.delete('/admin/blocked-ips/:ip', {
42
+ config: { rateLimit: RATE_LIMITS.ADMIN_DEFAULT },
43
+ handler: adminController.unblockIpHandler,
44
+ });
45
+ }
@@ -1,5 +1,6 @@
1
1
  import type { FastifyInstance } from 'fastify';
2
2
  import { authenticate } from '@libs/auth.js';
3
+ import { RATE_LIMITS } from '@config/rate-limit.config.js';
3
4
  import * as authController from './auth.controller.js';
4
5
  import {
5
6
  registerSchema,
@@ -7,30 +8,24 @@ import {
7
8
  } from './auth.schemas.js';
8
9
 
9
10
  export async function authRoutes(fastify: FastifyInstance): Promise<void> {
10
- // Register - Strict rate limiting to prevent abuse (5 requests per hour per IP)
11
+ // Register - Strict rate limiting to prevent abuse
11
12
  fastify.post('/auth/register', {
12
13
  schema: {
13
14
  body: registerSchema,
14
15
  },
15
16
  config: {
16
- rateLimit: {
17
- max: 5,
18
- timeWindow: '1 hour',
19
- },
17
+ rateLimit: RATE_LIMITS.AUTH_REGISTER,
20
18
  },
21
19
  handler: authController.register,
22
20
  });
23
21
 
24
- // Login - Strict rate limiting to prevent brute force (10 requests per 15 minutes per IP)
22
+ // Login - Strict rate limiting to prevent brute force
25
23
  fastify.post('/auth/login', {
26
24
  schema: {
27
25
  body: loginSchema,
28
26
  },
29
27
  config: {
30
- rateLimit: {
31
- max: 10,
32
- timeWindow: '15 minutes',
33
- },
28
+ rateLimit: RATE_LIMITS.AUTH_LOGIN,
34
29
  },
35
30
  handler: authController.login,
36
31
  });
@@ -38,10 +33,7 @@ export async function authRoutes(fastify: FastifyInstance): Promise<void> {
38
33
  // Logout - reads refresh token from cookie, no body schema needed
39
34
  fastify.post('/auth/logout', {
40
35
  config: {
41
- rateLimit: {
42
- max: 50,
43
- timeWindow: '15 minutes',
44
- },
36
+ rateLimit: RATE_LIMITS.AUTH_LOGOUT,
45
37
  },
46
38
  handler: authController.logout,
47
39
  });
@@ -49,10 +41,7 @@ export async function authRoutes(fastify: FastifyInstance): Promise<void> {
49
41
  // Refresh token - reads refresh token from cookie, no body schema needed
50
42
  fastify.post('/auth/refresh', {
51
43
  config: {
52
- rateLimit: {
53
- max: 20,
54
- timeWindow: '15 minutes',
55
- },
44
+ rateLimit: RATE_LIMITS.AUTH_REFRESH,
56
45
  },
57
46
  handler: authController.refresh,
58
47
  });
@@ -61,10 +50,7 @@ export async function authRoutes(fastify: FastifyInstance): Promise<void> {
61
50
  fastify.get('/auth/me', {
62
51
  preValidation: [authenticate],
63
52
  config: {
64
- rateLimit: {
65
- max: 60,
66
- timeWindow: '1 minute',
67
- },
53
+ rateLimit: RATE_LIMITS.AUTH_ME,
68
54
  },
69
55
  handler: authController.me,
70
56
  });
@@ -73,10 +59,7 @@ export async function authRoutes(fastify: FastifyInstance): Promise<void> {
73
59
  fastify.get('/auth/sessions', {
74
60
  preValidation: [authenticate],
75
61
  config: {
76
- rateLimit: {
77
- max: 30,
78
- timeWindow: '1 minute',
79
- },
62
+ rateLimit: RATE_LIMITS.AUTH_SESSIONS,
80
63
  },
81
64
  handler: authController.getSessions,
82
65
  });
@@ -85,10 +68,7 @@ export async function authRoutes(fastify: FastifyInstance): Promise<void> {
85
68
  fastify.post('/auth/logout-all', {
86
69
  preValidation: [authenticate],
87
70
  config: {
88
- rateLimit: {
89
- max: 10,
90
- timeWindow: '15 minutes',
91
- },
71
+ rateLimit: RATE_LIMITS.AUTH_LOGOUT_ALL,
92
72
  },
93
73
  handler: authController.logoutAllSessions,
94
74
  });
@@ -9,7 +9,8 @@ import path from 'path';
9
9
  import { usersService } from './users.service.js';
10
10
  import { validateImageFile, validateFileSize } from '@libs/storage/file-validator.js';
11
11
  import { successResponse } from '@shared/responses/successResponse.js';
12
- import type { GetUserAvatarParams } from './users.schemas.js';
12
+ import { clearAuthCookies } from '@libs/cookies.js';
13
+ import type { GetUserAvatarParams, UpdateProfileInput, ChangePasswordInput, DeleteAccountInput } from './users.schemas.js';
13
14
  import type { AuthenticatedRequest } from '@shared/types/index.js';
14
15
  import { logger } from '@libs/logger.js';
15
16
  import { BadRequestError } from '@shared/errors/errors.js';
@@ -114,6 +115,74 @@ class UsersController {
114
115
  // Send file directly
115
116
  return reply.sendFile(path.basename(filePath), path.dirname(filePath));
116
117
  }
118
+
119
+ /**
120
+ * Update profile handler
121
+ *
122
+ * PATCH /api/v1/users/me
123
+ * Auth: Required (JWT)
124
+ *
125
+ * @param request - Fastify request (authenticated)
126
+ * @param reply - Fastify reply
127
+ */
128
+ async updateProfile(
129
+ request: FastifyRequest<{ Body: UpdateProfileInput }>,
130
+ reply: FastifyReply
131
+ ): Promise<void> {
132
+ const userId = request.user.userId;
133
+
134
+ logger.info({ msg: 'Update profile request', userId });
135
+
136
+ const updatedUser = await usersService.updateProfile(userId, request.body);
137
+
138
+ return reply.send(successResponse('Profile updated successfully', updatedUser));
139
+ }
140
+
141
+ /**
142
+ * Change password handler
143
+ *
144
+ * PATCH /api/v1/users/me/password
145
+ * Auth: Required (JWT)
146
+ *
147
+ * @param request - Fastify request (authenticated)
148
+ * @param reply - Fastify reply
149
+ */
150
+ async changePassword(
151
+ request: FastifyRequest<{ Body: ChangePasswordInput }>,
152
+ reply: FastifyReply
153
+ ): Promise<void> {
154
+ const userId = request.user.userId;
155
+
156
+ logger.info({ msg: 'Change password request', userId });
157
+
158
+ await usersService.changePassword(userId, request.body);
159
+
160
+ return reply.send(successResponse('Password changed successfully', null));
161
+ }
162
+
163
+ /**
164
+ * Delete account handler
165
+ *
166
+ * DELETE /api/v1/users/me
167
+ * Auth: Required (JWT)
168
+ *
169
+ * @param request - Fastify request (authenticated)
170
+ * @param reply - Fastify reply
171
+ */
172
+ async deleteAccount(
173
+ request: FastifyRequest<{ Body: DeleteAccountInput }>,
174
+ reply: FastifyReply
175
+ ): Promise<void> {
176
+ const userId = request.user.userId;
177
+
178
+ logger.info({ msg: 'Delete account request', userId });
179
+
180
+ await usersService.deleteAccount(userId, request.body.password);
181
+
182
+ clearAuthCookies(reply);
183
+
184
+ return reply.send(successResponse('Account deleted successfully', null));
185
+ }
117
186
  }
118
187
 
119
188
  // Export singleton instance
@@ -71,6 +71,33 @@ class UsersRepository {
71
71
  select: userSelect,
72
72
  });
73
73
  }
74
+
75
+ /**
76
+ * Updates a user's profile fields
77
+ */
78
+ async updateUserProfile(
79
+ userId: string,
80
+ data: { firstName?: string; lastName?: string }
81
+ ): Promise<SafeUser> {
82
+ return prisma.user.update({
83
+ where: { id: userId },
84
+ data,
85
+ select: userSelect,
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Soft deletes a user by setting deletedAt and isActive = false
91
+ */
92
+ async softDeleteUser(userId: string): Promise<void> {
93
+ await prisma.user.update({
94
+ where: { id: userId },
95
+ data: {
96
+ deletedAt: new Date(),
97
+ isActive: false,
98
+ },
99
+ });
100
+ }
74
101
  }
75
102
 
76
103
  // Export singleton instance
@@ -6,20 +6,99 @@
6
6
 
7
7
  import type { FastifyInstance } from 'fastify';
8
8
  import { usersController } from './users.controller.js';
9
- import { GetUserAvatarSchema } from './users.schemas.js';
9
+ import {
10
+ GetUserAvatarSchema,
11
+ UpdateProfileSchema,
12
+ ChangePasswordSchema,
13
+ DeleteAccountSchema,
14
+ } from './users.schemas.js';
10
15
  import { authenticate } from '@libs/auth.js';
16
+ import { RATE_LIMITS } from '@config/rate-limit.config.js';
11
17
 
12
18
  /**
13
19
  * Users routes plugin
14
20
  *
15
21
  * Endpoints:
16
- * - POST /users/avatar - Upload avatar (authenticated)
17
- * - DELETE /users/avatar - Delete avatar (authenticated)
18
- * - GET /users/:userId/avatar - Get avatar (public)
22
+ * - PATCH /users/me - Update profile (authenticated)
23
+ * - PATCH /users/me/password - Change password (authenticated)
24
+ * - DELETE /users/me - Delete account (authenticated)
25
+ * - POST /users/avatar - Upload avatar (authenticated)
26
+ * - DELETE /users/avatar - Delete avatar (authenticated)
27
+ * - GET /users/:userId/avatar - Get avatar (public)
19
28
  *
20
29
  * @param fastify - Fastify instance
21
30
  */
22
31
  export async function usersRoutes(fastify: FastifyInstance): Promise<void> {
32
+ // --- Profile Management ---
33
+
34
+ /**
35
+ * Update profile
36
+ *
37
+ * PATCH /api/v1/users/me
38
+ * Body: { firstName?, lastName? }
39
+ * Auth: Required
40
+ * Rate limit: 10 requests per minute
41
+ */
42
+ fastify.patch(
43
+ '/users/me',
44
+ {
45
+ preValidation: [authenticate],
46
+ schema: {
47
+ body: UpdateProfileSchema,
48
+ },
49
+ config: {
50
+ rateLimit: RATE_LIMITS.USERS_UPDATE_PROFILE,
51
+ },
52
+ },
53
+ usersController.updateProfile.bind(usersController)
54
+ );
55
+
56
+ /**
57
+ * Change password
58
+ *
59
+ * PATCH /api/v1/users/me/password
60
+ * Body: { currentPassword, newPassword }
61
+ * Auth: Required
62
+ * Rate limit: 5 requests per minute
63
+ */
64
+ fastify.patch(
65
+ '/users/me/password',
66
+ {
67
+ preValidation: [authenticate],
68
+ schema: {
69
+ body: ChangePasswordSchema,
70
+ },
71
+ config: {
72
+ rateLimit: RATE_LIMITS.USERS_CHANGE_PASSWORD,
73
+ },
74
+ },
75
+ usersController.changePassword.bind(usersController)
76
+ );
77
+
78
+ /**
79
+ * Delete account
80
+ *
81
+ * DELETE /api/v1/users/me
82
+ * Body: { password }
83
+ * Auth: Required
84
+ * Rate limit: 3 requests per minute
85
+ */
86
+ fastify.delete(
87
+ '/users/me',
88
+ {
89
+ preValidation: [authenticate],
90
+ schema: {
91
+ body: DeleteAccountSchema,
92
+ },
93
+ config: {
94
+ rateLimit: RATE_LIMITS.USERS_DELETE_ACCOUNT,
95
+ },
96
+ },
97
+ usersController.deleteAccount.bind(usersController)
98
+ );
99
+
100
+ // --- Avatar Management ---
101
+
23
102
  /**
24
103
  * Upload avatar
25
104
  *
@@ -34,10 +113,7 @@ export async function usersRoutes(fastify: FastifyInstance): Promise<void> {
34
113
  {
35
114
  preValidation: [authenticate],
36
115
  config: {
37
- rateLimit: {
38
- max: 5,
39
- timeWindow: '1 minute',
40
- },
116
+ rateLimit: RATE_LIMITS.USERS_UPLOAD_AVATAR,
41
117
  },
42
118
  },
43
119
  usersController.uploadAvatar.bind(usersController)
@@ -55,10 +131,7 @@ export async function usersRoutes(fastify: FastifyInstance): Promise<void> {
55
131
  {
56
132
  preValidation: [authenticate],
57
133
  config: {
58
- rateLimit: {
59
- max: 10,
60
- timeWindow: '1 minute',
61
- },
134
+ rateLimit: RATE_LIMITS.USERS_DELETE_AVATAR,
62
135
  },
63
136
  },
64
137
  usersController.deleteAvatar.bind(usersController)
@@ -78,10 +151,7 @@ export async function usersRoutes(fastify: FastifyInstance): Promise<void> {
78
151
  params: GetUserAvatarSchema,
79
152
  },
80
153
  config: {
81
- rateLimit: {
82
- max: 100,
83
- timeWindow: '1 minute',
84
- },
154
+ rateLimit: RATE_LIMITS.USERS_GET_AVATAR,
85
155
  },
86
156
  },
87
157
  usersController.getAvatar.bind(usersController)