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;
}
}
@@ -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 ;
+12
View File
@@ -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 ;
@@ -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 ;
@@ -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 ;
+41
View File
@@ -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 ;
+6 -6
View File
@@ -1151,7 +1151,7 @@
<div class="stack">
<div class="card">
<strong>Frontend</strong>
Angular 20
Angular 20.1.5
</div>
<div class="card">
<strong>Backend</strong>
@@ -1218,7 +1218,7 @@
<section id="hosting">
<h2>4. Recommended Hosting</h2>
<ul>
<li>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. </li>
<li>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.</li>
</ul>
</section>
@@ -1307,9 +1307,9 @@ JWT_EXPIRATION=3600</code></pre>
<h3>Form Factor Analysis</h3>
<ul>
<li>Mobile Portrait: Fully responsive and functional</li>
<li>Mobile Landscape: Improved readability and layout</li>
<li>Desktop: Optimal user experience</li>
<li>Mobile Portrait: Fully responsive and functional - minimal UI and streamlined functionality for robust field use.</li>
<li>Mobile Landscape: Improved readability and layout - same features and user story as mobile portrait</li>
<li>Desktop: Full-featured user experience, with application configuration, administrator and edit/delete functionality</li>
</ul>
<h3>UX Observations</h3>
@@ -1329,7 +1329,7 @@ JWT_EXPIRATION=3600</code></pre>
<section id="sustain">
<h2>13. Sustainability Considerations</h2>
<p>Docker deployment, free-tier hosting, and modular design support long-term maintainability.</p>
<p>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. </p>
</section>
<section id="appendix-a">
@@ -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'],
@@ -19,6 +19,29 @@
<input class="field-input" type="date" [(ngModel)]="form.targetCompletionDate" />
</div>
@if (isEditMode) {
<hr class="close-divider" />
<div class="field">
<label class="field-label">Close Date</label>
<input class="field-input" type="date" [(ngModel)]="closeDate" />
</div>
@if (closeDate) {
<div class="field">
<label class="field-check">
<input type="checkbox" [(ngModel)]="achieved" />
Goal achieved
</label>
</div>
@if (!achieved) {
<div class="field">
<label class="field-label">Close Notes</label>
<textarea class="field-input field-textarea" [(ngModel)]="closeNotes"
placeholder="Reason the goal was closed without being achieved..."></textarea>
</div>
}
}
}
@if (errorMessage()) {
<p class="error">{{ errorMessage() }}</p>
}
@@ -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;
}
}
}
@@ -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);
@@ -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 @@
<button class="goal-tab add-goal" (click)="onAddGoal()">+ Goal</button>
@for (g of goals(); track g.goalId) {
<button class="goal-tab" [class.active]="selectedGoalId() === g.goalId || (selectedGoal()?.goalId === g.goalId)"
[class.closed]="!!g.closeDate"
(click)="onSelectGoal(g.goalId)">
{{ g.category }}
</button>
@@ -98,6 +100,11 @@
@if (selectedGoal()!.targetCompletionDate) {
<span class="goal-due">Due {{ formatDate(selectedGoal()!.targetCompletionDate) }}</span>
}
@if (selectedGoal()!.closeDate) {
<span class="goal-status" [class.achieved]="selectedGoal()!.achieved">
{{ selectedGoal()!.achieved ? '✓ Achieved' : 'Closed' }}
</span>
}
<button class="delete-goal-btn" (click)="onDeleteGoal()" aria-label="Delete goal" title="Delete goal">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
@@ -137,6 +137,28 @@
margin-left: auto;
}
.goal-status {
font-size: 11px;
font-weight: 600;
padding: 3px 8px;
border-radius: var(--radius-sm);
background: #FEF3C7;
color: #92400E;
}
.goal-status.achieved {
background: #D1FAE5;
color: #065F46;
}
.goal-tab.closed {
opacity: 0.55;
}
.goal-tab.closed.active {
opacity: 1;
}
.delete-goal-btn {
display: flex;
align-items: center;
@@ -3,6 +3,7 @@ import { Home } from './pages/home/home';
import { Workspace } from './components/workspace/workspace';
import { Reports } from './components/reports/reports';
import { StudentProgressReport } from './components/student-progress-report/student-progress-report';
import { Admin } from './pages/admin/admin';
export default [
{
path: '',
@@ -14,6 +15,7 @@ export default [
{ path: 'students/:studentId/goals/:goalId', component: Workspace },
{ path: 'reports', component: Reports },
{ path: 'reports/student-progress', component: StudentProgressReport },
{ path: 'admin', component: Admin },
],
},
] satisfies Routes;
@@ -0,0 +1,138 @@
<div class="admin-page">
<div class="admin-header">
<h1>Administration</h1>
</div>
@if (error()) {
<p class="error">{{ error() }}</p>
}
<div class="tabs">
<button class="tab" [class.active]="activeTab() === 'programs'" (click)="onSwitchTab('programs')">Programs</button>
<button class="tab" [class.active]="activeTab() === 'users'" (click)="onSwitchTab('users')">Users</button>
</div>
<!-- Programs Tab -->
@if (activeTab() === 'programs') {
<div class="tab-content">
<div class="toolbar">
<button class="action-btn" (click)="onAddProgram()">+ Add Program</button>
</div>
@if (programs().length === 0) {
<p class="empty">No programs yet. Create your first program to get started.</p>
} @else {
<div class="list">
@for (program of programs(); track program.programId) {
<div class="list-item">
<div class="list-item-info">
<div class="list-item-name">{{ program.name }}</div>
@if (program.description) {
<div class="list-item-meta">{{ program.description }}</div>
}
</div>
<button class="edit-btn" (click)="onEditProgram(program)">Edit</button>
</div>
}
</div>
}
</div>
}
<!-- Users Tab -->
@if (activeTab() === 'users') {
<div class="tab-content">
<div class="toolbar">
<button class="action-btn" (click)="onAddUser()">+ Add User</button>
</div>
@if (users().length === 0) {
<p class="empty">No users yet.</p>
} @else {
<div class="list">
@for (user of users(); track user.userId + user.programId) {
<div class="list-item">
<div class="list-item-info">
<div class="list-item-name">{{ user.name }}</div>
<div class="list-item-meta">{{ user.email }} · {{ user.roleName }} · {{ user.programName }}</div>
</div>
</div>
}
</div>
}
</div>
}
<!-- Database Section -->
<div class="section-divider"></div>
<h2 class="section-heading">Database</h2>
<div class="backup-row">
<button class="action-btn backup-btn" (click)="onBackupDatabase()" [disabled]="backingUp()">
@if (backingUp()) {
Backing up…
} @else {
Back Up Database
}
</button>
@if (backupSuccess()) {
<span class="backup-success">{{ backupSuccess() }}</span>
}
</div>
</div>
<!-- Program Modal -->
@if (showProgramModal()) {
<app-modal-shell [title]="editingProgram() ? 'Edit Program' : 'New Program'" (closed)="showProgramModal.set(false)">
<form (ngSubmit)="onSaveProgram()" class="modal-form">
<label>
Program Name
<input type="text" [(ngModel)]="programName" name="programName" required />
</label>
<label>
Description
<textarea [(ngModel)]="programDescription" name="programDescription" rows="3"></textarea>
</label>
<button type="submit">{{ editingProgram() ? 'Save' : 'Create' }}</button>
</form>
</app-modal-shell>
}
<!-- User Modal -->
@if (showUserModal()) {
<app-modal-shell title="New User" (closed)="showUserModal.set(false)">
<form (ngSubmit)="onSaveUser()" class="modal-form">
<label>
Name
<input type="text" [(ngModel)]="userName" name="userName" required />
</label>
<label>
Email
<input type="email" [(ngModel)]="userEmail" name="userEmail" required />
</label>
<label>
Password
<input type="password" [(ngModel)]="userPassword" name="userPassword" required />
</label>
<label>
Program
<select [(ngModel)]="userProgramId" name="userProgramId" required>
<option value="" disabled>Select a program</option>
@for (p of programs(); track p.programId) {
<option [value]="p.programId">{{ p.name }}</option>
}
</select>
</label>
<label>
Role
<select [(ngModel)]="userRoleId" name="userRoleId" required>
<option value="" disabled>Select a role</option>
@for (r of roles(); track r.roleId) {
<option [value]="r.roleId">{{ r.name }}</option>
}
</select>
</label>
<button type="submit">Create User</button>
</form>
</app-modal-shell>
}
@@ -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;
}
@@ -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<ProgramDto[]>([]);
protected readonly users = signal<DistrictUserDto[]>([]);
protected readonly roles = signal<RoleDto[]>([]);
protected readonly error = signal<string | null>(null);
// Program modal
protected readonly showProgramModal = signal(false);
protected readonly editingProgram = signal<ProgramDto | null>(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<string | null>(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());
}
}
@@ -54,6 +54,9 @@
<div class="sidebar-nav">
<a class="nav-link" routerLink="/reports">Reports</a>
@if (auth.user()?.role === 'district_admin' || auth.user()?.role === 'super_admin') {
<a class="nav-link" routerLink="/admin">Admin</a>
}
</div>
<div class="sidebar-bottom">
@@ -22,6 +22,8 @@
{{ loading() ? 'Signing in...' : 'Sign in' }}
</button>
</form>
<a class="link-button" routerLink="/register">Register your school district</a>
</div>
}
@@ -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',
})
@@ -0,0 +1,71 @@
@if (success()) {
<div class="card">
<h2>Registration Complete</h2>
<p class="subtitle">Your district has been created. You can now log in.</p>
<a class="link-button" routerLink="/login">Go to Login</a>
</div>
} @else {
<div class="card">
<h2>Register Your School District</h2>
<p class="subtitle">Create your account and set up your school district.</p>
@if (error()) {
<p class="error">{{ error() }}</p>
}
<form (ngSubmit)="onRegister()">
<fieldset>
<legend>Your Details</legend>
<label>
Name
<input type="text" [(ngModel)]="name" name="name" required />
</label>
<label>
Email
<input type="email" [(ngModel)]="email" name="email" required />
</label>
<label>
Password
<input type="password" [(ngModel)]="password" name="password" required />
</label>
</fieldset>
<fieldset>
<legend>District Details</legend>
<label>
District Name
<input type="text" [(ngModel)]="districtName" name="districtName" required />
</label>
<label>
Contact Email (optional)
<input type="email" [(ngModel)]="districtContactEmail" name="districtContactEmail" />
</label>
</fieldset>
<fieldset>
<legend>Your First Program</legend>
<label>
Program Name
<input type="text" [(ngModel)]="programName" name="programName" required />
</label>
<label>
Description (optional)
<textarea [(ngModel)]="programDescription" name="programDescription" rows="2"></textarea>
</label>
</fieldset>
<button type="submit" [disabled]="loading()">
{{ loading() ? 'Creating...' : 'Create District & Account' }}
</button>
</form>
<a class="link-button" routerLink="/login">Already have an account? Sign in</a>
</div>
}
@@ -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<string | null>(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.');
},
});
}
}
@@ -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<ResponseResult<object>> {
return this.http.post<ResponseResult<object>>(
`${this.base}/api/Auth/Register`,
request,
);
}
}