create-tigra 2.2.0 → 2.3.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 (27) hide show
  1. package/bin/create-tigra.js +2 -0
  2. package/package.json +4 -1
  3. package/template/_claude/commands/create-client.md +1 -4
  4. package/template/_claude/commands/create-server.md +0 -1
  5. package/template/_claude/rules/client/01-project-structure.md +0 -3
  6. package/template/_claude/rules/client/03-data-and-state.md +1 -1
  7. package/template/_claude/rules/server/project-conventions.md +13 -0
  8. package/template/client/package.json +2 -1
  9. package/template/server/package.json +2 -1
  10. package/template/server/postman/collection.json +114 -5
  11. package/template/server/postman/environment.json +2 -2
  12. package/template/server/prisma/schema.prisma +17 -1
  13. package/template/server/src/app.ts +4 -1
  14. package/template/server/src/jobs/cleanup-deleted-accounts.job.ts +3 -6
  15. package/template/server/src/libs/auth.ts +45 -1
  16. package/template/server/src/libs/ip-block.ts +90 -29
  17. package/template/server/src/libs/requestLogger.ts +1 -1
  18. package/template/server/src/libs/storage/file-storage.service.ts +65 -18
  19. package/template/server/src/libs/storage/file-validator.ts +0 -8
  20. package/template/server/src/modules/admin/admin.controller.ts +4 -3
  21. package/template/server/src/modules/auth/auth.repo.ts +18 -0
  22. package/template/server/src/modules/auth/auth.service.ts +52 -26
  23. package/template/server/src/modules/users/users.controller.ts +39 -21
  24. package/template/server/src/modules/users/users.routes.ts +127 -6
  25. package/template/server/src/modules/users/users.schemas.ts +24 -4
  26. package/template/server/src/modules/users/users.service.ts +23 -10
  27. package/template/server/src/shared/types/index.ts +2 -0
@@ -2,6 +2,7 @@
2
2
  * File Storage Service
3
3
  *
4
4
  * Handles local file system operations for user-uploaded files.
5
+ * Directory structure: uploads/users/{userId}/<media-type>/
5
6
  */
6
7
 
7
8
  import fs from 'fs/promises';
@@ -13,24 +14,39 @@ import { generateAvatarFilename } from './filename-sanitizer.js';
13
14
  /**
14
15
  * File Storage Service
15
16
  *
16
- * Manages local file storage with user-specific directories and SEO-friendly naming.
17
+ * Manages local file storage with per-user directories and SEO-friendly naming.
18
+ * All user media lives under uploads/users/{userId}/ for easy per-user cleanup.
17
19
  */
