diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6755399..c05911d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -10,7 +10,8 @@ "Bash(cd:*)", "Bash(tree:*)", "Bash(del Index.cshtml Index.cshtml.cs)", - "Bash(dotnet build:*)" + "Bash(dotnet build:*)", + "Bash(find:*)" ], "deny": [], "ask": [] diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e2845ff --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,56 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Full-stack ASP.NET Core 8 media management application (Media.JoshHeaps.Net) with encrypted photo storage, folder organization with sharing, and knowledge graph visualization. PostgreSQL backend, Razor Pages frontend with vanilla JavaScript. + +## Build & Run + +```bash +# Restore and build +dotnet build Media.JoshHeaps.Net.sln + +# Run the app (HTTPS: localhost:7007, HTTP: localhost:5029) +dotnet run --project Media.JoshHeaps.Net + +# Run database migrations (requires psql on PATH) +./run-migrations.ps1 -ConnectionString "" +``` + +Sensitive config (DB connection string, JWT signing key, encryption key, email credentials) is stored in .NET User Secrets (ID: `1ee15d15-1d19-471d-8772-ce72aeaafbd3`). No test project exists. + +## Architecture + +### Backend Layers + +- **Api/** — REST controllers. Routes: `/api/auth/`, `/api/media/`, `/api/folder/`, `/api/folder-share/`, `/api/graph/` +- **Services/** — Business logic (AuthService, MediaService, FolderService, GraphService, EmailService, EncryptionService, UserService). Registered via DI in Program.cs. +- **Models/** — Data models shared between API and Razor Pages +- **Pages/** — Razor Pages for server-rendered UI. Protected pages inherit from `AuthenticatedPageModel` (session-based auth). + +### Authentication + +Dual auth scheme: **session cookies** for Razor Pages, **JWT Bearer** for API endpoints. Session timeout is 2 hours; JWT expiry is 30 days. Account locks after 5 failed login attempts for 15 minutes. + +### Database + +PostgreSQL via `DbExecutor` singleton — a custom async query executor using raw Npgsql (no ORM). Parameterized queries use reflection on anonymous objects. Migration scripts in `Database/` are numbered 001–011 and must run in order (each script's dependencies come before it). **All database changes must be backwards-compatible with the existing production database — never drop tables, columns, or alter data in ways that could cause data loss. Use additive migrations (ADD COLUMN, CREATE TABLE, etc.) and `IF NOT EXISTS` guards where appropriate.** + +### File Storage + +Media files are encrypted with AES-256-CBC before storage in `App_Data/media/{userId}/`. Each file gets a random IV. Files are decrypted on-demand when served. Max upload: 10MB, allowed types: JPEG, PNG, GIF, WEBP. + +### Frontend + +Razor Pages + vanilla JS + Bootstrap 5. Key JS files in `wwwroot/js/`: +- `gallery.js` / `folders.js` / `upload.js` / `drag-drop.js` — gallery and folder UI +- `folder-sharing.js` — sharing modal and permissions +- `network-graph.js` — D3-based graph visualization with community detection +- `context-menu.js` — right-click context menus +- `theme.js` — dark/light theme toggle + +### Key Dependencies + +Npgsql (PostgreSQL), BCrypt.Net-Next (password hashing), MailKit (email), SixLabors.ImageSharp (image processing), Microsoft.AspNetCore.Authentication.JwtBearer diff --git a/MEDICAL_DOCS_ROADMAP.md b/MEDICAL_DOCS_ROADMAP.md new file mode 100644 index 0000000..de931f6 --- /dev/null +++ b/MEDICAL_DOCS_ROADMAP.md @@ -0,0 +1,45 @@ +# Medical Documentation System — Roadmap + +A dedicated admin-only section for organizing medical documents (receipts, doctor notes, recorded conversations, lab results, etc.) for multiple family members. Uses Claude AI to auto-classify, tag, and extract structured data from uploaded documents. Completely separate from the existing media/gallery system. + +--- + +## Phase 1: Database Foundation & Core Document Management ✅ +Tables for people, documents, tags, doctors, conditions, prescriptions, costs. Basic CRUD service. Admin-gated Razor Page with file upload and plain-text note entry. + +## Phase 2: People & Document Browsing ✅ +UI for managing people (family members). Document list with filtering by person, type icons, download/preview. + +## Phase 3: Claude AI Integration ✅ +`MedicalAiService` using Claude API. On upload: OCR, text extraction, auto-classification, auto-tagging, structured data extraction. Manual transcript field for audio files. + +## Phase 4: Doctors, Conditions & Prescription Tracking ✅ +Doctor/condition management (global doctors, per-person conditions). Prescription tracking with doctor linkage, expandable pickup history, and "last pickup" display. Inline add/edit/delete for all entities. + +## Phase 5: AI CLI Migration ✅ +Replaced Claude HTTP API with `claude -p` CLI pipe mode. Sequential `Channel` background queue replaces fire-and-forget `Task.Run`. Rate limit detection parses reset time from CLI output and pauses the queue. Temp file approach for image/PDF OCR via CLI with `--allowedTools Read`. + +## Phase 6: Bills & Payments ✅ +Replaced the flat `medical_document_costs` model (which double/triple-counted AI-extracted line items) with a proper billing system. Bills represent unique charges; payments track money applied toward them (patient payments, insurance payments, adjustments, write-offs). Summary card shows out-of-pocket vs total charged. Bills support linked documents, expandable payment lists, and filter by paid/unpaid status. Old costs API endpoints preserved for backward compatibility with AI processing. + +## Phase 7: AI Bills Integration ✅ +Updated AI extraction prompt to create bills + payments instead of flat costs. On re-process: deletes AI-sourced bills/payments for document, re-creates (prevents duplicates). Smart matching: if extracted charge matches existing bill for same person (same amount, category, date within 30 days), links document instead of creating new. Removed `AddCostsAsync`, all costs CRUD methods, costs API endpoints, and `MedicalDocumentCost` model. DB table retained per policy. + +## Phase 8: Search & Filtering ⬅️ **Up Next** +Full-text search on extracted text. Filter by person, doctor, condition, tags, document type, date range. Combined filters. + +## Phase 9: AI-Enhanced Insights +Medical timeline per person. Visit prep summaries. Batch re-analysis when AI improves. + +## Phase 10: Polish & Hardening +Pagination/lazy-loading. Export (PDF summary, CSV costs). Mobile-responsive UI. + +--- + +## Feature requests (I'm writing these down for me. I may ask you to do these in the future, so please design any changes with the fact in mind that they may need to acommodate these) + +## Calendar +A calendar that's easy to navigate, and shows what documents are on each day. If I see the month of January, at the very least, I should see something on the calendar indicating which days in January a document is associated with. + +## Custom Colors +An easy way to customize colors instead of just having a light mode/dark mode. I still want to have light mode/dark mode as defaults, but custom colors should be an option as well. diff --git a/Media.JoshHeaps.Net/Api/AdminApi.cs b/Media.JoshHeaps.Net/Api/AdminApi.cs new file mode 100644 index 0000000..a1701e3 --- /dev/null +++ b/Media.JoshHeaps.Net/Api/AdminApi.cs @@ -0,0 +1,184 @@ +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +namespace Media.JoshHeaps.Net.Api; + +[ApiController] +[Route("api/admin")] +public class AdminApi(DbExecutor dbExecutor) : ControllerBase +{ + [HttpGet("users")] + public async Task GetUsers([FromQuery] int page = 1, [FromQuery] int pageSize = 20) + { + var authUserId = GetUserIdFromAuth(); + if (authUserId == null) return Unauthorized(); + + if (!await IsAdmin(authUserId.Value)) return Forbid(); + + if (page < 1) page = 1; + if (pageSize < 1 || pageSize > 100) pageSize = 20; + var offset = (page - 1) * pageSize; + + var totalCount = await dbExecutor.ExecuteAsync( + "SELECT COUNT(*) FROM app.users"); + + var users = await dbExecutor.ExecuteListReaderAsync( + @"SELECT u.id, u.username, u.email, u.is_active + FROM app.users u + ORDER BY u.id + LIMIT @PageSize OFFSET @Offset", + reader => new + { + Id = reader.GetInt64(0), + Username = reader.GetString(1), + Email = reader.GetString(2), + IsActive = reader.GetBoolean(3) + }, + new { PageSize = pageSize, Offset = offset }); + + // Fetch all roles for these users in one query using a subquery + var userRoles = users.Count > 0 + ? await dbExecutor.ExecuteListReaderAsync( + @"SELECT ur.user_id, r.id, r.name + FROM app.user_roles ur + JOIN app.roles r ON ur.role_id = r.id + WHERE ur.user_id IN ( + SELECT u.id FROM app.users u ORDER BY u.id LIMIT @PageSize OFFSET @Offset + )", + reader => new + { + UserId = reader.GetInt64(0), + RoleId = reader.GetInt64(1), + RoleName = reader.GetString(2) + }, + new { PageSize = pageSize, Offset = offset }) + : []; + + var result = users.Select(u => new + { + u.Id, + u.Username, + u.Email, + u.IsActive, + Roles = userRoles.Where(r => r.UserId == u.Id) + .Select(r => new { Id = r.RoleId, Name = r.RoleName }) + .ToList() + }); + + return Ok(new { users = result, totalCount }); + } + + [HttpGet("roles")] + public async Task GetRoles() + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + + if (!await IsAdmin(userId.Value)) return Forbid(); + + var roles = await dbExecutor.ExecuteListReaderAsync( + "SELECT id, name FROM app.roles ORDER BY id", + reader => new { Id = reader.GetInt64(0), Name = reader.GetString(1) }); + + return Ok(roles); + } + + [HttpPost("roles")] + public async Task CreateRole([FromBody] CreateRoleRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + + if (!await IsAdmin(userId.Value)) return Forbid(); + + if (string.IsNullOrWhiteSpace(request.Name)) + return BadRequest(new { error = "Role name is required" }); + + var name = request.Name.Trim().ToLowerInvariant(); + + var existing = await dbExecutor.ExecuteAsync( + "SELECT EXISTS(SELECT 1 FROM app.roles WHERE name = @Name)", + new { Name = name }); + + if (existing) + return BadRequest(new { error = "Role already exists" }); + + var role = await dbExecutor.ExecuteReaderAsync( + "INSERT INTO app.roles (name) VALUES (@Name) RETURNING id, name", + reader => new { Id = reader.GetInt64(0), Name = reader.GetString(1) }, + new { Name = name }); + + return Ok(role); + } + + [HttpPost("users/{targetUserId}/roles/{roleId}")] + public async Task AssignRole(long targetUserId, long roleId) + { + var adminId = GetUserIdFromAuth(); + if (adminId == null) return Unauthorized(); + + if (!await IsAdmin(adminId.Value)) return Forbid(); + + var userExists = await dbExecutor.ExecuteAsync( + "SELECT EXISTS(SELECT 1 FROM app.users WHERE id = @UserId)", + new { UserId = targetUserId }); + if (!userExists) return NotFound(new { error = "User not found" }); + + var roleExists = await dbExecutor.ExecuteAsync( + "SELECT EXISTS(SELECT 1 FROM app.roles WHERE id = @RoleId)", + new { RoleId = roleId }); + if (!roleExists) return NotFound(new { error = "Role not found" }); + + var alreadyAssigned = await dbExecutor.ExecuteAsync( + "SELECT EXISTS(SELECT 1 FROM app.user_roles WHERE user_id = @UserId AND role_id = @RoleId)", + new { UserId = targetUserId, RoleId = roleId }); + if (alreadyAssigned) return Ok(new { success = true }); + + await dbExecutor.ExecuteNonQueryAsync( + "INSERT INTO app.user_roles (user_id, role_id) VALUES (@UserId, @RoleId)", + new { UserId = targetUserId, RoleId = roleId }); + + return Ok(new { success = true }); + } + + [HttpDelete("users/{targetUserId}/roles/{roleId}")] + public async Task RemoveRole(long targetUserId, long roleId) + { + var adminId = GetUserIdFromAuth(); + if (adminId == null) return Unauthorized(); + + if (!await IsAdmin(adminId.Value)) return Forbid(); + + await dbExecutor.ExecuteNonQueryAsync( + "DELETE FROM app.user_roles WHERE user_id = @UserId AND role_id = @RoleId", + new { UserId = targetUserId, RoleId = roleId }); + + return Ok(new { success = true }); + } + + private async Task IsAdmin(long userId) + { + return await dbExecutor.ExecuteAsync( + "SELECT EXISTS(SELECT 1 FROM app.user_roles ur JOIN app.roles r ON ur.role_id = r.id WHERE ur.user_id = @UserId AND r.name = 'admin')", + new { UserId = userId }); + } + + private long? GetUserIdFromAuth() + { + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId)) + { + return jwtUserId; + } + + var userIdString = HttpContext.Session.GetString("UserId"); + if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId)) + { + return sessionUserId; + } + + return null; + } +} + +public record CreateRoleRequest(string Name); diff --git a/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs b/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs new file mode 100644 index 0000000..a5e1caf --- /dev/null +++ b/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs @@ -0,0 +1,853 @@ +using Media.JoshHeaps.Net.Services; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +namespace Media.JoshHeaps.Net.Api; + +[ApiController] +[Route("api/medical-docs")] +public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDocsService, MedicalAiService medicalAiService) : ControllerBase +{ + // --- People --- + + [HttpGet("people")] + public async Task GetPeople() + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + var people = await medicalDocsService.GetPeopleAsync(); + return Ok(people); + } + + [HttpPost("people")] + public async Task CreatePerson([FromBody] CreatePersonRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + if (string.IsNullOrWhiteSpace(request.Name)) + return BadRequest(new { error = "Name is required" }); + + var person = await medicalDocsService.CreatePersonAsync(request.Name.Trim(), request.DateOfBirth, request.Notes); + if (person == null) + return StatusCode(500, new { error = "Failed to create person" }); + + return Ok(person); + } + + // --- Documents --- + + [HttpGet("documents")] + public async Task GetDocuments([FromQuery] long? personId, [FromQuery] int offset = 0, [FromQuery] int limit = 50) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + if (limit < 1 || limit > 100) limit = 50; + if (offset < 0) offset = 0; + + var documents = await medicalDocsService.GetDocumentsAsync(personId, offset, limit); + return Ok(documents); + } + + [HttpGet("documents/search")] + public async Task SearchDocuments( + [FromQuery] long personId, + [FromQuery] string? search = null, + [FromQuery] string? classification = null, + [FromQuery] string? documentType = null, + [FromQuery] long? doctorId = null, + [FromQuery] long? tagId = null, + [FromQuery] long? conditionId = null, + [FromQuery] DateTime? fromDate = null, + [FromQuery] DateTime? toDate = null, + [FromQuery] bool? aiProcessed = null, + [FromQuery] int offset = 0, + [FromQuery] int limit = 50) + { + 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 (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); + return Ok(documents); + } + + [HttpGet("tags")] + public async Task GetPersonTags([FromQuery] long personId) + { + 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" }); + + var tags = await medicalDocsService.GetPersonTagsAsync(personId); + return Ok(tags); + } + + [HttpPost("documents/upload")] + [RequestSizeLimit(52_428_800)] // 50MB + public async Task UploadDocument([FromForm] long personId, [FromForm] string? title, [FromForm] string? description, [FromForm] DateTime? documentDate, [FromForm] string? classification, IFormFile file) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + if (file == null || file.Length == 0) + return BadRequest(new { error = "No file provided" }); + + var doc = await medicalDocsService.SaveDocumentAsync(personId, file, title, description, documentDate, classification); + if (doc == null) + return StatusCode(500, new { error = "Failed to save document" }); + + medicalAiService.EnqueueProcessing(doc.Id); + + return Ok(doc); + } + + [HttpPost("documents/note")] + public async Task CreateNote([FromBody] CreateNoteRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + if (request.PersonId <= 0) + return BadRequest(new { error = "Person is required" }); + if (string.IsNullOrWhiteSpace(request.Title)) + return BadRequest(new { error = "Title is required" }); + + var doc = await medicalDocsService.SaveNoteAsync(request.PersonId, request.Title.Trim(), request.Description ?? "", request.DocumentDate, request.Classification); + if (doc == null) + return StatusCode(500, new { error = "Failed to create note" }); + + medicalAiService.EnqueueProcessing(doc.Id); + + return Ok(doc); + } + + [HttpGet("documents/{id}")] + public async Task GetDocument(long id) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + var doc = await medicalDocsService.GetDocumentByIdAsync(id); + if (doc == null) return NotFound(new { error = "Document not found" }); + + return Ok(doc); + } + + [HttpGet("documents/{id}/download")] + public async Task DownloadDocument(long id) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + var doc = await medicalDocsService.GetDocumentByIdAsync(id); + if (doc == null) return NotFound(new { error = "Document not found" }); + + if (doc.DocumentType != "file") + return BadRequest(new { error = "Cannot download a note" }); + + var data = await medicalDocsService.GetDecryptedDocumentDataAsync(id); + if (data == null) return NotFound(new { error = "File not found" }); + + return File(data, doc.MimeType ?? "application/octet-stream", doc.FileName); + } + + [HttpPut("documents/{id}")] + public async Task UpdateDocument(long id, [FromBody] UpdateDocumentRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) 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" }); + + return Ok(new { success = true }); + } + + [HttpDelete("documents/{id}")] + public async Task DeleteDocument(long id) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + var success = await medicalDocsService.DeleteDocumentAsync(id); + if (!success) return NotFound(new { error = "Document not found" }); + + return Ok(new { success = true }); + } + + // --- AI Processing --- + + [HttpPost("documents/{id}/process")] + public async Task ProcessDocument(long id) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + var doc = await medicalDocsService.GetDocumentByIdAsync(id); + if (doc == null) return NotFound(new { error = "Document not found" }); + + medicalAiService.EnqueueProcessing(id); + + return Ok(new { success = true, message = "AI processing started" }); + } + + [HttpPost("documents/process-all")] + public async Task ProcessAllDocuments() + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + var unprocessedIds = await medicalDocsService.GetUnprocessedDocumentIdsAsync(); + + foreach (var docId in unprocessedIds) + { + medicalAiService.EnqueueProcessing(docId); + } + + return Ok(new { success = true, queued = unprocessedIds.Count }); + } + + [HttpPost("documents/process-batch")] + public async Task ProcessBatch([FromBody] ProcessBatchRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + if (request.DocumentIds == null || request.DocumentIds.Count == 0) + return BadRequest(new { error = "No document IDs provided" }); + + var queued = 0; + foreach (var docId in request.DocumentIds) + { + var doc = await medicalDocsService.GetDocumentByIdAsync(docId); + if (doc != null) + { + medicalAiService.EnqueueProcessing(docId); + queued++; + } + } + + return Ok(new { queued }); + } + + [HttpGet("documents/{id}/tags")] + public async Task GetDocumentTags(long id) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + var tags = await medicalDocsService.GetDocumentTagsAsync(id); + return Ok(tags); + } + + // --- Doctors --- + + [HttpGet("doctors")] + public async Task GetDoctors() + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + var doctors = await medicalDocsService.GetDoctorsAsync(); + return Ok(doctors); + } + + [HttpPost("doctors")] + public async Task CreateDoctor([FromBody] CreateDoctorRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) 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); + if (doctor == null) + return StatusCode(500, new { error = "Failed to create doctor" }); + + return Ok(doctor); + } + + [HttpPut("doctors/{id}")] + public async Task UpdateDoctor(long id, [FromBody] CreateDoctorRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + if (string.IsNullOrWhiteSpace(request.Name)) + return BadRequest(new { error = "Name is required" }); + + var success = await medicalDocsService.UpdateDoctorAsync(id, request.Name.Trim(), request.Specialty, request.Phone, request.Address, request.Notes); + if (!success) return NotFound(new { error = "Doctor not found" }); + + return Ok(new { success = true }); + } + + [HttpDelete("doctors/{id}")] + public async Task DeleteDoctor(long id) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + var success = await medicalDocsService.DeleteDoctorAsync(id); + if (!success) return NotFound(new { error = "Doctor not found" }); + + return Ok(new { success = true }); + } + + // --- Conditions --- + + [HttpGet("conditions")] + public async Task GetConditions([FromQuery] long personId) + { + 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" }); + + var conditions = await medicalDocsService.GetConditionsAsync(personId); + return Ok(conditions); + } + + [HttpPost("conditions")] + public async Task CreateCondition([FromBody] CreateConditionRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + if (request.PersonId <= 0) + return BadRequest(new { error = "Person is required" }); + if (string.IsNullOrWhiteSpace(request.Name)) + return BadRequest(new { error = "Name is required" }); + + var condition = await medicalDocsService.CreateConditionAsync(request.PersonId, request.Name.Trim(), request.DiagnosedDate, request.Notes); + if (condition == null) + return StatusCode(500, new { error = "Failed to create condition" }); + + return Ok(condition); + } + + [HttpPut("conditions/{id}")] + public async Task UpdateCondition(long id, [FromBody] UpdateConditionRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + if (string.IsNullOrWhiteSpace(request.Name)) + return BadRequest(new { error = "Name is required" }); + + var success = await medicalDocsService.UpdateConditionAsync(id, request.Name.Trim(), request.DiagnosedDate, request.Notes, request.IsActive); + if (!success) return NotFound(new { error = "Condition not found" }); + + return Ok(new { success = true }); + } + + [HttpDelete("conditions/{id}")] + public async Task DeleteCondition(long id) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + var success = await medicalDocsService.DeleteConditionAsync(id); + if (!success) return NotFound(new { error = "Condition not found" }); + + return Ok(new { success = true }); + } + + // --- Prescriptions --- + + [HttpGet("prescriptions")] + public async Task GetPrescriptions([FromQuery] long personId) + { + 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" }); + + var prescriptions = await medicalDocsService.GetPrescriptionsAsync(personId); + return Ok(prescriptions); + } + + [HttpPost("prescriptions")] + public async Task CreatePrescription([FromBody] CreatePrescriptionRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + if (request.PersonId <= 0) + return BadRequest(new { error = "Person is required" }); + if (string.IsNullOrWhiteSpace(request.MedicationName)) + return BadRequest(new { error = "Medication name is required" }); + + var prescription = await medicalDocsService.CreatePrescriptionAsync(request.PersonId, request.MedicationName.Trim(), request.Dosage, request.Frequency, request.DoctorId, request.StartDate, request.Notes, request.RxNumber?.Trim()); + if (prescription == null) + return StatusCode(500, new { error = "Failed to create prescription" }); + + return Ok(prescription); + } + + [HttpPut("prescriptions/{id}")] + public async Task UpdatePrescription(long id, [FromBody] UpdatePrescriptionRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + if (string.IsNullOrWhiteSpace(request.MedicationName)) + return BadRequest(new { error = "Medication name is required" }); + + var success = await medicalDocsService.UpdatePrescriptionAsync(id, request.MedicationName.Trim(), request.Dosage, request.Frequency, request.DoctorId, request.StartDate, request.EndDate, request.Notes, request.IsActive, request.RxNumber?.Trim()); + if (!success) return NotFound(new { error = "Prescription not found" }); + + return Ok(new { success = true }); + } + + [HttpDelete("prescriptions/{id}")] + public async Task DeletePrescription(long id) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + var success = await medicalDocsService.DeletePrescriptionAsync(id); + if (!success) return NotFound(new { error = "Prescription not found" }); + + return Ok(new { success = true }); + } + + // --- Pickups --- + + [HttpGet("prescriptions/{id}/pickups")] + public async Task GetPickups(long id) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + var pickups = await medicalDocsService.GetPickupsAsync(id); + return Ok(pickups); + } + + [HttpPost("prescriptions/{id}/pickups")] + public async Task CreatePickup(long id, [FromBody] CreatePickupRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + var pickup = await medicalDocsService.CreatePickupAsync(id, request.PickupDate, request.Quantity, request.Pharmacy, request.Cost, request.Notes); + if (pickup == null) + return StatusCode(500, new { error = "Failed to create pickup" }); + + return Ok(pickup); + } + + [HttpDelete("pickups/{id}")] + public async Task DeletePickup(long id) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + var success = await medicalDocsService.DeletePickupAsync(id); + if (!success) return NotFound(new { error = "Pickup not found" }); + + return Ok(new { success = true }); + } + + // --- Billing Providers --- + + [HttpGet("providers")] + public async Task GetProviders([FromQuery] long personId) + { + 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" }); + + var providers = await medicalDocsService.GetProvidersAsync(personId); + return Ok(providers); + } + + [HttpPost("providers")] + public async Task CreateProvider([FromBody] CreateProviderRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + if (request.PersonId <= 0) + return BadRequest(new { error = "Person is required" }); + if (string.IsNullOrWhiteSpace(request.Name)) + return BadRequest(new { error = "Name is required" }); + + var provider = await medicalDocsService.CreateProviderAsync(request.PersonId, request.Name.Trim(), request.Notes); + if (provider == null) + return StatusCode(500, new { error = "Failed to create provider" }); + + return Ok(provider); + } + + [HttpPut("providers/{id}")] + public async Task UpdateProvider(long id, [FromBody] UpdateProviderRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + if (string.IsNullOrWhiteSpace(request.Name)) + return BadRequest(new { error = "Name is required" }); + + var success = await medicalDocsService.UpdateProviderAsync(id, request.Name.Trim(), request.Notes); + if (!success) return NotFound(new { error = "Provider not found" }); + + return Ok(new { success = true }); + } + + [HttpDelete("providers/{id}")] + public async Task DeleteProvider(long id) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + var success = await medicalDocsService.DeleteProviderAsync(id); + if (!success) return NotFound(new { error = "Provider not found" }); + + return Ok(new { success = true }); + } + + // --- Provider Payments --- + + [HttpGet("providers/{id}/payments")] + public async Task GetProviderPayments(long id) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + var payments = await medicalDocsService.GetProviderPaymentsAsync(id); + return Ok(payments); + } + + [HttpPost("providers/{id}/payments")] + public async Task CreateProviderPayment(long id, [FromBody] CreateProviderPaymentRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + if (request.Amount <= 0) + return BadRequest(new { error = "Amount must be greater than 0" }); + + var payment = await medicalDocsService.CreateProviderPaymentAsync(id, request.Amount, request.PaymentDate, request.Description); + if (payment == null) + return StatusCode(500, new { error = "Failed to create payment" }); + + return Ok(payment); + } + + [HttpDelete("provider-payments/{id}")] + public async Task DeleteProviderPayment(long id) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + var success = await medicalDocsService.DeleteProviderPaymentAsync(id); + if (!success) return NotFound(new { error = "Payment not found" }); + + return Ok(new { success = true }); + } + + // --- Bills --- + + [HttpGet("bills")] + public async Task GetBills([FromQuery] long personId, [FromQuery] long? providerId = null) + { + 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" }); + + var bills = await medicalDocsService.GetBillsAsync(personId, providerId); + return Ok(bills); + } + + [HttpPost("bills")] + public async Task CreateBill([FromBody] CreateBillRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + if (request.PersonId <= 0) + return BadRequest(new { error = "Person is required" }); + if (request.TotalAmount <= 0) + return BadRequest(new { error = "Amount must be greater than 0" }); + + var bill = await medicalDocsService.CreateBillAsync(request.PersonId, request.TotalAmount, request.Summary, request.Category, request.BillDate, request.DoctorId, request.ProviderId); + if (bill == null) + return StatusCode(500, new { error = "Failed to create bill" }); + + return Ok(bill); + } + + [HttpPut("bills/{id}")] + public async Task UpdateBill(long id, [FromBody] UpdateBillRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + if (request.TotalAmount <= 0) + return BadRequest(new { error = "Amount must be greater than 0" }); + + var success = await medicalDocsService.UpdateBillAsync(id, request.TotalAmount, request.Summary, request.Category, request.BillDate, request.DoctorId, request.ProviderId); + if (!success) return NotFound(new { error = "Bill not found" }); + + return Ok(new { success = true }); + } + + [HttpDelete("bills/{id}")] + public async Task DeleteBill(long id) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + var success = await medicalDocsService.DeleteBillAsync(id); + if (!success) return NotFound(new { error = "Bill not found" }); + + return Ok(new { success = true }); + } + + [HttpPost("bills/{id}/documents")] + public async Task LinkDocumentToBill(long id, [FromBody] LinkDocumentRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + if (request.DocumentId <= 0) + return BadRequest(new { error = "Document is required" }); + + var success = await medicalDocsService.LinkDocumentToBillAsync(id, request.DocumentId); + if (!success) + return StatusCode(500, new { error = "Failed to link document" }); + + return Ok(new { success = true }); + } + + [HttpDelete("bills/{billId}/documents/{docId}")] + public async Task UnlinkDocumentFromBill(long billId, long docId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + var success = await medicalDocsService.UnlinkDocumentFromBillAsync(billId, docId); + if (!success) return NotFound(new { error = "Link not found" }); + + return Ok(new { success = true }); + } + + // --- Bill Charges --- + + [HttpGet("bills/{id}/charges")] + public async Task GetBillCharges(long id) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + var charges = await medicalDocsService.GetChargesAsync(id); + return Ok(charges); + } + + [HttpPost("bills/{id}/charges")] + public async Task CreateCharge(long id, [FromBody] CreateChargeRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + if (string.IsNullOrWhiteSpace(request.Description)) + return BadRequest(new { error = "Description is required" }); + if (request.Amount <= 0) + return BadRequest(new { error = "Amount must be greater than 0" }); + + var charge = await medicalDocsService.CreateChargeAsync(id, request.Description.Trim(), request.Amount); + if (charge == null) + return StatusCode(500, new { error = "Failed to create charge" }); + + return Ok(charge); + } + + [HttpDelete("bill-charges/{id}")] + public async Task DeleteCharge(long id) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + var success = await medicalDocsService.DeleteChargeAsync(id); + if (!success) return NotFound(new { error = "Charge not found" }); + + return Ok(new { success = true }); + } + + // --- Timeline --- + + [HttpGet("timeline")] + public async Task GetTimeline([FromQuery] long personId, [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 (limit < 1 || limit > 200) limit = 100; + if (offset < 0) offset = 0; + + var events = await medicalDocsService.GetTimelineAsync(personId, offset, limit); + return Ok(events); + } + + // --- Visit Prep --- + + [HttpGet("visit-prep")] + public async Task GetVisitPrep([FromQuery] long personId, [FromQuery] long doctorId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + if (personId <= 0 || doctorId <= 0) + return BadRequest(new { error = "personId and doctorId are required" }); + + var data = await medicalDocsService.GetVisitPrepAsync(personId, doctorId); + return Ok(data); + } + + [HttpPost("visit-prep/summary")] + public async Task GenerateVisitPrepSummary([FromBody] VisitPrepSummaryRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + if (request.PersonId <= 0 || request.DoctorId <= 0) + return BadRequest(new { error = "personId and doctorId are required" }); + + var data = await medicalDocsService.GetVisitPrepAsync(request.PersonId, request.DoctorId); + var doctor = await medicalDocsService.GetDoctorByIdAsync(request.DoctorId); + if (doctor == null) + return NotFound(new { error = "Doctor not found" }); + + var summary = await medicalAiService.GenerateVisitPrepSummaryAsync(doctor.Name, doctor.Specialty, data); + return Ok(new { summary }); + } + + [HttpGet("bills/summary")] + public async Task GetBillSummary([FromQuery] long personId) + { + 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" }); + + var summary = await medicalDocsService.GetBillSummaryAsync(personId); + return Ok(summary); + } + + // --- Auth helpers (same pattern as AdminApi) --- + + private async Task HasMedicalAccess(long userId) + { + return await dbExecutor.ExecuteAsync( + "SELECT EXISTS(SELECT 1 FROM app.user_roles ur JOIN app.roles r ON ur.role_id = r.id WHERE ur.user_id = @UserId AND r.name = 'medical')", + new { UserId = userId }); + } + + private long? GetUserIdFromAuth() + { + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId)) + { + return jwtUserId; + } + + var userIdString = HttpContext.Session.GetString("UserId"); + if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId)) + { + return sessionUserId; + } + + return null; + } +} + +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 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); +public record UpdatePrescriptionRequest(string MedicationName, string? Dosage = null, string? Frequency = null, long? DoctorId = null, DateTime? StartDate = null, DateTime? EndDate = null, string? Notes = null, bool IsActive = true, string? RxNumber = null); +public record CreatePickupRequest(DateTime PickupDate, string? Quantity = null, string? Pharmacy = null, decimal? Cost = null, string? Notes = null); +public record CreateProviderRequest(long PersonId, string Name, string? Notes = null); +public record UpdateProviderRequest(string Name, string? Notes = null); +public record CreateProviderPaymentRequest(decimal Amount, DateTime? PaymentDate = null, string? Description = null); +public record CreateBillRequest(long PersonId, decimal TotalAmount, string? Summary = null, string? Category = null, DateTime? BillDate = null, long? DoctorId = null, long? ProviderId = null); +public record UpdateBillRequest(decimal TotalAmount, string? Summary = null, string? Category = null, DateTime? BillDate = null, long? DoctorId = null, long? ProviderId = null); +public record LinkDocumentRequest(long DocumentId); +public record CreateChargeRequest(string Description, decimal Amount); +public record ProcessBatchRequest(List DocumentIds); +public record VisitPrepSummaryRequest(long PersonId, long DoctorId); diff --git a/Media.JoshHeaps.Net/Database/001_create_schema.sql b/Media.JoshHeaps.Net/Database/001_create_schema.sql new file mode 100644 index 0000000..2c0f019 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/001_create_schema.sql @@ -0,0 +1,2 @@ +CREATE SCHEMA IF NOT EXISTS app + AUTHORIZATION josh; \ No newline at end of file diff --git a/Media.JoshHeaps.Net/Database/002_create_login_table.sql b/Media.JoshHeaps.Net/Database/002_create_login_table.sql new file mode 100644 index 0000000..b420afb --- /dev/null +++ b/Media.JoshHeaps.Net/Database/002_create_login_table.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS app.users ( + id BIGSERIAL PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + username VARCHAR(50) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + email_verified BOOLEAN DEFAULT FALSE, + failed_login_attempts INTEGER DEFAULT 0, + locked_until TIMESTAMP NULL, + last_login TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_email ON app.users(email); +CREATE INDEX IF NOT EXISTS idx_username ON app.users(username); \ No newline at end of file diff --git a/Media.JoshHeaps.Net/Database/001_email_verification_tokens.sql b/Media.JoshHeaps.Net/Database/003_email_verification_tokens.sql similarity index 100% rename from Media.JoshHeaps.Net/Database/001_email_verification_tokens.sql rename to Media.JoshHeaps.Net/Database/003_email_verification_tokens.sql diff --git a/Media.JoshHeaps.Net/Database/002_user_profiles.sql b/Media.JoshHeaps.Net/Database/004_user_profiles.sql similarity index 100% rename from Media.JoshHeaps.Net/Database/002_user_profiles.sql rename to Media.JoshHeaps.Net/Database/004_user_profiles.sql diff --git a/Media.JoshHeaps.Net/Database/003_user_media.sql b/Media.JoshHeaps.Net/Database/005_user_media.sql similarity index 100% rename from Media.JoshHeaps.Net/Database/003_user_media.sql rename to Media.JoshHeaps.Net/Database/005_user_media.sql diff --git a/Media.JoshHeaps.Net/Database/004_folders.sql b/Media.JoshHeaps.Net/Database/006_folders.sql similarity index 100% rename from Media.JoshHeaps.Net/Database/004_folders.sql rename to Media.JoshHeaps.Net/Database/006_folders.sql diff --git a/Media.JoshHeaps.Net/Database/005_alter_user_media_add_folder.sql b/Media.JoshHeaps.Net/Database/007_alter_user_media_add_folder.sql similarity index 100% rename from Media.JoshHeaps.Net/Database/005_alter_user_media_add_folder.sql rename to Media.JoshHeaps.Net/Database/007_alter_user_media_add_folder.sql diff --git a/Media.JoshHeaps.Net/Database/006_folder_shares.sql b/Media.JoshHeaps.Net/Database/008_folder_shares.sql similarity index 100% rename from Media.JoshHeaps.Net/Database/006_folder_shares.sql rename to Media.JoshHeaps.Net/Database/008_folder_shares.sql diff --git a/Media.JoshHeaps.Net/Database/007_graphs.sql b/Media.JoshHeaps.Net/Database/009_graphs.sql similarity index 100% rename from Media.JoshHeaps.Net/Database/007_graphs.sql rename to Media.JoshHeaps.Net/Database/009_graphs.sql diff --git a/Media.JoshHeaps.Net/Database/008_graph_nodes.sql b/Media.JoshHeaps.Net/Database/010_graph_nodes.sql similarity index 100% rename from Media.JoshHeaps.Net/Database/008_graph_nodes.sql rename to Media.JoshHeaps.Net/Database/010_graph_nodes.sql diff --git a/Media.JoshHeaps.Net/Database/009_graph_edges.sql b/Media.JoshHeaps.Net/Database/011_graph_edges.sql similarity index 100% rename from Media.JoshHeaps.Net/Database/009_graph_edges.sql rename to Media.JoshHeaps.Net/Database/011_graph_edges.sql diff --git a/Media.JoshHeaps.Net/Database/012_user_roles.sql b/Media.JoshHeaps.Net/Database/012_user_roles.sql new file mode 100644 index 0000000..23892e2 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/012_user_roles.sql @@ -0,0 +1,20 @@ +-- Roles table for role-based access control +CREATE TABLE IF NOT EXISTS app.roles ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(50) UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Seed the admin role +INSERT INTO app.roles (name) VALUES ('admin') ON CONFLICT (name) DO NOTHING; + +-- User roles junction table +CREATE TABLE IF NOT EXISTS app.user_roles ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES app.users(id), + role_id BIGINT NOT NULL REFERENCES app.roles(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_user_role UNIQUE (user_id, role_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON app.user_roles(user_id); diff --git a/Media.JoshHeaps.Net/Database/013_medical_people.sql b/Media.JoshHeaps.Net/Database/013_medical_people.sql new file mode 100644 index 0000000..9653326 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/013_medical_people.sql @@ -0,0 +1,9 @@ +-- Medical people (family members, not tied to app users) +CREATE TABLE IF NOT EXISTS app.medical_people ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + date_of_birth DATE NULL, + notes TEXT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); diff --git a/Media.JoshHeaps.Net/Database/014_medical_doctors.sql b/Media.JoshHeaps.Net/Database/014_medical_doctors.sql new file mode 100644 index 0000000..f8f462e --- /dev/null +++ b/Media.JoshHeaps.Net/Database/014_medical_doctors.sql @@ -0,0 +1,11 @@ +-- Medical doctors +CREATE TABLE IF NOT EXISTS app.medical_doctors ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + specialty VARCHAR(255) NULL, + phone VARCHAR(50) NULL, + address TEXT NULL, + notes TEXT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); diff --git a/Media.JoshHeaps.Net/Database/015_medical_conditions.sql b/Media.JoshHeaps.Net/Database/015_medical_conditions.sql new file mode 100644 index 0000000..a7d1bc9 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/015_medical_conditions.sql @@ -0,0 +1,13 @@ +-- Medical conditions linked to people +CREATE TABLE IF NOT EXISTS app.medical_conditions ( + id BIGSERIAL PRIMARY KEY, + person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + diagnosed_date DATE NULL, + notes TEXT NULL, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_medical_conditions_person_id ON app.medical_conditions(person_id); diff --git a/Media.JoshHeaps.Net/Database/016_medical_documents.sql b/Media.JoshHeaps.Net/Database/016_medical_documents.sql new file mode 100644 index 0000000..cd97e72 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/016_medical_documents.sql @@ -0,0 +1,29 @@ +-- Core medical documents table +CREATE TABLE IF NOT EXISTS app.medical_documents ( + id BIGSERIAL PRIMARY KEY, + person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE, + document_type VARCHAR(10) NOT NULL DEFAULT 'file', -- 'file' or 'note' + file_name VARCHAR(255) NULL, + file_path VARCHAR(500) NULL, + file_size BIGINT NULL, + mime_type VARCHAR(100) NULL, + is_encrypted BOOLEAN DEFAULT true, + title VARCHAR(500) NULL, + description TEXT NULL, + document_date DATE NULL, -- the date OF the document + classification VARCHAR(100) NULL, -- receipt, lab_result, prescription, imaging, etc. + extracted_text TEXT NULL, + ai_processed BOOLEAN DEFAULT false, + ai_processed_at TIMESTAMP NULL, + ai_raw_response JSONB NULL, + doctor_id BIGINT NULL REFERENCES app.medical_doctors(id) ON DELETE SET NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_medical_documents_person_id ON app.medical_documents(person_id); +CREATE INDEX IF NOT EXISTS idx_medical_documents_doctor_id ON app.medical_documents(doctor_id); +CREATE INDEX IF NOT EXISTS idx_medical_documents_classification ON app.medical_documents(classification); +CREATE INDEX IF NOT EXISTS idx_medical_documents_document_date ON app.medical_documents(document_date); +CREATE INDEX IF NOT EXISTS idx_medical_documents_created_at ON app.medical_documents(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_medical_documents_ai_processed ON app.medical_documents(ai_processed); diff --git a/Media.JoshHeaps.Net/Database/017_medical_tags.sql b/Media.JoshHeaps.Net/Database/017_medical_tags.sql new file mode 100644 index 0000000..8c8cf6d --- /dev/null +++ b/Media.JoshHeaps.Net/Database/017_medical_tags.sql @@ -0,0 +1,16 @@ +-- Medical tags and document-tag junction +CREATE TABLE IF NOT EXISTS app.medical_tags ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS app.medical_document_tags ( + document_id BIGINT NOT NULL REFERENCES app.medical_documents(id) ON DELETE CASCADE, + tag_id BIGINT NOT NULL REFERENCES app.medical_tags(id) ON DELETE CASCADE, + source VARCHAR(10) NOT NULL DEFAULT 'manual', -- 'ai' or 'manual' + CONSTRAINT uq_medical_document_tag UNIQUE (document_id, tag_id) +); + +CREATE INDEX IF NOT EXISTS idx_medical_document_tags_document_id ON app.medical_document_tags(document_id); +CREATE INDEX IF NOT EXISTS idx_medical_document_tags_tag_id ON app.medical_document_tags(tag_id); diff --git a/Media.JoshHeaps.Net/Database/018_medical_prescriptions.sql b/Media.JoshHeaps.Net/Database/018_medical_prescriptions.sql new file mode 100644 index 0000000..0cf53e6 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/018_medical_prescriptions.sql @@ -0,0 +1,32 @@ +-- Medical prescriptions and pickup tracking +CREATE TABLE IF NOT EXISTS app.medical_prescriptions ( + id BIGSERIAL PRIMARY KEY, + person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE, + doctor_id BIGINT NULL REFERENCES app.medical_doctors(id) ON DELETE SET NULL, + medication_name VARCHAR(255) NOT NULL, + dosage VARCHAR(100) NULL, + frequency VARCHAR(100) NULL, + is_active BOOLEAN DEFAULT true, + start_date DATE NULL, + end_date DATE NULL, + notes TEXT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_medical_prescriptions_person_id ON app.medical_prescriptions(person_id); +CREATE INDEX IF NOT EXISTS idx_medical_prescriptions_doctor_id ON app.medical_prescriptions(doctor_id); + +CREATE TABLE IF NOT EXISTS app.medical_prescription_pickups ( + id BIGSERIAL PRIMARY KEY, + prescription_id BIGINT NOT NULL REFERENCES app.medical_prescriptions(id) ON DELETE CASCADE, + document_id BIGINT NULL REFERENCES app.medical_documents(id) ON DELETE SET NULL, + pickup_date DATE NOT NULL, + quantity VARCHAR(100) NULL, + pharmacy VARCHAR(255) NULL, + cost DECIMAL(10,2) NULL, + notes TEXT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_medical_prescription_pickups_prescription_id ON app.medical_prescription_pickups(prescription_id); diff --git a/Media.JoshHeaps.Net/Database/019_medical_document_costs.sql b/Media.JoshHeaps.Net/Database/019_medical_document_costs.sql new file mode 100644 index 0000000..8da9715 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/019_medical_document_costs.sql @@ -0,0 +1,16 @@ +-- Medical document costs +CREATE TABLE IF NOT EXISTS app.medical_document_costs ( + id BIGSERIAL PRIMARY KEY, + document_id BIGINT NOT NULL REFERENCES app.medical_documents(id) ON DELETE CASCADE, + person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE, + amount DECIMAL(10,2) NOT NULL, + cost_type VARCHAR(50) NULL, -- copay, deductible, out_of_pocket, etc. + category VARCHAR(50) NULL, -- office_visit, lab, pharmacy, etc. + cost_date DATE NULL, + description TEXT NULL, + source VARCHAR(10) NOT NULL DEFAULT 'manual', -- 'ai' or 'manual' + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_medical_document_costs_document_id ON app.medical_document_costs(document_id); +CREATE INDEX IF NOT EXISTS idx_medical_document_costs_person_id ON app.medical_document_costs(person_id); diff --git a/Media.JoshHeaps.Net/Database/020_medical_document_conditions.sql b/Media.JoshHeaps.Net/Database/020_medical_document_conditions.sql new file mode 100644 index 0000000..6ff9e7c --- /dev/null +++ b/Media.JoshHeaps.Net/Database/020_medical_document_conditions.sql @@ -0,0 +1,9 @@ +-- Junction table linking medical documents to conditions +CREATE TABLE IF NOT EXISTS app.medical_document_conditions ( + document_id BIGINT NOT NULL REFERENCES app.medical_documents(id) ON DELETE CASCADE, + condition_id BIGINT NOT NULL REFERENCES app.medical_conditions(id) ON DELETE CASCADE, + CONSTRAINT uq_medical_document_condition UNIQUE (document_id, condition_id) +); + +CREATE INDEX IF NOT EXISTS idx_medical_document_conditions_document_id ON app.medical_document_conditions(document_id); +CREATE INDEX IF NOT EXISTS idx_medical_document_conditions_condition_id ON app.medical_document_conditions(condition_id); diff --git a/Media.JoshHeaps.Net/Database/021_medical_bills.sql b/Media.JoshHeaps.Net/Database/021_medical_bills.sql new file mode 100644 index 0000000..ca68aa9 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/021_medical_bills.sql @@ -0,0 +1,36 @@ +-- Migration 021: Medical Bills & Payments +-- Replaces the flat medical_document_costs model with proper billing: +-- Bills (charges) with Payments (receipts) tracked against them. + +CREATE TABLE IF NOT EXISTS app.medical_bills ( + id BIGSERIAL PRIMARY KEY, + person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE, + total_amount DECIMAL(10,2) NOT NULL, + summary TEXT, + category VARCHAR(50), + bill_date DATE, + doctor_id BIGINT REFERENCES app.medical_doctors(id) ON DELETE SET NULL, + source VARCHAR(10) NOT NULL DEFAULT 'manual', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS app.medical_bill_documents ( + id BIGSERIAL PRIMARY KEY, + bill_id BIGINT NOT NULL REFERENCES app.medical_bills(id) ON DELETE CASCADE, + document_id BIGINT NOT NULL REFERENCES app.medical_documents(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(bill_id, document_id) +); + +CREATE TABLE IF NOT EXISTS app.medical_bill_payments ( + id BIGSERIAL PRIMARY KEY, + bill_id BIGINT NOT NULL REFERENCES app.medical_bills(id) ON DELETE CASCADE, + document_id BIGINT REFERENCES app.medical_documents(id) ON DELETE SET NULL, + amount DECIMAL(10,2) NOT NULL, + payment_type VARCHAR(30) NOT NULL, + payment_date DATE, + description TEXT, + source VARCHAR(10) NOT NULL DEFAULT 'manual', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); diff --git a/Media.JoshHeaps.Net/Database/022_medical_bill_charges.sql b/Media.JoshHeaps.Net/Database/022_medical_bill_charges.sql new file mode 100644 index 0000000..5316b2e --- /dev/null +++ b/Media.JoshHeaps.Net/Database/022_medical_bill_charges.sql @@ -0,0 +1,11 @@ +-- Migration 022: Bill Line Items (Charges) +-- Breaks down bill totals into individual named charges. + +CREATE TABLE IF NOT EXISTS app.medical_bill_charges ( + id BIGSERIAL PRIMARY KEY, + bill_id BIGINT NOT NULL REFERENCES app.medical_bills(id) ON DELETE CASCADE, + description TEXT NOT NULL, + amount DECIMAL(10,2) NOT NULL, + source VARCHAR(10) NOT NULL DEFAULT 'manual', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); diff --git a/Media.JoshHeaps.Net/Database/023_medical_billing_providers.sql b/Media.JoshHeaps.Net/Database/023_medical_billing_providers.sql new file mode 100644 index 0000000..fd48b29 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/023_medical_billing_providers.sql @@ -0,0 +1,28 @@ +-- 023: Medical billing providers +-- Providers are the top-level billing entity. Bills belong to a provider, payments go to a provider. + +CREATE TABLE IF NOT EXISTS app.medical_billing_providers ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE, + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_billing_providers_name_person + ON app.medical_billing_providers (LOWER(name), person_id); + +ALTER TABLE app.medical_bills ADD COLUMN IF NOT EXISTS provider_id BIGINT + REFERENCES app.medical_billing_providers(id) ON DELETE SET NULL; + +CREATE TABLE IF NOT EXISTS app.medical_provider_payments ( + id BIGSERIAL PRIMARY KEY, + provider_id BIGINT NOT NULL REFERENCES app.medical_billing_providers(id) ON DELETE CASCADE, + document_id BIGINT REFERENCES app.medical_documents(id) ON DELETE SET NULL, + amount DECIMAL(10,2) NOT NULL, + payment_date DATE, + description TEXT, + source VARCHAR(10) NOT NULL DEFAULT 'manual', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); diff --git a/Media.JoshHeaps.Net/Database/024_prescription_rx_number.sql b/Media.JoshHeaps.Net/Database/024_prescription_rx_number.sql new file mode 100644 index 0000000..7a84a70 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/024_prescription_rx_number.sql @@ -0,0 +1 @@ +ALTER TABLE app.medical_prescriptions ADD COLUMN IF NOT EXISTS rx_number VARCHAR(50) NULL; diff --git a/Media.JoshHeaps.Net/Models/MedicalBill.cs b/Media.JoshHeaps.Net/Models/MedicalBill.cs new file mode 100644 index 0000000..34682cc --- /dev/null +++ b/Media.JoshHeaps.Net/Models/MedicalBill.cs @@ -0,0 +1,21 @@ +namespace Media.JoshHeaps.Net.Models; + +public class MedicalBill +{ + public long Id { get; set; } + public long PersonId { get; set; } + public decimal TotalAmount { get; set; } + public string? Summary { get; set; } + public string? Category { get; set; } + public DateTime? BillDate { get; set; } + public long? DoctorId { get; set; } + public long? ProviderId { get; set; } + public string Source { get; set; } = "manual"; + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + + // Populated via JOIN, not stored in DB + public string? DoctorName { get; set; } + public string? ProviderName { get; set; } + public string? DocumentNames { get; set; } +} diff --git a/Media.JoshHeaps.Net/Models/MedicalBillCharge.cs b/Media.JoshHeaps.Net/Models/MedicalBillCharge.cs new file mode 100644 index 0000000..58329dc --- /dev/null +++ b/Media.JoshHeaps.Net/Models/MedicalBillCharge.cs @@ -0,0 +1,11 @@ +namespace Media.JoshHeaps.Net.Models; + +public class MedicalBillCharge +{ + public long Id { get; set; } + public long BillId { get; set; } + public string Description { get; set; } = ""; + public decimal Amount { get; set; } + public string Source { get; set; } = "manual"; + public DateTime CreatedAt { get; set; } +} diff --git a/Media.JoshHeaps.Net/Models/MedicalBillPayment.cs b/Media.JoshHeaps.Net/Models/MedicalBillPayment.cs new file mode 100644 index 0000000..96e1939 --- /dev/null +++ b/Media.JoshHeaps.Net/Models/MedicalBillPayment.cs @@ -0,0 +1,17 @@ +namespace Media.JoshHeaps.Net.Models; + +public class MedicalBillPayment +{ + public long Id { get; set; } + public long BillId { get; set; } + public long? DocumentId { get; set; } + public decimal Amount { get; set; } + public string PaymentType { get; set; } = string.Empty; + public DateTime? PaymentDate { get; set; } + public string? Description { get; set; } + public string Source { get; set; } = "manual"; + public DateTime CreatedAt { get; set; } + + // Populated via JOIN, not stored in DB + public string? DocumentName { get; set; } +} diff --git a/Media.JoshHeaps.Net/Models/MedicalBillingProvider.cs b/Media.JoshHeaps.Net/Models/MedicalBillingProvider.cs new file mode 100644 index 0000000..a77b291 --- /dev/null +++ b/Media.JoshHeaps.Net/Models/MedicalBillingProvider.cs @@ -0,0 +1,18 @@ +namespace Media.JoshHeaps.Net.Models; + +public class MedicalBillingProvider +{ + public long Id { get; set; } + public string Name { get; set; } = ""; + public long PersonId { get; set; } + public string? Notes { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + + // Populated via aggregation, not stored in DB + public decimal TotalCharged { get; set; } + public decimal TotalPaid { get; set; } + public int BillCount { get; set; } + + public decimal Balance => TotalCharged - TotalPaid; +} diff --git a/Media.JoshHeaps.Net/Models/MedicalCondition.cs b/Media.JoshHeaps.Net/Models/MedicalCondition.cs new file mode 100644 index 0000000..f1282cc --- /dev/null +++ b/Media.JoshHeaps.Net/Models/MedicalCondition.cs @@ -0,0 +1,13 @@ +namespace Media.JoshHeaps.Net.Models; + +public class MedicalCondition +{ + public long Id { get; set; } + public long PersonId { get; set; } + public string Name { get; set; } = string.Empty; + public DateTime? DiagnosedDate { get; set; } + public string? Notes { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/Media.JoshHeaps.Net/Models/MedicalDoctor.cs b/Media.JoshHeaps.Net/Models/MedicalDoctor.cs new file mode 100644 index 0000000..d655513 --- /dev/null +++ b/Media.JoshHeaps.Net/Models/MedicalDoctor.cs @@ -0,0 +1,13 @@ +namespace Media.JoshHeaps.Net.Models; + +public class MedicalDoctor +{ + public long Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Specialty { get; set; } + public string? Phone { get; set; } + public string? Address { get; set; } + public string? Notes { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/Media.JoshHeaps.Net/Models/MedicalDocument.cs b/Media.JoshHeaps.Net/Models/MedicalDocument.cs new file mode 100644 index 0000000..b9f8427 --- /dev/null +++ b/Media.JoshHeaps.Net/Models/MedicalDocument.cs @@ -0,0 +1,27 @@ +namespace Media.JoshHeaps.Net.Models; + +public class MedicalDocument +{ + public long Id { get; set; } + public long PersonId { get; set; } + public string DocumentType { get; set; } = "file"; // "file" or "note" + public string? FileName { get; set; } + public string? FilePath { get; set; } + public long? FileSize { get; set; } + public string? MimeType { get; set; } + public bool IsEncrypted { get; set; } = true; + public string? Title { get; set; } + public string? Description { get; set; } + public DateTime? DocumentDate { get; set; } + public string? Classification { get; set; } + public string? ExtractedText { get; set; } + public bool AiProcessed { get; set; } + public DateTime? AiProcessedAt { get; set; } + public string? AiRawResponse { get; set; } + public long? DoctorId { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + + // Convenience properties populated separately + public List Tags { get; set; } = []; +} diff --git a/Media.JoshHeaps.Net/Models/MedicalPerson.cs b/Media.JoshHeaps.Net/Models/MedicalPerson.cs new file mode 100644 index 0000000..14e9c25 --- /dev/null +++ b/Media.JoshHeaps.Net/Models/MedicalPerson.cs @@ -0,0 +1,11 @@ +namespace Media.JoshHeaps.Net.Models; + +public class MedicalPerson +{ + public long Id { get; set; } + public string Name { get; set; } = string.Empty; + public DateTime? DateOfBirth { get; set; } + public string? Notes { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/Media.JoshHeaps.Net/Models/MedicalPrescription.cs b/Media.JoshHeaps.Net/Models/MedicalPrescription.cs new file mode 100644 index 0000000..589a8e5 --- /dev/null +++ b/Media.JoshHeaps.Net/Models/MedicalPrescription.cs @@ -0,0 +1,22 @@ +namespace Media.JoshHeaps.Net.Models; + +public class MedicalPrescription +{ + public long Id { get; set; } + public long PersonId { get; set; } + public long? DoctorId { get; set; } + public string MedicationName { get; set; } = string.Empty; + public string? Dosage { get; set; } + public string? Frequency { get; set; } + public string? RxNumber { get; set; } + public bool IsActive { get; set; } = true; + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } + public string? Notes { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + + // Populated via JOIN, not stored in DB + public string? DoctorName { get; set; } + public DateTime? LastPickupDate { get; set; } +} diff --git a/Media.JoshHeaps.Net/Models/MedicalPrescriptionPickup.cs b/Media.JoshHeaps.Net/Models/MedicalPrescriptionPickup.cs new file mode 100644 index 0000000..f463b0c --- /dev/null +++ b/Media.JoshHeaps.Net/Models/MedicalPrescriptionPickup.cs @@ -0,0 +1,14 @@ +namespace Media.JoshHeaps.Net.Models; + +public class MedicalPrescriptionPickup +{ + public long Id { get; set; } + public long PrescriptionId { get; set; } + public long? DocumentId { get; set; } + public DateTime PickupDate { get; set; } + public string? Quantity { get; set; } + public string? Pharmacy { get; set; } + public decimal? Cost { get; set; } + public string? Notes { get; set; } + public DateTime CreatedAt { get; set; } +} diff --git a/Media.JoshHeaps.Net/Models/MedicalProviderPayment.cs b/Media.JoshHeaps.Net/Models/MedicalProviderPayment.cs new file mode 100644 index 0000000..5ee92f4 --- /dev/null +++ b/Media.JoshHeaps.Net/Models/MedicalProviderPayment.cs @@ -0,0 +1,13 @@ +namespace Media.JoshHeaps.Net.Models; + +public class MedicalProviderPayment +{ + public long Id { get; set; } + public long ProviderId { get; set; } + public long? DocumentId { get; set; } + public decimal Amount { get; set; } + public DateTime? PaymentDate { get; set; } + public string? Description { get; set; } + public string Source { get; set; } = "manual"; + public DateTime CreatedAt { get; set; } +} diff --git a/Media.JoshHeaps.Net/Models/MedicalTag.cs b/Media.JoshHeaps.Net/Models/MedicalTag.cs new file mode 100644 index 0000000..41f3cfb --- /dev/null +++ b/Media.JoshHeaps.Net/Models/MedicalTag.cs @@ -0,0 +1,8 @@ +namespace Media.JoshHeaps.Net.Models; + +public class MedicalTag +{ + public long Id { get; set; } + public string Name { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } +} diff --git a/Media.JoshHeaps.Net/Models/TimelineEvent.cs b/Media.JoshHeaps.Net/Models/TimelineEvent.cs new file mode 100644 index 0000000..b90af3d --- /dev/null +++ b/Media.JoshHeaps.Net/Models/TimelineEvent.cs @@ -0,0 +1,13 @@ +namespace Media.JoshHeaps.Net.Models; + +public class TimelineEvent +{ + public string EventType { get; set; } = ""; + public long Id { get; set; } + public string? Label { get; set; } + public string? Detail { get; set; } + public string? SubType { get; set; } + public DateTime? EventDate { get; set; } + public long? DoctorId { get; set; } + public DateTime CreatedAt { get; set; } +} diff --git a/Media.JoshHeaps.Net/Models/VisitPrepData.cs b/Media.JoshHeaps.Net/Models/VisitPrepData.cs new file mode 100644 index 0000000..882ce16 --- /dev/null +++ b/Media.JoshHeaps.Net/Models/VisitPrepData.cs @@ -0,0 +1,27 @@ +namespace Media.JoshHeaps.Net.Models; + +public class VisitPrepData +{ + public List RecentDocuments { get; set; } = []; + public List ActiveConditions { get; set; } = []; + public List ActivePrescriptions { get; set; } = []; + public List RecentBills { get; set; } = []; +} + +public class VisitPrepDocument +{ + public long Id { get; set; } + public string? Title { get; set; } + public string? FileName { get; set; } + public DateTime? DocumentDate { get; set; } + public string? Classification { get; set; } +} + +public class VisitPrepBill +{ + public long Id { get; set; } + public decimal TotalAmount { get; set; } + public string? Summary { get; set; } + public string? Category { get; set; } + public DateTime? BillDate { get; set; } +} diff --git a/Media.JoshHeaps.Net/Pages/Admin.cshtml b/Media.JoshHeaps.Net/Pages/Admin.cshtml new file mode 100644 index 0000000..48ecbde --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/Admin.cshtml @@ -0,0 +1,64 @@ +@page +@model Media.JoshHeaps.Net.Pages.AdminModel +@{ + ViewData["Title"] = "Admin"; + Layout = "_Layout"; +} + +@section Styles { + +} + +
+
+
+ + + + + + +

