diff --git a/api/Configuration/ConfigHelper.cs b/api/Configuration/ConfigHelper.cs new file mode 100644 index 0000000..4c3e7f1 --- /dev/null +++ b/api/Configuration/ConfigHelper.cs @@ -0,0 +1,6 @@ +namespace WinStudentGoalTracker.Api.Configuration; + +public static class ConfigHelper +{ + public static IConfiguration Configuration { get; set; } = null!; +} diff --git a/api/Program.cs b/api/Program.cs index 3917ef1..c4dff81 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -1,41 +1,30 @@ -var builder = WebApplication.CreateBuilder(args); - -// Add services to the container. -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi -builder.Services.AddOpenApi(); - -var app = builder.Build(); - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.MapOpenApi(); -} - -app.UseHttpsRedirection(); - -var summaries = new[] -{ - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" -}; - -app.MapGet("/weatherforecast", () => -{ - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - ( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)] - )) - .ToArray(); - return forecast; -}) -.WithName("GetWeatherForecast"); - -app.Run(); - -record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) -{ - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); -} +using WinStudentGoalTracker.Api.Configuration; + +var builder = WebApplication.CreateBuilder(args); + +ConfigHelper.Configuration = builder.Configuration; + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader(); + }); +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseCors(); +app.UseHttpsRedirection(); +app.MapControllers(); + +app.Run(); diff --git a/api/api.csproj b/api/api.csproj index d278206..4b8535c 100644 --- a/api/api.csproj +++ b/api/api.csproj @@ -1,13 +1,15 @@ - - - - net9.0 - enable - enable - - - - - - - + + + + net9.0 + enable + enable + + + + + + + + + diff --git a/api/api.http b/api/api.http index f23a40a..d040041 100644 --- a/api/api.http +++ b/api/api.http @@ -1,6 +1,40 @@ -@api_HostAddress = http://localhost:5123 - -GET {{api_HostAddress}}/weatherforecast/ -Accept: application/json - -### +@api_HostAddress = http://localhost:5123 + +GET {{api_HostAddress}}/api/student +Accept: application/json + +### + +GET {{api_HostAddress}}/api/student/1001 +Accept: application/json + +### + +POST {{api_HostAddress}}/api/student +Content-Type: application/json + +{ + "idStudent": 1001, + "idProgram": 10, + "identifier": "WIN-1001", + "programYear": 2026, + "enrollmentDate": "2026-01-15", + "expectedGrad": "2028-06-01" +} + +### + +PUT {{api_HostAddress}}/api/student/1001 +Content-Type: application/json + +{ + "identifier": "WIN-1001-A", + "programYear": 2027 +} + +### + +DELETE {{api_HostAddress}}/api/student/1001 +Accept: application/json + +### diff --git a/api/appsettings.Development.json b/api/appsettings.Development.json index ff66ba6..707cbc5 100644 --- a/api/appsettings.Development.json +++ b/api/appsettings.Development.json @@ -1,8 +1,11 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } +{ + "ConnectionStrings": { + "DefaultConnection": "Server=localhost;Port=3306;Database=win_student_goal_tracker;Uid=root;Pwd=change_me;" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } } } diff --git a/api/appsettings.json b/api/appsettings.json index 4d56694..a76fc22 100644 --- a/api/appsettings.json +++ b/api/appsettings.json @@ -1,9 +1,12 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} +{ + "ConnectionStrings": { + "DefaultConnection": "Server=localhost;Port=3306;Database=win_student_goal_tracker;Uid=root;Pwd=change_me;" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/api/dotnet-api-design-document.md b/api/design/dotnet-api-design-document.md similarity index 100% rename from api/dotnet-api-design-document.md rename to api/design/dotnet-api-design-document.md diff --git a/api/design/permission_structure.md b/api/design/permission_structure.md new file mode 100644 index 0000000..e69de29 diff --git a/api/design/role-based-access-control.md b/api/design/role-based-access-control.md new file mode 100644 index 0000000..d79ce71 --- /dev/null +++ b/api/design/role-based-access-control.md @@ -0,0 +1,499 @@ +# Role-Based Access Control Design Document + +## Student Goal & Progress Tracking Application + +--- + +## 1. Overview + +This document defines the authorization model for the Student Goal & Progress Tracking application. The system uses a combination of role-based access control (RBAC) and resource-level assignment enforcement to determine what each user can do and to whom. + +All authorization decisions flow from two sources of truth: + +- **User role** — defines the category of actions a user may perform (e.g., Teacher, Paraeducator, Supervisor). +- **Student assignments** — defines which students a user has access to and whether they hold primary responsibility. + +These two dimensions are evaluated together at every access point. A user's role determines what operations are available to them in general, and their assignment to a specific student determines whether they can perform those operations against that student's records. + +--- + +## 2. Roles + +### 2.1 Teacher + +Teachers are the primary instructional staff responsible for student documentation. + +- May be assigned to multiple students. +- May hold primary assignment for one or more students. +- Primary assignment grants full control over the student's goals and records. +- Non-primary assignment grants read access and the ability to add progress entries. + +### 2.2 Paraeducator + +Paraeducators are support staff who assist students in the field. + +- May be assigned to multiple students. +- May add progress entries and critical notes for assigned students. +- May edit or delete only entries they personally created. +- Cannot create, edit, or archive goals. +- Cannot view sensitive records unless explicitly permitted. + +### 2.3 Supervisor + +Supervisors are oversight users who review documentation for evaluation, audit, or legal purposes. + +- Have read-only access to all student records, entries, and reports. +- Cannot create, modify, or delete any records. +- Access is modeled through student assignments, ensuring uniform query behavior. + +--- + +## 3. Student Assignments + +### 3.1 Design Principle + +The `student_assignments` table is the single source of truth for access control. Every user — regardless of role — must have an active assignment to a student in order to access that student's records. This eliminates role-based branching in queries and ensures that all access is explicit and auditable. + +### 3.2 Assignment Schema + +```sql +CREATE TABLE student_assignments ( + id INT PRIMARY KEY AUTO_INCREMENT, + user_id INT NOT NULL, + student_id INT NOT NULL, + is_primary BOOLEAN NOT NULL DEFAULT FALSE, + start_date DATE NOT NULL, + end_date DATE NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by INT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (student_id) REFERENCES students(id) +); +``` + +### 3.3 Field Definitions + +| Field | Description | +|---|---| +| `user_id` | The user being granted access. | +| `student_id` | The student the user is being assigned to. | +| `is_primary` | Whether this user holds primary responsibility for this student. Primary users may create and manage goals, edit the student profile, and view sensitive records. | +| `start_date` | The date the assignment becomes effective. | +| `end_date` | The date the assignment expires. NULL indicates an open-ended assignment. | +| `is_active` | Whether the assignment is currently active. Supports manual deactivation independent of date range. | +| `created_at` | Timestamp of assignment creation. | +| `created_by` | The user who created the assignment. | + +### 3.4 Primary Assignment Rules + +- Only users with the Teacher role may hold a primary assignment (`is_primary = TRUE`). +- A student should have exactly one primary assignment at any given time. +- Paraeducators and Supervisors always have `is_primary = FALSE`. +- The `is_primary` flag determines access to privileged operations such as goal management and student profile editing. + +### 3.5 Supervisor Assignment Strategy + +When a supervisor is added to the system, they receive an assignment record for each student in the program. When a new student is created, an assignment record is created for each active supervisor. This ensures supervisors are queryable through the same assignment-based access path as all other users, eliminating role-based branching in data access queries. + +--- + +## 4. Permission Matrix + +The following matrix defines which operations are available to each role and assignment level. All operations require an active assignment to the relevant student. + +| Operation | Primary Teacher | Non-Primary Teacher | Paraeducator | Supervisor | +|---|---|---|---|---| +| View student profile | ✅ | ✅ | ✅ | ✅ | +| Edit student profile | ✅ | ❌ | ❌ | ❌ | +| Create goal | ✅ | ❌ | ❌ | ❌ | +| Edit goal | ✅ | ❌ | ❌ | ❌ | +| Archive goal | ✅ | ❌ | ❌ | ❌ | +| Add progress entry | ✅ | ✅ | ✅ | ❌ | +| Edit own progress entry | ✅ | ✅ | ✅ | ❌ | +| Edit others' progress entry | ✅ | ❌ | ❌ | ❌ | +| Delete own progress entry | ✅ | ✅ | ✅ | ❌ | +| Delete others' progress entry | ✅ | ❌ | ❌ | ❌ | +| Add critical note | ✅ | ✅ | ✅ | ❌ | +| View sensitive records | ✅ | ❌ | ❌ | ❌ | +| Generate report | ✅ | ✅ | ❌ | ✅ | + +--- + +## 5. Authorization Architecture + +### 5.1 Approach + +The application uses ASP.NET Core's built-in policy-based and resource-based authorization framework. Authorization logic is centralized in handler classes rather than distributed across controllers or repositories. + +There are two complementary authorization strategies: + +- **Resource-based authorization** — used for single-entity endpoints. The controller loads or identifies the resource, then calls `IAuthorizationService.AuthorizeAsync` with a resource object. Registered handlers evaluate the user's role and assignment. +- **Query-scoped access** — used for list endpoints. The query itself joins through the `student_assignments` table so that unauthorized records never leave the database. + +### 5.2 Operations + +Operations represent the vocabulary of actions the system can authorize. They are defined as static fields on a central `Operations` class. + +```csharp +public static class Operations +{ + public static readonly OperationAuthorizationRequirement ViewStudent = + new() { Name = nameof(ViewStudent) }; + public static readonly OperationAuthorizationRequirement EditStudent = + new() { Name = nameof(EditStudent) }; + public static readonly OperationAuthorizationRequirement CreateGoal = + new() { Name = nameof(CreateGoal) }; + public static readonly OperationAuthorizationRequirement EditGoal = + new() { Name = nameof(EditGoal) }; + public static readonly OperationAuthorizationRequirement ArchiveGoal = + new() { Name = nameof(ArchiveGoal) }; + public static readonly OperationAuthorizationRequirement AddProgressEntry = + new() { Name = nameof(AddProgressEntry) }; + public static readonly OperationAuthorizationRequirement EditProgressEntry = + new() { Name = nameof(EditProgressEntry) }; + public static readonly OperationAuthorizationRequirement DeleteProgressEntry = + new() { Name = nameof(DeleteProgressEntry) }; + public static readonly OperationAuthorizationRequirement AddCriticalNote = + new() { Name = nameof(AddCriticalNote) }; + public static readonly OperationAuthorizationRequirement ViewSensitiveRecords = + new() { Name = nameof(ViewSensitiveRecords) }; + public static readonly OperationAuthorizationRequirement GenerateReport = + new() { Name = nameof(GenerateReport) }; +} +``` + +### 5.3 Resource Objects + +Resource objects are lightweight records that carry the context an authorization handler needs to make a decision. They are passed to `AuthorizeAsync` and routed to the appropriate handler by the framework's type-matching system. + +```csharp +public record StudentResource(int StudentId); + +public record ProgressEntryResource(int StudentId, int EntryId, int CreatedByUserId); + +public record CriticalNoteResource(int StudentId, int NoteId, int CreatedByUserId, string? SensitivityLevel); +``` + +### 5.4 Authorization Handlers + +#### 5.4.1 Student Authorization Handler + +This handler evaluates all student-scoped operations. It loads the user's assignment and checks the `is_primary` flag and user role to determine access. + +```csharp +public class StudentAuthorizationHandler + : AuthorizationHandler +{ + private readonly IAssignmentRepository _assignments; + + public StudentAuthorizationHandler(IAssignmentRepository assignments) + { + _assignments = assignments; + } + + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + OperationAuthorizationRequirement requirement, + StudentResource resource) + { + var userId = context.User.GetUserId(); + var role = context.User.GetRole(); + + var assignment = await _assignments.GetActiveAssignment(userId, resource.StudentId); + if (assignment is null) + return; + + switch (requirement.Name) + { + case nameof(Operations.ViewStudent): + // Any active assignment grants view access + context.Succeed(requirement); + break; + + case nameof(Operations.EditStudent): + case nameof(Operations.CreateGoal): + case nameof(Operations.EditGoal): + case nameof(Operations.ArchiveGoal): + case nameof(Operations.ViewSensitiveRecords): + // Only the primary teacher + if (assignment.IsPrimary && role == Role.Teacher) + context.Succeed(requirement); + break; + + case nameof(Operations.AddProgressEntry): + case nameof(Operations.AddCriticalNote): + // Teachers and paraeducators with any assignment + if (role is Role.Teacher or Role.Paraeducator) + context.Succeed(requirement); + break; + + case nameof(Operations.GenerateReport): + // Teachers and supervisors + if (role is Role.Teacher or Role.Supervisor) + context.Succeed(requirement); + break; + } + } +} +``` + +#### 5.4.2 Progress Entry Authorization Handler + +This handler evaluates entry-level ownership for edit and delete operations. It is called after the student-level check has already passed in the controller. + +```csharp +public class ProgressEntryAuthorizationHandler + : AuthorizationHandler +{ + private readonly IAssignmentRepository _assignments; + + public ProgressEntryAuthorizationHandler(IAssignmentRepository assignments) + { + _assignments = assignments; + } + + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + OperationAuthorizationRequirement requirement, + ProgressEntryResource resource) + { + var userId = context.User.GetUserId(); + var role = context.User.GetRole(); + + switch (requirement.Name) + { + case nameof(Operations.EditProgressEntry): + case nameof(Operations.DeleteProgressEntry): + // Any author can edit or delete their own entry + if (resource.CreatedByUserId == userId) + { + context.Succeed(requirement); + return; + } + + // Primary teachers can edit or delete anyone's entry + // for their assigned students + if (role == Role.Teacher) + { + var assignment = await _assignments + .GetActiveAssignment(userId, resource.StudentId); + if (assignment is not null && assignment.IsPrimary) + context.Succeed(requirement); + } + break; + } + } +} +``` + +### 5.5 Handler Registration + +```csharp +builder.Services.AddAuthorizationCore(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +``` + +--- + +## 6. Data Access Patterns + +### 6.1 Single-Resource Endpoints + +For endpoints that operate on a specific student, goal, or entry, the controller performs authorization before executing the operation. + +``` +Request → Extract resource identifier → AuthorizeAsync → Proceed or return 403 +``` + +Example flow for updating a goal: + +1. Extract `studentId` and `goalId` from the request. +2. Call `AuthorizeAsync(User, new StudentResource(studentId), Operations.EditGoal)`. +3. If authorization fails, return `403 Forbidden`. +4. Load the goal, validate it belongs to the student, and perform the update. + +### 6.2 List Endpoints + +For endpoints that return collections (e.g., "get my students"), the query joins through `student_assignments` to scope results to the current user. This ensures unauthorized records never leave the database. + +```csharp +public async Task> GetAccessibleStudents(int userId) +{ + return await _db.QueryAsync( + @"SELECT s.id, s.identifier, s.program_year, s.age, + sa.is_primary + FROM students s + INNER JOIN student_assignments sa ON sa.student_id = s.id + WHERE sa.user_id = @UserId + AND sa.is_active = TRUE + AND sa.start_date <= CURDATE() + AND (sa.end_date IS NULL OR sa.end_date >= CURDATE()) + AND s.is_deleted = FALSE + ORDER BY s.identifier", + new { UserId = userId }); +} +``` + +This query is role-agnostic. Supervisors, teachers, and paraeducators all use the same query. The assignments table determines what each user sees. + +### 6.3 Assignment Caching + +Because the assignment lookup is called on every single-resource authorization check, the repository is wrapped with a per-request cache to avoid redundant database queries within a single HTTP request. + +```csharp +public class CachedAssignmentRepository : IAssignmentRepository +{ + private readonly IAssignmentRepository _inner; + private readonly Dictionary<(int, int), StudentAssignment?> _cache = new(); + + public CachedAssignmentRepository(IAssignmentRepository inner) + { + _inner = inner; + } + + public async Task GetActiveAssignment(int userId, int studentId) + { + var key = (userId, studentId); + if (_cache.TryGetValue(key, out var cached)) + return cached; + + var result = await _inner.GetActiveAssignment(userId, studentId); + _cache[key] = result; + return result; + } +} +``` + +This is registered as a scoped service so the cache lives for the duration of one HTTP request. + +--- + +## 7. Core Assignment Query + +The core query used by both the authorization handlers and the cached repository: + +```sql +SELECT id, user_id, student_id, is_primary, start_date, end_date, is_active +FROM student_assignments +WHERE user_id = @UserId + AND student_id = @StudentId + AND is_active = TRUE + AND start_date <= CURDATE() + AND (end_date IS NULL OR end_date >= CURDATE()) +LIMIT 1; +``` + +This query enforces both the active flag and the date range, supporting time-bound assignments such as temporary coverage. + +--- + +## 8. Controller Patterns + +### 8.1 Single-Resource Authorization + +```csharp +[HttpPut("{goalId}")] +public async Task UpdateGoal(int studentId, int goalId, [FromBody] UpdateGoalRequest request) +{ + var authResult = await _auth.AuthorizeAsync( + User, new StudentResource(studentId), Operations.EditGoal); + if (!authResult.Succeeded) return Forbid(); + + var goal = await _goals.GetById(goalId); + if (goal is null || goal.StudentId != studentId) return NotFound(); + + await _goals.Update(goalId, request, User.GetUserId()); + return NoContent(); +} +``` + +### 8.2 Two-Layer Authorization (Student + Entry Ownership) + +```csharp +[HttpPut("{entryId}")] +public async Task UpdateEntry(int studentId, int entryId, [FromBody] UpdateEntryRequest request) +{ + // Layer 1: Can you access this student at all? + var studentAuth = await _auth.AuthorizeAsync( + User, new StudentResource(studentId), Operations.ViewStudent); + if (!studentAuth.Succeeded) return Forbid(); + + // Load the entry + var entry = await _entries.GetById(entryId); + if (entry is null || entry.StudentId != studentId) return NotFound(); + + // Layer 2: Can you edit THIS entry specifically? + var entryAuth = await _auth.AuthorizeAsync( + User, + new ProgressEntryResource(entry.StudentId, entry.Id, entry.CreatedByUserId), + Operations.EditProgressEntry); + if (!entryAuth.Succeeded) return Forbid(); + + await _entries.Update(entryId, request, User.GetUserId()); + return NoContent(); +} +``` + +### 8.3 List Endpoint (Query-Scoped) + +```csharp +[HttpGet] +public async Task GetMyStudents() +{ + var userId = User.GetUserId(); + var students = await _students.GetAccessibleStudents(userId); + return Ok(students); +} +``` + +--- + +## 9. Sensitive Record Visibility + +Records flagged as sensitive are only visible to users whose assignment has `is_primary = TRUE` and whose role is `Teacher`. This is enforced in two places: + +- **Single-resource access**: The `ViewSensitiveRecords` operation is checked via `AuthorizeAsync` before returning sensitive content. +- **List queries**: Sensitive records are excluded from query results unless the current user meets the primary teacher criteria: + +```sql +AND (pe.is_sensitive = FALSE OR sa.is_primary = TRUE) +``` + +--- + +## 10. Key Design Decisions + +### 10.1 Assignments as the Single Source of Truth + +All access decisions — whether evaluated in authorization handlers or embedded in SQL queries — derive from the `student_assignments` table. Roles determine the nature of permitted operations. Assignments determine the scope. This separation keeps the system predictable and auditable. + +### 10.2 `is_primary` Over Assignment Type Enum + +Rather than an enum of assignment types, the `is_primary` boolean provides a clear binary distinction: primary users have full control over a student's goals and records; non-primary users have limited, contributory access. The user's role combined with the `is_primary` flag covers all permission combinations described in the permission matrix. + +### 10.3 Supervisors Modeled as Assignments + +Supervisors receive explicit assignment records for each student. This avoids special-casing supervisor access in queries and handlers. Supervisors always have `is_primary = FALSE`, which naturally restricts them to read-only operations. + +### 10.4 Two Authorization Strategies + +Single-resource endpoints use `IAuthorizationService.AuthorizeAsync` with resource objects. List endpoints use query-scoped access via the assignments table. Both strategies use the same underlying assignment data, ensuring consistency. + +--- + +## 11. Testing Strategy + +### 11.1 Unit Tests + +Each authorization handler should be unit tested in isolation by mocking the assignment repository and asserting `Succeed` or implicit denial for every combination of role, assignment status, `is_primary` flag, and operation. + +### 11.2 Integration Tests + +List queries should be integration tested to verify that they return only records the user is assigned to. A useful pattern is to compare the results of a list query against individual `AuthorizeAsync` calls for each returned record, asserting that they agree. + +### 11.3 Consistency Audit + +A periodic or on-demand audit job can iterate over list query results and verify that every returned record passes the corresponding `AuthorizeAsync` check. This catches drift between the two authorization strategies. \ No newline at end of file diff --git a/api/src/BaseClasses/BaseController.cs b/api/src/BaseClasses/BaseController.cs new file mode 100644 index 0000000..dc09d65 --- /dev/null +++ b/api/src/BaseClasses/BaseController.cs @@ -0,0 +1,42 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Mvc; + +namespace WinStudentGoalTracker.BaseClasses; + +public class BaseController : ControllerBase +{ + protected (int userId, ActionResult? error) GetUserIdFromClaims() + { + var userIdClaim = User.FindFirst("user_id")?.Value + ?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + + if (string.IsNullOrWhiteSpace(userIdClaim) || !int.TryParse(userIdClaim, out var userId)) + { + return (0, Unauthorized("Missing or invalid user_id claim.")); + } + + return (userId, null); + } + + protected (string email, List roles, ActionResult? error) GetUserDetailsFromClaims() + { + var email = User.FindFirst(ClaimTypes.Email)?.Value; + if (string.IsNullOrWhiteSpace(email)) + { + return (string.Empty, new List(), Unauthorized("Missing email claim.")); + } + + var roles = User.FindAll(ClaimTypes.Role).Select(claim => claim.Value).ToList(); + return (email, roles, null); + } + + protected bool HasRole(string role) + { + return User.IsInRole(role); + } + + protected bool HasAnyRole(params string[] roles) + { + return roles.Any(User.IsInRole); + } +} diff --git a/api/src/Controllers/StudentController.cs b/api/src/Controllers/StudentController.cs new file mode 100644 index 0000000..57c78f8 --- /dev/null +++ b/api/src/Controllers/StudentController.cs @@ -0,0 +1,142 @@ +using Microsoft.AspNetCore.Mvc; +using WinStudentGoalTracker.Models; +using WinStudentGoalTracker.BaseClasses; +using WinStudentGoalTracker.DataAccess; + +namespace WinStudentGoalTracker.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class StudentController : BaseController +{ + private readonly StudentRepository _studentRepository = new(); + + + // TODO refactor this stored procedure + // to getmystudents + // This required auth system to be set up first + [HttpGet] + [ProducesResponseType(typeof(ResponseResult>), StatusCodes.Status200OK)] + public async Task>>> GetAll() + { + var students = await _studentRepository.GetAllAsync(); + var response = students.Select(StudentResponse.FromDatabaseModel); + + return Ok(new ResponseResult> + { + Success = true, + Data = response + }); + } + + [HttpGet("{idStudent:int}")] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status404NotFound)] + public async Task>> GetById(int idStudent) + { + var student = await _studentRepository.GetByIdAsync(idStudent); + if (student is null) + { + return NotFound(new ResponseResult + { + Success = false, + Message = "Student not found." + }); + } + + return Ok(new ResponseResult + { + Success = true, + Data = StudentResponse.FromDatabaseModel(student) + }); + } + + [HttpPost] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status400BadRequest)] + public async Task>> Create([FromBody] CreateStudentDto request) + { + var existing = await _studentRepository.GetByIdAsync(request.IdStudent); + if (existing is not null) + { + return BadRequest(new ResponseResult + { + Success = false, + Message = $"Student with id {request.IdStudent} already exists." + }); + } + + var created = await _studentRepository.InsertAsync(request); + if (created is null) + { + return BadRequest(new ResponseResult + { + Success = false, + Message = "Unable to create student." + }); + } + + var response = StudentResponse.FromDatabaseModel(created); + return CreatedAtAction(nameof(GetById), new { idStudent = response.IdStudent }, new ResponseResult + { + Success = true, + Data = response + }); + } + + [HttpPut("{idStudent:int}")] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status404NotFound)] + public async Task>> Update(int idStudent, [FromBody] UpdateStudentDto request) + { + var existing = await _studentRepository.GetByIdAsync(idStudent); + if (existing is null) + { + return NotFound(new ResponseResult + { + Success = false, + Message = "Student not found." + }); + } + + var updated = await _studentRepository.UpdateAsync(idStudent, request); + var refreshed = await _studentRepository.GetByIdAsync(idStudent); + if (refreshed is null) + { + return NotFound(new ResponseResult + { + Success = false, + Message = "Student not found after update." + }); + } + + return Ok(new ResponseResult + { + Success = true, + Message = updated ? null : "No changes were applied.", + Data = StudentResponse.FromDatabaseModel(refreshed) + }); + } + + [HttpDelete("{idStudent:int}")] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status404NotFound)] + public async Task>> Delete(int idStudent) + { + var deleted = await _studentRepository.DeleteAsync(idStudent); + if (!deleted) + { + return NotFound(new ResponseResult + { + Success = false, + Message = "Student not found." + }); + } + + return Ok(new ResponseResult + { + Success = true, + Message = "Student deleted." + }); + } +} diff --git a/api/src/DataAccess/DatabaseManager.cs b/api/src/DataAccess/DatabaseManager.cs new file mode 100644 index 0000000..dd5de08 --- /dev/null +++ b/api/src/DataAccess/DatabaseManager.cs @@ -0,0 +1,16 @@ +using Dapper; +using WinStudentGoalTracker.Api.Configuration; + +namespace WinStudentGoalTracker.DataAccess; + +public static class DatabaseManager +{ + static DatabaseManager() + { + DefaultTypeMap.MatchNamesWithUnderscores = true; + } + + public static string ConnectionString => + ConfigHelper.Configuration.GetConnectionString("DefaultConnection") + ?? throw new MissingFieldException("DefaultConnection not configured."); +} diff --git a/api/src/DataAccess/Models/DataTransferObjects/CreateStudentDto.cs b/api/src/DataAccess/Models/DataTransferObjects/CreateStudentDto.cs new file mode 100644 index 0000000..a958b9b --- /dev/null +++ b/api/src/DataAccess/Models/DataTransferObjects/CreateStudentDto.cs @@ -0,0 +1,11 @@ +namespace WinStudentGoalTracker.DataAccess; + +public class CreateStudentDto +{ + public required int IdStudent { get; set; } + public int? IdProgram { get; set; } + public string? Identifier { get; set; } + public int? ProgramYear { get; set; } + public DateTime? EnrollmentDate { get; set; } + public DateTime? ExpectedGrad { get; set; } +} diff --git a/api/src/DataAccess/Models/DataTransferObjects/UpdateStudentDto.cs b/api/src/DataAccess/Models/DataTransferObjects/UpdateStudentDto.cs new file mode 100644 index 0000000..212fde7 --- /dev/null +++ b/api/src/DataAccess/Models/DataTransferObjects/UpdateStudentDto.cs @@ -0,0 +1,10 @@ +namespace WinStudentGoalTracker.DataAccess; + +public class UpdateStudentDto +{ + public int? IdProgram { get; set; } + public string? Identifier { get; set; } + public int? ProgramYear { get; set; } + public DateTime? EnrollmentDate { get; set; } + public DateTime? ExpectedGrad { get; set; } +} diff --git a/api/src/DataAccess/Models/DatabaseObjects/dbStudent.cs b/api/src/DataAccess/Models/DatabaseObjects/dbStudent.cs new file mode 100644 index 0000000..8a5b50e --- /dev/null +++ b/api/src/DataAccess/Models/DatabaseObjects/dbStudent.cs @@ -0,0 +1,12 @@ +namespace WinStudentGoalTracker.DataAccess; + +public class dbStudent +{ + public required int IdStudent { get; set; } + public int? IdProgram { get; set; } + public string? Identifier { get; set; } + public int? ProgramYear { get; set; } + public DateTime? EnrollmentDate { get; set; } + public DateTime? ExpectedGrad { get; set; } + public DateTime? CreatedAt { get; set; } +} diff --git a/api/src/DataAccess/Repositories/StudentRepository.cs b/api/src/DataAccess/Repositories/StudentRepository.cs new file mode 100644 index 0000000..9dbbebb --- /dev/null +++ b/api/src/DataAccess/Repositories/StudentRepository.cs @@ -0,0 +1,72 @@ +using System.Data; +using Dapper; +using MySql.Data.MySqlClient; + +namespace WinStudentGoalTracker.DataAccess; + +public class StudentRepository +{ + private IDbConnection Connection => new MySqlConnection(DatabaseManager.ConnectionString); + + public async Task> GetAllAsync() + { + using var db = Connection; + return await db.QueryAsync( + "sp_Student_GetAll", + commandType: CommandType.StoredProcedure); + } + + public async Task GetByIdAsync(int idStudent) + { + using var db = Connection; + return await db.QuerySingleOrDefaultAsync( + "sp_Student_GetById", + new { p_id_student = idStudent }, + commandType: CommandType.StoredProcedure); + } + + public async Task InsertAsync(CreateStudentDto dto) + { + using var db = Connection; + return await db.QuerySingleOrDefaultAsync( + "sp_Student_Insert", + new + { + p_id_student = dto.IdStudent, + p_id_program = dto.IdProgram, + p_identifier = dto.Identifier, + p_program_year = dto.ProgramYear, + p_enrollment_date = dto.EnrollmentDate, + p_expected_grad = dto.ExpectedGrad + }, + commandType: CommandType.StoredProcedure); + } + + public async Task UpdateAsync(int idStudent, UpdateStudentDto dto) + { + using var db = Connection; + var rowsAffected = await db.ExecuteScalarAsync( + "sp_Student_Update", + new + { + p_id_student = idStudent, + p_id_program = dto.IdProgram, + p_identifier = dto.Identifier, + p_program_year = dto.ProgramYear, + p_enrollment_date = dto.EnrollmentDate, + p_expected_grad = dto.ExpectedGrad + }, + commandType: CommandType.StoredProcedure); + return rowsAffected > 0; + } + + public async Task DeleteAsync(int idStudent) + { + using var db = Connection; + var rowsAffected = await db.ExecuteScalarAsync( + "sp_Student_Delete", + new { p_id_student = idStudent }, + commandType: CommandType.StoredProcedure); + return rowsAffected > 0; + } +} diff --git a/api/src/Models/ResponseTypes/ResponseResult.cs b/api/src/Models/ResponseTypes/ResponseResult.cs new file mode 100644 index 0000000..c6c3fa2 --- /dev/null +++ b/api/src/Models/ResponseTypes/ResponseResult.cs @@ -0,0 +1,8 @@ +namespace WinStudentGoalTracker.Models; + +public class ResponseResult +{ + public bool Success { get; set; } + public string? Message { get; set; } + public T? Data { get; set; } +} diff --git a/api/src/Models/ResponseTypes/StudentResponse.cs b/api/src/Models/ResponseTypes/StudentResponse.cs new file mode 100644 index 0000000..b0aed06 --- /dev/null +++ b/api/src/Models/ResponseTypes/StudentResponse.cs @@ -0,0 +1,28 @@ +using WinStudentGoalTracker.DataAccess; + +namespace WinStudentGoalTracker.Models; + +public class StudentResponse +{ + public int IdStudent { get; set; } + public int? IdProgram { get; set; } + public string? Identifier { get; set; } + public int? ProgramYear { get; set; } + public DateTime? EnrollmentDate { get; set; } + public DateTime? ExpectedGrad { get; set; } + public DateTime? CreatedAt { get; set; } + + public static StudentResponse FromDatabaseModel(dbStudent student) + { + return new StudentResponse + { + IdStudent = student.IdStudent, + IdProgram = student.IdProgram, + Identifier = student.Identifier, + ProgramYear = student.ProgramYear, + EnrollmentDate = student.EnrollmentDate, + ExpectedGrad = student.ExpectedGrad, + CreatedAt = student.CreatedAt + }; + } +} diff --git a/api/src/Models/Security/UserRoles.cs b/api/src/Models/Security/UserRoles.cs new file mode 100644 index 0000000..bf7e7d7 --- /dev/null +++ b/api/src/Models/Security/UserRoles.cs @@ -0,0 +1,21 @@ +namespace WinStudentGoalTracker.Models; + +public static class UserRoles +{ + // Role names from role-based-access-control.md + public const string Teacher = "Teacher"; + public const string Paraeducator = "Paraeducator"; + public const string ProgramAdmin = "ProgramAdmin"; + public const string DistrictAdmin = "DistrictAdmin"; + public const string SuperAdmin = "SuperAdmin"; + + public static readonly IReadOnlyList All = new[] + { + Teacher, + Paraeducator, + ProgramAdmin, + DistrictAdmin, + SuperAdmin + + }; +} diff --git a/db/Objects/procedures/sp_Student_Delete.sql b/db/Objects/procedures/sp_Student_Delete.sql new file mode 100644 index 0000000..0599406 --- /dev/null +++ b/db/Objects/procedures/sp_Student_Delete.sql @@ -0,0 +1,12 @@ +DROP PROCEDURE IF EXISTS sp_Student_Delete; +DELIMITER $$ + +CREATE PROCEDURE sp_Student_Delete(IN p_id_student INT) +BEGIN + DELETE FROM student + WHERE id_student = p_id_student; + + SELECT ROW_COUNT() AS rows_affected; +END$$ + +DELIMITER ; diff --git a/db/Objects/procedures/sp_Student_GetAll.sql b/db/Objects/procedures/sp_Student_GetAll.sql new file mode 100644 index 0000000..a554bd7 --- /dev/null +++ b/db/Objects/procedures/sp_Student_GetAll.sql @@ -0,0 +1,18 @@ +DROP PROCEDURE IF EXISTS sp_Student_GetAll; +DELIMITER $$ + +CREATE PROCEDURE sp_Student_GetAll() +BEGIN + SELECT + id_student, + id_program, + identifier, + program_year, + enrollment_date, + expected_grad, + created_at + FROM student + ORDER BY id_student; +END$$ + +DELIMITER ; diff --git a/db/Objects/procedures/sp_Student_GetById.sql b/db/Objects/procedures/sp_Student_GetById.sql new file mode 100644 index 0000000..fe1abaa --- /dev/null +++ b/db/Objects/procedures/sp_Student_GetById.sql @@ -0,0 +1,19 @@ +DROP PROCEDURE IF EXISTS sp_Student_GetById; +DELIMITER $$ + +CREATE PROCEDURE sp_Student_GetById(IN p_id_student INT) +BEGIN + SELECT + id_student, + id_program, + identifier, + program_year, + enrollment_date, + expected_grad, + created_at + FROM student + WHERE id_student = p_id_student + LIMIT 1; +END$$ + +DELIMITER ; diff --git a/db/Objects/procedures/sp_Student_Insert.sql b/db/Objects/procedures/sp_Student_Insert.sql new file mode 100644 index 0000000..11dce58 --- /dev/null +++ b/db/Objects/procedures/sp_Student_Insert.sql @@ -0,0 +1,47 @@ +DROP PROCEDURE IF EXISTS sp_Student_Insert; +DELIMITER $$ + +CREATE PROCEDURE sp_Student_Insert( + IN p_id_student INT, + IN p_id_program INT, + IN p_identifier VARCHAR(50), + IN p_program_year INT, + IN p_enrollment_date DATE, + IN p_expected_grad DATE +) +BEGIN + INSERT INTO student + ( + id_student, + id_program, + identifier, + program_year, + enrollment_date, + expected_grad, + created_at + ) + VALUES + ( + p_id_student, + p_id_program, + p_identifier, + p_program_year, + p_enrollment_date, + p_expected_grad, + UTC_TIMESTAMP() + ); + + SELECT + id_student, + id_program, + identifier, + program_year, + enrollment_date, + expected_grad, + created_at + FROM student + WHERE id_student = p_id_student + LIMIT 1; +END$$ + +DELIMITER ; diff --git a/db/Objects/procedures/sp_Student_Update.sql b/db/Objects/procedures/sp_Student_Update.sql new file mode 100644 index 0000000..dec1524 --- /dev/null +++ b/db/Objects/procedures/sp_Student_Update.sql @@ -0,0 +1,25 @@ +DROP PROCEDURE IF EXISTS sp_Student_Update; +DELIMITER $$ + +CREATE PROCEDURE sp_Student_Update( + IN p_id_student INT, + IN p_id_program INT, + IN p_identifier VARCHAR(50), + IN p_program_year INT, + IN p_enrollment_date DATE, + IN p_expected_grad DATE +) +BEGIN + UPDATE student + SET + id_program = COALESCE(p_id_program, id_program), + identifier = COALESCE(p_identifier, identifier), + program_year = COALESCE(p_program_year, program_year), + enrollment_date = COALESCE(p_enrollment_date, enrollment_date), + expected_grad = COALESCE(p_expected_grad, expected_grad) + WHERE id_student = p_id_student; + + SELECT ROW_COUNT() AS rows_affected; +END$$ + +DELIMITER ;