18
20
  class FileStorageService {
19
21
  private readonly uploadDir: string;
20
- private readonly avatarsDir: string;
22
+ private readonly usersDir: string;
21
23
 
22
24
  constructor() {
23
25
  // Base upload directory: server/uploads
24
26
  this.uploadDir = path.join(process.cwd(), 'uploads');
25
- // Avatars subdirectory: server/uploads/avatars
26
- this.avatarsDir = path.join(this.uploadDir, 'avatars');
27
+ // Users media directory: server/uploads/users
28
+ this.usersDir = path.join(this.uploadDir, 'users');
29
+ }
30
+
31
+ /**
32
+ * Gets the base directory for a user's media
33
+ */
34
+ private getUserDir(userId: string): string {
35
+ return path.join(this.usersDir, userId);
36
+ }
37
+
38
+ /**
39
+ * Gets the avatar directory for a user
40
+ */
41
+ private getUserAvatarDir(userId: string): string {
42
+ return path.join(this.getUserDir(userId), 'avatar');
27
43
  }
28
44
 
29
45
  /**
30
46
  * Saves an avatar image to user-specific directory
31
47
  *
32
48
  * Process:
33
- * 1. Create user directory if it doesn't exist
49
+ * 1. Create user avatar directory if it doesn't exist
34
50
  * 2. Generate SEO-friendly filename
35
51
  * 3. Save buffer to file (overwrites existing avatar)
36
52
  * 4. Return filename and public URL
@@ -50,7 +66,7 @@ class FileStorageService {
50
66
  * 'Doe'
51
67
  * );
52
68
  * // filename: "john-doe-avatar.webp"
53
- * // url: "/uploads/avatars/{userId}/john-doe-avatar.webp"
69
+ * // url: "/uploads/users/{userId}/avatar/john-doe-avatar.webp"
54
70
  * ```
55
71
  */
56
72
  async saveAvatar(
@@ -61,18 +77,18 @@ class FileStorageService {
61
77
  ): Promise<{ filename: string; url: string }> {
62
78
  try {
63
79
  // Ensure user's avatar directory exists
64
- const userDir = path.join(this.avatarsDir, userId);
65
- await this.ensureDirectoryExists(userDir);
80
+ const avatarDir = this.getUserAvatarDir(userId);
81
+ await this.ensureDirectoryExists(avatarDir);
66
82
 
67
83
  // Generate SEO-friendly filename
68
84
  const filename = generateAvatarFilename(firstName, lastName, 'webp');
69
- const filePath = path.join(userDir, filename);
85
+ const filePath = path.join(avatarDir, filename);
70
86
 
71
87
  // Write file to disk (overwrites existing)
72
88
  await fs.writeFile(filePath, buffer);
73
89
 
74
90
  // Generate public URL
75
- const url = `/uploads/avatars/${userId}/${filename}`;
91
+ const url = `/uploads/users/${userId}/avatar/${filename}`;
76
92
 
77
93
  logger.info({
78
94
  msg: 'Avatar saved successfully',
@@ -101,17 +117,17 @@ class FileStorageService {
101
117
  */
102
118
  async deleteAvatar(userId: string): Promise<void> {
103
119
  try {
104
- const userDir = path.join(this.avatarsDir, userId);
120
+ const avatarDir = this.getUserAvatarDir(userId);
105
121
 
106
122
  // Check if directory exists
107
- const exists = await this.directoryExists(userDir);
123
+ const exists = await this.directoryExists(avatarDir);
108
124
  if (!exists) {
109
125
  logger.info({ msg: 'Avatar directory does not exist, nothing to delete', userId });
110
126
  return;
111
127
  }
112
128
 
113
- // Delete entire user directory and contents
114
- await fs.rm(userDir, { recursive: true, force: true });
129
+ // Delete avatar directory and contents
130
+ await fs.rm(avatarDir, { recursive: true, force: true });
115
131
 
116
132
  logger.info({ msg: 'Avatar deleted successfully', userId });
117
133
  } catch (error) {
@@ -120,6 +136,37 @@ class FileStorageService {
120
136
  }
121
137
  }
122
138
 
139
+ /**
140
+ * Deletes all media for a user (entire user directory)
141
+ *
142
+ * Used by the cleanup job when permanently purging deleted accounts.
143
+ * No-op if the user directory doesn't exist.
144
+ *
145
+ * @param userId - User's unique ID
146
+ *
147
+ * @example
148
+ * ```typescript
149
+ * await fileStorageService.deleteUserMedia(userId);
150
+ * ```
151
+ */
152
+ async deleteUserMedia(userId: string): Promise<void> {
153
+ try {
154
+ const userDir = this.getUserDir(userId);
155
+
156
+ const exists = await this.directoryExists(userDir);
157
+ if (!exists) {
158
+ return;
159
+ }
160
+
161
+ await fs.rm(userDir, { recursive: true, force: true });
162
+
163
+ logger.info({ msg: 'User media deleted successfully', userId });
164
+ } catch (error) {
165
+ logger.error({ err: error, msg: 'Failed to delete user media', userId });
166
+ throw new InternalError('Failed to delete user media', 'FILE_DELETE_FAILED');
167
+ }
168
+ }
169
+
123
170
  /**
124
171
  * Gets the full file system path for an avatar
125
172
  *
@@ -128,7 +175,7 @@ class FileStorageService {
128
175
  * @returns Absolute file path
129
176
  */
130
177
  getAvatarPath(userId: string, filename: string): string {
131
- return path.join(this.avatarsDir, userId, filename);
178
+ return path.join(this.getUserAvatarDir(userId), filename);
132
179
  }
133
180
 
134
181
  /**
@@ -139,7 +186,7 @@ class FileStorageService {
139
186
  * @returns Public URL path
140
187
  */
141
188
  getAvatarUrl(userId: string, filename: string): string {
142
- return `/uploads/avatars/${userId}/${filename}`;
189
+ return `/uploads/users/${userId}/avatar/${filename}`;
143
190
  }
144
191
 
145
192
  /**
@@ -192,13 +239,13 @@ class FileStorageService {
192
239
  /**
193
240
  * Initializes the upload directory structure
194
241
  *
195
- * Creates base uploads directory and avatars subdirectory if they don't exist.
242
+ * Creates base uploads directory and users subdirectory if they don't exist.
196
243
  * Should be called at application startup.
197
244
  */
198
245
  async initialize(): Promise<void> {
199
246
  try {
200
247
  await this.ensureDirectoryExists(this.uploadDir);
201
- await this.ensureDirectoryExists(this.avatarsDir);
248
+ await this.ensureDirectoryExists(this.usersDir);
202
249
  logger.info({ msg: 'File storage initialized', uploadDir: this.uploadDir });
203
250
  } catch (error) {
204
251
  logger.error({ err: error, msg: 'Failed to initialize file storage' });
@@ -43,14 +43,6 @@ export function validateImageFile(file: MultipartFile): void {
43
43
  throw new ValidationError('No file was uploaded', 'FILE_REQUIRED');
44
44
  }
45
45
 
46
- // Validate file size
47
- if (!file.file.bytesRead) {
48
- throw new ValidationError('Uploaded file is empty', 'FILE_EMPTY');
49
- }
50
-
51
- // Note: file.file.bytesRead might not be available until the stream is consumed
52
- // For proper size validation, we'll need to check this during buffer reading
53
-
54
46
  // Validate MIME type
55
47
  const mimeType = file.mimetype;
56
48
  if (!(FILE_UPLOAD_CONSTANTS.ALLOWED_MIME_TYPES as readonly string[]).includes(mimeType)) {
@@ -5,7 +5,8 @@ import { ValidationError } from '@shared/errors/errors.js';
5
5
  import { blockIp, unblockIp, getBlockedIps } from '@libs/ip-block.js';
6
6
 
7
7
  const blockIpSchema = z.object({
8
- ip: z.string().ip({ message: 'Invalid IP address' }),
8
+ ip: z.union([z.ipv4(), z.ipv6()], { message: 'Invalid IP address' }),
9
+ reason: z.string().max(500).optional(),
9
10
  });
10
11
 
11
12
  type BlockIpBody = z.infer<typeof blockIpSchema>;
@@ -27,8 +28,8 @@ export async function blockIpHandler(
27
28
  if (!parsed.success) {
28
29
  throw new ValidationError(parsed.error.issues[0]?.message ?? 'Invalid IP address', 'INVALID_IP');
29
30
  }
30
- await blockIp(parsed.data.ip);
31
- reply.status(201).send(successResponse('IP blocked successfully', { ip: parsed.data.ip }));
31
+ const blocked = await blockIp(parsed.data.ip, request.user.userId, parsed.data.reason);
32
+ reply.status(201).send(successResponse('IP blocked successfully', blocked));
32
33
  }
33
34
 
34
35
  export async function unblockIpHandler(
@@ -6,6 +6,24 @@ export async function findUserByEmail(email: string) {
6
6
  });
7
7
  }
8
8
 
9
+ export async function findDeletedUserByEmail(email: string) {
10
+ return prisma.user.findFirst({
11
+ where: { email, deletedAt: { not: null } },
12
+ });
13
+ }
14
+
15
+ export async function restoreUser(userId: string): Promise<void> {
16
+ await prisma.user.update({
17
+ where: { id: userId },
18
+ data: {
19
+ deletedAt: null,
20
+ isActive: true,
21
+ failedLoginAttempts: 0,
22
+ lockedUntil: null,
23
+ },
24
+ });
25
+ }
26
+
9
27
  export async function findUserById(id: string) {
10
28
  return prisma.user.findUnique({
11
29
  where: { id, deletedAt: null },
@@ -78,6 +78,15 @@ export async function register(
78
78
  throw new ConflictError('Email already registered', 'EMAIL_ALREADY_EXISTS');
79
79
  }
80
80
 
81
+ // Check if a soft-deleted account exists with this email — direct them to login to restore
82
+ const deletedUser = await authRepo.findDeletedUserByEmail(input.email);
83
+ if (deletedUser) {
84
+ throw new ConflictError(
85
+ 'An account with this email was recently deleted. Log in to restore it.',
86
+ 'EMAIL_ALREADY_EXISTS',
87
+ );
88
+ }
89
+
81
90
  const hashedPassword = await hashPassword(input.password);
82
91
 
83
92
  const user = await authRepo.createUser({
@@ -119,40 +128,57 @@ export async function login(
119
128
  deviceInfo?: string,
120
129
  ipAddress?: string,
121
130
  ): Promise<AuthResult> {
122
- const user = await authRepo.findUserByEmail(input.email);
123
- if (!user) {
124
- throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
125
- }
131
+ let user = await authRepo.findUserByEmail(input.email);
126
132
 
127
- // Generic error for disabled accounts prevent info leakage
128
- if (!user.isActive) {
129
- throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
130
- }
133
+ // If no active user found, check for a soft-deleted account that can be restored
134
+ if (!user) {
135
+ const deletedUser = await authRepo.findDeletedUserByEmail(input.email);
136
+ if (!deletedUser) {
137
+ throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
138
+ }
131
139
 
132
- // Check account lockout
133
- if (user.lockedUntil && user.lockedUntil > new Date()) {
134
- throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
135
- }
140
+ // Verify password before restoring — don't restore on wrong password
141
+ const validPassword = await verifyPassword(input.password, deletedUser.password);
142
+ if (!validPassword) {
143
+ throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
144
+ }
136
145
 
137
- const valid = await verifyPassword(input.password, user.password);
146
+ // Restore account: clears deletedAt, sets isActive = true, resets lockout
147
+ await authRepo.restoreUser(deletedUser.id);
148
+ user = { ...deletedUser, deletedAt: null, isActive: true, failedLoginAttempts: 0, lockedUntil: null };
149
+ } else {
150
+ // Normal login flow for active accounts
138
151
 
139
- if (!valid) {
140
- // Increment failed attempts
141
- const newAttempts = user.failedLoginAttempts + 1;
142
- await authRepo.incrementFailedAttempts(user.id);
152
+ // Generic error for disabled accounts — prevent info leakage
153
+ if (!user.isActive) {
154
+ throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
155
+ }
143
156
 
144
- // Check if we need to lock the account
145
- const lockDuration = getLockoutDuration(newAttempts);
146
- if (lockDuration) {
147
- await authRepo.setAccountLock(user.id, new Date(Date.now() + lockDuration));
157
+ // Check account lockout
158
+ if (user.lockedUntil && user.lockedUntil > new Date()) {
159
+ throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
148
160
  }
149
161
 
150
- throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
151
- }
162
+ const valid = await verifyPassword(input.password, user.password);
163
+
164
+ if (!valid) {
165
+ // Increment failed attempts
166
+ const newAttempts = user.failedLoginAttempts + 1;
167
+ await authRepo.incrementFailedAttempts(user.id);
168
+
169
+ // Check if we need to lock the account
170
+ const lockDuration = getLockoutDuration(newAttempts);
171
+ if (lockDuration) {
172
+ await authRepo.setAccountLock(user.id, new Date(Date.now() + lockDuration));
173
+ }
174
+
175
+ throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
176
+ }
152
177
 
153
- // Successful login — reset failed attempts
154
- if (user.failedLoginAttempts > 0 || user.lockedUntil) {
155
- await authRepo.resetFailedAttempts(user.id);
178
+ // Successful login — reset failed attempts
179
+ if (user.failedLoginAttempts > 0 || user.lockedUntil) {
180
+ await authRepo.resetFailedAttempts(user.id);
181
+ }
156
182
  }
157
183
 
158
184
  const accessToken = signAccessToken({
@@ -10,7 +10,8 @@ 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
12
  import { clearAuthCookies } from '@libs/cookies.js';
13
- import type { GetUserAvatarParams, UpdateProfileInput, ChangePasswordInput, DeleteAccountInput } from './users.schemas.js';
13
+ import { ChangePasswordSchema, AdminChangePasswordSchema, DeleteAccountSchema } from './users.schemas.js';
14
+ import type { GetUserAvatarParams, UpdateProfileInput } from './users.schemas.js';
14
15
  import type { AuthenticatedRequest } from '@shared/types/index.js';
15
16
  import { logger } from '@libs/logger.js';
16
17
  import { BadRequestError } from '@shared/errors/errors.js';
@@ -48,8 +49,8 @@ class UsersController {
48
49
  // Validate file size
49
50
  validateFileSize(buffer);
50
51
 
51
- // Get authenticated user ID
52
- const userId = request.user.userId;
52
+ // Get target user ID (set by resolveMe or resolveTargetUser middleware)
53
+ const userId = request.targetUserId!;
53
54
 
54
55
  logger.info({
55
56
  msg: 'Avatar upload request',
@@ -79,8 +80,8 @@ class UsersController {
79
80
  * @param reply - Fastify reply
80
81
  */
81
82
  async deleteAvatar(request: AuthenticatedRequest, reply: FastifyReply): Promise<void> {
82
- // Get authenticated user ID
83
- const userId = request.user.userId;
83
+ // Get target user ID (set by resolveMe or resolveTargetUser middleware)
84
+ const userId = request.targetUserId!;
84
85
 
85
86
  logger.info({ msg: 'Avatar delete request', userId });
86
87
 
@@ -126,14 +127,15 @@ class UsersController {
126
127
  * @param reply - Fastify reply
127
128
  */
128
129
  async updateProfile(
129
- request: FastifyRequest<{ Body: UpdateProfileInput }>,
130
+ request: FastifyRequest,
130
131
  reply: FastifyReply
131
132
  ): Promise<void> {
132
- const userId = request.user.userId;
133
+ const userId = request.targetUserId!;
134
+ const body = request.body as UpdateProfileInput;
133
135
 
134
136
  logger.info({ msg: 'Update profile request', userId });
135
137
 
136
- const updatedUser = await usersService.updateProfile(userId, request.body);
138
+ const updatedUser = await usersService.updateProfile(userId, body);
137
139
 
138
140
  return reply.send(successResponse('Profile updated successfully', updatedUser));
139
141
  }
@@ -148,14 +150,21 @@ class UsersController {
148
150
  * @param reply - Fastify reply
149
151
  */
150
152
  async changePassword(
151
- request: FastifyRequest<{ Body: ChangePasswordInput }>,
153
+ request: FastifyRequest,
152
154
  reply: FastifyReply
153
155
  ): 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);
156
+ const userId = request.targetUserId!;
157
+ const isAdminAction = request.isAdminAction ?? false;
158
+
159
+ if (isAdminAction) {
160
+ const parsed = AdminChangePasswordSchema.parse(request.body);
161
+ logger.info({ msg: 'Admin change password request', targetUserId: userId, adminUserId: request.user.userId });
162
+ await usersService.changePassword(userId, parsed, true);
163
+ } else {
164
+ const parsed = ChangePasswordSchema.parse(request.body);
165
+ logger.info({ msg: 'Change password request', userId });
166
+ await usersService.changePassword(userId, parsed, false);
167
+ }
159
168
 
160
169
  return reply.send(successResponse('Password changed successfully', null));
161
170
  }
@@ -170,16 +179,25 @@ class UsersController {
170
179
  * @param reply - Fastify reply
171
180
  */
172
181
  async deleteAccount(
173
- request: FastifyRequest<{ Body: DeleteAccountInput }>,
182
+ request: FastifyRequest,
174
183
  reply: FastifyReply
175
184
  ): 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);
185
+ const userId = request.targetUserId!;
186
+ const isAdminAction = request.isAdminAction ?? false;
187
+
188
+ if (isAdminAction) {
189
+ logger.info({ msg: 'Admin delete account request', targetUserId: userId, adminUserId: request.user.userId });
190
+ await usersService.deleteAccount(userId, '', true);
191
+ } else {
192
+ const parsed = DeleteAccountSchema.parse(request.body);
193
+ logger.info({ msg: 'Delete account request', userId });
194
+ await usersService.deleteAccount(userId, parsed.password, false);
195
+ }
181
196
 
182
- clearAuthCookies(reply);
197
+ // Only clear cookies if the user is deleting their OWN account
198
+ if (!isAdminAction) {
199
+ clearAuthCookies(reply);
200
+ }
183
201
 
184
202
  return reply.send(successResponse('Account deleted successfully', null));
185
203
  }
@@ -8,11 +8,12 @@ import type { FastifyInstance } from 'fastify';
8
8
  import { usersController } from './users.controller.js';
9
9
  import {
10
10
  GetUserAvatarSchema,
11
+ UserIdParamSchema,
11
12
  UpdateProfileSchema,
12
13
  ChangePasswordSchema,
13
14
  DeleteAccountSchema,
14
15
  } from './users.schemas.js';
15
- import { authenticate } from '@libs/auth.js';
16
+ import { authenticate, resolveMe, resolveTargetUser } from '@libs/auth.js';
16
17
  import { RATE_LIMITS } from '@config/rate-limit.config.js';
17
18
 
18
19
  /**
@@ -25,6 +26,11 @@ import { RATE_LIMITS } from '@config/rate-limit.config.js';
25
26
  * - POST /users/avatar - Upload avatar (authenticated)
26
27
  * - DELETE /users/avatar - Delete avatar (authenticated)
27
28
  * - GET /users/:userId/avatar - Get avatar (public)
29
+ * - PATCH /users/:userId - Update profile (owner or admin)
30
+ * - PATCH /users/:userId/password - Change password (owner or admin)
31
+ * - DELETE /users/:userId - Delete account (owner or admin)
32
+ * - POST /users/:userId/avatar - Upload avatar (owner or admin)
33
+ * - DELETE /users/:userId/avatar - Delete avatar (owner or admin)
28
34
  *
29
35
  * @param fastify - Fastify instance
30
36
  */
@@ -42,7 +48,7 @@ export async function usersRoutes(fastify: FastifyInstance): Promise<void> {
42
48
  fastify.patch(
43
49
  '/users/me',
44
50
  {
45
- preValidation: [authenticate],
51
+ preValidation: [authenticate, resolveMe],
46
52
  schema: {
47
53
  body: UpdateProfileSchema,
48
54
  },
@@ -64,7 +70,7 @@ export async function usersRoutes(fastify: FastifyInstance): Promise<void> {
64
70
  fastify.patch(
65
71
  '/users/me/password',
66
72
  {
67
- preValidation: [authenticate],
73
+ preValidation: [authenticate, resolveMe],
68
74
  schema: {
69
75
  body: ChangePasswordSchema,
70
76
  },
@@ -86,7 +92,7 @@ export async function usersRoutes(fastify: FastifyInstance): Promise<void> {
86
92
  fastify.delete(
87
93
  '/users/me',
88
94
  {
89
- preValidation: [authenticate],
95
+ preValidation: [authenticate, resolveMe],
90
96
  schema: {
91
97
  body: DeleteAccountSchema,
92
98
  },
@@ -111,7 +117,7 @@ export async function usersRoutes(fastify: FastifyInstance): Promise<void> {
111
117
  fastify.post(
112
118
  '/users/avatar',
113
119
  {
114
- preValidation: [authenticate],
120
+ preValidation: [authenticate, resolveMe],
115
121
  config: {
116
122
  rateLimit: RATE_LIMITS.USERS_UPLOAD_AVATAR,
117
123
  },
@@ -129,7 +135,7 @@ export async function usersRoutes(fastify: FastifyInstance): Promise<void> {
129
135
  fastify.delete(
130
136
  '/users/avatar',
131
137
  {
132
- preValidation: [authenticate],
138
+ preValidation: [authenticate, resolveMe],
133
139
  config: {
134
140
  rateLimit: RATE_LIMITS.USERS_DELETE_AVATAR,
135
141
  },
@@ -156,4 +162,119 @@ export async function usersRoutes(fastify: FastifyInstance): Promise<void> {
156
162
  },
157
163
  usersController.getAvatar.bind(usersController)
158
164
  );
165
+
166
+ // --- /users/:userId variants (owner or admin) ---
167
+
168
+ /**
169
+ * Update profile (by user ID)
170
+ *
171
+ * PATCH /api/v1/users/:userId
172
+ * Body: { firstName?, lastName? }
173
+ * Auth: Required (owner or admin)
174
+ * Rate limit: 10 requests per minute
175
+ */
176
+ fastify.patch(
177
+ '/users/:userId',
178
+ {
179
+ preValidation: [authenticate, resolveTargetUser],
180
+ schema: {
181
+ params: UserIdParamSchema,
182
+ body: UpdateProfileSchema,
183
+ },
184
+ config: {
185
+ rateLimit: RATE_LIMITS.USERS_UPDATE_PROFILE,
186
+ },
187
+ },
188
+ usersController.updateProfile.bind(usersController)
189
+ );
190
+
191
+ /**
192
+ * Change password (by user ID)
193
+ *
194
+ * PATCH /api/v1/users/:userId/password
195
+ * Body (owner): { currentPassword, newPassword }
196
+ * Body (admin): { newPassword }
197
+ * Auth: Required (owner or admin)
198
+ * Rate limit: 5 requests per minute
199
+ */
200
+ fastify.patch(
201
+ '/users/:userId/password',
202
+ {
203
+ preValidation: [authenticate, resolveTargetUser],
204
+ schema: {
205
+ params: UserIdParamSchema,
206
+ },
207
+ config: {
208
+ rateLimit: RATE_LIMITS.USERS_CHANGE_PASSWORD,
209
+ },
210
+ },
211
+ usersController.changePassword.bind(usersController)
212
+ );
213
+
214
+ /**
215
+ * Delete account (by user ID)
216
+ *
217
+ * DELETE /api/v1/users/:userId
218
+ * Body (owner): { password }
219
+ * Body (admin): empty
220
+ * Auth: Required (owner or admin)
221
+ * Rate limit: 3 requests per minute
222
+ */
223
+ fastify.delete(
224
+ '/users/:userId',
225
+ {
226
+ preValidation: [authenticate, resolveTargetUser],
227
+ schema: {
228
+ params: UserIdParamSchema,
229
+ },
230
+ config: {
231
+ rateLimit: RATE_LIMITS.USERS_DELETE_ACCOUNT,
232
+ },
233
+ },
234
+ usersController.deleteAccount.bind(usersController)
235
+ );
236
+
237
+ /**
238
+ * Upload avatar (by user ID)
239
+ *
240
+ * POST /api/v1/users/:userId/avatar
241
+ * Content-Type: multipart/form-data
242
+ * Body: { file: File }
243
+ * Auth: Required (owner or admin)
244
+ * Rate limit: 5 requests per minute
245
+ */
246
+ fastify.post(
247
+ '/users/:userId/avatar',
248
+ {
249
+ preValidation: [authenticate, resolveTargetUser],
250
+ schema: {
251
+ params: UserIdParamSchema,
252
+ },
253
+ config: {
254
+ rateLimit: RATE_LIMITS.USERS_UPLOAD_AVATAR,
255
+ },
256
+ },
257
+ usersController.uploadAvatar.bind(usersController)
258
+ );
259
+
260
+ /**
261
+ * Delete avatar (by user ID)
262
+ *
263
+ * DELETE /api/v1/users/:userId/avatar
264
+ * Auth: Required (owner or admin)
265
+ * Rate limit: 10 requests per minute
266
+ */
267
+ fastify.delete(
268
+ '/users/:userId/avatar',
269
+ {
270
+ preValidation: [authenticate, resolveTargetUser],
271
+ schema: {
272
+ params: UserIdParamSchema,
273
+ },
274
+ config: {
275
+ rateLimit: RATE_LIMITS.USERS_DELETE_AVATAR,
276
+ },
277
+ },
278
+ usersController.deleteAvatar.bind(usersController)
279
+ );
159
280
  }