Author SHA1 Message Date
jheaps d3120497f3 Merge pull request 'Feature/breadboard' (#4) from feature/breadboard into master
Deploy Media Server / build-deploy (push) Successful in 53s
Reviewed-on: #4
2026-08-26 17:36:05 -06:00
Josh-Heaps eab4e51a65 Merge branch 'feature/blog' into feature/breadboard 2026-08-26 17:33:17 -06:00
Josh-Heaps 0bee491629 Merge remote-tracking branch 'origin/master' into feature/blog
# Conflicts:
#	Media.JoshHeaps.Net/Program.cs
#	Media.JoshHeaps.Net/appsettings.json
2026-08-26 17:32:58 -06:00
jheaps d0f5b843a4 Merge pull request 'Fix/restore blog api' (#2) from fix/restore-blog-api into master
Deploy Media Server / build-deploy (push) Successful in 55s
Reviewed-on: #2
2026-08-26 17:23:58 -06:00
Josh-Heaps b2d0c72541 add cache invalidation 2026-08-26 17:19:17 -06:00
Josh-Heaps 941759f7c3 Add image upload capabilities 2026-08-26 17:19:17 -06:00
Josh-Heaps 2443b92c23 Update blog button 2026-08-26 17:19:16 -06:00
Josh-HeapsandClaude Opus 4.6 d8015f54ef Add blog management: migration, API, service, and admin UI
Blog posts stored in PostgreSQL with markdown rendering via Markdig.
Public API for reads, admin-only writes with dual auth (JWT + session).
Admin page for creating, editing, and deleting posts.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-08-26 17:19:16 -06:00
jheaps d1eb772e89 Merge pull request 'add gitea workflow' (#1) from gitea/workflow into master
Deploy Media Server / build-deploy (push) Successful in 54s
Reviewed-on: #1
2026-08-26 16:04:43 -06:00
Josh-Heaps caad111457 add gitea workflow 2026-08-26 16:04:03 -06:00
Josh-Heaps 9c4ff64070 Fix jitter 2026-08-20 09:54:18 -06:00
Josh-Heaps 639ace0030 Add transistors 2026-08-18 12:18:54 -06:00
Josh-Heaps cb0df5728c Add basic breadboard simulator 2026-08-17 14:28:52 -06:00
jheapsandClaude Sonnet 4.6 0393dacf63 Embed user roles as JWT claims in SSO token
IssueToken is now async so it can query app.user_roles before signing
the JWT. Role names are added as ClaimTypes.Role claims, enabling
role-based authorization in downstream apps (e.g. retirementAdmin).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-18 19:29:54 -06:00
Josh Heaps 7bbd679171 Merge pull request #15 from JoshHeaps/feature/blog
Add sso for other websites
2026-04-21 16:15:45 -06:00
Josh-Heaps e3d9c8f273 Add sso for other websites 2026-04-21 16:13:26 -06:00
Josh Heaps 64107d2e52 Merge pull request #14 from JoshHeaps/feature/blog
add cache invalidation
2026-03-06 17:06:51 -07:00
Josh-Heaps 15a84b2027 add cache invalidation 2026-03-06 17:05:36 -07:00
Josh Heaps f1e2aad8bf Merge pull request #13 from JoshHeaps/feature/blog
Add image upload capabilities
2026-03-06 16:35:39 -07:00
Josh-Heaps cb86e15cc0 Add image upload capabilities 2026-03-06 16:31:20 -07:00
Josh Heaps 9b182eaedf Merge pull request #12 from JoshHeaps/feature/blog
Add blog management: migration, API, service, and admin UI
2026-03-06 16:13:30 -07:00
Josh-Heaps 9f3f610025 Update blog button 2026-03-06 16:12:58 -07:00
Josh-HeapsandClaude Opus 4.6 a3c90ad06e Add blog management: migration, API, service, and admin UI
Blog posts stored in PostgreSQL with markdown rendering via Markdig.
Public API for reads, admin-only writes with dual auth (JWT + session).
Admin page for creating, editing, and deleting posts.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-06 15:33:49 -07:00
Josh Heaps 7dc70767d2 Merge pull request #11 from JoshHeaps/features/MultiFeatureBranch
Prescription ai update
2026-02-17 09:35:56 -07:00
Josh-Heaps bb35460097 Prescription ai update 2026-02-17 09:32:43 -07:00
Josh Heaps dea8d1bf90 Merge pull request #10 from JoshHeaps/features/MultiFeatureBranch
bugfix
2026-02-17 09:13:09 -07:00
Josh-Heaps d0c8c1249b bugfix 2026-02-17 09:11:49 -07:00
Josh Heaps 7e6ce61f03 Merge pull request #9 from JoshHeaps/features/MultiFeatureBranch
Features/multi feature branch
2026-02-13 14:15:12 -07:00
Josh-Heaps e971a5f329 Add custom colors 2026-02-13 14:13:32 -07:00
Josh-Heaps 91ef4f41dd Make an all patients view. 2026-02-13 10:11:44 -07:00
Josh Heaps e3d94b9f43 Merge pull request #8 from JoshHeaps/features/MultiFeatureBranch
Features/multi feature branch
2026-02-12 15:18:10 -07:00
Josh-Heaps cfd6bb530b Fix so people are scoped to users 2026-02-12 15:14:49 -07:00
Josh-Heaps c173677f57 Add password reset functionality 2026-02-12 13:14:01 -07:00
Josh Heaps dda6ac68dc Merge pull request #7 from JoshHeaps/mobileUi
Deploy Media Server / build (push) Successful in 39s
Deploy Media Server / deploy (push) Failing after 7s
Add document viewer
2026-02-12 09:55:24 -07:00
Josh-Heaps 8982011155 Add document viewer 2026-02-12 09:53:25 -07:00
Josh Heaps af309ca8d6 Merge pull request #6 from JoshHeaps/mobileUi
mobile ui
2026-02-10 17:31:09 -07:00
Josh-Heaps 811f732495 mobile ui 2026-02-10 17:30:26 -07:00
Josh Heaps 6b83d7df0f Merge pull request #5 from JoshHeaps/feature/AdminPage
Feature/admin page
2026-02-10 16:55:41 -07:00
Josh-Heaps 6814270b7c Fully implement medical documentation 2026-02-10 16:54:51 -07:00
Josh-Heaps 9ede3ae53f Implement first four phases of medical docs roadmap 2026-02-09 16:35:45 -07:00
Josh-Heaps e69e5663ce Add fleshed out admin page 2026-02-09 14:38:58 -07:00
Josh-Heaps 14d748de80 Add user roles and admin page 2026-02-09 13:52:59 -07:00
Josh-Heaps 9bbbe3aaad update claude.md 2026-02-09 13:52:49 -07:00
Josh-Heaps c9aa9886e4 Add claude.md 2026-02-09 13:28:31 -07:00
Josh-Heaps a26ffec104 Fix sql scripts 2026-02-09 13:23:34 -07:00
Josh Heaps c35396f906 Merge pull request #4 from JoshHeaps/feature/NoteGraph
Create node graph page
2026-01-02 17:15:16 -07:00
Josh-Heaps dae8fbfe5e Create node graph page 2026-01-02 17:13:39 -07:00
Josh Heaps 9e9439eec9 Merge pull request #3 from JoshHeaps/feature/Folders
Add folder sharing
2025-10-21 17:47:31 -06:00
Josh Heaps dd6995bfc7 Merge pull request #2 from JoshHeaps/feature/Folders
Add folders
2025-10-21 16:51:28 -06:00
178 changed files with 29230 additions and 409 deletions
+8 -1
View File
@@ -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": []
+77
View File
@@ -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"
+56
View File
@@ -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 001011 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
+45
View File
@@ -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.
+184
View File
@@ -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);
+6 -15
View File
@@ -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);
+206
View File
@@ -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);
+141
View File
@@ -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);
+11 -18
View File
@@ -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 -15
View File
@@ -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);
}
+381
View File
@@ -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
+8 -17
View File
@@ -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++;
+943
View File
@@ -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);
+190
View File
@@ -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; }
}
+74
View File
@@ -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)
);
+16
View File
@@ -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" />
+16
View File
@@ -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);
}
+17
View File
@@ -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; }
}
+17
View File
@@ -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; } = [];
}
+16
View File
@@ -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; }
}
+21
View File
@@ -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; }
}
+8
View File
@@ -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; }
}
+64
View File
@@ -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>
}
+20
View File
@@ -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();
}
}
+176
View File
@@ -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">
<h1>Welcome back, @Model.Dashboard?.Username!</h1>
<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"/>
@@ -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)
{
+219
View File
@@ -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();
}
}
}
+11 -4
View File
@@ -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,10 +64,13 @@
<div class="invalid-feedback"></div>
</div>
<div class="checkbox-wrapper">
<input type="checkbox" id="rememberMe" name="rememberMe"
@(Model.RememberMe ? "checked" : "") />
<label for="rememberMe">Remember me</label>
<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>
+17 -12
View File
@@ -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;
}
+102
View File
@@ -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">&#128197;</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 &amp; 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">&times;</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">&times;</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()">&times;</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()">&times;</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();
}
}
}
+11 -127
View File
@@ -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>
+3 -12
View File
@@ -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;
+15
View File
@@ -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();
+171
View File
@@ -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}");
}
}
}
+258
View File
@@ -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