Admin

+
+
+ Profile + Logout +
+
+ +
+
+

Roles

+
+
+
+ + +
+
+ +
+
+

Users

+
+
+ + + + + + + + + + + +
IDUsernameRolesActions
+
+ +
+
+ +@section Scripts { + +} diff --git a/Media.JoshHeaps.Net/Pages/Admin.cshtml.cs b/Media.JoshHeaps.Net/Pages/Admin.cshtml.cs new file mode 100644 index 0000000..7fd447f --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/Admin.cshtml.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Media.JoshHeaps.Net.Pages +{ + public class AdminModel(DbExecutor dbExecutor) : AuthenticatedPageModel + { + private readonly DbExecutor _dbExecutor = dbExecutor; + + public async Task OnGetAsync() + { + RequireAuthentication(); + LoadUserSession(); + + var denied = await RequireRole("admin", _dbExecutor); + if (denied != null) return denied; + + return Page(); + } + } +} diff --git a/Media.JoshHeaps.Net/Pages/AuthenticatedPageModel.cs b/Media.JoshHeaps.Net/Pages/AuthenticatedPageModel.cs index 81d6bd9..c559bf8 100644 --- a/Media.JoshHeaps.Net/Pages/AuthenticatedPageModel.cs +++ b/Media.JoshHeaps.Net/Pages/AuthenticatedPageModel.cs @@ -5,6 +5,15 @@ namespace Media.JoshHeaps.Net.Pages; public abstract class AuthenticatedPageModel : PageModel { + protected async Task RequireRole(string role, DbExecutor dbExecutor) + { + var hasRole = await dbExecutor.ExecuteAsync( + "SELECT EXISTS(SELECT 1 FROM app.user_roles ur JOIN app.roles r ON ur.role_id = r.id WHERE ur.user_id = @UserId AND r.name = @Role)", + new { UserId, Role = role }); + + return hasRole ? null : NotFound(); + } + public long UserId { get; private set; } protected string Username { get; private set; } = string.Empty; protected string Email { get; private set; } = string.Empty; diff --git a/Media.JoshHeaps.Net/Pages/Landing.cshtml b/Media.JoshHeaps.Net/Pages/Landing.cshtml index d599859..1d72a2e 100644 --- a/Media.JoshHeaps.Net/Pages/Landing.cshtml +++ b/Media.JoshHeaps.Net/Pages/Landing.cshtml @@ -92,6 +92,66 @@ + + @if (Model.IsAdmin) + { + +
+
+
+ + + +
+
+
+

Admin

+

Site administration and management tools

+
+
+ +
+ + } + + @if (Model.HasMedicalRole) + { + +
+
+
+ + + + + + +
+
+
+

Medical Documents

+

Organize medical records, receipts, and notes for the family

+
+
+ +
+ }