Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eab4e51a65 | ||
|
|
0bee491629 | ||
|
|
d0f5b843a4 | ||
|
|
b2d0c72541 | ||
|
|
941759f7c3 | ||
|
|
2443b92c23 | ||
|
|
d8015f54ef | ||
|
|
d1eb772e89 | ||
|
|
caad111457 | ||
|
|
9c4ff64070 | ||
|
|
639ace0030 | ||
|
|
cb0df5728c | ||
|
|
0393dacf63 | ||
|
|
7bbd679171 | ||
|
|
e3d9c8f273 | ||
|
|
64107d2e52 | ||
|
|
15a84b2027 | ||
|
|
f1e2aad8bf | ||
|
|
cb86e15cc0 | ||
|
|
9b182eaedf | ||
|
|
9f3f610025 | ||
|
|
a3c90ad06e | ||
|
|
7dc70767d2 | ||
|
|
bb35460097 | ||
|
|
dea8d1bf90 | ||
|
|
d0c8c1249b | ||
|
|
7e6ce61f03 | ||
|
|
e971a5f329 | ||
|
|
91ef4f41dd | ||
|
|
e3d94b9f43 | ||
|
|
cfd6bb530b | ||
|
|
c173677f57 | ||
|
|
dda6ac68dc | ||
|
|
8982011155 | ||
|
|
af309ca8d6 | ||
|
|
811f732495 | ||
|
|
6b83d7df0f | ||
|
|
6814270b7c | ||
|
|
9ede3ae53f | ||
|
|
e69e5663ce | ||
|
|
14d748de80 | ||
|
|
9bbbe3aaad | ||
|
|
c9aa9886e4 | ||
|
|
a26ffec104 | ||
|
|
c35396f906 | ||
|
|
dae8fbfe5e | ||
|
|
9e9439eec9 | ||
|
|
dd6995bfc7 |
@@ -6,7 +6,14 @@
|
||||
"Bash(powershell:*)",
|
||||
"Bash(dotnet script:*)",
|
||||
"Bash(taskkill:*)",
|
||||
"Bash(dir:*)"
|
||||
"Bash(dir:*)",
|
||||
"Bash(cd:*)",
|
||||
"Bash(tree:*)",
|
||||
"Bash(del Index.cshtml Index.cshtml.cs)",
|
||||
"Bash(dotnet build:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(ls:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# .gitea/workflows/deploy.yml
|
||||
name: Deploy Media Server
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: deploy-media-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Install toolchain
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends rsync openssh-client
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore
|
||||
|
||||
- name: Publish
|
||||
run: dotnet publish -c Release -o ./publish
|
||||
|
||||
- name: Prepare SSH
|
||||
env:
|
||||
SSH_KEY: ${{ secrets.MEDIA_SSH_KEY }}
|
||||
SSH_HOST: ${{ secrets.MEDIA_SSH_HOST }}
|
||||
SSH_PORT: ${{ secrets.MEDIA_SSH_PORT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PORT="${SSH_PORT:-22}"
|
||||
install -m 700 -d ~/.ssh
|
||||
printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh-keyscan -p "$PORT" "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
# Staged through /tmp because the deploy user can't write /var/www directly.
|
||||
# /usr/local/sbin/deploy-media (root-owned, the only command in deploy's
|
||||
# sudoers) moves it into place with --exclude=App_Data, so the uploaded
|
||||
# media and its encrypted store are never touched by --delete.
|
||||
- name: Stage publish output
|
||||
env:
|
||||
SSH_HOST: ${{ secrets.MEDIA_SSH_HOST }}
|
||||
SSH_PORT: ${{ secrets.MEDIA_SSH_PORT }}
|
||||
SSH_USER: ${{ secrets.MEDIA_SSH_USER }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PORT="${SSH_PORT:-22}"
|
||||
SSH_OPTS="-p $PORT -i $HOME/.ssh/deploy_key -o StrictHostKeyChecking=yes"
|
||||
ssh $SSH_OPTS "$SSH_USER@$SSH_HOST" \
|
||||
"rm -rf /tmp/media-app-stage && mkdir -p /tmp/media-app-stage"
|
||||
rsync -az --delete -e "ssh $SSH_OPTS" \
|
||||
publish/ "$SSH_USER@$SSH_HOST:/tmp/media-app-stage/"
|
||||
|
||||
- name: Install and restart
|
||||
env:
|
||||
SSH_HOST: ${{ secrets.MEDIA_SSH_HOST }}
|
||||
SSH_PORT: ${{ secrets.MEDIA_SSH_PORT }}
|
||||
SSH_USER: ${{ secrets.MEDIA_SSH_USER }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PORT="${SSH_PORT:-22}"
|
||||
ssh -p "$PORT" -i "$HOME/.ssh/deploy_key" "$SSH_USER@$SSH_HOST" \
|
||||
"sudo -n /usr/local/sbin/deploy-media && rm -rf /tmp/media-app-stage"
|
||||
@@ -0,0 +1,56 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Full-stack ASP.NET Core 8 media management application (Media.JoshHeaps.Net) with encrypted photo storage, folder organization with sharing, and knowledge graph visualization. PostgreSQL backend, Razor Pages frontend with vanilla JavaScript.
|
||||
|
||||
## Build & Run
|
||||
|
||||
```bash
|
||||
# Restore and build
|
||||
dotnet build Media.JoshHeaps.Net.sln
|
||||
|
||||
# Run the app (HTTPS: localhost:7007, HTTP: localhost:5029)
|
||||
dotnet run --project Media.JoshHeaps.Net
|
||||
|
||||
# Run database migrations (requires psql on PATH)
|
||||
./run-migrations.ps1 -ConnectionString "<connection_string>"
|
||||
```
|
||||
|
||||
Sensitive config (DB connection string, JWT signing key, encryption key, email credentials) is stored in .NET User Secrets (ID: `1ee15d15-1d19-471d-8772-ce72aeaafbd3`). No test project exists.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend Layers
|
||||
|
||||
- **Api/** — REST controllers. Routes: `/api/auth/`, `/api/media/`, `/api/folder/`, `/api/folder-share/`, `/api/graph/`
|
||||
- **Services/** — Business logic (AuthService, MediaService, FolderService, GraphService, EmailService, EncryptionService, UserService). Registered via DI in Program.cs.
|
||||
- **Models/** — Data models shared between API and Razor Pages
|
||||
- **Pages/** — Razor Pages for server-rendered UI. Protected pages inherit from `AuthenticatedPageModel` (session-based auth).
|
||||
|
||||
### Authentication
|
||||
|
||||
Dual auth scheme: **session cookies** for Razor Pages, **JWT Bearer** for API endpoints. Session timeout is 2 hours; JWT expiry is 30 days. Account locks after 5 failed login attempts for 15 minutes.
|
||||
|
||||
### Database
|
||||
|
||||
PostgreSQL via `DbExecutor` singleton — a custom async query executor using raw Npgsql (no ORM). Parameterized queries use reflection on anonymous objects. Migration scripts in `Database/` are numbered 001–011 and must run in order (each script's dependencies come before it). **All database changes must be backwards-compatible with the existing production database — never drop tables, columns, or alter data in ways that could cause data loss. Use additive migrations (ADD COLUMN, CREATE TABLE, etc.) and `IF NOT EXISTS` guards where appropriate.**
|
||||
|
||||
### File Storage
|
||||
|
||||
Media files are encrypted with AES-256-CBC before storage in `App_Data/media/{userId}/`. Each file gets a random IV. Files are decrypted on-demand when served. Max upload: 10MB, allowed types: JPEG, PNG, GIF, WEBP.
|
||||
|
||||
### Frontend
|
||||
|
||||
Razor Pages + vanilla JS + Bootstrap 5. Key JS files in `wwwroot/js/`:
|
||||
- `gallery.js` / `folders.js` / `upload.js` / `drag-drop.js` — gallery and folder UI
|
||||
- `folder-sharing.js` — sharing modal and permissions
|
||||
- `network-graph.js` — D3-based graph visualization with community detection
|
||||
- `context-menu.js` — right-click context menus
|
||||
- `theme.js` — dark/light theme toggle
|
||||
|
||||
### Key Dependencies
|
||||
|
||||
Npgsql (PostgreSQL), BCrypt.Net-Next (password hashing), MailKit (email), SixLabors.ImageSharp (image processing), Microsoft.AspNetCore.Authentication.JwtBearer
|
||||
@@ -0,0 +1,45 @@
|
||||
# Medical Documentation System — Roadmap
|
||||
|
||||
A dedicated admin-only section for organizing medical documents (receipts, doctor notes, recorded conversations, lab results, etc.) for multiple family members. Uses Claude AI to auto-classify, tag, and extract structured data from uploaded documents. Completely separate from the existing media/gallery system.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Database Foundation & Core Document Management ✅
|
||||
Tables for people, documents, tags, doctors, conditions, prescriptions, costs. Basic CRUD service. Admin-gated Razor Page with file upload and plain-text note entry.
|
||||
|
||||
## Phase 2: People & Document Browsing ✅
|
||||
UI for managing people (family members). Document list with filtering by person, type icons, download/preview.
|
||||
|
||||
## Phase 3: Claude AI Integration ✅
|
||||
`MedicalAiService` using Claude API. On upload: OCR, text extraction, auto-classification, auto-tagging, structured data extraction. Manual transcript field for audio files.
|
||||
|
||||
## Phase 4: Doctors, Conditions & Prescription Tracking ✅
|
||||
Doctor/condition management (global doctors, per-person conditions). Prescription tracking with doctor linkage, expandable pickup history, and "last pickup" display. Inline add/edit/delete for all entities.
|
||||
|
||||
## Phase 5: AI CLI Migration ✅
|
||||
Replaced Claude HTTP API with `claude -p` CLI pipe mode. Sequential `Channel<long>` background queue replaces fire-and-forget `Task.Run`. Rate limit detection parses reset time from CLI output and pauses the queue. Temp file approach for image/PDF OCR via CLI with `--allowedTools Read`.
|
||||
|
||||
## Phase 6: Bills & Payments ✅
|
||||
Replaced the flat `medical_document_costs` model (which double/triple-counted AI-extracted line items) with a proper billing system. Bills represent unique charges; payments track money applied toward them (patient payments, insurance payments, adjustments, write-offs). Summary card shows out-of-pocket vs total charged. Bills support linked documents, expandable payment lists, and filter by paid/unpaid status. Old costs API endpoints preserved for backward compatibility with AI processing.
|
||||
|
||||
## Phase 7: AI Bills Integration ✅
|
||||
Updated AI extraction prompt to create bills + payments instead of flat costs. On re-process: deletes AI-sourced bills/payments for document, re-creates (prevents duplicates). Smart matching: if extracted charge matches existing bill for same person (same amount, category, date within 30 days), links document instead of creating new. Removed `AddCostsAsync`, all costs CRUD methods, costs API endpoints, and `MedicalDocumentCost` model. DB table retained per policy.
|
||||
|
||||
## Phase 8: Search & Filtering ⬅️ **Up Next**
|
||||
Full-text search on extracted text. Filter by person, doctor, condition, tags, document type, date range. Combined filters.
|
||||
|
||||
## Phase 9: AI-Enhanced Insights
|
||||
Medical timeline per person. Visit prep summaries. Batch re-analysis when AI improves.
|
||||
|
||||
## Phase 10: Polish & Hardening
|
||||
Pagination/lazy-loading. Export (PDF summary, CSV costs). Mobile-responsive UI.
|
||||
|
||||
---
|
||||
|
||||
## Feature requests (I'm writing these down for me. I may ask you to do these in the future, so please design any changes with the fact in mind that they may need to acommodate these)
|
||||
|
||||
## Calendar
|
||||
A calendar that's easy to navigate, and shows what documents are on each day. If I see the month of January, at the very least, I should see something on the calendar indicating which days in January a document is associated with.
|
||||
|
||||
## Custom Colors
|
||||
An easy way to customize colors instead of just having a light mode/dark mode. I still want to have light mode/dark mode as defaults, but custom colors should be an option as well.
|
||||
@@ -0,0 +1,184 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Api;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin")]
|
||||
public class AdminApi(DbExecutor dbExecutor) : ControllerBase
|
||||
{
|
||||
[HttpGet("users")]
|
||||
public async Task<IActionResult> GetUsers([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||
{
|
||||
var authUserId = GetUserIdFromAuth();
|
||||
if (authUserId == null) return Unauthorized();
|
||||
|
||||
if (!await IsAdmin(authUserId.Value)) return Forbid();
|
||||
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1 || pageSize > 100) pageSize = 20;
|
||||
var offset = (page - 1) * pageSize;
|
||||
|
||||
var totalCount = await dbExecutor.ExecuteAsync<long>(
|
||||
"SELECT COUNT(*) FROM app.users");
|
||||
|
||||
var users = await dbExecutor.ExecuteListReaderAsync(
|
||||
@"SELECT u.id, u.username, u.email, u.is_active
|
||||
FROM app.users u
|
||||
ORDER BY u.id
|
||||
LIMIT @PageSize OFFSET @Offset",
|
||||
reader => new
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
Username = reader.GetString(1),
|
||||
Email = reader.GetString(2),
|
||||
IsActive = reader.GetBoolean(3)
|
||||
},
|
||||
new { PageSize = pageSize, Offset = offset });
|
||||
|
||||
// Fetch all roles for these users in one query using a subquery
|
||||
var userRoles = users.Count > 0
|
||||
? await dbExecutor.ExecuteListReaderAsync(
|
||||
@"SELECT ur.user_id, r.id, r.name
|
||||
FROM app.user_roles ur
|
||||
JOIN app.roles r ON ur.role_id = r.id
|
||||
WHERE ur.user_id IN (
|
||||
SELECT u.id FROM app.users u ORDER BY u.id LIMIT @PageSize OFFSET @Offset
|
||||
)",
|
||||
reader => new
|
||||
{
|
||||
UserId = reader.GetInt64(0),
|
||||
RoleId = reader.GetInt64(1),
|
||||
RoleName = reader.GetString(2)
|
||||
},
|
||||
new { PageSize = pageSize, Offset = offset })
|
||||
: [];
|
||||
|
||||
var result = users.Select(u => new
|
||||
{
|
||||
u.Id,
|
||||
u.Username,
|
||||
u.Email,
|
||||
u.IsActive,
|
||||
Roles = userRoles.Where(r => r.UserId == u.Id)
|
||||
.Select(r => new { Id = r.RoleId, Name = r.RoleName })
|
||||
.ToList()
|
||||
});
|
||||
|
||||
return Ok(new { users = result, totalCount });
|
||||
}
|
||||
|
||||
[HttpGet("roles")]
|
||||
public async Task<IActionResult> GetRoles()
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
if (!await IsAdmin(userId.Value)) return Forbid();
|
||||
|
||||
var roles = await dbExecutor.ExecuteListReaderAsync(
|
||||
"SELECT id, name FROM app.roles ORDER BY id",
|
||||
reader => new { Id = reader.GetInt64(0), Name = reader.GetString(1) });
|
||||
|
||||
return Ok(roles);
|
||||
}
|
||||
|
||||
[HttpPost("roles")]
|
||||
public async Task<IActionResult> CreateRole([FromBody] CreateRoleRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
if (!await IsAdmin(userId.Value)) return Forbid();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return BadRequest(new { error = "Role name is required" });
|
||||
|
||||
var name = request.Name.Trim().ToLowerInvariant();
|
||||
|
||||
var existing = await dbExecutor.ExecuteAsync<bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM app.roles WHERE name = @Name)",
|
||||
new { Name = name });
|
||||
|
||||
if (existing)
|
||||
return BadRequest(new { error = "Role already exists" });
|
||||
|
||||
var role = await dbExecutor.ExecuteReaderAsync(
|
||||
"INSERT INTO app.roles (name) VALUES (@Name) RETURNING id, name",
|
||||
reader => new { Id = reader.GetInt64(0), Name = reader.GetString(1) },
|
||||
new { Name = name });
|
||||
|
||||
return Ok(role);
|
||||
}
|
||||
|
||||
[HttpPost("users/{targetUserId}/roles/{roleId}")]
|
||||
public async Task<IActionResult> AssignRole(long targetUserId, long roleId)
|
||||
{
|
||||
var adminId = GetUserIdFromAuth();
|
||||
if (adminId == null) return Unauthorized();
|
||||
|
||||
if (!await IsAdmin(adminId.Value)) return Forbid();
|
||||
|
||||
var userExists = await dbExecutor.ExecuteAsync<bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM app.users WHERE id = @UserId)",
|
||||
new { UserId = targetUserId });
|
||||
if (!userExists) return NotFound(new { error = "User not found" });
|
||||
|
||||
var roleExists = await dbExecutor.ExecuteAsync<bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM app.roles WHERE id = @RoleId)",
|
||||
new { RoleId = roleId });
|
||||
if (!roleExists) return NotFound(new { error = "Role not found" });
|
||||
|
||||
var alreadyAssigned = await dbExecutor.ExecuteAsync<bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM app.user_roles WHERE user_id = @UserId AND role_id = @RoleId)",
|
||||
new { UserId = targetUserId, RoleId = roleId });
|
||||
if (alreadyAssigned) return Ok(new { success = true });
|
||||
|
||||
await dbExecutor.ExecuteNonQueryAsync(
|
||||
"INSERT INTO app.user_roles (user_id, role_id) VALUES (@UserId, @RoleId)",
|
||||
new { UserId = targetUserId, RoleId = roleId });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpDelete("users/{targetUserId}/roles/{roleId}")]
|
||||
public async Task<IActionResult> RemoveRole(long targetUserId, long roleId)
|
||||
{
|
||||
var adminId = GetUserIdFromAuth();
|
||||
if (adminId == null) return Unauthorized();
|
||||
|
||||
if (!await IsAdmin(adminId.Value)) return Forbid();
|
||||
|
||||
await dbExecutor.ExecuteNonQueryAsync(
|
||||
"DELETE FROM app.user_roles WHERE user_id = @UserId AND role_id = @RoleId",
|
||||
new { UserId = targetUserId, RoleId = roleId });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
private async Task<bool> IsAdmin(long userId)
|
||||
{
|
||||
return await dbExecutor.ExecuteAsync<bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM app.user_roles ur JOIN app.roles r ON ur.role_id = r.id WHERE ur.user_id = @UserId AND r.name = 'admin')",
|
||||
new { UserId = userId });
|
||||
}
|
||||
|
||||
private long? GetUserIdFromAuth()
|
||||
{
|
||||
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId))
|
||||
{
|
||||
return jwtUserId;
|
||||
}
|
||||
|
||||
var userIdString = HttpContext.Session.GetString("UserId");
|
||||
if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId))
|
||||
{
|
||||
return sessionUserId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateRoleRequest(string Name);
|
||||
@@ -11,17 +11,8 @@ namespace Media.JoshHeaps.Net.Api;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
public class AuthApi : ControllerBase
|
||||
public class AuthApi(AuthService authService, IConfiguration configuration) : ControllerBase
|
||||
{
|
||||
private readonly AuthService _authService;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public AuthApi(AuthService authService, IConfiguration configuration)
|
||||
{
|
||||
_authService = authService;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
||||
{
|
||||
@@ -30,7 +21,7 @@ public class AuthApi : ControllerBase
|
||||
return BadRequest(new { error = "Email/username and password are required" });
|
||||
}
|
||||
|
||||
var (success, error, userInfo) = await _authService.LoginAsync(request.EmailOrUsername, request.Password);
|
||||
var (success, error, userInfo) = await authService.LoginAsync(request.EmailOrUsername, request.Password);
|
||||
|
||||
if (!success || userInfo == null)
|
||||
{
|
||||
@@ -77,10 +68,10 @@ public class AuthApi : ControllerBase
|
||||
|
||||
private string GenerateJwtToken(UserLoginInfo user)
|
||||
{
|
||||
var jwtKey = _configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key not configured");
|
||||
var jwtIssuer = _configuration["Jwt:Issuer"] ?? throw new InvalidOperationException("JWT Issuer not configured");
|
||||
var jwtAudience = _configuration["Jwt:Audience"] ?? throw new InvalidOperationException("JWT Audience not configured");
|
||||
var jwtExpiryDays = int.Parse(_configuration["Jwt:ExpiryInDays"] ?? "30");
|
||||
var jwtKey = configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key not configured");
|
||||
var jwtIssuer = configuration["Jwt:Issuer"] ?? throw new InvalidOperationException("JWT Issuer not configured");
|
||||
var jwtAudience = configuration["Jwt:Audience"] ?? throw new InvalidOperationException("JWT Audience not configured");
|
||||
var jwtExpiryDays = int.Parse(configuration["Jwt:ExpiryInDays"] ?? "30");
|
||||
|
||||
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey));
|
||||
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
using Media.JoshHeaps.Net.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Api;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/blog")]
|
||||
public class BlogApi(BlogService blogService, DbExecutor dbExecutor, IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger<BlogApi> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet("posts")]
|
||||
public async Task<IActionResult> GetPosts()
|
||||
{
|
||||
var posts = await blogService.GetAllPostsAsync();
|
||||
return Ok(posts.Select(MapToPublicDto));
|
||||
}
|
||||
|
||||
[HttpGet("posts/{slug}")]
|
||||
public async Task<IActionResult> GetPostBySlug(string slug)
|
||||
{
|
||||
var post = await blogService.GetPostBySlugAsync(slug);
|
||||
if (post is null) return NotFound();
|
||||
return Ok(MapToPublicDto(post));
|
||||
}
|
||||
|
||||
[HttpGet("posts/tags/{tag}")]
|
||||
public async Task<IActionResult> GetPostsByTag(string tag)
|
||||
{
|
||||
var posts = await blogService.GetPostsByTagAsync(tag);
|
||||
return Ok(posts.Select(MapToPublicDto));
|
||||
}
|
||||
|
||||
[HttpGet("posts/admin/{id}")]
|
||||
public async Task<IActionResult> GetPostForAdmin(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId is null) return Unauthorized();
|
||||
if (!await IsAdmin(userId.Value)) return Forbid();
|
||||
|
||||
var post = await blogService.GetPostByIdAsync(id);
|
||||
if (post is null) return NotFound();
|
||||
return Ok(MapToAdminDto(post));
|
||||
}
|
||||
|
||||
[HttpPost("posts")]
|
||||
public async Task<IActionResult> CreatePost([FromBody] CreateBlogPostRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId is null) return Unauthorized();
|
||||
if (!await IsAdmin(userId.Value)) return Forbid();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Title))
|
||||
return BadRequest(new { error = "Title is required" });
|
||||
if (string.IsNullOrWhiteSpace(request.MarkdownContent))
|
||||
return BadRequest(new { error = "Content is required" });
|
||||
|
||||
var post = await blogService.CreatePostAsync(
|
||||
request.Title.Trim(),
|
||||
request.Summary?.Trim() ?? "",
|
||||
request.MarkdownContent,
|
||||
request.Tags ?? [],
|
||||
userId.Value,
|
||||
request.PublishedDate ?? DateTime.UtcNow);
|
||||
|
||||
if (post is null)
|
||||
return StatusCode(500, new { error = "Failed to create post" });
|
||||
|
||||
_ = InvalidateFrontendCacheAsync();
|
||||
return Ok(MapToAdminDto(post));
|
||||
}
|
||||
|
||||
[HttpPut("posts/{id}")]
|
||||
public async Task<IActionResult> UpdatePost(long id, [FromBody] UpdateBlogPostRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId is null) return Unauthorized();
|
||||
if (!await IsAdmin(userId.Value)) return Forbid();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Title))
|
||||
return BadRequest(new { error = "Title is required" });
|
||||
if (string.IsNullOrWhiteSpace(request.MarkdownContent))
|
||||
return BadRequest(new { error = "Content is required" });
|
||||
|
||||
var post = await blogService.UpdatePostAsync(
|
||||
id,
|
||||
request.Title.Trim(),
|
||||
request.Summary?.Trim() ?? "",
|
||||
request.MarkdownContent,
|
||||
request.Tags ?? [],
|
||||
request.PublishedDate ?? DateTime.UtcNow);
|
||||
|
||||
if (post is null)
|
||||
return NotFound(new { error = "Post not found" });
|
||||
|
||||
_ = InvalidateFrontendCacheAsync();
|
||||
return Ok(MapToAdminDto(post));
|
||||
}
|
||||
|
||||
[HttpDelete("posts/{id}")]
|
||||
public async Task<IActionResult> DeletePost(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId is null) return Unauthorized();
|
||||
if (!await IsAdmin(userId.Value)) return Forbid();
|
||||
|
||||
var deleted = await blogService.DeletePostAsync(id);
|
||||
if (!deleted) return NotFound(new { error = "Post not found" });
|
||||
|
||||
_ = InvalidateFrontendCacheAsync();
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpPost("images")]
|
||||
public async Task<IActionResult> UploadImage(IFormFile file)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId is null) return Unauthorized();
|
||||
if (!await IsAdmin(userId.Value)) return Forbid();
|
||||
|
||||
if (file is null || file.Length == 0)
|
||||
return BadRequest(new { error = "No file provided" });
|
||||
|
||||
var (url, error) = await blogService.SaveImageAsync(file);
|
||||
if (url is null)
|
||||
return BadRequest(new { error });
|
||||
|
||||
return Ok(new { url });
|
||||
}
|
||||
|
||||
[HttpGet("images/{fileName}")]
|
||||
public IActionResult GetImage(string fileName)
|
||||
{
|
||||
var (filePath, mimeType) = blogService.GetImagePath(fileName);
|
||||
if (filePath is null)
|
||||
return NotFound();
|
||||
|
||||
return PhysicalFile(filePath, mimeType!);
|
||||
}
|
||||
|
||||
private static object MapToPublicDto(Models.BlogPost post) => new
|
||||
{
|
||||
post.Id,
|
||||
post.Slug,
|
||||
post.Title,
|
||||
post.Summary,
|
||||
post.Tags,
|
||||
post.PublishedDate,
|
||||
post.HtmlContent
|
||||
};
|
||||
|
||||
private static object MapToAdminDto(Models.BlogPost post) => new
|
||||
{
|
||||
post.Id,
|
||||
post.Slug,
|
||||
post.Title,
|
||||
post.Summary,
|
||||
post.MarkdownContent,
|
||||
post.Tags,
|
||||
post.PublishedDate,
|
||||
post.CreatedAt,
|
||||
post.UpdatedAt
|
||||
};
|
||||
|
||||
private async Task InvalidateFrontendCacheAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = configuration["BlogCache:InvalidateUrl"];
|
||||
var key = configuration["BlogCache:InvalidateKey"];
|
||||
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(key)) return;
|
||||
|
||||
var client = httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, url);
|
||||
request.Headers.Add("X-Invalidate-Key", key);
|
||||
await client.SendAsync(request);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to invalidate frontend blog cache");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> IsAdmin(long userId)
|
||||
{
|
||||
return await dbExecutor.ExecuteAsync<bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM app.user_roles ur JOIN app.roles r ON ur.role_id = r.id WHERE ur.user_id = @UserId AND r.name = 'admin')",
|
||||
new { UserId = userId });
|
||||
}
|
||||
|
||||
private long? GetUserIdFromAuth()
|
||||
{
|
||||
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId))
|
||||
return jwtUserId;
|
||||
|
||||
var userIdString = HttpContext.Session.GetString("UserId");
|
||||
if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId))
|
||||
return sessionUserId;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateBlogPostRequest(string Title, string? Summary, string MarkdownContent, List<string>? Tags, DateTime? PublishedDate);
|
||||
public record UpdateBlogPostRequest(string Title, string? Summary, string MarkdownContent, List<string>? Tags, DateTime? PublishedDate);
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Media.JoshHeaps.Net.Models;
|
||||
using Media.JoshHeaps.Net.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Api;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/breadboard")]
|
||||
public class BreadboardApi(BreadboardService breadboardService) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Pipeline-level guard so an oversized body is rejected before it is buffered into a
|
||||
/// string. The validator's 2 MB circuit cap is the real limit; the extra megabyte is
|
||||
/// headroom for the JSON envelope around it.
|
||||
/// </summary>
|
||||
private const long MaxRequestBodyBytes = 3L * 1024 * 1024;
|
||||
|
||||
[HttpGet("projects")]
|
||||
public async Task<IActionResult> ListProjects()
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(Problems("Not authenticated"));
|
||||
}
|
||||
|
||||
var projects = await breadboardService.GetProjectsAsync(userId.Value);
|
||||
return Ok(projects);
|
||||
}
|
||||
|
||||
[HttpPost("projects")]
|
||||
[RequestSizeLimit(MaxRequestBodyBytes)]
|
||||
public async Task<IActionResult> CreateProject([FromBody] CreateBreadboardProjectRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(Problems("Not authenticated"));
|
||||
}
|
||||
|
||||
var result = await breadboardService.CreateProjectAsync(userId.Value, request.Name, request.Description);
|
||||
return MapResult(result);
|
||||
}
|
||||
|
||||
[HttpGet("projects/{projectId:long}")]
|
||||
public async Task<IActionResult> GetProject(long projectId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(Problems("Not authenticated"));
|
||||
}
|
||||
|
||||
var project = await breadboardService.GetProjectAsync(projectId, userId.Value);
|
||||
if (project == null)
|
||||
{
|
||||
return NotFound(Problems(BreadboardResult.NotFoundMessage));
|
||||
}
|
||||
|
||||
return Ok(project);
|
||||
}
|
||||
|
||||
[HttpPut("projects/{projectId:long}")]
|
||||
[RequestSizeLimit(MaxRequestBodyBytes)]
|
||||
public async Task<IActionResult> UpdateProject(long projectId, [FromBody] UpdateBreadboardProjectRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(Problems("Not authenticated"));
|
||||
}
|
||||
|
||||
var result = await breadboardService.UpdateProjectAsync(
|
||||
projectId,
|
||||
userId.Value,
|
||||
request.Name,
|
||||
request.Description,
|
||||
RawCircuit(request.Circuit));
|
||||
|
||||
return MapResult(result);
|
||||
}
|
||||
|
||||
[HttpDelete("projects/{projectId:long}")]
|
||||
public async Task<IActionResult> DeleteProject(long projectId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(Problems("Not authenticated"));
|
||||
}
|
||||
|
||||
var result = await breadboardService.DeleteProjectAsync(projectId, userId.Value);
|
||||
return MapResult(result);
|
||||
}
|
||||
|
||||
/// <summary>A project that belongs to someone else is reported as missing, never as forbidden.</summary>
|
||||
private IActionResult MapResult(BreadboardResult result) => result.Outcome switch
|
||||
{
|
||||
BreadboardOutcome.Success => result.Project is null ? NoContent() : Ok(result.Project),
|
||||
BreadboardOutcome.NotFound => NotFound(new { errors = result.Errors }),
|
||||
BreadboardOutcome.Invalid => BadRequest(new { errors = result.Errors }),
|
||||
_ => StatusCode(StatusCodes.Status500InternalServerError, new { errors = result.Errors })
|
||||
};
|
||||
|
||||
private static object Problems(string message) => new { errors = new[] { message } };
|
||||
|
||||
/// <summary>
|
||||
/// An omitted circuit and an explicit JSON null both mean "leave the circuit alone".
|
||||
/// Anything else is handed to the service as raw text — the server never reshapes the document.
|
||||
/// </summary>
|
||||
private static string? RawCircuit(JsonElement? circuit) =>
|
||||
circuit is { ValueKind: not JsonValueKind.Undefined and not JsonValueKind.Null } element
|
||||
? element.GetRawText()
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// JWT first, then the session cookie — the repo-wide pattern.
|
||||
/// CSRF note: the session path carries no antiforgery token, and is safe today only
|
||||
/// because of three framework defaults — session cookies are SameSite=Lax, no CORS policy
|
||||
/// is registered, and an application/json body forces a preflight. Adding a permissive
|
||||
/// CORS policy or SameSite=None anywhere in this app makes these writes CSRF-able.
|
||||
/// </summary>
|
||||
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 CreateBreadboardProjectRequest(string? Name, string? Description);
|
||||
|
||||
public record UpdateBreadboardProjectRequest(string? Name, string? Description, JsonElement? Circuit);
|
||||
@@ -6,15 +6,8 @@ namespace Media.JoshHeaps.Net.Api;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/folder")]
|
||||
public class FolderApi : ControllerBase
|
||||
public class FolderApi(FolderService folderService) : ControllerBase
|
||||
{
|
||||
private readonly FolderService _folderService;
|
||||
|
||||
public FolderApi(FolderService folderService)
|
||||
{
|
||||
_folderService = folderService;
|
||||
}
|
||||
|
||||
[HttpGet("list")]
|
||||
public async Task<IActionResult> ListFolders([FromQuery] long? folderId = null)
|
||||
{
|
||||
@@ -24,7 +17,7 @@ public class FolderApi : ControllerBase
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var folders = await _folderService.GetUserFoldersAsync(userId.Value, folderId);
|
||||
var folders = await folderService.GetUserFoldersAsync(userId.Value, folderId);
|
||||
return Ok(folders);
|
||||
}
|
||||
|
||||
@@ -37,7 +30,7 @@ public class FolderApi : ControllerBase
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var path = await _folderService.GetFolderPathAsync(folderId, userId.Value);
|
||||
var path = await folderService.GetFolderPathAsync(folderId, userId.Value);
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
@@ -58,14 +51,14 @@ public class FolderApi : ControllerBase
|
||||
// If parent folder specified, check ownership (can't create in shared folders)
|
||||
if (request.ParentFolderId.HasValue)
|
||||
{
|
||||
var parentOwnerId = await _folderService.GetFolderOwnerIdAsync(request.ParentFolderId.Value);
|
||||
var parentOwnerId = await folderService.GetFolderOwnerIdAsync(request.ParentFolderId.Value);
|
||||
if (parentOwnerId != userId.Value)
|
||||
{
|
||||
return Forbid("Cannot create folders in shared folders");
|
||||
}
|
||||
}
|
||||
|
||||
var folder = await _folderService.CreateFolderAsync(userId.Value, request.Name, request.ParentFolderId);
|
||||
var folder = await folderService.CreateFolderAsync(userId.Value, request.Name, request.ParentFolderId);
|
||||
if (folder == null)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to create folder" });
|
||||
@@ -89,13 +82,13 @@ public class FolderApi : ControllerBase
|
||||
}
|
||||
|
||||
// Check ownership (can't rename shared folders)
|
||||
var ownerId = await _folderService.GetFolderOwnerIdAsync(request.FolderId);
|
||||
var ownerId = await folderService.GetFolderOwnerIdAsync(request.FolderId);
|
||||
if (ownerId != userId.Value)
|
||||
{
|
||||
return Forbid("Cannot rename shared folders");
|
||||
}
|
||||
|
||||
var success = await _folderService.RenameFolderAsync(request.FolderId, userId.Value, request.NewName);
|
||||
var success = await folderService.RenameFolderAsync(request.FolderId, userId.Value, request.NewName);
|
||||
if (!success)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to rename folder" });
|
||||
@@ -114,13 +107,13 @@ public class FolderApi : ControllerBase
|
||||
}
|
||||
|
||||
// Check ownership (can't delete shared folders)
|
||||
var ownerId = await _folderService.GetFolderOwnerIdAsync(folderId);
|
||||
var ownerId = await folderService.GetFolderOwnerIdAsync(folderId);
|
||||
if (ownerId != userId.Value)
|
||||
{
|
||||
return Forbid("Cannot delete shared folders");
|
||||
}
|
||||
|
||||
var success = await _folderService.DeleteFolderAsync(folderId, userId.Value, deleteContents);
|
||||
var success = await folderService.DeleteFolderAsync(folderId, userId.Value, deleteContents);
|
||||
if (!success)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to delete folder" });
|
||||
@@ -139,13 +132,13 @@ public class FolderApi : ControllerBase
|
||||
}
|
||||
|
||||
// Check ownership (can't move shared folders)
|
||||
var ownerId = await _folderService.GetFolderOwnerIdAsync(request.FolderId);
|
||||
var ownerId = await folderService.GetFolderOwnerIdAsync(request.FolderId);
|
||||
if (ownerId != userId.Value)
|
||||
{
|
||||
return Forbid("Cannot move shared folders");
|
||||
}
|
||||
|
||||
var success = await _folderService.MoveFolderAsync(request.FolderId, userId.Value, request.NewParentFolderId);
|
||||
var success = await folderService.MoveFolderAsync(request.FolderId, userId.Value, request.NewParentFolderId);
|
||||
if (!success)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to move folder" });
|
||||
|
||||
@@ -6,17 +6,8 @@ namespace Media.JoshHeaps.Net.Api;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/folder-share")]
|
||||
public class FolderShareApi : ControllerBase
|
||||
public class FolderShareApi(FolderService folderService, UserService userService) : ControllerBase
|
||||
{
|
||||
private readonly FolderService _folderService;
|
||||
private readonly UserService _userService;
|
||||
|
||||
public FolderShareApi(FolderService folderService, UserService userService)
|
||||
{
|
||||
_folderService = folderService;
|
||||
_userService = userService;
|
||||
}
|
||||
|
||||
[HttpPost("share")]
|
||||
public async Task<IActionResult> ShareFolder([FromBody] ShareFolderRequest request)
|
||||
{
|
||||
@@ -26,7 +17,7 @@ public class FolderShareApi : ControllerBase
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var share = await _folderService.ShareFolderAsync(request.FolderId, userId.Value, request.SharedWithUserId);
|
||||
var share = await folderService.ShareFolderAsync(request.FolderId, userId.Value, request.SharedWithUserId);
|
||||
if (share == null)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to share folder" });
|
||||
@@ -44,7 +35,7 @@ public class FolderShareApi : ControllerBase
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var success = await _folderService.UnshareFolderAsync(folderId, userId.Value, sharedWithUserId);
|
||||
var success = await folderService.UnshareFolderAsync(folderId, userId.Value, sharedWithUserId);
|
||||
if (!success)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to unshare folder" });
|
||||
@@ -62,7 +53,7 @@ public class FolderShareApi : ControllerBase
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var shares = await _folderService.GetFolderSharesAsync(folderId, userId.Value);
|
||||
var shares = await folderService.GetFolderSharesAsync(folderId, userId.Value);
|
||||
return Ok(shares);
|
||||
}
|
||||
|
||||
@@ -75,7 +66,7 @@ public class FolderShareApi : ControllerBase
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var sharedFolders = await _folderService.GetSharedFoldersAsync(userId.Value);
|
||||
var sharedFolders = await folderService.GetSharedFoldersAsync(userId.Value);
|
||||
return Ok(sharedFolders);
|
||||
}
|
||||
|
||||
@@ -93,7 +84,7 @@ public class FolderShareApi : ControllerBase
|
||||
return Ok(new List<object>());
|
||||
}
|
||||
|
||||
var users = await _userService.SearchUsersAsync(query, userId.Value);
|
||||
var users = await userService.SearchUsersAsync(query, userId.Value);
|
||||
return Ok(users);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
using Media.JoshHeaps.Net.Services;
|
||||
using Media.JoshHeaps.Net.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Api;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/graph")]
|
||||
public class GraphApi(GraphService graphService) : ControllerBase
|
||||
{
|
||||
|
||||
#region Graph Endpoints
|
||||
|
||||
[HttpGet("list")]
|
||||
public async Task<IActionResult> ListGraphs()
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var graphs = await graphService.GetUserGraphsAsync(userId.Value);
|
||||
return Ok(graphs);
|
||||
}
|
||||
|
||||
[HttpGet("{graphId}")]
|
||||
public async Task<IActionResult> GetGraph(long graphId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var graph = await graphService.GetGraphByIdAsync(graphId, userId.Value);
|
||||
if (graph == null)
|
||||
{
|
||||
return NotFound(new { error = "Graph not found" });
|
||||
}
|
||||
|
||||
return Ok(graph);
|
||||
}
|
||||
|
||||
[HttpGet("{graphId}/data")]
|
||||
public async Task<IActionResult> GetGraphData(long graphId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var graphData = await graphService.GetGraphDataAsync(graphId, userId.Value);
|
||||
if (graphData.Graph == null || graphData.Graph.Id == 0)
|
||||
{
|
||||
return NotFound(new { error = "Graph not found" });
|
||||
}
|
||||
|
||||
return Ok(graphData);
|
||||
}
|
||||
|
||||
[HttpPost("create")]
|
||||
public async Task<IActionResult> CreateGraph([FromBody] CreateGraphRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return BadRequest(new { error = "Graph name is required" });
|
||||
}
|
||||
|
||||
var graph = await graphService.CreateGraphAsync(userId.Value, request.Name, request.Description);
|
||||
if (graph == null)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to create graph" });
|
||||
}
|
||||
|
||||
return Ok(graph);
|
||||
}
|
||||
|
||||
[HttpPut("{graphId}")]
|
||||
public async Task<IActionResult> UpdateGraph(long graphId, [FromBody] UpdateGraphRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return BadRequest(new { error = "Graph name is required" });
|
||||
}
|
||||
|
||||
var success = await graphService.UpdateGraphAsync(graphId, userId.Value, request.Name, request.Description);
|
||||
if (!success)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to update graph" });
|
||||
}
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpDelete("{graphId}")]
|
||||
public async Task<IActionResult> DeleteGraph(long graphId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var success = await graphService.DeleteGraphAsync(graphId, userId.Value);
|
||||
if (!success)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to delete graph" });
|
||||
}
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpPost("{graphId}/clone")]
|
||||
public async Task<IActionResult> CloneGraph(long graphId, [FromBody] CloneGraphRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.NewName))
|
||||
{
|
||||
return BadRequest(new { error = "New graph name is required" });
|
||||
}
|
||||
|
||||
var newGraph = await graphService.CloneGraphAsync(graphId, userId.Value, request.NewName);
|
||||
if (newGraph == null)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to clone graph" });
|
||||
}
|
||||
|
||||
return Ok(newGraph);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Node Endpoints
|
||||
|
||||
[HttpGet("{graphId}/nodes")]
|
||||
public async Task<IActionResult> GetNodes(long graphId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var nodes = await graphService.GetGraphNodesAsync(graphId, userId.Value);
|
||||
return Ok(nodes);
|
||||
}
|
||||
|
||||
[HttpPost("{graphId}/nodes")]
|
||||
public async Task<IActionResult> CreateNode(long graphId, [FromBody] CreateNodeRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Label))
|
||||
{
|
||||
return BadRequest(new { error = "Node label is required" });
|
||||
}
|
||||
|
||||
var node = await graphService.CreateNodeAsync(graphId, userId.Value, request.Label, request.Notes);
|
||||
if (node == null)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to create node" });
|
||||
}
|
||||
|
||||
return Ok(node);
|
||||
}
|
||||
|
||||
[HttpPut("nodes/{nodeId}")]
|
||||
public async Task<IActionResult> UpdateNode(long nodeId, [FromBody] UpdateNodeRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Label))
|
||||
{
|
||||
return BadRequest(new { error = "Node label is required" });
|
||||
}
|
||||
|
||||
var success = await graphService.UpdateNodeAsync(nodeId, userId.Value, request.Label, request.Notes);
|
||||
if (!success)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to update node" });
|
||||
}
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpDelete("nodes/{nodeId}")]
|
||||
public async Task<IActionResult> DeleteNode(long nodeId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var success = await graphService.DeleteNodeAsync(nodeId, userId.Value);
|
||||
if (!success)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to delete node" });
|
||||
}
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpGet("{graphId}/nodes/search")]
|
||||
public async Task<IActionResult> SearchNodes(long graphId, [FromQuery] string q)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
return BadRequest(new { error = "Search term is required" });
|
||||
}
|
||||
|
||||
var nodes = await graphService.SearchNodesAsync(graphId, userId.Value, q);
|
||||
return Ok(nodes);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Endpoints
|
||||
|
||||
[HttpGet("{graphId}/edges")]
|
||||
public async Task<IActionResult> GetEdges(long graphId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var edges = await graphService.GetGraphEdgesAsync(graphId, userId.Value);
|
||||
return Ok(edges);
|
||||
}
|
||||
|
||||
[HttpPost("{graphId}/edges")]
|
||||
public async Task<IActionResult> CreateEdge(long graphId, [FromBody] CreateEdgeRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var edge = await graphService.CreateEdgeAsync(graphId, userId.Value, request.SourceNodeId, request.TargetNodeId);
|
||||
if (edge == null)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to create edge" });
|
||||
}
|
||||
|
||||
return Ok(edge);
|
||||
}
|
||||
|
||||
[HttpDelete("edges/{edgeId}")]
|
||||
public async Task<IActionResult> DeleteEdge(long edgeId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var success = await graphService.DeleteEdgeAsync(edgeId, userId.Value);
|
||||
if (!success)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to delete edge" });
|
||||
}
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Filter Endpoints
|
||||
|
||||
[HttpGet("{graphId}/nodes/filter/connects-to/{targetNodeId}")]
|
||||
public async Task<IActionResult> FilterConnectsTo(long graphId, long targetNodeId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var nodes = await graphService.FilterConnectsToAsync(graphId, userId.Value, targetNodeId);
|
||||
return Ok(nodes);
|
||||
}
|
||||
|
||||
[HttpGet("{graphId}/nodes/filter/not-connects-to/{targetNodeId}")]
|
||||
public async Task<IActionResult> FilterDoesNotConnectTo(long graphId, long targetNodeId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var nodes = await graphService.FilterDoesNotConnectToAsync(graphId, userId.Value, targetNodeId);
|
||||
return Ok(nodes);
|
||||
}
|
||||
|
||||
[HttpGet("{graphId}/nodes/filter/two-degrees/{targetNodeId}")]
|
||||
public async Task<IActionResult> FilterTwoDegrees(long graphId, long targetNodeId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var nodes = await graphService.FilterConnectsTwoDegreesAsync(graphId, userId.Value, targetNodeId);
|
||||
return Ok(nodes);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private long? GetUserIdFromAuth()
|
||||
{
|
||||
// First try JWT claims (for mobile/API authentication)
|
||||
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId))
|
||||
{
|
||||
return jwtUserId;
|
||||
}
|
||||
|
||||
// Fall back to session (for web authentication)
|
||||
var userIdString = HttpContext.Session.GetString("UserId");
|
||||
if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId))
|
||||
{
|
||||
return sessionUserId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Request Models
|
||||
|
||||
public record CreateGraphRequest(string Name, string? Description);
|
||||
public record UpdateGraphRequest(string Name, string? Description);
|
||||
public record CloneGraphRequest(string NewName);
|
||||
public record CreateNodeRequest(string Label, string? Notes);
|
||||
public record UpdateNodeRequest(string Label, string? Notes);
|
||||
public record CreateEdgeRequest(long SourceNodeId, long TargetNodeId);
|
||||
|
||||
#endregion
|
||||
@@ -6,17 +6,8 @@ namespace Media.JoshHeaps.Net.Api;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/media")]
|
||||
public class MediaApi : ControllerBase
|
||||
public class MediaApi(MediaService mediaService, FolderService folderService) : ControllerBase
|
||||
{
|
||||
private readonly MediaService _mediaService;
|
||||
private readonly FolderService _folderService;
|
||||
|
||||
public MediaApi(MediaService mediaService, FolderService folderService)
|
||||
{
|
||||
_mediaService = mediaService;
|
||||
_folderService = folderService;
|
||||
}
|
||||
|
||||
[HttpGet("load")]
|
||||
public async Task<IActionResult> LoadMedia([FromQuery] int offset = 0, [FromQuery] int limit = 20, [FromQuery] long? folderId = null)
|
||||
{
|
||||
@@ -27,7 +18,7 @@ public class MediaApi : ControllerBase
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var media = await _mediaService.GetUserMediaAsync(userId.Value, offset, limit, folderId, userId.Value);
|
||||
var media = await mediaService.GetUserMediaAsync(userId.Value, offset, limit, folderId, userId.Value);
|
||||
|
||||
return Ok(media);
|
||||
}
|
||||
@@ -43,14 +34,14 @@ public class MediaApi : ControllerBase
|
||||
}
|
||||
|
||||
// Get media metadata (includes ownership check)
|
||||
var media = await _mediaService.GetMediaByIdAsync(mediaId, userId.Value);
|
||||
var media = await mediaService.GetMediaByIdAsync(mediaId, userId.Value);
|
||||
if (media == null)
|
||||
{
|
||||
return NotFound(new { error = "Image not found or access denied" });
|
||||
}
|
||||
|
||||
// Get decrypted image data
|
||||
var imageData = await _mediaService.GetDecryptedMediaDataAsync(mediaId, userId.Value);
|
||||
var imageData = await mediaService.GetDecryptedMediaDataAsync(mediaId, userId.Value);
|
||||
if (imageData == null)
|
||||
{
|
||||
return NotFound(new { error = "Image file not found" });
|
||||
@@ -73,7 +64,7 @@ public class MediaApi : ControllerBase
|
||||
// Check folder ownership if uploading to a folder (can't upload to shared folders)
|
||||
if (folderId.HasValue)
|
||||
{
|
||||
var ownerId = await _folderService.GetFolderOwnerIdAsync(folderId.Value);
|
||||
var ownerId = await folderService.GetFolderOwnerIdAsync(folderId.Value);
|
||||
if (ownerId != userId.Value)
|
||||
{
|
||||
return Forbid("Cannot upload to shared folders");
|
||||
@@ -99,7 +90,7 @@ public class MediaApi : ControllerBase
|
||||
}
|
||||
|
||||
// Save media using existing service
|
||||
var media = await _mediaService.SaveMediaAsync(userId.Value, file, description, folderId);
|
||||
var media = await mediaService.SaveMediaAsync(userId.Value, file, description, folderId);
|
||||
if (media == null)
|
||||
{
|
||||
return StatusCode(500, new { error = "Failed to upload image" });
|
||||
@@ -118,7 +109,7 @@ public class MediaApi : ControllerBase
|
||||
return Unauthorized(new { error = "Not authenticated" });
|
||||
}
|
||||
|
||||
var success = await _mediaService.MoveMediaToFolderAsync(request.MediaId, userId.Value, request.FolderId);
|
||||
var success = await mediaService.MoveMediaToFolderAsync(request.MediaId, userId.Value, request.FolderId);
|
||||
if (!success)
|
||||
{
|
||||
return BadRequest(new { error = "Failed to move media" });
|
||||
@@ -147,7 +138,7 @@ public class MediaApi : ControllerBase
|
||||
|
||||
foreach (var mediaId in request.MediaIds)
|
||||
{
|
||||
var success = await _mediaService.MoveMediaToFolderAsync(mediaId, userId.Value, request.FolderId);
|
||||
var success = await mediaService.MoveMediaToFolderAsync(mediaId, userId.Value, request.FolderId);
|
||||
if (success)
|
||||
{
|
||||
successCount++;
|
||||
|
||||
@@ -0,0 +1,943 @@
|
||||
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<IActionResult> GetPeople()
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
|
||||
var people = await medicalDocsService.GetPeopleAsync(userId.Value);
|
||||
return Ok(people);
|
||||
}
|
||||
|
||||
[HttpPost("people")]
|
||||
public async Task<IActionResult> 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(userId.Value, request.Name.Trim(), request.DateOfBirth, request.Notes);
|
||||
if (person == null)
|
||||
return StatusCode(500, new { error = "Failed to create person" });
|
||||
|
||||
return Ok(person);
|
||||
}
|
||||
|
||||
// --- People Access ---
|
||||
|
||||
[HttpGet("people/{personId}/access")]
|
||||
public async Task<IActionResult> GetPersonAccess(long personId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasPersonAccess(userId.Value, personId)) return Forbid();
|
||||
|
||||
var users = await medicalDocsService.GetPeopleAccessAsync(personId);
|
||||
return Ok(users);
|
||||
}
|
||||
|
||||
[HttpPost("people/{personId}/access")]
|
||||
public async Task<IActionResult> GrantPersonAccess(long personId, [FromBody] GrantAccessRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasPersonAccess(userId.Value, personId)) return Forbid();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Username))
|
||||
return BadRequest(new { error = "Username is required" });
|
||||
|
||||
var targetUser = await dbExecutor.ExecuteReaderAsync(
|
||||
"SELECT id FROM app.users WHERE LOWER(username) = LOWER(@username)",
|
||||
reader => reader.GetInt64(0),
|
||||
new { username = request.Username.Trim() });
|
||||
|
||||
if (targetUser == 0)
|
||||
return NotFound(new { error = "User not found" });
|
||||
|
||||
var success = await medicalDocsService.GrantAccessAsync(personId, targetUser);
|
||||
if (!success)
|
||||
return StatusCode(500, new { error = "Failed to grant access" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpDelete("people/{personId}/access/{targetUserId}")]
|
||||
public async Task<IActionResult> RevokePersonAccess(long personId, long targetUserId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasPersonAccess(userId.Value, personId)) return Forbid();
|
||||
|
||||
var success = await medicalDocsService.RevokeAccessAsync(personId, targetUserId);
|
||||
if (!success)
|
||||
return BadRequest(new { error = "Cannot revoke access — at least one user must have access" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
// --- Documents ---
|
||||
|
||||
[HttpGet("documents")]
|
||||
public async Task<IActionResult> 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 (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
|
||||
|
||||
if (limit < 1 || limit > 100) limit = 50;
|
||||
if (offset < 0) offset = 0;
|
||||
|
||||
var documents = await medicalDocsService.GetDocumentsAsync(personId, offset, limit);
|
||||
return Ok(documents);
|
||||
}
|
||||
|
||||
[HttpGet("documents/search")]
|
||||
public async Task<IActionResult> SearchDocuments(
|
||||
[FromQuery] long? personId = null,
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] string? classification = null,
|
||||
[FromQuery] string? documentType = null,
|
||||
[FromQuery] long? doctorId = null,
|
||||
[FromQuery] long? tagId = null,
|
||||
[FromQuery] long? conditionId = null,
|
||||
[FromQuery] DateTime? fromDate = null,
|
||||
[FromQuery] DateTime? toDate = null,
|
||||
[FromQuery] bool? aiProcessed = null,
|
||||
[FromQuery] int offset = 0,
|
||||
[FromQuery] int limit = 50)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
|
||||
|
||||
if (limit < 1 || limit > 100) limit = 50;
|
||||
if (offset < 0) offset = 0;
|
||||
|
||||
var documents = await medicalDocsService.SearchDocumentsAsync(personId, search, classification, documentType, doctorId, tagId, conditionId, fromDate, toDate, aiProcessed, accessUserId: personId.HasValue ? null : userId, offset: offset, limit: limit);
|
||||
return Ok(documents);
|
||||
}
|
||||
|
||||
[HttpGet("tags")]
|
||||
public async Task<IActionResult> GetPersonTags([FromQuery] long? personId = null)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
|
||||
|
||||
var tags = await medicalDocsService.GetPersonTagsAsync(personId, accessUserId: personId.HasValue ? null : userId);
|
||||
return Ok(tags);
|
||||
}
|
||||
|
||||
[HttpPost("documents/upload")]
|
||||
[RequestSizeLimit(52_428_800)] // 50MB
|
||||
public async Task<IActionResult> 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 (!await HasPersonAccess(userId.Value, personId)) 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<IActionResult> 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 (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
|
||||
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<IActionResult> GetDocument(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid();
|
||||
|
||||
var doc = await medicalDocsService.GetDocumentByIdAsync(id);
|
||||
if (doc == null) return NotFound(new { error = "Document not found" });
|
||||
|
||||
return Ok(doc);
|
||||
}
|
||||
|
||||
[HttpGet("documents/{id}/download")]
|
||||
public async Task<IActionResult> DownloadDocument(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid();
|
||||
|
||||
var doc = await medicalDocsService.GetDocumentByIdAsync(id);
|
||||
if (doc == null) return NotFound(new { error = "Document not found" });
|
||||
|
||||
if (doc.DocumentType != "file")
|
||||
return BadRequest(new { error = "Cannot download a note" });
|
||||
|
||||
var data = await medicalDocsService.GetDecryptedDocumentDataAsync(id);
|
||||
if (data == null) return NotFound(new { error = "File not found" });
|
||||
|
||||
return File(data, doc.MimeType ?? "application/octet-stream", doc.FileName);
|
||||
}
|
||||
|
||||
[HttpPut("documents/{id}")]
|
||||
public async Task<IActionResult> UpdateDocument(long id, [FromBody] UpdateDocumentRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid();
|
||||
|
||||
var success = await medicalDocsService.UpdateDocumentAsync(id, request.Title, request.Description, request.DocumentDate, request.Classification, request.DoctorId);
|
||||
if (!success) return NotFound(new { error = "Document not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpDelete("documents/{id}")]
|
||||
public async Task<IActionResult> DeleteDocument(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid();
|
||||
|
||||
var success = await medicalDocsService.DeleteDocumentAsync(id);
|
||||
if (!success) return NotFound(new { error = "Document not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
// --- AI Processing ---
|
||||
|
||||
[HttpPost("documents/{id}/process")]
|
||||
public async Task<IActionResult> ProcessDocument(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid();
|
||||
|
||||
var doc = await medicalDocsService.GetDocumentByIdAsync(id);
|
||||
if (doc == null) return NotFound(new { error = "Document not found" });
|
||||
|
||||
medicalAiService.EnqueueProcessing(id);
|
||||
|
||||
return Ok(new { success = true, message = "AI processing started" });
|
||||
}
|
||||
|
||||
[HttpPost("documents/process-all")]
|
||||
public async Task<IActionResult> ProcessAllDocuments()
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
|
||||
var unprocessedIds = await medicalDocsService.GetUnprocessedDocumentIdsAsync();
|
||||
|
||||
foreach (var docId in unprocessedIds)
|
||||
{
|
||||
medicalAiService.EnqueueProcessing(docId);
|
||||
}
|
||||
|
||||
return Ok(new { success = true, queued = unprocessedIds.Count });
|
||||
}
|
||||
|
||||
[HttpPost("documents/process-batch")]
|
||||
public async Task<IActionResult> ProcessBatch([FromBody] ProcessBatchRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
|
||||
if (request.DocumentIds == null || request.DocumentIds.Count == 0)
|
||||
return BadRequest(new { error = "No document IDs provided" });
|
||||
|
||||
var queued = 0;
|
||||
foreach (var docId in request.DocumentIds)
|
||||
{
|
||||
var doc = await medicalDocsService.GetDocumentByIdAsync(docId);
|
||||
if (doc != null)
|
||||
{
|
||||
medicalAiService.EnqueueProcessing(docId);
|
||||
queued++;
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new { queued });
|
||||
}
|
||||
|
||||
[HttpGet("documents/{id}/tags")]
|
||||
public async Task<IActionResult> GetDocumentTags(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid();
|
||||
|
||||
var tags = await medicalDocsService.GetDocumentTagsAsync(id);
|
||||
return Ok(tags);
|
||||
}
|
||||
|
||||
// --- Doctors (shared, no per-person access check) ---
|
||||
|
||||
[HttpGet("doctors")]
|
||||
public async Task<IActionResult> GetDoctors([FromQuery] long? personId = null)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
|
||||
|
||||
var doctors = await medicalDocsService.GetDoctorsAsync(personId, accessUserId: personId.HasValue ? null : userId);
|
||||
return Ok(doctors);
|
||||
}
|
||||
|
||||
[HttpPost("doctors")]
|
||||
public async Task<IActionResult> CreateDoctor([FromBody] CreateDoctorRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return BadRequest(new { error = "Name is required" });
|
||||
|
||||
var doctor = await medicalDocsService.CreateDoctorAsync(request.PersonId, 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<IActionResult> UpdateDoctor(long id, [FromBody] CreateDoctorRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "doctor", id)) return Forbid();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return BadRequest(new { error = "Name is required" });
|
||||
|
||||
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<IActionResult> DeleteDoctor(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "doctor", id)) return Forbid();
|
||||
|
||||
var success = await medicalDocsService.DeleteDoctorAsync(id);
|
||||
if (!success) return NotFound(new { error = "Doctor not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
// --- Conditions ---
|
||||
|
||||
[HttpGet("conditions")]
|
||||
public async Task<IActionResult> GetConditions([FromQuery] long? personId = null)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
|
||||
|
||||
var conditions = await medicalDocsService.GetConditionsAsync(personId, accessUserId: personId.HasValue ? null : userId);
|
||||
return Ok(conditions);
|
||||
}
|
||||
|
||||
[HttpPost("conditions")]
|
||||
public async Task<IActionResult> 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 (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
|
||||
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<IActionResult> UpdateCondition(long id, [FromBody] UpdateConditionRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "condition", id)) return Forbid();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return BadRequest(new { error = "Name is required" });
|
||||
|
||||
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<IActionResult> DeleteCondition(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "condition", id)) return Forbid();
|
||||
|
||||
var success = await medicalDocsService.DeleteConditionAsync(id);
|
||||
if (!success) return NotFound(new { error = "Condition not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
// --- Prescriptions ---
|
||||
|
||||
[HttpGet("prescriptions")]
|
||||
public async Task<IActionResult> GetPrescriptions([FromQuery] long? personId = null)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
|
||||
|
||||
var prescriptions = await medicalDocsService.GetPrescriptionsAsync(personId, accessUserId: personId.HasValue ? null : userId);
|
||||
return Ok(prescriptions);
|
||||
}
|
||||
|
||||
[HttpPost("prescriptions")]
|
||||
public async Task<IActionResult> 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 (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
|
||||
if (string.IsNullOrWhiteSpace(request.MedicationName))
|
||||
return BadRequest(new { error = "Medication name is required" });
|
||||
|
||||
var prescription = await medicalDocsService.CreatePrescriptionAsync(request.PersonId, request.MedicationName.Trim(), request.Dosage, request.Frequency, request.DoctorId, request.StartDate, request.Notes, request.RxNumber?.Trim());
|
||||
if (prescription == null)
|
||||
return StatusCode(500, new { error = "Failed to create prescription" });
|
||||
|
||||
return Ok(prescription);
|
||||
}
|
||||
|
||||
[HttpPut("prescriptions/{id}")]
|
||||
public async Task<IActionResult> UpdatePrescription(long id, [FromBody] UpdatePrescriptionRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.MedicationName))
|
||||
return BadRequest(new { error = "Medication name is required" });
|
||||
|
||||
var success = await medicalDocsService.UpdatePrescriptionAsync(id, request.MedicationName.Trim(), request.Dosage, request.Frequency, request.DoctorId, request.StartDate, request.EndDate, request.Notes, request.IsActive, request.RxNumber?.Trim());
|
||||
if (!success) return NotFound(new { error = "Prescription not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpDelete("prescriptions/{id}")]
|
||||
public async Task<IActionResult> DeletePrescription(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid();
|
||||
|
||||
var success = await medicalDocsService.DeletePrescriptionAsync(id);
|
||||
if (!success) return NotFound(new { error = "Prescription not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
// --- Pickups ---
|
||||
|
||||
[HttpGet("prescriptions/{id}/pickups")]
|
||||
public async Task<IActionResult> GetPickups(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid();
|
||||
|
||||
var pickups = await medicalDocsService.GetPickupsAsync(id);
|
||||
return Ok(pickups);
|
||||
}
|
||||
|
||||
[HttpPost("prescriptions/{id}/pickups")]
|
||||
public async Task<IActionResult> CreatePickup(long id, [FromBody] CreatePickupRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid();
|
||||
|
||||
var pickup = await medicalDocsService.CreatePickupAsync(id, request.PickupDate, request.Quantity, request.Pharmacy, request.Cost, request.Notes);
|
||||
if (pickup == null)
|
||||
return StatusCode(500, new { error = "Failed to create pickup" });
|
||||
|
||||
return Ok(pickup);
|
||||
}
|
||||
|
||||
[HttpDelete("pickups/{id}")]
|
||||
public async Task<IActionResult> DeletePickup(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "pickup", id)) return Forbid();
|
||||
|
||||
var success = await medicalDocsService.DeletePickupAsync(id);
|
||||
if (!success) return NotFound(new { error = "Pickup not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
// --- Billing Providers ---
|
||||
|
||||
[HttpGet("providers")]
|
||||
public async Task<IActionResult> GetProviders([FromQuery] long? personId = null)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
|
||||
|
||||
var providers = await medicalDocsService.GetProvidersAsync(personId, accessUserId: personId.HasValue ? null : userId);
|
||||
return Ok(providers);
|
||||
}
|
||||
|
||||
[HttpPost("providers")]
|
||||
public async Task<IActionResult> CreateProvider([FromBody] CreateProviderRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
|
||||
if (request.PersonId <= 0)
|
||||
return BadRequest(new { error = "Person is required" });
|
||||
if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return BadRequest(new { error = "Name is required" });
|
||||
|
||||
var provider = await medicalDocsService.CreateProviderAsync(request.PersonId, request.Name.Trim(), request.Notes);
|
||||
if (provider == null)
|
||||
return StatusCode(500, new { error = "Failed to create provider" });
|
||||
|
||||
return Ok(provider);
|
||||
}
|
||||
|
||||
[HttpPut("providers/{id}")]
|
||||
public async Task<IActionResult> UpdateProvider(long id, [FromBody] UpdateProviderRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return BadRequest(new { error = "Name is required" });
|
||||
|
||||
var success = await medicalDocsService.UpdateProviderAsync(id, request.Name.Trim(), request.Notes);
|
||||
if (!success) return NotFound(new { error = "Provider not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpDelete("providers/{id}")]
|
||||
public async Task<IActionResult> DeleteProvider(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid();
|
||||
|
||||
var success = await medicalDocsService.DeleteProviderAsync(id);
|
||||
if (!success) return NotFound(new { error = "Provider not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
// --- Provider Payments ---
|
||||
|
||||
[HttpGet("providers/{id}/payments")]
|
||||
public async Task<IActionResult> GetProviderPayments(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid();
|
||||
|
||||
var payments = await medicalDocsService.GetProviderPaymentsAsync(id);
|
||||
return Ok(payments);
|
||||
}
|
||||
|
||||
[HttpPost("providers/{id}/payments")]
|
||||
public async Task<IActionResult> CreateProviderPayment(long id, [FromBody] CreateProviderPaymentRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid();
|
||||
|
||||
if (request.Amount <= 0)
|
||||
return BadRequest(new { error = "Amount must be greater than 0" });
|
||||
|
||||
var payment = await medicalDocsService.CreateProviderPaymentAsync(id, request.Amount, request.PaymentDate, request.Description);
|
||||
if (payment == null)
|
||||
return StatusCode(500, new { error = "Failed to create payment" });
|
||||
|
||||
return Ok(payment);
|
||||
}
|
||||
|
||||
[HttpDelete("provider-payments/{id}")]
|
||||
public async Task<IActionResult> DeleteProviderPayment(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "provider-payment", id)) return Forbid();
|
||||
|
||||
var success = await medicalDocsService.DeleteProviderPaymentAsync(id);
|
||||
if (!success) return NotFound(new { error = "Payment not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
// --- Bills ---
|
||||
|
||||
[HttpGet("bills")]
|
||||
public async Task<IActionResult> GetBills([FromQuery] long? personId = null, [FromQuery] long? providerId = null)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
|
||||
|
||||
var bills = await medicalDocsService.GetBillsAsync(personId, providerId, accessUserId: personId.HasValue ? null : userId);
|
||||
return Ok(bills);
|
||||
}
|
||||
|
||||
[HttpPost("bills")]
|
||||
public async Task<IActionResult> CreateBill([FromBody] CreateBillRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
|
||||
if (request.PersonId <= 0)
|
||||
return BadRequest(new { error = "Person is required" });
|
||||
if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
|
||||
if (request.TotalAmount <= 0)
|
||||
return BadRequest(new { error = "Amount must be greater than 0" });
|
||||
|
||||
var bill = await medicalDocsService.CreateBillAsync(request.PersonId, request.TotalAmount, request.Summary, request.Category, request.BillDate, request.DoctorId, request.ProviderId);
|
||||
if (bill == null)
|
||||
return StatusCode(500, new { error = "Failed to create bill" });
|
||||
|
||||
return Ok(bill);
|
||||
}
|
||||
|
||||
[HttpPut("bills/{id}")]
|
||||
public async Task<IActionResult> UpdateBill(long id, [FromBody] UpdateBillRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid();
|
||||
|
||||
if (request.TotalAmount <= 0)
|
||||
return BadRequest(new { error = "Amount must be greater than 0" });
|
||||
|
||||
var success = await medicalDocsService.UpdateBillAsync(id, request.TotalAmount, request.Summary, request.Category, request.BillDate, request.DoctorId, request.ProviderId);
|
||||
if (!success) return NotFound(new { error = "Bill not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpDelete("bills/{id}")]
|
||||
public async Task<IActionResult> DeleteBill(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid();
|
||||
|
||||
var success = await medicalDocsService.DeleteBillAsync(id);
|
||||
if (!success) return NotFound(new { error = "Bill not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpPost("bills/{id}/documents")]
|
||||
public async Task<IActionResult> LinkDocumentToBill(long id, [FromBody] LinkDocumentRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid();
|
||||
|
||||
if (request.DocumentId <= 0)
|
||||
return BadRequest(new { error = "Document is required" });
|
||||
|
||||
var success = await medicalDocsService.LinkDocumentToBillAsync(id, request.DocumentId);
|
||||
if (!success)
|
||||
return StatusCode(500, new { error = "Failed to link document" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpDelete("bills/{billId}/documents/{docId}")]
|
||||
public async Task<IActionResult> UnlinkDocumentFromBill(long billId, long docId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "bill", billId)) return Forbid();
|
||||
|
||||
var success = await medicalDocsService.UnlinkDocumentFromBillAsync(billId, docId);
|
||||
if (!success) return NotFound(new { error = "Link not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
// --- Bill Charges ---
|
||||
|
||||
[HttpGet("bills/{id}/charges")]
|
||||
public async Task<IActionResult> GetBillCharges(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid();
|
||||
|
||||
var charges = await medicalDocsService.GetChargesAsync(id);
|
||||
return Ok(charges);
|
||||
}
|
||||
|
||||
[HttpPost("bills/{id}/charges")]
|
||||
public async Task<IActionResult> CreateCharge(long id, [FromBody] CreateChargeRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Description))
|
||||
return BadRequest(new { error = "Description is required" });
|
||||
if (request.Amount <= 0)
|
||||
return BadRequest(new { error = "Amount must be greater than 0" });
|
||||
|
||||
var charge = await medicalDocsService.CreateChargeAsync(id, request.Description.Trim(), request.Amount);
|
||||
if (charge == null)
|
||||
return StatusCode(500, new { error = "Failed to create charge" });
|
||||
|
||||
return Ok(charge);
|
||||
}
|
||||
|
||||
[HttpDelete("bill-charges/{id}")]
|
||||
public async Task<IActionResult> DeleteCharge(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "bill-charge", id)) return Forbid();
|
||||
|
||||
var success = await medicalDocsService.DeleteChargeAsync(id);
|
||||
if (!success) return NotFound(new { error = "Charge not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
// --- Timeline ---
|
||||
|
||||
[HttpGet("timeline")]
|
||||
public async Task<IActionResult> GetTimeline([FromQuery] long? personId = null, [FromQuery] int offset = 0, [FromQuery] int limit = 100)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
|
||||
|
||||
if (limit < 1 || limit > 200) limit = 100;
|
||||
if (offset < 0) offset = 0;
|
||||
|
||||
var events = await medicalDocsService.GetTimelineAsync(personId, accessUserId: personId.HasValue ? null : userId, offset: offset, limit: limit);
|
||||
return Ok(events);
|
||||
}
|
||||
|
||||
// --- Visit Prep ---
|
||||
|
||||
[HttpGet("visit-prep")]
|
||||
public async Task<IActionResult> GetVisitPrep([FromQuery] long personId, [FromQuery] long doctorId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
|
||||
if (personId <= 0 || doctorId <= 0)
|
||||
return BadRequest(new { error = "personId and doctorId are required" });
|
||||
if (!await HasPersonAccess(userId.Value, personId)) return Forbid();
|
||||
|
||||
var data = await medicalDocsService.GetVisitPrepAsync(personId, doctorId);
|
||||
return Ok(data);
|
||||
}
|
||||
|
||||
[HttpPost("visit-prep/summary")]
|
||||
public async Task<IActionResult> GenerateVisitPrepSummary([FromBody] VisitPrepSummaryRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
|
||||
if (request.PersonId <= 0 || request.DoctorId <= 0)
|
||||
return BadRequest(new { error = "personId and doctorId are required" });
|
||||
if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
|
||||
|
||||
var data = await medicalDocsService.GetVisitPrepAsync(request.PersonId, request.DoctorId);
|
||||
var doctor = await medicalDocsService.GetDoctorByIdAsync(request.DoctorId);
|
||||
if (doctor == null)
|
||||
return NotFound(new { error = "Doctor not found" });
|
||||
|
||||
var summary = await medicalAiService.GenerateVisitPrepSummaryAsync(doctor.Name, doctor.Specialty, data);
|
||||
return Ok(new { summary });
|
||||
}
|
||||
|
||||
[HttpGet("bills/summary")]
|
||||
public async Task<IActionResult> GetBillSummary([FromQuery] long? personId = null)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
|
||||
|
||||
var summary = await medicalDocsService.GetBillSummaryAsync(personId, accessUserId: personId.HasValue ? null : userId);
|
||||
return Ok(summary);
|
||||
}
|
||||
|
||||
// --- Auth helpers ---
|
||||
|
||||
private async Task<bool> HasMedicalAccess(long userId)
|
||||
{
|
||||
return await dbExecutor.ExecuteAsync<bool>(
|
||||
"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 async Task<bool> HasPersonAccess(long userId, long personId)
|
||||
{
|
||||
return await medicalDocsService.HasAccessToPersonAsync(userId, personId);
|
||||
}
|
||||
|
||||
private async Task<bool> HasResourceAccess(long userId, string resourceType, long resourceId)
|
||||
{
|
||||
var personId = await medicalDocsService.GetPersonIdForResourceAsync(resourceType, resourceId);
|
||||
if (personId == null) return false;
|
||||
return await medicalDocsService.HasAccessToPersonAsync(userId, personId.Value);
|
||||
}
|
||||
|
||||
private long? GetUserIdFromAuth()
|
||||
{
|
||||
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId))
|
||||
{
|
||||
return jwtUserId;
|
||||
}
|
||||
|
||||
var userIdString = HttpContext.Session.GetString("UserId");
|
||||
if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId))
|
||||
{
|
||||
return sessionUserId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public record CreatePersonRequest(string Name, DateTime? DateOfBirth = null, string? Notes = null);
|
||||
public record CreateNoteRequest(long PersonId, string Title, string? Description = null, DateTime? DocumentDate = null, string? Classification = null);
|
||||
public record UpdateDocumentRequest(string? Title = null, string? Description = null, DateTime? DocumentDate = null, string? Classification = null, long? DoctorId = null);
|
||||
public record CreateDoctorRequest(long PersonId, string Name, string? Specialty = null, string? Phone = null, string? Address = null, string? Notes = null);
|
||||
public record CreateConditionRequest(long PersonId, string Name, DateTime? DiagnosedDate = null, string? Notes = null);
|
||||
public record UpdateConditionRequest(string Name, DateTime? DiagnosedDate = null, string? Notes = null, bool IsActive = true);
|
||||
public record CreatePrescriptionRequest(long PersonId, string MedicationName, string? Dosage = null, string? Frequency = null, long? DoctorId = null, DateTime? StartDate = null, string? Notes = null, string? RxNumber = null);
|
||||
public record UpdatePrescriptionRequest(string MedicationName, string? Dosage = null, string? Frequency = null, long? DoctorId = null, DateTime? StartDate = null, DateTime? EndDate = null, string? Notes = null, bool IsActive = true, string? RxNumber = null);
|
||||
public record CreatePickupRequest(DateTime PickupDate, string? Quantity = null, string? Pharmacy = null, decimal? Cost = null, string? Notes = null);
|
||||
public record CreateProviderRequest(long PersonId, string Name, string? Notes = null);
|
||||
public record UpdateProviderRequest(string Name, string? Notes = null);
|
||||
public record CreateProviderPaymentRequest(decimal Amount, DateTime? PaymentDate = null, string? Description = null);
|
||||
public record CreateBillRequest(long PersonId, decimal TotalAmount, string? Summary = null, string? Category = null, DateTime? BillDate = null, long? DoctorId = null, long? ProviderId = null);
|
||||
public record UpdateBillRequest(decimal TotalAmount, string? Summary = null, string? Category = null, DateTime? BillDate = null, long? DoctorId = null, long? ProviderId = null);
|
||||
public record LinkDocumentRequest(long DocumentId);
|
||||
public record CreateChargeRequest(string Description, decimal Amount);
|
||||
public record ProcessBatchRequest(List<long> DocumentIds);
|
||||
public record VisitPrepSummaryRequest(long PersonId, long DoctorId);
|
||||
public record GrantAccessRequest(string Username);
|
||||
@@ -0,0 +1,190 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Media.JoshHeaps.Net.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Npgsql;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Api;
|
||||
|
||||
[ApiController]
|
||||
[Route("sso")]
|
||||
public class SsoApi(DbExecutor db, IConfiguration config, ILogger<SsoApi> logger) : ControllerBase
|
||||
{
|
||||
[HttpPost("token")]
|
||||
public async Task<IActionResult> Exchange([FromBody] SsoTokenRequest request)
|
||||
{
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.ClientId) || string.IsNullOrWhiteSpace(request.Code))
|
||||
{
|
||||
return BadRequest(new { error = "client_id and code are required" });
|
||||
}
|
||||
|
||||
if (!Request.Headers.TryGetValue("X-Client-Secret", out var providedSecret) || string.IsNullOrWhiteSpace(providedSecret))
|
||||
{
|
||||
return Unauthorized(new { error = "missing client credentials" });
|
||||
}
|
||||
|
||||
var client = SsoClientRegistry.Find(config, request.ClientId);
|
||||
if (client == null || !BCrypt.Net.BCrypt.Verify(providedSecret!, client.ClientSecretHash))
|
||||
{
|
||||
logger.LogWarning("SSO token exchange failed: bad client credentials for {ClientId}", request.ClientId);
|
||||
return Unauthorized(new { error = "invalid client credentials" });
|
||||
}
|
||||
|
||||
var codeHash = HashCode(request.Code);
|
||||
var row = await ConsumeCodeAsync(codeHash);
|
||||
if (row == null)
|
||||
{
|
||||
return BadRequest(new { error = "invalid, expired, or already-used code" });
|
||||
}
|
||||
|
||||
if (!string.Equals(row.Value.ClientId, request.ClientId, StringComparison.Ordinal))
|
||||
{
|
||||
return BadRequest(new { error = "code was issued for a different client" });
|
||||
}
|
||||
|
||||
if (!client.AllowsRedirectUri(row.Value.RedirectUri))
|
||||
{
|
||||
return BadRequest(new { error = "redirect_uri mismatch" });
|
||||
}
|
||||
|
||||
var user = await LoadUserAsync(row.Value.UserId);
|
||||
if (user == null)
|
||||
{
|
||||
return BadRequest(new { error = "user no longer exists" });
|
||||
}
|
||||
|
||||
var jwt = await IssueTokenAsync(user, request.ClientId);
|
||||
return Ok(new SsoTokenResponse
|
||||
{
|
||||
AccessToken = jwt,
|
||||
TokenType = "Bearer",
|
||||
ExpiresIn = 300
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<(long UserId, string ClientId, string RedirectUri)?> ConsumeCodeAsync(string codeHash)
|
||||
{
|
||||
var connectionString = config["connectionString"]!;
|
||||
await using var conn = new NpgsqlConnection(connectionString);
|
||||
await conn.OpenAsync();
|
||||
await using var tx = await conn.BeginTransactionAsync();
|
||||
|
||||
long userId;
|
||||
string clientId;
|
||||
string redirectUri;
|
||||
|
||||
await using (var select = new NpgsqlCommand(
|
||||
@"SELECT user_id, client_id, redirect_uri
|
||||
FROM app.sso_authorization_codes
|
||||
WHERE code_hash = @h
|
||||
AND consumed_at IS NULL
|
||||
AND expires_at > NOW()
|
||||
FOR UPDATE", conn, tx))
|
||||
{
|
||||
select.Parameters.AddWithValue("@h", codeHash);
|
||||
await using var reader = await select.ExecuteReaderAsync();
|
||||
if (!await reader.ReadAsync()) return null;
|
||||
userId = reader.GetInt64(0);
|
||||
clientId = reader.GetString(1);
|
||||
redirectUri = reader.GetString(2);
|
||||
}
|
||||
|
||||
await using (var update = new NpgsqlCommand(
|
||||
"UPDATE app.sso_authorization_codes SET consumed_at = NOW() WHERE code_hash = @h",
|
||||
conn, tx))
|
||||
{
|
||||
update.Parameters.AddWithValue("@h", codeHash);
|
||||
await update.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await tx.CommitAsync();
|
||||
return (userId, clientId, redirectUri);
|
||||
}
|
||||
|
||||
private async Task<SsoUser?> LoadUserAsync(long userId)
|
||||
{
|
||||
return await db.ExecuteReaderAsync(
|
||||
"SELECT id, email, username, email_verified FROM app.users WHERE id = @userId AND is_active = true",
|
||||
reader => new SsoUser
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
Email = reader.GetString(1),
|
||||
Username = reader.GetString(2),
|
||||
EmailVerified = reader.GetBoolean(3)
|
||||
},
|
||||
new { userId });
|
||||
}
|
||||
|
||||
private async Task<string> IssueTokenAsync(SsoUser user, string audience)
|
||||
{
|
||||
var jwtKey = config["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key not configured");
|
||||
var jwtIssuer = config["Jwt:Issuer"] ?? throw new InvalidOperationException("JWT Issuer not configured");
|
||||
|
||||
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey));
|
||||
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var roles = await LoadUserRolesAsync(user.Id);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Email, user.Email),
|
||||
new Claim(ClaimTypes.Name, user.Username),
|
||||
new Claim("EmailVerified", user.EmailVerified.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N"))
|
||||
};
|
||||
|
||||
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: jwtIssuer,
|
||||
audience: audience,
|
||||
claims: claims,
|
||||
expires: DateTime.UtcNow.AddMinutes(5),
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
private async Task<List<string>> LoadUserRolesAsync(long userId)
|
||||
{
|
||||
return await db.ExecuteListReaderAsync(
|
||||
@"SELECT r.name
|
||||
FROM app.user_roles ur
|
||||
JOIN app.roles r ON ur.role_id = r.id
|
||||
WHERE ur.user_id = @userId",
|
||||
reader => reader.GetString(0),
|
||||
new { userId });
|
||||
}
|
||||
|
||||
private static string HashCode(string code)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(code));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SsoTokenRequest
|
||||
{
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
public string Code { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class SsoTokenResponse
|
||||
{
|
||||
public string AccessToken { get; set; } = string.Empty;
|
||||
public string TokenType { get; set; } = "Bearer";
|
||||
public int ExpiresIn { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class SsoUser
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public bool EmailVerified { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Media.JoshHeaps.Net.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Api;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/theme")]
|
||||
public partial class ThemeApi(ThemeService themeService) : ControllerBase
|
||||
{
|
||||
private static readonly HashSet<string> ValidCssVariables =
|
||||
[
|
||||
"--bg-primary", "--bg-secondary", "--bg-tertiary", "--bg-hover",
|
||||
"--text-primary", "--text-secondary",
|
||||
"--border-primary", "--border-secondary",
|
||||
"--accent-primary", "--accent-hover",
|
||||
"--danger", "--danger-hover", "--success"
|
||||
];
|
||||
|
||||
[GeneratedRegex(@"^#[0-9a-fA-F]{6}$")]
|
||||
private static partial Regex HexColorRegex();
|
||||
|
||||
[HttpGet("my")]
|
||||
public async Task<IActionResult> GetMyTheme()
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var theme = await themeService.GetUserThemeAsync(userId.Value);
|
||||
if (theme == null)
|
||||
{
|
||||
return Ok(new { baseTheme = "light", colorOverrides = new Dictionary<string, string>() });
|
||||
}
|
||||
|
||||
return Ok(new { baseTheme = theme.BaseTheme, colorOverrides = theme.ColorOverrides });
|
||||
}
|
||||
|
||||
[HttpPut("my")]
|
||||
public async Task<IActionResult> SaveMyTheme([FromBody] SaveThemeRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
if (request.BaseTheme != "dark" && request.BaseTheme != "light")
|
||||
return BadRequest("baseTheme must be 'dark' or 'light'");
|
||||
|
||||
foreach (var (key, value) in request.ColorOverrides)
|
||||
{
|
||||
if (!ValidCssVariables.Contains(key))
|
||||
return BadRequest($"Invalid CSS variable: {key}");
|
||||
if (!HexColorRegex().IsMatch(value))
|
||||
return BadRequest($"Invalid hex color for {key}: {value}");
|
||||
}
|
||||
|
||||
await themeService.SaveUserThemeAsync(userId.Value, request.BaseTheme, request.ColorOverrides);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
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 SaveThemeRequest(string BaseTheme, Dictionary<string, string> ColorOverrides);
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE SCHEMA IF NOT EXISTS app
|
||||
AUTHORIZATION josh;
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE IF NOT EXISTS app.users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
email_verified BOOLEAN DEFAULT FALSE,
|
||||
failed_login_attempts INTEGER DEFAULT 0,
|
||||
locked_until TIMESTAMP NULL,
|
||||
last_login TIMESTAMP NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_email ON app.users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_username ON app.users(username);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Network Graphs Table
|
||||
-- Stores user-created network graphs
|
||||
CREATE TABLE IF NOT EXISTS app.graphs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES app.users(id) ON DELETE CASCADE,
|
||||
-- Ensure graph names are unique per user
|
||||
UNIQUE (user_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_graphs_user_id ON app.graphs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_graphs_name ON app.graphs(name);
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Graph Nodes Table
|
||||
-- Stores nodes within network graphs
|
||||
-- Node positions are calculated automatically by the client using community detection
|
||||
CREATE TABLE IF NOT EXISTS app.graph_nodes (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
graph_id BIGINT NOT NULL,
|
||||
label VARCHAR(255) NOT NULL,
|
||||
notes TEXT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (graph_id) REFERENCES app.graphs(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_graph_nodes_graph_id ON app.graph_nodes(graph_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_graph_nodes_label ON app.graph_nodes(label);
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Graph Edges Table
|
||||
-- Stores connections/relationships between nodes in a graph
|
||||
CREATE TABLE IF NOT EXISTS app.graph_edges (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
graph_id BIGINT NOT NULL,
|
||||
source_node_id BIGINT NOT NULL,
|
||||
target_node_id BIGINT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (graph_id) REFERENCES app.graphs(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (source_node_id) REFERENCES app.graph_nodes(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (target_node_id) REFERENCES app.graph_nodes(id) ON DELETE CASCADE,
|
||||
-- Prevent duplicate edges between the same nodes
|
||||
UNIQUE (graph_id, source_node_id, target_node_id),
|
||||
-- Prevent self-loops (node connecting to itself)
|
||||
CHECK (source_node_id != target_node_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_graph_edges_graph_id ON app.graph_edges(graph_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_graph_edges_source_node_id ON app.graph_edges(source_node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_graph_edges_target_node_id ON app.graph_edges(target_node_id);
|
||||
-- Composite index for efficient bidirectional queries
|
||||
CREATE INDEX IF NOT EXISTS idx_graph_edges_nodes ON app.graph_edges(source_node_id, target_node_id);
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Roles table for role-based access control
|
||||
CREATE TABLE IF NOT EXISTS app.roles (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(50) UNIQUE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Seed the admin role
|
||||
INSERT INTO app.roles (name) VALUES ('admin') ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- User roles junction table
|
||||
CREATE TABLE IF NOT EXISTS app.user_roles (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES app.users(id),
|
||||
role_id BIGINT NOT NULL REFERENCES app.roles(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uq_user_role UNIQUE (user_id, role_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON app.user_roles(user_id);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Migration 021: Medical Bills & Payments
|
||||
-- Replaces the flat medical_document_costs model with proper billing:
|
||||
-- Bills (charges) with Payments (receipts) tracked against them.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.medical_bills (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE,
|
||||
total_amount DECIMAL(10,2) NOT NULL,
|
||||
summary TEXT,
|
||||
category VARCHAR(50),
|
||||
bill_date DATE,
|
||||
doctor_id BIGINT REFERENCES app.medical_doctors(id) ON DELETE SET NULL,
|
||||
source VARCHAR(10) NOT NULL DEFAULT 'manual',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.medical_bill_documents (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
bill_id BIGINT NOT NULL REFERENCES app.medical_bills(id) ON DELETE CASCADE,
|
||||
document_id BIGINT NOT NULL REFERENCES app.medical_documents(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(bill_id, document_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.medical_bill_payments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
bill_id BIGINT NOT NULL REFERENCES app.medical_bills(id) ON DELETE CASCADE,
|
||||
document_id BIGINT REFERENCES app.medical_documents(id) ON DELETE SET NULL,
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
payment_type VARCHAR(30) NOT NULL,
|
||||
payment_date DATE,
|
||||
description TEXT,
|
||||
source VARCHAR(10) NOT NULL DEFAULT 'manual',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Migration 022: Bill Line Items (Charges)
|
||||
-- Breaks down bill totals into individual named charges.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.medical_bill_charges (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
bill_id BIGINT NOT NULL REFERENCES app.medical_bills(id) ON DELETE CASCADE,
|
||||
description TEXT NOT NULL,
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
source VARCHAR(10) NOT NULL DEFAULT 'manual',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
-- 023: Medical billing providers
|
||||
-- Providers are the top-level billing entity. Bills belong to a provider, payments go to a provider.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.medical_billing_providers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_billing_providers_name_person
|
||||
ON app.medical_billing_providers (LOWER(name), person_id);
|
||||
|
||||
ALTER TABLE app.medical_bills ADD COLUMN IF NOT EXISTS provider_id BIGINT
|
||||
REFERENCES app.medical_billing_providers(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.medical_provider_payments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
provider_id BIGINT NOT NULL REFERENCES app.medical_billing_providers(id) ON DELETE CASCADE,
|
||||
document_id BIGINT REFERENCES app.medical_documents(id) ON DELETE SET NULL,
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
payment_date DATE,
|
||||
description TEXT,
|
||||
source VARCHAR(10) NOT NULL DEFAULT 'manual',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE app.medical_prescriptions ADD COLUMN IF NOT EXISTS rx_number VARCHAR(50) NULL;
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE app.password_reset_tokens (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
token_hash VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES app.users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_prt_token_hash ON app.password_reset_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_prt_user_id ON app.password_reset_tokens(user_id);
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS app.medical_people_access (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL REFERENCES app.users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(person_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mpa_user_id ON app.medical_people_access(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mpa_person_id ON app.medical_people_access(person_id);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Add person_id to medical_doctors to scope doctors per person
|
||||
|
||||
ALTER TABLE app.medical_doctors ADD COLUMN IF NOT EXISTS person_id BIGINT REFERENCES app.medical_people(id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_medical_doctors_person_id ON app.medical_doctors(person_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_medical_doctors_person_name
|
||||
ON app.medical_doctors(person_id, LOWER(name)) WHERE person_id IS NOT NULL;
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE IF NOT EXISTS app.user_theme_overrides (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES app.users(id) ON DELETE CASCADE,
|
||||
base_theme TEXT NOT NULL DEFAULT 'light',
|
||||
color_overrides JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_uto_user_id ON app.user_theme_overrides(user_id);
|
||||
@@ -0,0 +1,30 @@
|
||||
CREATE TABLE IF NOT EXISTS app.blog_posts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
slug VARCHAR(200) UNIQUE NOT NULL,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
markdown_content TEXT NOT NULL,
|
||||
html_content TEXT NOT NULL,
|
||||
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||
author_id BIGINT NOT NULL REFERENCES app.users(id),
|
||||
published_date TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_blog_posts_slug ON app.blog_posts(slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_blog_posts_published_date ON app.blog_posts(published_date DESC);
|
||||
|
||||
-- Seed existing blog post
|
||||
INSERT INTO app.blog_posts (slug, title, summary, markdown_content, html_content, tags, author_id, published_date)
|
||||
SELECT
|
||||
'my-first-post',
|
||||
'My First Post',
|
||||
'Welcome to my blog! Here I''ll share thoughts on software development, projects I''m working on, and things I find interesting.',
|
||||
E'# Welcome to My Blog\n\nI''ve been meaning to start writing about the things I build and learn, and I''m finally getting around to it.\n\n## What to Expect\n\nI plan to write about:\n\n- **Projects** I''m working on, like this website and the other things on my portfolio\n- **Software development** tips and patterns I find useful\n- **Problem solving** approaches that have helped me grow as a developer\n\n## Why a Blog?\n\nBuilding things is great, but explaining *how* and *why* you built them is just as valuable. Writing forces you to organize your thoughts, and hopefully someone else finds it useful along the way.\n\nStay tuned for more posts!',
|
||||
E'<h1>Welcome to My Blog</h1>\n<p>I''ve been meaning to start writing about the things I build and learn, and I''m finally getting around to it.</p>\n<h2>What to Expect</h2>\n<p>I plan to write about:</p>\n<ul>\n<li><strong>Projects</strong> I''m working on, like this website and the other things on my portfolio</li>\n<li><strong>Software development</strong> tips and patterns I find useful</li>\n<li><strong>Problem solving</strong> approaches that have helped me grow as a developer</li>\n</ul>\n<h2>Why a Blog?</h2>\n<p>Building things is great, but explaining <em>how</em> and <em>why</em> you built them is just as valuable. Writing forces you to organize your thoughts, and hopefully someone else finds it useful along the way.</p>\n<p>Stay tuned for more posts!</p>',
|
||||
ARRAY['dev', 'personal'],
|
||||
(SELECT id FROM app.users WHERE id = 1),
|
||||
'2026-03-06T00:00:00Z'
|
||||
WHERE EXISTS (SELECT 1 FROM app.users WHERE id = 1)
|
||||
AND NOT EXISTS (SELECT 1 FROM app.blog_posts WHERE slug = 'my-first-post');
|
||||
@@ -0,0 +1,15 @@
|
||||
-- SSO authorization codes for the OAuth2 authorization-code flow.
|
||||
-- The raw code is never stored; we persist the SHA-256 hash only.
|
||||
-- Codes are single-use and short-lived (see Sso:CodeLifetimeSeconds in config).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.sso_authorization_codes (
|
||||
code_hash TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL,
|
||||
user_id BIGINT NOT NULL REFERENCES app.users(id) ON DELETE CASCADE,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
consumed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sso_codes_expires ON app.sso_authorization_codes(expires_at);
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Breadboard circuit simulator.
|
||||
-- A project owns one circuit document (schema v1) stored as JSONB: boards,
|
||||
-- components and wires laid out on full-size 830-point breadboards.
|
||||
-- Memory images hold the contents of memory components keyed by the component's
|
||||
-- uid within the circuit document; no endpoints use them yet.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.breadboard_projects (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES app.users(id),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
circuit JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_breadboard_projects_user_id ON app.breadboard_projects(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.breadboard_memory_images (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_id BIGINT NOT NULL REFERENCES app.breadboard_projects(id) ON DELETE CASCADE,
|
||||
component_uid TEXT NOT NULL,
|
||||
data BYTEA NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_breadboard_memory_images UNIQUE (project_id, component_uid)
|
||||
);
|
||||
@@ -103,4 +103,20 @@ public class DbExecutor(IConfiguration config)
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Executes a non-query command (INSERT, UPDATE, DELETE) and returns rows affected
|
||||
public async Task<int> ExecuteNonQueryAsync(string query, object? parameters = null)
|
||||
{
|
||||
parameters ??= new();
|
||||
using var conn = new NpgsqlConnection(ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
using var cmd = new NpgsqlCommand(query, conn);
|
||||
|
||||
foreach (var prop in parameters.GetType().GetProperties())
|
||||
{
|
||||
cmd.Parameters.AddWithValue($"@{prop.Name}", prop.GetValue(parameters) ?? DBNull.Value);
|
||||
}
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="MailKit" Version="4.8.0" />
|
||||
<PackageReference Include="Markdig" Version="1.1.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
||||
<PackageReference Include="Npgsql" Version="9.0.4" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class BlogPost
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
public string MarkdownContent { get; set; } = string.Empty;
|
||||
public string HtmlContent { get; set; } = string.Empty;
|
||||
public List<string> Tags { get; set; } = [];
|
||||
public long AuthorId { get; set; }
|
||||
public DateTime PublishedDate { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
/// <summary>Row shape for the project list — deliberately excludes the circuit document.</summary>
|
||||
public sealed record BreadboardProjectSummary(
|
||||
long Id,
|
||||
string Name,
|
||||
string? Description,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// A single project including its circuit document. The circuit is opaque to C# —
|
||||
/// it is stored and validated as text and only parsed here so the API emits it as a
|
||||
/// real JSON object rather than a JSON-encoded string.
|
||||
/// </summary>
|
||||
public sealed record BreadboardProject(
|
||||
long Id,
|
||||
string Name,
|
||||
string? Description,
|
||||
JsonNode? Circuit,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt);
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public enum BreadboardOutcome
|
||||
{
|
||||
Success,
|
||||
NotFound,
|
||||
Invalid,
|
||||
Failed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of a write against a breadboard project. The API layer maps the outcome to a
|
||||
/// status code; every rule that produces <see cref="BreadboardOutcome.Invalid"/> lives in
|
||||
/// the service (or the validator it delegates to), never in the controller.
|
||||
/// </summary>
|
||||
public sealed record BreadboardResult(
|
||||
BreadboardOutcome Outcome,
|
||||
IReadOnlyList<string> Errors,
|
||||
BreadboardProject? Project)
|
||||
{
|
||||
/// <summary>The single wording for "gone or never yours" — read and write paths share it.</summary>
|
||||
public const string NotFoundMessage = "Project not found";
|
||||
|
||||
public static BreadboardResult Succeeded(BreadboardProject? project = null) =>
|
||||
new(BreadboardOutcome.Success, [], project);
|
||||
|
||||
public static BreadboardResult Missing() =>
|
||||
new(BreadboardOutcome.NotFound, [NotFoundMessage], null);
|
||||
|
||||
public static BreadboardResult Invalid(IReadOnlyList<string> errors) =>
|
||||
new(BreadboardOutcome.Invalid, errors, null);
|
||||
|
||||
public static BreadboardResult Failed(string error) =>
|
||||
new(BreadboardOutcome.Failed, [error], null);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class Graph
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long UserId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class GraphWithCounts : Graph
|
||||
{
|
||||
public int NodeCount { get; set; }
|
||||
public int EdgeCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class GraphEdge
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long GraphId { get; set; }
|
||||
public long SourceNodeId { get; set; }
|
||||
public long TargetNodeId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class GraphData
|
||||
{
|
||||
public Graph Graph { get; set; } = new();
|
||||
public List<GraphNodeWithConnections> Nodes { get; set; } = [];
|
||||
public List<GraphEdge> Edges { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class GraphNode
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long GraphId { get; set; }
|
||||
public string Label { get; set; } = string.Empty;
|
||||
public string? Notes { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class GraphNodeWithConnections : GraphNode
|
||||
{
|
||||
public int ConnectionCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class MedicalBill
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PersonId { get; set; }
|
||||
public decimal TotalAmount { get; set; }
|
||||
public string? Summary { get; set; }
|
||||
public string? Category { get; set; }
|
||||
public DateTime? BillDate { get; set; }
|
||||
public long? DoctorId { get; set; }
|
||||
public long? ProviderId { get; set; }
|
||||
public string Source { get; set; } = "manual";
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
// Populated via JOIN, not stored in DB
|
||||
public string? DoctorName { get; set; }
|
||||
public string? ProviderName { get; set; }
|
||||
public string? DocumentNames { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class MedicalBillCharge
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long BillId { get; set; }
|
||||
public string Description { get; set; } = "";
|
||||
public decimal Amount { get; set; }
|
||||
public string Source { get; set; } = "manual";
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class MedicalBillPayment
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long BillId { get; set; }
|
||||
public long? DocumentId { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public string PaymentType { get; set; } = string.Empty;
|
||||
public DateTime? PaymentDate { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string Source { get; set; } = "manual";
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
// Populated via JOIN, not stored in DB
|
||||
public string? DocumentName { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class MedicalBillingProvider
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public long PersonId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
// Populated via aggregation, not stored in DB
|
||||
public decimal TotalCharged { get; set; }
|
||||
public decimal TotalPaid { get; set; }
|
||||
public int BillCount { get; set; }
|
||||
|
||||
public decimal Balance => TotalCharged - TotalPaid;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class MedicalDoctor
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PersonId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Specialty { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -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<MedicalTag> Tags { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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; }
|
||||
}
|
||||
|
||||
public class PersonAccessUser
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Username { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class MedicalPrescription
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PersonId { get; set; }
|
||||
public long? DoctorId { get; set; }
|
||||
public string MedicationName { get; set; } = string.Empty;
|
||||
public string? Dosage { get; set; }
|
||||
public string? Frequency { get; set; }
|
||||
public string? RxNumber { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public DateTime? StartDate { get; set; }
|
||||
public DateTime? EndDate { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
// Populated via JOIN, not stored in DB
|
||||
public string? DoctorName { get; set; }
|
||||
public DateTime? LastPickupDate { get; set; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class MedicalProviderPayment
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long ProviderId { get; set; }
|
||||
public long? DocumentId { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public DateTime? PaymentDate { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string Source { get; set; } = "manual";
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class TimelineEvent
|
||||
{
|
||||
public string EventType { get; set; } = "";
|
||||
public long Id { get; set; }
|
||||
public long PersonId { get; set; }
|
||||
public string? Label { get; set; }
|
||||
public string? Detail { get; set; }
|
||||
public string? SubType { get; set; }
|
||||
public DateTime? EventDate { get; set; }
|
||||
public long? DoctorId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class UserThemeOverrides
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long UserId { get; set; }
|
||||
public string BaseTheme { get; set; } = "light";
|
||||
public Dictionary<string, string> ColorOverrides { get; set; } = new();
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class VisitPrepData
|
||||
{
|
||||
public List<VisitPrepDocument> RecentDocuments { get; set; } = [];
|
||||
public List<MedicalCondition> ActiveConditions { get; set; } = [];
|
||||
public List<MedicalPrescription> ActivePrescriptions { get; set; } = [];
|
||||
public List<VisitPrepBill> RecentBills { get; set; } = [];
|
||||
}
|
||||
|
||||
public class VisitPrepDocument
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public string? FileName { get; set; }
|
||||
public DateTime? DocumentDate { get; set; }
|
||||
public string? Classification { get; set; }
|
||||
}
|
||||
|
||||
public class VisitPrepBill
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public decimal TotalAmount { get; set; }
|
||||
public string? Summary { get; set; }
|
||||
public string? Category { get; set; }
|
||||
public DateTime? BillDate { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
@page
|
||||
@model Media.JoshHeaps.Net.Pages.AdminModel
|
||||
@{
|
||||
ViewData["Title"] = "Admin";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/admin.css" asp-append-version="true" />
|
||||
}
|
||||
|
||||
<div class="dashboard-container">
|
||||
<div class="welcome-section">
|
||||
<div class="welcome-left">
|
||||
<a href="/Landing" class="back-button" title="Back to Home">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="19" y1="12" x2="5" y2="12"></line>
|
||||
<polyline points="12 19 5 12 12 5"></polyline>
|
||||
</svg>
|
||||
</a>
|
||||
<h1>Admin</h1>
|
||||
</div>
|
||||
<div class="quick-actions">
|
||||
<a href="/Profile" class="btn btn-secondary">Profile</a>
|
||||
<a href="/Logout" class="btn btn-danger">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-section">
|
||||
<div class="section-header">
|
||||
<h2>Roles</h2>
|
||||
</div>
|
||||
<div class="roles-list" id="rolesList"></div>
|
||||
<div class="create-role-form">
|
||||
<input type="text" id="newRoleName" placeholder="New role name" class="form-input" />
|
||||
<button id="createRoleBtn" class="btn btn-primary">Create Role</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-section">
|
||||
<div class="section-header">
|
||||
<h2>Users</h2>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Roles</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersTableBody">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="pagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/admin.js" asp-append-version="true"></script>
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Pages
|
||||
{
|
||||
public class AdminModel(DbExecutor dbExecutor) : AuthenticatedPageModel
|
||||
{
|
||||
private readonly DbExecutor _dbExecutor = dbExecutor;
|
||||
|
||||
public async Task<IActionResult> OnGetAsync()
|
||||
{
|
||||
RequireAuthentication();
|
||||
LoadUserSession();
|
||||
|
||||
var denied = await RequireRole("admin", _dbExecutor);
|
||||
if (denied != null) return denied;
|
||||
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,15 @@ namespace Media.JoshHeaps.Net.Pages;
|
||||
|
||||
public abstract class AuthenticatedPageModel : PageModel
|
||||
{
|
||||
protected async Task<IActionResult?> RequireRole(string role, DbExecutor dbExecutor)
|
||||
{
|
||||
var hasRole = await dbExecutor.ExecuteAsync<bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM app.user_roles ur JOIN app.roles r ON ur.role_id = r.id WHERE ur.user_id = @UserId AND r.name = @Role)",
|
||||
new { UserId, Role = role });
|
||||
|
||||
return hasRole ? null : NotFound();
|
||||
}
|
||||
|
||||
public long UserId { get; private set; }
|
||||
protected string Username { get; private set; } = string.Empty;
|
||||
protected string Email { get; private set; } = string.Empty;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
@page
|
||||
@model Media.JoshHeaps.Net.Pages.BlogAdminModel
|
||||
@{
|
||||
ViewData["Title"] = "Blog Admin";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/blog-admin.css" asp-append-version="true" />
|
||||
}
|
||||
|
||||
<div class="dashboard-container">
|
||||
<div class="welcome-section">
|
||||
<div class="welcome-left">
|
||||
<a href="/Admin" class="back-button" title="Back to Admin">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="19" y1="12" x2="5" y2="12"></line>
|
||||
<polyline points="12 19 5 12 12 5"></polyline>
|
||||
</svg>
|
||||
</a>
|
||||
<h1>Blog Admin</h1>
|
||||
</div>
|
||||
<div class="quick-actions">
|
||||
<button id="newPostBtn" class="btn btn-primary">New Post</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="postsList" class="admin-section">
|
||||
<div class="section-header">
|
||||
<h2>Posts</h2>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Slug</th>
|
||||
<th>Tags</th>
|
||||
<th>Published</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="postsTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="postEditor" class="admin-section" style="display: none;">
|
||||
<div class="section-header">
|
||||
<h2 id="editorTitle">New Post</h2>
|
||||
</div>
|
||||
<input type="hidden" id="editPostId" />
|
||||
<div class="form-group">
|
||||
<label for="postTitle">Title</label>
|
||||
<input type="text" id="postTitle" class="form-input" placeholder="Post title" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="postSummary">Summary</label>
|
||||
<input type="text" id="postSummary" class="form-input" placeholder="Brief summary" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="postTags">Tags (comma-separated)</label>
|
||||
<input type="text" id="postTags" class="form-input" placeholder="dev, personal" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="postDate">Published Date</label>
|
||||
<input type="date" id="postDate" class="form-input" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="postContent">Markdown Content</label>
|
||||
<div class="content-toolbar">
|
||||
<button type="button" id="uploadImageBtn" class="btn btn-secondary btn-sm">Upload Image</button>
|
||||
<input type="file" id="imageFileInput" accept="image/jpeg,image/png,image/gif,image/webp" style="display: none;" />
|
||||
<span id="uploadStatus" class="upload-status"></span>
|
||||
</div>
|
||||
<textarea id="postContent" class="form-input form-textarea" placeholder="Write your post in markdown..."></textarea>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<button id="savePostBtn" class="btn btn-primary">Save</button>
|
||||
<button id="cancelEditBtn" class="btn btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/blog-admin.js" asp-append-version="true"></script>
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Pages;
|
||||
|
||||
public class BlogAdminModel(DbExecutor dbExecutor) : AuthenticatedPageModel
|
||||
{
|
||||
private readonly DbExecutor _dbExecutor = dbExecutor;
|
||||
|
||||
public async Task<IActionResult> OnGetAsync()
|
||||
{
|
||||
RequireAuthentication();
|
||||
LoadUserSession();
|
||||
|
||||
var denied = await RequireRole("admin", _dbExecutor);
|
||||
if (denied != null) return denied;
|
||||
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
@page
|
||||
@model Media.JoshHeaps.Net.Pages.BreadboardModel
|
||||
@{
|
||||
ViewData["Title"] = "Breadboard Simulator";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/breadboard-projects.css" asp-append-version="true" />
|
||||
}
|
||||
|
||||
@* Class names are this page's own (bbp- prefix, styled in breadboard-projects.css).
|
||||
This app never links Bootstrap's stylesheet, so Bootstrap class names here would imply
|
||||
styling that does not exist. Colours come from the site.css custom properties, so the
|
||||
page follows the theme toggle without any theme-specific rules. *@
|
||||
<div class="bbp-page">
|
||||
<div class="bbp-header">
|
||||
<div class="bbp-header-left">
|
||||
<a href="/Landing" class="bbp-back" title="Back to Home">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="19" y1="12" x2="5" y2="12"></line>
|
||||
<polyline points="12 19 5 12 12 5"></polyline>
|
||||
</svg>
|
||||
</a>
|
||||
<h1>Breadboard Simulator</h1>
|
||||
</div>
|
||||
<a href="/Logout" class="bbp-btn bbp-btn-danger">Logout</a>
|
||||
</div>
|
||||
|
||||
<div id="bb-error" class="bbp-error" role="alert"></div>
|
||||
|
||||
<div class="bbp-card">
|
||||
<h2 class="bbp-card-title">New project</h2>
|
||||
<form id="bb-create-form" class="bbp-create-form">
|
||||
<div class="bbp-field">
|
||||
<label for="bb-new-name">Name</label>
|
||||
<input type="text" class="bbp-input" id="bb-new-name" maxlength="200" required />
|
||||
</div>
|
||||
<div class="bbp-field bbp-field-grow">
|
||||
<label for="bb-new-description">Description</label>
|
||||
<input type="text" class="bbp-input" id="bb-new-description" maxlength="2000" />
|
||||
</div>
|
||||
<button type="submit" class="bbp-btn bbp-btn-primary">Create</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@if (Model.Projects.Count == 0)
|
||||
{
|
||||
<p class="bbp-empty">No projects yet. Create one above to start wiring.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="bbp-list" id="bb-project-list">
|
||||
@foreach (var project in Model.Projects)
|
||||
{
|
||||
<div class="bbp-item" data-project-id="@project.Id" data-project-name="@project.Name">
|
||||
<div class="bbp-item-main">
|
||||
<a class="bbp-item-name" href="/[email protected]">@project.Name</a>
|
||||
@if (!string.IsNullOrWhiteSpace(project.Description))
|
||||
{
|
||||
<div class="bbp-item-description">@project.Description</div>
|
||||
}
|
||||
@* Rendered in the viewer's timezone by the module script below, not the server's. *@
|
||||
<div class="bbp-item-meta bbp-updated" data-updated-utc="@project.UpdatedAt.ToUniversalTime().ToString("O")"></div>
|
||||
</div>
|
||||
<div class="bbp-item-actions">
|
||||
@* An <a>, not a <button data-action>, so the delegated handler below ignores it. *@
|
||||
<a class="bbp-btn bbp-btn-primary" href="/[email protected]">Open</a>
|
||||
<button type="button" class="bbp-btn" data-action="rename">Rename</button>
|
||||
<button type="button" class="bbp-btn bbp-btn-danger" data-action="delete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script type="module">
|
||||
const projectsUrl = '/api/breadboard/projects';
|
||||
const errorBox = document.getElementById('bb-error');
|
||||
|
||||
function showError(message) {
|
||||
errorBox.textContent = message;
|
||||
errorBox.classList.add('is-visible');
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
errorBox.textContent = '';
|
||||
errorBox.classList.remove('is-visible');
|
||||
}
|
||||
|
||||
async function send(method, url, body) {
|
||||
clearError();
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body)
|
||||
});
|
||||
} catch {
|
||||
// A silent no-op would read as success, which is the worst outcome for a delete.
|
||||
showError('Network error - could not reach the server. Nothing was changed.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let message = `Request failed (${response.status})`;
|
||||
try {
|
||||
const payload = await response.json();
|
||||
if (Array.isArray(payload?.errors) && payload.errors.length > 0) {
|
||||
message = payload.errors.join(' ');
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON error body; the status-based message stands.
|
||||
}
|
||||
|
||||
showError(message);
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const cell of document.querySelectorAll('.bbp-updated')) {
|
||||
const updated = new Date(cell.dataset.updatedUtc);
|
||||
cell.textContent = `Updated ${updated.toLocaleString()}`;
|
||||
}
|
||||
|
||||
document.getElementById('bb-create-form').addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const name = document.getElementById('bb-new-name').value.trim();
|
||||
const description = document.getElementById('bb-new-description').value.trim();
|
||||
if (name.length === 0) {
|
||||
showError('Name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (await send('POST', projectsUrl, { name, description: description || null })) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('bb-project-list')?.addEventListener('click', async (event) => {
|
||||
const button = event.target.closest('button[data-action]');
|
||||
if (button === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const row = button.closest('[data-project-id]');
|
||||
const id = row.dataset.projectId;
|
||||
const currentName = row.dataset.projectName;
|
||||
|
||||
if (button.dataset.action === 'rename') {
|
||||
const name = window.prompt('New name', currentName);
|
||||
if (name === null || name.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
if (await send('PUT', `${projectsUrl}/${id}`, { name: name.trim() })) {
|
||||
window.location.reload();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.confirm(`Delete "${currentName}"? This cannot be undone.`)) {
|
||||
if (await send('DELETE', `${projectsUrl}/${id}`)) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Media.JoshHeaps.Net.Models;
|
||||
using Media.JoshHeaps.Net.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Pages;
|
||||
|
||||
public class BreadboardModel(BreadboardService breadboardService) : AuthenticatedPageModel
|
||||
{
|
||||
public List<BreadboardProjectSummary> Projects { get; private set; } = [];
|
||||
|
||||
public async Task<IActionResult> OnGetAsync()
|
||||
{
|
||||
RequireAuthentication();
|
||||
LoadUserSession();
|
||||
|
||||
// RequireAuthentication only queues a redirect, so bail out explicitly rather than
|
||||
// rendering the page against a zero user id.
|
||||
if (UserId == 0)
|
||||
{
|
||||
return Redirect("/Login");
|
||||
}
|
||||
|
||||
Projects = await breadboardService.GetProjectsAsync(UserId);
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
@page
|
||||
@model Media.JoshHeaps.Net.Pages.BreadboardEditorModel
|
||||
@{
|
||||
ViewData["Title"] = $"Breadboard - {Model.ProjectName}";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/breadboard.css" asp-append-version="true" />
|
||||
}
|
||||
|
||||
@* Shell only. Everything inside #breadboard-editor is built by the editor module, so the
|
||||
component palette can grow without a Razor edit. Contract agreed with frontend-impl:
|
||||
the root id and the three data-attributes below are the entire server-to-editor surface. *@
|
||||
<div class="bb-fullbleed">
|
||||
<div id="breadboard-editor"
|
||||
data-project-id="@Model.ProjectId"
|
||||
data-project-name="@Model.ProjectName"
|
||||
data-api-base="/api/breadboard"></div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script type="module" src="~/js/breadboard/editor/main.js"></script>
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Media.JoshHeaps.Net.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Pages;
|
||||
|
||||
public class BreadboardEditorModel(BreadboardService breadboardService) : AuthenticatedPageModel
|
||||
{
|
||||
public long ProjectId { get; private set; }
|
||||
public string ProjectName { get; private set; } = string.Empty;
|
||||
|
||||
public async Task<IActionResult> OnGetAsync([FromQuery] long projectId)
|
||||
{
|
||||
RequireAuthentication();
|
||||
LoadUserSession();
|
||||
|
||||
if (UserId == 0)
|
||||
{
|
||||
return Redirect("/Login");
|
||||
}
|
||||
|
||||
// Ownership check lives in SQL, so someone else's project is simply not found. The
|
||||
// summary lookup deliberately skips the circuit — the editor module fetches the
|
||||
// document itself, and pulling it here would parse a multi-megabyte payload to throw away.
|
||||
var project = await breadboardService.GetProjectSummaryAsync(projectId, UserId);
|
||||
if (project == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
ProjectId = project.Id;
|
||||
ProjectName = project.Name;
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,27 @@
|
||||
@page "{handler?}"
|
||||
@model Media.JoshHeaps.Net.Pages.IndexModel
|
||||
@model Media.JoshHeaps.Net.Pages.GalleryModel
|
||||
@{
|
||||
ViewData["Title"] = "Dashboard";
|
||||
ViewData["Title"] = "Gallery";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/Home/page.css" asp-append-version="true" />
|
||||
<link rel="stylesheet" href="~/css/Gallery/page.css" asp-append-version="true" />
|
||||
<link rel="stylesheet" href="~/css/gallery.css" asp-append-version="true" />
|
||||
}
|
||||
|
||||
<div class="dashboard-container">
|
||||
<div class="welcome-section">
|
||||
<div class="welcome-left">
|
||||
<a href="/Landing" class="back-button" title="Back to Home">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="19" y1="12" x2="5" y2="12"></line>
|
||||
<polyline points="12 19 5 12 12 5"></polyline>
|
||||
</svg>
|
||||
</a>
|
||||
<h1>Welcome back, @Model.Dashboard?.Username!</h1>
|
||||
</div>
|
||||
|
||||
<div class="quick-actions">
|
||||
@if (Model.Dashboard?.EmailVerified == false)
|
||||
{
|
||||
@@ -59,11 +68,11 @@
|
||||
<div class="card">
|
||||
<div class="gallery-header">
|
||||
<div class="breadcrumb-nav">
|
||||
<a href="/[email protected]">Home</a>
|
||||
<a href="/Gallery[email protected]">Home</a>
|
||||
@foreach (var folder in Model.FolderPath)
|
||||
{
|
||||
<span class="breadcrumb-separator">/</span>
|
||||
<a href="/[email protected]&[email protected]">@folder.Name</a>
|
||||
<a href="/Gallery[email protected]&[email protected]">@folder.Name</a>
|
||||
}
|
||||
@if (Model.IsSharedFolder && !string.IsNullOrEmpty(Model.SharedByUsername))
|
||||
{
|
||||
@@ -72,9 +81,9 @@
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="view-mode-toggle">
|
||||
<a href="/?view=own" class="view-mode-btn @(Model.ViewMode == "own" ? "active" : "")">My Folders</a>
|
||||
<a href="/?view=shared" class="view-mode-btn @(Model.ViewMode == "shared" ? "active" : "")">Shared with Me</a>
|
||||
<a href="/?view=all" class="view-mode-btn @(Model.ViewMode == "all" ? "active" : "")">All</a>
|
||||
<a href="/Gallery?view=own" class="view-mode-btn @(Model.ViewMode == "own" ? "active" : "")">My Folders</a>
|
||||
<a href="/Gallery?view=shared" class="view-mode-btn @(Model.ViewMode == "shared" ? "active" : "")">Shared with Me</a>
|
||||
<a href="/Gallery?view=all" class="view-mode-btn @(Model.ViewMode == "all" ? "active" : "")">All</a>
|
||||
</div>
|
||||
@if (!Model.IsSharedFolder)
|
||||
{
|
||||
@@ -97,8 +106,8 @@
|
||||
@if (Model.CurrentFolderId.HasValue)
|
||||
{
|
||||
var backUrl = Model.FolderPath.Count > 1
|
||||
? $"/?folderId={Model.FolderPath[Model.FolderPath.Count - 2].Id}&view={Model.ViewMode}"
|
||||
: $"/?view={Model.ViewMode}";
|
||||
? $"/Gallery?folderId={Model.FolderPath[Model.FolderPath.Count - 2].Id}&view={Model.ViewMode}"
|
||||
: $"/Gallery?view={Model.ViewMode}";
|
||||
<div class="gallery-item folder-item" data-folder-id="back" onclick="window.location.href='@backUrl'">
|
||||
<div class="folder-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -110,7 +119,7 @@
|
||||
}
|
||||
@foreach (var sharedFolder in Model.SharedFolders)
|
||||
{
|
||||
<div class="gallery-item folder-item shared-folder-item" data-folder-id="@sharedFolder.FolderId" onclick="window.location.href='/[email protected]&[email protected]'">
|
||||
<div class="gallery-item folder-item shared-folder-item" data-folder-id="@sharedFolder.FolderId" onclick="window.location.href='/Gallery[email protected]&[email protected]'">
|
||||
<div class="folder-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
@@ -125,7 +134,7 @@
|
||||
}
|
||||
@foreach (var folder in Model.Folders)
|
||||
{
|
||||
<div class="gallery-item folder-item @(folder.UserId != Model.UserId ? "shared-folder-item" : "")" data-folder-id="@folder.Id" data-owner-id="@folder.UserId" onclick="window.location.href='/[email protected]&[email protected]'">
|
||||
<div class="gallery-item folder-item @(folder.UserId != Model.UserId ? "shared-folder-item" : "")" data-folder-id="@folder.Id" data-owner-id="@folder.UserId" onclick="window.location.href='/Gallery[email protected]&[email protected]'">
|
||||
<div class="folder-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
+6
-6
@@ -4,16 +4,16 @@ using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Pages
|
||||
{
|
||||
public class IndexModel(UserService userService, MediaService mediaService, FolderService folderService) : AuthenticatedPageModel
|
||||
public class GalleryModel(UserService userService, MediaService mediaService, FolderService folderService) : AuthenticatedPageModel
|
||||
{
|
||||
private readonly MediaService _mediaService = mediaService;
|
||||
private readonly FolderService _folderService = folderService;
|
||||
|
||||
public UserDashboard? Dashboard { get; set; }
|
||||
public List<UserMedia> MediaItems { get; set; } = new();
|
||||
public List<Folder> Folders { get; set; } = new();
|
||||
public List<Folder> FolderPath { get; set; } = new();
|
||||
public List<SharedFolderInfo> SharedFolders { get; set; } = new();
|
||||
public List<UserMedia> MediaItems { get; set; } = [];
|
||||
public List<Folder> Folders { get; set; } = [];
|
||||
public List<Folder> FolderPath { get; set; } = [];
|
||||
public List<SharedFolderInfo> SharedFolders { get; set; } = [];
|
||||
public long? CurrentFolderId { get; set; }
|
||||
public bool IsSharedFolder { get; set; }
|
||||
public long? FolderOwnerId { get; set; }
|
||||
@@ -64,7 +64,7 @@ namespace Media.JoshHeaps.Net.Pages
|
||||
SharedFolders = await _folderService.GetSharedFoldersAsync(UserId);
|
||||
if (!CurrentFolderId.HasValue)
|
||||
{
|
||||
Folders = new List<Folder>();
|
||||
Folders = [];
|
||||
}
|
||||
else if (IsSharedFolder && FolderOwnerId.HasValue)
|
||||
{
|
||||
@@ -0,0 +1,219 @@
|
||||
@page
|
||||
@model Media.JoshHeaps.Net.Pages.LandingModel
|
||||
@{
|
||||
ViewData["Title"] = "Home";
|
||||
Layout = null;
|
||||
}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>@ViewData["Title"] - Media Manager</title>
|
||||
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||
<link rel="stylesheet" href="~/css/landing.css" asp-append-version="true" />
|
||||
<script src="~/js/theme.js" asp-append-version="true"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="landing-wrapper">
|
||||
<div class="landing-container">
|
||||
<div class="landing-header">
|
||||
<div class="logo-area">
|
||||
<div class="logo-circle">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>Welcome Back</h1>
|
||||
<p class="subtitle">Choose a workspace to continue</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cards-grid">
|
||||
<a href="/Gallery" class="landing-card">
|
||||
<div class="card-content">
|
||||
<div class="card-icon-wrapper">
|
||||
<div class="card-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
||||
<polyline points="21 15 16 10 5 21"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-text">
|
||||
<h2>Media Storage</h2>
|
||||
<p class="card-description">Upload, organize, and manage your images in folders with sharing capabilities</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span class="card-link-text">Open workspace</span>
|
||||
<div class="card-arrow">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/NetworkGraph" class="landing-card">
|
||||
<div class="card-content">
|
||||
<div class="card-icon-wrapper">
|
||||
<div class="card-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<circle cx="6" cy="18" r="2"></circle>
|
||||
<circle cx="18" cy="18" r="2"></circle>
|
||||
<circle cx="18" cy="6" r="2"></circle>
|
||||
<circle cx="6" cy="6" r="2"></circle>
|
||||
<line x1="13.5" y1="10.5" x2="16.5" y2="7.5"></line>
|
||||
<line x1="10.5" y1="10.5" x2="7.5" y2="7.5"></line>
|
||||
<line x1="10.5" y1="13.5" x2="7.5" y2="16.5"></line>
|
||||
<line x1="13.5" y1="13.5" x2="16.5" y2="16.5"></line>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-text">
|
||||
<h2>Network Graphs</h2>
|
||||
<p class="card-description">Create and visualize network graphs to map relationships and connections</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span class="card-link-text">Open workspace</span>
|
||||
<div class="card-arrow">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/Breadboard" class="landing-card">
|
||||
<div class="card-content">
|
||||
<div class="card-icon-wrapper">
|
||||
<div class="card-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect>
|
||||
<line x1="9" y1="4" x2="9" y2="20"></line>
|
||||
<line x1="15" y1="4" x2="15" y2="20"></line>
|
||||
<line x1="4" y1="9" x2="20" y2="9"></line>
|
||||
<line x1="4" y1="15" x2="20" y2="15"></line>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-text">
|
||||
<h2>Breadboard Simulator</h2>
|
||||
<p class="card-description">Build and simulate logic circuits on a virtual solderless breadboard</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span class="card-link-text">Open workspace</span>
|
||||
<div class="card-arrow">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@if (Model.IsAdmin)
|
||||
{
|
||||
<a href="/Admin" class="landing-card">
|
||||
<div class="card-content">
|
||||
<div class="card-icon-wrapper">
|
||||
<div class="card-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-text">
|
||||
<h2>Admin</h2>
|
||||
<p class="card-description">Site administration and management tools</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span class="card-link-text">Open workspace</span>
|
||||
<div class="card-arrow">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/BlogAdmin" class="landing-card">
|
||||
<div class="card-content">
|
||||
<div class="card-icon-wrapper">
|
||||
<div class="card-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20h9"></path>
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-text">
|
||||
<h2>Blog</h2>
|
||||
<p class="card-description">Create and manage blog posts for your website</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span class="card-link-text">Open workspace</span>
|
||||
<div class="card-arrow">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
|
||||
@if (Model.HasMedicalRole)
|
||||
{
|
||||
<a href="/MedicalDocs" class="landing-card">
|
||||
<div class="card-content">
|
||||
<div class="card-icon-wrapper">
|
||||
<div class="card-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||
<polyline points="14 2 14 8 20 8"></polyline>
|
||||
<line x1="12" y1="11" x2="12" y2="17"></line>
|
||||
<line x1="9" y1="14" x2="15" y2="14"></line>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-text">
|
||||
<h2>Medical Documents</h2>
|
||||
<p class="card-description">Organize medical records, receipts, and notes for the family</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span class="card-link-text">Open workspace</span>
|
||||
<div class="card-arrow">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="landing-footer">
|
||||
<a href="/Profile" class="footer-link">Profile</a>
|
||||
<span class="footer-separator">•</span>
|
||||
<a href="/Logout" class="footer-link">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Pages
|
||||
{
|
||||
public class LandingModel(DbExecutor dbExecutor) : AuthenticatedPageModel
|
||||
{
|
||||
public bool IsAdmin { get; set; }
|
||||
public bool HasMedicalRole { get; set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync()
|
||||
{
|
||||
RequireAuthentication();
|
||||
LoadUserSession();
|
||||
|
||||
IsAdmin = await dbExecutor.ExecuteAsync<bool>(
|
||||
"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<bool>(
|
||||
"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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,10 @@
|
||||
|
||||
<form id="loginForm" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
@if (!string.IsNullOrEmpty(Model.ReturnUrl))
|
||||
{
|
||||
<input type="hidden" name="ReturnUrl" value="@Model.ReturnUrl" />
|
||||
}
|
||||
<div class="form-group">
|
||||
<label for="email" class="form-label">Email or Username</label>
|
||||
<input type="text" class="form-control" id="email" name="email"
|
||||
@@ -60,11 +64,14 @@
|
||||
<div class="invalid-feedback"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-options">
|
||||
<div class="checkbox-wrapper">
|
||||
<input type="checkbox" id="rememberMe" name="rememberMe"
|
||||
@(Model.RememberMe ? "checked" : "") />
|
||||
<label for="rememberMe">Remember me</label>
|
||||
</div>
|
||||
<a href="/LoginHelp" class="forgot-password-link">Forgot password?</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">Sign In</button>
|
||||
</form>
|
||||
|
||||
@@ -5,10 +5,8 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Pages;
|
||||
|
||||
public class LoginModel : PageModel
|
||||
public class LoginModel(AuthService authService) : PageModel
|
||||
{
|
||||
private readonly AuthService _authService;
|
||||
|
||||
[BindProperty]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
@@ -18,22 +16,20 @@ public class LoginModel : PageModel
|
||||
[BindProperty]
|
||||
public bool RememberMe { get; set; }
|
||||
|
||||
[BindProperty(SupportsGet = true)]
|
||||
public string? ReturnUrl { get; set; }
|
||||
|
||||
public string? ErrorMessage { get; set; }
|
||||
public string? SuccessMessage { get; set; }
|
||||
public string? WarningMessage { get; set; }
|
||||
|
||||
public LoginModel(AuthService authService)
|
||||
{
|
||||
_authService = authService;
|
||||
}
|
||||
|
||||
public void OnGet([FromQuery] string? registered, [FromQuery] string? verified)
|
||||
public void OnGet([FromQuery] string? registered, [FromQuery] string? verified, [FromQuery] string? reset)
|
||||
{
|
||||
// Check if user is already logged in
|
||||
var userId = HttpContext.Session.GetString("UserId");
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
Response.Redirect("/");
|
||||
Response.Redirect(SafeReturnUrl() ?? "/Landing");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -48,6 +44,12 @@ public class LoginModel : PageModel
|
||||
{
|
||||
SuccessMessage = "Email verified! You can now sign in.";
|
||||
}
|
||||
|
||||
// Show success message if password was just reset
|
||||
if (reset == "true")
|
||||
{
|
||||
SuccessMessage = "Your password has been reset. You can now sign in with your new password.";
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostAsync()
|
||||
@@ -58,7 +60,7 @@ public class LoginModel : PageModel
|
||||
return Page();
|
||||
}
|
||||
|
||||
var (success, error, userInfo) = await _authService.LoginAsync(Email, Password);
|
||||
var (success, error, userInfo) = await authService.LoginAsync(Email, Password);
|
||||
|
||||
if (!success || userInfo == null)
|
||||
{
|
||||
@@ -91,6 +93,9 @@ public class LoginModel : PageModel
|
||||
Response.Cookies.Append("RememberMe", userInfo.Id.ToString(), cookieOptions);
|
||||
}
|
||||
|
||||
return Redirect("/");
|
||||
return Redirect(SafeReturnUrl() ?? "/Landing");
|
||||
}
|
||||
|
||||
private string? SafeReturnUrl() =>
|
||||
!string.IsNullOrWhiteSpace(ReturnUrl) && Url.IsLocalUrl(ReturnUrl) ? ReturnUrl : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
@page
|
||||
@model Media.JoshHeaps.Net.Pages.LoginHelpModel
|
||||
@{
|
||||
ViewData["Title"] = "Login Help";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/auth.css" asp-append-version="true" />
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/auth.js" asp-append-version="true"></script>
|
||||
}
|
||||
|
||||
<div class="auth-container">
|
||||
<div class="auth-card">
|
||||
@if (Model.ShowResetForm)
|
||||
{
|
||||
<div class="auth-header">
|
||||
<h1>Reset Password</h1>
|
||||
<p>Enter your new password below</p>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
@Model.ErrorMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
<form id="resetPasswordForm" method="post" asp-page-handler="ResetPassword">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="Token" value="@Model.Token" />
|
||||
|
||||
<div class="form-group">
|
||||
<label for="newPassword" class="form-label">New Password</label>
|
||||
<div class="password-wrapper">
|
||||
<input type="password" class="form-control" id="newPassword" name="NewPassword"
|
||||
autocomplete="new-password" required minlength="8" />
|
||||
<button type="button" class="password-toggle">Show</button>
|
||||
</div>
|
||||
<div class="invalid-feedback"></div>
|
||||
<div class="password-strength">
|
||||
<div class="password-strength-bar"></div>
|
||||
</div>
|
||||
<div class="password-strength-text"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword" class="form-label">Confirm Password</label>
|
||||
<div class="password-wrapper">
|
||||
<input type="password" class="form-control" id="confirmPassword" name="ConfirmPassword"
|
||||
autocomplete="new-password" required minlength="8" />
|
||||
<button type="button" class="password-toggle">Show</button>
|
||||
</div>
|
||||
<div class="invalid-feedback"></div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">Reset Password</button>
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="auth-header">
|
||||
<h1>Forgot Password</h1>
|
||||
<p>Enter your email to receive a reset link</p>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
@Model.ErrorMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
|
||||
{
|
||||
<div class="alert alert-success">
|
||||
@Model.SuccessMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
<form id="requestResetForm" method="post" asp-page-handler="RequestReset">
|
||||
@Html.AntiForgeryToken()
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email" class="form-label">Email Address</label>
|
||||
<input type="email" class="form-control" id="email" name="Email"
|
||||
value="@Model.Email" autocomplete="email" required />
|
||||
<div class="invalid-feedback"></div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">Send Reset Link</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
<div class="auth-footer">
|
||||
<p>Remember your password? <a href="/Login">Sign in</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,111 @@
|
||||
using Media.JoshHeaps.Net.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Pages;
|
||||
|
||||
public class LoginHelpModel(AuthService authService, EmailService emailService, ILogger<LoginHelpModel> logger) : PageModel
|
||||
{
|
||||
[BindProperty]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[BindProperty]
|
||||
public string Token { get; set; } = string.Empty;
|
||||
|
||||
[BindProperty]
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
|
||||
[BindProperty]
|
||||
public string ConfirmPassword { get; set; } = string.Empty;
|
||||
|
||||
public string? ErrorMessage { get; set; }
|
||||
public string? SuccessMessage { get; set; }
|
||||
public bool ShowResetForm { get; set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync([FromQuery] string? token)
|
||||
{
|
||||
// Redirect if already logged in
|
||||
var userId = HttpContext.Session.GetString("UserId");
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
return Redirect("/Landing");
|
||||
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
var (valid, error) = await authService.ValidatePasswordResetTokenAsync(token);
|
||||
if (valid)
|
||||
{
|
||||
ShowResetForm = true;
|
||||
Token = token;
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorMessage = error;
|
||||
}
|
||||
}
|
||||
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostRequestResetAsync()
|
||||
{
|
||||
// Redirect if already logged in
|
||||
var userId = HttpContext.Session.GetString("UserId");
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
return Redirect("/Landing");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Email))
|
||||
{
|
||||
ErrorMessage = "Please enter your email address.";
|
||||
return Page();
|
||||
}
|
||||
|
||||
var (success, error, token, username) = await authService.RequestPasswordResetAsync(Email.Trim());
|
||||
|
||||
if (!success)
|
||||
{
|
||||
logger.LogError("Password reset request failed for {Email}: {Error}", Email, error);
|
||||
}
|
||||
|
||||
// Send email if we got a token back (user exists and is eligible)
|
||||
if (token != null)
|
||||
{
|
||||
await emailService.SendPasswordResetEmailAsync(Email.Trim(), username ?? Email.Split('@')[0], token);
|
||||
}
|
||||
|
||||
// Always show the same message regardless of whether the email exists
|
||||
SuccessMessage = "If an account exists with that email, you will receive a password reset link shortly.";
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostResetPasswordAsync()
|
||||
{
|
||||
// Redirect if already logged in
|
||||
var userId = HttpContext.Session.GetString("UserId");
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
return Redirect("/Landing");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(NewPassword) || NewPassword.Length < 8)
|
||||
{
|
||||
ErrorMessage = "Password must be at least 8 characters.";
|
||||
ShowResetForm = true;
|
||||
return Page();
|
||||
}
|
||||
|
||||
if (NewPassword != ConfirmPassword)
|
||||
{
|
||||
ErrorMessage = "Passwords do not match.";
|
||||
ShowResetForm = true;
|
||||
return Page();
|
||||
}
|
||||
|
||||
var (success, error) = await authService.ResetPasswordAsync(Token, NewPassword);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
ErrorMessage = error;
|
||||
return Page();
|
||||
}
|
||||
|
||||
return Redirect("/Login?reset=true");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
@page
|
||||
@model Media.JoshHeaps.Net.Pages.MedicalDocsModel
|
||||
@{
|
||||
ViewData["Title"] = "Medical Documents";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/medical-docs.css" asp-append-version="true" />
|
||||
}
|
||||
|
||||
<div class="dashboard-container">
|
||||
<div class="welcome-section">
|
||||
<div class="welcome-left">
|
||||
<a href="/Landing" class="back-button" title="Back to Home">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="19" y1="12" x2="5" y2="12"></line>
|
||||
<polyline points="12 19 5 12 12 5"></polyline>
|
||||
</svg>
|
||||
</a>
|
||||
<h1>Medical Documents</h1>
|
||||
</div>
|
||||
<div class="quick-actions">
|
||||
<a href="/Profile" class="btn btn-secondary">Profile</a>
|
||||
<a href="/Logout" class="btn btn-danger">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="medical-layout">
|
||||
<!-- Sidebar: People -->
|
||||
<div class="medical-sidebar">
|
||||
<h3>People</h3>
|
||||
<div class="sidebar-people-list" id="peopleList"></div>
|
||||
<div class="sidebar-add-person">
|
||||
<input type="text" id="newPersonName" placeholder="Add person..." class="form-input" />
|
||||
<button id="addPersonBtn" class="btn btn-primary btn-sm">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="medical-main">
|
||||
<!-- Summary Cards -->
|
||||
<div class="summary-cards" id="summaryCards" style="display: none;">
|
||||
<div class="summary-card active" data-tab="documents" onclick="medDocsSwitchTab('documents')">
|
||||
<div class="count" id="summaryDocCount">0</div>
|
||||
<div class="label">Documents</div>
|
||||
</div>
|
||||
<div class="summary-card" data-tab="conditions" onclick="medDocsSwitchTab('conditions')">
|
||||
<div class="count" id="summaryCondCount">0</div>
|
||||
<div class="label">Conditions</div>
|
||||
</div>
|
||||
<div class="summary-card" data-tab="prescriptions" onclick="medDocsSwitchTab('prescriptions')">
|
||||
<div class="count" id="summaryRxCount">0</div>
|
||||
<div class="label">Prescriptions</div>
|
||||
</div>
|
||||
<div class="summary-card" data-tab="doctors" onclick="medDocsSwitchTab('doctors')">
|
||||
<div class="count" id="summaryDrCount">0</div>
|
||||
<div class="label">Doctors</div>
|
||||
</div>
|
||||
<div class="summary-card" data-tab="bills" onclick="medDocsSwitchTab('bills')">
|
||||
<div class="count" id="summaryBillOop">$0</div>
|
||||
<div class="label">Total Paid</div>
|
||||
<div class="sublabel" id="summaryBillCharged">of $0 charged</div>
|
||||
<div class="sublabel" id="summaryBillDue"></div>
|
||||
</div>
|
||||
<div class="summary-card" data-tab="timeline" onclick="medDocsSwitchTab('timeline')">
|
||||
<div class="count" id="summaryTimelineIcon">📅</div>
|
||||
<div class="label">Timeline</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Tabs -->
|
||||
<div class="main-tabs" id="mainTabs">
|
||||
<button class="main-tab" data-tab="documents" onclick="medDocsSwitchTab('documents')">Documents</button>
|
||||
<button class="main-tab" data-tab="conditions" onclick="medDocsSwitchTab('conditions')">Conditions</button>
|
||||
<button class="main-tab" data-tab="prescriptions" onclick="medDocsSwitchTab('prescriptions')">Prescriptions</button>
|
||||
<button class="main-tab active" data-tab="doctors" onclick="medDocsSwitchTab('doctors')">Doctors</button>
|
||||
<button class="main-tab" data-tab="bills" onclick="medDocsSwitchTab('bills')">Bills</button>
|
||||
<button class="main-tab" data-tab="timeline" onclick="medDocsSwitchTab('timeline')">Timeline</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Panels -->
|
||||
<div class="tab-panels">
|
||||
<!-- Documents Panel -->
|
||||
<div class="tab-panel" id="panel-documents">
|
||||
<div class="add-form-toggle">
|
||||
<button class="btn btn-secondary" onclick="medDocsToggleAddForm('panel-documents')">+ Add Document</button>
|
||||
<span class="doc-count" id="docCount"></span>
|
||||
<button class="btn btn-secondary btn-sm" id="processAllBtn" style="display: none;" onclick="medDocsProcessAll()">Process All with AI</button>
|
||||
<button class="btn btn-secondary btn-sm" id="batchModeBtn" style="display: none;" onclick="medDocsToggleBatchMode()">Select & Reprocess</button>
|
||||
</div>
|
||||
<div class="add-form-collapsible">
|
||||
<div class="upload-sub-tabs">
|
||||
<button class="tab-btn active" data-tab="file">Upload File</button>
|
||||
<button class="tab-btn" data-tab="note">Text Note</button>
|
||||
</div>
|
||||
|
||||
<!-- File Upload -->
|
||||
<div class="tab-content active" id="tab-file">
|
||||
<div class="upload-area" id="dropZone">
|
||||
<div class="upload-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="17 8 12 3 7 8"></polyline>
|
||||
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<p>Drag & drop files here or <button class="link-btn" id="browseBtn">browse</button></p>
|
||||
<p class="upload-hint">Any file type, up to 50MB</p>
|
||||
<input type="file" id="fileInput" multiple style="display: none;" />
|
||||
</div>
|
||||
<div class="upload-fields">
|
||||
<input type="text" id="fileTitle" placeholder="Title (optional)" class="form-input" />
|
||||
<input type="text" id="fileDescription" placeholder="Description (optional)" class="form-input" />
|
||||
<input type="date" id="fileDate" class="form-input" />
|
||||
<select id="fileClassification" class="form-input">
|
||||
<option value="">Classification (optional)</option>
|
||||
<option value="receipt">Receipt</option>
|
||||
<option value="lab_result">Lab Result</option>
|
||||
<option value="prescription">Prescription</option>
|
||||
<option value="imaging">Imaging</option>
|
||||
<option value="dr_note">Doctor Note</option>
|
||||
<option value="insurance">Insurance</option>
|
||||
<option value="referral">Referral</option>
|
||||
<option value="discharge">Discharge Summary</option>
|
||||
<option value="recording">Recording/Transcript</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="upload-queue" id="uploadQueue"></div>
|
||||
</div>
|
||||
|
||||
<!-- Text Note -->
|
||||
<div class="tab-content" id="tab-note">
|
||||
<div class="note-form">
|
||||
<input type="text" id="noteTitle" placeholder="Title *" class="form-input" />
|
||||
<textarea id="noteDescription" placeholder="Note content..." class="form-input note-textarea" rows="6"></textarea>
|
||||
<div class="note-fields">
|
||||
<input type="date" id="noteDate" class="form-input" />
|
||||
<select id="noteClassification" class="form-input">
|
||||
<option value="">Classification (optional)</option>
|
||||
<option value="receipt">Receipt</option>
|
||||
<option value="lab_result">Lab Result</option>
|
||||
<option value="prescription">Prescription</option>
|
||||
<option value="dr_note">Doctor Note</option>
|
||||
<option value="recording">Recording/Transcript</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="saveNoteBtn" class="btn btn-primary">Save Note</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-bar" id="filterBar" style="display: none;">
|
||||
<input type="text" id="filterSearch" placeholder="Search documents..." class="form-input filter-search" />
|
||||
<select id="filterClassification" class="form-input">
|
||||
<option value="">All Classifications</option>
|
||||
<option value="receipt">Receipt</option>
|
||||
<option value="lab_result">Lab Result</option>
|
||||
<option value="prescription">Prescription</option>
|
||||
<option value="imaging">Imaging</option>
|
||||
<option value="dr_note">Doctor Note</option>
|
||||
<option value="insurance">Insurance</option>
|
||||
<option value="referral">Referral</option>
|
||||
<option value="discharge">Discharge Summary</option>
|
||||
<option value="recording">Recording/Transcript</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
<select id="filterDocType" class="form-input">
|
||||
<option value="">All Types</option>
|
||||
<option value="file">Files</option>
|
||||
<option value="note">Notes</option>
|
||||
</select>
|
||||
<select id="filterDoctor" class="form-input">
|
||||
<option value="">All Doctors</option>
|
||||
</select>
|
||||
<select id="filterTag" class="form-input">
|
||||
<option value="">All Tags</option>
|
||||
</select>
|
||||
<select id="filterCondition" class="form-input">
|
||||
<option value="">All Conditions</option>
|
||||
</select>
|
||||
<input type="date" id="filterFromDate" class="form-input" title="From date" />
|
||||
<input type="date" id="filterToDate" class="form-input" title="To date" />
|
||||
<button class="btn btn-secondary btn-sm" onclick="medDocsClearFilters()">Clear</button>
|
||||
</div>
|
||||
<div class="batch-toolbar" id="batchToolbar" style="display:none">
|
||||
<label><input type="checkbox" id="selectAllCheckbox" onchange="medDocsToggleSelectAll(this.checked)"> Select All</label>
|
||||
<span id="batchCount">0 selected</span>
|
||||
<button class="btn btn-primary btn-sm" onclick="medDocsProcessBatch()">Reprocess Selected</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="medDocsToggleBatchMode()">Cancel</button>
|
||||
</div>
|
||||
<div class="documents-list" id="documentsList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Conditions Panel -->
|
||||
<div class="tab-panel" id="panel-conditions">
|
||||
<div class="add-form-toggle">
|
||||
<button class="btn btn-secondary" onclick="medDocsToggleAddForm('panel-conditions')">+ Add Condition</button>
|
||||
</div>
|
||||
<div class="add-form-collapsible">
|
||||
<div class="inline-form-row">
|
||||
<input type="text" id="newConditionName" placeholder="Condition name *" class="form-input" />
|
||||
<input type="date" id="newConditionDate" class="form-input" title="Diagnosed date" />
|
||||
<input type="text" id="newConditionNotes" placeholder="Notes" class="form-input" />
|
||||
<button class="btn btn-primary btn-sm" onclick="medDocsAddCondition()">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="conditions-list" id="conditionsList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Prescriptions Panel -->
|
||||
<div class="tab-panel" id="panel-prescriptions">
|
||||
<div class="add-form-toggle">
|
||||
<button class="btn btn-secondary" onclick="medDocsToggleAddForm('panel-prescriptions')">+ Add Prescription</button>
|
||||
</div>
|
||||
<div class="add-form-collapsible">
|
||||
<div class="inline-form-row">
|
||||
<input type="text" id="newRxMedication" placeholder="Medication name *" class="form-input" />
|
||||
<input type="text" id="newRxNumber" placeholder="RX#" class="form-input rx-number-input" />
|
||||
<input type="text" id="newRxDosage" placeholder="Dosage" class="form-input" />
|
||||
<input type="text" id="newRxFrequency" placeholder="Frequency" class="form-input" />
|
||||
<select id="newRxDoctor" class="form-input">
|
||||
<option value="">Doctor (optional)</option>
|
||||
</select>
|
||||
<input type="date" id="newRxStartDate" class="form-input" title="Start date" />
|
||||
<button class="btn btn-primary btn-sm" onclick="medDocsAddPrescription()">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prescriptions-list" id="prescriptionsList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Doctors Panel -->
|
||||
<div class="tab-panel active" id="panel-doctors">
|
||||
<div class="add-form-toggle">
|
||||
<button class="btn btn-secondary" onclick="medDocsToggleAddForm('panel-doctors')">+ Add Doctor</button>
|
||||
</div>
|
||||
<div class="add-form-collapsible">
|
||||
<div class="inline-form-row">
|
||||
<input type="text" id="newDoctorName" placeholder="Name *" class="form-input" />
|
||||
<input type="text" id="newDoctorSpecialty" placeholder="Specialty" class="form-input" />
|
||||
<input type="text" id="newDoctorPhone" placeholder="Phone" class="form-input" />
|
||||
<input type="text" id="newDoctorAddress" placeholder="Address" class="form-input" />
|
||||
<button class="btn btn-primary btn-sm" onclick="medDocsAddDoctor()">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doctors-list" id="doctorsList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Bills Panel -->
|
||||
<div class="tab-panel" id="panel-bills">
|
||||
<div class="add-form-toggle">
|
||||
<button class="btn btn-secondary" onclick="medDocsToggleAddForm('panel-bills')">+ Add Provider</button>
|
||||
</div>
|
||||
<div class="add-form-collapsible">
|
||||
<div class="inline-form-row">
|
||||
<input type="text" id="newProviderName" placeholder="Provider name *" class="form-input" />
|
||||
<input type="text" id="newProviderNotes" placeholder="Notes (optional)" class="form-input" />
|
||||
<button class="btn btn-primary btn-sm" onclick="medDocsAddProvider()">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="billSummarySection"></div>
|
||||
<div class="providers-list" id="providersList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline Panel -->
|
||||
<div class="tab-panel" id="panel-timeline">
|
||||
<div id="timelineList"></div>
|
||||
<button class="btn btn-secondary" id="timelineLoadMore" style="display:none" onclick="medDocsLoadMoreTimeline()">Load More</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Document Viewer Modal -->
|
||||
<div class="doc-viewer-overlay" id="docViewerOverlay" style="display:none" onclick="medDocsCloseViewer(event)">
|
||||
<div class="doc-viewer-modal" onclick="event.stopPropagation()">
|
||||
<div class="doc-viewer-header">
|
||||
<span class="doc-viewer-title" id="docViewerTitle"></span>
|
||||
<button class="doc-viewer-close" onclick="medDocsCloseViewer()" title="Close">×</button>
|
||||
</div>
|
||||
<div class="doc-viewer-body" id="docViewerBody">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Share Access Modal -->
|
||||
<div class="doc-viewer-overlay" id="shareAccessOverlay" style="display:none" onclick="medDocsCloseShareModal(event)">
|
||||
<div class="doc-viewer-modal" style="max-width:420px;max-height:400px;" onclick="event.stopPropagation()">
|
||||
<div class="doc-viewer-header">
|
||||
<span class="doc-viewer-title">Share Patient Access</span>
|
||||
<button class="doc-viewer-close" onclick="medDocsCloseShareModal()" title="Close">×</button>
|
||||
</div>
|
||||
<div class="doc-viewer-body" style="padding:1rem;overflow-y:auto;">
|
||||
<div id="shareAccessList" style="margin-bottom:1rem;"></div>
|
||||
<div style="display:flex;gap:0.5rem;">
|
||||
<input type="text" id="shareUsername" placeholder="Username..." class="form-input" style="flex:1;" />
|
||||
<button class="btn btn-primary btn-sm" onclick="medDocsGrantAccess()">Grant</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/medical-docs/state.js" asp-append-version="true"></script>
|
||||
<script src="~/js/medical-docs/tabs.js" asp-append-version="true"></script>
|
||||
<script src="~/js/medical-docs/people.js" asp-append-version="true"></script>
|
||||
<script src="~/js/medical-docs/documents.js" asp-append-version="true"></script>
|
||||
<script src="~/js/medical-docs/doctors.js" asp-append-version="true"></script>
|
||||
<script src="~/js/medical-docs/conditions.js" asp-append-version="true"></script>
|
||||
<script src="~/js/medical-docs/prescriptions.js" asp-append-version="true"></script>
|
||||
<script src="~/js/medical-docs/bills.js" asp-append-version="true"></script>
|
||||
<script src="~/js/medical-docs/timeline.js" asp-append-version="true"></script>
|
||||
<script src="~/js/medical-docs/init.js" asp-append-version="true"></script>
|
||||
}
|
||||
@@ -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<IActionResult> OnGetAsync()
|
||||
{
|
||||
RequireAuthentication();
|
||||
LoadUserSession();
|
||||
|
||||
var denied = await RequireRole("medical", _dbExecutor);
|
||||
if (denied != null) return denied;
|
||||
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
@page
|
||||
@model Media.JoshHeaps.Net.Pages.NetworkGraphModel
|
||||
@{
|
||||
ViewData["Title"] = "Network Graphs";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/Gallery/page.css" asp-append-version="true" />
|
||||
<link rel="stylesheet" href="~/css/network-graph.css" asp-append-version="true" />
|
||||
}
|
||||
|
||||
<div class="dashboard-container">
|
||||
<!-- Header Section -->
|
||||
<div class="welcome-section">
|
||||
<div class="welcome-left">
|
||||
<a href="/Landing" class="back-button" title="Back to Home">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="19" y1="12" x2="5" y2="12"></line>
|
||||
<polyline points="12 19 5 12 12 5"></polyline>
|
||||
</svg>
|
||||
</a>
|
||||
<h1>Network Graphs</h1>
|
||||
</div>
|
||||
<div class="quick-actions">
|
||||
<button type="button" class="btn btn-primary" id="new-graph-btn">New Graph</button>
|
||||
<a href="/Profile" class="btn btn-secondary">Profile</a>
|
||||
<a href="/Logout" class="btn btn-danger">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Graph Selection & Management -->
|
||||
<div class="graph-manager-section">
|
||||
<div class="graph-selector-card card">
|
||||
<h2>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 8px;">
|
||||
<path d="M3 3v18h18"></path>
|
||||
<path d="M18 17V9"></path>
|
||||
<path d="M13 17V5"></path>
|
||||
<path d="M8 17v-3"></path>
|
||||
</svg>
|
||||
Your Graphs
|
||||
</h2>
|
||||
<div class="graphs-list" id="graphs-list">
|
||||
@if (Model.Graphs.Count == 0)
|
||||
{
|
||||
<div class="empty-state">
|
||||
<p>No graphs yet. Create your first graph to get started!</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var graph in Model.Graphs)
|
||||
{
|
||||
<div class="graph-item @(graph.Id == Model.SelectedGraphId ? "active" : "")" data-graph-id="@graph.Id">
|
||||
<div class="graph-info" onclick="selectGraph(@graph.Id)">
|
||||
<div class="graph-name">@graph.Name</div>
|
||||
<div class="graph-stats">
|
||||
<span class="stat">@graph.NodeCount nodes</span>
|
||||
<span class="stat">@graph.EdgeCount edges</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="graph-actions">
|
||||
<button type="button" class="btn-icon" onclick="event.stopPropagation(); editGraph(@graph.Id, '@graph.Name', '@(graph.Description ?? "")')" title="Edit">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="btn-icon" onclick="event.stopPropagation(); cloneGraph(@graph.Id, '@graph.Name')" title="Clone">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="btn-icon danger" onclick="event.stopPropagation(); deleteGraph(@graph.Id, '@graph.Name')" title="Delete">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Graph Visualization & Tools -->
|
||||
<div class="graph-workspace" id="graph-workspace" style="display: none;">
|
||||
<!-- Toolbar -->
|
||||
<div class="graph-toolbar card">
|
||||
<div class="toolbar-section">
|
||||
<button type="button" class="btn btn-secondary" id="add-node-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="8" x2="12" y2="16"></line>
|
||||
<line x1="8" y1="12" x2="16" y2="12"></line>
|
||||
</svg>
|
||||
Add Node
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" id="link-nodes-btn" disabled>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
|
||||
</svg>
|
||||
Link Selected
|
||||
</button>
|
||||
</div>
|
||||
<div class="toolbar-section">
|
||||
<input type="text" id="search-nodes" class="search-input" placeholder="Search nodes..." />
|
||||
<input type="number" id="degrees-input" class="search-input" placeholder="Degrees" min="1" max="10" value="1" style="width: 100px; display: none;" />
|
||||
<select id="filter-mode-select" class="filter-select" style="display: none;">
|
||||
<option value="exact">Exactly</option>
|
||||
<option value="within">Within</option>
|
||||
<option value="outside">Outside</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-secondary" id="apply-filter-btn" style="display: none;">Apply Filter</button>
|
||||
<button type="button" class="btn btn-secondary" id="clear-filter-btn" style="display: none;">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canvas -->
|
||||
<div class="graph-canvas-container card">
|
||||
<canvas id="graph-canvas"></canvas>
|
||||
<div class="canvas-help-text">
|
||||
<span>💡 Right-click nodes for options • Ctrl+Click to select multiple • Double-click to edit</span>
|
||||
</div>
|
||||
<div class="canvas-controls">
|
||||
<button type="button" class="canvas-btn" id="zoom-in-btn" title="Zoom In">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
<line x1="11" y1="8" x2="11" y2="14"></line>
|
||||
<line x1="8" y1="11" x2="14" y2="11"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="canvas-btn" id="zoom-out-btn" title="Zoom Out">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
<line x1="8" y1="11" x2="14" y2="11"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="canvas-btn" id="reset-view-btn" title="Reset View">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path>
|
||||
<path d="M21 3v5h-5"></path>
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path>
|
||||
<path d="M3 21v-5h5"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Graph Modal -->
|
||||
<div id="graph-modal" class="modal" style="display: none;">
|
||||
<div class="modal-backdrop" onclick="closeGraphModal()"></div>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-header">
|
||||
<h3 id="graph-modal-title">New Graph</h3>
|
||||
<button type="button" class="modal-close" onclick="closeGraphModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="graph-form">
|
||||
<div class="form-group">
|
||||
<label for="graph-name">Graph Name *</label>
|
||||
<input type="text" id="graph-name" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="graph-description">Description</label>
|
||||
<textarea id="graph-description" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeGraphModal()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" id="save-graph-btn">Create Graph</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Node Modal -->
|
||||
<div id="node-modal" class="modal" style="display: none;">
|
||||
<div class="modal-backdrop" onclick="closeNodeModal()"></div>
|
||||
<div class="modal-dialog modal-dialog-large">
|
||||
<div class="modal-header">
|
||||
<h3 id="node-modal-title">New Node</h3>
|
||||
<button type="button" class="modal-close" onclick="closeNodeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="node-form">
|
||||
<div class="form-group">
|
||||
<label for="node-label">Label *</label>
|
||||
<input type="text" id="node-label" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="node-notes">Notes</label>
|
||||
<textarea id="node-notes" class="form-control" rows="5" placeholder="Add detailed notes about this node..."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Connections Section (only shown when editing) -->
|
||||
<div id="node-connections-section" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 4px;">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
|
||||
</svg>
|
||||
Connections
|
||||
</label>
|
||||
|
||||
<!-- Add Connection -->
|
||||
<div class="connection-search-container">
|
||||
<input type="text" id="connection-search" class="form-control" placeholder="Search nodes to connect..." autocomplete="off" />
|
||||
<div id="connection-search-results" class="search-results-dropdown" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Current Connections List -->
|
||||
<div id="node-connections-list" class="connections-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeNodeModal()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" id="save-node-btn">Create Node</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context Menu -->
|
||||
<div id="node-context-menu" class="context-menu" style="display: none;">
|
||||
<div class="context-menu-item" data-action="edit">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||
</svg>
|
||||
Edit Node
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="link">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
|
||||
</svg>
|
||||
Link to Selected
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="select">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 11 12 14 22 4"></polyline>
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
|
||||
</svg>
|
||||
Add to Selection
|
||||
</div>
|
||||
<div class="context-menu-divider"></div>
|
||||
<div class="context-menu-item danger" data-action="delete">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>
|
||||
Delete Node
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
const selectedGraphId = @(Model.SelectedGraphId?.ToString() ?? "null");
|
||||
</script>
|
||||
<script src="~/js/network-graph.js" asp-append-version="true"></script>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using Media.JoshHeaps.Net.Models;
|
||||
using Media.JoshHeaps.Net.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Pages
|
||||
{
|
||||
public class NetworkGraphModel(GraphService graphService) : AuthenticatedPageModel
|
||||
{
|
||||
private readonly GraphService _graphService = graphService;
|
||||
|
||||
public List<GraphWithCounts> Graphs { get; set; } = [];
|
||||
public long? SelectedGraphId { get; set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync([FromQuery] long? graphId = null)
|
||||
{
|
||||
RequireAuthentication();
|
||||
LoadUserSession();
|
||||
|
||||
Graphs = await _graphService.GetUserGraphsAsync(UserId);
|
||||
SelectedGraphId = graphId;
|
||||
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,133 +7,8 @@
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/Home/page.css" asp-append-version="true" />
|
||||
<style>
|
||||
.profile-container {
|
||||
max-width: 800px;
|
||||
margin: 40px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.profile-section h2 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.profile-field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.profile-field label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.profile-field-value {
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.theme-toggle-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.theme-toggle-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.theme-toggle-label strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.theme-toggle-label span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
transition: 0.3s;
|
||||
border-radius: 34px;
|
||||
}
|
||||
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: var(--text-secondary);
|
||||
transition: 0.3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .toggle-slider {
|
||||
background-color: var(--accent-primary);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
input:checked + .toggle-slider:before {
|
||||
transform: translateX(24px);
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="~/css/profile.css" asp-append-version="true" />
|
||||
<link rel="stylesheet" href="~/css/theme-customizer.css" asp-append-version="true" />
|
||||
}
|
||||
|
||||
<div class="profile-container">
|
||||
@@ -176,5 +51,14 @@
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="theme-toggle-container">
|
||||
<div class="theme-toggle-label">
|
||||
<strong>Custom Colors</strong>
|
||||
<span>Personalize individual theme colors</span>
|
||||
</div>
|
||||
<button class="customize-btn" onclick="openThemeCustomizer()">Customize</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="~/js/theme-customizer.js" asp-append-version="true"></script>
|
||||
|
||||
@@ -5,11 +5,8 @@ using System.Text.RegularExpressions;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Pages;
|
||||
|
||||
public class RegisterModel : PageModel
|
||||
public class RegisterModel(AuthService authService, EmailService emailService) : PageModel
|
||||
{
|
||||
private readonly AuthService _authService;
|
||||
private readonly EmailService _emailService;
|
||||
|
||||
[BindProperty]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
@@ -24,12 +21,6 @@ public class RegisterModel : PageModel
|
||||
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
public RegisterModel(AuthService authService, EmailService emailService)
|
||||
{
|
||||
_authService = authService;
|
||||
_emailService = emailService;
|
||||
}
|
||||
|
||||
public void OnGet()
|
||||
{
|
||||
// Check if user is already logged in
|
||||
@@ -79,7 +70,7 @@ public class RegisterModel : PageModel
|
||||
}
|
||||
|
||||
// Register user
|
||||
var (success, error, verificationToken) = await _authService.RegisterUserAsync(Email, Username, Password);
|
||||
var (success, error, verificationToken) = await authService.RegisterUserAsync(Email, Username, Password);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
@@ -90,7 +81,7 @@ public class RegisterModel : PageModel
|
||||
// Send verification email
|
||||
if (!string.IsNullOrEmpty(verificationToken))
|
||||
{
|
||||
await _emailService.SendVerificationEmailAsync(Email, Username, verificationToken);
|
||||
await emailService.SendVerificationEmailAsync(Email, Username, verificationToken);
|
||||
}
|
||||
|
||||
// Redirect to verification pending page
|
||||
|
||||
@@ -4,23 +4,14 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Pages;
|
||||
|
||||
public class ResendVerificationModel : PageModel
|
||||
public class ResendVerificationModel(AuthService authService, EmailService emailService) : PageModel
|
||||
{
|
||||
private readonly AuthService _authService;
|
||||
private readonly EmailService _emailService;
|
||||
|
||||
[BindProperty]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
public string? ErrorMessage { get; set; }
|
||||
public string? SuccessMessage { get; set; }
|
||||
|
||||
public ResendVerificationModel(AuthService authService, EmailService emailService)
|
||||
{
|
||||
_authService = authService;
|
||||
_emailService = emailService;
|
||||
}
|
||||
|
||||
public void OnGet([FromQuery] string? email)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(email))
|
||||
@@ -37,7 +28,7 @@ public class ResendVerificationModel : PageModel
|
||||
return Page();
|
||||
}
|
||||
|
||||
var (success, error, verificationToken) = await _authService.ResendVerificationTokenAsync(Email);
|
||||
var (success, error, verificationToken) = await authService.ResendVerificationTokenAsync(Email);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
@@ -48,7 +39,7 @@ public class ResendVerificationModel : PageModel
|
||||
// Send verification email
|
||||
if (!string.IsNullOrEmpty(verificationToken))
|
||||
{
|
||||
var emailSent = await _emailService.SendVerificationEmailAsync(Email, Email, verificationToken);
|
||||
var emailSent = await emailService.SendVerificationEmailAsync(Email, Email, verificationToken);
|
||||
|
||||
if (emailSent)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
@page "/sso/authorize"
|
||||
@model Media.JoshHeaps.Net.Pages.Sso.AuthorizeModel
|
||||
@{
|
||||
Layout = null;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Media.JoshHeaps.Net.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Pages.Sso;
|
||||
|
||||
public class AuthorizeModel(DbExecutor db, IConfiguration config, ILogger<AuthorizeModel> logger) : AuthenticatedPageModel
|
||||
{
|
||||
public async Task<IActionResult> OnGetAsync(
|
||||
[FromQuery(Name = "client_id")] string? clientId,
|
||||
[FromQuery(Name = "redirect_uri")] string? redirectUri,
|
||||
[FromQuery] string? state)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(redirectUri) || string.IsNullOrWhiteSpace(state))
|
||||
{
|
||||
return BadRequest("client_id, redirect_uri, and state are required");
|
||||
}
|
||||
|
||||
var client = SsoClientRegistry.Find(config, clientId);
|
||||
if (client == null) return BadRequest("unknown client_id");
|
||||
if (!client.AllowsRedirectUri(redirectUri)) return BadRequest("redirect_uri is not registered for this client");
|
||||
|
||||
if (!IsAuthenticated())
|
||||
{
|
||||
var original = $"/sso/authorize?client_id={Uri.EscapeDataString(clientId)}&redirect_uri={Uri.EscapeDataString(redirectUri)}&state={Uri.EscapeDataString(state)}";
|
||||
return Redirect($"/Login?ReturnUrl={Uri.EscapeDataString(original)}");
|
||||
}
|
||||
|
||||
LoadUserSession();
|
||||
|
||||
var code = GenerateCode();
|
||||
var codeHash = HashCode(code);
|
||||
var lifetime = int.TryParse(config["Sso:CodeLifetimeSeconds"], out var s) ? s : 60;
|
||||
var expiresAt = DateTimeOffset.UtcNow.AddSeconds(lifetime);
|
||||
|
||||
await db.ExecuteNonQueryAsync(
|
||||
@"INSERT INTO app.sso_authorization_codes (code_hash, client_id, user_id, redirect_uri, expires_at)
|
||||
VALUES (@codeHash, @clientId, @userId, @redirectUri, @expiresAt)",
|
||||
new { codeHash, clientId, userId = UserId, redirectUri, expiresAt });
|
||||
|
||||
logger.LogInformation("SSO code issued for user {UserId} to client {ClientId}", UserId, clientId);
|
||||
|
||||
var separator = redirectUri.Contains('?') ? '&' : '?';
|
||||
return Redirect($"{redirectUri}{separator}code={Uri.EscapeDataString(code)}&state={Uri.EscapeDataString(state)}");
|
||||
}
|
||||
|
||||
private static string GenerateCode()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(32);
|
||||
return Convert.ToBase64String(bytes).Replace("+", "-").Replace("/", "_").TrimEnd('=');
|
||||
}
|
||||
|
||||
private static string HashCode(string code)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(code));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -4,19 +4,12 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Pages;
|
||||
|
||||
public class VerifyEmailModel : PageModel
|
||||
public class VerifyEmailModel(AuthService authService) : PageModel
|
||||
{
|
||||
private readonly AuthService _authService;
|
||||
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public bool ShowResendLink { get; set; }
|
||||
|
||||
public VerifyEmailModel(AuthService authService)
|
||||
{
|
||||
_authService = authService;
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnGetAsync([FromQuery] string? token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token))
|
||||
@@ -27,7 +20,7 @@ public class VerifyEmailModel : PageModel
|
||||
return Page();
|
||||
}
|
||||
|
||||
var (success, error) = await _authService.VerifyEmailAsync(token);
|
||||
var (success, error) = await authService.VerifyEmailAsync(token);
|
||||
|
||||
Success = success;
|
||||
|
||||
|
||||
@@ -16,6 +16,14 @@ builder.Services.AddScoped<EmailService>();
|
||||
builder.Services.AddScoped<UserService>();
|
||||
builder.Services.AddScoped<MediaService>();
|
||||
builder.Services.AddScoped<FolderService>();
|
||||
builder.Services.AddScoped<GraphService>();
|
||||
builder.Services.AddScoped<MedicalDocsService>();
|
||||
builder.Services.AddSingleton<MedicalAiService>();
|
||||
builder.Services.AddScoped<ThemeService>();
|
||||
builder.Services.AddScoped<BlogService>();
|
||||
builder.Services.AddScoped<BreadboardValidator>();
|
||||
builder.Services.AddScoped<BreadboardService>();
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
// Add session support
|
||||
builder.Services.AddDistributedMemoryCache();
|
||||
@@ -71,4 +79,11 @@ app.UseAuthorization();
|
||||
app.MapRazorPages();
|
||||
app.MapControllers(); // Map API controllers
|
||||
|
||||
// Redirect root to Landing page
|
||||
app.MapGet("/", context =>
|
||||
{
|
||||
context.Response.Redirect("/Landing");
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Media.JoshHeaps.Net;
|
||||
using Media.JoshHeaps.Net.Models;
|
||||
|
||||
@@ -302,4 +304,173 @@ public class AuthService(DbExecutor db)
|
||||
new { userId, lastLogin = DateTime.UtcNow }
|
||||
);
|
||||
}
|
||||
|
||||
public static string GenerateSecureToken()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(32);
|
||||
return Convert.ToBase64String(bytes)
|
||||
.Replace("+", "-")
|
||||
.Replace("/", "_")
|
||||
.TrimEnd('=');
|
||||
}
|
||||
|
||||
public static string HashToken(string token)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string? Error, string? Token, string? Username)> RequestPasswordResetAsync(string email)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userRow = await db.ExecuteReaderAsync(
|
||||
"SELECT id, username, is_active, locked_until FROM app.users WHERE email = @email",
|
||||
reader => new
|
||||
{
|
||||
UserId = reader.GetInt64(0),
|
||||
Username = reader.GetString(1),
|
||||
IsActive = reader.GetBoolean(2),
|
||||
LockedUntil = reader.IsDBNull(3) ? (DateTime?)null : reader.GetDateTime(3)
|
||||
},
|
||||
new { email }
|
||||
);
|
||||
|
||||
if (userRow == null)
|
||||
{
|
||||
// Artificial delay to prevent timing-based email enumeration
|
||||
await Task.Delay(Random.Shared.Next(100, 300));
|
||||
return (true, null, null, null);
|
||||
}
|
||||
|
||||
// Silently succeed for inactive/locked accounts (don't reveal state)
|
||||
if (!userRow.IsActive ||
|
||||
(userRow.LockedUntil.HasValue && userRow.LockedUntil.Value > DateTime.UtcNow))
|
||||
{
|
||||
return (true, null, null, null);
|
||||
}
|
||||
|
||||
// Rate limit: max 3 requests per hour
|
||||
var recentCount = await db.ExecuteAsync<long>(
|
||||
@"SELECT COUNT(*) FROM app.password_reset_tokens
|
||||
WHERE user_id = @userId AND created_at > @cutoff",
|
||||
new { userId = userRow.UserId, cutoff = DateTimeOffset.UtcNow.AddHours(-1) }
|
||||
);
|
||||
|
||||
if (recentCount >= 3)
|
||||
{
|
||||
return (true, null, null, null);
|
||||
}
|
||||
|
||||
// Invalidate all existing unused tokens for this user
|
||||
await db.ExecuteNonQueryAsync(
|
||||
@"UPDATE app.password_reset_tokens
|
||||
SET used_at = @now
|
||||
WHERE user_id = @userId AND used_at IS NULL",
|
||||
new { userId = userRow.UserId, now = DateTimeOffset.UtcNow }
|
||||
);
|
||||
|
||||
// Generate and store new token
|
||||
var token = GenerateSecureToken();
|
||||
var tokenHash = HashToken(token);
|
||||
var expiresAt = DateTimeOffset.UtcNow.AddHours(1);
|
||||
|
||||
await db.ExecuteNonQueryAsync(
|
||||
@"INSERT INTO app.password_reset_tokens (user_id, token_hash, expires_at)
|
||||
VALUES (@userId, @tokenHash, @expiresAt)",
|
||||
new { userId = userRow.UserId, tokenHash, expiresAt }
|
||||
);
|
||||
|
||||
return (true, null, token, userRow.Username);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, $"Password reset request failed: {ex.Message}", null, null);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(bool Valid, string? Error)> ValidatePasswordResetTokenAsync(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tokenHash = HashToken(token);
|
||||
|
||||
var tokenRow = await db.ExecuteReaderAsync(
|
||||
@"SELECT expires_at, used_at FROM app.password_reset_tokens
|
||||
WHERE token_hash = @tokenHash",
|
||||
reader => new
|
||||
{
|
||||
ExpiresAt = reader.GetFieldValue<DateTimeOffset>(0),
|
||||
UsedAt = reader.IsDBNull(1) ? (DateTimeOffset?)null : reader.GetFieldValue<DateTimeOffset>(1)
|
||||
},
|
||||
new { tokenHash }
|
||||
);
|
||||
|
||||
if (tokenRow == null)
|
||||
return (false, "Invalid or expired reset link. Please request a new one.");
|
||||
|
||||
if (tokenRow.UsedAt.HasValue)
|
||||
return (false, "This reset link has already been used. Please request a new one.");
|
||||
|
||||
if (tokenRow.ExpiresAt < DateTimeOffset.UtcNow)
|
||||
return (false, "This reset link has expired. Please request a new one.");
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, $"Token validation failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string? Error)> ResetPasswordAsync(string token, string newPassword)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tokenHash = HashToken(token);
|
||||
|
||||
var tokenRow = await db.ExecuteReaderAsync(
|
||||
@"SELECT id, user_id, expires_at, used_at FROM app.password_reset_tokens
|
||||
WHERE token_hash = @tokenHash",
|
||||
reader => new
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
UserId = reader.GetInt64(1),
|
||||
ExpiresAt = reader.GetFieldValue<DateTimeOffset>(2),
|
||||
UsedAt = reader.IsDBNull(3) ? (DateTimeOffset?)null : reader.GetFieldValue<DateTimeOffset>(3)
|
||||
},
|
||||
new { tokenHash }
|
||||
);
|
||||
|
||||
if (tokenRow == null)
|
||||
return (false, "Invalid or expired reset link. Please request a new one.");
|
||||
|
||||
if (tokenRow.UsedAt.HasValue)
|
||||
return (false, "This reset link has already been used. Please request a new one.");
|
||||
|
||||
if (tokenRow.ExpiresAt < DateTimeOffset.UtcNow)
|
||||
return (false, "This reset link has expired. Please request a new one.");
|
||||
|
||||
// Hash new password and update user
|
||||
var passwordHash = HashPassword(newPassword);
|
||||
await db.ExecuteNonQueryAsync(
|
||||
@"UPDATE app.users
|
||||
SET password_hash = @passwordHash, failed_login_attempts = 0, locked_until = NULL
|
||||
WHERE id = @userId",
|
||||
new { userId = tokenRow.UserId, passwordHash }
|
||||
);
|
||||
|
||||
// Mark token as used
|
||||
await db.ExecuteNonQueryAsync(
|
||||
"UPDATE app.password_reset_tokens SET used_at = @now WHERE id = @tokenId",
|
||||
new { tokenId = tokenRow.Id, now = DateTimeOffset.UtcNow }
|
||||
);
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, $"Password reset failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Markdig;
|
||||
using Media.JoshHeaps.Net.Models;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Services;
|
||||
|
||||
public partial class BlogService(DbExecutor db, IWebHostEnvironment environment, ILogger<BlogService> logger)
|
||||
{
|
||||
private static readonly HashSet<string> AllowedMimeTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
||||
private const long MaxImageSize = 10 * 1024 * 1024;
|
||||
private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder()
|
||||
.UseAdvancedExtensions()
|
||||
.Build();
|
||||
|
||||
public async Task<List<BlogPost>> GetAllPostsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await db.ExecuteListReaderAsync(
|
||||
@"SELECT id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at
|
||||
FROM app.blog_posts
|
||||
ORDER BY published_date DESC",
|
||||
MapBlogPost);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get all blog posts");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<BlogPost?> GetPostByIdAsync(long id)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await db.ExecuteReaderAsync(
|
||||
@"SELECT id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at
|
||||
FROM app.blog_posts
|
||||
WHERE id = @Id",
|
||||
MapBlogPost,
|
||||
new { Id = id });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get blog post by id {Id}", id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<BlogPost?> GetPostBySlugAsync(string slug)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await db.ExecuteReaderAsync(
|
||||
@"SELECT id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at
|
||||
FROM app.blog_posts
|
||||
WHERE slug = @Slug",
|
||||
MapBlogPost,
|
||||
new { Slug = slug });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get blog post by slug {Slug}", slug);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<BlogPost>> GetPostsByTagAsync(string tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await db.ExecuteListReaderAsync(
|
||||
@"SELECT id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at
|
||||
FROM app.blog_posts
|
||||
WHERE @Tag = ANY(tags)
|
||||
ORDER BY published_date DESC",
|
||||
MapBlogPost,
|
||||
new { Tag = tag });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get blog posts by tag {Tag}", tag);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<BlogPost?> CreatePostAsync(string title, string summary, string markdownContent, List<string> tags, long authorId, DateTime publishedDate)
|
||||
{
|
||||
try
|
||||
{
|
||||
var slug = GenerateSlug(title);
|
||||
var htmlContent = Markdown.ToHtml(markdownContent, Pipeline);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
return await db.ExecuteReaderAsync(
|
||||
@"INSERT INTO app.blog_posts (slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at)
|
||||
VALUES (@Slug, @Title, @Summary, @MarkdownContent, @HtmlContent, @Tags, @AuthorId, @PublishedDate, @CreatedAt, @UpdatedAt)
|
||||
RETURNING id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at",
|
||||
MapBlogPost,
|
||||
new
|
||||
{
|
||||
Slug = slug,
|
||||
Title = title,
|
||||
Summary = summary,
|
||||
MarkdownContent = markdownContent,
|
||||
HtmlContent = htmlContent,
|
||||
Tags = tags.ToArray(),
|
||||
AuthorId = authorId,
|
||||
PublishedDate = publishedDate,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to create blog post '{Title}'", title);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<BlogPost?> UpdatePostAsync(long id, string title, string summary, string markdownContent, List<string> tags, DateTime publishedDate)
|
||||
{
|
||||
try
|
||||
{
|
||||
var slug = GenerateSlug(title);
|
||||
var htmlContent = Markdown.ToHtml(markdownContent, Pipeline);
|
||||
|
||||
return await db.ExecuteReaderAsync(
|
||||
@"UPDATE app.blog_posts
|
||||
SET slug = @Slug, title = @Title, summary = @Summary, markdown_content = @MarkdownContent,
|
||||
html_content = @HtmlContent, tags = @Tags, published_date = @PublishedDate, updated_at = @UpdatedAt
|
||||
WHERE id = @Id
|
||||
RETURNING id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at",
|
||||
MapBlogPost,
|
||||
new
|
||||
{
|
||||
Id = id,
|
||||
Slug = slug,
|
||||
Title = title,
|
||||
Summary = summary,
|
||||
MarkdownContent = markdownContent,
|
||||
HtmlContent = htmlContent,
|
||||
Tags = tags.ToArray(),
|
||||
PublishedDate = publishedDate,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to update blog post {Id}", id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeletePostAsync(long id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rows = await db.ExecuteNonQueryAsync(
|
||||
"DELETE FROM app.blog_posts WHERE id = @Id",
|
||||
new { Id = id });
|
||||
return rows > 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to delete blog post {Id}", id);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(string? url, string? error)> SaveImageAsync(IFormFile file)
|
||||
{
|
||||
if (!AllowedMimeTypes.Contains(file.ContentType))
|
||||
return (null, "Only JPEG, PNG, GIF, and WEBP images are allowed");
|
||||
|
||||
if (file.Length > MaxImageSize)
|
||||
return (null, "Image must be under 10MB");
|
||||
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(ext)) ext = ".jpg";
|
||||
|
||||
var fileName = $"{Guid.NewGuid()}{ext}";
|
||||
var folder = Path.Combine(environment.ContentRootPath, "App_Data", "blog");
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
var filePath = Path.Combine(folder, fileName);
|
||||
try
|
||||
{
|
||||
await using var stream = new FileStream(filePath, FileMode.Create);
|
||||
await file.CopyToAsync(stream);
|
||||
return ($"/api/blog/images/{fileName}", null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to save blog image");
|
||||
if (File.Exists(filePath)) File.Delete(filePath);
|
||||
return (null, "Failed to save image");
|
||||
}
|
||||
}
|
||||
|
||||
public (string? filePath, string? mimeType) GetImagePath(string fileName)
|
||||
{
|
||||
if (fileName.Contains("..") || fileName.Contains('/') || fileName.Contains('\\'))
|
||||
return (null, null);
|
||||
|
||||
var filePath = Path.Combine(environment.ContentRootPath, "App_Data", "blog", fileName);
|
||||
if (!File.Exists(filePath))
|
||||
return (null, null);
|
||||
|
||||
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
var mimeType = ext switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
|
||||
return (filePath, mimeType);
|
||||
}
|
||||
|
||||
private static BlogPost MapBlogPost(Npgsql.NpgsqlDataReader reader)
|
||||
{
|
||||
return new BlogPost
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
Slug = reader.GetString(1),
|
||||
Title = reader.GetString(2),
|
||||
Summary = reader.GetString(3),
|
||||
MarkdownContent = reader.GetString(4),
|
||||
HtmlContent = reader.GetString(5),
|
||||
Tags = reader.GetFieldValue<string[]>(6).ToList(),
|
||||
AuthorId = reader.GetInt64(7),
|
||||
PublishedDate = reader.GetDateTime(8),
|
||||
CreatedAt = reader.GetDateTime(9),
|
||||
UpdatedAt = reader.GetDateTime(10)
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateSlug(string title)
|
||||
{
|
||||
var slug = title.ToLowerInvariant();
|
||||
slug = SlugInvalidChars().Replace(slug, "");
|
||||
slug = SlugWhitespace().Replace(slug, "-");
|
||||
slug = SlugMultipleDashes().Replace(slug, "-");
|
||||
return slug.Trim('-');
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"[^a-z0-9\s-]")]
|
||||
private static partial Regex SlugInvalidChars();
|
||||
|
||||
[GeneratedRegex(@"\s+")]
|
||||
private static partial Regex SlugWhitespace();
|
||||
|
||||
[GeneratedRegex(@"-{2,}")]
|
||||
private static partial Regex SlugMultipleDashes();
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Media.JoshHeaps.Net.Models;
|
||||
using Npgsql;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Data access and write rules for breadboard projects. The circuit document is opaque
|
||||
/// here: it travels as JSON text, is checked by <see cref="BreadboardValidator"/> before
|
||||
/// it ever reaches the database, and is only parsed on the way out so the API can emit it
|
||||
/// as a JSON object. Ownership is enforced in SQL — every statement is scoped by user_id,
|
||||
/// so a project belonging to someone else is indistinguishable from one that never existed.
|
||||
/// </summary>
|
||||
public class BreadboardService(DbExecutor db, BreadboardValidator validator, ILogger<BreadboardService> logger)
|
||||
{
|
||||
public const int MaxNameLength = 200;
|
||||
public const int MaxDescriptionLength = 2000;
|
||||
public const int MaxProjectsPerUser = 200;
|
||||
|
||||
private const string EmptyCircuitJson = """{"version":1,"boards":[],"components":[],"wires":[]}""";
|
||||
|
||||
public async Task<List<BreadboardProjectSummary>> GetProjectsAsync(long userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = @"
|
||||
SELECT id, name, description, created_at, updated_at
|
||||
FROM app.breadboard_projects
|
||||
WHERE user_id = @userId
|
||||
ORDER BY updated_at DESC";
|
||||
|
||||
return await db.ExecuteListReaderAsync(query, MapSummary, new { userId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to list breadboard projects for user {UserId}", userId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ownership check plus display fields, without dragging the circuit document along.
|
||||
/// Pages that only need to know "is this mine, and what is it called" use this so the
|
||||
/// document is fetched exactly once, by the editor module over the API.
|
||||
/// </summary>
|
||||
public async Task<BreadboardProjectSummary?> GetProjectSummaryAsync(long projectId, long userId)
|
||||
{
|
||||
var query = @"
|
||||
SELECT id, name, description, created_at, updated_at
|
||||
FROM app.breadboard_projects
|
||||
WHERE id = @projectId AND user_id = @userId";
|
||||
|
||||
return await db.ExecuteReaderAsync(query, MapSummary, new { projectId, userId });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns null only when the project does not exist or is not this user's. Database
|
||||
/// failures deliberately propagate — a caller must never turn an outage into a 404.
|
||||
/// </summary>
|
||||
public async Task<BreadboardProject?> GetProjectAsync(long projectId, long userId)
|
||||
{
|
||||
var query = @"
|
||||
SELECT id, name, description, circuit::text, created_at, updated_at
|
||||
FROM app.breadboard_projects
|
||||
WHERE id = @projectId AND user_id = @userId";
|
||||
|
||||
return await db.ExecuteReaderAsync(query, MapProject, new { projectId, userId });
|
||||
}
|
||||
|
||||
public async Task<BreadboardResult> CreateProjectAsync(long userId, string? name, string? description)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
var trimmedName = ValidateName(name, errors);
|
||||
var trimmedDescription = ValidateDescription(description, errors);
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return BreadboardResult.Invalid(errors);
|
||||
}
|
||||
|
||||
// Even the starter document goes through the validator — there is no trusted path by
|
||||
// which a circuit reaches the database unchecked. It is server-authored though, so a
|
||||
// rejection means the seed and the validator have drifted: that is our bug, not the
|
||||
// caller's, and it must not surface as a 400 blaming their input.
|
||||
var circuitCheck = validator.Validate(EmptyCircuitJson);
|
||||
if (!circuitCheck.IsValid)
|
||||
{
|
||||
logger.LogError(
|
||||
"Seed breadboard circuit document was rejected by the validator: {Errors}",
|
||||
string.Join(", ", circuitCheck.Errors));
|
||||
return BreadboardResult.Failed("Failed to create project");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// An authenticated user can otherwise grow the table without bound, 2 MB at a time.
|
||||
var projectCount = await db.ExecuteAsync<long>(
|
||||
"SELECT COUNT(*) FROM app.breadboard_projects WHERE user_id = @userId",
|
||||
new { userId });
|
||||
|
||||
if (projectCount >= MaxProjectsPerUser)
|
||||
{
|
||||
return BreadboardResult.Invalid([$"Project limit reached ({MaxProjectsPerUser} per account)"]);
|
||||
}
|
||||
|
||||
var query = @"
|
||||
INSERT INTO app.breadboard_projects (user_id, name, description, circuit, created_at, updated_at)
|
||||
VALUES (@userId, @name, @description, @circuit::jsonb, NOW(), NOW())
|
||||
RETURNING id, name, description, circuit::text, created_at, updated_at";
|
||||
|
||||
var project = await db.ExecuteReaderAsync(query, MapProject, new
|
||||
{
|
||||
userId,
|
||||
name = trimmedName,
|
||||
description = trimmedDescription,
|
||||
circuit = EmptyCircuitJson
|
||||
});
|
||||
|
||||
return project is null
|
||||
? BreadboardResult.Failed("Failed to create project")
|
||||
: BreadboardResult.Succeeded(project);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to create breadboard project for user {UserId}", userId);
|
||||
return BreadboardResult.Failed("Failed to create project");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Partial update: a null argument means "leave unchanged". An explicitly blank
|
||||
/// description clears the column.
|
||||
/// </summary>
|
||||
public async Task<BreadboardResult> UpdateProjectAsync(
|
||||
long projectId,
|
||||
long userId,
|
||||
string? name,
|
||||
string? description,
|
||||
string? circuitJson)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
var setName = name is not null;
|
||||
var trimmedName = setName ? ValidateName(name, errors) : null;
|
||||
|
||||
var setDescription = description is not null;
|
||||
var trimmedDescription = setDescription ? ValidateDescription(description, errors) : null;
|
||||
|
||||
var setCircuit = circuitJson is not null;
|
||||
if (setCircuit)
|
||||
{
|
||||
var circuitCheck = validator.Validate(circuitJson!);
|
||||
if (!circuitCheck.IsValid)
|
||||
{
|
||||
errors.AddRange(circuitCheck.Errors);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return BreadboardResult.Invalid(errors);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var query = @"
|
||||
UPDATE app.breadboard_projects
|
||||
SET name = CASE WHEN @setName THEN @name ELSE name END,
|
||||
-- NULLIF keeps @description a non-null text parameter, so the server never has
|
||||
-- to infer a type for an untyped NULL, and clearing still works.
|
||||
description = CASE WHEN @setDescription THEN NULLIF(@description, '') ELSE description END,
|
||||
circuit = CASE WHEN @setCircuit THEN @circuit::jsonb ELSE circuit END,
|
||||
-- A no-op save must not reorder the project list, which sorts on updated_at.
|
||||
updated_at = CASE WHEN @setName OR @setDescription OR @setCircuit THEN NOW() ELSE updated_at END
|
||||
WHERE id = @projectId AND user_id = @userId";
|
||||
|
||||
var rows = await db.ExecuteNonQueryAsync(query, new
|
||||
{
|
||||
projectId,
|
||||
userId,
|
||||
setName,
|
||||
// Guarded by @setName — the CASE is what keeps this placeholder off the column.
|
||||
name = trimmedName ?? string.Empty,
|
||||
setDescription,
|
||||
description = trimmedDescription ?? string.Empty,
|
||||
setCircuit,
|
||||
// Guarded by @setCircuit, but the ::jsonb cast still parses it, so it must be
|
||||
// valid JSON even on the branch the CASE discards.
|
||||
circuit = circuitJson ?? EmptyCircuitJson
|
||||
});
|
||||
|
||||
return rows == 0 ? BreadboardResult.Missing() : BreadboardResult.Succeeded();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to update breadboard project {ProjectId} for user {UserId}", projectId, userId);
|
||||
return BreadboardResult.Failed("Failed to update project");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<BreadboardResult> DeleteProjectAsync(long projectId, long userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = "DELETE FROM app.breadboard_projects WHERE id = @projectId AND user_id = @userId";
|
||||
var rows = await db.ExecuteNonQueryAsync(query, new { projectId, userId });
|
||||
|
||||
return rows == 0 ? BreadboardResult.Missing() : BreadboardResult.Succeeded();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to delete breadboard project {ProjectId} for user {UserId}", projectId, userId);
|
||||
return BreadboardResult.Failed("Failed to delete project");
|
||||
}
|
||||
}
|
||||
|
||||
private static BreadboardProjectSummary MapSummary(NpgsqlDataReader reader) => new(
|
||||
reader.GetInt64(0),
|
||||
reader.GetString(1),
|
||||
reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
reader.GetDateTime(3),
|
||||
reader.GetDateTime(4));
|
||||
|
||||
private BreadboardProject MapProject(NpgsqlDataReader reader) => new(
|
||||
reader.GetInt64(0),
|
||||
reader.GetString(1),
|
||||
reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
ParseCircuit(reader.GetString(3), reader.GetInt64(0)),
|
||||
reader.GetDateTime(4),
|
||||
reader.GetDateTime(5));
|
||||
|
||||
private JsonNode? ParseCircuit(string circuitJson, long projectId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonNode.Parse(circuitJson);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Stored circuit for breadboard project {ProjectId} is not parseable JSON", projectId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ValidateName(string? name, List<string> errors)
|
||||
{
|
||||
var trimmed = name?.Trim() ?? string.Empty;
|
||||
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
errors.Add("Name is required");
|
||||
}
|
||||
else if (trimmed.Length > MaxNameLength)
|
||||
{
|
||||
errors.Add($"Name must be {MaxNameLength} characters or fewer");
|
||||
}
|
||||
else if (trimmed.Any(char.IsControl))
|
||||
{
|
||||
// PostgreSQL rejects NUL in text outright; catching it here makes it a 400 rather
|
||||
// than a generic 500, and the rest of the control range has no business in a name.
|
||||
errors.Add("Name must not contain control characters");
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static string? ValidateDescription(string? description, List<string> errors)
|
||||
{
|
||||
var trimmed = description?.Trim();
|
||||
|
||||
if (string.IsNullOrEmpty(trimmed))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.Length > MaxDescriptionLength)
|
||||
{
|
||||
errors.Add($"Description must be {MaxDescriptionLength} characters or fewer");
|
||||
}
|
||||
else if (trimmed.Any(c => char.IsControl(c) && c is not ('\n' or '\r' or '\t')))
|
||||
{
|
||||
// Line breaks and tabs are legitimate in free text — CR is in the list because a
|
||||
// <textarea> submits CRLF. A NUL is not legitimate: PostgreSQL cannot store it in a
|
||||
// text column at all, so blocking it here makes it a 400 instead of a 500.
|
||||
// This allow-list is a team-lead ruling, not a local preference; Name is
|
||||
// deliberately stricter because it is a single-line label.
|
||||
errors.Add("Description must not contain control characters");
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user