From c173677f57148d531fe2c53af52824aec19387d0 Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Thu, 12 Feb 2026 13:14:01 -0700 Subject: [PATCH 01/15] Add password reset functionality --- .../Database/025_password_reset_tokens.sql | 12 ++ Media.JoshHeaps.Net/Pages/Gallery.cshtml | 1 + Media.JoshHeaps.Net/Pages/Login.cshtml | 11 +- Media.JoshHeaps.Net/Pages/Login.cshtml.cs | 8 +- Media.JoshHeaps.Net/Pages/LoginHelp.cshtml | 102 +++++++++++ Media.JoshHeaps.Net/Pages/LoginHelp.cshtml.cs | 111 ++++++++++++ Media.JoshHeaps.Net/Services/AuthService.cs | 171 ++++++++++++++++++ Media.JoshHeaps.Net/Services/EmailService.cs | 2 +- Media.JoshHeaps.Net/wwwroot/css/auth.css | 23 +++ Media.JoshHeaps.Net/wwwroot/js/auth.js | 136 ++++++++++++++ 10 files changed, 571 insertions(+), 6 deletions(-) create mode 100644 Media.JoshHeaps.Net/Database/025_password_reset_tokens.sql create mode 100644 Media.JoshHeaps.Net/Pages/LoginHelp.cshtml create mode 100644 Media.JoshHeaps.Net/Pages/LoginHelp.cshtml.cs diff --git a/Media.JoshHeaps.Net/Database/025_password_reset_tokens.sql b/Media.JoshHeaps.Net/Database/025_password_reset_tokens.sql new file mode 100644 index 0000000..187086d --- /dev/null +++ b/Media.JoshHeaps.Net/Database/025_password_reset_tokens.sql @@ -0,0 +1,12 @@ +CREATE TABLE app.password_reset_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + token_hash VARCHAR(64) NOT NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ NULL, + FOREIGN KEY (user_id) REFERENCES app.users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_prt_token_hash ON app.password_reset_tokens(token_hash); +CREATE INDEX IF NOT EXISTS idx_prt_user_id ON app.password_reset_tokens(user_id); diff --git a/Media.JoshHeaps.Net/Pages/Gallery.cshtml b/Media.JoshHeaps.Net/Pages/Gallery.cshtml index 9c8fd76..94cf68b 100644 --- a/Media.JoshHeaps.Net/Pages/Gallery.cshtml +++ b/Media.JoshHeaps.Net/Pages/Gallery.cshtml @@ -21,6 +21,7 @@

Welcome back, @Model.Dashboard?.Username!

