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.
- package/bin/create-tigra.js +2 -0
- package/package.json +4 -1
- package/template/_claude/commands/create-client.md +1 -4
- package/template/_claude/commands/create-server.md +0 -1
- package/template/_claude/rules/client/01-project-structure.md +0 -3
- package/template/_claude/rules/client/03-data-and-state.md +1 -1
- package/template/_claude/rules/server/project-conventions.md +13 -0
- package/template/client/package.json +2 -1
- package/template/server/package.json +2 -1
- package/template/server/postman/collection.json +114 -5
- package/template/server/postman/environment.json +2 -2
- package/template/server/prisma/schema.prisma +17 -1
- package/template/server/src/app.ts +4 -1
- package/template/server/src/jobs/cleanup-deleted-accounts.job.ts +3 -6
- package/template/server/src/libs/auth.ts +45 -1
- package/template/server/src/libs/ip-block.ts +90 -29
- package/template/server/src/libs/requestLogger.ts +1 -1
- package/template/server/src/libs/storage/file-storage.service.ts +65 -18
- package/template/server/src/libs/storage/file-validator.ts +0 -8
- package/template/server/src/modules/admin/admin.controller.ts +4 -3
- package/template/server/src/modules/auth/auth.repo.ts +18 -0
- package/template/server/src/modules/auth/auth.service.ts +52 -26
- package/template/server/src/modules/users/users.controller.ts +39 -21
- package/template/server/src/modules/users/users.routes.ts +127 -6
- package/template/server/src/modules/users/users.schemas.ts +24 -4
- package/template/server/src/modules/users/users.service.ts +23 -10
- 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
|
|
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
|
|
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
|
-
//
|
|
26
|
-
this.
|
|
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/
|
|
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
|
|
65
|
-
await this.ensureDirectoryExists(
|
|
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(
|
|
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/
|
|
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
|
|
120
|
+
const avatarDir = this.getUserAvatarDir(userId);
|
|
105
121
|
|
|
106
122
|
// Check if directory exists
|
|
107
|
-
const exists = await this.directoryExists(
|
|
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
|
|
114
|
-
await fs.rm(
|
|
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.
|
|
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/
|
|
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
|
|
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.
|
|
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.
|
|
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',
|
|
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
|
-
|
|
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
|
-
//
|
|
128
|
-
if (!user
|
|
129
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
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
|
|
52
|
-
const userId = request.
|
|
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
|
|
83
|
-
const userId = request.
|
|
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
|
|
130
|
+
request: FastifyRequest,
|
|
130
131
|
reply: FastifyReply
|
|
131
132
|
): Promise<void> {
|
|
132
|
-
const userId = request.
|
|
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,
|
|
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
|
|
153
|
+
request: FastifyRequest,
|
|
152
154
|
reply: FastifyReply
|
|
153
155
|
): Promise<void> {
|
|
154
|
-
const userId = request.
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
182
|
+
request: FastifyRequest,
|
|
174
183
|
reply: FastifyReply
|
|
175
184
|
): Promise<void> {
|
|
176
|
-
const userId = request.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
}
|