Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions backend/src/controllers/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
37 changes: 37 additions & 0 deletions backend/src/routes/users.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions backend/src/schemas/validation.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
2 changes: 1 addition & 1 deletion backend/src/services/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down