feat: Add UserController and user-related DTOs for user management functionality

This commit is contained in:
Marek Lesko
2025-11-07 20:49:39 +00:00
parent 441b00b510
commit 74dfb95d99
4 changed files with 612 additions and 1 deletions

View File

@@ -0,0 +1,483 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Api.Models;
using Api.Models.DTOs;
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;
namespace Api.Controllers
{
[ApiController]
[Authorize]
[Route("api/[controller]")]
public class UserController : ControllerBase
{
private readonly AppDbContext _context;
private readonly ILogger<UserController> _logger;
public UserController(AppDbContext context, ILogger<UserController> logger)
{
_context = context;
_logger = logger;
}
/// <summary>
/// Get all users with pagination support
/// </summary>
/// <param name="page">Page number (default: 1)</param>
/// <param name="pageSize">Page size (default: 10, max: 100)</param>
/// <param name="search">Search term for email, first name, or last name</param>
/// <param name="isActive">Filter by active status</param>
/// <returns>Paginated list of users</returns>
[HttpGet]
public async Task<ActionResult<object>> GetUsers(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 10,
[FromQuery] string? search = null,
[FromQuery] bool? isActive = null)
{
try
{
// Validate pagination parameters
page = Math.Max(1, page);
pageSize = Math.Min(100, Math.Max(1, pageSize));
var query = _context.Users.Include(u => u.OAuthProviders).AsQueryable();
// Apply filters
if (isActive.HasValue)
{
query = query.Where(u => u.IsActive == isActive.Value);
}
if (!string.IsNullOrEmpty(search))
{
var searchTerm = search.ToLower();
query = query.Where(u =>
u.Email.ToLower().Contains(searchTerm) ||
(u.FirstName != null && u.FirstName.ToLower().Contains(searchTerm)) ||
(u.LastName != null && u.LastName.ToLower().Contains(searchTerm)));
}
var totalCount = await query.CountAsync();
var users = await query
.OrderByDescending(u => u.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(u => new UserDto
{
Id = u.Id,
Email = u.Email,
FirstName = u.FirstName,
LastName = u.LastName,
ProfilePictureUrl = u.ProfilePictureUrl,
CreatedAt = u.CreatedAt,
LastLoginAt = u.LastLoginAt,
IsActive = u.IsActive,
OAuthProviders = u.OAuthProviders.Select(op => new UserOAuthProviderDto
{
Id = op.Id,
Provider = op.Provider,
ProviderId = op.ProviderId,
ProviderEmail = op.ProviderEmail,
ProviderName = op.ProviderName,
CreatedAt = op.CreatedAt,
LastUsedAt = op.LastUsedAt
}).ToList()
})
.ToListAsync();
return Ok(new
{
users,
pagination = new
{
page,
pageSize,
totalCount,
totalPages = (int)Math.Ceiling(totalCount / (double)pageSize),
hasNext = page * pageSize < totalCount,
hasPrevious = page > 1
}
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving users");
return StatusCode(500, "Internal server error while retrieving users");
}
}
/// <summary>
/// Get a specific user by ID
/// </summary>
/// <param name="id">User ID</param>
/// <returns>User details</returns>
[HttpGet("{id}")]
public async Task<ActionResult<UserDto>> GetUser(int id)
{
try
{
var user = await _context.Users
.Include(u => u.OAuthProviders)
.FirstOrDefaultAsync(u => u.Id == id);
if (user == null)
{
return NotFound($"User with ID {id} not found");
}
var userDto = new UserDto
{
Id = user.Id,
Email = user.Email,
FirstName = user.FirstName,
LastName = user.LastName,
ProfilePictureUrl = user.ProfilePictureUrl,
CreatedAt = user.CreatedAt,
LastLoginAt = user.LastLoginAt,
IsActive = user.IsActive,
OAuthProviders = user.OAuthProviders.Select(op => new UserOAuthProviderDto
{
Id = op.Id,
Provider = op.Provider,
ProviderId = op.ProviderId,
ProviderEmail = op.ProviderEmail,
ProviderName = op.ProviderName,
CreatedAt = op.CreatedAt,
LastUsedAt = op.LastUsedAt
}).ToList()
};
return Ok(userDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving user with ID {UserId}", id);
return StatusCode(500, "Internal server error while retrieving user");
}
}
/// <summary>
/// Get the current user's profile
/// </summary>
/// <returns>Current user's profile</returns>
[HttpGet("me")]
public async Task<ActionResult<UserDto>> GetCurrentUser()
{
try
{
var userIdClaim = User.FindFirst("user_id")?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId))
{
return Unauthorized("Invalid user token");
}
return await GetUser(userId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving current user profile");
return StatusCode(500, "Internal server error while retrieving current user");
}
}
/// <summary>
/// Create a new user
/// </summary>
/// <param name="request">User creation request</param>
/// <returns>Created user</returns>
[HttpPost]
public async Task<ActionResult<UserDto>> CreateUser([FromBody] CreateUserRequest request)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
try
{
// Check if user with this email already exists
var existingUser = await _context.Users
.FirstOrDefaultAsync(u => u.Email == request.Email);
if (existingUser != null)
{
return Conflict($"User with email {request.Email} already exists");
}
var user = new User
{
Email = request.Email,
FirstName = request.FirstName,
LastName = request.LastName,
ProfilePictureUrl = request.ProfilePictureUrl,
IsActive = request.IsActive,
CreatedAt = DateTime.UtcNow
};
_context.Users.Add(user);
await _context.SaveChangesAsync();
_logger.LogInformation("Created new user with ID {UserId} and email {Email}", user.Id, user.Email);
var userDto = new UserDto
{
Id = user.Id,
Email = user.Email,
FirstName = user.FirstName,
LastName = user.LastName,
ProfilePictureUrl = user.ProfilePictureUrl,
CreatedAt = user.CreatedAt,
LastLoginAt = user.LastLoginAt,
IsActive = user.IsActive,
OAuthProviders = new List<UserOAuthProviderDto>()
};
return CreatedAtAction(nameof(GetUser), new { id = user.Id }, userDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating user with email {Email}", request.Email);
return StatusCode(500, "Internal server error while creating user");
}
}
/// <summary>
/// Update a user
/// </summary>
/// <param name="id">User ID</param>
/// <param name="request">User update request</param>
/// <returns>No content on success</returns>
[HttpPut("{id}")]
public async Task<IActionResult> UpdateUser(int id, [FromBody] UpdateUserRequest request)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
try
{
var user = await _context.Users.FindAsync(id);
if (user == null)
{
return NotFound($"User with ID {id} not found");
}
// Update only provided fields
if (request.FirstName != null)
{
user.FirstName = request.FirstName;
}
if (request.LastName != null)
{
user.LastName = request.LastName;
}
if (request.ProfilePictureUrl != null)
{
user.ProfilePictureUrl = request.ProfilePictureUrl;
}
if (request.IsActive.HasValue)
{
user.IsActive = request.IsActive.Value;
}
_context.Entry(user).State = EntityState.Modified;
await _context.SaveChangesAsync();
_logger.LogInformation("Updated user with ID {UserId}", id);
return NoContent();
}
catch (DbUpdateConcurrencyException)
{
if (!await _context.Users.AnyAsync(e => e.Id == id))
{
return NotFound($"User with ID {id} not found");
}
else
{
throw;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating user with ID {UserId}", id);
return StatusCode(500, "Internal server error while updating user");
}
}
/// <summary>
/// Update the current user's profile
/// </summary>
/// <param name="request">User update request</param>
/// <returns>No content on success</returns>
[HttpPut("me")]
public async Task<IActionResult> UpdateCurrentUser([FromBody] UpdateUserRequest request)
{
try
{
var userIdClaim = User.FindFirst("user_id")?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId))
{
return Unauthorized("Invalid user token");
}
return await UpdateUser(userId, request);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating current user profile");
return StatusCode(500, "Internal server error while updating current user");
}
}
/// <summary>
/// Delete a user (soft delete by setting IsActive to false)
/// </summary>
/// <param name="id">User ID</param>
/// <returns>No content on success</returns>
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteUser(int id)
{
try
{
var user = await _context.Users.FindAsync(id);
if (user == null)
{
return NotFound($"User with ID {id} not found");
}
// Soft delete by setting IsActive to false
user.IsActive = false;
_context.Entry(user).State = EntityState.Modified;
await _context.SaveChangesAsync();
_logger.LogInformation("Soft deleted user with ID {UserId}", id);
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting user with ID {UserId}", id);
return StatusCode(500, "Internal server error while deleting user");
}
}
/// <summary>
/// Permanently delete a user and all associated OAuth providers
/// </summary>
/// <param name="id">User ID</param>
/// <returns>No content on success</returns>
[HttpDelete("{id}/permanent")]
public async Task<IActionResult> PermanentlyDeleteUser(int id)
{
try
{
var user = await _context.Users
.Include(u => u.OAuthProviders)
.FirstOrDefaultAsync(u => u.Id == id);
if (user == null)
{
return NotFound($"User with ID {id} not found");
}
// Remove all OAuth providers first due to foreign key constraints
_context.UserOAuthProviders.RemoveRange(user.OAuthProviders);
// Remove the user
_context.Users.Remove(user);
await _context.SaveChangesAsync();
_logger.LogInformation("Permanently deleted user with ID {UserId} and {ProviderCount} OAuth providers",
id, user.OAuthProviders.Count);
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error permanently deleting user with ID {UserId}", id);
return StatusCode(500, "Internal server error while permanently deleting user");
}
}
/// <summary>
/// Reactivate a soft-deleted user
/// </summary>
/// <param name="id">User ID</param>
/// <returns>No content on success</returns>
[HttpPost("{id}/reactivate")]
public async Task<IActionResult> ReactivateUser(int id)
{
try
{
var user = await _context.Users.FindAsync(id);
if (user == null)
{
return NotFound($"User with ID {id} not found");
}
user.IsActive = true;
_context.Entry(user).State = EntityState.Modified;
await _context.SaveChangesAsync();
_logger.LogInformation("Reactivated user with ID {UserId}", id);
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error reactivating user with ID {UserId}", id);
return StatusCode(500, "Internal server error while reactivating user");
}
}
/// <summary>
/// Get user statistics
/// </summary>
/// <returns>User statistics</returns>
[HttpGet("statistics")]
public async Task<ActionResult<object>> GetUserStatistics()
{
try
{
var totalUsers = await _context.Users.CountAsync();
var activeUsers = await _context.Users.CountAsync(u => u.IsActive);
var inactiveUsers = totalUsers - activeUsers;
var usersWithLogin = await _context.Users.CountAsync(u => u.LastLoginAt != null);
var recentUsers = await _context.Users.CountAsync(u => u.CreatedAt >= DateTime.UtcNow.AddDays(-30));
var providerStats = await _context.UserOAuthProviders
.GroupBy(op => op.Provider)
.Select(g => new
{
Provider = g.Key.ToString(),
Count = g.Count(),
UniqueUsers = g.Select(op => op.UserId).Distinct().Count()
})
.ToListAsync();
return Ok(new
{
totalUsers,
activeUsers,
inactiveUsers,
usersWithLogin,
recentUsers,
providerStatistics = providerStats
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving user statistics");
return StatusCode(500, "Internal server error while retrieving statistics");
}
}
}
}

View File

@@ -36,6 +36,64 @@ namespace Api.Models.DTOs
public List<string> Providers { get; set; } = new List<string>(); public List<string> Providers { get; set; } = new List<string>();
} }
// User CRUD DTOs
public class CreateUserRequest
{
[Required]
[EmailAddress]
[StringLength(255)]
public string Email { get; set; } = string.Empty;
[StringLength(255)]
public string? FirstName { get; set; }
[StringLength(255)]
public string? LastName { get; set; }
[StringLength(500)]
public string? ProfilePictureUrl { get; set; }
public bool IsActive { get; set; } = true;
}
public class UpdateUserRequest
{
[StringLength(255)]
public string? FirstName { get; set; }
[StringLength(255)]
public string? LastName { get; set; }
[StringLength(500)]
public string? ProfilePictureUrl { get; set; }
public bool? IsActive { get; set; }
}
public class UserDto
{
public int Id { get; set; }
public string Email { get; set; } = string.Empty;
public string? FirstName { get; set; }
public string? LastName { get; set; }
public string? ProfilePictureUrl { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? LastLoginAt { get; set; }
public bool IsActive { get; set; }
public List<UserOAuthProviderDto> OAuthProviders { get; set; } = new List<UserOAuthProviderDto>();
}
public class UserOAuthProviderDto
{
public int Id { get; set; }
public OAuthProvider Provider { get; set; }
public string ProviderId { get; set; } = string.Empty;
public string? ProviderEmail { get; set; }
public string? ProviderName { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? LastUsedAt { get; set; }
}
public class JwtSettings public class JwtSettings
{ {
public string SecretKey { get; set; } = string.Empty; public string SecretKey { get; set; } = string.Empty;

70
Api/User.http Normal file
View File

@@ -0,0 +1,70 @@
### User Controller API Tests
@baseUrl = http://localhost:5000
@authToken = YOUR_JWT_TOKEN_HERE
### Get all users with pagination
GET {{baseUrl}}/api/user?page=1&pageSize=10
Authorization: Bearer {{authToken}}
### Get all users with search
GET {{baseUrl}}/api/user?search=john&isActive=true
Authorization: Bearer {{authToken}}
### Get specific user by ID
GET {{baseUrl}}/api/user/1
Authorization: Bearer {{authToken}}
### Get current user profile
GET {{baseUrl}}/api/user/me
Authorization: Bearer {{authToken}}
### Create a new user
POST {{baseUrl}}/api/user
Authorization: Bearer {{authToken}}
Content-Type: application/json
{
"email": "newuser@example.com",
"firstName": "John",
"lastName": "Doe",
"profilePictureUrl": "https://example.com/profile.jpg",
"isActive": true
}
### Update a user
PUT {{baseUrl}}/api/user/1
Authorization: Bearer {{authToken}}
Content-Type: application/json
{
"firstName": "Updated John",
"lastName": "Updated Doe",
"isActive": true
}
### Update current user profile
PUT {{baseUrl}}/api/user/me
Authorization: Bearer {{authToken}}
Content-Type: application/json
{
"firstName": "My Updated Name",
"profilePictureUrl": "https://example.com/new-profile.jpg"
}
### Soft delete user (deactivate)
DELETE {{baseUrl}}/api/user/1
Authorization: Bearer {{authToken}}
### Reactivate a soft-deleted user
POST {{baseUrl}}/api/user/1/reactivate
Authorization: Bearer {{authToken}}
### Permanently delete user
DELETE {{baseUrl}}/api/user/1/permanent
Authorization: Bearer {{authToken}}
### Get user statistics
GET {{baseUrl}}/api/user/statistics
Authorization: Bearer {{authToken}}

View File

@@ -305,7 +305,7 @@ export class AuthenticationService {
redirectUri: `${window.location.origin}/authentication/callback` redirectUri: `${window.location.origin}/authentication/callback`
}; };
console.log('Google OAuth Config:', googleConfig); console.log('Google OAuth Config:', JSON.stringify(googleConfig));
return this.startLogin(googleConfig); return this.startLogin(googleConfig);
} }