Prototype illustrative role assignments project

This commit is contained in:
ivan-pelly
2026-02-19 07:19:50 -08:00
parent 9d9a416d1c
commit f178add2be
34 changed files with 2118 additions and 0 deletions
@@ -0,0 +1,127 @@
// =============================================================================
// DummyAssignmentRepository.cs
// =============================================================================
// This is the HARDCODED version of the assignment repository.
// It simulates what the database would return, without needing a real database.
//
// THE DEMO DATA:
// ┌─────────┬───────────────┬────────────┬───────────────────┐
// │ User ID │ Name │ Student ID │ Assignment Type │
// ├─────────┼───────────────┼────────────┼───────────────────┤
// │ 1 │ Ms. Rivera │ 101 │ PrimaryTeacher │
// │ 1 │ Ms. Rivera │ 102 │ PrimaryTeacher │
// │ 2 │ Mr. Daniels │ 101 │ Paraeducator │
// │ 3 │ Dr. Patel │ 101 │ Supervisor │
// │ 3 │ Dr. Patel │ 102 │ Supervisor │
// │ 3 │ Dr. Patel │ 103 │ Supervisor │
// │ 3 │ Dr. Patel │ 104 │ Supervisor │
// └─────────┴───────────────┴────────────┴───────────────────┘
//
// Notice: Student 103 and 104 are NOT assigned to User 1 (Ms. Rivera) or
// User 2 (Mr. Daniels). If those users try to access those students, the
// authorization handler will get null from GetActiveAssignment and deny access.
//
// Dr. Patel (Supervisor) has Supervisor-type assignments to ALL students,
// which grants read-only access everywhere.
// =============================================================================
using RolesAssignments.Models;
namespace RolesAssignments.Data;
public class DummyAssignmentRepository : IAssignmentRepository
{
// This list simulates the student_assignments table in the database.
// Each entry represents one row — one relationship between a user and a student.
private static readonly List<StudentAssignment> _assignments = new()
{
// Ms. Rivera (User 1) is the primary teacher for Students 101 and 102
new StudentAssignment
{
Id = 1, UserId = 1, StudentId = 101,
AssignmentType = AssignmentType.PrimaryTeacher,
StartDate = new DateTime(2025, 9, 1), EndDate = null, IsActive = true
},
new StudentAssignment
{
Id = 2, UserId = 1, StudentId = 102,
AssignmentType = AssignmentType.PrimaryTeacher,
StartDate = new DateTime(2025, 9, 1), EndDate = null, IsActive = true
},
// Mr. Daniels (User 2) is a paraeducator assigned to Student 101
new StudentAssignment
{
Id = 3, UserId = 2, StudentId = 101,
AssignmentType = AssignmentType.Paraeducator,
StartDate = new DateTime(2025, 9, 1), EndDate = null, IsActive = true
},
// Dr. Patel (User 3) is a supervisor for ALL students
new StudentAssignment
{
Id = 4, UserId = 3, StudentId = 101,
AssignmentType = AssignmentType.Supervisor,
StartDate = new DateTime(2025, 9, 1), EndDate = null, IsActive = true
},
new StudentAssignment
{
Id = 5, UserId = 3, StudentId = 102,
AssignmentType = AssignmentType.Supervisor,
StartDate = new DateTime(2025, 9, 1), EndDate = null, IsActive = true
},
new StudentAssignment
{
Id = 6, UserId = 3, StudentId = 103,
AssignmentType = AssignmentType.Supervisor,
StartDate = new DateTime(2025, 9, 1), EndDate = null, IsActive = true
},
new StudentAssignment
{
Id = 7, UserId = 3, StudentId = 104,
AssignmentType = AssignmentType.Supervisor,
StartDate = new DateTime(2025, 9, 1), EndDate = null, IsActive = true
},
};
/// <summary>
/// Finds an active assignment between the given user and student.
/// Returns null if no active assignment exists.
/// </summary>
public Task<StudentAssignment?> GetActiveAssignment(int userId, int studentId)
{
// This LINQ query does in-memory what the SQL query would do in a real database.
var now = DateTime.UtcNow;
var assignment = _assignments.FirstOrDefault(a =>
a.UserId == userId
&& a.StudentId == studentId
&& a.IsActive
&& a.StartDate <= now
&& (a.EndDate == null || a.EndDate >= now));
// ==========================================
// REAL DATABASE VERSION (commented out):
// ==========================================
// In production, this method would use Dapper to query MySQL:
//
// public async Task<StudentAssignment?> GetActiveAssignment(int userId, int studentId)
// {
// return await _db.QueryFirstOrDefaultAsync<StudentAssignment>(
// @"SELECT id, user_id AS UserId, student_id AS StudentId,
// assignment_type AS AssignmentType,
// start_date AS StartDate, end_date AS EndDate,
// is_active AS IsActive
// 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",
// new { UserId = userId, StudentId = studentId });
// }
return Task.FromResult(assignment);
}
}
@@ -0,0 +1,121 @@
// =============================================================================
// DummyGoalRepository.cs
// =============================================================================
// Hardcoded goal data. Goals belong to students and can only be created/edited
// by the PrimaryTeacher (enforced by the authorization handler, not here).
//
// This repository is intentionally "dumb" — it just stores and retrieves data.
// Authorization is handled elsewhere (in the handlers and controllers).
// This separation of concerns is important: the repository doesn't know or
// care about permissions. It just does CRUD.
// =============================================================================
using RolesAssignments.Models;
namespace RolesAssignments.Data;
public class DummyGoalRepository : IGoalRepository
{
// Simulates the goals table. We use a static list so data persists
// across requests during a single run of the app (but resets on restart).
private static readonly List<Goal> _goals = new()
{
new Goal
{
Id = 1, StudentId = 101,
Title = "Reading Fluency",
Description = "Student will read 20 words per minute by end of semester.",
CreatedByUserId = 1, CreatedAt = new DateTime(2025, 9, 15)
},
new Goal
{
Id = 2, StudentId = 101,
Title = "Math Facts",
Description = "Student will master addition facts to 10.",
CreatedByUserId = 1, CreatedAt = new DateTime(2025, 9, 15)
},
new Goal
{
Id = 3, StudentId = 102,
Title = "Social Skills",
Description = "Student will initiate peer interactions 3 times per day.",
CreatedByUserId = 1, CreatedAt = new DateTime(2025, 9, 16)
},
};
// Counter for generating new IDs (simulates AUTO_INCREMENT)
private static int _nextId = 4;
public Task<IEnumerable<Goal>> GetByStudentId(int studentId)
{
var goals = _goals.Where(g => g.StudentId == studentId);
// ==========================================
// REAL DATABASE VERSION (commented out):
// ==========================================
// return await _db.QueryAsync<Goal>(
// @"SELECT id AS Id, student_id AS StudentId, title AS Title,
// description AS Description,
// created_by_user_id AS CreatedByUserId,
// created_at AS CreatedAt
// FROM goals
// WHERE student_id = @StudentId
// ORDER BY created_at",
// new { StudentId = studentId });
return Task.FromResult(goals);
}
public Task<Goal?> GetById(int goalId)
{
var goal = _goals.FirstOrDefault(g => g.Id == goalId);
return Task.FromResult(goal);
}
public Task<int> Create(int studentId, CreateGoalRequest request, int createdByUserId)
{
var goal = new Goal
{
Id = _nextId++,
StudentId = studentId,
Title = request.Title,
Description = request.Description,
CreatedByUserId = createdByUserId,
CreatedAt = DateTime.UtcNow
};
_goals.Add(goal);
// ==========================================
// REAL DATABASE VERSION (commented out):
// ==========================================
// var goalId = await _db.ExecuteScalarAsync<int>(
// @"INSERT INTO goals (student_id, title, description, created_by_user_id, created_at)
// VALUES (@StudentId, @Title, @Description, @CreatedByUserId, NOW());
// SELECT LAST_INSERT_ID();",
// new { StudentId = studentId, request.Title, request.Description, CreatedByUserId = createdByUserId });
return Task.FromResult(goal.Id);
}
public Task Update(int goalId, UpdateGoalRequest request, int updatedByUserId)
{
var goal = _goals.FirstOrDefault(g => g.Id == goalId);
if (goal is not null)
{
goal.Title = request.Title;
goal.Description = request.Description;
}
// ==========================================
// REAL DATABASE VERSION (commented out):
// ==========================================
// await _db.ExecuteAsync(
// @"UPDATE goals
// SET title = @Title, description = @Description,
// updated_by_user_id = @UpdatedByUserId, updated_at = NOW()
// WHERE id = @GoalId",
// new { GoalId = goalId, request.Title, request.Description, UpdatedByUserId = updatedByUserId });
return Task.CompletedTask;
}
}
@@ -0,0 +1,151 @@
// =============================================================================
// DummyProgressEntryRepository.cs
// =============================================================================
// Hardcoded progress entry data. This is the entity that demonstrates the
// ownership model — entries have a CreatedByUserId, and the authorization
// handler uses it to determine who can edit/delete them.
//
// The seed data includes entries created by different users so you can test:
// - Ms. Rivera (User 1) editing any entry for her students → ALLOWED
// - Mr. Daniels (User 2) editing his own entry → ALLOWED
// - Mr. Daniels (User 2) editing Ms. Rivera's entry → DENIED
// =============================================================================
using RolesAssignments.Models;
namespace RolesAssignments.Data;
public class DummyProgressEntryRepository : IProgressEntryRepository
{
// Simulates the progress_entries table
private static readonly List<ProgressEntry> _entries = new()
{
// Entry created by Ms. Rivera (User 1) for Student 101
new ProgressEntry
{
Id = 1, StudentId = 101,
Notes = "Student read 12 words per minute today. Showing improvement.",
CreatedByUserId = 1, // Ms. Rivera made this entry
CreatedAt = new DateTime(2025, 10, 1),
IsDeleted = false
},
// Entry created by Mr. Daniels (User 2) for Student 101
new ProgressEntry
{
Id = 2, StudentId = 101,
Notes = "Worked on addition facts during small group. Got 7/10 correct.",
CreatedByUserId = 2, // Mr. Daniels made this entry
CreatedAt = new DateTime(2025, 10, 2),
IsDeleted = false
},
// Another entry by Ms. Rivera for Student 102
new ProgressEntry
{
Id = 3, StudentId = 102,
Notes = "Student initiated conversation with peer during recess.",
CreatedByUserId = 1, // Ms. Rivera made this entry
CreatedAt = new DateTime(2025, 10, 3),
IsDeleted = false
},
};
private static int _nextId = 4;
public Task<IEnumerable<ProgressEntry>> GetByStudentId(int studentId)
{
var entries = _entries.Where(e => e.StudentId == studentId && !e.IsDeleted);
// ==========================================
// REAL DATABASE VERSION (commented out):
// ==========================================
// return await _db.QueryAsync<ProgressEntry>(
// @"SELECT id AS Id, student_id AS StudentId, notes AS Notes,
// created_by_user_id AS CreatedByUserId,
// created_at AS CreatedAt, is_deleted AS IsDeleted
// FROM progress_entries
// WHERE student_id = @StudentId AND is_deleted = FALSE
// ORDER BY created_at DESC",
// new { StudentId = studentId });
return Task.FromResult(entries);
}
public Task<ProgressEntry?> GetById(int entryId)
{
var entry = _entries.FirstOrDefault(e => e.Id == entryId && !e.IsDeleted);
return Task.FromResult(entry);
}
public Task<int> Create(int studentId, CreateEntryRequest request, int createdByUserId)
{
var entry = new ProgressEntry
{
Id = _nextId++,
StudentId = studentId,
Notes = request.Notes,
CreatedByUserId = createdByUserId,
CreatedAt = DateTime.UtcNow,
IsDeleted = false
};
_entries.Add(entry);
// ==========================================
// REAL DATABASE VERSION (commented out):
// ==========================================
// var entryId = await _db.ExecuteScalarAsync<int>(
// @"INSERT INTO progress_entries (student_id, notes, created_by_user_id, created_at, is_deleted)
// VALUES (@StudentId, @Notes, @CreatedByUserId, NOW(), FALSE);
// SELECT LAST_INSERT_ID();",
// new { StudentId = studentId, request.Notes, CreatedByUserId = createdByUserId });
return Task.FromResult(entry.Id);
}
public Task Update(int entryId, UpdateEntryRequest request, int updatedByUserId)
{
var entry = _entries.FirstOrDefault(e => e.Id == entryId && !e.IsDeleted);
if (entry is not null)
{
entry.Notes = request.Notes;
}
// ==========================================
// REAL DATABASE VERSION (commented out):
// ==========================================
// await _db.ExecuteAsync(
// @"UPDATE progress_entries
// SET notes = @Notes, updated_by_user_id = @UpdatedByUserId, updated_at = NOW()
// WHERE id = @EntryId AND is_deleted = FALSE",
// new { EntryId = entryId, request.Notes, UpdatedByUserId = updatedByUserId });
return Task.CompletedTask;
}
/// <summary>
/// Soft-delete: marks the entry as deleted instead of removing it.
/// The row stays in the database for auditing purposes.
/// </summary>
public Task SoftDelete(int entryId, int deletedByUserId)
{
var entry = _entries.FirstOrDefault(e => e.Id == entryId);
if (entry is not null)
{
entry.IsDeleted = true;
}
// ==========================================
// REAL DATABASE VERSION (commented out):
// ==========================================
// await _db.ExecuteAsync(
// @"UPDATE progress_entries
// SET is_deleted = TRUE,
// deleted_by_user_id = @DeletedByUserId,
// deleted_at = NOW()
// WHERE id = @EntryId",
// new { EntryId = entryId, DeletedByUserId = deletedByUserId });
return Task.CompletedTask;
}
}
@@ -0,0 +1,112 @@
// =============================================================================
// DummyStudentRepository.cs
// =============================================================================
// Hardcoded student data. The most interesting method here is
// GetAccessibleStudents, which demonstrates the "scoped list" pattern.
//
// THE IDEA:
// For single-resource endpoints (GET /students/101), the controller calls
// AuthorizeAsync to check access. But for LIST endpoints (GET /students),
// you can't load ALL students and then authorize each one — that's slow
// and leaks the existence of records the user shouldn't see.
//
// Instead, the list query itself is scoped through the assignments table.
// The query joins students through student_assignments, so it only returns
// students the user is assigned to. Unauthorized records never leave the
// database.
// =============================================================================
using RolesAssignments.Models;
namespace RolesAssignments.Data;
public class DummyStudentRepository : IStudentRepository
{
// Simulates the students table
private static readonly List<Student> _students = new()
{
new Student { Id = 101, Identifier = "Student A", ProgramYear = "2025-2026", Age = 8, IsDeleted = false },
new Student { Id = 102, Identifier = "Student B", ProgramYear = "2025-2026", Age = 9, IsDeleted = false },
new Student { Id = 103, Identifier = "Student C", ProgramYear = "2025-2026", Age = 7, IsDeleted = false },
new Student { Id = 104, Identifier = "Student D", ProgramYear = "2025-2026", Age = 10, IsDeleted = false },
};
// We need the assignment data to scope the list query
private readonly IAssignmentRepository _assignments;
public DummyStudentRepository(IAssignmentRepository assignments)
{
_assignments = assignments;
}
/// <summary>
/// Returns only the students that the given user is assigned to.
/// In a real database, this would be a single SQL query with a JOIN
/// through student_assignments. Here we simulate it in-memory.
/// </summary>
public Task<IEnumerable<StudentSummaryDto>> GetAccessibleStudents(int userId)
{
// In-memory simulation: we cast the dummy assignment repo to get
// access to the underlying data. This is a prototype shortcut.
// In production, this would be a SQL JOIN (see below).
// We use the DummyAssignmentRepository to simulate the JOIN.
// For each student, check if the user has an active assignment.
var now = DateTime.UtcNow;
var assignmentRepo = (DummyAssignmentRepository)_assignments;
// Because we can't easily access the private static list from the
// assignment repo in this simulation, we'll use GetActiveAssignment
// for each student. This is an N+1 query in-memory — fine for a demo,
// terrible in production (which is why the real version uses a JOIN).
var results = new List<StudentSummaryDto>();
foreach (var student in _students.Where(s => !s.IsDeleted))
{
var assignment = _assignments.GetActiveAssignment(userId, student.Id).Result;
if (assignment is not null)
{
results.Add(new StudentSummaryDto
{
Id = student.Id,
Identifier = student.Identifier,
ProgramYear = student.ProgramYear,
Age = student.Age,
AssignmentType = assignment.AssignmentType.ToString()
});
}
}
// ==========================================
// REAL DATABASE VERSION (commented out):
// ==========================================
// In production, this is ONE query — no N+1 problem:
//
// return await _db.QueryAsync<StudentSummaryDto>(
// @"SELECT s.id AS Id,
// s.identifier AS Identifier,
// s.program_year AS ProgramYear,
// s.age AS Age,
// sa.assignment_type AS AssignmentType
// 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 });
//
// Notice: there is NO role check in the query. The assignments table
// already encodes who can see what — supervisors have Supervisor-type
// assignments to all students, so they naturally appear in the JOIN.
return Task.FromResult<IEnumerable<StudentSummaryDto>>(results);
}
public Task<Student?> GetById(int studentId)
{
var student = _students.FirstOrDefault(s => s.Id == studentId && !s.IsDeleted);
return Task.FromResult(student);
}
}
@@ -0,0 +1,36 @@
// =============================================================================
// IAssignmentRepository.cs
// =============================================================================
// This interface defines WHAT the assignment repository can do, without saying
// HOW it does it. In this prototype, the "how" is hardcoded dummy data.
// In production, the "how" would be a MySQL query via Dapper.
//
// WHY USE AN INTERFACE?
// ---------------------
// By coding against IAssignmentRepository (the interface) instead of a
// concrete class, you can swap implementations without changing any other code.
// Today: DummyAssignmentRepository (hardcoded). Tomorrow: DapperAssignmentRepository
// (real database). The controllers, handlers, and everything else don't change —
// they only know about the interface, not the implementation behind it.
//
// The swap happens in one place: Program.cs, where you register the service.
// =============================================================================
using RolesAssignments.Models;
namespace RolesAssignments.Data;
public interface IAssignmentRepository
{
/// <summary>
/// Looks up the active assignment between a specific user and a specific student.
/// Returns null if no active assignment exists (meaning the user has no access
/// to that student).
///
/// "Active" means:
/// - is_active is true
/// - start_date is today or earlier
/// - end_date is null (ongoing) or today or later
/// </summary>
Task<StudentAssignment?> GetActiveAssignment(int userId, int studentId);
}
@@ -0,0 +1,53 @@
// =============================================================================
// IStudentRepository.cs / IGoalRepository.cs / IProgressEntryRepository.cs
// =============================================================================
// All three repository interfaces in one file for this prototype.
// Each defines the operations available for that entity.
// =============================================================================
using RolesAssignments.Models;
namespace RolesAssignments.Data;
// ---------------------------------------------------------------------------
// STUDENT REPOSITORY
// ---------------------------------------------------------------------------
public interface IStudentRepository
{
/// <summary>
/// Returns a list of students the given user is allowed to see.
/// This is the "scoped list" query — it filters at the database level
/// so unauthorized student records never leave the data layer.
/// </summary>
Task<IEnumerable<StudentSummaryDto>> GetAccessibleStudents(int userId);
/// <summary>
/// Returns a single student by ID, or null if not found.
/// NOTE: This does NOT check authorization — the controller is responsible
/// for calling AuthorizeAsync before returning the data.
/// </summary>
Task<Student?> GetById(int studentId);
}
// ---------------------------------------------------------------------------
// GOAL REPOSITORY
// ---------------------------------------------------------------------------
public interface IGoalRepository
{
Task<IEnumerable<Goal>> GetByStudentId(int studentId);
Task<Goal?> GetById(int goalId);
Task<int> Create(int studentId, CreateGoalRequest request, int createdByUserId);
Task Update(int goalId, UpdateGoalRequest request, int updatedByUserId);
}
// ---------------------------------------------------------------------------
// PROGRESS ENTRY REPOSITORY
// ---------------------------------------------------------------------------
public interface IProgressEntryRepository
{
Task<IEnumerable<ProgressEntry>> GetByStudentId(int studentId);
Task<ProgressEntry?> GetById(int entryId);
Task<int> Create(int studentId, CreateEntryRequest request, int createdByUserId);
Task Update(int entryId, UpdateEntryRequest request, int updatedByUserId);
Task SoftDelete(int entryId, int deletedByUserId);
}