diff --git a/backend/src/controllers/users.controller.ts b/backend/src/controllers/users.controller.ts index d437f10..fcba3c1 100644 --- a/backend/src/controllers/users.controller.ts +++ b/backend/src/controllers/users.controller.ts @@ -42,6 +42,27 @@ export class UsersController { } } + /** + * PATCH /api/users/me — requires auth; updates username and/or avatarUrl + */ + async updateMyProfile(req: AuthenticatedRequest, res: Response) { + try { + const userId = req.user!.userId; + const { username, avatarUrl } = req.body; + const updated = await userService.updateProfile(userId, { username, avatarUrl }); + return res.status(200).json({ success: true, data: updated }); + } catch (error: any) { + if (error.message === 'User not found') { + return res.status(404).json({ success: false, message: 'User not found' }); + } + if (error.message === 'Username already in use') { + return res.status(409).json({ success: false, message: 'Username already in use' }); + } + logger.error('UsersController.updateMyProfile error', { error }); + return res.status(500).json({ success: false, message: 'Internal server error' }); + } + } + /** * GET /api/users — admin only; paginated user list with filters */ diff --git a/backend/src/routes/users.routes.ts b/backend/src/routes/users.routes.ts index f30315e..2310ede 100644 --- a/backend/src/routes/users.routes.ts +++ b/backend/src/routes/users.routes.ts @@ -3,6 +3,8 @@ import { Router, Response, NextFunction } from 'express'; import { usersController } from '../controllers/users.controller.js'; import { requireAuth } from '../middleware/auth.middleware.js'; import { requireAdmin } from '../middleware/admin.middleware.js'; +import { validate } from '../middleware/validation.middleware.js'; +import { updateProfileBody } from '../schemas/validation.schemas.js'; import { AuthenticatedRequest } from '../types/auth.types.js'; import { UserRepository } from '../repositories/user.repository.js'; @@ -47,6 +49,41 @@ async function rejectSuspended( */ router.get('/me', requireAuth, rejectSuspended, usersController.getMyProfile.bind(usersController)); +/** + * @swagger + * /api/users/me: + * patch: + * summary: Update authenticated user's profile + * tags: [Users] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * username: + * type: string + * minLength: 3 + * maxLength: 30 + * pattern: '^[a-zA-Z0-9_]+$' + * avatarUrl: + * type: string + * format: uri + * responses: + * 200: + * description: Updated user profile + * 400: + * description: Validation error + * 401: + * description: Unauthorized + * 409: + * description: Username already in use + */ +router.patch('/me', requireAuth, rejectSuspended, validate({ body: updateProfileBody }), usersController.updateMyProfile.bind(usersController)); + /** * @swagger * /api/users: diff --git a/backend/src/schemas/validation.schemas.ts b/backend/src/schemas/validation.schemas.ts index cd79ef2..7698d50 100644 --- a/backend/src/schemas/validation.schemas.ts +++ b/backend/src/schemas/validation.schemas.ts @@ -374,3 +374,20 @@ export const getTransactionsQuery = z.object({ .datetime() .optional(), }); + +// --- User profile update schema (issue #36) --- + +export const updateProfileBody = z + .object({ + username: z + .string() + .trim() + .min(3, 'Username must be at least 3 characters') + .max(30, 'Username must be at most 30 characters') + .regex(/^[a-zA-Z0-9_]+$/, 'Username may only contain letters, numbers, and underscores') + .optional(), + avatarUrl: z.string().url('avatarUrl must be a valid URL').optional(), + }) + .refine((data) => data.username !== undefined || data.avatarUrl !== undefined, { + message: 'At least one field (username or avatarUrl) must be provided', + }); diff --git a/backend/src/services/user.service.ts b/backend/src/services/user.service.ts index 99ce95a..1ebf918 100644 --- a/backend/src/services/user.service.ts +++ b/backend/src/services/user.service.ts @@ -106,7 +106,7 @@ export class UserService { if (sanitizedData.username) { const existing = await this.userRepository.findByUsername(sanitizedData.username); - if (existing && existing.id !== userId) throw new Error('Username already taken'); + if (existing && existing.id !== userId) throw new Error('Username already in use'); } const user = await this.userRepository.update(userId, sanitizedData);