+
@if (Model.Dashboard?.EmailVerified == false) { diff --git a/Media.JoshHeaps.Net/Pages/Login.cshtml b/Media.JoshHeaps.Net/Pages/Login.cshtml index 79ee689..6e0637e 100644 --- a/Media.JoshHeaps.Net/Pages/Login.cshtml +++ b/Media.JoshHeaps.Net/Pages/Login.cshtml @@ -60,10 +60,13 @@
-
- - +
+
+ + +
+ Forgot password?
diff --git a/Media.JoshHeaps.Net/Pages/Login.cshtml.cs b/Media.JoshHeaps.Net/Pages/Login.cshtml.cs index ec68471..cf5a8a0 100644 --- a/Media.JoshHeaps.Net/Pages/Login.cshtml.cs +++ b/Media.JoshHeaps.Net/Pages/Login.cshtml.cs @@ -20,7 +20,7 @@ public class LoginModel(AuthService authService) : PageModel public string? SuccessMessage { get; set; } public string? WarningMessage { get; set; } - public void OnGet([FromQuery] string? registered, [FromQuery] string? verified) + public void OnGet([FromQuery] string? registered, [FromQuery] string? verified, [FromQuery] string? reset) { // Check if user is already logged in var userId = HttpContext.Session.GetString("UserId"); @@ -41,6 +41,12 @@ public class LoginModel(AuthService authService) : PageModel { SuccessMessage = "Email verified! You can now sign in."; } + + // Show success message if password was just reset + if (reset == "true") + { + SuccessMessage = "Your password has been reset. You can now sign in with your new password."; + } } public async Task OnPostAsync() diff --git a/Media.JoshHeaps.Net/Pages/LoginHelp.cshtml b/Media.JoshHeaps.Net/Pages/LoginHelp.cshtml new file mode 100644 index 0000000..7df47af --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/LoginHelp.cshtml @@ -0,0 +1,102 @@ +@page +@model Media.JoshHeaps.Net.Pages.LoginHelpModel +@{ + ViewData["Title"] = "Login Help"; + Layout = "_Layout"; +} + +@section Styles { + +} + +@section Scripts { + +} + +
+
+ @if (Model.ShowResetForm) + { +
+

Reset Password

+

Enter your new password below

+
+ + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) + { +
+ @Model.ErrorMessage +
+ } + +
+ @Html.AntiForgeryToken() + + +
+ +
+ + +
+
+
+
+
+
+
+ +
+ +
+ + +
+
+
+ + +
+ } + else + { +
+

Forgot Password

+

Enter your email to receive a reset link

+
+ + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) + { +
+ @Model.ErrorMessage +
+ } + + @if (!string.IsNullOrEmpty(Model.SuccessMessage)) + { +
+ @Model.SuccessMessage +
+ } + +
+ @Html.AntiForgeryToken() + +
+ + +
+
+ + +
+ } + + +
+
diff --git a/Media.JoshHeaps.Net/Pages/LoginHelp.cshtml.cs b/Media.JoshHeaps.Net/Pages/LoginHelp.cshtml.cs new file mode 100644 index 0000000..f55031c --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/LoginHelp.cshtml.cs @@ -0,0 +1,111 @@ +using Media.JoshHeaps.Net.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Media.JoshHeaps.Net.Pages; + +public class LoginHelpModel(AuthService authService, EmailService emailService, ILogger logger) : PageModel +{ + [BindProperty] + public string Email { get; set; } = string.Empty; + + [BindProperty] + public string Token { get; set; } = string.Empty; + + [BindProperty] + public string NewPassword { get; set; } = string.Empty; + + [BindProperty] + public string ConfirmPassword { get; set; } = string.Empty; + + public string? ErrorMessage { get; set; } + public string? SuccessMessage { get; set; } + public bool ShowResetForm { get; set; } + + public async Task OnGetAsync([FromQuery] string? token) + { + // Redirect if already logged in + var userId = HttpContext.Session.GetString("UserId"); + if (!string.IsNullOrEmpty(userId)) + return Redirect("/Landing"); + + if (!string.IsNullOrEmpty(token)) + { + var (valid, error) = await authService.ValidatePasswordResetTokenAsync(token); + if (valid) + { + ShowResetForm = true; + Token = token; + } + else + { + ErrorMessage = error; + } + } + + return Page(); + } + + public async Task OnPostRequestResetAsync() + { + // Redirect if already logged in + var userId = HttpContext.Session.GetString("UserId"); + if (!string.IsNullOrEmpty(userId)) + return Redirect("/Landing"); + + if (string.IsNullOrWhiteSpace(Email)) + { + ErrorMessage = "Please enter your email address."; + return Page(); + } + + var (success, error, token, username) = await authService.RequestPasswordResetAsync(Email.Trim()); + + if (!success) + { + logger.LogError("Password reset request failed for {Email}: {Error}", Email, error); + } + + // Send email if we got a token back (user exists and is eligible) + if (token != null) + { + await emailService.SendPasswordResetEmailAsync(Email.Trim(), username ?? Email.Split('@')[0], token); + } + + // Always show the same message regardless of whether the email exists + SuccessMessage = "If an account exists with that email, you will receive a password reset link shortly."; + return Page(); + } + + public async Task OnPostResetPasswordAsync() + { + // Redirect if already logged in + var userId = HttpContext.Session.GetString("UserId"); + if (!string.IsNullOrEmpty(userId)) + return Redirect("/Landing"); + + if (string.IsNullOrWhiteSpace(NewPassword) || NewPassword.Length < 8) + { + ErrorMessage = "Password must be at least 8 characters."; + ShowResetForm = true; + return Page(); + } + + if (NewPassword != ConfirmPassword) + { + ErrorMessage = "Passwords do not match."; + ShowResetForm = true; + return Page(); + } + + var (success, error) = await authService.ResetPasswordAsync(Token, NewPassword); + + if (!success) + { + ErrorMessage = error; + return Page(); + } + + return Redirect("/Login?reset=true"); + } +} diff --git a/Media.JoshHeaps.Net/Services/AuthService.cs b/Media.JoshHeaps.Net/Services/AuthService.cs index 5106df3..340e11b 100644 --- a/Media.JoshHeaps.Net/Services/AuthService.cs +++ b/Media.JoshHeaps.Net/Services/AuthService.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using Media.JoshHeaps.Net; using Media.JoshHeaps.Net.Models; @@ -302,4 +304,173 @@ public class AuthService(DbExecutor db) new { userId, lastLogin = DateTime.UtcNow } ); } + + public static string GenerateSecureToken() + { + var bytes = RandomNumberGenerator.GetBytes(32); + return Convert.ToBase64String(bytes) + .Replace("+", "-") + .Replace("/", "_") + .TrimEnd('='); + } + + public static string HashToken(string token) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + public async Task<(bool Success, string? Error, string? Token, string? Username)> RequestPasswordResetAsync(string email) + { + try + { + var userRow = await db.ExecuteReaderAsync( + "SELECT id, username, is_active, locked_until FROM app.users WHERE email = @email", + reader => new + { + UserId = reader.GetInt64(0), + Username = reader.GetString(1), + IsActive = reader.GetBoolean(2), + LockedUntil = reader.IsDBNull(3) ? (DateTime?)null : reader.GetDateTime(3) + }, + new { email } + ); + + if (userRow == null) + { + // Artificial delay to prevent timing-based email enumeration + await Task.Delay(Random.Shared.Next(100, 300)); + return (true, null, null, null); + } + + // Silently succeed for inactive/locked accounts (don't reveal state) + if (!userRow.IsActive || + (userRow.LockedUntil.HasValue && userRow.LockedUntil.Value > DateTime.UtcNow)) + { + return (true, null, null, null); + } + + // Rate limit: max 3 requests per hour + var recentCount = await db.ExecuteAsync( + @"SELECT COUNT(*) FROM app.password_reset_tokens + WHERE user_id = @userId AND created_at > @cutoff", + new { userId = userRow.UserId, cutoff = DateTimeOffset.UtcNow.AddHours(-1) } + ); + + if (recentCount >= 3) + { + return (true, null, null, null); + } + + // Invalidate all existing unused tokens for this user + await db.ExecuteNonQueryAsync( + @"UPDATE app.password_reset_tokens + SET used_at = @now + WHERE user_id = @userId AND used_at IS NULL", + new { userId = userRow.UserId, now = DateTimeOffset.UtcNow } + ); + + // Generate and store new token + var token = GenerateSecureToken(); + var tokenHash = HashToken(token); + var expiresAt = DateTimeOffset.UtcNow.AddHours(1); + + await db.ExecuteNonQueryAsync( + @"INSERT INTO app.password_reset_tokens (user_id, token_hash, expires_at) + VALUES (@userId, @tokenHash, @expiresAt)", + new { userId = userRow.UserId, tokenHash, expiresAt } + ); + + return (true, null, token, userRow.Username); + } + catch (Exception ex) + { + return (false, $"Password reset request failed: {ex.Message}", null, null); + } + } + + public async Task<(bool Valid, string? Error)> ValidatePasswordResetTokenAsync(string token) + { + try + { + var tokenHash = HashToken(token); + + var tokenRow = await db.ExecuteReaderAsync( + @"SELECT expires_at, used_at FROM app.password_reset_tokens + WHERE token_hash = @tokenHash", + reader => new + { + ExpiresAt = reader.GetFieldValue(0), + UsedAt = reader.IsDBNull(1) ? (DateTimeOffset?)null : reader.GetFieldValue(1) + }, + new { tokenHash } + ); + + if (tokenRow == null) + return (false, "Invalid or expired reset link. Please request a new one."); + + if (tokenRow.UsedAt.HasValue) + return (false, "This reset link has already been used. Please request a new one."); + + if (tokenRow.ExpiresAt < DateTimeOffset.UtcNow) + return (false, "This reset link has expired. Please request a new one."); + + return (true, null); + } + catch (Exception ex) + { + return (false, $"Token validation failed: {ex.Message}"); + } + } + + public async Task<(bool Success, string? Error)> ResetPasswordAsync(string token, string newPassword) + { + try + { + var tokenHash = HashToken(token); + + var tokenRow = await db.ExecuteReaderAsync( + @"SELECT id, user_id, expires_at, used_at FROM app.password_reset_tokens + WHERE token_hash = @tokenHash", + reader => new + { + Id = reader.GetInt64(0), + UserId = reader.GetInt64(1), + ExpiresAt = reader.GetFieldValue(2), + UsedAt = reader.IsDBNull(3) ? (DateTimeOffset?)null : reader.GetFieldValue(3) + }, + new { tokenHash } + ); + + if (tokenRow == null) + return (false, "Invalid or expired reset link. Please request a new one."); + + if (tokenRow.UsedAt.HasValue) + return (false, "This reset link has already been used. Please request a new one."); + + if (tokenRow.ExpiresAt < DateTimeOffset.UtcNow) + return (false, "This reset link has expired. Please request a new one."); + + // Hash new password and update user + var passwordHash = HashPassword(newPassword); + await db.ExecuteNonQueryAsync( + @"UPDATE app.users + SET password_hash = @passwordHash, failed_login_attempts = 0, locked_until = NULL + WHERE id = @userId", + new { userId = tokenRow.UserId, passwordHash } + ); + + // Mark token as used + await db.ExecuteNonQueryAsync( + "UPDATE app.password_reset_tokens SET used_at = @now WHERE id = @tokenId", + new { tokenId = tokenRow.Id, now = DateTimeOffset.UtcNow } + ); + + return (true, null); + } + catch (Exception ex) + { + return (false, $"Password reset failed: {ex.Message}"); + } + } } diff --git a/Media.JoshHeaps.Net/Services/EmailService.cs b/Media.JoshHeaps.Net/Services/EmailService.cs index 29d7970..c39b980 100644 --- a/Media.JoshHeaps.Net/Services/EmailService.cs +++ b/Media.JoshHeaps.Net/Services/EmailService.cs @@ -107,7 +107,7 @@ If you didn't create an account, you can safely ignore this email. try { var appUrl = config["AppUrl"] ?? "http://localhost:5000"; - var resetUrl = $"{appUrl}/ResetPassword?token={resetToken}"; + var resetUrl = $"{appUrl}/LoginHelp?token={resetToken}"; var message = new MimeMessage(); message.From.Add(new MailboxAddress( diff --git a/Media.JoshHeaps.Net/wwwroot/css/auth.css b/Media.JoshHeaps.Net/wwwroot/css/auth.css index e7c7115..70fad61 100644 --- a/Media.JoshHeaps.Net/wwwroot/css/auth.css +++ b/Media.JoshHeaps.Net/wwwroot/css/auth.css @@ -152,6 +152,29 @@ display: block; } +.form-options { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} + +.form-options .checkbox-wrapper { + margin-bottom: 0; +} + +.forgot-password-link { + color: var(--text-secondary); + font-size: 13px; + text-decoration: none; + transition: color 0.2s ease; +} + +.forgot-password-link:hover { + color: var(--accent-primary); + text-decoration: underline; +} + .checkbox-wrapper { display: flex; align-items: center; diff --git a/Media.JoshHeaps.Net/wwwroot/js/auth.js b/Media.JoshHeaps.Net/wwwroot/js/auth.js index a36a8d3..f44f27a 100644 --- a/Media.JoshHeaps.Net/wwwroot/js/auth.js +++ b/Media.JoshHeaps.Net/wwwroot/js/auth.js @@ -336,9 +336,145 @@ function initRegisterForm() { }); } +// Password reset form validation +function initPasswordResetForm() { + const form = document.getElementById('resetPasswordForm'); + if (!form) return; + + const passwordInput = document.getElementById('newPassword'); + const confirmPasswordInput = document.getElementById('confirmPassword'); + + if (passwordInput) { + passwordInput.addEventListener('input', function() { + checkPasswordStrength(this.value); + if (this.value && validatePassword(this.value)) { + clearError(this); + } + + if (confirmPasswordInput && confirmPasswordInput.value) { + if (confirmPasswordInput.value === this.value) { + clearError(confirmPasswordInput); + } else { + showError(confirmPasswordInput, 'Passwords do not match'); + } + } + }); + + passwordInput.addEventListener('blur', function() { + if (!this.value) { + showError(this, 'Password is required'); + } else if (!validatePassword(this.value)) { + showError(this, 'Password must be at least 8 characters'); + } else { + clearError(this); + } + }); + } + + if (confirmPasswordInput) { + confirmPasswordInput.addEventListener('input', function() { + if (passwordInput && this.value === passwordInput.value) { + clearError(this); + } + }); + + confirmPasswordInput.addEventListener('blur', function() { + if (!this.value) { + showError(this, 'Please confirm your password'); + } else if (passwordInput && this.value !== passwordInput.value) { + showError(this, 'Passwords do not match'); + } else { + clearError(this); + } + }); + } + + form.addEventListener('submit', function(e) { + e.preventDefault(); + + let isValid = true; + + if (!passwordInput.value) { + showError(passwordInput, 'Password is required'); + isValid = false; + } else if (!validatePassword(passwordInput.value)) { + showError(passwordInput, 'Password must be at least 8 characters'); + isValid = false; + } else { + clearError(passwordInput); + } + + if (!confirmPasswordInput.value) { + showError(confirmPasswordInput, 'Please confirm your password'); + isValid = false; + } else if (confirmPasswordInput.value !== passwordInput.value) { + showError(confirmPasswordInput, 'Passwords do not match'); + isValid = false; + } else { + clearError(confirmPasswordInput); + } + + if (isValid) { + const submitBtn = form.querySelector('button[type="submit"]'); + submitBtn.disabled = true; + submitBtn.innerHTML = ' Resetting...'; + form.submit(); + } + }); +} + +// Request reset form validation +function initRequestResetForm() { + const form = document.getElementById('requestResetForm'); + if (!form) return; + + const emailInput = document.getElementById('email'); + + if (emailInput) { + emailInput.addEventListener('blur', function() { + if (!this.value.trim()) { + showError(this, 'Email is required'); + } else if (!validateEmail(this.value)) { + showError(this, 'Please enter a valid email address'); + } else { + clearError(this); + } + }); + + emailInput.addEventListener('input', function() { + if (this.value.trim() && validateEmail(this.value)) { + clearError(this); + } + }); + } + + form.addEventListener('submit', function(e) { + e.preventDefault(); + + if (!emailInput.value.trim()) { + showError(emailInput, 'Email is required'); + return; + } + + if (!validateEmail(emailInput.value)) { + showError(emailInput, 'Please enter a valid email address'); + return; + } + + clearError(emailInput); + + const submitBtn = form.querySelector('button[type="submit"]'); + submitBtn.disabled = true; + submitBtn.innerHTML = ' Sending...'; + form.submit(); + }); +} + // Initialize on page load document.addEventListener('DOMContentLoaded', function() { initPasswordToggles(); initLoginForm(); initRegisterForm(); + initPasswordResetForm(); + initRequestResetForm(); }); From cfd6bb530bdd04669dbf6124e9fcb51cc5f4a625 Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Thu, 12 Feb 2026 15:14:49 -0700 Subject: [PATCH 02/15] 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); From 91ef4f41ddc8ba7a374408e9577c9d87ac445a9e Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Fri, 13 Feb 2026 10:11:44 -0700 Subject: [PATCH 03/15] Make an all patients view. --- Media.JoshHeaps.Net/Api/MedicalDocsApi.cs | 84 +++----- .../027_medical_doctors_person_id.sql | 7 + Media.JoshHeaps.Net/Models/MedicalDoctor.cs | 1 + Media.JoshHeaps.Net/Models/TimelineEvent.cs | 1 + .../Services/MedicalAiService.cs | 38 +++- .../Services/MedicalDocsService.cs | 200 +++++++++++------- .../wwwroot/css/medical-docs.css | 20 ++ .../wwwroot/js/medical-docs/bills.js | 37 ++-- .../wwwroot/js/medical-docs/conditions.js | 9 +- .../wwwroot/js/medical-docs/doctors.js | 6 +- .../wwwroot/js/medical-docs/documents.js | 14 +- .../wwwroot/js/medical-docs/init.js | 6 +- .../wwwroot/js/medical-docs/people.js | 33 ++- .../wwwroot/js/medical-docs/prescriptions.js | 10 +- .../wwwroot/js/medical-docs/state.js | 6 + .../wwwroot/js/medical-docs/tabs.js | 14 +- .../wwwroot/js/medical-docs/timeline.js | 12 +- 17 files changed, 315 insertions(+), 183 deletions(-) create mode 100644 Media.JoshHeaps.Net/Database/027_medical_doctors_person_id.sql diff --git a/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs b/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs index 4aaaee8..6807e65 100644 --- a/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs +++ b/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs @@ -112,7 +112,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc [HttpGet("documents/search")] public async Task SearchDocuments( - [FromQuery] long personId, + [FromQuery] long? personId = null, [FromQuery] string? search = null, [FromQuery] string? classification = null, [FromQuery] string? documentType = null, @@ -128,30 +128,24 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); - - if (personId <= 0) - return BadRequest(new { error = "personId is required" }); - if (!await HasPersonAccess(userId.Value, personId)) 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; - var documents = await medicalDocsService.SearchDocumentsAsync(personId, search, classification, documentType, doctorId, tagId, conditionId, fromDate, toDate, aiProcessed, offset, limit); + var documents = await medicalDocsService.SearchDocumentsAsync(personId, search, classification, documentType, doctorId, tagId, conditionId, fromDate, toDate, aiProcessed, accessUserId: personId.HasValue ? null : userId, offset: offset, limit: limit); return Ok(documents); } [HttpGet("tags")] - public async Task GetPersonTags([FromQuery] long personId) + public async Task GetPersonTags([FromQuery] long? personId = null) { 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 (personId <= 0) - return BadRequest(new { error = "personId is required" }); - if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); - - var tags = await medicalDocsService.GetPersonTagsAsync(personId); + var tags = await medicalDocsService.GetPersonTagsAsync(personId, accessUserId: personId.HasValue ? null : userId); return Ok(tags); } @@ -334,13 +328,14 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc // --- Doctors (shared, no per-person access check) --- [HttpGet("doctors")] - public async Task GetDoctors() + public async Task GetDoctors([FromQuery] long? personId = null) { 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(); - var doctors = await medicalDocsService.GetDoctorsAsync(); + var doctors = await medicalDocsService.GetDoctorsAsync(personId, accessUserId: personId.HasValue ? null : userId); return Ok(doctors); } @@ -350,11 +345,12 @@ 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, request.PersonId)) return Forbid(); if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest(new { error = "Name is required" }); - var doctor = await medicalDocsService.CreateDoctorAsync(request.Name.Trim(), request.Specialty, request.Phone, request.Address, request.Notes); + var doctor = await medicalDocsService.CreateDoctorAsync(request.PersonId, request.Name.Trim(), request.Specialty, request.Phone, request.Address, request.Notes); if (doctor == null) return StatusCode(500, new { error = "Failed to create doctor" }); @@ -367,6 +363,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, "doctor", id)) return Forbid(); if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest(new { error = "Name is required" }); @@ -383,6 +380,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, "doctor", id)) return Forbid(); var success = await medicalDocsService.DeleteDoctorAsync(id); if (!success) return NotFound(new { error = "Doctor not found" }); @@ -393,17 +391,14 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc // --- Conditions --- [HttpGet("conditions")] - public async Task GetConditions([FromQuery] long personId) + public async Task GetConditions([FromQuery] long? personId = null) { 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 (personId <= 0) - return BadRequest(new { error = "personId is required" }); - if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); - - var conditions = await medicalDocsService.GetConditionsAsync(personId); + var conditions = await medicalDocsService.GetConditionsAsync(personId, accessUserId: personId.HasValue ? null : userId); return Ok(conditions); } @@ -461,17 +456,14 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc // --- Prescriptions --- [HttpGet("prescriptions")] - public async Task GetPrescriptions([FromQuery] long personId) + public async Task GetPrescriptions([FromQuery] long? personId = null) { 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 (personId <= 0) - return BadRequest(new { error = "personId is required" }); - if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); - - var prescriptions = await medicalDocsService.GetPrescriptionsAsync(personId); + var prescriptions = await medicalDocsService.GetPrescriptionsAsync(personId, accessUserId: personId.HasValue ? null : userId); return Ok(prescriptions); } @@ -572,17 +564,14 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc // --- Billing Providers --- [HttpGet("providers")] - public async Task GetProviders([FromQuery] long personId) + public async Task GetProviders([FromQuery] long? personId = null) { 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 (personId <= 0) - return BadRequest(new { error = "personId is required" }); - if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); - - var providers = await medicalDocsService.GetProvidersAsync(personId); + var providers = await medicalDocsService.GetProvidersAsync(personId, accessUserId: personId.HasValue ? null : userId); return Ok(providers); } @@ -686,17 +675,14 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc // --- Bills --- [HttpGet("bills")] - public async Task GetBills([FromQuery] long personId, [FromQuery] long? providerId = null) + public async Task GetBills([FromQuery] long? personId = null, [FromQuery] long? providerId = null) { 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 (personId <= 0) - return BadRequest(new { error = "personId is required" }); - if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); - - var bills = await medicalDocsService.GetBillsAsync(personId, providerId); + var bills = await medicalDocsService.GetBillsAsync(personId, providerId, accessUserId: personId.HasValue ? null : userId); return Ok(bills); } @@ -834,20 +820,17 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc // --- Timeline --- [HttpGet("timeline")] - public async Task GetTimeline([FromQuery] long personId, [FromQuery] int offset = 0, [FromQuery] int limit = 100) + public async Task GetTimeline([FromQuery] long? personId = null, [FromQuery] int offset = 0, [FromQuery] int limit = 100) { var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); - - if (personId <= 0) - return BadRequest(new { error = "personId is required" }); - if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); + if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid(); if (limit < 1 || limit > 200) limit = 100; if (offset < 0) offset = 0; - var events = await medicalDocsService.GetTimelineAsync(personId, offset, limit); + var events = await medicalDocsService.GetTimelineAsync(personId, accessUserId: personId.HasValue ? null : userId, offset: offset, limit: limit); return Ok(events); } @@ -889,17 +872,14 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc } [HttpGet("bills/summary")] - public async Task GetBillSummary([FromQuery] long personId) + public async Task GetBillSummary([FromQuery] long? personId = null) { 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 (personId <= 0) - return BadRequest(new { error = "personId is required" }); - if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); - - var summary = await medicalDocsService.GetBillSummaryAsync(personId); + var summary = await medicalDocsService.GetBillSummaryAsync(personId, accessUserId: personId.HasValue ? null : userId); return Ok(summary); } @@ -945,7 +925,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc public record CreatePersonRequest(string Name, DateTime? DateOfBirth = null, string? Notes = null); public record CreateNoteRequest(long PersonId, string Title, string? Description = null, DateTime? DocumentDate = null, string? Classification = null); public record UpdateDocumentRequest(string? Title = null, string? Description = null, DateTime? DocumentDate = null, string? Classification = null, long? DoctorId = null); -public record CreateDoctorRequest(string Name, string? Specialty = null, string? Phone = null, string? Address = null, string? Notes = null); +public record CreateDoctorRequest(long PersonId, string Name, string? Specialty = null, string? Phone = null, string? Address = null, string? Notes = null); public record CreateConditionRequest(long PersonId, string Name, DateTime? DiagnosedDate = null, string? Notes = null); public record UpdateConditionRequest(string Name, DateTime? DiagnosedDate = null, string? Notes = null, bool IsActive = true); public record CreatePrescriptionRequest(long PersonId, string MedicationName, string? Dosage = null, string? Frequency = null, long? DoctorId = null, DateTime? StartDate = null, string? Notes = null, string? RxNumber = null); diff --git a/Media.JoshHeaps.Net/Database/027_medical_doctors_person_id.sql b/Media.JoshHeaps.Net/Database/027_medical_doctors_person_id.sql new file mode 100644 index 0000000..5249524 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/027_medical_doctors_person_id.sql @@ -0,0 +1,7 @@ +-- Add person_id to medical_doctors to scope doctors per person + +ALTER TABLE app.medical_doctors ADD COLUMN IF NOT EXISTS person_id BIGINT REFERENCES app.medical_people(id); + +CREATE INDEX IF NOT EXISTS idx_medical_doctors_person_id ON app.medical_doctors(person_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_medical_doctors_person_name + ON app.medical_doctors(person_id, LOWER(name)) WHERE person_id IS NOT NULL; diff --git a/Media.JoshHeaps.Net/Models/MedicalDoctor.cs b/Media.JoshHeaps.Net/Models/MedicalDoctor.cs index d655513..50866cd 100644 --- a/Media.JoshHeaps.Net/Models/MedicalDoctor.cs +++ b/Media.JoshHeaps.Net/Models/MedicalDoctor.cs @@ -3,6 +3,7 @@ namespace Media.JoshHeaps.Net.Models; public class MedicalDoctor { public long Id { get; set; } + public long PersonId { get; set; } public string Name { get; set; } = string.Empty; public string? Specialty { get; set; } public string? Phone { get; set; } diff --git a/Media.JoshHeaps.Net/Models/TimelineEvent.cs b/Media.JoshHeaps.Net/Models/TimelineEvent.cs index b90af3d..30f606b 100644 --- a/Media.JoshHeaps.Net/Models/TimelineEvent.cs +++ b/Media.JoshHeaps.Net/Models/TimelineEvent.cs @@ -4,6 +4,7 @@ public class TimelineEvent { public string EventType { get; set; } = ""; public long Id { get; set; } + public long PersonId { get; set; } public string? Label { get; set; } public string? Detail { get; set; } public string? SubType { get; set; } diff --git a/Media.JoshHeaps.Net/Services/MedicalAiService.cs b/Media.JoshHeaps.Net/Services/MedicalAiService.cs index 9ddf4ac..1c465b9 100644 --- a/Media.JoshHeaps.Net/Services/MedicalAiService.cs +++ b/Media.JoshHeaps.Net/Services/MedicalAiService.cs @@ -123,7 +123,7 @@ public class MedicalAiService { try { - var doctor = await medicalDocsService.FindOrCreateDoctorByNameAsync(doctorName.Trim()); + var doctor = await medicalDocsService.FindOrCreateDoctorByNameAsync(doc.PersonId, doctorName.Trim(), this); doctorId = doctor?.Id; } catch (Exception ex) @@ -631,6 +631,42 @@ Consider abbreviations, slight misspellings, and variations (e.g., ""Intermounta } } + public async Task FuzzyMatchDoctorAsync(string extractedName, List existingDoctorNames) + { + try + { + var doctorList = string.Join("\n", existingDoctorNames.Select(n => $" - {n}")); + + var systemPrompt = @"You are matching a doctor name extracted from a medical document against a list of known doctors for a patient. Return ONLY a JSON object with: +- ""matchedName"": the exact string from the existing list that matches, or null if no match +- ""confidence"": ""high"", ""medium"", or ""low"" + +Consider abbreviations, titles, and variations (e.g., ""Dr. John Smith"" matches ""John Smith"", ""J. Smith MD"" matches ""John Smith"", ""Smith, John"" matches ""John Smith""). Only return a match with medium or high confidence."; + + var userPrompt = $"Extracted doctor name: \"{extractedName}\"\n\nExisting doctors:\n{doctorList}"; + + var response = await CallClaudeCliAsync(systemPrompt, userPrompt, HaikuModel); + if (response == null) return null; + + var json = ExtractJson(response); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var matchedName = root.TryGetProperty("matchedName", out var mn) && mn.ValueKind == JsonValueKind.String ? mn.GetString() : null; + var confidence = root.TryGetProperty("confidence", out var conf) ? conf.GetString() : "low"; + + if (matchedName != null && confidence != "low") + return matchedName; + + return null; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Fuzzy doctor matching failed for \"{ExtractedName}\"", extractedName); + return null; + } + } + public async Task FuzzyMatchConditionAsync(string extractedName, List existingConditionNames) { try diff --git a/Media.JoshHeaps.Net/Services/MedicalDocsService.cs b/Media.JoshHeaps.Net/Services/MedicalDocsService.cs index 4535444..cfbc42f 100644 --- a/Media.JoshHeaps.Net/Services/MedicalDocsService.cs +++ b/Media.JoshHeaps.Net/Services/MedicalDocsService.cs @@ -155,6 +155,7 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, var sql = resourceType switch { "document" => "SELECT person_id FROM app.medical_documents WHERE id = @id", + "doctor" => "SELECT person_id FROM app.medical_doctors 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", @@ -297,11 +298,14 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, } } - public async Task> SearchDocumentsAsync(long personId, string? search = null, string? classification = null, string? documentType = null, long? doctorId = null, long? tagId = null, long? conditionId = null, DateTime? fromDate = null, DateTime? toDate = null, bool? aiProcessed = null, int offset = 0, int limit = 50) + public async Task> SearchDocumentsAsync(long? personId = null, string? search = null, string? classification = null, string? documentType = null, long? doctorId = null, long? tagId = null, long? conditionId = null, DateTime? fromDate = null, DateTime? toDate = null, bool? aiProcessed = null, long? accessUserId = null, int offset = 0, int limit = 50) { try { - var conditions = new List { "person_id = @personId" }; + var personFilter = personId.HasValue + ? "person_id = @personId" + : "person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)"; + var conditions = new List { personFilter }; if (!string.IsNullOrWhiteSpace(search)) conditions.Add("(title ILIKE @search OR description ILIKE @search OR extracted_text ILIKE @search)"); @@ -339,7 +343,8 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, return await db.ExecuteListReaderAsync(query, MapDocument, new { - personId, + personId = personId ?? 0L, + accessUserId = accessUserId ?? 0L, search = !string.IsNullOrWhiteSpace(search) ? $"%{search}%" : (string?)null, classification, documentType, @@ -360,16 +365,20 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, } } - public async Task> GetPersonTagsAsync(long personId) + public async Task> GetPersonTagsAsync(long? personId = null, long? accessUserId = null) { try { + var personFilter = personId.HasValue + ? "d.person_id = @personId" + : "d.person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)"; + return await db.ExecuteListReaderAsync( - @"SELECT DISTINCT t.id, t.name, t.created_at + $@"SELECT DISTINCT t.id, t.name, t.created_at FROM app.medical_tags t JOIN app.medical_document_tags dt ON dt.tag_id = t.id JOIN app.medical_documents d ON dt.document_id = d.id - WHERE d.person_id = @personId + WHERE {personFilter} ORDER BY t.name", reader => new MedicalTag { @@ -377,7 +386,7 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, Name = reader.GetString(1), CreatedAt = reader.GetDateTime(2) }, - new { personId }); + new { personId = personId ?? 0L, accessUserId = accessUserId ?? 0L }); } catch (Exception ex) { @@ -497,39 +506,45 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, // --- Doctors --- - public async Task> GetDoctorsAsync() + public async Task> GetDoctorsAsync(long? personId = null, long? accessUserId = null) { try { + var personFilter = personId.HasValue + ? "person_id = @personId" + : "person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)"; + return await db.ExecuteListReaderAsync( - "SELECT id, name, specialty, phone, address, notes, created_at, updated_at FROM app.medical_doctors ORDER BY name", + $"SELECT id, person_id, name, specialty, phone, address, notes, created_at, updated_at FROM app.medical_doctors WHERE {personFilter} ORDER BY name", reader => new MedicalDoctor { Id = reader.GetInt64(0), - Name = reader.GetString(1), - Specialty = reader.IsDBNull(2) ? null : reader.GetString(2), - Phone = reader.IsDBNull(3) ? null : reader.GetString(3), - Address = reader.IsDBNull(4) ? null : reader.GetString(4), - Notes = reader.IsDBNull(5) ? null : reader.GetString(5), - CreatedAt = reader.GetDateTime(6), - UpdatedAt = reader.GetDateTime(7) - }); + PersonId = reader.GetInt64(1), + Name = reader.GetString(2), + Specialty = reader.IsDBNull(3) ? null : reader.GetString(3), + Phone = reader.IsDBNull(4) ? null : reader.GetString(4), + Address = reader.IsDBNull(5) ? null : reader.GetString(5), + Notes = reader.IsDBNull(6) ? null : reader.GetString(6), + CreatedAt = reader.GetDateTime(7), + UpdatedAt = reader.GetDateTime(8) + }, + new { personId = personId ?? 0L, accessUserId = accessUserId ?? 0L }); } catch (Exception ex) { - logger.LogError(ex, "Failed to get doctors"); + logger.LogError(ex, "Failed to get doctors for person {PersonId}", personId); return []; } } - public async Task CreateDoctorAsync(string name, string? specialty = null, string? phone = null, string? address = null, string? notes = null) + public async Task CreateDoctorAsync(long personId, string name, string? specialty = null, string? phone = null, string? address = null, string? notes = null) { try { var now = DateTime.UtcNow; return await db.ExecuteReaderAsync( - @"INSERT INTO app.medical_doctors (name, specialty, phone, address, notes, created_at, updated_at) - VALUES (@name, @specialty, @phone, @address, @notes, @createdAt, @updatedAt) + @"INSERT INTO app.medical_doctors (person_id, name, specialty, phone, address, notes, created_at, updated_at) + VALUES (@personId, @name, @specialty, @phone, @address, @notes, @createdAt, @updatedAt) RETURNING id, name, specialty, phone, address, notes, created_at, updated_at", reader => new MedicalDoctor { @@ -542,11 +557,11 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, CreatedAt = reader.GetDateTime(6), UpdatedAt = reader.GetDateTime(7) }, - new { name, specialty, phone, address, notes, createdAt = now, updatedAt = now }); + new { personId, name, specialty, phone, address, notes, createdAt = now, updatedAt = now }); } catch (Exception ex) { - logger.LogError(ex, "Failed to create doctor"); + logger.LogError(ex, "Failed to create doctor for person {PersonId}", personId); return null; } } @@ -610,12 +625,16 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, // --- Conditions --- - public async Task> GetConditionsAsync(long personId) + public async Task> GetConditionsAsync(long? personId = null, long? accessUserId = null) { try { + var personFilter = personId.HasValue + ? "person_id = @personId" + : "person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)"; + return await db.ExecuteListReaderAsync( - "SELECT id, person_id, name, diagnosed_date, notes, is_active, created_at, updated_at FROM app.medical_conditions WHERE person_id = @personId ORDER BY name", + $"SELECT id, person_id, name, diagnosed_date, notes, is_active, created_at, updated_at FROM app.medical_conditions WHERE {personFilter} ORDER BY name", reader => new MedicalCondition { Id = reader.GetInt64(0), @@ -627,7 +646,7 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, CreatedAt = reader.GetDateTime(6), UpdatedAt = reader.GetDateTime(7) }, - new { personId }); + new { personId = personId ?? 0L, accessUserId = accessUserId ?? 0L }); } catch (Exception ex) { @@ -698,17 +717,21 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, // --- Prescriptions --- - public async Task> GetPrescriptionsAsync(long personId) + public async Task> GetPrescriptionsAsync(long? personId = null, long? accessUserId = null) { try { + var personFilter = personId.HasValue + ? "p.person_id = @personId" + : "p.person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)"; + return await db.ExecuteListReaderAsync( - @"SELECT p.id, p.person_id, p.doctor_id, p.medication_name, p.dosage, p.frequency, p.is_active, p.start_date, p.end_date, p.notes, p.created_at, p.updated_at, p.rx_number, + $@"SELECT p.id, p.person_id, p.doctor_id, p.medication_name, p.dosage, p.frequency, p.is_active, p.start_date, p.end_date, p.notes, p.created_at, p.updated_at, p.rx_number, d.name AS doctor_name, (SELECT MAX(pk.pickup_date) FROM app.medical_prescription_pickups pk WHERE pk.prescription_id = p.id) AS last_pickup FROM app.medical_prescriptions p LEFT JOIN app.medical_doctors d ON p.doctor_id = d.id - WHERE p.person_id = @personId + WHERE {personFilter} ORDER BY p.medication_name", reader => new MedicalPrescription { @@ -728,7 +751,7 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, DoctorName = reader.IsDBNull(13) ? null : reader.GetString(13), LastPickupDate = reader.IsDBNull(14) ? null : reader.GetDateTime(14) }, - new { personId }); + new { personId = personId ?? 0L, accessUserId = accessUserId ?? 0L }); } catch (Exception ex) { @@ -990,12 +1013,13 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, } } - public async Task FindOrCreateDoctorByNameAsync(string doctorName) + public async Task FindOrCreateDoctorByNameAsync(long personId, string doctorName, MedicalAiService aiService) { try { + // Step 1: Exact match var existing = await db.ExecuteReaderAsync( - "SELECT id, name, specialty, phone, address, notes, created_at, updated_at FROM app.medical_doctors WHERE LOWER(name) = LOWER(@name) LIMIT 1", + "SELECT id, name, specialty, phone, address, notes, created_at, updated_at FROM app.medical_doctors WHERE person_id = @personId AND LOWER(name) = LOWER(@name) LIMIT 1", reader => new MedicalDoctor { Id = reader.GetInt64(0), @@ -1007,15 +1031,29 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, CreatedAt = reader.GetDateTime(6), UpdatedAt = reader.GetDateTime(7) }, - new { name = doctorName }); + new { personId, name = doctorName }); if (existing != null) return existing; - return await CreateDoctorAsync(doctorName); + // Step 2: AI fuzzy match + var allDoctors = await GetDoctorsAsync(personId); + if (allDoctors.Count > 0) + { + var existingNames = allDoctors.Select(d => d.Name).ToList(); + var matchedName = await aiService.FuzzyMatchDoctorAsync(doctorName, existingNames); + if (matchedName != null) + { + var matched = allDoctors.FirstOrDefault(d => string.Equals(d.Name, matchedName, StringComparison.OrdinalIgnoreCase)); + if (matched != null) return matched; + } + } + + // Step 3: Create new + return await CreateDoctorAsync(personId, doctorName); } catch (Exception ex) { - logger.LogError(ex, "Failed to find or create doctor by name \"{DoctorName}\"", doctorName); + logger.LogError(ex, "Failed to find or create doctor by name \"{DoctorName}\" for person {PersonId}", doctorName, personId); return null; } } @@ -1032,7 +1070,7 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, var docName = rxInfo.DoctorName ?? doctorName; if (!string.IsNullOrWhiteSpace(docName)) { - var doctor = await FindOrCreateDoctorByNameAsync(docName.Trim()); + var doctor = await FindOrCreateDoctorByNameAsync(personId, docName.Trim(), aiService); doctorId = doctor?.Id; } @@ -1198,18 +1236,22 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, // --- Billing Providers --- - public async Task> GetProvidersAsync(long personId) + public async Task> GetProvidersAsync(long? personId = null, long? accessUserId = null) { try { + var personFilter = personId.HasValue + ? "p.person_id = @personId" + : "p.person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)"; + return await db.ExecuteListReaderAsync( - @"SELECT p.id, p.name, p.person_id, p.notes, p.created_at, p.updated_at, + $@"SELECT p.id, p.name, p.person_id, p.notes, p.created_at, p.updated_at, COALESCE(SUM(b.total_amount), 0) AS total_charged, COALESCE((SELECT SUM(pp.amount) FROM app.medical_provider_payments pp WHERE pp.provider_id = p.id), 0) AS total_paid, COUNT(DISTINCT b.id) AS bill_count FROM app.medical_billing_providers p LEFT JOIN app.medical_bills b ON b.provider_id = p.id - WHERE p.person_id = @personId + WHERE {personFilter} GROUP BY p.id, p.name, p.person_id, p.notes, p.created_at, p.updated_at ORDER BY p.name", reader => new MedicalBillingProvider @@ -1224,7 +1266,7 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, TotalPaid = reader.GetDecimal(7), BillCount = reader.GetInt32(8) }, - new { personId }); + new { personId = personId ?? 0L, accessUserId = accessUserId ?? 0L }); } catch (Exception ex) { @@ -1480,10 +1522,13 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, // --- Bills --- - public async Task> GetBillsAsync(long personId, long? providerId = null) + public async Task> GetBillsAsync(long? personId = null, long? providerId = null, long? accessUserId = null) { try { + var personFilter = personId.HasValue + ? "b.person_id = @personId" + : "b.person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)"; var providerFilter = providerId.HasValue ? "AND b.provider_id = @providerId" : ""; var query = $@"SELECT b.id, b.person_id, b.total_amount, b.summary, b.category, b.bill_date, b.doctor_id, b.provider_id, b.source, b.created_at, b.updated_at, @@ -1496,10 +1541,10 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, FROM app.medical_bills b LEFT JOIN app.medical_doctors d ON b.doctor_id = d.id LEFT JOIN app.medical_billing_providers bp ON b.provider_id = bp.id - WHERE b.person_id = @personId {providerFilter} + WHERE {personFilter} {providerFilter} ORDER BY b.bill_date DESC NULLS LAST, b.created_at DESC"; - return await db.ExecuteListReaderAsync(query, MapBill, new { personId, providerId }); + return await db.ExecuteListReaderAsync(query, MapBill, new { personId = personId ?? 0L, accessUserId = accessUserId ?? 0L, providerId }); } catch (Exception ex) { @@ -1619,26 +1664,34 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, // --- Bill Summary --- - public async Task GetBillSummaryAsync(long personId) + public async Task GetBillSummaryAsync(long? personId = null, long? accessUserId = null) { try { + var personFilter = personId.HasValue + ? "b.person_id = @personId" + : "b.person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)"; + var providerPersonFilter = personId.HasValue + ? "prov.person_id = @personId" + : "prov.person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)"; + var filterParams = new { personId = personId ?? 0L, accessUserId = accessUserId ?? 0L }; + var totals = await db.ExecuteReaderAsync( - @"SELECT COALESCE(SUM(b.total_amount), 0), + $@"SELECT COALESCE(SUM(b.total_amount), 0), COALESCE((SELECT SUM(pp.amount) FROM app.medical_provider_payments pp JOIN app.medical_billing_providers prov ON pp.provider_id = prov.id - WHERE prov.person_id = @personId), 0) + WHERE {providerPersonFilter}), 0) FROM app.medical_bills b - WHERE b.person_id = @personId", + WHERE {personFilter}", reader => new { Charged = reader.GetDecimal(0), TotalPaid = reader.GetDecimal(1) }, - new { personId }); + filterParams); var byYear = await db.ExecuteListReaderAsync( - @"SELECT EXTRACT(YEAR FROM COALESCE(b.bill_date, b.created_at))::int AS year, + $@"SELECT EXTRACT(YEAR FROM COALESCE(b.bill_date, b.created_at))::int AS year, SUM(b.total_amount), COUNT(b.id) FROM app.medical_bills b - WHERE b.person_id = @personId + WHERE {personFilter} GROUP BY EXTRACT(YEAR FROM COALESCE(b.bill_date, b.created_at))::int ORDER BY year DESC", reader => new YearBreakdown @@ -1647,15 +1700,15 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, Total = reader.GetDecimal(1), Count = reader.GetInt32(2) }, - new { personId }); + filterParams); var byProvider = await db.ExecuteListReaderAsync( - @"SELECT COALESCE(prov.name, 'Unassigned'), + $@"SELECT COALESCE(prov.name, 'Unassigned'), COALESCE(SUM(b.total_amount), 0), COUNT(b.id) FROM app.medical_bills b LEFT JOIN app.medical_billing_providers prov ON b.provider_id = prov.id - WHERE b.person_id = @personId + WHERE {personFilter} GROUP BY COALESCE(prov.name, 'Unassigned') ORDER BY COALESCE(SUM(b.total_amount), 0) DESC", reader => new ProviderBreakdown @@ -1664,7 +1717,7 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, Total = reader.GetDecimal(1), Count = reader.GetInt32(2) }, - new { personId }); + filterParams); var charged = totals?.Charged ?? 0; var totalPaid = totals?.TotalPaid ?? 0; @@ -2284,30 +2337,34 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, // --- Timeline --- - public async Task> GetTimelineAsync(long personId, int offset = 0, int limit = 100) + public async Task> GetTimelineAsync(long? personId = null, long? accessUserId = null, int offset = 0, int limit = 100) { try { + var personFilter = personId.HasValue + ? "= @personId" + : "IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)"; + return await db.ExecuteListReaderAsync( - @"SELECT event_type, id, label, detail, sub_type, event_date, doctor_id, created_at + $@"SELECT event_type, id, person_id, label, detail, sub_type, event_date, doctor_id, created_at FROM ( - SELECT 'document' AS event_type, d.id, COALESCE(d.title, d.file_name) AS label, d.description AS detail, + SELECT 'document' AS event_type, d.id, d.person_id, COALESCE(d.title, d.file_name) AS label, d.description AS detail, d.classification AS sub_type, d.document_date AS event_date, d.doctor_id, d.created_at - FROM app.medical_documents d WHERE d.person_id = @personId + FROM app.medical_documents d WHERE d.person_id {personFilter} UNION ALL - SELECT 'condition', c.id, c.name, c.notes, + SELECT 'condition', c.id, c.person_id, c.name, c.notes, CASE WHEN c.is_active THEN 'active' ELSE 'resolved' END, c.diagnosed_date, NULL, c.created_at - FROM app.medical_conditions c WHERE c.person_id = @personId + FROM app.medical_conditions c WHERE c.person_id {personFilter} UNION ALL - SELECT 'prescription', p.id, p.medication_name, CONCAT_WS(' - ', p.dosage, p.frequency), + SELECT 'prescription', p.id, p.person_id, p.medication_name, CONCAT_WS(' - ', p.dosage, p.frequency), CASE WHEN p.is_active THEN 'active' ELSE 'ended' END, p.start_date, p.doctor_id, p.created_at - FROM app.medical_prescriptions p WHERE p.person_id = @personId + FROM app.medical_prescriptions p WHERE p.person_id {personFilter} UNION ALL - SELECT 'bill', b.id, b.summary, bp.name, + SELECT 'bill', b.id, b.person_id, b.summary, bp.name, b.category, b.bill_date, b.doctor_id, b.created_at FROM app.medical_bills b LEFT JOIN app.medical_billing_providers bp ON b.provider_id = bp.id - WHERE b.person_id = @personId + WHERE b.person_id {personFilter} ) AS timeline ORDER BY COALESCE(event_date, created_at) DESC LIMIT @limit OFFSET @offset", @@ -2315,14 +2372,15 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, { EventType = reader.GetString(0), Id = reader.GetInt64(1), - Label = reader.IsDBNull(2) ? null : reader.GetString(2), - Detail = reader.IsDBNull(3) ? null : reader.GetString(3), - SubType = reader.IsDBNull(4) ? null : reader.GetString(4), - EventDate = reader.IsDBNull(5) ? null : reader.GetDateTime(5), - DoctorId = reader.IsDBNull(6) ? null : reader.GetInt64(6), - CreatedAt = reader.GetDateTime(7) + PersonId = reader.GetInt64(2), + Label = reader.IsDBNull(3) ? null : reader.GetString(3), + Detail = reader.IsDBNull(4) ? null : reader.GetString(4), + SubType = reader.IsDBNull(5) ? null : reader.GetString(5), + EventDate = reader.IsDBNull(6) ? null : reader.GetDateTime(6), + DoctorId = reader.IsDBNull(7) ? null : reader.GetInt64(7), + CreatedAt = reader.GetDateTime(8) }, - new { personId, limit, offset }); + new { personId = personId ?? 0L, accessUserId = accessUserId ?? 0L, limit, offset }); } catch (Exception ex) { diff --git a/Media.JoshHeaps.Net/wwwroot/css/medical-docs.css b/Media.JoshHeaps.Net/wwwroot/css/medical-docs.css index 43b72e6..ad9c685 100644 --- a/Media.JoshHeaps.Net/wwwroot/css/medical-docs.css +++ b/Media.JoshHeaps.Net/wwwroot/css/medical-docs.css @@ -1527,6 +1527,26 @@ line-height: 1.6; } +/* ======================== */ +/* All-mode (read-only) */ +/* ======================== */ + +.all-mode .add-form-toggle { display: none; } +.all-mode .add-form-collapsible { display: none; } + +.person-badge { + display: inline-block; + padding: 1px 7px; + background: var(--accent-primary); + color: #fff; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.3px; + vertical-align: middle; + margin-right: 4px; +} + /* ======================== */ /* Responsive (<=768px) */ /* ======================== */ diff --git a/Media.JoshHeaps.Net/wwwroot/js/medical-docs/bills.js b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/bills.js index 7f36456..04024de 100644 --- a/Media.JoshHeaps.Net/wwwroot/js/medical-docs/bills.js +++ b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/bills.js @@ -4,11 +4,10 @@ // --- Providers --- app.loadBills = async function () { - if (!state.selectedPersonId) return; - + const personParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : ''; const [providersRes, summaryRes] = await Promise.all([ - fetch(`${app.API}/providers?personId=${state.selectedPersonId}`), - fetch(`${app.API}/bills/summary?personId=${state.selectedPersonId}`) + fetch(`${app.API}/providers${personParam}`), + fetch(`${app.API}/bills/summary${personParam}`) ]); if (providersRes.ok) { @@ -78,7 +77,8 @@ if (!container) return; // Fetch unassigned bills - const unassignedRes = await fetch(`${app.API}/bills?personId=${state.selectedPersonId}`); + const unassignedBillParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : ''; + const unassignedRes = await fetch(`${app.API}/bills${unassignedBillParam}`); let unassignedBills = []; if (unassignedRes.ok) { const allBills = await unassignedRes.json(); @@ -99,7 +99,7 @@
- ${app.escapeHtml(p.name)} + ${app.personBadge(p.personId)}${app.escapeHtml(p.name)} ${statusLabel}
@@ -164,8 +164,9 @@ const section = document.getElementById(`provider-details-${providerId}`); if (!section) return; + const billsParam = state.selectedPersonId ? `personId=${state.selectedPersonId}&` : ''; const [billsRes, paymentsRes] = await Promise.all([ - fetch(`${app.API}/bills?personId=${state.selectedPersonId}&providerId=${providerId}`), + fetch(`${app.API}/bills?${billsParam}providerId=${providerId}`), fetch(`${app.API}/providers/${providerId}/payments`) ]); @@ -213,7 +214,7 @@ ${docNames ? `
${docNames}
` : ''}