This commit is contained in:
ivan-pelly
2026-04-19 14:09:30 -07:00
parent 09673ab53c
commit cd204e4d10
33 changed files with 1521 additions and 11 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ var dbName = Environment.GetEnvironmentVariable("MYSQL_DATABASE") ?? "winstudent
var dbUser = Environment.GetEnvironmentVariable("MYSQL_USER") ?? "root";
var dbPassword = Environment.GetEnvironmentVariable("MYSQL_PASSWORD") ?? "";
builder.Configuration["ConnectionStrings:DefaultConnection"] =
$"Server={dbServer};Port={dbPort};Database={dbName};Uid={dbUser};Pwd={dbPassword};";
$"Server={dbServer};Port={dbPort};Database={dbName};Uid={dbUser};Pwd={dbPassword};SslMode=Disabled;";
// Override JWT key from .env if present
var envJwtKey = Environment.GetEnvironmentVariable("JWT_KEY");
+2 -1
View File
@@ -10,7 +10,8 @@
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="DotNetEnv" Version="3.1.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0" />
<PackageReference Include="MySql.Data" Version="8.4.0" />
<PackageReference Include="MySql.Data" Version="9.6.0" />
<PackageReference Include="MySqlBackup.NET" Version="2.7.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.3.0" />
</ItemGroup>
+363
View File
@@ -0,0 +1,363 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using WinStudentGoalTracker.BaseClasses;
using WinStudentGoalTracker.DataAccess;
using WinStudentGoalTracker.Models;
using WinStudentGoalTracker.Services;
namespace WinStudentGoalTracker.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AdminController : BaseController
{
private readonly AdminRepository _adminRepo = new();
// *****************************************************************
// Returns the district ID for the current user by resolving the
// program→district FK relationship. The JWT carries program_id
// but not district_id; this method bridges that gap. This is a
// deliberate design choice: the JWT stays lean, and we derive
// the district from the program FK on each request.
// *****************************************************************
private async Task<(Guid districtId, ActionResult? error)> GetDistrictForCurrentUser(Guid programId)
{
var districtId = await _adminRepo.GetDistrictIdForProgramAsync(programId);
if (!districtId.HasValue)
{
return (Guid.Empty, NotFound(new ResponseResult<object>
{
Success = false,
Message = "District not found for the current program."
}));
}
return (districtId.Value, null);
}
// ************************ Programs *************************
// *****************************************************************
// Returns all programs for the current user's district.
// *****************************************************************
[HttpGet("programs")]
[Authorize(Roles = $"{UserRoles.DistrictAdmin},{UserRoles.SuperAdmin}")]
[ProducesResponseType(typeof(ResponseResult<List<object>>), StatusCodes.Status200OK)]
public async Task<ActionResult<ResponseResult<List<object>>>> GetPrograms()
{
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
if (error is not null) return error;
var (districtId, districtError) = await GetDistrictForCurrentUser(programId);
if (districtError is not null) return districtError;
var programs = await _adminRepo.GetProgramsByDistrictAsync(districtId);
var result = programs.Select(p => new
{
programId = p.IdProgram,
name = p.Name,
description = p.Description,
createdAt = p.CreatedAt
}).ToList();
return Ok(new ResponseResult<List<object>>
{
Success = true,
Message = "Programs retrieved.",
Data = result.Cast<object>().ToList()
});
}
// *****************************************************************
// Creates a new program under the current user's district.
// *****************************************************************
[HttpPost("programs")]
[Authorize(Roles = $"{UserRoles.DistrictAdmin},{UserRoles.SuperAdmin}")]
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status400BadRequest)]
public async Task<ActionResult<ResponseResult<object>>> CreateProgram([FromBody] AdminCreateProgramDto dto)
{
if (string.IsNullOrWhiteSpace(dto.Name))
{
return BadRequest(new ResponseResult<object>
{
Success = false,
Message = "Program name is required."
});
}
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
if (error is not null) return error;
var (districtId, districtError) = await GetDistrictForCurrentUser(programId);
if (districtError is not null) return districtError;
var newProgramId = Guid.NewGuid();
var created = await _adminRepo.CreateProgramAsync(newProgramId, districtId, dto.Name, dto.Description);
return Ok(new ResponseResult<object>
{
Success = true,
Message = "Program created.",
Data = new
{
programId = newProgramId,
name = dto.Name,
description = dto.Description
}
});
}
// *****************************************************************
// Updates a program's name and description.
// *****************************************************************
[HttpPut("programs/{idProgram:guid}")]
[Authorize(Roles = $"{UserRoles.DistrictAdmin},{UserRoles.SuperAdmin}")]
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status200OK)]
public async Task<ActionResult<ResponseResult<object>>> UpdateProgram(Guid idProgram, [FromBody] AdminCreateProgramDto dto)
{
if (string.IsNullOrWhiteSpace(dto.Name))
{
return BadRequest(new ResponseResult<object>
{
Success = false,
Message = "Program name is required."
});
}
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
if (error is not null) return error;
// Verify the program belongs to the user's district
var (districtId, districtError) = await GetDistrictForCurrentUser(programId);
if (districtError is not null) return districtError;
var targetDistrictId = await _adminRepo.GetDistrictIdForProgramAsync(idProgram);
if (!targetDistrictId.HasValue || targetDistrictId.Value != districtId)
{
return NotFound(new ResponseResult<object>
{
Success = false,
Message = "Program not found in your district."
});
}
await _adminRepo.UpdateProgramAsync(idProgram, dto.Name, dto.Description);
return Ok(new ResponseResult<object>
{
Success = true,
Message = "Program updated."
});
}
// ************************ Users *************************
// *****************************************************************
// Returns all active users across programs in the user's district.
// *****************************************************************
[HttpGet("users")]
[Authorize(Roles = $"{UserRoles.DistrictAdmin},{UserRoles.SuperAdmin}")]
[ProducesResponseType(typeof(ResponseResult<List<object>>), StatusCodes.Status200OK)]
public async Task<ActionResult<ResponseResult<List<object>>>> GetUsers()
{
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
if (error is not null) return error;
var (districtId, districtError) = await GetDistrictForCurrentUser(programId);
if (districtError is not null) return districtError;
var users = await _adminRepo.GetUsersByDistrictAsync(districtId);
var result = users.Select(u => new
{
userId = u.IdUser,
email = u.Email,
name = u.Name,
programId = u.IdProgram,
programName = u.ProgramName,
roleId = u.IdRole,
roleName = u.RoleName,
createdAt = u.CreatedAt
}).ToList();
return Ok(new ResponseResult<List<object>>
{
Success = true,
Message = "Users retrieved.",
Data = result.Cast<object>().ToList()
});
}
// *****************************************************************
// Creates a new user and assigns them to a program with a role.
// District admins cannot assign the super_admin role.
// *****************************************************************
[HttpPost("users")]
[Authorize(Roles = $"{UserRoles.DistrictAdmin},{UserRoles.SuperAdmin}")]
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status400BadRequest)]
public async Task<ActionResult<ResponseResult<object>>> CreateUser([FromBody] AdminCreateUserDto dto)
{
if (string.IsNullOrWhiteSpace(dto.Email) ||
string.IsNullOrWhiteSpace(dto.Name) ||
string.IsNullOrWhiteSpace(dto.Password) ||
string.IsNullOrWhiteSpace(dto.ProgramId) ||
string.IsNullOrWhiteSpace(dto.RoleId))
{
return BadRequest(new ResponseResult<object>
{
Success = false,
Message = "Email, name, password, program, and role are required."
});
}
if (!Guid.TryParse(dto.ProgramId, out var targetProgramId) ||
!Guid.TryParse(dto.RoleId, out var roleId))
{
return BadRequest(new ResponseResult<object>
{
Success = false,
Message = "Invalid program or role ID."
});
}
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
if (error is not null) return error;
// Verify the target program belongs to the user's district
var (districtId, districtError) = await GetDistrictForCurrentUser(programId);
if (districtError is not null) return districtError;
var targetDistrictId = await _adminRepo.GetDistrictIdForProgramAsync(targetProgramId);
if (!targetDistrictId.HasValue || targetDistrictId.Value != districtId)
{
return NotFound(new ResponseResult<object>
{
Success = false,
Message = "Program not found in your district."
});
}
// Prevent district_admin from assigning super_admin role
var allRoles = await _adminRepo.GetAllRolesAsync();
var selectedRole = allRoles.FirstOrDefault(r => r.IdRole == roleId);
if (selectedRole == null)
{
return BadRequest(new ResponseResult<object>
{
Success = false,
Message = "Invalid role."
});
}
if (selectedRole.InternalName == UserRoles.SuperAdmin && role != UserRoles.SuperAdmin)
{
return BadRequest(new ResponseResult<object>
{
Success = false,
Message = "Only super admins can assign the super admin role."
});
}
// Check for duplicate email
if (await _adminRepo.EmailExistsAsync(dto.Email))
{
return Ok(new ResponseResult<object>
{
Success = false,
Message = "An account with this email already exists."
});
}
// Create user
var newUserId = Guid.NewGuid();
var (hash, salt) = PasswordHasher.HashPassword(dto.Password);
await _adminRepo.CreateUserAsync(newUserId, dto.Email, dto.Name, hash, salt);
// Assign to program with role
await _adminRepo.AssignUserToProgramAsync(newUserId, targetProgramId, roleId);
return Ok(new ResponseResult<object>
{
Success = true,
Message = "User created and assigned to program.",
Data = new { userId = newUserId, email = dto.Email, name = dto.Name }
});
}
// ************************ Roles *************************
// *****************************************************************
// Returns all available roles. Excludes super_admin for
// non-super-admin users.
// *****************************************************************
[HttpGet("roles")]
[Authorize(Roles = $"{UserRoles.DistrictAdmin},{UserRoles.SuperAdmin}")]
[ProducesResponseType(typeof(ResponseResult<List<object>>), StatusCodes.Status200OK)]
public async Task<ActionResult<ResponseResult<List<object>>>> GetRoles()
{
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
if (error is not null) return error;
var roles = await _adminRepo.GetAllRolesAsync();
// District admins cannot see/assign the super_admin role
if (role != UserRoles.SuperAdmin)
{
roles = roles.Where(r => r.InternalName != UserRoles.SuperAdmin);
}
var result = roles.Select(r => new
{
roleId = r.IdRole,
name = r.Name,
internalName = r.InternalName,
description = r.Description
}).ToList();
return Ok(new ResponseResult<List<object>>
{
Success = true,
Message = "Roles retrieved.",
Data = result.Cast<object>().ToList()
});
}
// ************************ Backup *************************
// *****************************************************************
// Exports the entire MySQL database as a .sql dump file using
// MySqlBackup.NET. Runs in-process with no shell-out; uses a
// single-transaction snapshot for InnoDB consistency (no locking).
// Restricted to district/super admins.
// *****************************************************************
[HttpGet("backup")]
[Authorize(Roles = $"{UserRoles.DistrictAdmin},{UserRoles.SuperAdmin}")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status500InternalServerError)]
public IActionResult BackupDatabase()
{
try
{
using var conn = new MySql.Data.MySqlClient.MySqlConnection(DatabaseManager.ConnectionString);
conn.Open();
using var cmd = new MySql.Data.MySqlClient.MySqlCommand { Connection = conn };
var backup = new MySql.Data.MySqlClient.MySqlBackup(cmd);
using var memoryStream = new MemoryStream();
backup.ExportToMemoryStream(memoryStream);
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
var fileName = $"winstudentgoaltracker_backup_{timestamp}.sql";
return File(memoryStream.ToArray(), "application/sql", fileName);
}
catch (Exception ex)
{
return StatusCode(500, new ResponseResult<object>
{
Success = false,
Message = $"Backup failed: {ex.Message}"
});
}
}
}
+72
View File
@@ -452,4 +452,76 @@ public class AuthController : BaseController
Message = "Password set successfully."
});
}
// *****************************************************************
// Self-service registration: creates a school district, the user's
// first program, and the user account. The user is assigned as
// district_admin on the new program, giving them a program_id for
// their JWT and access to create more programs from the admin panel.
// *****************************************************************
[HttpPost("Register")]
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status400BadRequest)]
public async Task<ActionResult<ResponseResult<object>>> Register([FromBody] RegisterDto dto)
{
if (string.IsNullOrWhiteSpace(dto.Email) ||
string.IsNullOrWhiteSpace(dto.Password) ||
string.IsNullOrWhiteSpace(dto.Name) ||
string.IsNullOrWhiteSpace(dto.DistrictName) ||
string.IsNullOrWhiteSpace(dto.ProgramName))
{
return BadRequest(new ResponseResult<object>
{
Success = false,
Message = "Email, password, name, district name, and program name are required."
});
}
var adminRepo = new AdminRepository();
// Check for duplicate email
if (await adminRepo.EmailExistsAsync(dto.Email))
{
return Ok(new ResponseResult<object>
{
Success = false,
Message = "An account with this email already exists."
});
}
// Look up the district_admin role
var districtAdminRole = await adminRepo.GetRoleByInternalNameAsync(UserRoles.DistrictAdmin);
if (districtAdminRole == null)
{
return Ok(new ResponseResult<object>
{
Success = false,
Message = "System configuration error: district_admin role not found."
});
}
// Create the school district
var districtId = Guid.NewGuid();
await adminRepo.CreateDistrictAsync(districtId, dto.DistrictName, dto.DistrictContactEmail);
// Create the user with hashed password
var userId = Guid.NewGuid();
var (hash, salt) = PasswordHasher.HashPassword(dto.Password);
await adminRepo.CreateUserAsync(userId, dto.Email, dto.Name, hash, salt);
// Create the user's first program under the new district.
// This gives the district_admin a program_id for their JWT,
// enabling them to log in and manage the district from the admin panel.
var programId = Guid.NewGuid();
await adminRepo.CreateProgramAsync(programId, districtId, dto.ProgramName, dto.ProgramDescription);
// Assign the user as district_admin on the new program
await adminRepo.AssignUserToProgramAsync(userId, programId, districtAdminRole.IdRole, isPrimary: true);
return Ok(new ResponseResult<object>
{
Success = true,
Message = "Registration successful. You can now log in."
});
}
}
@@ -0,0 +1,7 @@
namespace WinStudentGoalTracker.DataAccess;
public class AdminCreateProgramDto
{
public string? Name { get; set; }
public string? Description { get; set; }
}
@@ -0,0 +1,10 @@
namespace WinStudentGoalTracker.DataAccess;
public class AdminCreateUserDto
{
public string? Email { get; set; }
public string? Name { get; set; }
public string? Password { get; set; }
public string? ProgramId { get; set; }
public string? RoleId { get; set; }
}
@@ -0,0 +1,12 @@
namespace WinStudentGoalTracker.DataAccess;
public class RegisterDto
{
public string? Email { get; set; }
public string? Password { get; set; }
public string? Name { get; set; }
public string? DistrictName { get; set; }
public string? DistrictContactEmail { get; set; }
public string? ProgramName { get; set; }
public string? ProgramDescription { get; set; }
}
@@ -0,0 +1,14 @@
namespace WinStudentGoalTracker.DataAccess;
public class dbDistrictUserRow
{
public Guid IdUser { get; set; }
public string? Email { get; set; }
public string? Name { get; set; }
public DateTime CreatedAt { get; set; }
public Guid IdProgram { get; set; }
public string? ProgramName { get; set; }
public Guid IdRole { get; set; }
public string? RoleName { get; set; }
public string? RoleInternalName { get; set; }
}
@@ -0,0 +1,10 @@
namespace WinStudentGoalTracker.DataAccess;
public class dbProgramRow
{
public Guid IdProgram { get; set; }
public Guid IdSchoolDistrict { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public DateTime CreatedAt { get; set; }
}
@@ -0,0 +1,9 @@
namespace WinStudentGoalTracker.DataAccess;
public class dbRoleRow
{
public Guid IdRole { get; set; }
public string? Name { get; set; }
public string? InternalName { get; set; }
public string? Description { get; set; }
}
@@ -0,0 +1,177 @@
using System.Data;
using Dapper;
using MySql.Data.MySqlClient;
namespace WinStudentGoalTracker.DataAccess;
public class AdminRepository
{
private IDbConnection Connection => new MySqlConnection(DatabaseManager.ConnectionString);
// *****************************************************************
// Returns the district ID for a given program ID. Used to resolve
// the user's district from their JWT program_id claim, since the
// JWT does not carry district_id directly. The program→district
// FK relationship is the source of truth.
// *****************************************************************
public async Task<Guid?> GetDistrictIdForProgramAsync(Guid programId)
{
using var db = Connection;
var row = await db.QuerySingleOrDefaultAsync<dbProgramRow>(
"sp_Program_GetById",
new { p_id_program = programId.ToString() },
commandType: CommandType.StoredProcedure);
return row?.IdSchoolDistrict;
}
// *****************************************************************
// Creates a new school district and returns the created row.
// *****************************************************************
public async Task<dynamic?> CreateDistrictAsync(Guid districtId, string name, string? contactEmail)
{
using var db = Connection;
return await db.QuerySingleOrDefaultAsync<dynamic>(
"sp_SchoolDistrict_Insert",
new
{
p_id_school_district = districtId.ToString(),
p_name = name,
p_contact_email = contactEmail
},
commandType: CommandType.StoredProcedure);
}
// *****************************************************************
// Creates a new user and returns the created row.
// *****************************************************************
public async Task<dynamic?> CreateUserAsync(Guid userId, string email, string name, string passwordHash, string passwordSalt)
{
using var db = Connection;
return await db.QuerySingleOrDefaultAsync<dynamic>(
"sp_User_Insert",
new
{
p_id_user = userId.ToString(),
p_email = email,
p_name = name,
p_password_hash = passwordHash,
p_password_salt = passwordSalt
},
commandType: CommandType.StoredProcedure);
}
// *****************************************************************
// Assigns a user to a program with a given role.
// *****************************************************************
public async Task<bool> AssignUserToProgramAsync(Guid userId, Guid programId, Guid roleId, bool isPrimary = true)
{
using var db = Connection;
var rowsAffected = await db.ExecuteScalarAsync<int>(
"sp_UserProgram_Insert",
new
{
p_id_user_program = Guid.NewGuid().ToString(),
p_id_user = userId.ToString(),
p_id_program = programId.ToString(),
p_id_role = roleId.ToString(),
p_is_primary = isPrimary ? 1 : 0
},
commandType: CommandType.StoredProcedure);
return rowsAffected > 0;
}
// *****************************************************************
// Returns all programs belonging to a given district.
// *****************************************************************
public async Task<IEnumerable<dbProgramRow>> GetProgramsByDistrictAsync(Guid districtId)
{
using var db = Connection;
return await db.QueryAsync<dbProgramRow>(
"sp_Program_GetByDistrictId",
new { p_id_school_district = districtId.ToString() },
commandType: CommandType.StoredProcedure);
}
// *****************************************************************
// Creates a new program under a given district.
// *****************************************************************
public async Task<dbProgramRow?> CreateProgramAsync(Guid programId, Guid districtId, string name, string? description)
{
using var db = Connection;
return await db.QuerySingleOrDefaultAsync<dbProgramRow>(
"sp_Program_Insert",
new
{
p_id_program = programId.ToString(),
p_id_school_district = districtId.ToString(),
p_name = name,
p_description = description
},
commandType: CommandType.StoredProcedure);
}
// *****************************************************************
// Updates an existing program's name and description.
// *****************************************************************
public async Task<bool> UpdateProgramAsync(Guid programId, string name, string? description)
{
using var db = Connection;
var rowsAffected = await db.ExecuteScalarAsync<int>(
"sp_Program_Update",
new
{
p_id_program = programId.ToString(),
p_name = name,
p_description = description
},
commandType: CommandType.StoredProcedure);
return rowsAffected > 0;
}
// *****************************************************************
// Returns all active users across all programs in a district.
// *****************************************************************
public async Task<IEnumerable<dbDistrictUserRow>> GetUsersByDistrictAsync(Guid districtId)
{
using var db = Connection;
return await db.QueryAsync<dbDistrictUserRow>(
"sp_User_GetByDistrictId",
new { p_id_school_district = districtId.ToString() },
commandType: CommandType.StoredProcedure);
}
// *****************************************************************
// Returns all available roles.
// *****************************************************************
public async Task<IEnumerable<dbRoleRow>> GetAllRolesAsync()
{
using var db = Connection;
return await db.QueryAsync<dbRoleRow>(
"sp_Role_GetAll",
commandType: CommandType.StoredProcedure);
}
// *****************************************************************
// Returns a role by its internal_name (e.g. "district_admin").
// Queries the role table directly since no SP exists for this.
// *****************************************************************
public async Task<dbRoleRow?> GetRoleByInternalNameAsync(string internalName)
{
using var db = Connection;
return await db.QuerySingleOrDefaultAsync<dbRoleRow>(
"SELECT id_role AS IdRole, name AS Name, internal_name AS InternalName, description AS Description FROM role WHERE internal_name = @internalName",
new { internalName });
}
// *****************************************************************
// Checks if a user with the given email already exists.
// *****************************************************************
public async Task<bool> EmailExistsAsync(string email)
{
using var db = Connection;
var count = await db.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM `user` WHERE email = @email",
new { email });
return count > 0;
}
}