Files
Jim BassoandClaude Opus 4.8 830eb4f6dc
Build & Push Docker image / test (push) Successful in 45s
Build & Push Docker image / docker (push) Successful in 1m15s
Add TODO.md backlog and docs/DESIGN.md (original plan)
Track the project backlog in-repo and preserve the original pre-implementation
design with a note on where the build diverged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-11 15:50:18 -05:00

109 lines
5.9 KiB
Markdown

# WishNinja — Design (original plan)
> **Status:** This is the original pre-implementation design, kept for posterity. The build
> followed it closely, but a few details evolved during implementation. For the current state of
> the code, the README and the git history are authoritative. Notable deltas from this document:
>
> - **Item images are always stored locally.** Pasted URLs are downloaded server-side and saved as
> files; the `ImageKind.ExternalUrl` value and `WishlistItem.ImageUrl` column were removed.
> - **Ordering:** list queries order by `Id` (not `CreatedAt`), because SQLite cannot `ORDER BY`
> a `DateTimeOffset` column.
> - **Connection string:** derived from `WishNinja__DataPath` (default `/data`); override via
> `ConnectionStrings__DefaultConnection` (not `ConnectionStrings__Default`).
> - **CI registry auth:** uses a `REGISTRY_TOKEN` PAT secret, not the auto-provided token.
> - **Seeder:** implemented as `Services/StartupInitializer.cs` (migrate + seed roles + admin),
> not `AdminSeeder.cs`.
> - **Container** runs as root for hassle-free Unraid bind-mount permissions (the "non-root" note
> below was not adopted).
---
## Context
The user runs an Unraid home server hosting many self-hosted apps and wants a **new gift
wishlist manager** ("WishNinja") that ships as a **Docker image** and lives in their
self-hosted **Gitea** instance. The working directory was empty — a clean greenfield build.
The app lets a closed group (family/friends) create wishlists, add items with links/prices/
images, and **claim/reserve gifts so the owner never sees what's been claimed** (preserving the
surprise) while other viewers do (avoiding duplicate gifts). Users are managed internally with
email-based password resets.
### Confirmed decisions
- **Stack:** ASP.NET Core **Blazor Web App** (interactive **server** render mode), **.NET 10 LTS**.
- **Persistence:** **SQLite** via **EF Core**, single DB file on a mounted volume.
- **Auth:** **ASP.NET Core Identity** with internal accounts + email password resets.
- **Onboarding:** **Admin invites only** (no open self-registration).
- **Claim privacy:** **Claims hidden from the list owner**; visible to other viewers.
- **Item images:** **file upload + clipboard paste** (both stored locally on `/data`) **+ external URL**.
- **Delivery:** Docker image, built via Gitea Actions → Gitea container registry, run on Unraid.
---
## Architecture & Stack
| Concern | Choice |
|---|---|
| Framework | ASP.NET Core 10 (LTS), Blazor Web App, InteractiveServer render mode |
| ORM / DB | EF Core 10 + SQLite (`Microsoft.EntityFrameworkCore.Sqlite`) |
| Identity | ASP.NET Core Identity (cookie auth, roles: `Admin`, `User`) |
| Email | `MailKit` for SMTP (password reset + invite emails) |
| Data Protection | Keys persisted to `/data/keys` so cookies/antiforgery survive restarts |
| Image storage | Local files under `/data/uploads`; served via a static-files mapping |
| Container base | `mcr.microsoft.com/dotnet/aspnet:10.0` (Debian slim), built from `sdk:10.0` |
**Why Blazor Server interactivity:** all-C#, single container, no separate JS build, live claim
buttons. Reverse proxy must allow **WebSocket upgrades** (SignalR) — documented in the README.
---
## Data Model (EF Core entities)
- **ApplicationUser : IdentityUser** — adds `DisplayName`, `CreatedAt`.
- **Wishlist** — `Id`, `OwnerId` (FK user), `Title`, `Description`, `Visibility`
(`AllMembers` | `SpecificUsers`), `IsArchived`, `CreatedAt`.
- **WishlistShare** — `WishlistId`, `UserId` (rows only when `Visibility = SpecificUsers`).
- **WishlistItem** — `Id`, `WishlistId`, `Name`, `Description`, `ProductUrl`, `ImageKind`,
`ImagePath`, `Price`, `Priority`, `Quantity`, `SortOrder`, `CreatedAt`.
- **Claim** — `Id`, `WishlistItemId`, `ClaimedByUserId`, `Quantity`, `Note`, `CreatedAt`.
- **Invite** — `Id`, `Email`, `TokenHash`, `Role`, `InvitedByUserId`, `ExpiresAt`, `AcceptedAt`.
EF migrations applied automatically at startup for friction-free self-hosting.
---
## Core Features
### Wishlists & items
- CRUD wishlists; set visibility (all members / specific users via `WishlistShare`).
- CRUD items with name, description, product link, price, priority, quantity, image.
- Item images via three inputs, all stored locally:
- **File upload** → saved to `/data/uploads`, `ImageKind=Uploaded`.
- **Clipboard paste** → JS-interop `paste` handler captures the blob, uploads via the same path.
- **Image URL** → downloaded server-side and saved as a local file.
- Upload validation: allowlist content types (png/jpeg/webp/gif), size cap, GUID filenames.
### Claim / reservation (owner-hidden)
- Any member viewing a list they **don't own** sees each item's claim state and can claim/
unclaim (respecting `Quantity`).
- The **owner's view of their own list never loads or renders claim data** — enforced at the
query layer, not just hidden in the UI, so it can't leak via timing/DOM. This is the single most
important correctness rule and gets dedicated unit tests.
### Auth & onboarding (admin-invite-only)
- Self-registration disabled. Admin invites by email → emailed invite link with a one-time token
→ recipient sets `DisplayName` + password → account created with `User` role.
- First-run **admin seeding** from env vars (`SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD`).
- Password reset via Identity's token flow + MailKit SMTP email.
- Admin area: list/disable users, manage invites.
---
## Verification
- **Automated:** `dotnet test` — owner queries never return claim data; non-owner queries do;
invite token single-use/expiry; image upload type/size validation; list queries run on SQLite.
- **Container:** `docker compose up --build`; verify SQLite DB + uploads persist across restarts
via the `/data` volume.
- **Unraid:** deploy image, map appdata volume + port, place behind reverse proxy with WebSockets
enabled, confirm Blazor interactivity (claim button) works through the proxy.