diff --git a/MEDICAL_DOCS_ROADMAP.md b/MEDICAL_DOCS_ROADMAP.md new file mode 100644 index 0000000..769f7b9 --- /dev/null +++ b/MEDICAL_DOCS_ROADMAP.md @@ -0,0 +1,29 @@ +# 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: Financial Tracking ⬅️ **Up Next** +Receipt cost extraction and storage. Aggregation views by year, person, and category. + +## Phase 6: Search & Filtering +Full-text search on extracted text. Filter by person, doctor, condition, tags, document type, date range. Combined filters. + +## Phase 7: AI-Enhanced Insights +Medical timeline per person. Visit prep summaries. Batch re-analysis when AI improves. + +## Phase 8: Polish & Hardening +Background AI processing queue. Pagination/lazy-loading. Export (PDF summary, CSV costs). Mobile-responsive UI. diff --git a/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs b/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs new file mode 100644 index 0000000..d0046b5 --- /dev/null +++ b/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs @@ -0,0 +1,449 @@ +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); + } + + [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); + } + + [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 }); + } + + [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); + 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); + 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 }); + } + + // --- 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 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); +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); +public record CreatePickupRequest(DateTime PickupDate, string? Quantity = null, string? Pharmacy = null, decimal? Cost = null, string? Notes = null); 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/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/MedicalDocumentCost.cs b/Media.JoshHeaps.Net/Models/MedicalDocumentCost.cs new file mode 100644 index 0000000..9295c7e --- /dev/null +++ b/Media.JoshHeaps.Net/Models/MedicalDocumentCost.cs @@ -0,0 +1,15 @@ +namespace Media.JoshHeaps.Net.Models; + +public class MedicalDocumentCost +{ + public long Id { get; set; } + public long DocumentId { get; set; } + public long PersonId { get; set; } + public decimal Amount { get; set; } + public string? CostType { get; set; } + public string? Category { get; set; } + public DateTime? CostDate { 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/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..af8b93f --- /dev/null +++ b/Media.JoshHeaps.Net/Models/MedicalPrescription.cs @@ -0,0 +1,21 @@ +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 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/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/Pages/Landing.cshtml b/Media.JoshHeaps.Net/Pages/Landing.cshtml index 8f23e87..1d72a2e 100644 --- a/Media.JoshHeaps.Net/Pages/Landing.cshtml +++ b/Media.JoshHeaps.Net/Pages/Landing.cshtml @@ -119,6 +119,38 @@ + + } + + @if (Model.HasMedicalRole) + { + +
+
+
+ + + + + + +
+
+
+

Medical Documents

+

Organize medical records, receipts, and notes for the family

