diff --git a/api/Program.cs b/api/Program.cs index 9af7b9b..644e8df 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -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"); diff --git a/api/api.csproj b/api/api.csproj index 4c9f483..c95e9b2 100644 --- a/api/api.csproj +++ b/api/api.csproj @@ -10,7 +10,8 @@ - + + diff --git a/api/src/Controllers/AdminController.cs b/api/src/Controllers/AdminController.cs new file mode 100644 index 0000000..5bbb24d --- /dev/null +++ b/api/src/Controllers/AdminController.cs @@ -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 + { + 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>), StatusCodes.Status200OK)] + public async Task>>> 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> + { + Success = true, + Message = "Programs retrieved.", + Data = result.Cast().ToList() + }); + } + + // ***************************************************************** + // Creates a new program under the current user's district. + // ***************************************************************** + [HttpPost("programs")] + [Authorize(Roles = $"{UserRoles.DistrictAdmin},{UserRoles.SuperAdmin}")] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status400BadRequest)] + public async Task>> CreateProgram([FromBody] AdminCreateProgramDto dto) + { + if (string.IsNullOrWhiteSpace(dto.Name)) + { + return BadRequest(new ResponseResult + { + 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 + { + 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), StatusCodes.Status200OK)] + public async Task>> UpdateProgram(Guid idProgram, [FromBody] AdminCreateProgramDto dto) + { + if (string.IsNullOrWhiteSpace(dto.Name)) + { + return BadRequest(new ResponseResult + { + 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 + { + Success = false, + Message = "Program not found in your district." + }); + } + + await _adminRepo.UpdateProgramAsync(idProgram, dto.Name, dto.Description); + + return Ok(new ResponseResult + { + 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>), StatusCodes.Status200OK)] + public async Task>>> 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> + { + Success = true, + Message = "Users retrieved.", + Data = result.Cast().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), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status400BadRequest)] + public async Task>> 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 + { + 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 + { + 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 + { + 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 + { + Success = false, + Message = "Invalid role." + }); + } + if (selectedRole.InternalName == UserRoles.SuperAdmin && role != UserRoles.SuperAdmin) + { + return BadRequest(new ResponseResult + { + 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 + { + 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 + { + 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>), StatusCodes.Status200OK)] + public async Task>>> 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> + { + Success = true, + Message = "Roles retrieved.", + Data = result.Cast().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), 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 + { + Success = false, + Message = $"Backup failed: {ex.Message}" + }); + } + } +} diff --git a/api/src/Controllers/AuthController.cs b/api/src/Controllers/AuthController.cs index de550dc..c7d3551 100644 --- a/api/src/Controllers/AuthController.cs +++ b/api/src/Controllers/AuthController.cs @@ -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), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status400BadRequest)] + public async Task>> 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 + { + 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 + { + 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 + { + 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 + { + Success = true, + Message = "Registration successful. You can now log in." + }); + } } diff --git a/api/src/DataAccess/Models/DataTransferObjects/AdminCreateProgramDto.cs b/api/src/DataAccess/Models/DataTransferObjects/AdminCreateProgramDto.cs new file mode 100644 index 0000000..aed4405 --- /dev/null +++ b/api/src/DataAccess/Models/DataTransferObjects/AdminCreateProgramDto.cs @@ -0,0 +1,7 @@ +namespace WinStudentGoalTracker.DataAccess; + +public class AdminCreateProgramDto +{ + public string? Name { get; set; } + public string? Description { get; set; } +} diff --git a/api/src/DataAccess/Models/DataTransferObjects/AdminCreateUserDto.cs b/api/src/DataAccess/Models/DataTransferObjects/AdminCreateUserDto.cs new file mode 100644 index 0000000..4a2eb68 --- /dev/null +++ b/api/src/DataAccess/Models/DataTransferObjects/AdminCreateUserDto.cs @@ -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; } +} diff --git a/api/src/DataAccess/Models/DataTransferObjects/RegisterDto.cs b/api/src/DataAccess/Models/DataTransferObjects/RegisterDto.cs new file mode 100644 index 0000000..ea4228e --- /dev/null +++ b/api/src/DataAccess/Models/DataTransferObjects/RegisterDto.cs @@ -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; } +} diff --git a/api/src/DataAccess/Models/DatabaseObjects/dbDistrictUserRow.cs b/api/src/DataAccess/Models/DatabaseObjects/dbDistrictUserRow.cs new file mode 100644 index 0000000..e19a70d --- /dev/null +++ b/api/src/DataAccess/Models/DatabaseObjects/dbDistrictUserRow.cs @@ -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; } +} diff --git a/api/src/DataAccess/Models/DatabaseObjects/dbProgramRow.cs b/api/src/DataAccess/Models/DatabaseObjects/dbProgramRow.cs new file mode 100644 index 0000000..0b3437b --- /dev/null +++ b/api/src/DataAccess/Models/DatabaseObjects/dbProgramRow.cs @@ -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; } +} diff --git a/api/src/DataAccess/Models/DatabaseObjects/dbRoleRow.cs b/api/src/DataAccess/Models/DatabaseObjects/dbRoleRow.cs new file mode 100644 index 0000000..be4ac2b --- /dev/null +++ b/api/src/DataAccess/Models/DatabaseObjects/dbRoleRow.cs @@ -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; } +} diff --git a/api/src/DataAccess/Repositories/AdminRepository.cs b/api/src/DataAccess/Repositories/AdminRepository.cs new file mode 100644 index 0000000..76d4a79 --- /dev/null +++ b/api/src/DataAccess/Repositories/AdminRepository.cs @@ -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 GetDistrictIdForProgramAsync(Guid programId) + { + using var db = Connection; + var row = await db.QuerySingleOrDefaultAsync( + "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 CreateDistrictAsync(Guid districtId, string name, string? contactEmail) + { + using var db = Connection; + return await db.QuerySingleOrDefaultAsync( + "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 CreateUserAsync(Guid userId, string email, string name, string passwordHash, string passwordSalt) + { + using var db = Connection; + return await db.QuerySingleOrDefaultAsync( + "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 AssignUserToProgramAsync(Guid userId, Guid programId, Guid roleId, bool isPrimary = true) + { + using var db = Connection; + var rowsAffected = await db.ExecuteScalarAsync( + "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> GetProgramsByDistrictAsync(Guid districtId) + { + using var db = Connection; + return await db.QueryAsync( + "sp_Program_GetByDistrictId", + new { p_id_school_district = districtId.ToString() }, + commandType: CommandType.StoredProcedure); + } + + // ***************************************************************** + // Creates a new program under a given district. + // ***************************************************************** + public async Task CreateProgramAsync(Guid programId, Guid districtId, string name, string? description) + { + using var db = Connection; + return await db.QuerySingleOrDefaultAsync( + "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 UpdateProgramAsync(Guid programId, string name, string? description) + { + using var db = Connection; + var rowsAffected = await db.ExecuteScalarAsync( + "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> GetUsersByDistrictAsync(Guid districtId) + { + using var db = Connection; + return await db.QueryAsync( + "sp_User_GetByDistrictId", + new { p_id_school_district = districtId.ToString() }, + commandType: CommandType.StoredProcedure); + } + + // ***************************************************************** + // Returns all available roles. + // ***************************************************************** + public async Task> GetAllRolesAsync() + { + using var db = Connection; + return await db.QueryAsync( + "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 GetRoleByInternalNameAsync(string internalName) + { + using var db = Connection; + return await db.QuerySingleOrDefaultAsync( + "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 EmailExistsAsync(string email) + { + using var db = Connection; + var count = await db.ExecuteScalarAsync( + "SELECT COUNT(*) FROM `user` WHERE email = @email", + new { email }); + return count > 0; + } +} diff --git a/db/Objects/procedures/sp_Program_GetByDistrictId.sql b/db/Objects/procedures/sp_Program_GetByDistrictId.sql new file mode 100644 index 0000000..b2db501 --- /dev/null +++ b/db/Objects/procedures/sp_Program_GetByDistrictId.sql @@ -0,0 +1,16 @@ +DELIMITER ;; +CREATE DEFINER=`root`@`%` PROCEDURE `sp_Program_GetByDistrictId`( + IN p_id_school_district CHAR(36) +) +BEGIN + SELECT + id_program, + id_school_district, + name, + description, + created_at + FROM program + WHERE id_school_district = p_id_school_district + ORDER BY name; +END;; +DELIMITER ; diff --git a/db/Objects/procedures/sp_Role_GetAll.sql b/db/Objects/procedures/sp_Role_GetAll.sql new file mode 100644 index 0000000..f5b01d1 --- /dev/null +++ b/db/Objects/procedures/sp_Role_GetAll.sql @@ -0,0 +1,12 @@ +DELIMITER ;; +CREATE DEFINER=`root`@`%` PROCEDURE `sp_Role_GetAll`() +BEGIN + SELECT + id_role, + name, + internal_name, + description + FROM role + ORDER BY name; +END;; +DELIMITER ; diff --git a/db/Objects/procedures/sp_UserProgram_Insert.sql b/db/Objects/procedures/sp_UserProgram_Insert.sql new file mode 100644 index 0000000..31b20b7 --- /dev/null +++ b/db/Objects/procedures/sp_UserProgram_Insert.sql @@ -0,0 +1,32 @@ +DELIMITER ;; +CREATE DEFINER=`root`@`%` PROCEDURE `sp_UserProgram_Insert`( + IN p_id_user_program CHAR(36), + IN p_id_user CHAR(36), + IN p_id_program CHAR(36), + IN p_id_role CHAR(36), + IN p_is_primary TINYINT +) +BEGIN + INSERT INTO user_program + ( + id_user_program, + id_user, + id_program, + id_role, + is_primary, + status, + joined_at + ) + VALUES + ( + p_id_user_program, + p_id_user, + p_id_program, + p_id_role, + p_is_primary, + 'active', + UTC_TIMESTAMP() + ); + SELECT ROW_COUNT() AS rows_affected; +END;; +DELIMITER ; diff --git a/db/Objects/procedures/sp_User_GetByDistrictId.sql b/db/Objects/procedures/sp_User_GetByDistrictId.sql new file mode 100644 index 0000000..2013f39 --- /dev/null +++ b/db/Objects/procedures/sp_User_GetByDistrictId.sql @@ -0,0 +1,24 @@ +DELIMITER ;; +CREATE DEFINER=`root`@`%` PROCEDURE `sp_User_GetByDistrictId`( + IN p_id_school_district CHAR(36) +) +BEGIN + SELECT DISTINCT + u.id_user, + u.email, + u.name, + u.created_at, + up.id_program, + p.name AS program_name, + r.id_role, + r.name AS role_name, + r.internal_name AS role_internal_name + FROM `user` u + INNER JOIN user_program up ON up.id_user = u.id_user + INNER JOIN program p ON p.id_program = up.id_program + INNER JOIN role r ON r.id_role = up.id_role + WHERE p.id_school_district = p_id_school_district + AND up.status = 'active' + ORDER BY u.name, p.name; +END;; +DELIMITER ; diff --git a/db/Objects/procedures/sp_User_Insert.sql b/db/Objects/procedures/sp_User_Insert.sql new file mode 100644 index 0000000..dbbe73b --- /dev/null +++ b/db/Objects/procedures/sp_User_Insert.sql @@ -0,0 +1,41 @@ +DELIMITER ;; +CREATE DEFINER=`root`@`%` PROCEDURE `sp_User_Insert`( + IN p_id_user CHAR(36), + IN p_email VARCHAR(255), + IN p_name VARCHAR(255), + IN p_password_hash VARCHAR(255), + IN p_password_salt VARCHAR(255) +) +BEGIN + INSERT INTO `user` + ( + id_user, + email, + name, + password_hash, + password_salt, + password_updated_at, + failed_login_attempts, + created_at + ) + VALUES + ( + p_id_user, + p_email, + p_name, + p_password_hash, + p_password_salt, + UTC_TIMESTAMP(), + 0, + UTC_TIMESTAMP() + ); + SELECT + id_user, + email, + name, + created_at + FROM `user` + WHERE id_user = p_id_user + LIMIT 1; +END;; +DELIMITER ; diff --git a/docs/technical.html b/docs/technical.html index 41deabe..5462dee 100644 --- a/docs/technical.html +++ b/docs/technical.html @@ -1151,7 +1151,7 @@
Frontend - Angular 20 + Angular 20.1.5
Backend @@ -1218,7 +1218,7 @@

4. Recommended Hosting

    -
  • The entire application is currently hosted online with Hetzner (www.hetzner.com), on a plan donated by team members. The team has committed to hosting for at least the next two years, and if/when that changes, will help the partner transition to the hosting platform of their choice.
  • +
  • The entire application is currently hosted online with Hetzner (www.hetzner.com), on a plan donated by team members. The team has committed to hosting for at least the next two years, and if/when that changes, will help the partner transition to the hosting platform of their choice. The team recommends staying with Hetzner based on their reliability and low cost.
@@ -1307,9 +1307,9 @@ JWT_EXPIRATION=3600

Form Factor Analysis

    -
  • Mobile Portrait: Fully responsive and functional
  • -
  • Mobile Landscape: Improved readability and layout
  • -
  • Desktop: Optimal user experience
  • +
  • Mobile Portrait: Fully responsive and functional - minimal UI and streamlined functionality for robust field use.
  • +
  • Mobile Landscape: Improved readability and layout - same features and user story as mobile portrait
  • +
  • Desktop: Full-featured user experience, with application configuration, administrator and edit/delete functionality

UX Observations

@@ -1329,7 +1329,7 @@ JWT_EXPIRATION=3600

13. Sustainability Considerations

-

Docker deployment, free-tier hosting, and modular design support long-term maintainability.

+

Docker deployment, very inexpensive hosting, and modular design support long-term maintainability. Addcitionally, the use of free, off-the-shelf technology choices (Angular, C#, MySQL) contribute a sustainable project tech stack.

diff --git a/ui/winstudentgoaltracker/src/app/app.routes.ts b/ui/winstudentgoaltracker/src/app/app.routes.ts index 06c3ea9..29fe943 100644 --- a/ui/winstudentgoaltracker/src/app/app.routes.ts +++ b/ui/winstudentgoaltracker/src/app/app.routes.ts @@ -1,11 +1,13 @@ import { inject } from '@angular/core'; import { Routes } from '@angular/router'; import { Login } from './shared/pages/login/login'; +import { Register } from './shared/pages/register/register'; import { PlatformService } from './shared/services/platform.service'; import { authGuard } from './shared/guards/auth.guard'; export const routes: Routes = [ { path: 'login', component: Login }, + { path: 'register', component: Register }, { path: '', canMatch: [() => inject(PlatformService).formFactor() === 'mobile'], diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/goal-modal/goal-modal.html b/ui/winstudentgoaltracker/src/app/desktop/components/goal-modal/goal-modal.html index 48dee84..63444dd 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/components/goal-modal/goal-modal.html +++ b/ui/winstudentgoaltracker/src/app/desktop/components/goal-modal/goal-modal.html @@ -19,6 +19,29 @@
+ @if (isEditMode) { +
+
+ + +
+ @if (closeDate) { +
+ +
+ @if (!achieved) { +
+ + +
+ } + } + } + @if (errorMessage()) {

{{ errorMessage() }}

} diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/goal-modal/goal-modal.scss b/ui/winstudentgoaltracker/src/app/desktop/components/goal-modal/goal-modal.scss index 2918c96..173467a 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/components/goal-modal/goal-modal.scss +++ b/ui/winstudentgoaltracker/src/app/desktop/components/goal-modal/goal-modal.scss @@ -1 +1,24 @@ /* Inherits all styles from modal-shell via ::ng-deep */ + +:host ::ng-deep { + .close-divider { + border: none; + border-top: 1px solid var(--border-color); + margin: 6px 0 14px; + } + + .field-check { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: #333; + cursor: pointer; + + input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; + } + } +} diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/goal-modal/goal-modal.ts b/ui/winstudentgoaltracker/src/app/desktop/components/goal-modal/goal-modal.ts index 37cd2be..f4c79a3 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/components/goal-modal/goal-modal.ts +++ b/ui/winstudentgoaltracker/src/app/desktop/components/goal-modal/goal-modal.ts @@ -43,6 +43,11 @@ export class GoalModal { targetCompletionDate: null, }; + // Close-goal fields — only used in edit mode. + protected closeDate: string | null = null; + protected achieved = false; + protected closeNotes = ''; + protected get isEditMode(): boolean { return !!this.goal(); } @@ -66,6 +71,11 @@ export class GoalModal { this.form.targetCompletionDate = existing.targetCompletionDate ? existing.targetCompletionDate.substring(0, 10) : null; + this.closeDate = existing.closeDate + ? existing.closeDate.substring(0, 10) + : null; + this.achieved = existing.achieved ?? false; + this.closeNotes = existing.closeNotes ?? ''; } else { // Add mode — pre-fill target date from IEP if available const iepDate = this.nextIepDate?.(); @@ -89,6 +99,9 @@ export class GoalModal { description: this.form.description, baseline: this.form.baseline, targetCompletionDate: this.form.targetCompletionDate, + closeDate: this.closeDate || null, + achieved: this.closeDate ? this.achieved : null, + closeNotes: this.closeDate && !this.achieved ? this.closeNotes || null : null, }, ); this.isSubmitting.set(false); diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/workspace/workspace.html b/ui/winstudentgoaltracker/src/app/desktop/components/workspace/workspace.html index 4e5f6b0..3f510ac 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/components/workspace/workspace.html +++ b/ui/winstudentgoaltracker/src/app/desktop/components/workspace/workspace.html @@ -39,6 +39,7 @@ [message]="'Delete \u0022' + (deletingBenchmark()!.shortName || deletingBenchmark()!.benchmark) + '\u0022? This cannot be undone.'" confirmLabel="Delete" [destructive]="true" + [doubleConfirm]="true" (confirmed)="onDeleteBenchmarkConfirmed()" (closed)="showDeleteBenchmarkConfirm.set(false)" /> } @@ -80,6 +81,7 @@ @for (g of goals(); track g.goalId) { @@ -98,6 +100,11 @@ @if (selectedGoal()!.targetCompletionDate) { Due {{ formatDate(selectedGoal()!.targetCompletionDate) }} } + @if (selectedGoal()!.closeDate) { + + {{ selectedGoal()!.achieved ? '✓ Achieved' : 'Closed' }} + + } + +
+ + + @if (activeTab() === 'programs') { +
+
+ +
+ + @if (programs().length === 0) { +

No programs yet. Create your first program to get started.

+ } @else { +
+ @for (program of programs(); track program.programId) { +
+
+
{{ program.name }}
+ @if (program.description) { +
{{ program.description }}
+ } +
+ +
+ } +
+ } +
+ } + + + @if (activeTab() === 'users') { +
+
+ +
+ + @if (users().length === 0) { +

No users yet.

+ } @else { +
+ @for (user of users(); track user.userId + user.programId) { +
+
+
{{ user.name }}
+
{{ user.email }} · {{ user.roleName }} · {{ user.programName }}
+
+
+ } +
+ } +
+ } + + +
+

Database

+
+ + @if (backupSuccess()) { + {{ backupSuccess() }} + } +
+ + + + +@if (showProgramModal()) { + + + +} + + +@if (showUserModal()) { + + + +} diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/admin/admin.scss b/ui/winstudentgoaltracker/src/app/desktop/pages/admin/admin.scss new file mode 100644 index 0000000..0f66847 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/pages/admin/admin.scss @@ -0,0 +1,193 @@ +.admin-page { + padding: 24px 32px; + max-width: 800px; +} + +.admin-header h1 { + font-size: 22px; + font-weight: 600; + margin: 0 0 16px; +} + +.error { + background: #fef2f2; + color: #dc2626; + padding: 10px 12px; + border-radius: 6px; + font-size: 14px; + margin-bottom: 12px; +} + +.tabs { + display: flex; + gap: 4px; + margin-bottom: 20px; +} + +.tab { + padding: 8px 18px; + font-size: 14px; + font-weight: 500; + border: none; + background: none; + color: var(--text-faint); + cursor: pointer; + border-radius: 8px; + transition: background 0.15s, color 0.15s; + + &.active { + background: #EEF2FF; + color: #4338CA; + } + + &:hover:not(.active) { + background: #f5f5f5; + } +} + +.toolbar { + margin-bottom: 16px; +} + +.action-btn { + padding: 8px 16px; + font-size: 14px; + font-weight: 500; + background: #4f46e5; + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; + transition: background 0.15s; + + &:hover { + background: #4338ca; + } +} + +.empty { + color: var(--text-faint); + font-size: 14px; + padding: 20px 0; +} + +.list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.list-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: #fff; + border: 1px solid #e5e5e0; + border-radius: 10px; +} + +.list-item-info { + flex: 1; + min-width: 0; +} + +.list-item-name { + font-size: 14px; + font-weight: 600; +} + +.list-item-meta { + font-size: 12px; + color: var(--text-faint); + margin-top: 2px; +} + +.edit-btn { + padding: 4px 12px; + font-size: 13px; + background: none; + border: 1px solid #ddd; + border-radius: 6px; + color: #555; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + + &:hover { + background: #f5f5f5; + border-color: #bbb; + } +} + +.modal-form { + display: flex; + flex-direction: column; + gap: 12px; + + label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 14px; + font-weight: 500; + color: #333; + } + + input, textarea, select { + padding: 8px 10px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 14px; + font-family: inherit; + outline: none; + transition: border-color 0.15s; + + &:focus { + border-color: #4f46e5; + } + } + + button[type='submit'] { + padding: 8px 16px; + font-size: 14px; + font-weight: 500; + background: #4f46e5; + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; + margin-top: 4px; + transition: background 0.15s; + + &:hover { + background: #4338ca; + } + } +} + +.section-divider { + border-top: 1px solid #e5e5e0; + margin: 28px 0 20px; +} + +.section-heading { + font-size: 16px; + font-weight: 600; + margin: 0 0 12px; +} + +.backup-row { + display: flex; + align-items: center; + gap: 12px; +} + +.backup-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.backup-success { + font-size: 13px; + color: #16a34a; +} diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/admin/admin.ts b/ui/winstudentgoaltracker/src/app/desktop/pages/admin/admin.ts new file mode 100644 index 0000000..03546ed --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/pages/admin/admin.ts @@ -0,0 +1,141 @@ +import { Component, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { AdminService, ProgramDto, DistrictUserDto, RoleDto } from '../../../shared/services/admin.service'; +import { ModalShell } from '../../components/modal-shell/modal-shell'; + +@Component({ + selector: 'app-admin', + imports: [FormsModule, ModalShell], + templateUrl: './admin.html', + styleUrl: './admin.scss', +}) +export class Admin { + + // ************************** Constructor ************************** + + constructor() { + this.loadAll(); + } + + // ************************** Declarations ************************* + + private readonly adminService = inject(AdminService); + + protected readonly activeTab = signal<'programs' | 'users'>('programs'); + protected readonly programs = signal([]); + protected readonly users = signal([]); + protected readonly roles = signal([]); + protected readonly error = signal(null); + + // Program modal + protected readonly showProgramModal = signal(false); + protected readonly editingProgram = signal(null); + protected programName = ''; + protected programDescription = ''; + + // User modal + protected readonly showUserModal = signal(false); + protected userName = ''; + protected userEmail = ''; + protected userPassword = ''; + protected userProgramId = ''; + protected userRoleId = ''; + + // Backup + protected readonly backingUp = signal(false); + protected readonly backupSuccess = signal(null); + + // ************************ Event Handlers ************************* + + onSwitchTab(tab: 'programs' | 'users') { + this.activeTab.set(tab); + } + + // --- Programs --- + + onAddProgram() { + this.editingProgram.set(null); + this.programName = ''; + this.programDescription = ''; + this.showProgramModal.set(true); + } + + onEditProgram(program: ProgramDto) { + this.editingProgram.set(program); + this.programName = program.name; + this.programDescription = program.description || ''; + this.showProgramModal.set(true); + } + + async onSaveProgram() { + this.error.set(null); + const editing = this.editingProgram(); + + if (editing) { + const result = await this.adminService.updateProgram(editing.programId, this.programName, this.programDescription); + if (!result.success) { + this.error.set(result.message); + return; + } + } else { + const result = await this.adminService.createProgram(this.programName, this.programDescription); + if (!result.success) { + this.error.set(result.message); + return; + } + } + + this.showProgramModal.set(false); + this.programs.set(await this.adminService.getPrograms()); + } + + // --- Users --- + + onAddUser() { + this.userName = ''; + this.userEmail = ''; + this.userPassword = ''; + this.userProgramId = ''; + this.userRoleId = ''; + this.showUserModal.set(true); + } + + async onSaveUser() { + this.error.set(null); + const result = await this.adminService.createUser( + this.userEmail, this.userName, this.userPassword, + this.userProgramId, this.userRoleId + ); + if (!result.success) { + this.error.set(result.message); + return; + } + this.showUserModal.set(false); + this.users.set(await this.adminService.getUsers()); + } + + // --- Backup --- + + async onBackupDatabase() { + this.backingUp.set(true); + this.backupSuccess.set(null); + this.error.set(null); + try { + await this.adminService.backupDatabase(); + this.backupSuccess.set('Backup downloaded successfully.'); + } catch { + this.error.set('Database backup failed. Please try again.'); + } finally { + this.backingUp.set(false); + } + } + + // ********************** Support Procedures *********************** + + private async loadAll() { + this.programs.set(await this.adminService.getPrograms()); + this.users.set(await this.adminService.getUsers()); + this.roles.set(await this.adminService.getRoles()); + } +} + diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.html b/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.html index 3264b52..1ef22e2 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.html +++ b/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.html @@ -54,6 +54,9 @@ } diff --git a/ui/winstudentgoaltracker/src/app/shared/pages/login/login.ts b/ui/winstudentgoaltracker/src/app/shared/pages/login/login.ts index 99a3227..da574fc 100644 --- a/ui/winstudentgoaltracker/src/app/shared/pages/login/login.ts +++ b/ui/winstudentgoaltracker/src/app/shared/pages/login/login.ts @@ -1,13 +1,13 @@ import { Component, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { HttpErrorResponse } from '@angular/common/http'; -import { Router } from '@angular/router'; +import { Router, RouterLink } from '@angular/router'; import { Auth } from '../../services/auth'; import { describeHttpError } from '../../classes/http-errors'; @Component({ selector: 'app-login', - imports: [FormsModule], + imports: [FormsModule, RouterLink], templateUrl: './login.html', styleUrl: './login.css', }) diff --git a/ui/winstudentgoaltracker/src/app/shared/pages/register/register.html b/ui/winstudentgoaltracker/src/app/shared/pages/register/register.html new file mode 100644 index 0000000..e4a0e41 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/shared/pages/register/register.html @@ -0,0 +1,71 @@ +@if (success()) { +
+

Registration Complete

+

Your district has been created. You can now log in.

+ Go to Login +
+} @else { +
+

Register Your School District

+

Create your account and set up your school district.

+ + @if (error()) { +

{{ error() }}

+ } + +
+
+ Your Details + + + + + + +
+ +
+ District Details + + + + +
+ +
+ Your First Program + + + + +
+ + +
+ + Already have an account? Sign in +
+} diff --git a/ui/winstudentgoaltracker/src/app/shared/pages/register/register.ts b/ui/winstudentgoaltracker/src/app/shared/pages/register/register.ts new file mode 100644 index 0000000..fba5658 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/shared/pages/register/register.ts @@ -0,0 +1,64 @@ +import { Component, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import { Api } from '../../services/api'; + +@Component({ + selector: 'app-register', + imports: [FormsModule, RouterLink], + templateUrl: './register.html', + styleUrl: './register.css', +}) +export class Register { + + // ************************** Constructor ************************** + + private readonly api = inject(Api); + private readonly router = inject(Router); + + // ************************** Declarations ************************* + + name = ''; + email = ''; + password = ''; + districtName = ''; + districtContactEmail = ''; + programName = ''; + programDescription = ''; + + // ************************** Properties *************************** + + protected readonly loading = signal(false); + protected readonly error = signal(null); + protected readonly success = signal(false); + + // ************************ Event Handlers ************************* + + onRegister() { + this.error.set(null); + this.loading.set(true); + + this.api.register({ + email: this.email, + password: this.password, + name: this.name, + districtName: this.districtName, + districtContactEmail: this.districtContactEmail || undefined, + programName: this.programName, + programDescription: this.programDescription || undefined, + }).subscribe({ + next: (res) => { + this.loading.set(false); + if (res.success) { + this.success.set(true); + } else { + this.error.set(res.message); + } + }, + error: () => { + this.loading.set(false); + this.error.set('An unexpected error occurred. Please try again.'); + }, + }); + } +} diff --git a/ui/winstudentgoaltracker/src/app/shared/services/api.ts b/ui/winstudentgoaltracker/src/app/shared/services/api.ts index 9f43e38..17353f9 100644 --- a/ui/winstudentgoaltracker/src/app/shared/services/api.ts +++ b/ui/winstudentgoaltracker/src/app/shared/services/api.ts @@ -53,6 +53,12 @@ export class Api { ); } - + // Self-service registration — creates a new district + program + user + register(request: { email: string; password: string; name: string; districtName: string; districtContactEmail?: string; programName: string; programDescription?: string }): Observable> { + return this.http.post>( + `${this.base}/api/Auth/Register`, + request, + ); + } }