create-tigra 2.1.5 → 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.
- package/package.json +1 -1
- package/template/server/.env.example +23 -0
- package/template/server/.env.example.production +21 -0
- package/template/server/postman/collection.json +415 -0
- package/template/server/postman/environment.json +31 -0
- package/template/server/src/app.ts +40 -10
- package/template/server/src/config/env.ts +9 -0
- package/template/server/src/config/rate-limit.config.ts +114 -0
- package/template/server/src/jobs/cleanup-deleted-accounts.job.ts +83 -0
- package/template/server/src/{libs/cleanup.ts → jobs/cleanup-expired-auth.job.ts} +10 -4
- package/template/server/src/jobs/index.ts +20 -0
- package/template/server/src/libs/ip-block.ts +145 -0
- package/template/server/src/libs/storage/file-validator.ts +4 -3
- package/template/server/src/libs/storage/image-optimizer.service.ts +1 -1
- package/template/server/src/modules/admin/admin.controller.ts +41 -0
- package/template/server/src/modules/admin/admin.routes.ts +45 -0
- package/template/server/src/modules/auth/auth.routes.ts +10 -30
- package/template/server/src/modules/users/users.controller.ts +70 -1
- package/template/server/src/modules/users/users.repo.ts +27 -0
- package/template/server/src/modules/users/users.routes.ts +86 -16
- package/template/server/src/modules/users/users.schemas.ts +38 -0
- package/template/server/src/modules/users/users.service.ts +110 -2
- package/template/server/tsconfig.json +2 -1
- package/template/server/uploads/avatars/.gitkeep +0 -1
|
@@ -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
|
|
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 {
|
|
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
|
-
* -
|
|
17
|
-
* -
|
|
18
|
-
* -
|
|
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)
|
|
@@ -15,7 +15,45 @@ export const GetUserAvatarSchema = z.object({
|
|
|
15
15
|
userId: z.string().uuid({ message: 'Invalid user ID format' }),
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Schema for updating user profile
|
|
20
|
+
*
|
|
21
|
+
* All fields optional — at least one must be provided (validated in service layer)
|
|
22
|
+
*/
|
|
23
|
+
export const UpdateProfileSchema = z.object({
|
|
24
|
+
firstName: z.string().min(2, 'First name must be at least 2 characters').max(100).trim().optional(),
|
|
25
|
+
lastName: z.string().min(2, 'Last name must be at least 2 characters').max(100).trim().optional(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Schema for changing user password
|
|
30
|
+
*
|
|
31
|
+
* Requires current password for verification and new password meeting strength rules
|
|
32
|
+
*/
|
|
33
|
+
export const ChangePasswordSchema = z.object({
|
|
34
|
+
currentPassword: z.string().min(1, 'Current password is required'),
|
|
35
|
+
newPassword: z
|
|
36
|
+
.string()
|
|
37
|
+
.min(8, 'Password must be at least 8 characters')
|
|
38
|
+
.max(128, 'Password must be at most 128 characters')
|
|
39
|
+
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
|
40
|
+
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
|
41
|
+
.regex(/[0-9]/, 'Password must contain at least one number'),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Schema for deleting user account
|
|
46
|
+
*
|
|
47
|
+
* Requires password confirmation for security
|
|
48
|
+
*/
|
|
49
|
+
export const DeleteAccountSchema = z.object({
|
|
50
|
+
password: z.string().min(1, 'Password is required'),
|
|
51
|
+
});
|
|
52
|
+
|
|
18
53
|
/**
|
|
19
54
|
* Type inference from schemas
|
|
20
55
|
*/
|
|
21
56
|
export type GetUserAvatarParams = z.infer<typeof GetUserAvatarSchema>;
|
|
57
|
+
export type UpdateProfileInput = z.infer<typeof UpdateProfileSchema>;
|
|
58
|
+
export type ChangePasswordInput = z.infer<typeof ChangePasswordSchema>;
|
|
59
|
+
export type DeleteAccountInput = z.infer<typeof DeleteAccountSchema>;
|
|
@@ -6,16 +6,20 @@
|
|
|
6
6
|
|
|
7
7
|
import { usersRepository } from './users.repo.js';
|
|
8
8
|
import type { SafeUser } from './users.repo.js';
|
|
9
|
+
import * as authRepo from '@modules/auth/auth.repo.js';
|
|
10
|
+
import { sessionRepository } from '@modules/auth/session.repo.js';
|
|
9
11
|
import { fileStorageService } from '@libs/storage/file-storage.service.js';
|
|
10
12
|
import { imageOptimizerService } from '@libs/storage/image-optimizer.service.js';
|
|
11
|
-
import {
|
|
13
|
+
import { verifyPassword, hashPassword } from '@libs/password.js';
|
|
14
|
+
import { NotFoundError, BadRequestError, UnauthorizedError } from '@shared/errors/errors.js';
|
|
12
15
|
import { logger } from '@libs/logger.js';
|
|
13
16
|
import path from 'path';
|
|
17
|
+
import type { UpdateProfileInput, ChangePasswordInput } from './users.schemas.js';
|
|
14
18
|
|
|
15
19
|
/**
|
|
16
20
|
* Users Service Class
|
|
17
21
|
*
|
|
18
|
-
* Handles business logic for user avatar operations.
|
|
22
|
+
* Handles business logic for user profile and avatar operations.
|
|
19
23
|
*/
|
|
20
24
|
class UsersService {
|
|
21
25
|
/**
|
|
@@ -163,6 +167,110 @@ class UsersService {
|
|
|
163
167
|
url: user.avatarUrl,
|
|
164
168
|
};
|
|
165
169
|
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Updates a user's profile
|
|
173
|
+
*
|
|
174
|
+
* @param userId - User's unique ID
|
|
175
|
+
* @param input - Fields to update (firstName, lastName)
|
|
176
|
+
* @returns Updated user
|
|
177
|
+
* @throws NotFoundError if user not found
|
|
178
|
+
* @throws BadRequestError if no fields provided
|
|
179
|
+
*/
|
|
180
|
+
async updateProfile(userId: string, input: UpdateProfileInput): Promise<SafeUser> {
|
|
181
|
+
const { firstName, lastName } = input;
|
|
182
|
+
if (!firstName && !lastName) {
|
|
183
|
+
throw new BadRequestError('At least one field must be provided', 'INVALID_INPUT');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const user = await usersRepository.getUserById(userId);
|
|
187
|
+
if (!user) {
|
|
188
|
+
throw new NotFoundError('User not found', 'USER_NOT_FOUND');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
logger.info({
|
|
192
|
+
msg: 'Updating user profile',
|
|
193
|
+
userId,
|
|
194
|
+
fields: Object.keys(input).filter(k => input[k as keyof UpdateProfileInput] !== undefined),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const updateData: { firstName?: string; lastName?: string } = {};
|
|
198
|
+
if (firstName) updateData.firstName = firstName;
|
|
199
|
+
if (lastName) updateData.lastName = lastName;
|
|
200
|
+
|
|
201
|
+
const updatedUser = await usersRepository.updateUserProfile(userId, updateData);
|
|
202
|
+
|
|
203
|
+
logger.info({ msg: 'User profile updated', userId });
|
|
204
|
+
|
|
205
|
+
return updatedUser;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Changes a user's password
|
|
210
|
+
*
|
|
211
|
+
* @param userId - User's unique ID
|
|
212
|
+
* @param input - Current and new password
|
|
213
|
+
* @throws NotFoundError if user not found
|
|
214
|
+
* @throws UnauthorizedError if current password is wrong
|
|
215
|
+
* @throws BadRequestError if new password same as current
|
|
216
|
+
*/
|
|
217
|
+
async changePassword(userId: string, input: ChangePasswordInput): Promise<void> {
|
|
218
|
+
const user = await authRepo.findUserById(userId);
|
|
219
|
+
if (!user) {
|
|
220
|
+
throw new NotFoundError('User not found', 'USER_NOT_FOUND');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const isValid = await verifyPassword(input.currentPassword, user.password);
|
|
224
|
+
if (!isValid) {
|
|
225
|
+
throw new UnauthorizedError('Current password is incorrect', 'INVALID_CREDENTIALS');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const isSamePassword = await verifyPassword(input.newPassword, user.password);
|
|
229
|
+
if (isSamePassword) {
|
|
230
|
+
throw new BadRequestError('New password must be different from current password', 'SAME_PASSWORD');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const hashedPassword = await hashPassword(input.newPassword);
|
|
234
|
+
await authRepo.updateUserPassword(userId, hashedPassword);
|
|
235
|
+
|
|
236
|
+
logger.info({ msg: 'User password changed', userId });
|
|
237
|
+
|
|
238
|
+
// Security: invalidate all sessions and refresh tokens
|
|
239
|
+
await sessionRepository.deleteAllUserSessions(userId);
|
|
240
|
+
await authRepo.deleteRefreshTokensByUserId(userId);
|
|
241
|
+
|
|
242
|
+
logger.info({ msg: 'All sessions invalidated after password change', userId });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Soft-deletes a user account
|
|
247
|
+
*
|
|
248
|
+
* @param userId - User's unique ID
|
|
249
|
+
* @param password - Password confirmation
|
|
250
|
+
* @throws NotFoundError if user not found
|
|
251
|
+
* @throws UnauthorizedError if password is wrong
|
|
252
|
+
*/
|
|
253
|
+
async deleteAccount(userId: string, password: string): Promise<void> {
|
|
254
|
+
const user = await authRepo.findUserById(userId);
|
|
255
|
+
if (!user) {
|
|
256
|
+
throw new NotFoundError('User not found', 'USER_NOT_FOUND');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const isValid = await verifyPassword(password, user.password);
|
|
260
|
+
if (!isValid) {
|
|
261
|
+
throw new UnauthorizedError('Incorrect password', 'INVALID_CREDENTIALS');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
logger.info({ msg: 'Soft-deleting user account', userId });
|
|
265
|
+
|
|
266
|
+
await usersRepository.softDeleteUser(userId);
|
|
267
|
+
|
|
268
|
+
// Delete all sessions and refresh tokens
|
|
269
|
+
await sessionRepository.deleteAllUserSessions(userId);
|
|
270
|
+
await authRepo.deleteRefreshTokensByUserId(userId);
|
|
271
|
+
|
|
272
|
+
logger.info({ msg: 'User account deleted', userId });
|
|
273
|
+
}
|
|
166
274
|
}
|
|
167
275
|
|
|
168
276
|
// Export singleton instance
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
|