+
+
+ +
} diff --git a/Media.JoshHeaps.Net/Pages/Landing.cshtml.cs b/Media.JoshHeaps.Net/Pages/Landing.cshtml.cs index 7c6ae0b..c5c6b8d 100644 --- a/Media.JoshHeaps.Net/Pages/Landing.cshtml.cs +++ b/Media.JoshHeaps.Net/Pages/Landing.cshtml.cs @@ -5,6 +5,7 @@ namespace Media.JoshHeaps.Net.Pages public class LandingModel(DbExecutor dbExecutor) : AuthenticatedPageModel { public bool IsAdmin { get; set; } + public bool HasMedicalRole { get; set; } public async Task OnGetAsync() { @@ -15,6 +16,10 @@ namespace Media.JoshHeaps.Net.Pages "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 }); + HasMedicalRole = 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 }); + return Page(); } } diff --git a/Media.JoshHeaps.Net/Pages/MedicalDocs.cshtml b/Media.JoshHeaps.Net/Pages/MedicalDocs.cshtml new file mode 100644 index 0000000..1d93769 --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/MedicalDocs.cshtml @@ -0,0 +1,203 @@ +@page +@model Media.JoshHeaps.Net.Pages.MedicalDocsModel +@{ + ViewData["Title"] = "Medical Documents"; + Layout = "_Layout"; +} + +@section Styles { + +} + +
+
+
+ + + + + + +

Medical Documents

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

People

+ + +
+ + +
+ + + + +
+ + + + +
+ + +
+ +
+
+ + + +
+
+
+ + +
+ + +
+
+
+ + + + + +
+

Drag & drop files here or

+

Any file type, up to 50MB

+ +
+
+ + + + +
+
+
+ + +
+
+ + +
+ + +
+ +
+
+
+
+
+ + +
+
+ +
+
+
+ + + + +
+
+
+
+ + +
+
+ +
+
+
+ + + + + + +
+
+
+
+ + +
+
+ +
+
+
+ + + + + +
+
+
+
+
+
+
+
+ +@section Scripts { + +} diff --git a/Media.JoshHeaps.Net/Pages/MedicalDocs.cshtml.cs b/Media.JoshHeaps.Net/Pages/MedicalDocs.cshtml.cs new file mode 100644 index 0000000..3303bee --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/MedicalDocs.cshtml.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Media.JoshHeaps.Net.Pages +{ + public class MedicalDocsModel(DbExecutor dbExecutor) : AuthenticatedPageModel + { + private readonly DbExecutor _dbExecutor = dbExecutor; + + public async Task OnGetAsync() + { + RequireAuthentication(); + LoadUserSession(); + + var denied = await RequireRole("medical", _dbExecutor); + if (denied != null) return denied; + + return Page(); + } + } +} diff --git a/Media.JoshHeaps.Net/Program.cs b/Media.JoshHeaps.Net/Program.cs index 9a70816..ee5d36a 100644 --- a/Media.JoshHeaps.Net/Program.cs +++ b/Media.JoshHeaps.Net/Program.cs @@ -17,6 +17,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); // Add session support builder.Services.AddDistributedMemoryCache(); diff --git a/Media.JoshHeaps.Net/Services/MedicalAiService.cs b/Media.JoshHeaps.Net/Services/MedicalAiService.cs new file mode 100644 index 0000000..12578fb --- /dev/null +++ b/Media.JoshHeaps.Net/Services/MedicalAiService.cs @@ -0,0 +1,363 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Media.JoshHeaps.Net.Services; + +public class MedicalAiService +{ + private readonly HttpClient _httpClient; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly string? _apiKey; + + private const string HaikuModel = "claude-haiku-4-5-20251001"; + private const string SonnetModel = "claude-sonnet-4-5-20250929"; + + public MedicalAiService(IConfiguration configuration, IServiceScopeFactory scopeFactory, ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + _apiKey = configuration["Anthropic:ApiKey"]; + + _httpClient = new HttpClient + { + BaseAddress = new Uri("https://api.anthropic.com") + }; + _httpClient.DefaultRequestHeaders.Add("x-api-key", _apiKey ?? ""); + _httpClient.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01"); + } + + public void EnqueueProcessing(long documentId) + { + if (string.IsNullOrEmpty(_apiKey)) + { + _logger.LogWarning("Anthropic API key not configured, skipping AI processing for document {DocumentId}", documentId); + return; + } + + _ = Task.Run(async () => + { + try + { + using var scope = _scopeFactory.CreateScope(); + var medicalDocsService = scope.ServiceProvider.GetRequiredService(); + await ProcessDocumentAsync(documentId, medicalDocsService); + } + catch (Exception ex) + { + _logger.LogError(ex, "Background AI processing failed for document {DocumentId}", documentId); + } + }); + } + + private async Task ProcessDocumentAsync(long documentId, MedicalDocsService medicalDocsService) + { + _logger.LogInformation("Starting AI processing for document {DocumentId}", documentId); + + var doc = await medicalDocsService.GetDocumentByIdAsync(documentId); + if (doc == null) + { + _logger.LogWarning("Document {DocumentId} not found for AI processing", documentId); + return; + } + + var aiResponses = new Dictionary(); + + // Step 1: Text extraction + string? extractedText = null; + + if (doc.DocumentType == "note") + { + extractedText = doc.Description; + } + else if (doc.DocumentType == "file") + { + var fileData = await medicalDocsService.GetDecryptedDocumentDataAsync(documentId); + if (fileData != null && doc.MimeType != null) + { + extractedText = await ExtractTextAsync(fileData, doc.MimeType); + if (extractedText != null) + aiResponses["extraction"] = new { model = HaikuModel, text = extractedText }; + } + } + + if (string.IsNullOrWhiteSpace(extractedText)) + { + _logger.LogWarning("No text could be extracted from document {DocumentId}", documentId); + extractedText = doc.Title ?? doc.FileName ?? ""; + } + + // Step 2: Classification + tagging (Haiku) + string? classification = doc.Classification; + List tags = []; + + try + { + var classResult = await ClassifyAndTagAsync(extractedText); + if (classResult != null) + { + classification = classResult.Classification ?? classification; + tags = classResult.Tags ?? []; + aiResponses["classification"] = classResult; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Classification failed for document {DocumentId}", documentId); + } + + // Step 3: Structured data extraction (Sonnet) + StructuredExtractionResult? structuredData = null; + + try + { + structuredData = await ExtractStructuredDataAsync(extractedText, classification ?? "other"); + if (structuredData != null) + aiResponses["structured"] = structuredData; + } + catch (Exception ex) + { + _logger.LogError(ex, "Structured extraction failed for document {DocumentId}", documentId); + } + + // Step 4: Persist results + var rawResponse = JsonSerializer.Serialize(aiResponses, JsonOpts); + + await medicalDocsService.UpdateAiResultsAsync(documentId, extractedText, classification, rawResponse); + + if (tags.Count > 0) + await medicalDocsService.AddTagsAsync(documentId, tags); + + if (structuredData?.Costs is { Count: > 0 }) + await medicalDocsService.AddCostsAsync(documentId, doc.PersonId, structuredData.Costs); + + _logger.LogInformation("AI processing complete for document {DocumentId}: classification={Classification}, tags={TagCount}, costs={CostCount}", + documentId, classification, tags.Count, structuredData?.Costs?.Count ?? 0); + } + + private async Task ExtractTextAsync(byte[] fileData, string mimeType) + { + _logger.LogInformation("Extracting text via Haiku vision, mimeType={MimeType}, size={Size}KB", mimeType, fileData.Length / 1024); + + var isImage = mimeType.StartsWith("image/", StringComparison.OrdinalIgnoreCase); + var isPdf = mimeType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase); + + if (!isImage && !isPdf) + { + _logger.LogInformation("Unsupported mime type {MimeType} for vision extraction", mimeType); + return null; + } + + var base64Data = Convert.ToBase64String(fileData); + + var mediaType = isPdf ? "application/pdf" : mimeType; + var sourceType = isPdf ? "base64" : "base64"; + + var content = new List + { + new + { + type = "image", + source = new + { + type = sourceType, + media_type = mediaType, + data = base64Data + } + }, + new + { + type = "text", + text = "Extract all text from this medical document. Include all visible text, numbers, dates, names, and amounts. Preserve the structure and formatting as much as possible. If the document is handwritten, do your best to transcribe it. Return only the extracted text, no commentary." + } + }; + + var response = await CallClaudeAsync(HaikuModel, content, "You are an OCR assistant. Extract text from medical documents accurately and completely."); + + return response; + } + + private async Task ClassifyAndTagAsync(string text) + { + _logger.LogInformation("Classifying and tagging via Haiku"); + + var truncatedText = text.Length > 4000 ? text[..4000] : text; + + var content = new List + { + new + { + type = "text", + text = $"Analyze this medical document text and classify it.\n\nDocument text:\n{truncatedText}" + } + }; + + var systemPrompt = @"You are a medical document classifier. Analyze the document text and return a JSON object with: +- ""classification"": one of: receipt, lab_result, prescription, imaging, dr_note, insurance, referral, discharge, recording, other +- ""tags"": array of relevant tag strings (lowercase, e.g. ""blood work"", ""cardiology"", ""annual physical"", ""copay"") + +Return ONLY the JSON object, no other text."; + + var response = await CallClaudeAsync(HaikuModel, content, systemPrompt); + + if (response == null) return null; + + // Parse JSON from response - handle potential markdown wrapping + var json = ExtractJson(response); + + try + { + return JsonSerializer.Deserialize(json, JsonOpts); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to parse classification response: {Response}", response); + return null; + } + } + + private async Task ExtractStructuredDataAsync(string text, string classification) + { + _logger.LogInformation("Extracting structured data via Sonnet, classification={Classification}", classification); + + var truncatedText = text.Length > 6000 ? text[..6000] : text; + + var content = new List + { + new + { + type = "text", + text = $"Classification: {classification}\n\nDocument text:\n{truncatedText}" + } + }; + + var systemPrompt = @"You are a medical document data extractor. Extract financial and medical data from the document. + +Return a JSON object with: +- ""costs"": array of cost objects, each with: ""amount"" (number), ""costType"" (string: copay, deductible, out_of_pocket, coinsurance, premium, total_charge, other), ""category"" (string: office_visit, lab, pharmacy, imaging, therapy, hospital, specialist, other), ""date"" (string, YYYY-MM-DD or null), ""description"" (string) +- ""doctorName"": string or null - the name of the doctor/provider mentioned +- ""prescriptionInfo"": object or null with ""medicationName"", ""dosage"", ""frequency"" fields + +Only include fields you can confidently extract from the text. If no costs are found, return an empty costs array. Return ONLY the JSON object, no other text."; + + var response = await CallClaudeAsync(SonnetModel, content, systemPrompt); + + if (response == null) return null; + + var json = ExtractJson(response); + + try + { + return JsonSerializer.Deserialize(json, JsonOpts); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to parse structured extraction response: {Response}", response); + return null; + } + } + + private async Task CallClaudeAsync(string model, List content, string systemPrompt) + { + var requestBody = new + { + model, + max_tokens = 4096, + system = systemPrompt, + messages = new[] + { + new + { + role = "user", + content + } + } + }; + + var json = JsonSerializer.Serialize(requestBody, JsonOpts); + var httpContent = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync("/v1/messages", httpContent); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(); + _logger.LogError("Claude API error {StatusCode}: {Error}", response.StatusCode, errorBody); + return null; + } + + var responseJson = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(responseJson, JsonOpts); + + var textBlock = result?.Content?.FirstOrDefault(c => c.Type == "text"); + return textBlock?.Text; + } + + private static string ExtractJson(string response) + { + // Handle markdown code blocks + var trimmed = response.Trim(); + if (trimmed.StartsWith("```")) + { + var firstNewline = trimmed.IndexOf('\n'); + if (firstNewline >= 0) + { + trimmed = trimmed[(firstNewline + 1)..]; + var lastFence = trimmed.LastIndexOf("```"); + if (lastFence >= 0) + trimmed = trimmed[..lastFence]; + } + } + return trimmed.Trim(); + } + + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + // --- Response DTOs --- + + private class ClaudeResponse + { + public List? Content { get; set; } + } + + private class ContentBlock + { + public string Type { get; set; } = ""; + public string? Text { get; set; } + } + + public class ClassificationResult + { + public string? Classification { get; set; } + public List? Tags { get; set; } + } + + public class StructuredExtractionResult + { + public List? Costs { get; set; } + public string? DoctorName { get; set; } + public PrescriptionInfo? PrescriptionInfo { get; set; } + } + + public class CostExtraction + { + public decimal Amount { get; set; } + public string? CostType { get; set; } + public string? Category { get; set; } + public string? Date { get; set; } + public string? Description { get; set; } + } + + public class PrescriptionInfo + { + public string? MedicationName { get; set; } + public string? Dosage { get; set; } + public string? Frequency { get; set; } + } +} diff --git a/Media.JoshHeaps.Net/Services/MedicalDocsService.cs b/Media.JoshHeaps.Net/Services/MedicalDocsService.cs new file mode 100644 index 0000000..7f2dee2 --- /dev/null +++ b/Media.JoshHeaps.Net/Services/MedicalDocsService.cs @@ -0,0 +1,769 @@ +using Media.JoshHeaps.Net.Models; + +namespace Media.JoshHeaps.Net.Services; + +public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, EncryptionService encryption, ILogger logger) +{ + // --- People --- + + public async Task> GetPeopleAsync() + { + try + { + return await db.ExecuteListReaderAsync( + "SELECT id, name, date_of_birth, notes, created_at, updated_at FROM app.medical_people ORDER BY name", + reader => new MedicalPerson + { + Id = reader.GetInt64(0), + Name = reader.GetString(1), + DateOfBirth = reader.IsDBNull(2) ? null : reader.GetDateTime(2), + Notes = reader.IsDBNull(3) ? null : reader.GetString(3), + CreatedAt = reader.GetDateTime(4), + UpdatedAt = reader.GetDateTime(5) + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get medical people"); + return []; + } + } + + public async Task CreatePersonAsync(string name, DateTime? dateOfBirth = null, string? notes = null) + { + try + { + var now = DateTime.UtcNow; + return 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", + reader => new MedicalPerson + { + Id = reader.GetInt64(0), + Name = reader.GetString(1), + DateOfBirth = reader.IsDBNull(2) ? null : reader.GetDateTime(2), + Notes = reader.IsDBNull(3) ? null : reader.GetString(3), + CreatedAt = reader.GetDateTime(4), + UpdatedAt = reader.GetDateTime(5) + }, + new { name, dateOfBirth, notes, createdAt = now, updatedAt = now }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create medical person"); + return null; + } + } + + // --- Documents --- + + public async Task SaveDocumentAsync(long personId, IFormFile file, string? title = null, string? description = null, DateTime? documentDate = null, string? classification = null) + { + string? tempFilePath = null; + string? encryptedFilePath = null; + + try + { + var fileExtension = Path.GetExtension(file.FileName); + var uniqueFileName = $"{Guid.NewGuid()}{fileExtension}.enc"; + + var mediaFolder = Path.Combine(environment.ContentRootPath, "App_Data", "medical", personId.ToString()); + Directory.CreateDirectory(mediaFolder); + + tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + encryptedFilePath = Path.Combine(mediaFolder, uniqueFileName); + + using (var fileStream = new FileStream(tempFilePath, FileMode.Create)) + { + await file.CopyToAsync(fileStream); + } + + await encryption.EncryptFileAsync(tempFilePath, encryptedFilePath); + + File.Delete(tempFilePath); + tempFilePath = null; + + var relativeFilePath = Path.Combine("App_Data", "medical", personId.ToString(), uniqueFileName); + var now = DateTime.UtcNow; + + var doc = await db.ExecuteReaderAsync( + @"INSERT INTO app.medical_documents (person_id, document_type, file_name, file_path, file_size, mime_type, is_encrypted, title, description, document_date, classification, created_at, updated_at) + VALUES (@personId, 'file', @fileName, @filePath, @fileSize, @mimeType, true, @title, @description, @documentDate, @classification, @createdAt, @updatedAt) + RETURNING id, person_id, document_type, file_name, file_path, file_size, mime_type, is_encrypted, title, description, document_date, classification, extracted_text, ai_processed, ai_processed_at, doctor_id, created_at, updated_at", + MapDocument, + new + { + personId, + fileName = file.FileName, + filePath = relativeFilePath, + fileSize = file.Length, + mimeType = file.ContentType, + title, + description, + documentDate, + classification, + createdAt = now, + updatedAt = now + }); + + return doc; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to save medical document for person {PersonId}", personId); + + if (tempFilePath != null && File.Exists(tempFilePath)) + { + try { File.Delete(tempFilePath); } catch { } + } + if (encryptedFilePath != null && File.Exists(encryptedFilePath)) + { + try { File.Delete(encryptedFilePath); } catch { } + } + + return null; + } + } + + public async Task SaveNoteAsync(long personId, string title, string description, DateTime? documentDate = null, string? classification = null) + { + try + { + var now = DateTime.UtcNow; + + return await db.ExecuteReaderAsync( + @"INSERT INTO app.medical_documents (person_id, document_type, title, description, document_date, classification, is_encrypted, created_at, updated_at) + VALUES (@personId, 'note', @title, @description, @documentDate, @classification, false, @createdAt, @updatedAt) + RETURNING id, person_id, document_type, file_name, file_path, file_size, mime_type, is_encrypted, title, description, document_date, classification, extracted_text, ai_processed, ai_processed_at, doctor_id, created_at, updated_at", + MapDocument, + new { personId, title, description, documentDate, classification, createdAt = now, updatedAt = now }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to save medical note for person {PersonId}", personId); + return null; + } + } + + public async Task> GetDocumentsAsync(long? personId = null, int offset = 0, int limit = 50) + { + try + { + string query; + object parameters; + + if (personId.HasValue) + { + query = @"SELECT id, person_id, document_type, file_name, file_path, file_size, mime_type, is_encrypted, title, description, document_date, classification, extracted_text, ai_processed, ai_processed_at, doctor_id, created_at, updated_at + FROM app.medical_documents + WHERE person_id = @personId + ORDER BY created_at DESC + OFFSET @offset LIMIT @limit"; + parameters = new { personId = personId.Value, offset, limit }; + } + else + { + query = @"SELECT id, person_id, document_type, file_name, file_path, file_size, mime_type, is_encrypted, title, description, document_date, classification, extracted_text, ai_processed, ai_processed_at, doctor_id, created_at, updated_at + FROM app.medical_documents + ORDER BY created_at DESC + OFFSET @offset LIMIT @limit"; + parameters = new { offset, limit }; + } + + return await db.ExecuteListReaderAsync(query, MapDocument, parameters); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get medical documents"); + return []; + } + } + + public async Task GetDocumentByIdAsync(long documentId) + { + try + { + return await db.ExecuteReaderAsync( + @"SELECT id, person_id, document_type, file_name, file_path, file_size, mime_type, is_encrypted, title, description, document_date, classification, extracted_text, ai_processed, ai_processed_at, doctor_id, created_at, updated_at + FROM app.medical_documents + WHERE id = @documentId", + MapDocument, + new { documentId }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get medical document {DocumentId}", documentId); + return null; + } + } + + public async Task GetDecryptedDocumentDataAsync(long documentId) + { + try + { + var doc = await GetDocumentByIdAsync(documentId); + if (doc == null || doc.DocumentType != "file" || doc.FilePath == null) + return null; + + var fullPath = Path.Combine(environment.ContentRootPath, doc.FilePath); + + if (!File.Exists(fullPath)) + { + logger.LogError("Medical document file not found at {FilePath}", fullPath); + return null; + } + + if (doc.IsEncrypted) + { + return await encryption.DecryptFileAsync(fullPath); + } + else + { + return await File.ReadAllBytesAsync(fullPath); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get decrypted medical document data for {DocumentId}", documentId); + return null; + } + } + + public async Task DeleteDocumentAsync(long documentId) + { + try + { + var doc = await GetDocumentByIdAsync(documentId); + if (doc == null) return false; + + await db.ExecuteNonQueryAsync("DELETE FROM app.medical_documents WHERE id = @documentId", new { documentId }); + + if (doc.DocumentType == "file" && doc.FilePath != null) + { + var physicalPath = Path.Combine(environment.ContentRootPath, doc.FilePath); + if (File.Exists(physicalPath)) + { + File.Delete(physicalPath); + } + } + + return true; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to delete medical document {DocumentId}", documentId); + return false; + } + } + + // --- Doctors --- + + public async Task> GetDoctorsAsync() + { + try + { + return await db.ExecuteListReaderAsync( + "SELECT id, name, specialty, phone, address, notes, created_at, updated_at FROM app.medical_doctors 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) + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get doctors"); + return []; + } + } + + public async Task CreateDoctorAsync(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) + RETURNING id, name, specialty, phone, address, notes, created_at, updated_at", + 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) + }, + new { name, specialty, phone, address, notes, createdAt = now, updatedAt = now }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create doctor"); + return null; + } + } + + public async Task UpdateDoctorAsync(long id, string name, string? specialty = null, string? phone = null, string? address = null, string? notes = null) + { + try + { + var rows = await db.ExecuteNonQueryAsync( + @"UPDATE app.medical_doctors SET name = @name, specialty = @specialty, phone = @phone, address = @address, notes = @notes, updated_at = @updatedAt + WHERE id = @id", + new { id, name, specialty, phone, address, notes, updatedAt = DateTime.UtcNow }); + return rows > 0; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to update doctor {DoctorId}", id); + return false; + } + } + + public async Task DeleteDoctorAsync(long id) + { + try + { + var rows = await db.ExecuteNonQueryAsync("DELETE FROM app.medical_doctors WHERE id = @id", new { id }); + return rows > 0; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to delete doctor {DoctorId}", id); + return false; + } + } + + // --- Conditions --- + + public async Task> GetConditionsAsync(long personId) + { + try + { + 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", + reader => new MedicalCondition + { + Id = reader.GetInt64(0), + PersonId = reader.GetInt64(1), + Name = reader.GetString(2), + DiagnosedDate = reader.IsDBNull(3) ? null : reader.GetDateTime(3), + Notes = reader.IsDBNull(4) ? null : reader.GetString(4), + IsActive = reader.GetBoolean(5), + CreatedAt = reader.GetDateTime(6), + UpdatedAt = reader.GetDateTime(7) + }, + new { personId }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get conditions for person {PersonId}", personId); + return []; + } + } + + public async Task CreateConditionAsync(long personId, string name, DateTime? diagnosedDate = null, string? notes = null) + { + try + { + var now = DateTime.UtcNow; + return await db.ExecuteReaderAsync( + @"INSERT INTO app.medical_conditions (person_id, name, diagnosed_date, notes, created_at, updated_at) + VALUES (@personId, @name, @diagnosedDate, @notes, @createdAt, @updatedAt) + RETURNING id, person_id, name, diagnosed_date, notes, is_active, created_at, updated_at", + reader => new MedicalCondition + { + Id = reader.GetInt64(0), + PersonId = reader.GetInt64(1), + Name = reader.GetString(2), + DiagnosedDate = reader.IsDBNull(3) ? null : reader.GetDateTime(3), + Notes = reader.IsDBNull(4) ? null : reader.GetString(4), + IsActive = reader.GetBoolean(5), + CreatedAt = reader.GetDateTime(6), + UpdatedAt = reader.GetDateTime(7) + }, + new { personId, name, diagnosedDate, notes, createdAt = now, updatedAt = now }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create condition for person {PersonId}", personId); + return null; + } + } + + public async Task UpdateConditionAsync(long id, string name, DateTime? diagnosedDate = null, string? notes = null, bool isActive = true) + { + try + { + var rows = await db.ExecuteNonQueryAsync( + @"UPDATE app.medical_conditions SET name = @name, diagnosed_date = @diagnosedDate, notes = @notes, is_active = @isActive, updated_at = @updatedAt + WHERE id = @id", + new { id, name, diagnosedDate, notes, isActive, updatedAt = DateTime.UtcNow }); + return rows > 0; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to update condition {ConditionId}", id); + return false; + } + } + + public async Task DeleteConditionAsync(long id) + { + try + { + var rows = await db.ExecuteNonQueryAsync("DELETE FROM app.medical_conditions WHERE id = @id", new { id }); + return rows > 0; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to delete condition {ConditionId}", id); + return false; + } + } + + // --- Prescriptions --- + + public async Task> GetPrescriptionsAsync(long personId) + { + try + { + 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, + 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 + ORDER BY p.medication_name", + reader => new MedicalPrescription + { + Id = reader.GetInt64(0), + PersonId = reader.GetInt64(1), + DoctorId = reader.IsDBNull(2) ? null : reader.GetInt64(2), + MedicationName = reader.GetString(3), + Dosage = reader.IsDBNull(4) ? null : reader.GetString(4), + Frequency = reader.IsDBNull(5) ? null : reader.GetString(5), + IsActive = reader.GetBoolean(6), + StartDate = reader.IsDBNull(7) ? null : reader.GetDateTime(7), + EndDate = reader.IsDBNull(8) ? null : reader.GetDateTime(8), + Notes = reader.IsDBNull(9) ? null : reader.GetString(9), + CreatedAt = reader.GetDateTime(10), + UpdatedAt = reader.GetDateTime(11), + DoctorName = reader.IsDBNull(12) ? null : reader.GetString(12), + LastPickupDate = reader.IsDBNull(13) ? null : reader.GetDateTime(13) + }, + new { personId }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get prescriptions for person {PersonId}", personId); + return []; + } + } + + public async Task CreatePrescriptionAsync(long personId, string medicationName, string? dosage = null, string? frequency = null, long? doctorId = null, DateTime? startDate = null, string? notes = null) + { + try + { + var now = DateTime.UtcNow; + return await db.ExecuteReaderAsync( + @"INSERT INTO app.medical_prescriptions (person_id, doctor_id, medication_name, dosage, frequency, start_date, notes, created_at, updated_at) + VALUES (@personId, @doctorId, @medicationName, @dosage, @frequency, @startDate, @notes, @createdAt, @updatedAt) + RETURNING id, person_id, doctor_id, medication_name, dosage, frequency, is_active, start_date, end_date, notes, created_at, updated_at", + reader => new MedicalPrescription + { + Id = reader.GetInt64(0), + PersonId = reader.GetInt64(1), + DoctorId = reader.IsDBNull(2) ? null : reader.GetInt64(2), + MedicationName = reader.GetString(3), + Dosage = reader.IsDBNull(4) ? null : reader.GetString(4), + Frequency = reader.IsDBNull(5) ? null : reader.GetString(5), + IsActive = reader.GetBoolean(6), + StartDate = reader.IsDBNull(7) ? null : reader.GetDateTime(7), + EndDate = reader.IsDBNull(8) ? null : reader.GetDateTime(8), + Notes = reader.IsDBNull(9) ? null : reader.GetString(9), + CreatedAt = reader.GetDateTime(10), + UpdatedAt = reader.GetDateTime(11) + }, + new { personId, doctorId, medicationName, dosage, frequency, startDate, notes, createdAt = now, updatedAt = now }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create prescription for person {PersonId}", personId); + return null; + } + } + + public async Task UpdatePrescriptionAsync(long id, string medicationName, string? dosage = null, string? frequency = null, long? doctorId = null, DateTime? startDate = null, DateTime? endDate = null, string? notes = null, bool isActive = true) + { + try + { + var rows = await db.ExecuteNonQueryAsync( + @"UPDATE app.medical_prescriptions SET medication_name = @medicationName, dosage = @dosage, frequency = @frequency, doctor_id = @doctorId, start_date = @startDate, end_date = @endDate, notes = @notes, is_active = @isActive, updated_at = @updatedAt + WHERE id = @id", + new { id, medicationName, dosage, frequency, doctorId, startDate, endDate, notes, isActive, updatedAt = DateTime.UtcNow }); + return rows > 0; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to update prescription {PrescriptionId}", id); + return false; + } + } + + public async Task DeletePrescriptionAsync(long id) + { + try + { + var rows = await db.ExecuteNonQueryAsync("DELETE FROM app.medical_prescriptions WHERE id = @id", new { id }); + return rows > 0; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to delete prescription {PrescriptionId}", id); + return false; + } + } + + // --- Prescription Pickups --- + + public async Task> GetPickupsAsync(long prescriptionId) + { + try + { + return await db.ExecuteListReaderAsync( + "SELECT id, prescription_id, document_id, pickup_date, quantity, pharmacy, cost, notes, created_at FROM app.medical_prescription_pickups WHERE prescription_id = @prescriptionId ORDER BY pickup_date DESC", + reader => new MedicalPrescriptionPickup + { + Id = reader.GetInt64(0), + PrescriptionId = reader.GetInt64(1), + DocumentId = reader.IsDBNull(2) ? null : reader.GetInt64(2), + PickupDate = reader.GetDateTime(3), + Quantity = reader.IsDBNull(4) ? null : reader.GetString(4), + Pharmacy = reader.IsDBNull(5) ? null : reader.GetString(5), + Cost = reader.IsDBNull(6) ? null : reader.GetDecimal(6), + Notes = reader.IsDBNull(7) ? null : reader.GetString(7), + CreatedAt = reader.GetDateTime(8) + }, + new { prescriptionId }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get pickups for prescription {PrescriptionId}", prescriptionId); + return []; + } + } + + public async Task CreatePickupAsync(long prescriptionId, DateTime pickupDate, string? quantity = null, string? pharmacy = null, decimal? cost = null, string? notes = null) + { + try + { + return await db.ExecuteReaderAsync( + @"INSERT INTO app.medical_prescription_pickups (prescription_id, pickup_date, quantity, pharmacy, cost, notes, created_at) + VALUES (@prescriptionId, @pickupDate, @quantity, @pharmacy, @cost, @notes, @createdAt) + RETURNING id, prescription_id, document_id, pickup_date, quantity, pharmacy, cost, notes, created_at", + reader => new MedicalPrescriptionPickup + { + Id = reader.GetInt64(0), + PrescriptionId = reader.GetInt64(1), + DocumentId = reader.IsDBNull(2) ? null : reader.GetInt64(2), + PickupDate = reader.GetDateTime(3), + Quantity = reader.IsDBNull(4) ? null : reader.GetString(4), + Pharmacy = reader.IsDBNull(5) ? null : reader.GetString(5), + Cost = reader.IsDBNull(6) ? null : reader.GetDecimal(6), + Notes = reader.IsDBNull(7) ? null : reader.GetString(7), + CreatedAt = reader.GetDateTime(8) + }, + new { prescriptionId, pickupDate, quantity, pharmacy, cost, notes, createdAt = DateTime.UtcNow }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create pickup for prescription {PrescriptionId}", prescriptionId); + return null; + } + } + + public async Task DeletePickupAsync(long id) + { + try + { + var rows = await db.ExecuteNonQueryAsync("DELETE FROM app.medical_prescription_pickups WHERE id = @id", new { id }); + return rows > 0; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to delete pickup {PickupId}", id); + return false; + } + } + + // --- AI Processing Helpers --- + + public async Task UpdateAiResultsAsync(long docId, string? extractedText, string? classification, string aiRawResponse) + { + try + { + await db.ExecuteNonQueryAsync( + @"UPDATE app.medical_documents + SET extracted_text = @extractedText, + classification = @classification, + ai_processed = true, + ai_processed_at = @processedAt, + ai_raw_response = @aiRawResponse::jsonb, + updated_at = @updatedAt + WHERE id = @docId", + new + { + docId, + extractedText, + classification, + aiRawResponse, + processedAt = DateTime.UtcNow, + updatedAt = DateTime.UtcNow + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to update AI results for document {DocumentId}", docId); + } + } + + public async Task AddTagsAsync(long docId, List tagNames, string source = "ai") + { + try + { + foreach (var tagName in tagNames) + { + var normalizedName = tagName.Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(normalizedName)) continue; + + // Upsert tag + var tagId = await db.ExecuteAsync( + @"INSERT INTO app.medical_tags (name) VALUES (@name) + ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name + RETURNING id", + new { name = normalizedName }); + + // Link tag to document + await db.ExecuteNonQueryAsync( + @"INSERT INTO app.medical_document_tags (document_id, tag_id, source) + VALUES (@documentId, @tagId, @source) + ON CONFLICT (document_id, tag_id) DO NOTHING", + new { documentId = docId, tagId, source }); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to add tags for document {DocumentId}", docId); + } + } + + public async Task AddCostsAsync(long docId, long personId, List costs) + { + try + { + foreach (var cost in costs) + { + if (cost.Amount <= 0) continue; + + DateTime? costDate = null; + if (!string.IsNullOrEmpty(cost.Date) && DateTime.TryParse(cost.Date, out var parsed)) + costDate = parsed; + + await db.ExecuteNonQueryAsync( + @"INSERT INTO app.medical_document_costs (document_id, person_id, amount, cost_type, category, cost_date, description, source) + VALUES (@documentId, @personId, @amount, @costType, @category, @costDate, @description, 'ai')", + new + { + documentId = docId, + personId, + amount = cost.Amount, + costType = cost.CostType, + category = cost.Category, + costDate, + description = cost.Description + }); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to add costs for document {DocumentId}", docId); + } + } + + public async Task> GetUnprocessedDocumentIdsAsync() + { + try + { + return await db.ExecuteListReaderAsync( + "SELECT id FROM app.medical_documents WHERE ai_processed = false ORDER BY created_at", + reader => reader.GetInt64(0)); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get unprocessed document IDs"); + return []; + } + } + + public async Task> GetDocumentTagsAsync(long documentId) + { + try + { + return await db.ExecuteListReaderAsync( + @"SELECT t.id, t.name, t.created_at + FROM app.medical_tags t + JOIN app.medical_document_tags dt ON dt.tag_id = t.id + WHERE dt.document_id = @documentId + ORDER BY t.name", + reader => new MedicalTag + { + Id = reader.GetInt64(0), + Name = reader.GetString(1), + CreatedAt = reader.GetDateTime(2) + }, + new { documentId }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get tags for document {DocumentId}", documentId); + return []; + } + } + + private static MedicalDocument MapDocument(Npgsql.NpgsqlDataReader reader) + { + return new MedicalDocument + { + Id = reader.GetInt64(0), + PersonId = reader.GetInt64(1), + DocumentType = reader.GetString(2), + FileName = reader.IsDBNull(3) ? null : reader.GetString(3), + FilePath = reader.IsDBNull(4) ? null : reader.GetString(4), + FileSize = reader.IsDBNull(5) ? null : reader.GetInt64(5), + MimeType = reader.IsDBNull(6) ? null : reader.GetString(6), + IsEncrypted = reader.GetBoolean(7), + Title = reader.IsDBNull(8) ? null : reader.GetString(8), + Description = reader.IsDBNull(9) ? null : reader.GetString(9), + DocumentDate = reader.IsDBNull(10) ? null : reader.GetDateTime(10), + Classification = reader.IsDBNull(11) ? null : reader.GetString(11), + ExtractedText = reader.IsDBNull(12) ? null : reader.GetString(12), + AiProcessed = reader.GetBoolean(13), + AiProcessedAt = reader.IsDBNull(14) ? null : reader.GetDateTime(14), + DoctorId = reader.IsDBNull(15) ? null : reader.GetInt64(15), + CreatedAt = reader.GetDateTime(16), + UpdatedAt = reader.GetDateTime(17) + }; + } +} diff --git a/Media.JoshHeaps.Net/wwwroot/css/medical-docs.css b/Media.JoshHeaps.Net/wwwroot/css/medical-docs.css new file mode 100644 index 0000000..2b49186 --- /dev/null +++ b/Media.JoshHeaps.Net/wwwroot/css/medical-docs.css @@ -0,0 +1,1000 @@ +.dashboard-container { + max-width: 1200px; + margin: 0 auto; + padding: 40px 20px; +} + +.welcome-section { + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + color: var(--text-primary); + padding: 32px 40px; + border-radius: 8px; + margin-bottom: 32px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.welcome-left { + display: flex; + align-items: center; + gap: 16px; +} + +.back-button { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 8px; + color: var(--text-secondary); + text-decoration: none; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.back-button:hover { + background: var(--bg-hover); + border-color: var(--accent-primary); + color: var(--accent-primary); + transform: translateX(-2px); +} + +.back-button svg { + width: 20px; + height: 20px; +} + +.welcome-section h1 { + margin: 0; + font-size: 28px; + font-weight: 600; + letter-spacing: -0.5px; +} + +.quick-actions { + display: flex; + gap: 12px; +} + +.btn { + padding: 10px 20px; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + text-decoration: none; + cursor: pointer; + border: 1px solid transparent; + transition: all 0.2s ease; +} + +.btn-primary { + background: var(--accent-primary); + color: #fff; + border-color: var(--accent-primary); +} + +.btn-primary:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +.btn-secondary { + background: var(--bg-secondary); + color: var(--text-primary); + border-color: var(--border-primary); +} + +.btn-secondary:hover { + background: var(--bg-hover); + border-color: var(--accent-primary); +} + +.btn-danger { + background: var(--danger); + color: #fff; + border-color: var(--danger); +} + +.btn-danger:hover { + background: var(--danger-hover); + border-color: var(--danger-hover); +} + +.btn-sm { + padding: 6px 12px; + font-size: 12px; +} + +/* ======================== */ +/* Layout: Sidebar + Main */ +/* ======================== */ + +.medical-layout { + display: flex; + gap: 24px; + min-height: calc(100vh - 200px); +} + +.medical-sidebar { + width: 220px; + flex-shrink: 0; + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 8px; + padding: 20px; + position: sticky; + top: 20px; + align-self: flex-start; +} + +.medical-sidebar h3 { + margin: 0 0 16px 0; + font-size: 16px; + font-weight: 600; + color: var(--text-primary); +} + +.sidebar-people-list { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 16px; +} + +.sidebar-people-list .person-pill { + width: 100%; + text-align: left; + justify-content: flex-start; +} + +.sidebar-add-person { + display: flex; + flex-direction: column; + gap: 8px; +} + +.sidebar-add-person .form-input { + width: 100%; + box-sizing: border-box; +} + +.sidebar-add-person .btn { + width: 100%; +} + +.medical-main { + flex: 1; + min-width: 0; +} + +/* ======================== */ +/* Summary Cards */ +/* ======================== */ + +.summary-cards { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-bottom: 20px; +} + +.summary-card { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 8px; + padding: 16px; + text-align: center; + cursor: pointer; + transition: all 0.2s ease; +} + +.summary-card:hover { + border-color: var(--accent-primary); +} + +.summary-card.active { + border-color: var(--accent-primary); + background: var(--bg-tertiary); +} + +.summary-card .count { + font-size: 24px; + font-weight: 600; + color: var(--text-primary); + line-height: 1; +} + +.summary-card .label { + font-size: 12px; + color: var(--text-secondary); + margin-top: 4px; +} + +/* ======================== */ +/* Main Tabs */ +/* ======================== */ + +.main-tabs { + display: flex; + gap: 4px; + margin-bottom: 20px; + border-bottom: 1px solid var(--border-primary); + padding-bottom: 0; +} + +.main-tab { + padding: 10px 20px; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: 14px; + transition: all 0.2s ease; +} + +.main-tab:hover { + color: var(--text-primary); +} + +.main-tab.active { + color: var(--accent-primary); + border-bottom-color: var(--accent-primary); +} + +.main-tab.disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* ======================== */ +/* Tab Panels */ +/* ======================== */ + +.tab-panel { + display: none; +} + +.tab-panel.active { + display: block; +} + +/* ======================== */ +/* Collapsible Add Forms */ +/* ======================== */ + +.add-form-toggle { + margin-bottom: 16px; + display: flex; + align-items: center; + gap: 12px; +} + +.add-form-collapsible { + display: none; + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 8px; + padding: 20px; + margin-bottom: 16px; +} + +.add-form-collapsible.open { + display: block; +} + +/* ======================== */ +/* People Pills */ +/* ======================== */ + +.person-pill { + display: inline-flex; + align-items: center; + padding: 8px 16px; + background: var(--bg-tertiary); + border: 2px solid var(--border-primary); + border-radius: 20px; + font-size: 14px; + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s ease; +} + +.person-pill:hover { + border-color: var(--accent-primary); + color: var(--accent-primary); +} + +.person-pill.active { + background: var(--accent-primary); + border-color: var(--accent-primary); + color: #fff; +} + +/* ======================== */ +/* Form Inputs */ +/* ======================== */ + +.form-input { + padding: 8px 12px; + background: var(--bg-primary); + border: 1px solid var(--border-primary); + border-radius: 6px; + color: var(--text-primary); + font-size: 14px; + outline: none; + transition: border-color 0.2s ease; +} + +.form-input:focus { + border-color: var(--accent-primary); +} + +/* Upload Sub-Tabs (file vs note within add document form) */ +.upload-sub-tabs { + display: flex; + gap: 4px; + margin-bottom: 16px; +} + +.tab-btn { + padding: 6px 16px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 6px; + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + transition: all 0.2s ease; +} + +.tab-btn:hover { + border-color: var(--accent-primary); + color: var(--accent-primary); +} + +.tab-btn.active { + background: var(--accent-primary); + border-color: var(--accent-primary); + color: #fff; +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* Upload Area */ +.upload-area { + border: 2px dashed var(--border-primary); + border-radius: 8px; + padding: 40px 20px; + text-align: center; + color: var(--text-secondary); + transition: all 0.2s ease; + margin-bottom: 12px; +} + +.upload-area.drag-over { + border-color: var(--accent-primary); + background: rgba(99, 102, 241, 0.05); +} + +.upload-icon { + margin-bottom: 12px; +} + +.upload-icon svg { + width: 40px; + height: 40px; + stroke: var(--text-secondary); +} + +.upload-area p { + margin: 4px 0; + font-size: 14px; +} + +.upload-hint { + font-size: 12px !important; + color: var(--text-secondary); + opacity: 0.7; +} + +.link-btn { + background: none; + border: none; + color: var(--accent-primary); + cursor: pointer; + text-decoration: underline; + font-size: inherit; + padding: 0; +} + +.upload-fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-bottom: 12px; +} + +.upload-queue { + display: flex; + flex-direction: column; + gap: 8px; +} + +.upload-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 6px; + font-size: 13px; + color: var(--text-primary); +} + +.upload-item .file-info { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.upload-item .file-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.upload-item .file-size { + color: var(--text-secondary); + font-size: 12px; + flex-shrink: 0; +} + +.upload-item .upload-status { + flex-shrink: 0; + font-size: 12px; +} + +.upload-item .upload-status.uploading { + color: var(--accent-primary); +} + +.upload-item .upload-status.done { + color: var(--success, #22c55e); +} + +.upload-item .upload-status.error { + color: var(--danger); +} + +/* Note Form */ +.note-form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.note-textarea { + resize: vertical; + min-height: 100px; + font-family: inherit; +} + +.note-fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +/* Documents List */ +.doc-count { + font-size: 13px; + color: var(--text-secondary); +} + +.documents-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.doc-item { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 18px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 8px; + transition: all 0.15s ease; +} + +.doc-item:hover { + border-color: var(--accent-primary); +} + +.doc-type-icon { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-secondary); + border-radius: 8px; + flex-shrink: 0; +} + +.doc-type-icon svg { + width: 18px; + height: 18px; + stroke: var(--text-secondary); +} + +.doc-info { + flex: 1; + min-width: 0; +} + +.doc-title { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.doc-meta { + font-size: 12px; + color: var(--text-secondary); + margin-top: 2px; + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.doc-classification { + display: inline-block; + padding: 2px 8px; + background: var(--accent-primary); + color: #fff; + border-radius: 10px; + font-size: 11px; + font-weight: 500; +} + +.doc-actions { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +.doc-actions button { + padding: 6px 10px; + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 6px; + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + transition: all 0.2s ease; +} + +.doc-actions button:hover { + border-color: var(--accent-primary); + color: var(--accent-primary); +} + +.doc-actions button.delete-btn:hover { + border-color: var(--danger); + color: var(--danger); +} + +/* Section header right */ +.section-header-right { + display: flex; + align-items: center; + gap: 12px; +} + +/* AI Status Badges */ +.ai-badge { + display: inline-block; + padding: 2px 6px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +.ai-badge.ai-done { + background: var(--success, #22c55e); + color: #fff; +} + +.ai-badge.ai-pending { + background: var(--bg-tertiary); + color: var(--text-secondary); + border: 1px solid var(--border-primary); +} + +/* AI Process Button */ +.ai-process-btn { + background: var(--accent-primary) !important; + color: #fff !important; + border-color: var(--accent-primary) !important; +} + +.ai-process-btn:hover { + background: var(--accent-hover) !important; + border-color: var(--accent-hover) !important; +} + +.ai-process-btn.processing { + opacity: 0.7; + cursor: wait; +} + +.empty-state { + text-align: center; + padding: 40px 20px; + color: var(--text-secondary); + font-size: 14px; +} + +/* Inline Add Forms */ +.inline-add-form { + margin-bottom: 16px; +} + +.inline-form-row { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.inline-form-row .form-input { + flex: 1; + min-width: 120px; +} + +.inline-form-row .btn { + flex-shrink: 0; +} + +.inline-edit-form { + width: 100%; +} + +/* Doctor Cards */ +.doctors-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.doctor-card { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 8px; + transition: all 0.15s ease; +} + +.doctor-card:hover { + border-color: var(--accent-primary); +} + +.doctor-info { + flex: 1; + min-width: 0; +} + +.doctor-name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); +} + +.doctor-details { + font-size: 12px; + color: var(--text-secondary); + margin-top: 2px; +} + +.doctor-notes { + font-size: 12px; + color: var(--text-secondary); + margin-top: 2px; + font-style: italic; +} + +/* Condition Items */ +.conditions-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.condition-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 8px; + transition: all 0.15s ease; +} + +.condition-item:hover { + border-color: var(--accent-primary); +} + +.condition-info { + flex: 1; + min-width: 0; +} + +.condition-name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; +} + +.condition-meta { + font-size: 12px; + color: var(--text-secondary); + margin-top: 2px; +} + +/* Status Badges */ +.badge-active { + display: inline-block; + padding: 2px 8px; + background: var(--success, #22c55e); + color: #fff; + border-radius: 10px; + font-size: 11px; + font-weight: 500; +} + +.badge-inactive { + display: inline-block; + padding: 2px 8px; + background: var(--bg-tertiary); + color: var(--text-secondary); + border: 1px solid var(--border-primary); + border-radius: 10px; + font-size: 11px; + font-weight: 500; +} + +/* Prescription Items */ +.prescriptions-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.prescription-item { + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 8px; + transition: all 0.15s ease; + overflow: hidden; +} + +.prescription-item:hover { + border-color: var(--accent-primary); +} + +.prescription-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + cursor: pointer; +} + +.prescription-info { + flex: 1; + min-width: 0; +} + +.prescription-name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; +} + +.prescription-meta { + font-size: 12px; + color: var(--text-secondary); + margin-top: 2px; +} + +.expand-btn { + font-size: 10px; + color: var(--text-secondary); + transition: transform 0.2s ease; + user-select: none; +} + +/* Pickup Section */ +.pickup-section { + border-top: 1px solid var(--border-primary); + padding: 14px 18px; + background: var(--bg-secondary); +} + +.pickup-form { + margin-bottom: 12px; +} + +.pickup-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.pickup-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 6px; + font-size: 13px; +} + +.pickup-info { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.pickup-date { + font-weight: 500; + color: var(--text-primary); +} + +.pickup-meta { + font-size: 12px; + color: var(--text-secondary); +} + +.pickup-item .delete-btn { + padding: 4px 8px; + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 4px; + color: var(--text-secondary); + font-size: 11px; + cursor: pointer; + transition: all 0.2s ease; +} + +.pickup-item .delete-btn:hover { + border-color: var(--danger); + color: var(--danger); +} + +/* ======================== */ +/* Responsive (<=768px) */ +/* ======================== */ + +@media (max-width: 768px) { + .welcome-section { + flex-direction: column; + gap: 16px; + padding: 20px; + align-items: flex-start; + } + + .medical-layout { + flex-direction: column; + } + + .medical-sidebar { + width: 100%; + position: static; + } + + .sidebar-people-list { + flex-direction: row; + flex-wrap: wrap; + } + + .sidebar-people-list .person-pill { + width: auto; + } + + .sidebar-add-person { + flex-direction: row; + } + + .sidebar-add-person .form-input { + flex: 1; + width: auto; + } + + .sidebar-add-person .btn { + width: auto; + } + + .summary-cards { + grid-template-columns: repeat(2, 1fr); + } + + .upload-fields { + grid-template-columns: 1fr; + } + + .note-fields { + grid-template-columns: 1fr; + } + + .doc-item { + flex-wrap: wrap; + } + + .doc-actions { + width: 100%; + justify-content: flex-end; + } + + .inline-form-row { + flex-direction: column; + } + + .inline-form-row .form-input { + min-width: unset; + width: 100%; + } + + .doctor-card, + .condition-item { + flex-wrap: wrap; + } + + .prescription-header { + flex-wrap: wrap; + } + + .pickup-item { + flex-wrap: wrap; + gap: 8px; + } + + .main-tabs { + overflow-x: auto; + } +} diff --git a/Media.JoshHeaps.Net/wwwroot/js/medical-docs.js b/Media.JoshHeaps.Net/wwwroot/js/medical-docs.js new file mode 100644 index 0000000..7944f62 --- /dev/null +++ b/Media.JoshHeaps.Net/wwwroot/js/medical-docs.js @@ -0,0 +1,966 @@ +(function () { + const API = '/api/medical-docs'; + let people = []; + let doctors = []; + let selectedPersonId = null; + let activeTab = 'doctors'; + + document.addEventListener('DOMContentLoaded', init); + + async function init() { + await loadPeople(); + await loadDoctors(); + + // Add person + document.getElementById('addPersonBtn').addEventListener('click', addPerson); + document.getElementById('newPersonName').addEventListener('keydown', (e) => { + if (e.key === 'Enter') addPerson(); + }); + + // Upload sub-tabs (file vs note) + document.querySelectorAll('.upload-sub-tabs .tab-btn').forEach(btn => { + btn.addEventListener('click', () => switchUploadTab(btn.dataset.tab)); + }); + + // File upload + document.getElementById('browseBtn').addEventListener('click', () => { + document.getElementById('fileInput').click(); + }); + document.getElementById('fileInput').addEventListener('change', handleFileSelect); + + // Drag and drop + const dropZone = document.getElementById('dropZone'); + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('drag-over'); + }); + dropZone.addEventListener('dragleave', () => { + dropZone.classList.remove('drag-over'); + }); + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('drag-over'); + if (e.dataTransfer.files.length > 0) { + uploadFiles(e.dataTransfer.files); + } + }); + + // Save note + document.getElementById('saveNoteBtn').addEventListener('click', saveNote); + + // Default state: only Doctors tab visible, no summary cards + switchMainTab('doctors'); + updateTabStates(); + } + + // --- Main Tab Switching --- + + function switchMainTab(tabName) { + // Person-scoped tabs require a person selection + if (!selectedPersonId && tabName !== 'doctors') return; + + activeTab = tabName; + + // Update tab buttons + document.querySelectorAll('.main-tab').forEach(btn => { + if (btn.dataset.tab === tabName) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } + }); + + // Update tab panels + document.querySelectorAll('.tab-panel').forEach(panel => { + if (panel.id === 'panel-' + tabName) { + panel.classList.add('active'); + } else { + panel.classList.remove('active'); + } + }); + + // Update summary card highlights + document.querySelectorAll('.summary-card').forEach(card => { + if (card.dataset.tab === tabName) { + card.classList.add('active'); + } else { + card.classList.remove('active'); + } + }); + } + + function updateTabStates() { + var personTabs = ['documents', 'conditions', 'prescriptions']; + personTabs.forEach(function (tab) { + var btn = document.querySelector('.main-tab[data-tab="' + tab + '"]'); + if (btn) { + if (selectedPersonId) { + btn.classList.remove('disabled'); + } else { + btn.classList.add('disabled'); + } + } + }); + } + + // --- Upload Sub-Tab Switching (file vs note) --- + + function switchUploadTab(tab) { + document.querySelectorAll('.upload-sub-tabs .tab-btn').forEach(b => b.classList.remove('active')); + document.querySelectorAll('.add-form-collapsible .tab-content').forEach(c => c.classList.remove('active')); + document.querySelector(`.upload-sub-tabs .tab-btn[data-tab="${tab}"]`).classList.add('active'); + document.getElementById(`tab-${tab}`).classList.add('active'); + } + + // --- Summary Cards --- + + function updateSummaryCount(id, count) { + const el = document.getElementById(id); + if (el) el.textContent = count; + } + + // --- Collapsible Add Forms --- + + function toggleAddForm(panelId) { + const panel = document.getElementById(panelId); + if (!panel) return; + const collapsible = panel.querySelector('.add-form-collapsible'); + if (collapsible) { + collapsible.classList.toggle('open'); + } + } + + // --- People --- + + async function loadPeople() { + const res = await fetch(`${API}/people`); + if (!res.ok) return; + people = await res.json(); + renderPeople(); + } + + function renderPeople() { + const container = document.getElementById('peopleList'); + container.innerHTML = people.map(p => + `` + ).join(''); + } + + async function addPerson() { + const input = document.getElementById('newPersonName'); + const name = input.value.trim(); + if (!name) return; + + const res = await fetch(`${API}/people`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }) + }); + + if (!res.ok) { + const err = await res.json(); + alert(err.error || 'Failed to add person'); + return; + } + + input.value = ''; + const person = await res.json(); + await loadPeople(); + selectPerson(person.id); + } + + function selectPerson(personId) { + selectedPersonId = personId; + renderPeople(); + + // Show summary cards and enable person-scoped tabs + document.getElementById('summaryCards').style.display = ''; + updateTabStates(); + + // Load data for this person + loadDocuments(); + loadConditions(); + loadPrescriptions(); + + // Switch to Documents tab by default + switchMainTab('documents'); + } + + // --- File Upload --- + + function handleFileSelect(e) { + if (e.target.files.length > 0) { + uploadFiles(e.target.files); + } + } + + async function uploadFiles(files) { + const queue = document.getElementById('uploadQueue'); + + for (const file of files) { + const item = document.createElement('div'); + item.className = 'upload-item'; + item.innerHTML = ` +
+ ${escapeHtml(file.name)} + ${formatSize(file.size)} +
+ Uploading... + `; + queue.appendChild(item); + + const statusEl = item.querySelector('.upload-status'); + + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('personId', selectedPersonId); + + const title = document.getElementById('fileTitle').value.trim(); + const description = document.getElementById('fileDescription').value.trim(); + const date = document.getElementById('fileDate').value; + const classification = document.getElementById('fileClassification').value; + + if (title) formData.append('title', title); + if (description) formData.append('description', description); + if (date) formData.append('documentDate', date); + if (classification) formData.append('classification', classification); + + const res = await fetch(`${API}/documents/upload`, { + method: 'POST', + body: formData + }); + + if (res.ok) { + statusEl.textContent = 'Done'; + statusEl.className = 'upload-status done'; + } else { + const err = await res.json().catch(() => ({})); + statusEl.textContent = err.error || 'Failed'; + statusEl.className = 'upload-status error'; + } + } catch { + statusEl.textContent = 'Error'; + statusEl.className = 'upload-status error'; + } + } + + // Clear fields after upload batch + document.getElementById('fileTitle').value = ''; + document.getElementById('fileDescription').value = ''; + document.getElementById('fileDate').value = ''; + document.getElementById('fileClassification').value = ''; + document.getElementById('fileInput').value = ''; + + await loadDocuments(); + } + + // --- Notes --- + + async function saveNote() { + const title = document.getElementById('noteTitle').value.trim(); + const description = document.getElementById('noteDescription').value.trim(); + const date = document.getElementById('noteDate').value || null; + const classification = document.getElementById('noteClassification').value || null; + + if (!title) { + alert('Title is required'); + return; + } + + const res = await fetch(`${API}/documents/note`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + personId: selectedPersonId, + title, + description, + documentDate: date, + classification + }) + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + alert(err.error || 'Failed to save note'); + return; + } + + document.getElementById('noteTitle').value = ''; + document.getElementById('noteDescription').value = ''; + document.getElementById('noteDate').value = ''; + document.getElementById('noteClassification').value = ''; + + await loadDocuments(); + } + + // --- Documents --- + + async function loadDocuments() { + if (!selectedPersonId) return; + + const res = await fetch(`${API}/documents?personId=${selectedPersonId}`); + if (!res.ok) return; + + const docs = await res.json(); + renderDocuments(docs); + updateSummaryCount('summaryDocCount', docs.length); + } + + function renderDocuments(docs) { + const container = document.getElementById('documentsList'); + const countEl = document.getElementById('docCount'); + countEl.textContent = `${docs.length} document${docs.length !== 1 ? 's' : ''}`; + + // Show/hide Process All button based on unprocessed docs + const unprocessedCount = docs.filter(d => !d.aiProcessed).length; + const processAllBtn = document.getElementById('processAllBtn'); + if (processAllBtn) { + processAllBtn.style.display = unprocessedCount > 0 ? '' : 'none'; + processAllBtn.textContent = `Process All with AI (${unprocessedCount})`; + } + + if (docs.length === 0) { + container.innerHTML = '
No documents yet. Upload a file or create a note above.
'; + return; + } + + container.innerHTML = docs.map(doc => { + const icon = getDocIcon(doc); + const displayTitle = doc.title || doc.fileName || 'Untitled'; + const meta = []; + + if (doc.documentDate) { + meta.push(formatDate(doc.documentDate)); + } + if (doc.documentType === 'file' && doc.fileSize) { + meta.push(formatSize(doc.fileSize)); + } + if (doc.documentType === 'note') { + meta.push('Note'); + } + + const classificationBadge = doc.classification + ? `${escapeHtml(formatClassification(doc.classification))}` + : ''; + + const aiStatusBadge = getAiStatusBadge(doc); + + const downloadBtn = doc.documentType === 'file' + ? `` + : ''; + + const processBtn = !doc.aiProcessed + ? `` + : ''; + + return `
+
${icon}
+
+
${escapeHtml(displayTitle)}
+
+ ${meta.join(' · ')} + ${classificationBadge} + ${aiStatusBadge} +
+ ${doc.description && doc.documentType === 'note' ? `
${escapeHtml(truncate(doc.description, 150))}
` : ''} +
+
+ ${processBtn} + ${downloadBtn} + +
+
`; + }).join(''); + } + + function getAiStatusBadge(doc) { + if (doc.aiProcessed) { + return 'AI'; + } + return 'No AI'; + } + + // --- Doctors --- + + async function loadDoctors() { + const res = await fetch(`${API}/doctors`); + if (!res.ok) return; + doctors = await res.json(); + renderDoctors(); + populateDoctorDropdown(); + updateSummaryCount('summaryDrCount', doctors.length); + } + + function renderDoctors() { + const container = document.getElementById('doctorsList'); + if (doctors.length === 0) { + container.innerHTML = '
No doctors added yet.
'; + return; + } + container.innerHTML = doctors.map(doc => { + const details = [doc.specialty, doc.phone, doc.address].filter(Boolean); + return `
+
+
${escapeHtml(doc.name)}
+
${details.map(d => escapeHtml(d)).join(' · ')}
+ ${doc.notes ? `
${escapeHtml(doc.notes)}
` : ''} +
+
+ + +
+
`; + }).join(''); + } + + function populateDoctorDropdown() { + const select = document.getElementById('newRxDoctor'); + if (!select) return; + const currentVal = select.value; + select.innerHTML = '' + + doctors.map(d => ``).join(''); + select.value = currentVal; + } + + async function addDoctor() { + const name = document.getElementById('newDoctorName').value.trim(); + if (!name) return; + + const res = await fetch(`${API}/doctors`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, + specialty: document.getElementById('newDoctorSpecialty').value.trim() || null, + phone: document.getElementById('newDoctorPhone').value.trim() || null, + address: document.getElementById('newDoctorAddress').value.trim() || null + }) + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + alert(err.error || 'Failed to add doctor'); + return; + } + + document.getElementById('newDoctorName').value = ''; + document.getElementById('newDoctorSpecialty').value = ''; + document.getElementById('newDoctorPhone').value = ''; + document.getElementById('newDoctorAddress').value = ''; + await loadDoctors(); + } + + async function deleteDoctor(id) { + if (!confirm('Delete this doctor?')) return; + const res = await fetch(`${API}/doctors/${id}`, { method: 'DELETE' }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + alert(err.error || 'Failed to delete'); + return; + } + await loadDoctors(); + } + + async function editDoctor(id) { + const doc = doctors.find(d => d.id === id); + if (!doc) return; + + const card = document.getElementById(`doctor-${id}`); + if (!card) return; + + card.innerHTML = `
+
+ + + + + + +
+
`; + } + + async function saveDoctor(id) { + const name = document.getElementById(`editDoctorName-${id}`).value.trim(); + if (!name) return; + + const res = await fetch(`${API}/doctors/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, + specialty: document.getElementById(`editDoctorSpecialty-${id}`).value.trim() || null, + phone: document.getElementById(`editDoctorPhone-${id}`).value.trim() || null, + address: document.getElementById(`editDoctorAddress-${id}`).value.trim() || null + }) + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + alert(err.error || 'Failed to update doctor'); + return; + } + + await loadDoctors(); + } + + // --- Conditions --- + + async function loadConditions() { + if (!selectedPersonId) return; + const res = await fetch(`${API}/conditions?personId=${selectedPersonId}`); + if (!res.ok) return; + const conditions = await res.json(); + renderConditions(conditions); + updateSummaryCount('summaryCondCount', conditions.length); + } + + function renderConditions(conditions) { + const container = document.getElementById('conditionsList'); + if (conditions.length === 0) { + container.innerHTML = '
No conditions tracked yet.
'; + return; + } + container.innerHTML = conditions.map(c => { + const meta = []; + if (c.diagnosedDate) meta.push('Diagnosed: ' + formatDate(c.diagnosedDate)); + const statusBadge = c.isActive + ? 'Active' + : 'Inactive'; + return `
+
+
${escapeHtml(c.name)} ${statusBadge}
+ ${meta.length ? `
${meta.join(' · ')}
` : ''} + ${c.notes ? `
${escapeHtml(c.notes)}
` : ''} +
+
+ + +
+
`; + }).join(''); + } + + async function addCondition() { + const name = document.getElementById('newConditionName').value.trim(); + if (!name) return; + + const res = await fetch(`${API}/conditions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + personId: selectedPersonId, + name, + diagnosedDate: document.getElementById('newConditionDate').value || null, + notes: document.getElementById('newConditionNotes').value.trim() || null + }) + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + alert(err.error || 'Failed to add condition'); + return; + } + + document.getElementById('newConditionName').value = ''; + document.getElementById('newConditionDate').value = ''; + document.getElementById('newConditionNotes').value = ''; + await loadConditions(); + } + + async function toggleConditionActive(id, isActive) { + const res = await fetch(`${API}/conditions?personId=${selectedPersonId}`); + if (!res.ok) return; + const conditions = await res.json(); + const condition = conditions.find(c => c.id === id); + if (!condition) return; + + const updateRes = await fetch(`${API}/conditions/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: condition.name, + diagnosedDate: condition.diagnosedDate, + notes: condition.notes, + isActive + }) + }); + + if (!updateRes.ok) { + const err = await updateRes.json().catch(() => ({})); + alert(err.error || 'Failed to update condition'); + return; + } + + await loadConditions(); + } + + async function deleteCondition(id) { + if (!confirm('Delete this condition?')) return; + const res = await fetch(`${API}/conditions/${id}`, { method: 'DELETE' }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + alert(err.error || 'Failed to delete'); + return; + } + await loadConditions(); + } + + // --- Prescriptions --- + + async function loadPrescriptions() { + if (!selectedPersonId) return; + const res = await fetch(`${API}/prescriptions?personId=${selectedPersonId}`); + if (!res.ok) return; + const prescriptions = await res.json(); + renderPrescriptions(prescriptions); + updateSummaryCount('summaryRxCount', prescriptions.length); + } + + function renderPrescriptions(prescriptions) { + const container = document.getElementById('prescriptionsList'); + if (prescriptions.length === 0) { + container.innerHTML = '
No prescriptions tracked yet.
'; + return; + } + container.innerHTML = prescriptions.map(rx => { + const meta = []; + if (rx.dosage) meta.push(rx.dosage); + if (rx.frequency) meta.push(rx.frequency); + if (rx.doctorName) meta.push('Dr. ' + rx.doctorName); + if (rx.startDate) meta.push('Started: ' + formatDate(rx.startDate)); + const lastPickup = rx.lastPickupDate ? formatDate(rx.lastPickupDate) : 'None'; + const statusBadge = rx.isActive + ? 'Active' + : 'Inactive'; + return `
+
+
+
+ + ${escapeHtml(rx.medicationName)} ${statusBadge} +
+
${meta.map(m => escapeHtml(m)).join(' · ')}
+
Last pickup: ${escapeHtml(lastPickup)}
+
+
+ +
+
+ +
`; + }).join(''); + } + + async function addPrescription() { + const medication = document.getElementById('newRxMedication').value.trim(); + if (!medication) return; + + const doctorId = document.getElementById('newRxDoctor').value; + + const res = await fetch(`${API}/prescriptions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + personId: selectedPersonId, + medicationName: medication, + dosage: document.getElementById('newRxDosage').value.trim() || null, + frequency: document.getElementById('newRxFrequency').value.trim() || null, + doctorId: doctorId ? parseInt(doctorId) : null, + startDate: document.getElementById('newRxStartDate').value || null + }) + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + alert(err.error || 'Failed to add prescription'); + return; + } + + document.getElementById('newRxMedication').value = ''; + document.getElementById('newRxDosage').value = ''; + document.getElementById('newRxFrequency').value = ''; + document.getElementById('newRxDoctor').value = ''; + document.getElementById('newRxStartDate').value = ''; + await loadPrescriptions(); + } + + async function deletePrescription(id) { + if (!confirm('Delete this prescription and all its pickup history?')) return; + const res = await fetch(`${API}/prescriptions/${id}`, { method: 'DELETE' }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + alert(err.error || 'Failed to delete'); + return; + } + await loadPrescriptions(); + } + + async function togglePickups(rxId) { + const section = document.getElementById(`pickups-${rxId}`); + const expandBtn = document.getElementById(`expand-${rxId}`); + if (section.style.display === 'none') { + section.style.display = ''; + expandBtn.innerHTML = '▼'; + await loadPickups(rxId); + } else { + section.style.display = 'none'; + expandBtn.innerHTML = '▶'; + } + } + + async function loadPickups(rxId) { + const res = await fetch(`${API}/prescriptions/${rxId}/pickups`); + if (!res.ok) return; + const pickups = await res.json(); + const container = document.getElementById(`pickupList-${rxId}`); + if (pickups.length === 0) { + container.innerHTML = '
No pickups logged.
'; + return; + } + container.innerHTML = pickups.map(p => { + const meta = []; + if (p.quantity) meta.push(p.quantity); + if (p.pharmacy) meta.push(p.pharmacy); + if (p.cost != null) meta.push('$' + parseFloat(p.cost).toFixed(2)); + return `
+
+ ${formatDate(p.pickupDate)} + ${meta.length ? `${meta.map(m => escapeHtml(m)).join(' · ')}` : ''} + ${p.notes ? `${escapeHtml(p.notes)}` : ''} +
+ +
`; + }).join(''); + } + + async function addPickup(rxId) { + const date = document.getElementById(`pickupDate-${rxId}`).value; + if (!date) { + alert('Pickup date is required'); + return; + } + + const costStr = document.getElementById(`pickupCost-${rxId}`).value; + + const res = await fetch(`${API}/prescriptions/${rxId}/pickups`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + pickupDate: date, + quantity: document.getElementById(`pickupQty-${rxId}`).value.trim() || null, + pharmacy: document.getElementById(`pickupPharmacy-${rxId}`).value.trim() || null, + cost: costStr ? parseFloat(costStr) : null + }) + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + alert(err.error || 'Failed to log pickup'); + return; + } + + document.getElementById(`pickupDate-${rxId}`).value = ''; + document.getElementById(`pickupQty-${rxId}`).value = ''; + document.getElementById(`pickupPharmacy-${rxId}`).value = ''; + document.getElementById(`pickupCost-${rxId}`).value = ''; + await loadPickups(rxId); + await loadPrescriptions(); + } + + async function deletePickup(id, rxId) { + if (!confirm('Delete this pickup record?')) return; + const res = await fetch(`${API}/pickups/${id}`, { method: 'DELETE' }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + alert(err.error || 'Failed to delete'); + return; + } + await loadPickups(rxId); + await loadPrescriptions(); + } + + // --- Helpers --- + + function getDocIcon(doc) { + if (doc.documentType === 'note') { + return ''; + } + const mime = (doc.mimeType || '').toLowerCase(); + if (mime.startsWith('image/')) { + return ''; + } + if (mime === 'application/pdf') { + return ''; + } + // Default file icon + return ''; + } + + function formatClassification(c) { + return c.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); + } + + function formatSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + } + + function formatDate(dateStr) { + const d = new Date(dateStr); + return d.toLocaleDateString(); + } + + function truncate(str, max) { + return str.length > max ? str.substring(0, max) + '...' : str; + } + + function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + function escapeAttr(str) { + return str.replace(/'/g, "\\'").replace(/"/g, '\\"'); + } + + // --- Global functions for inline handlers --- + + window.medDocsSelectPerson = function (id) { + selectPerson(id); + }; + + window.medDocsSwitchTab = function (tabName) { + switchMainTab(tabName); + }; + + window.medDocsToggleAddForm = function (panelId) { + toggleAddForm(panelId); + }; + + window.medDocsDownload = function (id, fileName) { + const a = document.createElement('a'); + a.href = `${API}/documents/${id}/download`; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }; + + window.medDocsDelete = async function (id) { + if (!confirm('Delete this document? This cannot be undone.')) return; + + const res = await fetch(`${API}/documents/${id}`, { method: 'DELETE' }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + alert(err.error || 'Failed to delete'); + return; + } + await loadDocuments(); + }; + + window.medDocsProcess = async function (id, btn) { + if (btn) { + btn.disabled = true; + btn.textContent = 'Processing...'; + btn.classList.add('processing'); + } + + try { + const res = await fetch(`${API}/documents/${id}/process`, { method: 'POST' }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + alert(err.error || 'Failed to start processing'); + if (btn) { + btn.disabled = false; + btn.textContent = 'Process AI'; + btn.classList.remove('processing'); + } + return; + } + + if (btn) { + btn.textContent = 'Queued'; + } + + // Reload after a delay to pick up results + setTimeout(() => loadDocuments(), 5000); + } catch { + if (btn) { + btn.disabled = false; + btn.textContent = 'Process AI'; + btn.classList.remove('processing'); + } + } + }; + + // --- Doctor globals --- + + window.medDocsAddDoctor = function () { addDoctor(); }; + window.medDocsDeleteDoctor = function (id) { deleteDoctor(id); }; + window.medDocsEditDoctor = function (id) { editDoctor(id); }; + window.medDocsSaveDoctor = function (id) { saveDoctor(id); }; + window.medDocsCancelEditDoctor = function () { renderDoctors(); }; + + // --- Condition globals --- + + window.medDocsAddCondition = function () { addCondition(); }; + window.medDocsDeleteCondition = function (id) { deleteCondition(id); }; + window.medDocsToggleCondition = function (id, isActive) { toggleConditionActive(id, isActive); }; + + // --- Prescription globals --- + + window.medDocsAddPrescription = function () { addPrescription(); }; + window.medDocsDeletePrescription = function (id) { deletePrescription(id); }; + window.medDocsTogglePickups = function (rxId) { togglePickups(rxId); }; + window.medDocsAddPickup = function (rxId) { addPickup(rxId); }; + window.medDocsDeletePickup = function (id, rxId) { deletePickup(id, rxId); }; + + window.medDocsProcessAll = async function () { + const btn = document.getElementById('processAllBtn'); + if (btn) { + btn.disabled = true; + btn.textContent = 'Processing...'; + } + + try { + const res = await fetch(`${API}/documents/process-all`, { method: 'POST' }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + alert(err.error || 'Failed to start processing'); + if (btn) { + btn.disabled = false; + } + await loadDocuments(); + return; + } + + const data = await res.json(); + if (btn) { + btn.textContent = `Queued ${data.queued} docs`; + } + + // Reload after a delay to pick up results + setTimeout(() => loadDocuments(), 8000); + } catch { + if (btn) { + btn.disabled = false; + } + await loadDocuments(); + } + }; +})();