From cfd6bb530bdd04669dbf6124e9fcb51cc5f4a625 Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Thu, 12 Feb 2026 15:14:49 -0700 Subject: [PATCH] Fix so people are scoped to users --- Media.JoshHeaps.Net/Api/MedicalDocsApi.cs | 118 +++++++++++++++- .../Database/026_medical_people_access.sql | 10 ++ Media.JoshHeaps.Net/Models/MedicalPerson.cs | 6 + Media.JoshHeaps.Net/Pages/MedicalDocs.cshtml | 17 +++ .../Services/MedicalAiService.cs | 2 +- .../Services/MedicalDocsService.cs | 127 +++++++++++++++++- .../wwwroot/css/medical-docs.css | 33 +++++ .../wwwroot/js/medical-docs/people.js | 88 +++++++++++- 8 files changed, 390 insertions(+), 11 deletions(-) create mode 100644 Media.JoshHeaps.Net/Database/026_medical_people_access.sql diff --git a/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs b/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs index a5e1caf..4aaaee8 100644 --- a/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs +++ b/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs @@ -17,7 +17,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); - var people = await medicalDocsService.GetPeopleAsync(); + var people = await medicalDocsService.GetPeopleAsync(userId.Value); return Ok(people); } @@ -31,13 +31,68 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest(new { error = "Name is required" }); - var person = await medicalDocsService.CreatePersonAsync(request.Name.Trim(), request.DateOfBirth, request.Notes); + var person = await medicalDocsService.CreatePersonAsync(userId.Value, request.Name.Trim(), request.DateOfBirth, request.Notes); if (person == null) return StatusCode(500, new { error = "Failed to create person" }); return Ok(person); } + // --- People Access --- + + [HttpGet("people/{personId}/access")] + public async Task GetPersonAccess(long personId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); + + var users = await medicalDocsService.GetPeopleAccessAsync(personId); + return Ok(users); + } + + [HttpPost("people/{personId}/access")] + public async Task GrantPersonAccess(long personId, [FromBody] GrantAccessRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); + + if (string.IsNullOrWhiteSpace(request.Username)) + return BadRequest(new { error = "Username is required" }); + + var targetUser = await dbExecutor.ExecuteReaderAsync( + "SELECT id FROM app.users WHERE LOWER(username) = LOWER(@username)", + reader => reader.GetInt64(0), + new { username = request.Username.Trim() }); + + if (targetUser == 0) + return NotFound(new { error = "User not found" }); + + var success = await medicalDocsService.GrantAccessAsync(personId, targetUser); + if (!success) + return StatusCode(500, new { error = "Failed to grant access" }); + + return Ok(new { success = true }); + } + + [HttpDelete("people/{personId}/access/{targetUserId}")] + public async Task RevokePersonAccess(long personId, long targetUserId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); + + var success = await medicalDocsService.RevokeAccessAsync(personId, targetUserId); + if (!success) + return BadRequest(new { error = "Cannot revoke access — at least one user must have access" }); + + return Ok(new { success = true }); + } + // --- Documents --- [HttpGet("documents")] @@ -46,6 +101,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid(); if (limit < 1 || limit > 100) limit = 50; if (offset < 0) offset = 0; @@ -75,6 +131,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (personId <= 0) return BadRequest(new { error = "personId is required" }); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); if (limit < 1 || limit > 100) limit = 50; if (offset < 0) offset = 0; @@ -92,6 +149,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (personId <= 0) return BadRequest(new { error = "personId is required" }); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); var tags = await medicalDocsService.GetPersonTagsAsync(personId); return Ok(tags); @@ -104,6 +162,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); if (file == null || file.Length == 0) return BadRequest(new { error = "No file provided" }); @@ -126,6 +185,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (request.PersonId <= 0) return BadRequest(new { error = "Person is required" }); + if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid(); if (string.IsNullOrWhiteSpace(request.Title)) return BadRequest(new { error = "Title is required" }); @@ -144,6 +204,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid(); var doc = await medicalDocsService.GetDocumentByIdAsync(id); if (doc == null) return NotFound(new { error = "Document not found" }); @@ -157,6 +218,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid(); var doc = await medicalDocsService.GetDocumentByIdAsync(id); if (doc == null) return NotFound(new { error = "Document not found" }); @@ -176,6 +238,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid(); var success = await medicalDocsService.UpdateDocumentAsync(id, request.Title, request.Description, request.DocumentDate, request.Classification, request.DoctorId); if (!success) return NotFound(new { error = "Document not found" }); @@ -189,6 +252,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid(); var success = await medicalDocsService.DeleteDocumentAsync(id); if (!success) return NotFound(new { error = "Document not found" }); @@ -204,6 +268,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid(); var doc = await medicalDocsService.GetDocumentByIdAsync(id); if (doc == null) return NotFound(new { error = "Document not found" }); @@ -260,12 +325,13 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid(); var tags = await medicalDocsService.GetDocumentTagsAsync(id); return Ok(tags); } - // --- Doctors --- + // --- Doctors (shared, no per-person access check) --- [HttpGet("doctors")] public async Task GetDoctors() @@ -335,6 +401,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (personId <= 0) return BadRequest(new { error = "personId is required" }); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); var conditions = await medicalDocsService.GetConditionsAsync(personId); return Ok(conditions); @@ -349,6 +416,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (request.PersonId <= 0) return BadRequest(new { error = "Person is required" }); + if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid(); if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest(new { error = "Name is required" }); @@ -365,6 +433,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "condition", id)) return Forbid(); if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest(new { error = "Name is required" }); @@ -381,6 +450,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "condition", id)) return Forbid(); var success = await medicalDocsService.DeleteConditionAsync(id); if (!success) return NotFound(new { error = "Condition not found" }); @@ -399,6 +469,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (personId <= 0) return BadRequest(new { error = "personId is required" }); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); var prescriptions = await medicalDocsService.GetPrescriptionsAsync(personId); return Ok(prescriptions); @@ -413,6 +484,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (request.PersonId <= 0) return BadRequest(new { error = "Person is required" }); + if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid(); if (string.IsNullOrWhiteSpace(request.MedicationName)) return BadRequest(new { error = "Medication name is required" }); @@ -429,6 +501,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid(); if (string.IsNullOrWhiteSpace(request.MedicationName)) return BadRequest(new { error = "Medication name is required" }); @@ -445,6 +518,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid(); var success = await medicalDocsService.DeletePrescriptionAsync(id); if (!success) return NotFound(new { error = "Prescription not found" }); @@ -460,6 +534,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid(); var pickups = await medicalDocsService.GetPickupsAsync(id); return Ok(pickups); @@ -471,6 +546,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid(); var pickup = await medicalDocsService.CreatePickupAsync(id, request.PickupDate, request.Quantity, request.Pharmacy, request.Cost, request.Notes); if (pickup == null) @@ -485,6 +561,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "pickup", id)) return Forbid(); var success = await medicalDocsService.DeletePickupAsync(id); if (!success) return NotFound(new { error = "Pickup not found" }); @@ -503,6 +580,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (personId <= 0) return BadRequest(new { error = "personId is required" }); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); var providers = await medicalDocsService.GetProvidersAsync(personId); return Ok(providers); @@ -517,6 +595,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (request.PersonId <= 0) return BadRequest(new { error = "Person is required" }); + if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid(); if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest(new { error = "Name is required" }); @@ -533,6 +612,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid(); if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest(new { error = "Name is required" }); @@ -549,6 +629,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid(); var success = await medicalDocsService.DeleteProviderAsync(id); if (!success) return NotFound(new { error = "Provider not found" }); @@ -564,6 +645,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid(); var payments = await medicalDocsService.GetProviderPaymentsAsync(id); return Ok(payments); @@ -575,6 +657,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid(); if (request.Amount <= 0) return BadRequest(new { error = "Amount must be greater than 0" }); @@ -592,6 +675,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "provider-payment", id)) return Forbid(); var success = await medicalDocsService.DeleteProviderPaymentAsync(id); if (!success) return NotFound(new { error = "Payment not found" }); @@ -610,6 +694,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (personId <= 0) return BadRequest(new { error = "personId is required" }); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); var bills = await medicalDocsService.GetBillsAsync(personId, providerId); return Ok(bills); @@ -624,6 +709,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (request.PersonId <= 0) return BadRequest(new { error = "Person is required" }); + if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid(); if (request.TotalAmount <= 0) return BadRequest(new { error = "Amount must be greater than 0" }); @@ -640,6 +726,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid(); if (request.TotalAmount <= 0) return BadRequest(new { error = "Amount must be greater than 0" }); @@ -656,6 +743,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid(); var success = await medicalDocsService.DeleteBillAsync(id); if (!success) return NotFound(new { error = "Bill not found" }); @@ -669,6 +757,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid(); if (request.DocumentId <= 0) return BadRequest(new { error = "Document is required" }); @@ -686,6 +775,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "bill", billId)) return Forbid(); var success = await medicalDocsService.UnlinkDocumentFromBillAsync(billId, docId); if (!success) return NotFound(new { error = "Link not found" }); @@ -701,6 +791,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid(); var charges = await medicalDocsService.GetChargesAsync(id); return Ok(charges); @@ -712,6 +803,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid(); if (string.IsNullOrWhiteSpace(request.Description)) return BadRequest(new { error = "Description is required" }); @@ -731,6 +823,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "bill-charge", id)) return Forbid(); var success = await medicalDocsService.DeleteChargeAsync(id); if (!success) return NotFound(new { error = "Charge not found" }); @@ -749,6 +842,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (personId <= 0) return BadRequest(new { error = "personId is required" }); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); if (limit < 1 || limit > 200) limit = 100; if (offset < 0) offset = 0; @@ -768,6 +862,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (personId <= 0 || doctorId <= 0) return BadRequest(new { error = "personId and doctorId are required" }); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); var data = await medicalDocsService.GetVisitPrepAsync(personId, doctorId); return Ok(data); @@ -782,6 +877,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (request.PersonId <= 0 || request.DoctorId <= 0) return BadRequest(new { error = "personId and doctorId are required" }); + if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid(); var data = await medicalDocsService.GetVisitPrepAsync(request.PersonId, request.DoctorId); var doctor = await medicalDocsService.GetDoctorByIdAsync(request.DoctorId); @@ -801,12 +897,13 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (personId <= 0) return BadRequest(new { error = "personId is required" }); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); var summary = await medicalDocsService.GetBillSummaryAsync(personId); return Ok(summary); } - // --- Auth helpers (same pattern as AdminApi) --- + // --- Auth helpers --- private async Task HasMedicalAccess(long userId) { @@ -815,6 +912,18 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc new { UserId = userId }); } + private async Task HasPersonAccess(long userId, long personId) + { + return await medicalDocsService.HasAccessToPersonAsync(userId, personId); + } + + private async Task HasResourceAccess(long userId, string resourceType, long resourceId) + { + var personId = await medicalDocsService.GetPersonIdForResourceAsync(resourceType, resourceId); + if (personId == null) return false; + return await medicalDocsService.HasAccessToPersonAsync(userId, personId.Value); + } + private long? GetUserIdFromAuth() { var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; @@ -851,3 +960,4 @@ public record LinkDocumentRequest(long DocumentId); public record CreateChargeRequest(string Description, decimal Amount); public record ProcessBatchRequest(List DocumentIds); public record VisitPrepSummaryRequest(long PersonId, long DoctorId); +public record GrantAccessRequest(string Username); diff --git a/Media.JoshHeaps.Net/Database/026_medical_people_access.sql b/Media.JoshHeaps.Net/Database/026_medical_people_access.sql new file mode 100644 index 0000000..4e9d8b2 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/026_medical_people_access.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS app.medical_people_access ( + id BIGSERIAL PRIMARY KEY, + person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES app.users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + UNIQUE(person_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_mpa_user_id ON app.medical_people_access(user_id); +CREATE INDEX IF NOT EXISTS idx_mpa_person_id ON app.medical_people_access(person_id); diff --git a/Media.JoshHeaps.Net/Models/MedicalPerson.cs b/Media.JoshHeaps.Net/Models/MedicalPerson.cs index 14e9c25..02ac67a 100644 --- a/Media.JoshHeaps.Net/Models/MedicalPerson.cs +++ b/Media.JoshHeaps.Net/Models/MedicalPerson.cs @@ -9,3 +9,9 @@ public class MedicalPerson public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } } + +public class PersonAccessUser +{ + public long Id { get; set; } + public string Username { get; set; } = string.Empty; +} diff --git a/Media.JoshHeaps.Net/Pages/MedicalDocs.cshtml b/Media.JoshHeaps.Net/Pages/MedicalDocs.cshtml index abd2830..be1579f 100644 --- a/Media.JoshHeaps.Net/Pages/MedicalDocs.cshtml +++ b/Media.JoshHeaps.Net/Pages/MedicalDocs.cshtml @@ -285,6 +285,23 @@ + + + @section Scripts { diff --git a/Media.JoshHeaps.Net/Services/MedicalAiService.cs b/Media.JoshHeaps.Net/Services/MedicalAiService.cs index 72158ed..9ddf4ac 100644 --- a/Media.JoshHeaps.Net/Services/MedicalAiService.cs +++ b/Media.JoshHeaps.Net/Services/MedicalAiService.cs @@ -447,7 +447,7 @@ Only include fields you can confidently extract. Return ONLY the JSON object, no return null; } - _logger.LogError("claude CLI exited with code {ExitCode}: {Stderr}", process.ExitCode, stderr); + _logger.LogError("claude CLI exited with code {ExitCode}.\nStderr: {Stderr}\nStdout: {Stdout}", process.ExitCode, stderr, stdout); return null; } diff --git a/Media.JoshHeaps.Net/Services/MedicalDocsService.cs b/Media.JoshHeaps.Net/Services/MedicalDocsService.cs index 0212d1d..4535444 100644 --- a/Media.JoshHeaps.Net/Services/MedicalDocsService.cs +++ b/Media.JoshHeaps.Net/Services/MedicalDocsService.cs @@ -6,12 +6,16 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, { // --- People --- - public async Task> GetPeopleAsync() + public async Task> GetPeopleAsync(long userId) { try { return await db.ExecuteListReaderAsync( - "SELECT id, name, date_of_birth, notes, created_at, updated_at FROM app.medical_people ORDER BY name", + @"SELECT mp.id, mp.name, mp.date_of_birth, mp.notes, mp.created_at, mp.updated_at + FROM app.medical_people mp + JOIN app.medical_people_access mpa ON mpa.person_id = mp.id + WHERE mpa.user_id = @userId + ORDER BY mp.name", reader => new MedicalPerson { Id = reader.GetInt64(0), @@ -20,7 +24,8 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, Notes = reader.IsDBNull(3) ? null : reader.GetString(3), CreatedAt = reader.GetDateTime(4), UpdatedAt = reader.GetDateTime(5) - }); + }, + new { userId }); } catch (Exception ex) { @@ -29,12 +34,12 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, } } - public async Task CreatePersonAsync(string name, DateTime? dateOfBirth = null, string? notes = null) + public async Task CreatePersonAsync(long userId, string name, DateTime? dateOfBirth = null, string? notes = null) { try { var now = DateTime.UtcNow; - return await db.ExecuteReaderAsync( + var person = await db.ExecuteReaderAsync( @"INSERT INTO app.medical_people (name, date_of_birth, notes, created_at, updated_at) VALUES (@name, @dateOfBirth, @notes, @createdAt, @updatedAt) RETURNING id, name, date_of_birth, notes, created_at, updated_at", @@ -48,6 +53,15 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, UpdatedAt = reader.GetDateTime(5) }, new { name, dateOfBirth, notes, createdAt = now, updatedAt = now }); + + if (person != null) + { + await db.ExecuteNonQueryAsync( + "INSERT INTO app.medical_people_access (person_id, user_id) VALUES (@personId, @userId) ON CONFLICT DO NOTHING", + new { personId = person.Id, userId }); + } + + return person; } catch (Exception ex) { @@ -56,6 +70,109 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, } } + // --- People Access --- + + public async Task HasAccessToPersonAsync(long userId, long personId) + { + try + { + return await db.ExecuteAsync( + "SELECT EXISTS(SELECT 1 FROM app.medical_people_access WHERE user_id = @userId AND person_id = @personId)", + new { userId, personId }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to check person access"); + return false; + } + } + + public async Task GrantAccessAsync(long personId, long targetUserId) + { + try + { + await db.ExecuteNonQueryAsync( + "INSERT INTO app.medical_people_access (person_id, user_id) VALUES (@personId, @userId) ON CONFLICT DO NOTHING", + new { personId, userId = targetUserId }); + return true; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to grant person access"); + return false; + } + } + + public async Task RevokeAccessAsync(long personId, long targetUserId) + { + try + { + var count = await db.ExecuteAsync( + "SELECT COUNT(*) FROM app.medical_people_access WHERE person_id = @personId", + new { personId }); + if (count <= 1) + return false; + + await db.ExecuteNonQueryAsync( + "DELETE FROM app.medical_people_access WHERE person_id = @personId AND user_id = @userId", + new { personId, userId = targetUserId }); + return true; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to revoke person access"); + return false; + } + } + + public async Task> GetPeopleAccessAsync(long personId) + { + try + { + return await db.ExecuteListReaderAsync( + @"SELECT u.id, u.username FROM app.users u + JOIN app.medical_people_access mpa ON mpa.user_id = u.id + WHERE mpa.person_id = @personId + ORDER BY u.username", + reader => new PersonAccessUser + { + Id = reader.GetInt64(0), + Username = reader.GetString(1) + }, + new { personId }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get person access list"); + return []; + } + } + + public async Task GetPersonIdForResourceAsync(string resourceType, long resourceId) + { + try + { + var sql = resourceType switch + { + "document" => "SELECT person_id FROM app.medical_documents WHERE id = @id", + "condition" => "SELECT person_id FROM app.medical_conditions WHERE id = @id", + "prescription" => "SELECT person_id FROM app.medical_prescriptions WHERE id = @id", + "pickup" => "SELECT p.person_id FROM app.medical_prescription_pickups pk JOIN app.medical_prescriptions p ON pk.prescription_id = p.id WHERE pk.id = @id", + "provider" => "SELECT person_id FROM app.medical_billing_providers WHERE id = @id", + "provider-payment" => "SELECT bp.person_id FROM app.medical_provider_payments pp JOIN app.medical_billing_providers bp ON pp.provider_id = bp.id WHERE pp.id = @id", + "bill" => "SELECT person_id FROM app.medical_bills WHERE id = @id", + "bill-charge" => "SELECT b.person_id FROM app.medical_bill_charges bc JOIN app.medical_bills b ON bc.bill_id = b.id WHERE bc.id = @id", + _ => throw new ArgumentException($"Unknown resource type: {resourceType}") + }; + return await db.ExecuteAsync(sql, new { id = resourceId }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get person ID for {ResourceType} {ResourceId}", resourceType, resourceId); + return null; + } + } + // --- Documents --- public async Task SaveDocumentAsync(long personId, IFormFile file, string? title = null, string? description = null, DateTime? documentDate = null, string? classification = null) diff --git a/Media.JoshHeaps.Net/wwwroot/css/medical-docs.css b/Media.JoshHeaps.Net/wwwroot/css/medical-docs.css index 189a697..43b72e6 100644 --- a/Media.JoshHeaps.Net/wwwroot/css/medical-docs.css +++ b/Media.JoshHeaps.Net/wwwroot/css/medical-docs.css @@ -323,6 +323,39 @@ color: #fff; } +.person-pill-row { + display: flex; + align-items: center; + gap: 4px; +} + +.person-pill-row .person-pill { + flex: 1; + min-width: 0; +} + +.person-share-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background: transparent; + border: 1px solid var(--border-primary); + border-radius: 50%; + color: var(--text-muted); + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.person-share-btn:hover { + border-color: var(--accent-primary); + color: var(--accent-primary); + background: var(--bg-tertiary); +} + /* ======================== */ /* Form Inputs */ /* ======================== */ diff --git a/Media.JoshHeaps.Net/wwwroot/js/medical-docs/people.js b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/people.js index f426d21..a0d2429 100644 --- a/Media.JoshHeaps.Net/wwwroot/js/medical-docs/people.js +++ b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/people.js @@ -11,7 +11,12 @@ app.renderPeople = function () { const container = document.getElementById('peopleList'); container.innerHTML = state.people.map(p => - `` + `
+ + +
` ).join(''); }; @@ -61,5 +66,86 @@ app.switchMainTab('documents'); }; + // --- Share Access --- + + app.openShareModal = async function (personId, event) { + event.stopPropagation(); + state.sharePersonId = personId; + document.getElementById('shareAccessOverlay').style.display = ''; + document.getElementById('shareUsername').value = ''; + await app.loadShareAccess(personId); + }; + + app.closeShareModal = function (event) { + if (event && event.target !== event.currentTarget) return; + document.getElementById('shareAccessOverlay').style.display = 'none'; + state.sharePersonId = null; + }; + + app.loadShareAccess = async function (personId) { + const container = document.getElementById('shareAccessList'); + container.innerHTML = '
Loading...
'; + + const res = await fetch(`${app.API}/people/${personId}/access`); + if (!res.ok) { + container.innerHTML = '
Failed to load access list
'; + return; + } + + const users = await res.json(); + state.shareAccessUsers = users; + + if (users.length === 0) { + container.innerHTML = '
No users have access
'; + return; + } + + container.innerHTML = users.map(u => + `
+ ${app.escapeHtml(u.username)} + +
` + ).join(''); + }; + + app.grantAccess = async function () { + const input = document.getElementById('shareUsername'); + const username = input.value.trim(); + if (!username || !state.sharePersonId) return; + + const res = await fetch(`${app.API}/people/${state.sharePersonId}/access`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username }) + }); + + if (!res.ok) { + const err = await res.json(); + alert(err.error || 'Failed to grant access'); + return; + } + + input.value = ''; + await app.loadShareAccess(state.sharePersonId); + }; + + app.revokeAccess = async function (personId, targetUserId) { + const res = await fetch(`${app.API}/people/${personId}/access/${targetUserId}`, { + method: 'DELETE' + }); + + if (!res.ok) { + const err = await res.json(); + alert(err.error || 'Failed to revoke access'); + return; + } + + await app.loadShareAccess(personId); + }; + window.medDocsSelectPerson = (id) => app.selectPerson(id); + window.medDocsOpenShareModal = (id, event) => app.openShareModal(id, event); + window.medDocsCloseShareModal = (event) => app.closeShareModal(event); + window.medDocsGrantAccess = () => app.grantAccess(); + window.medDocsRevokeAccess = (personId, userId) => app.revokeAccess(personId, userId); })(MedDocs);