]> git.morado.dev Git - telluride.page/commitdiff
Initial commit
authorRoberto Morado <roramigator@duck.com>
Tue, 14 Apr 2026 17:17:19 +0000 (13:17 -0400)
committerRoberto Morado <roramigator@duck.com>
Tue, 14 Apr 2026 17:17:19 +0000 (13:17 -0400)
91 files changed:
README.md [new file with mode: 0644]
config/app.config.json [new file with mode: 0644]
deno.json [new file with mode: 0644]
deno.lock [new file with mode: 0644]
dist/.DS_Store [new file with mode: 0644]
main.ts [new file with mode: 0644]
public/styles.css [new file with mode: 0644]
scripts/announcements.ts [new file with mode: 0644]
scripts/boards.ts [new file with mode: 0644]
scripts/build.ts [new file with mode: 0644]
scripts/db.ts [new file with mode: 0644]
scripts/members.ts [new file with mode: 0644]
scripts/threads.ts [new file with mode: 0644]
src/.DS_Store [new file with mode: 0644]
src/application/.DS_Store [new file with mode: 0644]
src/application/contracts/clock.ts [new file with mode: 0644]
src/application/contracts/id_generator.ts [new file with mode: 0644]
src/application/errors/application_error.ts [new file with mode: 0644]
src/application/usecases/archive_expired_posts_use_case.ts [new file with mode: 0644]
src/application/usecases/archive_expired_posts_use_case_test.ts [new file with mode: 0644]
src/application/usecases/authenticate_user_use_case.ts [new file with mode: 0644]
src/application/usecases/auto_create_boards_from_requests_use_case.ts [new file with mode: 0644]
src/application/usecases/auto_create_boards_from_requests_use_case_test.ts [new file with mode: 0644]
src/application/usecases/create_board_announcement_use_case.ts [new file with mode: 0644]
src/application/usecases/create_board_request_use_case.ts [new file with mode: 0644]
src/application/usecases/create_board_request_use_case_test.ts [new file with mode: 0644]
src/application/usecases/create_board_use_case.ts [new file with mode: 0644]
src/application/usecases/create_comment_use_case.ts [new file with mode: 0644]
src/application/usecases/create_comment_use_case_test.ts [new file with mode: 0644]
src/application/usecases/create_post_use_case.ts [new file with mode: 0644]
src/application/usecases/create_post_use_case_test.ts [new file with mode: 0644]
src/application/usecases/delete_board_announcement_use_case.ts [new file with mode: 0644]
src/application/usecases/get_board_by_slug_use_case.ts [new file with mode: 0644]
src/application/usecases/get_board_requests_overview_use_case.ts [new file with mode: 0644]
src/application/usecases/get_board_requests_overview_use_case_test.ts [new file with mode: 0644]
src/application/usecases/get_post_use_case.ts [new file with mode: 0644]
src/application/usecases/list_archived_posts_use_case.ts [new file with mode: 0644]
src/application/usecases/list_board_announcements_use_case.ts [new file with mode: 0644]
src/application/usecases/list_board_requests_use_case.ts [new file with mode: 0644]
src/application/usecases/list_boards_use_case.ts [new file with mode: 0644]
src/application/usecases/list_comments_use_case.ts [new file with mode: 0644]
src/application/usecases/list_popular_posts_use_case.ts [new file with mode: 0644]
src/application/usecases/list_popular_posts_use_case_test.ts [new file with mode: 0644]
src/application/usecases/list_posts_use_case.ts [new file with mode: 0644]
src/application/usecases/register_user_use_case.ts [new file with mode: 0644]
src/application/usecases/toggle_board_request_vote_use_case.ts [new file with mode: 0644]
src/application/usecases/toggle_board_request_vote_use_case_test.ts [new file with mode: 0644]
src/application/usecases/update_board_request_status_use_case.ts [new file with mode: 0644]
src/application/utils/password_hash.ts [new file with mode: 0644]
src/bootstrap/app_container.ts [new file with mode: 0644]
src/config/app_config.ts [new file with mode: 0644]
src/config/app_config_test.ts [new file with mode: 0644]
src/domain/.DS_Store [new file with mode: 0644]
src/domain/entities/board.ts [new file with mode: 0644]
src/domain/entities/board_announcement.ts [new file with mode: 0644]
src/domain/entities/board_request.ts [new file with mode: 0644]
src/domain/entities/board_request_vote.ts [new file with mode: 0644]
src/domain/entities/comment.ts [new file with mode: 0644]
src/domain/entities/image_post.ts [new file with mode: 0644]
src/domain/entities/user.ts [new file with mode: 0644]
src/domain/repositories/board_announcement_repository.ts [new file with mode: 0644]
src/domain/repositories/board_repository.ts [new file with mode: 0644]
src/domain/repositories/board_request_repository.ts [new file with mode: 0644]
src/domain/repositories/board_request_vote_repository.ts [new file with mode: 0644]
src/domain/repositories/comment_repository.ts [new file with mode: 0644]
src/domain/repositories/post_repository.ts [new file with mode: 0644]
src/domain/repositories/user_repository.ts [new file with mode: 0644]
src/domain/services/image_storage.ts [new file with mode: 0644]
src/domain/services/rate_limiter.ts [new file with mode: 0644]
src/domain/services/session_store.ts [new file with mode: 0644]
src/infrastructure/.DS_Store [new file with mode: 0644]
src/infrastructure/kv/kv_board_announcement_repository.ts [new file with mode: 0644]
src/infrastructure/kv/kv_board_repository.ts [new file with mode: 0644]
src/infrastructure/kv/kv_board_request_repository.ts [new file with mode: 0644]
src/infrastructure/kv/kv_board_request_vote_repository.ts [new file with mode: 0644]
src/infrastructure/kv/kv_comment_repository.ts [new file with mode: 0644]
src/infrastructure/kv/kv_post_repository.ts [new file with mode: 0644]
src/infrastructure/kv/kv_session_store.ts [new file with mode: 0644]
src/infrastructure/kv/kv_user_repository.ts [new file with mode: 0644]
src/infrastructure/storage/file_system_image_storage.ts [new file with mode: 0644]
src/infrastructure/storage/file_system_image_storage_test.ts [new file with mode: 0644]
src/infrastructure/system/crypto_id_generator.ts [new file with mode: 0644]
src/infrastructure/system/kv_rate_limiter.ts [new file with mode: 0644]
src/infrastructure/system/system_clock.ts [new file with mode: 0644]
src/interfaces/http/app.ts [new file with mode: 0644]
src/interfaces/http/views/app_page.ts [new file with mode: 0644]
src/interfaces/http/views/home_page.ts [new file with mode: 0644]
src/interfaces/http/views/shared.ts [new file with mode: 0644]
src/testing/asserts.ts [new file with mode: 0644]
src/testing/fakes.ts [new file with mode: 0644]
storage/.gitignore [new file with mode: 0644]

diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..f867d51
--- /dev/null
+++ b/README.md
@@ -0,0 +1,190 @@
+# KV Image Board
+
+KV Image Board is a server-rendered image- and discussion-board written in Deno, Hono, and Preact.
+Everything required to bootstrap a new instance—branding, rate limits, boards, welcome posts—is
+stored in a single JSON configuration file so deployments stay deterministic and environment
+agnostic.
+
+The stack is intentionally lightweight: server-side rendering only, Deno KV for storage, and a thin
+HTTP layer backed by a dependency-injected application core. The same core services are reused by
+the command line tooling to keep operational tasks consistent with runtime behaviour.
+
+---
+
+## Feature Highlights
+
+- **Config-driven bootstrap** â€“ `config/app.config.json` defines app copy, rate limits, default
+  boards, and the private members board, including its seeded welcome post.
+- **Members-only area** â€“ Authenticated users unlock the `/members/` board and can read permanent
+  announcements posted by administrators. Posts may omit subjects or attachments to simplify long
+  form communication.
+- **First-class moderation tooling** â€“ Ships with task runners for boards, threads, announcements,
+  members, and database maintenance. Scripts accept the short IDs shown in the UI for quick actions.
+- **Rate limited interactions** â€“ Posting threads, commenting, and filing board requests are guarded
+  by KV-backed token buckets shared between the HTTP API and CLI tools.
+- **Zero client JavaScript** â€“ Pages render on the server via Preact templates. Forms post back to
+  Hono routes for fast, accessible interactions.
+- **Deterministic retention** â€“ A background archive use case (run via cron or CLI) marks stale
+  threads read-only while permanent announcements never expire.
+
+---
+
+## Architecture Overview
+
+```
+├── config/                      # JSON configuration (sole source of initial data)
+├── src/
+│   â”œâ”€â”€ application/             # Pure use cases, contracts & utilities
+│   â”œâ”€â”€ bootstrap/               # Dependency container & startup hooks
+│   â”œâ”€â”€ config/                  # Runtime configuration loader & validation
+│   â”œâ”€â”€ domain/                  # Entities and repository interfaces
+│   â”œâ”€â”€ infrastructure/          # KV adapters, filesystem storage, session store
+│   â””── interfaces/http/         # Hono routes and Preact SSR views
+├── scripts/                     # Operational task runners (boards, threads, members, â€¦)
+├── public/                      # Static assets (CSS)
+├── storage/                     # Uploaded files (created on first run)
+└── main.ts                      # Entrypoint creating the KV-backed application
+```
+
+The runtime centres on a dependency container that wires repositories, services (clock, rate
+limiter, storage), and application use cases. HTTP handlers and CLI scripts both receive their
+dependencies from this container, guaranteeing identical behaviour regardless of the execution
+surface.
+
+Sessions and rate limiting live entirely inside Deno KV: session cookies are signed HMAC tokens, and
+rate limiting uses atomic KV counters keyed by session ID and client IP.
+
+---
+
+## Configuration
+
+All initial data is sourced from `config/app.config.json`. Example:
+
+```json
+{
+  "app": {
+    "name": "KV Image Board",
+    "description": "Browse community boards or request a new one.",
+    "homeHeading": "KV Boards",
+    "publicDir": "../public",
+    "storageRoot": "../storage",
+    "assetsBasePath": "/assets",
+    "uploadsBasePath": "/uploads",
+    "threadTtlDays": 7,
+    "boardRequestWindowDays": 3,
+    "footerText": "Powered by Deno KV Â· Community moderated boards",
+    "rateLimits": {
+      "createPost": { "limit": 5, "windowSeconds": 60 },
+      "createComment": { "limit": 10, "windowSeconds": 60 },
+      "createBoardRequest": { "limit": 3, "windowSeconds": 3600 }
+    }
+  },
+  "defaultBoard": {
+    "slug": "g",
+    "name": "General",
+    "description": "General purpose image board.",
+    "themeColor": "#0f172a"
+  },
+  "memberBoard": {
+    "slug": "members",
+    "name": "Members",
+    "description": "Private community board for registered members.",
+    "themeColor": "#1e3a8a",
+    "welcomePost": {
+      "title": "Welcome to the members board",
+      "body": "Introduce yourself and let others know what you are working on.",
+      "permanent": true
+    }
+  }
+}
+```
+
+* Tips:
+  * Relative paths are resolved against the configuration file. Keeping environment-specific JSON
+    alongside deployments is easy.
+  * Footer text, welcome post copy, and moderation defaults change instantly without touching code.
+  * Run with a different configuration via `APP_CONFIG_PATH=/path/to/app.config.json deno task dev`.
+
+---
+
+## Running Locally
+
+```bash
+# 1. Install prerequisites (Deno 1.39+)
+# 2. Optionally copy the config file and tweak values
+
+APP_CONFIG_PATH=./config/app.config.json deno task dev
+```
+
+By default the application listens on `http://localhost:8000`. On startup it:
+
+1. Loads the configuration and initialises the dependency container.
+2. Ensures the default public board and the private members board exist.
+3. Seeds the members board with the configured welcome post if there are no threads yet.
+
+Threads and comments are stored in Deno KV; uploaded media is written to `storage/` (created on the
+first write).
+
+---
+
+## CLI Toolbox
+
+All scripts run with the same container as the HTTP server. Short post IDs (first eight characters)
+shown in the UI can be used with every moderation command.
+
+| Task                      | Command & Description |
+|---------------------------|-----------------------|
+| Boards                    | `deno task boards list` / `create <slug> <name>` / `requests` / `delete <slug>` |
+| Threads                   | `deno task threads delete|repost|unpost|archive <postId>` or `archive-run` |
+| Announcements             | `deno task announcements list|add "message"|delete <id>` |
+| Members (admin posts)     | `deno task members post "body" [--title "..."] [--slug members]` / `archive <postId>` |
+| Database maintenance      | `deno task db wipe --yes` |
+| Formatting & linting      | `deno fmt`, `deno lint` |
+| Tests                     | `deno task test` |
+
+Because scripts accept short IDs, operators can copy the identifier shown in the UI (`No.ab12cd34`)
+and execute commands without hunting for the full UUID.
+
+---
+
+## Example Use Cases
+
+1. **Spin up a private community**: Edit the configuration to reflect your brand, set rate limits,
+   and deploy. Members sign up via the built-in auth panel, unlocking `/members/` while anonymous
+   users continue browsing public boards.
+2. **Publish an announcement**: `deno task members post "New guidelines are live." --title "Update"`
+   creates a permanent, non-expiring thread surfaced to every member. The scheduled archive job
+   ignores permanent posts.
+3. **Moderation sweep**: Run `deno task threads archive-run` nightly (or via cron) to move expired
+   threads into archive mode. For individual clean-up, use `deno task threads archive ab12cd34` with
+   the short ID shown in the browser.
+4. **Rapid prototyping**: Swap out the configuration file to test new colour schemes or different
+   board line-ups. Because bootstrap data is JSON, you can version and migrate it like any other
+   asset.
+
+---
+
+## Development Workflow
+
+- `deno lint` and `deno fmt` keep the codebase clean (lint is part of CI by default).
+- `deno test --allow-read --allow-write --allow-env --unstable-kv` covers application use cases,
+  repositories, and configuration parsing.
+- Scripts leverage the same use cases as the HTTP handlers, so adding new moderation capabilities
+  usually means wiring the relevant use case into both layers.
+- The UI relies on `public/styles.css`. When adding new components make sure the stylesheet contains
+  matching selectors (the audit step above ensured everything currently in use is covered).
+
+---
+
+## Troubleshooting
+
+| Symptom | Fix |
+|---------|-----|
+| `Thread XYZ not found` when using CLI | Use the short ID shown in the UI (`No.ab12cd34`). All tasks now accept prefixes and resolve the full ID automatically. |
+| `Configuration is missing ...` | Make sure every section exists in `app.config.json` (app, defaultBoard, memberBoard). |
+| Missing uploads | Confirm `storage/` is writable by the process and the configured `uploadsBasePath` points to a valid route. |
+| Rate limit triggered too quickly | Adjust the relevant entry in `app.rateLimits` and restart the server to reload configuration. |
+
+---
+
+Happy hacking! 
diff --git a/config/app.config.json b/config/app.config.json
new file mode 100644 (file)
index 0000000..5c9a536
--- /dev/null
@@ -0,0 +1,36 @@
+{
+  "app": {
+    "name": "Telluride Community Boards",
+    "description": "Browse community boards or request a new one.",
+    "homeHeading": "Telluride Board",
+    "publicDir": "../public",
+    "storageRoot": "../storage",
+    "assetsBasePath": "/assets",
+    "uploadsBasePath": "/uploads",
+    "threadTtlDays": 7,
+    "boardRequestWindowDays": 3,
+    "rateLimits": {
+      "createPost": { "limit": 5, "windowSeconds": 60 },
+      "createComment": { "limit": 10, "windowSeconds": 60 },
+      "createBoardRequest": { "limit": 3, "windowSeconds": 3600 }
+    },
+    "footerText": "Community moderated boards"
+  },
+  "defaultBoard": {
+    "slug": "g",
+    "name": "General",
+    "description": "General purpose image board.",
+    "themeColor": "#0f172a"
+  },
+  "memberBoard": {
+    "slug": "m",
+    "name": "Members",
+    "description": "Private community board for registered members.",
+    "themeColor": "#1e3a8a",
+    "welcomePost": {
+      "title": "Welcome to the members board",
+      "body": "Introduce yourself and let others know what you are working on.",
+      "permanent": true
+    }
+  }
+}
diff --git a/deno.json b/deno.json
new file mode 100644 (file)
index 0000000..7789515
--- /dev/null
+++ b/deno.json
@@ -0,0 +1,29 @@
+{
+  "imports": {
+    "@hono/hono": "jsr:@hono/hono@4.10.4",
+    "preact": "npm:preact@10.19.4",
+    "preact-render-to-string": "npm:preact-render-to-string@6.2.1"
+  },
+  "lint": {
+    "rules": {
+      "tags": ["recommended"]
+    }
+  },
+  "fmt": {
+    "lineWidth": 100,
+    "semiColons": true,
+    "singleQuote": false
+  },
+  "tasks": {
+    "dev": "deno run --allow-read --allow-write --allow-net --allow-env --unstable-kv --unstable-cron main.ts",
+    "fmt": "deno fmt",
+    "lint": "deno lint",
+    "test": "deno test --allow-read --allow-write --allow-env --unstable-kv",
+    "threads": "deno run --allow-read --allow-write --allow-env --unstable-kv scripts/threads.ts",
+    "boards": "deno run --allow-read --allow-write --allow-env --unstable-kv scripts/boards.ts",
+    "db": "deno run --allow-read --allow-write --allow-env --unstable-kv scripts/db.ts",
+    "announcements": "deno run --allow-read --allow-write --allow-env --unstable-kv scripts/announcements.ts",
+    "members": "deno run --allow-read --allow-write --allow-env --unstable-kv scripts/members.ts",
+    "compile": "deno run --allow-net --allow-read --allow-write --allow-run scripts/build.ts"
+  }
+}
diff --git a/deno.lock b/deno.lock
new file mode 100644 (file)
index 0000000..22cb294
--- /dev/null
+++ b/deno.lock
@@ -0,0 +1,50 @@
+{
+  "version": "5",
+  "specifiers": {
+    "jsr:@hono/hono@4.10.4": "4.10.4",
+    "jsr:@std/encoding@1": "1.0.10",
+    "jsr:@std/internal@^1.0.10": "1.0.12",
+    "jsr:@std/path@1": "1.1.2",
+    "npm:preact-render-to-string@6.2.1": "6.2.1_preact@10.19.4",
+    "npm:preact@10.19.4": "10.19.4"
+  },
+  "jsr": {
+    "@hono/hono@4.10.4": {
+      "integrity": "e54d00c4cf994e7ae297d7321793cf940656b9c5e934564c03ffc15499041b9e"
+    },
+    "@std/encoding@1.0.10": {
+      "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
+    },
+    "@std/internal@1.0.12": {
+      "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
+    },
+    "@std/path@1.1.2": {
+      "integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038",
+      "dependencies": [
+        "jsr:@std/internal"
+      ]
+    }
+  },
+  "npm": {
+    "preact-render-to-string@6.2.1_preact@10.19.4": {
+      "integrity": "sha512-5t7nFeMUextd53igL3GAakAAMaUD+dVWDHaRYaeh1tbPIjQIBtgJnMw6vf8VS/lviV0ggFtkgebatPxvtJsXyQ==",
+      "dependencies": [
+        "preact",
+        "pretty-format"
+      ]
+    },
+    "preact@10.19.4": {
+      "integrity": "sha512-dwaX5jAh0Ga8uENBX1hSOujmKWgx9RtL80KaKUFLc6jb4vCEAc3EeZ0rnQO/FO4VgjfPMfoLFWnNG8bHuZ9VLw=="
+    },
+    "pretty-format@3.8.0": {
+      "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="
+    }
+  },
+  "workspace": {
+    "dependencies": [
+      "jsr:@hono/hono@4.10.4",
+      "npm:preact-render-to-string@6.2.1",
+      "npm:preact@10.19.4"
+    ]
+  }
+}
diff --git a/dist/.DS_Store b/dist/.DS_Store
new file mode 100644 (file)
index 0000000..5008ddf
Binary files /dev/null and b/dist/.DS_Store differ
diff --git a/main.ts b/main.ts
new file mode 100644 (file)
index 0000000..bd07c8d
--- /dev/null
+++ b/main.ts
@@ -0,0 +1,77 @@
+import { createApp } from "./src/interfaces/http/app.ts";
+import { createAppContainer, ensureDefaultBoardsExist } from "./src/bootstrap/app_container.ts";
+
+const container = await createAppContainer({
+  configPath: Deno.env.get("APP_CONFIG_PATH") ?? undefined,
+});
+
+await ensureDefaultBoardsExist(container);
+
+const { config, useCases, services } = container;
+
+if ("cron" in Deno) {
+  Deno.cron("archive-stale-posts", "0 3 * * *", async () => {
+    const archived = await useCases.archiveExpiredPostsUseCase.execute();
+    if (archived > 0) {
+      console.log(`Archived ${archived} stale posts.`);
+    }
+  });
+
+  Deno.cron("auto-create-boards", "30 3 * * *", async () => {
+    const result = await useCases.autoCreateBoardsUseCase.execute();
+    if (result.created > 0) {
+      console.log(
+        `Auto-created ${result.created} board${result.created === 1 ? "" : "s"} from requests.`,
+      );
+    }
+  });
+}
+
+const sessionSecret = Deno.env.get("SESSION_COOKIE_SECRET") ?? crypto.randomUUID();
+const sessionCookieSecureEnv = Deno.env.get("SESSION_COOKIE_SECURE");
+const inferredEnv = (Deno.env.get("APP_ENV") ?? Deno.env.get("NODE_ENV") ?? "").toLowerCase();
+const sessionCookieSecure = sessionCookieSecureEnv !== undefined
+  ? !["false", "0", ""].includes(sessionCookieSecureEnv.toLowerCase())
+  : ["production", "staging"].includes(inferredEnv);
+const trustProxy = (Deno.env.get("TRUST_PROXY") ?? "false").toLowerCase() === "true";
+
+const app = createApp({
+  appName: config.app.name,
+  homePageTitle: config.app.name,
+  homePageHeading: config.app.homeHeading,
+  homePageDescription: config.app.description,
+  footerText: config.app.footerText,
+  assetsBasePath: config.app.assetsBasePath,
+  createPostUseCase: useCases.createPostUseCase,
+  listPostsUseCase: useCases.listPostsUseCase,
+  listArchivedPostsUseCase: useCases.listArchivedPostsUseCase,
+  getPostUseCase: useCases.getPostUseCase,
+  createCommentUseCase: useCases.createCommentUseCase,
+  listCommentsUseCase: useCases.listCommentsUseCase,
+  listBoardsUseCase: useCases.listBoardsUseCase,
+  createBoardRequestUseCase: useCases.createBoardRequestUseCase,
+  toggleBoardRequestVoteUseCase: useCases.toggleBoardRequestVoteUseCase,
+  getBoardBySlugUseCase: useCases.getBoardBySlugUseCase,
+  getBoardRequestsOverviewUseCase: useCases.getBoardRequestsOverviewUseCase,
+  listBoardAnnouncementsUseCase: useCases.listBoardAnnouncementsUseCase,
+  listPopularPostsUseCase: useCases.listPopularPostsUseCase,
+  imageStorage: services.imageStorage,
+  publicDir: config.app.publicDir,
+  uploadsBasePath: config.app.uploadsBasePath,
+  rateLimiter: services.rateLimiter,
+  rateLimits: config.app.rateLimits,
+  threadTtlDays: config.app.threadTtlDays,
+  boardRequestWindowDays: config.app.boardRequestWindowDays,
+  sessionSecret,
+  sessionCookieSecure,
+  trustProxy,
+  registerUserUseCase: useCases.registerUserUseCase,
+  authenticateUserUseCase: useCases.authenticateUserUseCase,
+  sessionStore: services.sessionStore,
+  userRepository: container.repositories.userRepository,
+  memberBoard: config.memberBoard,
+});
+
+const port = Number(Deno.env.get("PORT") ?? "8000");
+console.log(`${config.app.name} listening on http://localhost:${port}`);
+Deno.serve({ port }, (request, connInfo) => app.fetch(request, { connInfo }, connInfo));
diff --git a/public/styles.css b/public/styles.css
new file mode 100644 (file)
index 0000000..316ef85
--- /dev/null
@@ -0,0 +1,619 @@
+:root {
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
+  background-color: #f2f2f2;
+  color: #1a1a1a;
+  --brand-color: #1d4d7a;
+  --brand-border: #c4c4c4;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0 auto;
+  max-width: 960px;
+  padding: 24px 16px 64px;
+  background-color: #f2f2f2;
+  line-height: 1.5;
+}
+
+main,
+.home-main {
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+}
+
+a {
+  color: inherit;
+  text-decoration: none;
+}
+
+a:hover {
+  text-decoration: underline;
+}
+
+.board-header {
+  background: #e8e8e8;
+  border: 1px solid var(--brand-border);
+  padding: 16px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.board-nav {
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+  font-size: 0.9rem;
+}
+
+.board-title {
+  margin: 0;
+  font-size: 1.6rem;
+  font-weight: 600;
+  color: var(--brand-color);
+}
+
+.board-desc,
+.board-header .muted {
+  margin: 0;
+  color: #3c3c3c;
+  font-size: 0.95rem;
+}
+
+.post-form,
+.request-form,
+.request-list-container,
+.thread {
+  background: #fff;
+  border: 1px solid var(--brand-border);
+  padding: 14px;
+}
+
+.section-title {
+  margin: 0 0 10px;
+  font-size: 1.1rem;
+  color: var(--brand-color);
+}
+
+.form-grid {
+  display: grid;
+  grid-template-columns: 130px 1fr;
+  gap: 10px 14px;
+  align-items: center;
+}
+
+.form-grid label {
+  text-align: right;
+  font-size: 0.9rem;
+  color: #2f2f2f;
+}
+
+.form-grid input,
+.form-grid textarea {
+  font: inherit;
+  width: 100%;
+  padding: 6px 8px;
+  border: 1px solid #b0b0b0;
+  background: #fff;
+}
+
+.form-grid textarea {
+  resize: vertical;
+}
+
+.form-actions {
+  display: flex;
+  justify-content: flex-end;
+}
+
+button {
+  padding: 6px 12px;
+  border: 1px solid #b0b0b0;
+  background: #f5f5f5;
+  cursor: pointer;
+  font: inherit;
+}
+
+button:disabled {
+  opacity: 0.6;
+  cursor: wait;
+}
+
+button.vote-button,
+button.vote-button.voted {
+  font-size: 0.85rem;
+  padding: 4px 10px;
+}
+
+button.vote-button.voted {
+  background: #fff;
+}
+
+.error,
+.success {
+  margin: 8px 0;
+  padding: 8px 10px;
+  border: 1px solid #b0b0b0;
+  background: #fafafa;
+  font-size: 0.9rem;
+}
+
+.success {
+  color: #165a35;
+}
+
+.flash-container {
+  position: fixed;
+  top: 20px;
+  right: 20px;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  z-index: 2000;
+  max-width: 280px;
+}
+
+.flash-toast {
+  border: none;
+  border-radius: 6px;
+  padding: 10px 14px;
+  font: inherit;
+  text-align: left;
+  color: #fff;
+  background: var(--brand-color);
+  box-shadow: 0 4px 12px rgba(15, 23, 42, 0.2);
+  cursor: pointer;
+  transition: transform 0.15s ease, opacity 0.2s ease;
+}
+
+.flash-toast:hover {
+  transform: translateY(-1px);
+  opacity: 0.9;
+}
+
+.flash-toast:focus-visible {
+  outline: 2px solid #fff;
+  outline-offset: 2px;
+}
+
+.flash-toast.flash-error {
+  background: #b91c1c;
+}
+
+.flash-toast.flash-success {
+  background: #15803d;
+}
+
+.muted {
+  color: #555;
+  font-size: 0.9rem;
+}
+
+.board-grid {
+  display: grid;
+  gap: 16px;
+  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+}
+
+.board-card {
+  display: block;
+  background: #fff;
+  border: 1px solid var(--brand-border);
+  padding: 14px;
+}
+
+.board-card h3 {
+  margin: 0 0 6px;
+  font-size: 1.05rem;
+}
+
+.board-card p {
+  margin: 0;
+  color: #3f3f46;
+  font-size: 0.95rem;
+}
+
+.board-requests {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.request-panel {
+  border: 1px solid var(--brand-border);
+  background: #fff;
+  padding: 12px;
+}
+
+.request-panel summary {
+  cursor: pointer;
+  font-weight: 600;
+  margin-bottom: 10px;
+}
+
+.request-form {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.request-form label {
+  font-size: 0.9rem;
+  color: #333;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.request-form textarea {
+  resize: vertical;
+}
+
+.request-list {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.requests-body {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+  gap: 18px;
+}
+
+.request-list li {
+  background: #fff;
+  border: 1px solid var(--brand-border);
+  padding: 12px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.request-summary h3 {
+  margin: 0 0 4px;
+  font-size: 1rem;
+  color: var(--brand-color);
+}
+
+.request-summary p {
+  margin: 0;
+  color: #3f3f46;
+  font-size: 0.95rem;
+}
+
+.request-meta {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  font-size: 0.88rem;
+}
+
+.threads {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+}
+
+.thread header {
+  font-size: 0.9rem;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-bottom: 4px;
+  color: #303030;
+}
+
+.post-meta {
+  color: var(--brand-color);
+  font-weight: 600;
+}
+
+.post-date {
+  color: #505050;
+}
+
+.thread-body {
+  display: flex;
+  gap: 12px;
+}
+
+.thread-body.no-image {
+  flex-direction: column;
+}
+
+.attachment {
+  display: flex;
+}
+
+.thread-replies {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  margin-top: 12px;
+}
+
+.thread-reply {
+  margin-top: 16px;
+}
+
+.thread-body img {
+  max-width: 220px;
+  border: 1px solid var(--brand-border);
+}
+
+.thread-text {
+  max-width: 100%;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+
+.thread-text strong {
+  display: block;
+  margin-bottom: 4px;
+  color: var(--brand-color);
+}
+
+.id-link {
+  border: none;
+  background: none;
+  color: var(--brand-color);
+  cursor: pointer;
+  padding: 0;
+  font: inherit;
+  text-decoration: underline;
+}
+
+.id-link:hover {
+  text-decoration: none;
+}
+
+.reply-list {
+  display: flex;
+  gap: 6px;
+  align-items: center;
+  font-size: 0.85rem;
+  color: #4b4b4b;
+}
+
+.status-pill {
+  color: #a11e1e;
+  font-weight: 600;
+}
+
+.reply {
+  border-left: 2px solid var(--brand-border);
+  padding-left: 10px;
+  margin-top: 10px;
+}
+
+.reply.highlight {
+  background: rgba(253, 230, 138, 0.35);
+}
+
+.comment-form {
+  margin-top: 10px;
+  border: 1px solid var(--brand-border);
+  background: #fafafa;
+  padding: 8px;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.comment-form label {
+  font-size: 0.85rem;
+  color: #303030;
+}
+
+.comment-form input,
+.comment-form textarea {
+  border: 1px solid #b0b0b0;
+  padding: 6px;
+  font: inherit;
+}
+
+.spinner {
+  font-weight: 600;
+  font-size: 0.9rem;
+}
+
+@media (max-width: 720px) {
+  body {
+    padding: 20px 12px 48px;
+  }
+
+  .form-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .form-grid label {
+    text-align: left;
+  }
+
+  .thread-body {
+    flex-direction: column;
+  }
+
+  .thread-body img {
+    max-width: 100%;
+  }
+
+  .board-nav {
+    justify-content: flex-start;
+  }
+}
+
+.thread-body img,
+.board-card img,
+.request-summary img {
+  max-width: 220px;
+  height: auto;
+}
+
+.attachment img,
+.comment-attachment img {
+  max-width: 200px;
+  height: auto;
+}
+
+.comment-attachment img {
+  border: 1px solid var(--brand-border);
+}
+
+.bulletin-board {
+  background: #fff7d6;
+  border: 1px solid #d5c894;
+  padding: 12px;
+  color: #3d3d1f;
+}
+
+.bulletin-board h2 {
+  margin: 0 0 8px;
+  font-size: 1rem;
+}
+
+.bulletin-board ul {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.bulletin-board li {
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.request-controls {
+  display: flex;
+  justify-content: flex-end;
+  margin-bottom: 8px;
+}
+
+.popular-posts {
+  border: 1px solid var(--brand-border);
+  background: #fff;
+  padding: 14px;
+}
+
+.popular-posts h2 {
+  margin: 0 0 8px;
+  font-size: 1.05rem;
+}
+
+.popular-posts ul {
+  margin: 0;
+  padding: 0;
+  list-style: none;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.popular-posts li {
+  font-size: 0.95rem;
+}
+
+.site-footer {
+  margin-top: 24px;
+  text-align: center;
+  font-size: 0.85rem;
+  color: #4b4b4b;
+}
+
+.member-auth {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.member-auth .request-panel {
+  margin-top: 12px;
+}
+
+.member-card {
+  border: 1px solid var(--brand-border);
+  background: #fff;
+  padding: 16px;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.member-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+}
+
+.auth-forms {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+  gap: 18px;
+}
+
+.auth-form {
+  border: 1px solid var(--brand-border);
+  background: #f9f9f9;
+  padding: 14px;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.auth-form.active {
+  background: #fff;
+  border-color: var(--brand-color);
+  box-shadow: 0 0 0 2px rgba(29, 77, 122, 0.1);
+}
+
+.auth-form label {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  font-size: 0.9rem;
+  color: #333;
+}
+
+.auth-form input {
+  font: inherit;
+  padding: 6px 8px;
+  border: 1px solid #b0b0b0;
+  background: #fff;
+}
+
+.auth-form button {
+  align-self: flex-start;
+}
+
+.button {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  padding: 6px 14px;
+  border: 1px solid var(--brand-border);
+  background: #fff;
+  font: inherit;
+  cursor: pointer;
+  text-decoration: none;
+}
+
+.button:hover {
+  text-decoration: none;
+  background: #f5f5f5;
+}
+
+.logout-form {
+  margin: 0;
+}
diff --git a/scripts/announcements.ts b/scripts/announcements.ts
new file mode 100644 (file)
index 0000000..4827d99
--- /dev/null
@@ -0,0 +1,87 @@
+#!/usr/bin/env -S deno run --allow-read --allow-write --allow-env --unstable-kv
+
+import { createAppContainer } from "../src/bootstrap/app_container.ts";
+
+const COMMANDS = ["list", "add", "delete"] as const;
+type Command = (typeof COMMANDS)[number];
+
+const usage = () => {
+  console.log(`Announcements
+
+Usage:
+  deno task announcements list               List bulletin messages
+  deno task announcements add \"message\"      Add a bulletin
+  deno task announcements delete <id>        Remove a bulletin
+`);
+};
+
+const ensureArg = (value: string | undefined, message: string): string => {
+  if (!value) {
+    console.error(message);
+    usage();
+    Deno.exit(1);
+  }
+  return value;
+};
+
+const main = async () => {
+  const [command, ...rest] = Deno.args as [Command | undefined, ...string[]];
+  if (!command || !COMMANDS.includes(command)) {
+    usage();
+    Deno.exit(command ? 1 : 0);
+  }
+
+  const container = await createAppContainer({
+    configPath: Deno.env.get("APP_CONFIG_PATH") ?? undefined,
+  });
+
+  try {
+    const {
+      useCases: {
+        listBoardAnnouncementsUseCase,
+        createBoardAnnouncementUseCase,
+        deleteBoardAnnouncementUseCase,
+      },
+    } = container;
+
+    switch (command) {
+      case "list": {
+        const announcements = await listBoardAnnouncementsUseCase.execute();
+        if (announcements.length === 0) {
+          console.log("No announcements.");
+          break;
+        }
+        console.table(
+          announcements.map((announcement) => ({
+            id: announcement.id,
+            message: announcement.message,
+            createdAt: announcement.createdAt,
+          })),
+        );
+        break;
+      }
+      case "add": {
+        const message = rest.join(" ").trim();
+        ensureArg(message, "add requires a message to post");
+        const announcement = await createBoardAnnouncementUseCase.execute(message);
+        console.log(`Announcement ${announcement.id} created.`);
+        break;
+      }
+      case "delete": {
+        const id = ensureArg(rest[0], "delete requires <id>");
+        await deleteBoardAnnouncementUseCase.execute(id);
+        console.log(`Announcement ${id} deleted.`);
+        break;
+      }
+      default:
+        usage();
+        Deno.exit(1);
+    }
+  } finally {
+    container.close();
+  }
+};
+
+if (import.meta.main) {
+  await main();
+}
diff --git a/scripts/boards.ts b/scripts/boards.ts
new file mode 100644 (file)
index 0000000..f197211
--- /dev/null
@@ -0,0 +1,216 @@
+#!/usr/bin/env -S deno run --allow-read --allow-write --allow-env --unstable-kv
+
+import { createAppContainer } from "../src/bootstrap/app_container.ts";
+
+const COMMANDS = [
+  "list",
+  "requests",
+  "create",
+  "create-from-request",
+  "delete",
+  "close-request",
+] as const;
+
+type Command = (typeof COMMANDS)[number];
+
+const usage = () => {
+  console.log(`Board management
+
+Usage:
+  deno task boards list                          List all boards
+  deno task boards requests                      List board requests and vote counts
+  deno task boards create <slug> <name> [options]
+        --description \"...\"                      Optional description
+        --color #1E40AF                          Optional hex theme color
+  deno task boards create-from-request <id>       Fulfil a request and create the board
+  deno task boards delete <slug>                  Delete a board and its threads
+  deno task boards close-request <id>             Close a request without creating a board
+`);
+};
+
+const parseFlags = (args: string[]) => {
+  const result: Record<string, string | boolean> = {};
+  for (let i = 0; i < args.length; i += 1) {
+    const current = args[i];
+    if (!current.startsWith("--")) {
+      continue;
+    }
+    const key = current.slice(2);
+    const candidate = args[i + 1];
+    if (!candidate || candidate.startsWith("--")) {
+      result[key] = true;
+    } else {
+      result[key] = candidate;
+      i += 1;
+    }
+  }
+  return result;
+};
+
+const ensureArgument = (value: string | undefined, message: string) => {
+  if (!value) {
+    console.error(message);
+    usage();
+    Deno.exit(1);
+  }
+};
+
+const deleteBoardThreads = async (
+  container: Awaited<ReturnType<typeof createAppContainer>>,
+  boardId: string,
+) => {
+  const { postRepository, commentRepository } = container.repositories;
+  const { imageStorage } = container.services;
+
+  const posts = await postRepository.listByBoard(boardId);
+  for (const post of posts) {
+    const comments = await commentRepository.listByPost(post.id);
+    for (const comment of comments) {
+      if (comment.imagePath) {
+        await imageStorage.deleteImage(comment.imagePath);
+      }
+    }
+    await commentRepository.deleteByPost(post.id);
+    if (post.imagePath) {
+      await imageStorage.deleteImage(post.imagePath);
+    }
+    await postRepository.delete(post.id);
+  }
+  return posts.length;
+};
+
+const main = async () => {
+  const [command, ...rest] = Deno.args as [Command | undefined, ...string[]];
+  if (!command || !COMMANDS.includes(command)) {
+    usage();
+    Deno.exit(command ? 1 : 0);
+  }
+
+  const container = await createAppContainer({
+    configPath: Deno.env.get("APP_CONFIG_PATH") ?? undefined,
+  });
+
+  try {
+    const {
+      config,
+      repositories: {
+        boardRepository,
+        boardRequestRepository,
+        boardRequestVoteRepository,
+      },
+      useCases: {
+        listBoardsUseCase,
+        listBoardRequestsUseCase,
+        createBoardUseCase,
+        updateBoardRequestStatusUseCase,
+      },
+    } = container;
+
+    switch (command) {
+      case "list": {
+        const boards = await listBoardsUseCase.execute();
+        if (boards.length === 0) {
+          console.log("No boards configured.");
+          break;
+        }
+        console.table(
+          boards.map((board) => ({
+            slug: board.slug,
+            name: board.name,
+            themeColor: board.themeColor,
+            createdAt: board.createdAt,
+          })),
+        );
+        break;
+      }
+      case "requests": {
+        const requests = await listBoardRequestsUseCase.execute();
+        if (requests.length === 0) {
+          console.log("No active requests.");
+          break;
+        }
+        console.table(
+          await Promise.all(
+            requests.map(async (request) => ({
+              id: request.id,
+              slug: request.slug,
+              name: request.name,
+              votes: await boardRequestVoteRepository.countVotes(request.id),
+              status: request.status,
+              deadline: request.deadline,
+            })),
+          ),
+        );
+        break;
+      }
+      case "create": {
+        const [slug, name] = rest;
+        ensureArgument(slug, "create requires <slug> and <name>");
+        ensureArgument(name, "create requires <slug> and <name>");
+        const flags = parseFlags(rest.slice(2));
+        const description = typeof flags.description === "string" ? flags.description : "";
+        const themeColor = typeof flags.color === "string"
+          ? flags.color
+          : config.defaultBoard.themeColor;
+
+        const board = await createBoardUseCase.execute({
+          slug: slug!,
+          name: name!,
+          description,
+          themeColor,
+        });
+        console.log(`Created board /${board.slug}/ (${board.name}).`);
+        break;
+      }
+      case "create-from-request": {
+        const [requestId] = rest;
+        ensureArgument(requestId, "create-from-request requires <id>");
+        const request = await boardRequestRepository.findById(requestId!);
+        if (!request) {
+          console.error(`Request ${requestId} not found.`);
+          Deno.exit(1);
+        }
+        const board = await createBoardUseCase.execute({
+          slug: request.slug,
+          name: request.name,
+          description: request.description,
+          themeColor: request.themeColor,
+        });
+        await updateBoardRequestStatusUseCase.execute(request.id, "fulfilled");
+        console.log(`Created board /${board.slug}/ and marked request ${request.id} as fulfilled.`);
+        break;
+      }
+      case "delete": {
+        const [slug] = rest;
+        ensureArgument(slug, "delete requires <slug>");
+        const board = await boardRepository.findBySlug(slug!);
+        if (!board) {
+          console.error(`Board /${slug}/ not found.`);
+          Deno.exit(1);
+        }
+        const deletedThreads = await deleteBoardThreads(container, board.id);
+        await boardRepository.delete(board.id);
+        console.log(
+          `Deleted board /${slug}/ and ${deletedThreads} thread${deletedThreads === 1 ? "" : "s"}.`,
+        );
+        break;
+      }
+      case "close-request": {
+        const [requestId] = rest;
+        ensureArgument(requestId, "close-request requires <id>");
+        await updateBoardRequestStatusUseCase.execute(requestId!, "closed");
+        console.log(`Closed request ${requestId}.`);
+        break;
+      }
+      default:
+        usage();
+        Deno.exit(1);
+    }
+  } finally {
+    container.close();
+  }
+};
+
+if (import.meta.main) {
+  await main();
+}
diff --git a/scripts/build.ts b/scripts/build.ts
new file mode 100644 (file)
index 0000000..13dc682
--- /dev/null
@@ -0,0 +1,56 @@
+#!/usr/bin/env -S deno run --allow-read --allow-write --allow-run --allow-net
+
+import { dirname, fromFileUrl, join } from "jsr:@std/path@^1.0.0";
+
+const resolveProjectRoot = () => dirname(dirname(fromFileUrl(import.meta.url)));
+
+const projectRoot = resolveProjectRoot();
+const entryFile = join(projectRoot, "main.ts");
+const distDir = join(projectRoot, "dist");
+const executableName = Deno.build.os === "windows" ? "imgboard.exe" : "imgboard";
+const executableFile = join(distDir, executableName);
+
+const ensureDistDir = async () => {
+  await Deno.mkdir(distDir, { recursive: true });
+};
+
+const runCommand = async (args: string[]) => {
+  const command = new Deno.Command(Deno.execPath(), {
+    cwd: projectRoot,
+    args,
+    stdout: "inherit",
+    stderr: "inherit",
+  });
+  const { success } = await command.output();
+  if (!success) {
+    throw new Error(`Command failed: deno ${args.join(" ")}`);
+  }
+};
+
+const runCompile = async () => {
+  await ensureDistDir();
+  const compileArgs = [
+    "compile",
+    "--allow-read",
+    "--allow-write",
+    "--allow-net",
+    "--allow-env",
+    "--unstable-kv",
+    "--unstable-cron",
+    "--output",
+    executableFile,
+    entryFile,
+  ];
+  await runCommand(compileArgs);
+  console.log(`Executable written to ${executableFile}`);
+  console.log("Run with: ./dist/" + executableName);
+};
+
+if (import.meta.main) {
+  try {
+    await runCompile();
+  } catch (error) {
+    console.error("Build failed:", error);
+    Deno.exit(1);
+  }
+}
diff --git a/scripts/db.ts b/scripts/db.ts
new file mode 100644 (file)
index 0000000..a771ee3
--- /dev/null
@@ -0,0 +1,101 @@
+#!/usr/bin/env -S deno run --allow-read --allow-write --allow-env --unstable-kv
+
+const usage = () => {
+  console.log(`Database utilities
+
+Usage:
+  deno task db wipe [--yes]   Remove every key from the Deno KV store
+`);
+};
+
+const confirm = async (message: string): Promise<boolean> => {
+  await Deno.stdout.write(new TextEncoder().encode(`${message} [y/N] `));
+  const buf = new Uint8Array(1024);
+  const n = await Deno.stdin.read(buf);
+  if (!n) {
+    return false;
+  }
+  const answer = new TextDecoder().decode(buf.subarray(0, n)).trim().toLowerCase();
+  return answer === "y" || answer === "yes";
+};
+
+import { join } from "jsr:@std/path@^1.0.0";
+import { loadAppConfig } from "../src/config/app_config.ts";
+
+const wipe = async (force: boolean) => {
+  if (!force) {
+    const ok = await confirm(
+      "This will permanently delete all boards, posts, comments, and requests. Continue?",
+    );
+    if (!ok) {
+      console.log("Aborted.");
+      return;
+    }
+  }
+
+  const kv = await Deno.openKv();
+  try {
+    const batch: Deno.KvKey[] = [];
+    for await (const entry of kv.list({ prefix: [] })) {
+      batch.push(entry.key);
+      if (batch.length >= 128) {
+        await deleteBatch(kv, batch.splice(0));
+      }
+    }
+    if (batch.length > 0) {
+      await deleteBatch(kv, batch);
+    }
+    console.log("Database wiped.");
+  } finally {
+    kv.close();
+  }
+
+  await removeStoredImages();
+};
+
+const deleteBatch = async (kv: Deno.Kv, keys: Deno.KvKey[]) => {
+  const tx = kv.atomic();
+  for (const key of keys) {
+    tx.delete(key);
+  }
+  await tx.commit();
+};
+
+const removeStoredImages = async () => {
+  try {
+    const config = await loadAppConfig(Deno.env.get("APP_CONFIG_PATH") ?? undefined);
+    const imagesDir = join(config.app.storageRoot, "images");
+    await Deno.remove(imagesDir, { recursive: true });
+    console.log(`Removed image directory: ${imagesDir}`);
+  } catch (error) {
+    if (error instanceof Deno.errors.NotFound) {
+      return;
+    }
+    console.warn(
+      `Failed to remove stored images: ${error instanceof Error ? error.message : String(error)}`,
+    );
+  }
+};
+
+const main = async () => {
+  const [command, ...args] = Deno.args;
+  if (!command || command === "-h" || command === "--help") {
+    usage();
+    Deno.exit(0);
+  }
+
+  switch (command) {
+    case "wipe": {
+      const force = args.includes("--yes") || args.includes("-y");
+      await wipe(force);
+      break;
+    }
+    default:
+      usage();
+      Deno.exit(1);
+  }
+};
+
+if (import.meta.main) {
+  await main();
+}
diff --git a/scripts/members.ts b/scripts/members.ts
new file mode 100644 (file)
index 0000000..bb3efe2
--- /dev/null
@@ -0,0 +1,152 @@
+#!/usr/bin/env -S deno run --allow-read --allow-write --allow-env --unstable-kv
+
+import { createAppContainer } from "../src/bootstrap/app_container.ts";
+
+const COMMANDS = ["post", "archive"] as const;
+type Command = (typeof COMMANDS)[number];
+
+const usage = () => {
+  console.log(`Members board utilities
+
+Usage:
+  deno task members post <body> [--title "..."] [--slug members]
+  deno task members archive <postId>
+`);
+};
+
+const parseFlags = (args: string[]) => {
+  const result: Record<string, string | boolean> = {};
+  for (let i = 0; i < args.length; i += 1) {
+    const current = args[i];
+    if (!current.startsWith("--")) {
+      continue;
+    }
+    const key = current.slice(2);
+    const candidate = args[i + 1];
+    if (!candidate || candidate.startsWith("--")) {
+      result[key] = true;
+    } else {
+      result[key] = candidate;
+      i += 1;
+    }
+  }
+  return result;
+};
+
+const ensureValue = (value: string | undefined, message: string) => {
+  if (!value) {
+    console.error(message);
+    usage();
+    Deno.exit(1);
+  }
+  return value;
+};
+
+const createAdminPost = async (
+  container: Awaited<ReturnType<typeof createAppContainer>>,
+  body: string,
+  options: { title?: string; slug?: string },
+) => {
+  const trimmedBody = body.trim();
+  if (!trimmedBody) {
+    console.error("Post body must not be empty.");
+    Deno.exit(1);
+  }
+  const { memberBoard } = container.config;
+  const targetSlug = options.slug?.trim().length ? options.slug.trim() : memberBoard.slug;
+  const board = await container.repositories.boardRepository.findBySlug(targetSlug);
+  if (!board) {
+    console.error(`Board /${targetSlug}/ not found.`);
+    Deno.exit(1);
+  }
+
+  const post = await container.useCases.createPostUseCase.execute({
+    boardId: board.id,
+    title: options.title?.trim() || undefined,
+    description: trimmedBody,
+    permanent: true,
+  });
+  console.log(`Created permanent post ${post.id} on /${board.slug}/.`);
+};
+
+const archivePost = async (
+  container: Awaited<ReturnType<typeof createAppContainer>>,
+  postId: string,
+) => {
+  const post = await container.repositories.postRepository.findById(postId);
+  if (!post) {
+    console.error(`Post ${postId} not found.`);
+    Deno.exit(1);
+  }
+
+  const board = await container.repositories.boardRepository.findById(post.boardId);
+  if (!board) {
+    console.error(`Board for post ${postId} not found.`);
+    Deno.exit(1);
+  }
+
+  const targetSlug = container.config.memberBoard.slug;
+  if (board.slug !== targetSlug) {
+    console.error(`Post ${postId} does not belong to /${targetSlug}/.`);
+    Deno.exit(1);
+  }
+
+  post.status = "archived";
+  post.readOnly = true;
+  post.archivedAt = new Date().toISOString();
+  await container.repositories.postRepository.update(post);
+  console.log(`Archived post ${postId}.`);
+};
+
+const main = async () => {
+  const [command, ...rest] = Deno.args as [Command | undefined, ...string[]];
+  if (!command || !COMMANDS.includes(command)) {
+    usage();
+    Deno.exit(command ? 1 : 0);
+  }
+
+  const container = await createAppContainer({
+    configPath: Deno.env.get("APP_CONFIG_PATH") ?? undefined,
+  });
+
+  try {
+    switch (command) {
+      case "post": {
+        let bodyArg: string | undefined;
+        const flagArgs: string[] = [];
+        for (const arg of rest) {
+          if (bodyArg === undefined && !arg.startsWith("--")) {
+            bodyArg = arg;
+          } else {
+            flagArgs.push(arg);
+          }
+        }
+        const flags = parseFlags(flagArgs);
+        const candidateBody = typeof flags.body === "string" ? `${flags.body}` : bodyArg;
+        const body = ensureValue(
+          candidateBody,
+          'post requires a body (pass as argument or --body "...").',
+        );
+        const title = typeof flags.title === "string" ? `${flags.title}` : undefined;
+        const slug = typeof flags.slug === "string" ? `${flags.slug}` : undefined;
+        await createAdminPost(container, body, { title, slug });
+        break;
+      }
+      case "archive": {
+        const [postId] = rest;
+        const targetPostId = ensureValue(postId, "archive requires <postId>");
+        await archivePost(container, targetPostId);
+        break;
+      }
+      default:
+        usage();
+        Deno.exit(1);
+    }
+  } finally {
+    container.close();
+  }
+};
+
+if (import.meta.main) {
+  await main();
+}
diff --git a/scripts/threads.ts b/scripts/threads.ts
new file mode 100644 (file)
index 0000000..4eeefc0
--- /dev/null
@@ -0,0 +1,172 @@
+#!/usr/bin/env -S deno run --allow-read --allow-write --allow-env --unstable-kv
+
+import { createAppContainer } from "../src/bootstrap/app_container.ts";
+import type { PostRepository } from "../src/domain/repositories/post_repository.ts";
+
+const COMMANDS = ["delete", "repost", "unpost", "archive", "archive-run"] as const;
+type Command = (typeof COMMANDS)[number];
+
+const usage = () => {
+  console.log(`Thread management
+
+Usage:
+  deno task threads delete <postId>        Delete a thread and all of its assets
+  deno task threads repost <postId>        Surface an archived thread as read-only on the board
+  deno task threads unpost <postId>        Hide a reposted thread back into the archive
+  deno task threads archive <postId>       Archive a thread immediately (keep assets)
+  deno task threads archive-run            Run the archive job immediately
+`);
+};
+
+const ensurePostId = (command: string, value?: string): string => {
+  if (!value) {
+    console.error(`${command} requires a <postId>`);
+    usage();
+    Deno.exit(1);
+  }
+  return value;
+};
+
+const deleteThread = async (
+  container: Awaited<ReturnType<typeof createAppContainer>>,
+  postId: string,
+) => {
+  const { postRepository, commentRepository } = container.repositories;
+  const { imageStorage } = container.services;
+
+  const post = await findPostByPartialId(postRepository, postId);
+  if (!post) {
+    console.error(`Thread ${postId} not found.`);
+    Deno.exit(1);
+  }
+
+  const comments = await commentRepository.listByPost(post.id);
+  for (const comment of comments) {
+    if (comment.imagePath) {
+      await imageStorage.deleteImage(comment.imagePath);
+    }
+  }
+  await commentRepository.deleteByPost(post.id);
+  if (post.imagePath) {
+    await imageStorage.deleteImage(post.imagePath);
+  }
+  await postRepository.delete(post.id);
+
+  console.log(
+    `Deleted thread ${postId} and ${comments.length} comment${comments.length === 1 ? "" : "s"}.`,
+  );
+};
+
+const repostThread = async (
+  container: Awaited<ReturnType<typeof createAppContainer>>,
+  postId: string,
+) => {
+  const { postRepository } = container.repositories;
+  const post = await findPostByPartialId(postRepository, postId);
+  if (!post) {
+    console.error(`Thread ${postId} not found.`);
+    Deno.exit(1);
+  }
+  post.status = "active";
+  post.readOnly = true;
+  await postRepository.update(post);
+  console.log(`Thread ${postId} reposted to the board in read-only mode.`);
+};
+
+const unpostThread = async (
+  container: Awaited<ReturnType<typeof createAppContainer>>,
+  postId: string,
+) => {
+  const { postRepository } = container.repositories;
+  const post = await findPostByPartialId(postRepository, postId);
+  if (!post) {
+    console.error(`Thread ${postId} not found.`);
+    Deno.exit(1);
+  }
+  post.status = "archived";
+  post.readOnly = true;
+  post.archivedAt ??= new Date().toISOString();
+  await postRepository.update(post);
+  console.log(`Thread ${postId} hidden from the live board but kept in the archive.`);
+};
+
+const archiveThread = async (
+  container: Awaited<ReturnType<typeof createAppContainer>>,
+  postId: string,
+) => {
+  const { postRepository } = container.repositories;
+  const post = await findPostByPartialId(postRepository, postId);
+  if (!post) {
+    console.error(`Thread ${postId} not found.`);
+    Deno.exit(1);
+  }
+  post.status = "archived";
+  post.readOnly = true;
+  post.archivedAt = new Date().toISOString();
+  await postRepository.update(post);
+  console.log(`Thread ${postId} archived.`);
+};
+
+const findPostByPartialId = async (
+  postRepository: Pick<PostRepository, "findById" | "listAll">,
+  candidate: string,
+) => {
+  const normalised = candidate.trim();
+  if (normalised.length === 0) {
+    return null;
+  }
+  const direct = await postRepository.findById(normalised);
+  if (direct) {
+    return direct;
+  }
+  const posts = await postRepository.listAll();
+  const lower = normalised.toLowerCase();
+  return posts.find((post) => post.id.toLowerCase().startsWith(lower)) ?? null;
+};
+
+const main = async () => {
+  const [command, postId] = Deno.args as [Command | undefined, string | undefined];
+  if (!command || !COMMANDS.includes(command)) {
+    usage();
+    Deno.exit(command ? 1 : 0);
+  }
+
+  const container = await createAppContainer({
+    configPath: Deno.env.get("APP_CONFIG_PATH") ?? undefined,
+  });
+
+  try {
+    switch (command) {
+      case "delete": {
+        await deleteThread(container, ensurePostId(command, postId));
+        break;
+      }
+      case "repost": {
+        await repostThread(container, ensurePostId(command, postId));
+        break;
+      }
+      case "unpost": {
+        await unpostThread(container, ensurePostId(command, postId));
+        break;
+      }
+      case "archive": {
+        await archiveThread(container, ensurePostId(command, postId));
+        break;
+      }
+      case "archive-run": {
+        const archived = await container.useCases.archiveExpiredPostsUseCase.execute();
+        console.log(`Archived ${archived} thread${archived === 1 ? "" : "s"}.`);
+        break;
+      }
+      default:
+        usage();
+        Deno.exit(1);
+    }
+  } finally {
+    container.close();
+  }
+};
+
+if (import.meta.main) {
+  await main();
+}
diff --git a/src/.DS_Store b/src/.DS_Store
new file mode 100644 (file)
index 0000000..656cf19
Binary files /dev/null and b/src/.DS_Store differ
diff --git a/src/application/.DS_Store b/src/application/.DS_Store
new file mode 100644 (file)
index 0000000..5b0d93d
Binary files /dev/null and b/src/application/.DS_Store differ
diff --git a/src/application/contracts/clock.ts b/src/application/contracts/clock.ts
new file mode 100644 (file)
index 0000000..89d4db7
--- /dev/null
@@ -0,0 +1,3 @@
+export interface Clock {
+  now(): Date;
+}
diff --git a/src/application/contracts/id_generator.ts b/src/application/contracts/id_generator.ts
new file mode 100644 (file)
index 0000000..93cd792
--- /dev/null
@@ -0,0 +1,3 @@
+export interface IdGenerator {
+  generate(): string;
+}
diff --git a/src/application/errors/application_error.ts b/src/application/errors/application_error.ts
new file mode 100644 (file)
index 0000000..fe65552
--- /dev/null
@@ -0,0 +1,6 @@
+export class ApplicationError extends Error {
+  constructor(message: string, public readonly status = 400) {
+    super(message);
+    this.name = "ApplicationError";
+  }
+}
diff --git a/src/application/usecases/archive_expired_posts_use_case.ts b/src/application/usecases/archive_expired_posts_use_case.ts
new file mode 100644 (file)
index 0000000..b3dd22a
--- /dev/null
@@ -0,0 +1,30 @@
+import { PostRepository } from "../../domain/repositories/post_repository.ts";
+import { Clock } from "../contracts/clock.ts";
+
+export class ArchiveExpiredPostsUseCase {
+  constructor(
+    private readonly postRepository: PostRepository,
+    private readonly clock: Clock,
+    private readonly ttlMilliseconds: number,
+  ) {}
+
+  async execute(): Promise<number> {
+    const posts = await this.postRepository.listAll();
+    const now = this.clock.now().getTime();
+    const expired = posts.filter((rawPost) => {
+      const status = rawPost.status ?? "active";
+      const permanent = rawPost.permanent ?? false;
+      return status === "active" && !permanent &&
+        now - new Date(rawPost.createdAt).getTime() > this.ttlMilliseconds;
+    });
+
+    for (const post of expired) {
+      post.status = "archived";
+      post.readOnly = true;
+      post.archivedAt = new Date(now).toISOString();
+      await this.postRepository.update(post);
+    }
+
+    return expired.length;
+  }
+}
diff --git a/src/application/usecases/archive_expired_posts_use_case_test.ts b/src/application/usecases/archive_expired_posts_use_case_test.ts
new file mode 100644 (file)
index 0000000..6d8b9d6
--- /dev/null
@@ -0,0 +1,74 @@
+import { ArchiveExpiredPostsUseCase } from "./archive_expired_posts_use_case.ts";
+import { FixedClock, InMemoryPostRepository } from "../../testing/fakes.ts";
+import { assertEquals } from "../../testing/asserts.ts";
+
+const NOW = new Date("2024-03-01T00:00:00.000Z");
+
+const buildPost = (overrides: Partial<Record<string, unknown>> = {}) => ({
+  id: `post-${crypto.randomUUID()}`,
+  boardId: "board-1",
+  title: "Sample",
+  description: "",
+  createdAt: NOW.toISOString(),
+  commentCount: 0,
+  status: "active" as const,
+  readOnly: false,
+  archivedAt: null,
+  ...overrides,
+});
+
+Deno.test("ArchiveExpiredPostsUseCase archives posts older than TTL", async () => {
+  const postRepository = new InMemoryPostRepository();
+  const clock = new FixedClock(NOW);
+  const ttl = 24 * 60 * 60 * 1000; // one day
+
+  const recentPost = buildPost({
+    id: "recent",
+    createdAt: new Date(NOW.getTime() - ttl / 2).toISOString(),
+  });
+  const stalePost = buildPost({
+    id: "stale",
+    createdAt: new Date(NOW.getTime() - ttl * 2).toISOString(),
+  });
+
+  await postRepository.create(recentPost);
+  await postRepository.create(stalePost);
+
+  const useCase = new ArchiveExpiredPostsUseCase(postRepository, clock, ttl);
+  const archivedCount = await useCase.execute();
+
+  assertEquals(archivedCount, 1);
+
+  const storedRecent = await postRepository.findById("recent");
+  assertEquals(storedRecent?.status, "active");
+  assertEquals(storedRecent?.readOnly, false);
+  assertEquals(storedRecent?.archivedAt, null);
+
+  const storedStale = await postRepository.findById("stale");
+  assertEquals(storedStale?.status, "archived");
+  assertEquals(storedStale?.readOnly, true);
+  assertEquals(storedStale?.archivedAt, NOW.toISOString());
+});
+
+Deno.test("ArchiveExpiredPostsUseCase skips permanent posts", async () => {
+  const postRepository = new InMemoryPostRepository();
+  const clock = new FixedClock(NOW);
+  const ttl = 24 * 60 * 60 * 1000;
+
+  const permanentPost = buildPost({
+    id: "permanent",
+    createdAt: new Date(NOW.getTime() - ttl * 3).toISOString(),
+    permanent: true,
+  });
+
+  await postRepository.create(permanentPost);
+
+  const useCase = new ArchiveExpiredPostsUseCase(postRepository, clock, ttl);
+  const archivedCount = await useCase.execute();
+
+  assertEquals(archivedCount, 0);
+
+  const stored = await postRepository.findById("permanent");
+  assertEquals(stored?.status, "active");
+  assertEquals(stored?.permanent, true);
+});
diff --git a/src/application/usecases/authenticate_user_use_case.ts b/src/application/usecases/authenticate_user_use_case.ts
new file mode 100644 (file)
index 0000000..2315b3a
--- /dev/null
@@ -0,0 +1,30 @@
+import { ApplicationError } from "../errors/application_error.ts";
+import { UserRepository } from "../../domain/repositories/user_repository.ts";
+import { User } from "../../domain/entities/user.ts";
+import { verifyPassword } from "../utils/password_hash.ts";
+
+export interface AuthenticateUserCommand {
+  username: string;
+  password: string;
+}
+
+export class AuthenticateUserUseCase {
+  constructor(private readonly userRepository: UserRepository) {}
+
+  async execute(command: AuthenticateUserCommand): Promise<User> {
+    const username = command.username?.trim();
+    if (!username) {
+      throw new ApplicationError("Username is required.");
+    }
+    const password = command.password ?? "";
+    const user = await this.userRepository.findByUsername(username);
+    if (!user) {
+      throw new ApplicationError("Invalid username or password.", 401);
+    }
+    const valid = await verifyPassword(password, user.passwordHash);
+    if (!valid) {
+      throw new ApplicationError("Invalid username or password.", 401);
+    }
+    return user;
+  }
+}
diff --git a/src/application/usecases/auto_create_boards_from_requests_use_case.ts b/src/application/usecases/auto_create_boards_from_requests_use_case.ts
new file mode 100644 (file)
index 0000000..d77fa96
--- /dev/null
@@ -0,0 +1,58 @@
+import { BoardRepository } from "../../domain/repositories/board_repository.ts";
+import { BoardRequestRepository } from "../../domain/repositories/board_request_repository.ts";
+import { BoardRequestVoteRepository } from "../../domain/repositories/board_request_vote_repository.ts";
+import { CreateBoardUseCase } from "./create_board_use_case.ts";
+import { UpdateBoardRequestStatusUseCase } from "./update_board_request_status_use_case.ts";
+
+export class AutoCreateBoardsFromRequestsUseCase {
+  constructor(
+    private readonly boardRequestRepository: BoardRequestRepository,
+    private readonly boardRequestVoteRepository: BoardRequestVoteRepository,
+    private readonly boardRepository: BoardRepository,
+    private readonly createBoardUseCase: CreateBoardUseCase,
+    private readonly updateBoardRequestStatusUseCase: UpdateBoardRequestStatusUseCase,
+  ) {}
+
+  async execute(): Promise<{ created: number }> {
+    const requests = await this.boardRequestRepository.list();
+    const now = Date.now();
+    const expired = requests.filter((request) => {
+      if (request.status !== "open") {
+        return false;
+      }
+      return new Date(request.deadline).getTime() <= now;
+    });
+
+    let created = 0;
+    for (const request of expired) {
+      const voteCount = await this.boardRequestVoteRepository.countVotes(request.id);
+      if (request.voteCount !== voteCount) {
+        request.voteCount = voteCount;
+        await this.boardRequestRepository.update(request);
+      } else {
+        request.voteCount = voteCount;
+      }
+      if (voteCount === 0) {
+        await this.updateBoardRequestStatusUseCase.execute(request.id, "closed");
+        continue;
+      }
+
+      const existing = await this.boardRepository.findBySlug(request.slug);
+      if (existing) {
+        await this.updateBoardRequestStatusUseCase.execute(request.id, "fulfilled");
+        continue;
+      }
+
+      await this.createBoardUseCase.execute({
+        slug: request.slug,
+        name: request.name,
+        description: request.description,
+        themeColor: request.themeColor,
+      });
+      await this.updateBoardRequestStatusUseCase.execute(request.id, "fulfilled");
+      created += 1;
+    }
+
+    return { created };
+  }
+}
diff --git a/src/application/usecases/auto_create_boards_from_requests_use_case_test.ts b/src/application/usecases/auto_create_boards_from_requests_use_case_test.ts
new file mode 100644 (file)
index 0000000..f4a4052
--- /dev/null
@@ -0,0 +1,84 @@
+import { AutoCreateBoardsFromRequestsUseCase } from "./auto_create_boards_from_requests_use_case.ts";
+import { CreateBoardUseCase } from "./create_board_use_case.ts";
+import { UpdateBoardRequestStatusUseCase } from "./update_board_request_status_use_case.ts";
+import {
+  FixedClock,
+  InMemoryBoardRepository,
+  InMemoryBoardRequestRepository,
+  InMemoryBoardRequestVoteRepository,
+  SequenceIdGenerator,
+} from "../../testing/fakes.ts";
+import { assertEquals } from "../../testing/asserts.ts";
+
+const NOW = new Date("2024-03-10T00:00:00.000Z");
+
+const buildRequest = (id: string, overrides: Partial<Record<string, unknown>> = {}) => ({
+  id,
+  slug: `board-${id}`,
+  name: `Board ${id}`,
+  description: "",
+  themeColor: "#0f172a",
+  voteCount: 0,
+  createdAt: NOW.toISOString(),
+  deadline: new Date(Date.now() - 60 * 1000).toISOString(),
+  status: "open" as const,
+  ...overrides,
+});
+
+Deno.test("AutoCreateBoardsFromRequestsUseCase handles expired requests", async () => {
+  const boardRepository = new InMemoryBoardRepository();
+  const requestRepository = new InMemoryBoardRequestRepository();
+  const voteRepository = new InMemoryBoardRequestVoteRepository();
+  const idGenerator = new SequenceIdGenerator("board");
+  const clock = new FixedClock(NOW);
+
+  const createBoardUseCase = new CreateBoardUseCase(boardRepository, idGenerator, clock);
+  const updateStatusUseCase = new UpdateBoardRequestStatusUseCase(requestRepository);
+  const useCase = new AutoCreateBoardsFromRequestsUseCase(
+    requestRepository,
+    voteRepository,
+    boardRepository,
+    createBoardUseCase,
+    updateStatusUseCase,
+  );
+
+  await requestRepository.create(buildRequest("no-votes"));
+  await requestRepository.create(buildRequest("existing"));
+  await requestRepository.create(buildRequest("new"));
+
+  await boardRepository.create({
+    id: "existing-board",
+    slug: "board-existing",
+    name: "Existing Board",
+    description: "",
+    themeColor: "#0f172a",
+    createdAt: NOW.toISOString(),
+    status: "active",
+  });
+
+  await voteRepository.addVote("existing", "user-1");
+  await voteRepository.addVote("new", "user-1");
+  await voteRepository.addVote("new", "user-2");
+
+  assertEquals(await voteRepository.countVotes("new"), 2);
+  assertEquals(await boardRepository.findBySlug("board-new"), null);
+
+  const result = await useCase.execute();
+
+  const noVotes = await requestRepository.findById("no-votes");
+  assertEquals(noVotes?.status, "closed");
+  assertEquals(noVotes?.voteCount, 0);
+
+  const existing = await requestRepository.findById("existing");
+  assertEquals(existing?.status, "fulfilled");
+  assertEquals(existing?.voteCount, 1);
+
+  const createdRequest = await requestRepository.findById("new");
+  assertEquals(createdRequest?.status, "fulfilled");
+  assertEquals(createdRequest?.voteCount, 2);
+
+  const newBoard = await boardRepository.findBySlug("board-new");
+  assertEquals(newBoard?.name, "Board new");
+
+  assertEquals(result.created, 1);
+});
diff --git a/src/application/usecases/create_board_announcement_use_case.ts b/src/application/usecases/create_board_announcement_use_case.ts
new file mode 100644 (file)
index 0000000..ca0310e
--- /dev/null
@@ -0,0 +1,28 @@
+import { BoardAnnouncement } from "../../domain/entities/board_announcement.ts";
+import { BoardAnnouncementRepository } from "../../domain/repositories/board_announcement_repository.ts";
+import { ApplicationError } from "../errors/application_error.ts";
+import { Clock } from "../contracts/clock.ts";
+import { IdGenerator } from "../contracts/id_generator.ts";
+
+export class CreateBoardAnnouncementUseCase {
+  constructor(
+    private readonly announcementRepository: BoardAnnouncementRepository,
+    private readonly idGenerator: IdGenerator,
+    private readonly clock: Clock,
+  ) {}
+
+  async execute(message: string): Promise<BoardAnnouncement> {
+    const text = message?.trim();
+    if (!text) {
+      throw new ApplicationError("Announcement message is required.");
+    }
+
+    const announcement: BoardAnnouncement = {
+      id: this.idGenerator.generate(),
+      message: text,
+      createdAt: this.clock.now().toISOString(),
+    };
+    await this.announcementRepository.create(announcement);
+    return announcement;
+  }
+}
diff --git a/src/application/usecases/create_board_request_use_case.ts b/src/application/usecases/create_board_request_use_case.ts
new file mode 100644 (file)
index 0000000..5af887c
--- /dev/null
@@ -0,0 +1,86 @@
+import { BoardRequest } from "../../domain/entities/board_request.ts";
+import { BoardRepository } from "../../domain/repositories/board_repository.ts";
+import { BoardRequestRepository } from "../../domain/repositories/board_request_repository.ts";
+import { ApplicationError } from "../errors/application_error.ts";
+import { Clock } from "../contracts/clock.ts";
+import { IdGenerator } from "../contracts/id_generator.ts";
+
+export interface CreateBoardRequestCommand {
+  slug: string;
+  name: string;
+  description: string;
+  themeColor: string;
+  durationDays?: number;
+}
+
+export class CreateBoardRequestUseCase {
+  constructor(
+    private readonly boardRequestRepository: BoardRequestRepository,
+    private readonly boardRepository: BoardRepository,
+    private readonly idGenerator: IdGenerator,
+    private readonly clock: Clock,
+    private readonly defaultDurationDays: number,
+  ) {}
+
+  async execute(command: CreateBoardRequestCommand): Promise<BoardRequest> {
+    const slug = normalizeSlug(command.slug);
+    if (!slug) {
+      throw new ApplicationError("Slug is required.");
+    }
+
+    const name = command.name?.trim();
+    if (!name) {
+      throw new ApplicationError("Name is required.");
+    }
+
+    const themeColor = sanitizeColor(command.themeColor);
+
+    const existingBoard = await this.boardRepository.findBySlug(slug);
+    if (existingBoard) {
+      throw new ApplicationError("A board with that slug already exists.");
+    }
+
+    const existingRequests = await this.boardRequestRepository.list();
+    if (existingRequests.some((request) => request.slug === slug && request.status === "open")) {
+      throw new ApplicationError("A request for that board already exists.");
+    }
+
+    const createdAt = this.clock.now();
+    const durationDays = command.durationDays ?? this.defaultDurationDays;
+    const deadline = new Date(createdAt.getTime() + durationDays * 24 * 60 * 60 * 1000);
+
+    const request: BoardRequest = {
+      id: this.idGenerator.generate(),
+      slug,
+      name,
+      description: command.description?.trim() ?? "",
+      themeColor,
+      voteCount: 0,
+      createdAt: createdAt.toISOString(),
+      deadline: deadline.toISOString(),
+      status: "open",
+    };
+
+    await this.boardRequestRepository.create(request);
+    return request;
+  }
+}
+
+const normalizeSlug = (value: string | undefined): string => {
+  const slug = value?.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/--+/g, "-").replace(
+    /^-|-$/g,
+    "",
+  );
+  return slug ?? "";
+};
+
+const sanitizeColor = (value: string | undefined): string => {
+  const color = value?.trim();
+  if (!color) {
+    return "#0f172a";
+  }
+  if (/^#[0-9a-fA-F]{6}$/.test(color)) {
+    return color.toLowerCase();
+  }
+  throw new ApplicationError("Theme color must be a hex value like #1E40AF.");
+};
diff --git a/src/application/usecases/create_board_request_use_case_test.ts b/src/application/usecases/create_board_request_use_case_test.ts
new file mode 100644 (file)
index 0000000..561226b
--- /dev/null
@@ -0,0 +1,166 @@
+import { CreateBoardRequestUseCase } from "./create_board_request_use_case.ts";
+import {
+  FixedClock,
+  InMemoryBoardRepository,
+  InMemoryBoardRequestRepository,
+  SequenceIdGenerator,
+} from "../../testing/fakes.ts";
+import { assertEquals, assertRejects } from "../../testing/asserts.ts";
+
+const NOW = new Date("2024-02-01T00:00:00.000Z");
+
+Deno.test("CreateBoardRequestUseCase validates inputs", async (t) => {
+  const requestRepository = new InMemoryBoardRequestRepository();
+  const boardRepository = new InMemoryBoardRepository();
+  const idGenerator = new SequenceIdGenerator("request");
+  const clock = new FixedClock(NOW);
+  const useCase = new CreateBoardRequestUseCase(
+    requestRepository,
+    boardRepository,
+    idGenerator,
+    clock,
+    3,
+  );
+
+  await t.step("requires slug", async () => {
+    await assertRejects(
+      () =>
+        useCase.execute({
+          slug: "   ",
+          name: "Test",
+          description: "",
+          themeColor: "#123456",
+        }),
+      Error,
+      "Slug is required.",
+    );
+  });
+
+  await t.step("requires name", async () => {
+    await assertRejects(
+      () =>
+        useCase.execute({
+          slug: "test",
+          name: " ",
+          description: "",
+          themeColor: "#123456",
+        }),
+      Error,
+      "Name is required.",
+    );
+  });
+
+  await t.step("validates theme color", async () => {
+    await assertRejects(
+      () =>
+        useCase.execute({
+          slug: "test",
+          name: "Test",
+          description: "",
+          themeColor: "blue",
+        }),
+      Error,
+      "Theme color must be a hex value like #1E40AF.",
+    );
+  });
+});
+
+Deno.test("CreateBoardRequestUseCase detects conflicts and normalizes slug", async (t) => {
+  const requestRepository = new InMemoryBoardRequestRepository();
+  const boardRepository = new InMemoryBoardRepository();
+  const idGenerator = new SequenceIdGenerator("request");
+  const clock = new FixedClock(NOW);
+  const useCase = new CreateBoardRequestUseCase(
+    requestRepository,
+    boardRepository,
+    idGenerator,
+    clock,
+    5,
+  );
+
+  await boardRepository.create({
+    id: "board-1",
+    slug: "general",
+    name: "General",
+    description: "",
+    themeColor: "#0f172a",
+    createdAt: NOW.toISOString(),
+    status: "active",
+  });
+
+  await t.step("rejects existing board slug", async () => {
+    await assertRejects(
+      () =>
+        useCase.execute({
+          slug: "General  ",
+          name: "General 2",
+          description: "",
+          themeColor: "#123456",
+        }),
+      Error,
+      "A board with that slug already exists.",
+    );
+  });
+
+  await requestRepository.create({
+    id: "request-existing",
+    slug: "tech",
+    name: "Technology",
+    description: "",
+    themeColor: "#123456",
+    voteCount: 0,
+    createdAt: NOW.toISOString(),
+    deadline: NOW.toISOString(),
+    status: "open",
+  });
+
+  await t.step("rejects existing open request", async () => {
+    await assertRejects(
+      () =>
+        useCase.execute({
+          slug: "tech ",
+          name: "Technology 2",
+          description: "",
+          themeColor: "#abcdef",
+        }),
+      Error,
+      "A request for that board already exists.",
+    );
+  });
+});
+
+Deno.test("CreateBoardRequestUseCase creates request with defaults", async () => {
+  const requestRepository = new InMemoryBoardRequestRepository();
+  const boardRepository = new InMemoryBoardRepository();
+  const idGenerator = new SequenceIdGenerator("request");
+  const clock = new FixedClock(NOW);
+  const useCase = new CreateBoardRequestUseCase(
+    requestRepository,
+    boardRepository,
+    idGenerator,
+    clock,
+    7,
+  );
+
+  const request = await useCase.execute({
+    slug: "  New Board! ",
+    name: "  New Board ",
+    description: "  Something fun ",
+    themeColor: "   ",
+  });
+
+  assertEquals(request.id, "request-1");
+  assertEquals(request.slug, "new-board");
+  assertEquals(request.name, "New Board");
+  assertEquals(request.description, "Something fun");
+  assertEquals(request.themeColor, "#0f172a");
+  assertEquals(request.voteCount, 0);
+  assertEquals(request.status, "open");
+  assertEquals(request.createdAt, NOW.toISOString());
+
+  const expectedDeadline = new Date(NOW.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString();
+  assertEquals(request.deadline, expectedDeadline);
+
+  const stored = await requestRepository.findById("request-1");
+  assertEquals(stored, request);
+});
diff --git a/src/application/usecases/create_board_use_case.ts b/src/application/usecases/create_board_use_case.ts
new file mode 100644 (file)
index 0000000..45bf0f0
--- /dev/null
@@ -0,0 +1,69 @@
+import { Board } from "../../domain/entities/board.ts";
+import { BoardRepository } from "../../domain/repositories/board_repository.ts";
+import { ApplicationError } from "../errors/application_error.ts";
+import { Clock } from "../contracts/clock.ts";
+import { IdGenerator } from "../contracts/id_generator.ts";
+
+export interface CreateBoardCommand {
+  slug: string;
+  name: string;
+  description: string;
+  themeColor: string;
+}
+
+export class CreateBoardUseCase {
+  constructor(
+    private readonly boardRepository: BoardRepository,
+    private readonly idGenerator: IdGenerator,
+    private readonly clock: Clock,
+  ) {}
+
+  async execute(command: CreateBoardCommand): Promise<Board> {
+    const slug = normalizeSlug(command.slug);
+    if (!slug) {
+      throw new ApplicationError("Slug is required.");
+    }
+
+    const name = command.name?.trim();
+    if (!name) {
+      throw new ApplicationError("Name is required.");
+    }
+
+    const existing = await this.boardRepository.findBySlug(slug);
+    if (existing) {
+      throw new ApplicationError("Board slug already exists.");
+    }
+
+    const board: Board = {
+      id: this.idGenerator.generate(),
+      slug,
+      name,
+      description: command.description?.trim() ?? "",
+      themeColor: sanitizeColor(command.themeColor),
+      createdAt: this.clock.now().toISOString(),
+      status: "active",
+    };
+
+    await this.boardRepository.create(board);
+    return board;
+  }
+}
+
+const normalizeSlug = (value: string | undefined): string => {
+  const slug = value?.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/--+/g, "-").replace(
+    /^-|-$/g,
+    "",
+  );
+  return slug ?? "";
+};
+
+const sanitizeColor = (value: string | undefined): string => {
+  const color = value?.trim();
+  if (!color) {
+    return "#0f172a";
+  }
+  if (/^#[0-9a-fA-F]{6}$/.test(color)) {
+    return color.toLowerCase();
+  }
+  throw new ApplicationError("Theme color must be a hex value like #1E40AF.");
+};
diff --git a/src/application/usecases/create_comment_use_case.ts b/src/application/usecases/create_comment_use_case.ts
new file mode 100644 (file)
index 0000000..57e7caf
--- /dev/null
@@ -0,0 +1,120 @@
+import { Comment } from "../../domain/entities/comment.ts";
+import { CommentRepository } from "../../domain/repositories/comment_repository.ts";
+import { PostRepository } from "../../domain/repositories/post_repository.ts";
+import { ImageStorage } from "../../domain/services/image_storage.ts";
+import { ApplicationError } from "../errors/application_error.ts";
+import { Clock } from "../contracts/clock.ts";
+import { IdGenerator } from "../contracts/id_generator.ts";
+import { UserRepository } from "../../domain/repositories/user_repository.ts";
+
+export interface CreateCommentCommand {
+  postId: string;
+  author: string;
+  body: string;
+  parentCommentId?: string;
+  authorUserId?: string;
+  authorUsername?: string;
+  image?: {
+    data: Uint8Array;
+    mimeType: string;
+    originalName: string;
+  };
+}
+
+export class CreateCommentUseCase {
+  constructor(
+    private readonly postRepository: PostRepository,
+    private readonly commentRepository: CommentRepository,
+    private readonly imageStorage: ImageStorage,
+    private readonly idGenerator: IdGenerator,
+    private readonly clock: Clock,
+    private readonly userRepository: UserRepository,
+  ) {}
+
+  async execute(command: CreateCommentCommand): Promise<Comment> {
+    const post = await this.postRepository.findById(command.postId);
+    if (!post) {
+      throw new ApplicationError("Post not found.", 404);
+    }
+
+    const status = post.status ?? "active";
+    const readOnly = post.readOnly ?? false;
+    if (status !== "active" || readOnly) {
+      throw new ApplicationError("Thread is read-only.", 403);
+    }
+
+    if (!command.body?.trim()) {
+      throw new ApplicationError("Comment body is required.");
+    }
+
+    if (command.parentCommentId) {
+      const parent = await this.commentRepository.findById(
+        command.postId,
+        command.parentCommentId,
+      );
+      if (!parent) {
+        throw new ApplicationError("Parent comment not found.", 404);
+      }
+    }
+
+    const id = this.idGenerator.generate();
+    let imagePath: string | undefined;
+
+    if (command.image) {
+      imagePath = await this.imageStorage.saveImage({
+        id,
+        data: command.image.data,
+        mimeType: command.image.mimeType,
+        originalName: command.image.originalName,
+      });
+    }
+
+    const commentAuthor = await this.resolveAuthor(command);
+
+    const comment: Comment = {
+      id,
+      postId: command.postId,
+      parentCommentId: command.parentCommentId,
+      author: commentAuthor.displayName,
+      authorUserId: commentAuthor.userId ?? undefined,
+      body: command.body.trim(),
+      imagePath,
+      createdAt: this.clock.now().toISOString(),
+    };
+
+    await this.commentRepository.create(comment);
+    const totalComments = await this.commentRepository.countByPost(command.postId);
+    post.commentCount = totalComments;
+    await this.postRepository.update(post);
+    return comment;
+  }
+
+  private async resolveAuthor(command: CreateCommentCommand): Promise<{
+    displayName: string;
+    userId: string | null;
+  }> {
+    if (command.authorUserId) {
+      const user = await this.userRepository.findById(command.authorUserId);
+      if (!user) {
+        throw new ApplicationError("Account not found.", 403);
+      }
+      return {
+        displayName: user.username,
+        userId: user.id,
+      };
+    }
+
+    const provided = command.author?.trim() ?? "";
+    const displayName = provided.length > 0 ? provided : "Anonymous";
+    const reserved = await this.userRepository.isUsernameTaken(displayName);
+    if (reserved) {
+      throw new ApplicationError(
+        "That name is reserved by a registered member. Please choose another.",
+      );
+    }
+    return {
+      displayName,
+      userId: null,
+    };
+  }
+}
diff --git a/src/application/usecases/create_comment_use_case_test.ts b/src/application/usecases/create_comment_use_case_test.ts
new file mode 100644 (file)
index 0000000..d214e54
--- /dev/null
@@ -0,0 +1,253 @@
+import { CreateCommentUseCase } from "./create_comment_use_case.ts";
+import {
+  FixedClock,
+  InMemoryCommentRepository,
+  InMemoryImageStorage,
+  InMemoryPostRepository,
+  InMemoryUserRepository,
+  SequenceIdGenerator,
+} from "../../testing/fakes.ts";
+import { assertEquals, assertRejects } from "../../testing/asserts.ts";
+
+const NOW = new Date("2024-01-01T00:00:00.000Z");
+
+const buildPost = (overrides: Partial<Record<string, unknown>> = {}) => ({
+  id: "post-1",
+  boardId: "board-1",
+  title: "Hello",
+  description: "World",
+  imagePath: "/uploads/images/post-1",
+  createdAt: new Date("2024-01-01T00:00:00.000Z").toISOString(),
+  commentCount: 0,
+  status: "active" as const,
+  readOnly: false,
+  archivedAt: null,
+  ...overrides,
+});
+
+Deno.test("CreateCommentUseCase validates inputs", async (t) => {
+  const postRepository = new InMemoryPostRepository();
+  const commentRepository = new InMemoryCommentRepository();
+  const imageStorage = new InMemoryImageStorage();
+  const idGenerator = new SequenceIdGenerator("comment");
+  const clock = new FixedClock(NOW);
+  const userRepository = new InMemoryUserRepository();
+  const useCase = new CreateCommentUseCase(
+    postRepository,
+    commentRepository,
+    imageStorage,
+    idGenerator,
+    clock,
+    userRepository,
+  );
+
+  await t.step("post must exist", async () => {
+    await assertRejects(
+      () =>
+        useCase.execute({
+          postId: "missing",
+          author: "Anon",
+          body: "Hello",
+        }),
+      Error,
+      "Post not found.",
+    );
+  });
+
+  await postRepository.create(buildPost({ readOnly: true }));
+
+  await t.step("rejects read-only threads", async () => {
+    await assertRejects(
+      () =>
+        useCase.execute({
+          postId: "post-1",
+          author: "Anon",
+          body: "Hello",
+        }),
+      Error,
+      "Thread is read-only.",
+    );
+  });
+
+  await postRepository.update(buildPost({ status: "archived" }));
+
+  await t.step("rejects archived threads", async () => {
+    await assertRejects(
+      () =>
+        useCase.execute({
+          postId: "post-1",
+          author: "Anon",
+          body: "Hello",
+        }),
+      Error,
+      "Thread is read-only.",
+    );
+  });
+});
+
+Deno.test("CreateCommentUseCase validates body and parent comment", async (t) => {
+  const postRepository = new InMemoryPostRepository();
+  await postRepository.create(buildPost());
+
+  const commentRepository = new InMemoryCommentRepository();
+  const imageStorage = new InMemoryImageStorage();
+  const idGenerator = new SequenceIdGenerator("comment");
+  const clock = new FixedClock(NOW);
+  const userRepository = new InMemoryUserRepository();
+  const useCase = new CreateCommentUseCase(
+    postRepository,
+    commentRepository,
+    imageStorage,
+    idGenerator,
+    clock,
+    userRepository,
+  );
+
+  await t.step("requires non-empty body", async () => {
+    await assertRejects(
+      () =>
+        useCase.execute({
+          postId: "post-1",
+          author: "",
+          body: "   ",
+        }),
+      Error,
+      "Comment body is required.",
+    );
+  });
+
+  await t.step("parent comment must exist when provided", async () => {
+    await assertRejects(
+      () =>
+        useCase.execute({
+          postId: "post-1",
+          author: "",
+          body: "Hello",
+          parentCommentId: "missing",
+        }),
+      Error,
+      "Parent comment not found.",
+    );
+  });
+});
+
+Deno.test("CreateCommentUseCase saves comment, increments counter, and stores image", async () => {
+  const postRepository = new InMemoryPostRepository();
+  await postRepository.create(buildPost());
+
+  const commentRepository = new InMemoryCommentRepository();
+  const imageStorage = new InMemoryImageStorage();
+  const idGenerator = new SequenceIdGenerator("comment");
+  const clock = new FixedClock(NOW);
+  const userRepository = new InMemoryUserRepository();
+  const useCase = new CreateCommentUseCase(
+    postRepository,
+    commentRepository,
+    imageStorage,
+    idGenerator,
+    clock,
+    userRepository,
+  );
+
+  const comment = await useCase.execute({
+    postId: "post-1",
+    author: "  Alice  ",
+    body: "  Nice thread ",
+    image: {
+      data: new Uint8Array([5, 6, 7]),
+      mimeType: "image/png",
+      originalName: "reply.png",
+    },
+  });
+
+  assertEquals(comment.id, "comment-1");
+  assertEquals(comment.author, "Alice");
+  assertEquals(comment.body, "Nice thread");
+  assertEquals(comment.imagePath, "/uploads/images/comment-1");
+  assertEquals(comment.createdAt, NOW.toISOString());
+
+  const post = await postRepository.findById("post-1");
+  assertEquals(post?.commentCount, 1);
+
+  const stored = await commentRepository.findById("post-1", "comment-1");
+  assertEquals(stored, comment);
+
+  const storedImage = await imageStorage.getImage("/uploads/images/comment-1");
+  assertEquals(Array.from(storedImage), [5, 6, 7]);
+});
+
+Deno.test("CreateCommentUseCase prevents using a claimed username", async () => {
+  const postRepository = new InMemoryPostRepository();
+  await postRepository.create(buildPost());
+
+  const commentRepository = new InMemoryCommentRepository();
+  const imageStorage = new InMemoryImageStorage();
+  const idGenerator = new SequenceIdGenerator("comment");
+  const clock = new FixedClock(NOW);
+  const userRepository = new InMemoryUserRepository();
+
+  await userRepository.create({
+    id: "user-1",
+    username: "Alice",
+    passwordHash: "hash",
+    createdAt: NOW.toISOString(),
+  });
+
+  const useCase = new CreateCommentUseCase(
+    postRepository,
+    commentRepository,
+    imageStorage,
+    idGenerator,
+    clock,
+    userRepository,
+  );
+
+  await assertRejects(
+    () =>
+      useCase.execute({
+        postId: "post-1",
+        author: "Alice",
+        body: "Hi",
+      }),
+    Error,
+    "That name is reserved by a registered member. Please choose another.",
+  );
+});
+
+Deno.test("CreateCommentUseCase uses member identity when provided", async () => {
+  const postRepository = new InMemoryPostRepository();
+  await postRepository.create(buildPost());
+
+  const commentRepository = new InMemoryCommentRepository();
+  const imageStorage = new InMemoryImageStorage();
+  const idGenerator = new SequenceIdGenerator("comment");
+  const clock = new FixedClock(NOW);
+  const userRepository = new InMemoryUserRepository();
+
+  await userRepository.create({
+    id: "user-1",
+    username: "Alice",
+    passwordHash: "hash",
+    createdAt: NOW.toISOString(),
+  });
+
+  const useCase = new CreateCommentUseCase(
+    postRepository,
+    commentRepository,
+    imageStorage,
+    idGenerator,
+    clock,
+    userRepository,
+  );
+
+  const comment = await useCase.execute({
+    postId: "post-1",
+    author: "Someone Else",
+    body: "Hi",
+    authorUserId: "user-1",
+    authorUsername: "Alice",
+  });
+
+  assertEquals(comment.author, "Alice");
+  assertEquals(comment.authorUserId, "user-1");
+});
diff --git a/src/application/usecases/create_post_use_case.ts b/src/application/usecases/create_post_use_case.ts
new file mode 100644 (file)
index 0000000..2b27e1e
--- /dev/null
@@ -0,0 +1,76 @@
+import { ImagePost } from "../../domain/entities/image_post.ts";
+import { PostRepository } from "../../domain/repositories/post_repository.ts";
+import { ImageStorage } from "../../domain/services/image_storage.ts";
+import { ApplicationError } from "../errors/application_error.ts";
+import { Clock } from "../contracts/clock.ts";
+import { IdGenerator } from "../contracts/id_generator.ts";
+
+export interface CreatePostCommand {
+  boardId: string;
+  description: string;
+  title?: string;
+  image?: {
+    data: Uint8Array;
+    mimeType: string;
+    originalName: string;
+  };
+  permanent?: boolean;
+}
+
+export class CreatePostUseCase {
+  constructor(
+    private readonly postRepository: PostRepository,
+    private readonly imageStorage: ImageStorage,
+    private readonly idGenerator: IdGenerator,
+    private readonly clock: Clock,
+  ) {}
+
+  async execute(command: CreatePostCommand): Promise<ImagePost> {
+    if (!command.boardId?.trim()) {
+      throw new ApplicationError("Board is required.");
+    }
+
+    const description = command.description?.trim();
+    if (!description) {
+      throw new ApplicationError("Post body is required.");
+    }
+
+    const id = this.idGenerator.generate();
+    const createdAt = this.clock.now().toISOString();
+
+    let imagePath: string | undefined;
+    if (command.image) {
+      imagePath = await this.imageStorage.saveImage({
+        id,
+        data: command.image.data,
+        mimeType: command.image.mimeType,
+        originalName: command.image.originalName,
+      });
+    }
+
+    const title = command.title?.trim();
+
+    const post: ImagePost = {
+      id,
+      boardId: command.boardId.trim(),
+      description,
+      createdAt,
+      commentCount: 0,
+      status: "active",
+      readOnly: false,
+      archivedAt: null,
+    };
+    if (title && title.length > 0) {
+      post.title = title;
+    }
+    if (imagePath) {
+      post.imagePath = imagePath;
+    }
+    if (command.permanent === true) {
+      post.permanent = true;
+    }
+
+    await this.postRepository.create(post);
+    return post;
+  }
+}
diff --git a/src/application/usecases/create_post_use_case_test.ts b/src/application/usecases/create_post_use_case_test.ts
new file mode 100644 (file)
index 0000000..953b875
--- /dev/null
@@ -0,0 +1,114 @@
+import { CreatePostUseCase } from "./create_post_use_case.ts";
+import type { CreatePostCommand } from "./create_post_use_case.ts";
+import { FixedClock, InMemoryPostRepository, SequenceIdGenerator } from "../../testing/fakes.ts";
+import { ImageStorage, SaveImageInput } from "../../domain/services/image_storage.ts";
+import { assertEquals, assertRejects, assertStrictEquals } from "../../testing/asserts.ts";
+
+class RecordingImageStorage implements ImageStorage {
+  saved: SaveImageInput | null = null;
+
+  saveImage(input: SaveImageInput): Promise<string> {
+    this.saved = { ...input };
+    return Promise.resolve(`/uploads/images/${input.id}`);
+  }
+
+  getImage(_path: string): Promise<Uint8Array> {
+    return Promise.reject(new Error("not implemented"));
+  }
+
+  deleteImage(_path: string): Promise<void> {
+    return Promise.resolve();
+  }
+}
+
+const NOW = new Date("2024-01-01T12:00:00.000Z");
+
+Deno.test("CreatePostUseCase validates required fields", async (t) => {
+  const postRepository = new InMemoryPostRepository();
+  const imageStorage = new RecordingImageStorage();
+  const idGenerator = new SequenceIdGenerator("post");
+  const clock = new FixedClock(NOW);
+  const useCase = new CreatePostUseCase(postRepository, imageStorage, idGenerator, clock);
+
+  await t.step("missing board id", async () => {
+    await assertRejects(
+      () =>
+        useCase.execute({
+          boardId: "   ",
+          title: "Test",
+          description: "",
+          image: {
+            data: new Uint8Array([1]),
+            mimeType: "image/png",
+            originalName: "test.png",
+          },
+        }),
+      Error,
+      "Board is required.",
+    );
+  });
+
+  await t.step("missing body", async () => {
+    await assertRejects(
+      () =>
+        useCase.execute({
+          boardId: "board-1",
+          description: "   ",
+        } as CreatePostCommand),
+      Error,
+      "Post body is required.",
+    );
+  });
+});
+
+Deno.test("CreatePostUseCase persists trimmed post data", async () => {
+  const postRepository = new InMemoryPostRepository();
+  const imageStorage = new RecordingImageStorage();
+  const idGenerator = new SequenceIdGenerator("post");
+  const clock = new FixedClock(NOW);
+  const useCase = new CreatePostUseCase(postRepository, imageStorage, idGenerator, clock);
+
+  const result = await useCase.execute({
+    boardId: "  board-1  ",
+    description: "  First post ",
+    image: {
+      data: new Uint8Array([1, 2, 3]),
+      mimeType: "image/jpeg",
+      originalName: "photo.jpg",
+    },
+  });
+
+  assertEquals(result.id, "post-1");
+  assertEquals(result.boardId, "board-1");
+  assertEquals(result.description, "First post");
+  assertEquals(result.commentCount, 0);
+  assertEquals(result.status, "active");
+  assertStrictEquals(imageStorage.saved?.id, "post-1");
+  assertEquals(imageStorage.saved?.mimeType, "image/jpeg");
+  assertEquals(imageStorage.saved?.originalName, "photo.jpg");
+  assertEquals(result.createdAt, NOW.toISOString());
+
+  const stored = await postRepository.findById("post-1");
+  assertEquals(stored, result);
+});
+
+Deno.test("CreatePostUseCase allows missing image and title", async () => {
+  const postRepository = new InMemoryPostRepository();
+  const imageStorage = new RecordingImageStorage();
+  const idGenerator = new SequenceIdGenerator("post");
+  const clock = new FixedClock(NOW);
+  const useCase = new CreatePostUseCase(postRepository, imageStorage, idGenerator, clock);
+
+  const result = await useCase.execute({
+    boardId: "members",
+    description: "Announcement without image",
+    permanent: true,
+  });
+
+  assertEquals(result.boardId, "members");
+  assertEquals(result.title, undefined);
+  assertEquals(result.imagePath, undefined);
+  assertEquals(result.description, "Announcement without image");
+  assertEquals(result.permanent, true);
+  assertEquals(imageStorage.saved, null);
+});
diff --git a/src/application/usecases/delete_board_announcement_use_case.ts b/src/application/usecases/delete_board_announcement_use_case.ts
new file mode 100644 (file)
index 0000000..c1a20a9
--- /dev/null
@@ -0,0 +1,9 @@
+import { BoardAnnouncementRepository } from "../../domain/repositories/board_announcement_repository.ts";
+
+export class DeleteBoardAnnouncementUseCase {
+  constructor(private readonly announcementRepository: BoardAnnouncementRepository) {}
+
+  async execute(id: string): Promise<void> {
+    await this.announcementRepository.delete(id);
+  }
+}
diff --git a/src/application/usecases/get_board_by_slug_use_case.ts b/src/application/usecases/get_board_by_slug_use_case.ts
new file mode 100644 (file)
index 0000000..db3e711
--- /dev/null
@@ -0,0 +1,15 @@
+import { Board } from "../../domain/entities/board.ts";
+import { BoardRepository } from "../../domain/repositories/board_repository.ts";
+import { ApplicationError } from "../errors/application_error.ts";
+
+export class GetBoardBySlugUseCase {
+  constructor(private readonly boardRepository: BoardRepository) {}
+
+  async execute(slug: string): Promise<Board> {
+    const board = await this.boardRepository.findBySlug(slug);
+    if (!board) {
+      throw new ApplicationError("Board not found.", 404);
+    }
+    return board;
+  }
+}
diff --git a/src/application/usecases/get_board_requests_overview_use_case.ts b/src/application/usecases/get_board_requests_overview_use_case.ts
new file mode 100644 (file)
index 0000000..b26d06e
--- /dev/null
@@ -0,0 +1,29 @@
+import { BoardRequest } from "../../domain/entities/board_request.ts";
+import { BoardRequestRepository } from "../../domain/repositories/board_request_repository.ts";
+import { BoardRequestVoteRepository } from "../../domain/repositories/board_request_vote_repository.ts";
+
+export interface BoardRequestOverview extends BoardRequest {
+  voted: boolean;
+}
+
+export class GetBoardRequestsOverviewUseCase {
+  constructor(
+    private readonly requestRepository: BoardRequestRepository,
+    private readonly voteRepository: BoardRequestVoteRepository,
+  ) {}
+
+  async execute(userId: string): Promise<BoardRequestOverview[]> {
+    const requests = await this.requestRepository.list();
+    const results: BoardRequestOverview[] = [];
+    for (const request of requests) {
+      const voted = await this.voteRepository.hasVote(request.id, userId);
+      const voteCount = await this.voteRepository.countVotes(request.id);
+      if (voteCount !== request.voteCount) {
+        request.voteCount = voteCount;
+        await this.requestRepository.update(request);
+      }
+      results.push({ ...request, voteCount, voted });
+    }
+    return results.sort((a, b) => a.deadline.localeCompare(b.deadline));
+  }
+}
diff --git a/src/application/usecases/get_board_requests_overview_use_case_test.ts b/src/application/usecases/get_board_requests_overview_use_case_test.ts
new file mode 100644 (file)
index 0000000..998cce5
--- /dev/null
@@ -0,0 +1,54 @@
+import { GetBoardRequestsOverviewUseCase } from "./get_board_requests_overview_use_case.ts";
+import {
+  InMemoryBoardRequestRepository,
+  InMemoryBoardRequestVoteRepository,
+} from "../../testing/fakes.ts";
+import { assertEquals } from "../../testing/asserts.ts";
+
+Deno.test("GetBoardRequestsOverviewUseCase merges vote counts and flags voted requests", async () => {
+  const requestRepository = new InMemoryBoardRequestRepository();
+  const voteRepository = new InMemoryBoardRequestVoteRepository();
+  const useCase = new GetBoardRequestsOverviewUseCase(requestRepository, voteRepository);
+
+  await requestRepository.create({
+    id: "req-1",
+    slug: "alpha",
+    name: "Alpha",
+    description: "",
+    themeColor: "#111111",
+    voteCount: 5,
+    createdAt: "2024-01-01T00:00:00.000Z",
+    deadline: "2024-01-10T00:00:00.000Z",
+    status: "open",
+  });
+
+  await requestRepository.create({
+    id: "req-2",
+    slug: "beta",
+    name: "Beta",
+    description: "",
+    themeColor: "#222222",
+    voteCount: 0,
+    createdAt: "2024-01-02T00:00:00.000Z",
+    deadline: "2024-01-05T00:00:00.000Z",
+    status: "open",
+  });
+
+  await voteRepository.addVote("req-1", "user-1");
+  await voteRepository.addVote("req-1", "user-2");
+  await voteRepository.addVote("req-2", "user-1");
+
+  const overview = await useCase.execute("user-1");
+  assertEquals(overview.length, 2);
+  // Sorted by deadline ascending, so req-2 first
+  assertEquals(overview[0].id, "req-2");
+  assertEquals(overview[0].voteCount, 1);
+  assertEquals(overview[0].voted, true);
+
+  assertEquals(overview[1].id, "req-1");
+  assertEquals(overview[1].voteCount, 2);
+  assertEquals(overview[1].voted, true);
+
+  const stored = await requestRepository.findById("req-1");
+  assertEquals(stored?.voteCount, 2);
+});
diff --git a/src/application/usecases/get_post_use_case.ts b/src/application/usecases/get_post_use_case.ts
new file mode 100644 (file)
index 0000000..ce3dcf9
--- /dev/null
@@ -0,0 +1,16 @@
+import { ImagePost } from "../../domain/entities/image_post.ts";
+import { PostRepository } from "../../domain/repositories/post_repository.ts";
+import { ApplicationError } from "../errors/application_error.ts";
+
+export class GetPostUseCase {
+  constructor(private readonly postRepository: PostRepository) {}
+
+  async execute(id: string): Promise<ImagePost> {
+    const post = await this.postRepository.findById(id);
+    if (!post) {
+      throw new ApplicationError("Post not found.", 404);
+    }
+
+    return post;
+  }
+}
diff --git a/src/application/usecases/list_archived_posts_use_case.ts b/src/application/usecases/list_archived_posts_use_case.ts
new file mode 100644 (file)
index 0000000..52acb03
--- /dev/null
@@ -0,0 +1,19 @@
+import { ImagePost } from "../../domain/entities/image_post.ts";
+import { PostRepository } from "../../domain/repositories/post_repository.ts";
+
+export class ListArchivedPostsUseCase {
+  constructor(private readonly postRepository: PostRepository) {}
+
+  async execute(boardId: string): Promise<ImagePost[]> {
+    const posts = await this.postRepository.listByBoard(boardId);
+    return posts
+      .map((post) => ({
+        ...post,
+        status: post.status ?? "active",
+        readOnly: post.readOnly ?? false,
+        permanent: post.permanent ?? false,
+      }))
+      .filter((post) => post.status === "archived")
+      .sort((a, b) => (b.archivedAt ?? b.createdAt).localeCompare(a.archivedAt ?? a.createdAt));
+  }
+}
diff --git a/src/application/usecases/list_board_announcements_use_case.ts b/src/application/usecases/list_board_announcements_use_case.ts
new file mode 100644 (file)
index 0000000..c5288c2
--- /dev/null
@@ -0,0 +1,11 @@
+import { BoardAnnouncement } from "../../domain/entities/board_announcement.ts";
+import { BoardAnnouncementRepository } from "../../domain/repositories/board_announcement_repository.ts";
+
+export class ListBoardAnnouncementsUseCase {
+  constructor(private readonly announcementRepository: BoardAnnouncementRepository) {}
+
+  async execute(limit = 10): Promise<BoardAnnouncement[]> {
+    const announcements = await this.announcementRepository.list();
+    return announcements.slice(0, limit);
+  }
+}
diff --git a/src/application/usecases/list_board_requests_use_case.ts b/src/application/usecases/list_board_requests_use_case.ts
new file mode 100644 (file)
index 0000000..7dd459b
--- /dev/null
@@ -0,0 +1,11 @@
+import { BoardRequest } from "../../domain/entities/board_request.ts";
+import { BoardRequestRepository } from "../../domain/repositories/board_request_repository.ts";
+
+export class ListBoardRequestsUseCase {
+  constructor(private readonly boardRequestRepository: BoardRequestRepository) {}
+
+  async execute(): Promise<BoardRequest[]> {
+    const requests = await this.boardRequestRepository.list();
+    return requests.sort((a, b) => a.deadline.localeCompare(b.deadline));
+  }
+}
diff --git a/src/application/usecases/list_boards_use_case.ts b/src/application/usecases/list_boards_use_case.ts
new file mode 100644 (file)
index 0000000..d03dd49
--- /dev/null
@@ -0,0 +1,11 @@
+import { Board } from "../../domain/entities/board.ts";
+import { BoardRepository } from "../../domain/repositories/board_repository.ts";
+
+export class ListBoardsUseCase {
+  constructor(private readonly boardRepository: BoardRepository) {}
+
+  async execute(): Promise<Board[]> {
+    const boards = await this.boardRepository.list();
+    return boards.sort((a, b) => a.slug.localeCompare(b.slug));
+  }
+}
diff --git a/src/application/usecases/list_comments_use_case.ts b/src/application/usecases/list_comments_use_case.ts
new file mode 100644 (file)
index 0000000..460fb1f
--- /dev/null
@@ -0,0 +1,11 @@
+import { Comment } from "../../domain/entities/comment.ts";
+import { CommentRepository } from "../../domain/repositories/comment_repository.ts";
+
+export class ListCommentsUseCase {
+  constructor(private readonly commentRepository: CommentRepository) {}
+
+  async execute(postId: string): Promise<Comment[]> {
+    const comments = await this.commentRepository.listByPost(postId);
+    return comments.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
+  }
+}
diff --git a/src/application/usecases/list_popular_posts_use_case.ts b/src/application/usecases/list_popular_posts_use_case.ts
new file mode 100644 (file)
index 0000000..16e68d0
--- /dev/null
@@ -0,0 +1,44 @@
+import { BoardRepository } from "../../domain/repositories/board_repository.ts";
+import { PostRepository } from "../../domain/repositories/post_repository.ts";
+import { ImagePost } from "../../domain/entities/image_post.ts";
+
+export interface PopularPost {
+  post: ImagePost;
+  boardSlug: string;
+  boardName: string;
+}
+
+export class ListPopularPostsUseCase {
+  constructor(
+    private readonly postRepository: PostRepository,
+    private readonly boardRepository: BoardRepository,
+  ) {}
+
+  async execute(limit = 10): Promise<PopularPost[]> {
+    const posts = await this.postRepository.listAll();
+    const sorted = posts
+      .map((post) => ({
+        ...post,
+        status: post.status ?? "active",
+        permanent: post.permanent ?? false,
+      }))
+      .filter((post) => post.status === "active")
+      .sort((a, b) => b.commentCount - a.commentCount || b.createdAt.localeCompare(a.createdAt))
+      .slice(0, limit);
+
+    const results: PopularPost[] = [];
+    for (const post of sorted) {
+      const board = await this.boardRepository.findById(post.boardId);
+      if (!board) {
+        continue;
+      }
+      results.push({
+        post,
+        boardSlug: board.slug,
+        boardName: board.name,
+      });
+    }
+
+    return results;
+  }
+}
diff --git a/src/application/usecases/list_popular_posts_use_case_test.ts b/src/application/usecases/list_popular_posts_use_case_test.ts
new file mode 100644 (file)
index 0000000..417bca3
--- /dev/null
@@ -0,0 +1,75 @@
+import { ListPopularPostsUseCase } from "./list_popular_posts_use_case.ts";
+import { InMemoryBoardRepository, InMemoryPostRepository } from "../../testing/fakes.ts";
+import { assertEquals } from "../../testing/asserts.ts";
+
+Deno.test("ListPopularPostsUseCase sorts by comment count and excludes missing boards", async () => {
+  const postRepository = new InMemoryPostRepository();
+  const boardRepository = new InMemoryBoardRepository();
+  const useCase = new ListPopularPostsUseCase(postRepository, boardRepository);
+
+  await postRepository.create({
+    id: "post-a",
+    boardId: "board-a",
+    title: "A",
+    description: "",
+    imagePath: "",
+    createdAt: "2024-01-01T00:00:00.000Z",
+    commentCount: 10,
+    status: "active",
+    readOnly: false,
+    archivedAt: null,
+  });
+
+  await postRepository.create({
+    id: "post-b",
+    boardId: "board-b",
+    title: "B",
+    description: "",
+    imagePath: "",
+    createdAt: "2024-01-02T00:00:00.000Z",
+    commentCount: 5,
+    status: "active",
+    readOnly: false,
+    archivedAt: null,
+  });
+
+  await postRepository.create({
+    id: "post-c",
+    boardId: "board-c",
+    title: "C",
+    description: "",
+    imagePath: "",
+    createdAt: "2024-01-03T00:00:00.000Z",
+    commentCount: 20,
+    status: "archived",
+    readOnly: true,
+    archivedAt: "2024-01-04T00:00:00.000Z",
+  });
+
+  await boardRepository.create({
+    id: "board-a",
+    slug: "a",
+    name: "Board A",
+    description: "",
+    themeColor: "#111111",
+    createdAt: "2024-01-01T00:00:00.000Z",
+    status: "active",
+  });
+
+  await boardRepository.create({
+    id: "board-b",
+    slug: "b",
+    name: "Board B",
+    description: "",
+    themeColor: "#111111",
+    createdAt: "2024-01-01T00:00:00.000Z",
+    status: "active",
+  });
+
+  const popular = await useCase.execute(5);
+  assertEquals(popular.length, 2);
+  assertEquals(popular[0].post.id, "post-a");
+  assertEquals(popular[0].boardSlug, "a");
+  assertEquals(popular[0].boardName, "Board A");
+  assertEquals(popular[1].post.id, "post-b");
+});
diff --git a/src/application/usecases/list_posts_use_case.ts b/src/application/usecases/list_posts_use_case.ts
new file mode 100644 (file)
index 0000000..b9e4d9b
--- /dev/null
@@ -0,0 +1,19 @@
+import { ImagePost } from "../../domain/entities/image_post.ts";
+import { PostRepository } from "../../domain/repositories/post_repository.ts";
+
+export class ListPostsUseCase {
+  constructor(private readonly postRepository: PostRepository) {}
+
+  async execute(boardId: string): Promise<ImagePost[]> {
+    const posts = await this.postRepository.listByBoard(boardId);
+    return posts
+      .map((post) => ({
+        ...post,
+        status: post.status ?? "active",
+        readOnly: post.readOnly ?? false,
+        permanent: post.permanent ?? false,
+      }))
+      .filter((post) => post.status === "active")
+      .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
+  }
+}
diff --git a/src/application/usecases/register_user_use_case.ts b/src/application/usecases/register_user_use_case.ts
new file mode 100644 (file)
index 0000000..50527c1
--- /dev/null
@@ -0,0 +1,52 @@
+import { ApplicationError } from "../errors/application_error.ts";
+import { Clock } from "../contracts/clock.ts";
+import { IdGenerator } from "../contracts/id_generator.ts";
+import { UserRepository } from "../../domain/repositories/user_repository.ts";
+import { User } from "../../domain/entities/user.ts";
+import { hashPassword } from "../utils/password_hash.ts";
+
+export interface RegisterUserCommand {
+  username: string;
+  password: string;
+}
+
+const USERNAME_PATTERN = /^[a-zA-Z0-9_]{3,20}$/;
+
+export class RegisterUserUseCase {
+  constructor(
+    private readonly userRepository: UserRepository,
+    private readonly idGenerator: IdGenerator,
+    private readonly clock: Clock,
+  ) {}
+
+  async execute(command: RegisterUserCommand): Promise<User> {
+    const username = command.username?.trim();
+    const password = command.password ?? "";
+
+    if (!username || !USERNAME_PATTERN.test(username)) {
+      throw new ApplicationError(
+        "Username must be 3-20 characters and use letters, numbers, or underscores.",
+      );
+    }
+
+    if (password.length < 4) {
+      throw new ApplicationError("Password must be at least 4 characters long.");
+    }
+
+    const usernameTaken = await this.userRepository.isUsernameTaken(username);
+    if (usernameTaken) {
+      throw new ApplicationError("That username is already taken.");
+    }
+
+    const passwordHash = await hashPassword(password);
+    const user: User = {
+      id: this.idGenerator.generate(),
+      username,
+      passwordHash,
+      createdAt: this.clock.now().toISOString(),
+    };
+
+    await this.userRepository.create(user);
+    return user;
+  }
+}
diff --git a/src/application/usecases/toggle_board_request_vote_use_case.ts b/src/application/usecases/toggle_board_request_vote_use_case.ts
new file mode 100644 (file)
index 0000000..d1782ad
--- /dev/null
@@ -0,0 +1,40 @@
+import { BoardRequestRepository } from "../../domain/repositories/board_request_repository.ts";
+import { BoardRequestVoteRepository } from "../../domain/repositories/board_request_vote_repository.ts";
+import { ApplicationError } from "../errors/application_error.ts";
+
+export interface ToggleBoardRequestVoteCommand {
+  requestId: string;
+  userId: string;
+}
+
+export class ToggleBoardRequestVoteUseCase {
+  constructor(
+    private readonly requestRepository: BoardRequestRepository,
+    private readonly voteRepository: BoardRequestVoteRepository,
+  ) {}
+
+  async execute(
+    command: ToggleBoardRequestVoteCommand,
+  ): Promise<{ voteCount: number; voted: boolean }> {
+    const request = await this.requestRepository.findById(command.requestId);
+    if (!request) {
+      throw new ApplicationError("Request not found.", 404);
+    }
+
+    if (request.status !== "open") {
+      throw new ApplicationError("Voting closed for this request.");
+    }
+
+    const hasVote = await this.voteRepository.hasVote(command.requestId, command.userId);
+    if (hasVote) {
+      await this.voteRepository.removeVote(command.requestId, command.userId);
+    } else {
+      await this.voteRepository.addVote(command.requestId, command.userId);
+    }
+
+    const voteCount = await this.voteRepository.countVotes(command.requestId);
+    request.voteCount = voteCount;
+    await this.requestRepository.update(request);
+    return { voteCount, voted: !hasVote };
+  }
+}
diff --git a/src/application/usecases/toggle_board_request_vote_use_case_test.ts b/src/application/usecases/toggle_board_request_vote_use_case_test.ts
new file mode 100644 (file)
index 0000000..798d794
--- /dev/null
@@ -0,0 +1,79 @@
+import { ToggleBoardRequestVoteUseCase } from "./toggle_board_request_vote_use_case.ts";
+import {
+  InMemoryBoardRequestRepository,
+  InMemoryBoardRequestVoteRepository,
+} from "../../testing/fakes.ts";
+import { assertEquals, assertRejects } from "../../testing/asserts.ts";
+
+const buildRequest = (overrides: Partial<Record<string, unknown>> = {}) => ({
+  id: "request-1",
+  slug: "tech",
+  name: "Tech",
+  description: "",
+  themeColor: "#123456",
+  voteCount: 0,
+  createdAt: new Date("2024-01-01T00:00:00.000Z").toISOString(),
+  deadline: new Date("2024-01-08T00:00:00.000Z").toISOString(),
+  status: "open" as const,
+  ...overrides,
+});
+
+Deno.test("ToggleBoardRequestVoteUseCase validates request status", async (t) => {
+  const requestRepository = new InMemoryBoardRequestRepository();
+  const voteRepository = new InMemoryBoardRequestVoteRepository();
+  const useCase = new ToggleBoardRequestVoteUseCase(requestRepository, voteRepository);
+
+  await t.step("request must exist", async () => {
+    await assertRejects(
+      () =>
+        useCase.execute({
+          requestId: "missing",
+          userId: "user-1",
+        }),
+      Error,
+      "Request not found.",
+    );
+  });
+
+  await requestRepository.create(buildRequest({ status: "closed" }));
+
+  await t.step("request must be open for voting", async () => {
+    await assertRejects(
+      () =>
+        useCase.execute({
+          requestId: "request-1",
+          userId: "user-1",
+        }),
+      Error,
+      "Voting closed for this request.",
+    );
+  });
+});
+
+Deno.test("ToggleBoardRequestVoteUseCase toggles votes and updates counts", async () => {
+  const requestRepository = new InMemoryBoardRequestRepository();
+  const voteRepository = new InMemoryBoardRequestVoteRepository();
+  const useCase = new ToggleBoardRequestVoteUseCase(requestRepository, voteRepository);
+
+  await requestRepository.create(buildRequest());
+
+  const first = await useCase.execute({
+    requestId: "request-1",
+    userId: "user-1",
+  });
+
+  assertEquals(first, { voteCount: 1, voted: true });
+
+  const storedAfterFirst = await requestRepository.findById("request-1");
+  assertEquals(storedAfterFirst?.voteCount, 1);
+
+  const second = await useCase.execute({
+    requestId: "request-1",
+    userId: "user-1",
+  });
+
+  assertEquals(second, { voteCount: 0, voted: false });
+
+  const storedAfterSecond = await requestRepository.findById("request-1");
+  assertEquals(storedAfterSecond?.voteCount, 0);
+});
diff --git a/src/application/usecases/update_board_request_status_use_case.ts b/src/application/usecases/update_board_request_status_use_case.ts
new file mode 100644 (file)
index 0000000..f4ea7ef
--- /dev/null
@@ -0,0 +1,15 @@
+import { BoardRequestRepository } from "../../domain/repositories/board_request_repository.ts";
+import { ApplicationError } from "../errors/application_error.ts";
+
+export class UpdateBoardRequestStatusUseCase {
+  constructor(private readonly boardRequestRepository: BoardRequestRepository) {}
+
+  async execute(requestId: string, status: "open" | "closed" | "fulfilled"): Promise<void> {
+    const request = await this.boardRequestRepository.findById(requestId);
+    if (!request) {
+      throw new ApplicationError("Request not found.", 404);
+    }
+    request.status = status;
+    await this.boardRequestRepository.update(request);
+  }
+}
diff --git a/src/application/utils/password_hash.ts b/src/application/utils/password_hash.ts
new file mode 100644 (file)
index 0000000..ba6cca7
--- /dev/null
@@ -0,0 +1,25 @@
+const textEncoder = new TextEncoder();
+
+export const hashPassword = async (password: string): Promise<string> => {
+  const data = textEncoder.encode(password);
+  const digest = await crypto.subtle.digest("SHA-256", data);
+  return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join(
+    "",
+  );
+};
+
+export const verifyPassword = async (password: string, hash: string): Promise<boolean> => {
+  const computed = await hashPassword(password);
+  return timingSafeCompare(computed, hash);
+};
+
+const timingSafeCompare = (a: string, b: string): boolean => {
+  if (a.length !== b.length) {
+    return false;
+  }
+  let mismatch = 0;
+  for (let i = 0; i < a.length; i += 1) {
+    mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
+  }
+  return mismatch === 0;
+};
diff --git a/src/bootstrap/app_container.ts b/src/bootstrap/app_container.ts
new file mode 100644 (file)
index 0000000..cd19808
--- /dev/null
@@ -0,0 +1,269 @@
+import { type AppConfig, loadAppConfig } from "../config/app_config.ts";
+import { KvBoardRepository } from "../infrastructure/kv/kv_board_repository.ts";
+import { KvBoardRequestRepository } from "../infrastructure/kv/kv_board_request_repository.ts";
+import { KvBoardRequestVoteRepository } from "../infrastructure/kv/kv_board_request_vote_repository.ts";
+import { KvBoardAnnouncementRepository } from "../infrastructure/kv/kv_board_announcement_repository.ts";
+import { KvPostRepository } from "../infrastructure/kv/kv_post_repository.ts";
+import { KvCommentRepository } from "../infrastructure/kv/kv_comment_repository.ts";
+import { KvUserRepository } from "../infrastructure/kv/kv_user_repository.ts";
+import { KvSessionStore } from "../infrastructure/kv/kv_session_store.ts";
+import { FileSystemImageStorage } from "../infrastructure/storage/file_system_image_storage.ts";
+import { CryptoIdGenerator } from "../infrastructure/system/crypto_id_generator.ts";
+import { SystemClock } from "../infrastructure/system/system_clock.ts";
+import { KvRateLimiter } from "../infrastructure/system/kv_rate_limiter.ts";
+import { CreateBoardUseCase } from "../application/usecases/create_board_use_case.ts";
+import { ListBoardsUseCase } from "../application/usecases/list_boards_use_case.ts";
+import { CreateBoardRequestUseCase } from "../application/usecases/create_board_request_use_case.ts";
+import { ToggleBoardRequestVoteUseCase } from "../application/usecases/toggle_board_request_vote_use_case.ts";
+import { GetBoardBySlugUseCase } from "../application/usecases/get_board_by_slug_use_case.ts";
+import { GetBoardRequestsOverviewUseCase } from "../application/usecases/get_board_requests_overview_use_case.ts";
+import { ListBoardAnnouncementsUseCase } from "../application/usecases/list_board_announcements_use_case.ts";
+import { ListPopularPostsUseCase } from "../application/usecases/list_popular_posts_use_case.ts";
+import { UpdateBoardRequestStatusUseCase } from "../application/usecases/update_board_request_status_use_case.ts";
+import { AutoCreateBoardsFromRequestsUseCase } from "../application/usecases/auto_create_boards_from_requests_use_case.ts";
+import { CreatePostUseCase } from "../application/usecases/create_post_use_case.ts";
+import { ListPostsUseCase } from "../application/usecases/list_posts_use_case.ts";
+import { GetPostUseCase } from "../application/usecases/get_post_use_case.ts";
+import { CreateCommentUseCase } from "../application/usecases/create_comment_use_case.ts";
+import { ListCommentsUseCase } from "../application/usecases/list_comments_use_case.ts";
+import { ArchiveExpiredPostsUseCase } from "../application/usecases/archive_expired_posts_use_case.ts";
+import { ListArchivedPostsUseCase } from "../application/usecases/list_archived_posts_use_case.ts";
+import { ListBoardRequestsUseCase } from "../application/usecases/list_board_requests_use_case.ts";
+import { CreateBoardAnnouncementUseCase } from "../application/usecases/create_board_announcement_use_case.ts";
+import { DeleteBoardAnnouncementUseCase } from "../application/usecases/delete_board_announcement_use_case.ts";
+import { RateLimiter } from "../domain/services/rate_limiter.ts";
+import { SessionStore } from "../domain/services/session_store.ts";
+import { RegisterUserUseCase } from "../application/usecases/register_user_use_case.ts";
+import { AuthenticateUserUseCase } from "../application/usecases/authenticate_user_use_case.ts";
+
+export interface AppContainer {
+  config: AppConfig;
+  kv: Deno.Kv;
+  repositories: {
+    boardRepository: KvBoardRepository;
+    boardRequestRepository: KvBoardRequestRepository;
+    boardRequestVoteRepository: KvBoardRequestVoteRepository;
+    boardAnnouncementRepository: KvBoardAnnouncementRepository;
+    postRepository: KvPostRepository;
+    commentRepository: KvCommentRepository;
+    userRepository: KvUserRepository;
+  };
+  services: {
+    clock: SystemClock;
+    idGenerator: CryptoIdGenerator;
+    imageStorage: FileSystemImageStorage;
+    rateLimiter: RateLimiter;
+    sessionStore: SessionStore;
+  };
+  useCases: {
+    createBoardUseCase: CreateBoardUseCase;
+    listBoardsUseCase: ListBoardsUseCase;
+    createBoardRequestUseCase: CreateBoardRequestUseCase;
+    listBoardRequestsUseCase: ListBoardRequestsUseCase;
+    toggleBoardRequestVoteUseCase: ToggleBoardRequestVoteUseCase;
+    getBoardBySlugUseCase: GetBoardBySlugUseCase;
+    getBoardRequestsOverviewUseCase: GetBoardRequestsOverviewUseCase;
+    listBoardAnnouncementsUseCase: ListBoardAnnouncementsUseCase;
+    createBoardAnnouncementUseCase: CreateBoardAnnouncementUseCase;
+    deleteBoardAnnouncementUseCase: DeleteBoardAnnouncementUseCase;
+    listPopularPostsUseCase: ListPopularPostsUseCase;
+    updateBoardRequestStatusUseCase: UpdateBoardRequestStatusUseCase;
+    autoCreateBoardsUseCase: AutoCreateBoardsFromRequestsUseCase;
+    createPostUseCase: CreatePostUseCase;
+    listPostsUseCase: ListPostsUseCase;
+    getPostUseCase: GetPostUseCase;
+    createCommentUseCase: CreateCommentUseCase;
+    listCommentsUseCase: ListCommentsUseCase;
+    archiveExpiredPostsUseCase: ArchiveExpiredPostsUseCase;
+    listArchivedPostsUseCase: ListArchivedPostsUseCase;
+    registerUserUseCase: RegisterUserUseCase;
+    authenticateUserUseCase: AuthenticateUserUseCase;
+  };
+  close(): void;
+}
+
+export interface CreateAppContainerOptions {
+  configPath?: string;
+  kv?: Deno.Kv;
+}
+
+export const createAppContainer = async (
+  options: CreateAppContainerOptions = {},
+): Promise<AppContainer> => {
+  const config = await loadAppConfig(options.configPath);
+  const kv = options.kv ?? await Deno.openKv();
+  const boardRepository = new KvBoardRepository(kv);
+  const boardRequestRepository = new KvBoardRequestRepository(kv);
+  const boardRequestVoteRepository = new KvBoardRequestVoteRepository(kv);
+  const boardAnnouncementRepository = new KvBoardAnnouncementRepository(kv);
+  const postRepository = new KvPostRepository(kv);
+  const commentRepository = new KvCommentRepository(kv);
+  const userRepository = new KvUserRepository(kv);
+  const sessionStore = new KvSessionStore(kv);
+
+  const clock = new SystemClock();
+  const idGenerator = new CryptoIdGenerator();
+  const imageStorage = new FileSystemImageStorage({
+    root: config.app.storageRoot,
+    publicBasePath: config.app.uploadsBasePath,
+  });
+  const rateLimiter = new KvRateLimiter(kv);
+
+  const createBoardUseCase = new CreateBoardUseCase(boardRepository, idGenerator, clock);
+  const listBoardsUseCase = new ListBoardsUseCase(boardRepository);
+  const updateBoardRequestStatusUseCase = new UpdateBoardRequestStatusUseCase(
+    boardRequestRepository,
+  );
+  const autoCreateBoardsUseCase = new AutoCreateBoardsFromRequestsUseCase(
+    boardRequestRepository,
+    boardRequestVoteRepository,
+    boardRepository,
+    createBoardUseCase,
+    updateBoardRequestStatusUseCase,
+  );
+
+  const createBoardRequestUseCase = new CreateBoardRequestUseCase(
+    boardRequestRepository,
+    boardRepository,
+    idGenerator,
+    clock,
+    config.app.boardRequestWindowDays,
+  );
+
+  const createPostUseCase = new CreatePostUseCase(postRepository, imageStorage, idGenerator, clock);
+  const listPostsUseCase = new ListPostsUseCase(postRepository);
+  const getPostUseCase = new GetPostUseCase(postRepository);
+  const createCommentUseCase = new CreateCommentUseCase(
+    postRepository,
+    commentRepository,
+    imageStorage,
+    idGenerator,
+    clock,
+    userRepository,
+  );
+  const listCommentsUseCase = new ListCommentsUseCase(commentRepository);
+  const archiveExpiredPostsUseCase = new ArchiveExpiredPostsUseCase(
+    postRepository,
+    clock,
+    config.app.threadTtlDays * 24 * 60 * 60 * 1000,
+  );
+  const listArchivedPostsUseCase = new ListArchivedPostsUseCase(postRepository);
+
+  const listBoardRequestsUseCase = new ListBoardRequestsUseCase(boardRequestRepository);
+  const toggleBoardRequestVoteUseCase = new ToggleBoardRequestVoteUseCase(
+    boardRequestRepository,
+    boardRequestVoteRepository,
+  );
+  const getBoardBySlugUseCase = new GetBoardBySlugUseCase(boardRepository);
+  const getBoardRequestsOverviewUseCase = new GetBoardRequestsOverviewUseCase(
+    boardRequestRepository,
+    boardRequestVoteRepository,
+  );
+  const listBoardAnnouncementsUseCase = new ListBoardAnnouncementsUseCase(
+    boardAnnouncementRepository,
+  );
+  const createBoardAnnouncementUseCase = new CreateBoardAnnouncementUseCase(
+    boardAnnouncementRepository,
+    idGenerator,
+    clock,
+  );
+  const deleteBoardAnnouncementUseCase = new DeleteBoardAnnouncementUseCase(
+    boardAnnouncementRepository,
+  );
+  const listPopularPostsUseCase = new ListPopularPostsUseCase(postRepository, boardRepository);
+  const registerUserUseCase = new RegisterUserUseCase(userRepository, idGenerator, clock);
+  const authenticateUserUseCase = new AuthenticateUserUseCase(userRepository);
+
+  const close = () => {
+    if (!options.kv) {
+      kv.close();
+    }
+  };
+
+  return {
+    config,
+    kv,
+    repositories: {
+      boardRepository,
+      boardRequestRepository,
+      boardRequestVoteRepository,
+      boardAnnouncementRepository,
+      postRepository,
+      commentRepository,
+      userRepository,
+    },
+    services: {
+      clock,
+      idGenerator,
+      imageStorage,
+      rateLimiter,
+      sessionStore,
+    },
+    useCases: {
+      createBoardUseCase,
+      listBoardsUseCase,
+      createBoardRequestUseCase,
+      listBoardRequestsUseCase,
+      toggleBoardRequestVoteUseCase,
+      getBoardBySlugUseCase,
+      getBoardRequestsOverviewUseCase,
+      listBoardAnnouncementsUseCase,
+      createBoardAnnouncementUseCase,
+      deleteBoardAnnouncementUseCase,
+      listPopularPostsUseCase,
+      updateBoardRequestStatusUseCase,
+      autoCreateBoardsUseCase,
+      createPostUseCase,
+      listPostsUseCase,
+      getPostUseCase,
+      createCommentUseCase,
+      listCommentsUseCase,
+      archiveExpiredPostsUseCase,
+      listArchivedPostsUseCase,
+      registerUserUseCase,
+      authenticateUserUseCase,
+    },
+    close,
+  };
+};
+
+export const ensureDefaultBoardsExist = async (container: AppContainer) => {
+  const ensureBoard = async (
+    boardConfig: AppConfig["defaultBoard"],
+    label: string,
+  ) => {
+    const existingBoard = await container.repositories.boardRepository.findBySlug(
+      boardConfig.slug,
+    );
+    if (existingBoard) {
+      return existingBoard;
+    }
+    const created = await container.useCases.createBoardUseCase.execute({
+      slug: boardConfig.slug,
+      name: boardConfig.name,
+      description: boardConfig.description,
+      themeColor: boardConfig.themeColor,
+    });
+    console.log(`Created ${label} board /${created.slug}/`);
+    return created;
+  };
+
+  const defaultBoard = await ensureBoard(container.config.defaultBoard, "default");
+  const memberBoard = await ensureBoard(container.config.memberBoard, "member");
+
+  const welcomePost = container.config.memberBoard.welcomePost;
+  if (welcomePost) {
+    const existingPosts = await container.repositories.postRepository.listByBoard(memberBoard.id);
+    if (existingPosts.length === 0) {
+      await container.useCases.createPostUseCase.execute({
+        boardId: memberBoard.id,
+        title: welcomePost.title,
+        description: welcomePost.body,
+        permanent: welcomePost.permanent ?? true,
+      });
+      console.log(`Seeded welcome post for /${memberBoard.slug}/ board.`);
+    }
+  }
+
+  return defaultBoard;
+};
diff --git a/src/config/app_config.ts b/src/config/app_config.ts
new file mode 100644 (file)
index 0000000..56f4b34
--- /dev/null
@@ -0,0 +1,280 @@
+import { dirname, fromFileUrl, isAbsolute, normalize, resolve } from "jsr:@std/path@^1.0.0";
+
+export interface AppConfig {
+  app: {
+    name: string;
+    description: string;
+    homeHeading: string;
+    publicDir: string;
+    storageRoot: string;
+    assetsBasePath: string;
+    uploadsBasePath: string;
+    threadTtlDays: number;
+    boardRequestWindowDays: number;
+    rateLimits: RateLimitConfig;
+    footerText: string;
+  };
+  defaultBoard: BoardConfig;
+  memberBoard: MemberBoardConfig;
+}
+
+export interface RateLimitConfig {
+  createPost: RateLimitRuleConfig;
+  createComment: RateLimitRuleConfig;
+  createBoardRequest: RateLimitRuleConfig;
+}
+
+export interface RateLimitRuleConfig {
+  limit: number;
+  windowMs: number;
+}
+
+export type BoardConfig = {
+  slug: string;
+  name: string;
+  description: string;
+  themeColor: string;
+};
+
+export interface MemberBoardConfig extends BoardConfig {
+  welcomePost?: InitialPostConfig;
+}
+
+export interface InitialPostConfig {
+  title?: string;
+  body: string;
+  image?: string;
+  permanent?: boolean;
+}
+
+const DEFAULT_CONFIG_PATH = normalize(
+  fromFileUrl(new URL("../../config/app.config.json", import.meta.url)),
+);
+
+const toNumber = (value: unknown, fallbackMessage: string): number => {
+  if (typeof value === "number" && Number.isFinite(value)) {
+    return value;
+  }
+  if (typeof value === "string" && value.trim().length > 0) {
+    const parsed = Number(value);
+    if (Number.isFinite(parsed)) {
+      return parsed;
+    }
+  }
+  throw new Error(fallbackMessage);
+};
+
+const requireString = (value: unknown, message: string): string => {
+  if (typeof value === "string" && value.trim().length > 0) {
+    return value.trim();
+  }
+  throw new Error(message);
+};
+
+const resolvePath = (baseDir: string, value: string): string => {
+  const target = isAbsolute(value) ? value : resolve(baseDir, value);
+  return normalize(target);
+};
+
+const sanitizeBasePath = (value: string, field: string): string => {
+  const trimmed = value.trim();
+  if (trimmed.length === 0) {
+    throw new Error(`${field} must not be empty.`);
+  }
+  return `/${trimmed.replace(/^\/+/, "").replace(/\/+$/, "")}`;
+};
+
+const parseRateLimitRule = (field: string, value: unknown): RateLimitRuleConfig => {
+  if (!value || typeof value !== "object") {
+    throw new Error(`${field} must be an object with limit and windowSeconds.`);
+  }
+  const rule = value as Record<string, unknown>;
+  const limit = toNumber(rule.limit, `${field}.limit must be a number.`);
+  const windowSeconds = toNumber(rule.windowSeconds, `${field}.windowSeconds must be a number.`);
+  if (limit <= 0) {
+    throw new Error(`${field}.limit must be greater than zero.`);
+  }
+  if (windowSeconds <= 0) {
+    throw new Error(`${field}.windowSeconds must be greater than zero.`);
+  }
+  return {
+    limit,
+    windowMs: windowSeconds * 1000,
+  };
+};
+
+const parseBoardConfig = (
+  section: Record<string, unknown>,
+  field: string,
+): BoardConfig => {
+  const slug = requireString(
+    section.slug,
+    `${field}.slug must be a non-empty string.`,
+  );
+  const name = requireString(
+    section.name,
+    `${field}.name must be a non-empty string.`,
+  );
+  const description = typeof section.description === "string" ? section.description.trim() : "";
+  const themeColor = requireString(
+    section.themeColor,
+    `${field}.themeColor must be a non-empty string.`,
+  );
+  return {
+    slug,
+    name,
+    description,
+    themeColor,
+  };
+};
+
+const parseWelcomePost = (
+  field: string,
+  value: unknown,
+): InitialPostConfig | undefined => {
+  if (value === undefined || value === null) {
+    return undefined;
+  }
+  if (typeof value !== "object") {
+    throw new Error(
+      `${field} must be an object with body, optional title, image, and permanent fields.`,
+    );
+  }
+  const record = value as Record<string, unknown>;
+  const body = requireString(
+    record.body,
+    `${field}.body must be a non-empty string.`,
+  );
+  const title = typeof record.title === "string" ? record.title.trim() : "";
+  const image = typeof record.image === "string" && record.image.trim().length > 0
+    ? record.image.trim()
+    : undefined;
+  const permanent = typeof record.permanent === "boolean" ? record.permanent : false;
+  return {
+    body,
+    title: title.length > 0 ? title : undefined,
+    image,
+    permanent,
+  };
+};
+
+export const loadAppConfig = async (path?: string): Promise<AppConfig> => {
+  const resolvedPath = path
+    ? normalize(isAbsolute(path) ? path : resolve(Deno.cwd(), path))
+    : DEFAULT_CONFIG_PATH;
+
+  const baseDir = dirname(resolvedPath);
+  let parsed: unknown;
+  try {
+    const raw = await Deno.readTextFile(resolvedPath);
+    parsed = JSON.parse(raw);
+  } catch (error) {
+    throw new Error(
+      `Unable to read configuration from ${resolvedPath}: ${
+        error instanceof Error ? error.message : String(error)
+      }`,
+    );
+  }
+
+  if (typeof parsed !== "object" || parsed === null) {
+    throw new Error("Configuration file must contain a JSON object.");
+  }
+
+  const appSection = (parsed as Record<string, unknown>).app;
+  const defaultBoardSection = (parsed as Record<string, unknown>).defaultBoard;
+  const memberBoardSection = (parsed as Record<string, unknown>).memberBoard;
+
+  if (!appSection || typeof appSection !== "object") {
+    throw new Error("Configuration is missing the app section.");
+  }
+  if (!defaultBoardSection || typeof defaultBoardSection !== "object") {
+    throw new Error("Configuration is missing the defaultBoard section.");
+  }
+  if (!memberBoardSection || typeof memberBoardSection !== "object") {
+    throw new Error("Configuration is missing the memberBoard section.");
+  }
+
+  const app = appSection as Record<string, unknown>;
+  const defaultBoard = defaultBoardSection as Record<string, unknown>;
+  const memberBoard = memberBoardSection as Record<string, unknown>;
+
+  const appName = requireString(app.name, "app.name must be a non-empty string.");
+  const appDescription = requireString(
+    app.description,
+    "app.description must be a non-empty string.",
+  );
+  const homeHeading = requireString(
+    app.homeHeading,
+    "app.homeHeading must be a non-empty string.",
+  );
+  const publicDir = resolvePath(
+    baseDir,
+    requireString(app.publicDir, "app.publicDir must be provided."),
+  );
+  const storageRoot = resolvePath(
+    baseDir,
+    requireString(app.storageRoot, "app.storageRoot must be provided."),
+  );
+  const uploadsBasePath = sanitizeBasePath(
+    requireString(app.uploadsBasePath, "app.uploadsBasePath must be provided."),
+    "app.uploadsBasePath",
+  );
+  const assetsBasePath = sanitizeBasePath(
+    typeof app.assetsBasePath === "string" ? app.assetsBasePath : "/assets",
+    "app.assetsBasePath",
+  );
+  const threadTtlDays = toNumber(app.threadTtlDays, "app.threadTtlDays must be a number.");
+  const boardRequestWindowDays = toNumber(
+    app.boardRequestWindowDays,
+    "app.boardRequestWindowDays must be a number.",
+  );
+  const rateLimitSection = (app.rateLimits ?? {}) as Record<string, unknown>;
+  const rateLimits: RateLimitConfig = {
+    createPost: parseRateLimitRule(
+      "app.rateLimits.createPost",
+      rateLimitSection.createPost ?? {
+        limit: 5,
+        windowSeconds: 60,
+      },
+    ),
+    createComment: parseRateLimitRule(
+      "app.rateLimits.createComment",
+      rateLimitSection.createComment ?? { limit: 10, windowSeconds: 60 },
+    ),
+    createBoardRequest: parseRateLimitRule(
+      "app.rateLimits.createBoardRequest",
+      rateLimitSection.createBoardRequest ?? { limit: 3, windowSeconds: 3600 },
+    ),
+  };
+
+  const footerText = requireString(app.footerText, "app.footerText must be a non-empty string.");
+
+  const defaultBoardConfig = parseBoardConfig(defaultBoard, "defaultBoard");
+  const memberBoardBase = parseBoardConfig(memberBoard, "memberBoard");
+  const welcomePost = parseWelcomePost(
+    "memberBoard.welcomePost",
+    memberBoard.welcomePost,
+  );
+  const memberBoardConfig: MemberBoardConfig = {
+    ...memberBoardBase,
+    welcomePost,
+  };
+
+  return {
+    app: {
+      name: appName,
+      description: appDescription,
+      homeHeading,
+      publicDir,
+      storageRoot,
+      assetsBasePath,
+      uploadsBasePath,
+      threadTtlDays,
+      boardRequestWindowDays,
+      rateLimits,
+      footerText,
+    },
+    defaultBoard: defaultBoardConfig,
+    memberBoard: memberBoardConfig,
+  };
+};
diff --git a/src/config/app_config_test.ts b/src/config/app_config_test.ts
new file mode 100644 (file)
index 0000000..50350ad
--- /dev/null
@@ -0,0 +1,74 @@
+import { join } from "jsr:@std/path@^1.0.0";
+import { loadAppConfig } from "./app_config.ts";
+import { assertEquals } from "../testing/asserts.ts";
+
+Deno.test("loadAppConfig resolves relative paths and normalizes values", async () => {
+  const tempDir = await Deno.makeTempDir();
+  try {
+    const configPath = join(tempDir, "config.json");
+    await Deno.writeTextFile(
+      configPath,
+      JSON.stringify({
+        app: {
+          name: "Test Board",
+          description: "Testing configuration loader.",
+          homeHeading: "Test Heading",
+          publicDir: "./public",
+          storageRoot: "./storage",
+          assetsBasePath: "/static",
+          uploadsBasePath: "uploads/",
+          threadTtlDays: 5,
+          boardRequestWindowDays: 2,
+          footerText: "Test footer text.",
+        },
+        defaultBoard: {
+          slug: "test",
+          name: "Test",
+          description: "A test board.",
+          themeColor: "#123456",
+        },
+        memberBoard: {
+          slug: "members",
+          name: "Members",
+          description: "Private area.",
+          themeColor: "#1E3A8A",
+          welcomePost: {
+            title: "Welcome",
+            body: "First member post.",
+            permanent: true,
+          },
+        },
+      }),
+    );
+
+    const config = await loadAppConfig(configPath);
+
+    assertEquals(config.app.name, "Test Board");
+    assertEquals(config.app.description, "Testing configuration loader.");
+    assertEquals(config.app.homeHeading, "Test Heading");
+    assertEquals(config.app.assetsBasePath, "/static");
+    assertEquals(config.app.threadTtlDays, 5);
+    assertEquals(config.app.boardRequestWindowDays, 2);
+    assertEquals(config.app.uploadsBasePath, "/uploads");
+    assertEquals(config.app.rateLimits.createPost.limit, 5);
+    assertEquals(config.app.rateLimits.createPost.windowMs, 60_000);
+    assertEquals(config.app.rateLimits.createComment.limit, 10);
+    assertEquals(config.app.rateLimits.createComment.windowMs, 60_000);
+    assertEquals(config.app.rateLimits.createBoardRequest.limit, 3);
+    assertEquals(config.app.rateLimits.createBoardRequest.windowMs, 3_600_000);
+    assertEquals(config.defaultBoard.slug, "test");
+    assertEquals(config.defaultBoard.themeColor, "#123456");
+    assertEquals(config.app.footerText, "Test footer text.");
+    assertEquals(config.memberBoard.slug, "members");
+    assertEquals(config.memberBoard.name, "Members");
+    assertEquals(config.memberBoard.description, "Private area.");
+    assertEquals(config.memberBoard.welcomePost?.body, "First member post.");
+    assertEquals(config.memberBoard.welcomePost?.permanent, true);
+
+    // Paths should resolve relative to the config file.
+    assertEquals(config.app.publicDir.startsWith(tempDir), true);
+    assertEquals(config.app.storageRoot.startsWith(tempDir), true);
+  } finally {
+    await Deno.remove(tempDir, { recursive: true });
+  }
+});
diff --git a/src/domain/.DS_Store b/src/domain/.DS_Store
new file mode 100644 (file)
index 0000000..71e9067
Binary files /dev/null and b/src/domain/.DS_Store differ
diff --git a/src/domain/entities/board.ts b/src/domain/entities/board.ts
new file mode 100644 (file)
index 0000000..0c4a1e8
--- /dev/null
@@ -0,0 +1,9 @@
+export interface Board {
+  id: string;
+  slug: string;
+  name: string;
+  description: string;
+  themeColor: string;
+  createdAt: string;
+  status: "active";
+}
diff --git a/src/domain/entities/board_announcement.ts b/src/domain/entities/board_announcement.ts
new file mode 100644 (file)
index 0000000..45130f1
--- /dev/null
@@ -0,0 +1,5 @@
+export interface BoardAnnouncement {
+  id: string;
+  message: string;
+  createdAt: string;
+}
diff --git a/src/domain/entities/board_request.ts b/src/domain/entities/board_request.ts
new file mode 100644 (file)
index 0000000..31742eb
--- /dev/null
@@ -0,0 +1,11 @@
+export interface BoardRequest {
+  id: string;
+  slug: string;
+  name: string;
+  description: string;
+  themeColor: string;
+  voteCount: number;
+  createdAt: string;
+  deadline: string;
+  status: "open" | "closed" | "fulfilled";
+}
diff --git a/src/domain/entities/board_request_vote.ts b/src/domain/entities/board_request_vote.ts
new file mode 100644 (file)
index 0000000..ce92f46
--- /dev/null
@@ -0,0 +1,5 @@
+export interface BoardRequestVote {
+  requestId: string;
+  userId: string;
+  createdAt: string;
+}
diff --git a/src/domain/entities/comment.ts b/src/domain/entities/comment.ts
new file mode 100644 (file)
index 0000000..203c269
--- /dev/null
@@ -0,0 +1,10 @@
+export interface Comment {
+  id: string;
+  postId: string;
+  parentCommentId?: string;
+  author: string;
+  authorUserId?: string;
+  body: string;
+  imagePath?: string;
+  createdAt: string;
+}
diff --git a/src/domain/entities/image_post.ts b/src/domain/entities/image_post.ts
new file mode 100644 (file)
index 0000000..1e53c1c
--- /dev/null
@@ -0,0 +1,13 @@
+export interface ImagePost {
+  id: string;
+  boardId: string;
+  description: string;
+  createdAt: string;
+  commentCount: number;
+  status: "active" | "archived";
+  readOnly: boolean;
+  title?: string;
+  imagePath?: string | null;
+  archivedAt?: string | null;
+  permanent?: boolean;
+}
diff --git a/src/domain/entities/user.ts b/src/domain/entities/user.ts
new file mode 100644 (file)
index 0000000..9625fe6
--- /dev/null
@@ -0,0 +1,6 @@
+export interface User {
+  id: string;
+  username: string;
+  passwordHash: string;
+  createdAt: string;
+}
diff --git a/src/domain/repositories/board_announcement_repository.ts b/src/domain/repositories/board_announcement_repository.ts
new file mode 100644 (file)
index 0000000..f9097d2
--- /dev/null
@@ -0,0 +1,7 @@
+import { BoardAnnouncement } from "../entities/board_announcement.ts";
+
+export interface BoardAnnouncementRepository {
+  list(): Promise<BoardAnnouncement[]>;
+  create(announcement: BoardAnnouncement): Promise<void>;
+  delete(id: string): Promise<void>;
+}
diff --git a/src/domain/repositories/board_repository.ts b/src/domain/repositories/board_repository.ts
new file mode 100644 (file)
index 0000000..0aca8d9
--- /dev/null
@@ -0,0 +1,10 @@
+import { Board } from "../entities/board.ts";
+
+export interface BoardRepository {
+  create(board: Board): Promise<void>;
+  update(board: Board): Promise<void>;
+  findById(id: string): Promise<Board | null>;
+  findBySlug(slug: string): Promise<Board | null>;
+  list(): Promise<Board[]>;
+  delete(id: string): Promise<void>;
+}
diff --git a/src/domain/repositories/board_request_repository.ts b/src/domain/repositories/board_request_repository.ts
new file mode 100644 (file)
index 0000000..653ce3f
--- /dev/null
@@ -0,0 +1,9 @@
+import { BoardRequest } from "../entities/board_request.ts";
+
+export interface BoardRequestRepository {
+  create(request: BoardRequest): Promise<void>;
+  update(request: BoardRequest): Promise<void>;
+  findById(id: string): Promise<BoardRequest | null>;
+  list(): Promise<BoardRequest[]>;
+  delete(id: string): Promise<void>;
+}
diff --git a/src/domain/repositories/board_request_vote_repository.ts b/src/domain/repositories/board_request_vote_repository.ts
new file mode 100644 (file)
index 0000000..d28c6b2
--- /dev/null
@@ -0,0 +1,6 @@
+export interface BoardRequestVoteRepository {
+  hasVote(requestId: string, userId: string): Promise<boolean>;
+  addVote(requestId: string, userId: string): Promise<void>;
+  removeVote(requestId: string, userId: string): Promise<void>;
+  countVotes(requestId: string): Promise<number>;
+}
diff --git a/src/domain/repositories/comment_repository.ts b/src/domain/repositories/comment_repository.ts
new file mode 100644 (file)
index 0000000..30fd5fc
--- /dev/null
@@ -0,0 +1,9 @@
+import { Comment } from "../entities/comment.ts";
+
+export interface CommentRepository {
+  create(comment: Comment): Promise<void>;
+  findById(postId: string, id: string): Promise<Comment | null>;
+  listByPost(postId: string): Promise<Comment[]>;
+  deleteByPost(postId: string): Promise<void>;
+  countByPost(postId: string): Promise<number>;
+}
diff --git a/src/domain/repositories/post_repository.ts b/src/domain/repositories/post_repository.ts
new file mode 100644 (file)
index 0000000..a5971b4
--- /dev/null
@@ -0,0 +1,10 @@
+import { ImagePost } from "../entities/image_post.ts";
+
+export interface PostRepository {
+  create(post: ImagePost): Promise<void>;
+  findById(id: string): Promise<ImagePost | null>;
+  listAll(): Promise<ImagePost[]>;
+  listByBoard(boardId: string): Promise<ImagePost[]>;
+  update(post: ImagePost): Promise<void>;
+  delete(id: string): Promise<void>;
+}
diff --git a/src/domain/repositories/user_repository.ts b/src/domain/repositories/user_repository.ts
new file mode 100644 (file)
index 0000000..2450de7
--- /dev/null
@@ -0,0 +1,8 @@
+import { User } from "../entities/user.ts";
+
+export interface UserRepository {
+  create(user: User): Promise<void>;
+  findById(id: string): Promise<User | null>;
+  findByUsername(username: string): Promise<User | null>;
+  isUsernameTaken(username: string): Promise<boolean>;
+}
diff --git a/src/domain/services/image_storage.ts b/src/domain/services/image_storage.ts
new file mode 100644 (file)
index 0000000..b1f52ce
--- /dev/null
@@ -0,0 +1,12 @@
+export interface SaveImageInput {
+  id: string;
+  data: Uint8Array;
+  mimeType: string;
+  originalName: string;
+}
+
+export interface ImageStorage {
+  saveImage(input: SaveImageInput): Promise<string>;
+  getImage(path: string): Promise<Uint8Array>;
+  deleteImage(path: string): Promise<void>;
+}
diff --git a/src/domain/services/rate_limiter.ts b/src/domain/services/rate_limiter.ts
new file mode 100644 (file)
index 0000000..fbd1298
--- /dev/null
@@ -0,0 +1,8 @@
+export interface RateLimitRule {
+  limit: number;
+  windowMs: number;
+}
+
+export interface RateLimiter {
+  consume(key: string, rule: RateLimitRule): Promise<boolean>;
+}
diff --git a/src/domain/services/session_store.ts b/src/domain/services/session_store.ts
new file mode 100644 (file)
index 0000000..b787fa0
--- /dev/null
@@ -0,0 +1,11 @@
+export interface SessionData {
+  userId: string | null;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface SessionStore {
+  get(sessionId: string): Promise<SessionData | null>;
+  set(sessionId: string, data: SessionData): Promise<void>;
+  delete(sessionId: string): Promise<void>;
+}
diff --git a/src/infrastructure/.DS_Store b/src/infrastructure/.DS_Store
new file mode 100644 (file)
index 0000000..6c5a008
Binary files /dev/null and b/src/infrastructure/.DS_Store differ
diff --git a/src/infrastructure/kv/kv_board_announcement_repository.ts b/src/infrastructure/kv/kv_board_announcement_repository.ts
new file mode 100644 (file)
index 0000000..98d8bde
--- /dev/null
@@ -0,0 +1,26 @@
+import { BoardAnnouncement } from "../../domain/entities/board_announcement.ts";
+import { BoardAnnouncementRepository } from "../../domain/repositories/board_announcement_repository.ts";
+
+const ANNOUNCEMENT_PREFIX = ["announcement"] as const;
+
+export class KvBoardAnnouncementRepository implements BoardAnnouncementRepository {
+  constructor(private readonly kv: Deno.Kv) {}
+
+  async list(): Promise<BoardAnnouncement[]> {
+    const announcements: BoardAnnouncement[] = [];
+    for await (const entry of this.kv.list<BoardAnnouncement>({ prefix: ANNOUNCEMENT_PREFIX })) {
+      if (entry.value) {
+        announcements.push(entry.value);
+      }
+    }
+    return announcements.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
+  }
+
+  async create(announcement: BoardAnnouncement): Promise<void> {
+    await this.kv.set([...ANNOUNCEMENT_PREFIX, announcement.id], announcement);
+  }
+
+  async delete(id: string): Promise<void> {
+    await this.kv.delete([...ANNOUNCEMENT_PREFIX, id]);
+  }
+}
diff --git a/src/infrastructure/kv/kv_board_repository.ts b/src/infrastructure/kv/kv_board_repository.ts
new file mode 100644 (file)
index 0000000..1090723
--- /dev/null
@@ -0,0 +1,57 @@
+import { Board } from "../../domain/entities/board.ts";
+import { BoardRepository } from "../../domain/repositories/board_repository.ts";
+
+const BOARD_KEY_PREFIX = ["board"] as const;
+const BOARD_SLUG_INDEX_PREFIX = ["board_slug"] as const;
+
+export class KvBoardRepository implements BoardRepository {
+  constructor(private readonly kv: Deno.Kv) {}
+
+  async create(board: Board): Promise<void> {
+    await this.kv.atomic()
+      .set([...BOARD_KEY_PREFIX, board.id], board)
+      .set([...BOARD_SLUG_INDEX_PREFIX, board.slug], { id: board.id })
+      .commit();
+  }
+
+  async update(board: Board): Promise<void> {
+    await this.kv.atomic()
+      .set([...BOARD_KEY_PREFIX, board.id], board)
+      .set([...BOARD_SLUG_INDEX_PREFIX, board.slug], { id: board.id })
+      .commit();
+  }
+
+  async findById(id: string): Promise<Board | null> {
+    const result = await this.kv.get<Board>([...BOARD_KEY_PREFIX, id]);
+    return result.value ?? null;
+  }
+
+  async findBySlug(slug: string): Promise<Board | null> {
+    const index = await this.kv.get<{ id: string }>([...BOARD_SLUG_INDEX_PREFIX, slug]);
+    if (!index.value) {
+      return null;
+    }
+    return await this.findById(index.value.id);
+  }
+
+  async list(): Promise<Board[]> {
+    const boards: Board[] = [];
+    for await (const entry of this.kv.list<Board>({ prefix: BOARD_KEY_PREFIX })) {
+      if (entry.value) {
+        boards.push(entry.value);
+      }
+    }
+    return boards;
+  }
+
+  async delete(id: string): Promise<void> {
+    const board = await this.findById(id);
+    if (!board) {
+      return;
+    }
+    await this.kv.atomic()
+      .delete([...BOARD_KEY_PREFIX, id])
+      .delete([...BOARD_SLUG_INDEX_PREFIX, board.slug])
+      .commit();
+  }
+}
diff --git a/src/infrastructure/kv/kv_board_request_repository.ts b/src/infrastructure/kv/kv_board_request_repository.ts
new file mode 100644 (file)
index 0000000..95f80d6
--- /dev/null
@@ -0,0 +1,35 @@
+import { BoardRequest } from "../../domain/entities/board_request.ts";
+import { BoardRequestRepository } from "../../domain/repositories/board_request_repository.ts";
+
+const BOARD_REQUEST_PREFIX = ["board_request"] as const;
+
+export class KvBoardRequestRepository implements BoardRequestRepository {
+  constructor(private readonly kv: Deno.Kv) {}
+
+  async create(request: BoardRequest): Promise<void> {
+    await this.kv.set([...BOARD_REQUEST_PREFIX, request.id], request);
+  }
+
+  async update(request: BoardRequest): Promise<void> {
+    await this.kv.set([...BOARD_REQUEST_PREFIX, request.id], request);
+  }
+
+  async findById(id: string): Promise<BoardRequest | null> {
+    const result = await this.kv.get<BoardRequest>([...BOARD_REQUEST_PREFIX, id]);
+    return result.value ?? null;
+  }
+
+  async list(): Promise<BoardRequest[]> {
+    const requests: BoardRequest[] = [];
+    for await (const entry of this.kv.list<BoardRequest>({ prefix: BOARD_REQUEST_PREFIX })) {
+      if (entry.value) {
+        requests.push(entry.value);
+      }
+    }
+    return requests;
+  }
+
+  async delete(id: string): Promise<void> {
+    await this.kv.delete([...BOARD_REQUEST_PREFIX, id]);
+  }
+}
diff --git a/src/infrastructure/kv/kv_board_request_vote_repository.ts b/src/infrastructure/kv/kv_board_request_vote_repository.ts
new file mode 100644 (file)
index 0000000..e53cdd8
--- /dev/null
@@ -0,0 +1,33 @@
+import { BoardRequestVoteRepository } from "../../domain/repositories/board_request_vote_repository.ts";
+
+const VOTE_PREFIX = ["board_request_vote"] as const;
+
+export class KvBoardRequestVoteRepository implements BoardRequestVoteRepository {
+  constructor(private readonly kv: Deno.Kv) {}
+
+  private key(requestId: string, userId: string) {
+    return [...VOTE_PREFIX, requestId, userId] as const;
+  }
+
+  async hasVote(requestId: string, userId: string): Promise<boolean> {
+    const result = await this.kv.get(this.key(requestId, userId));
+    return result.value !== null;
+  }
+
+  async addVote(requestId: string, userId: string): Promise<void> {
+    await this.kv.set(this.key(requestId, userId), true);
+  }
+
+  async removeVote(requestId: string, userId: string): Promise<void> {
+    await this.kv.delete(this.key(requestId, userId));
+  }
+
+  async countVotes(requestId: string): Promise<number> {
+    let count = 0;
+    const prefix = [...VOTE_PREFIX, requestId] as const;
+    for await (const _ of this.kv.list({ prefix })) {
+      count += 1;
+    }
+    return count;
+  }
+}
diff --git a/src/infrastructure/kv/kv_comment_repository.ts b/src/infrastructure/kv/kv_comment_repository.ts
new file mode 100644 (file)
index 0000000..b95c5c6
--- /dev/null
@@ -0,0 +1,48 @@
+import { Comment } from "../../domain/entities/comment.ts";
+import { CommentRepository } from "../../domain/repositories/comment_repository.ts";
+
+const COMMENT_KEY_PREFIX = ["comment"] as const;
+
+export class KvCommentRepository implements CommentRepository {
+  constructor(private readonly kv: Deno.Kv) {}
+
+  async create(comment: Comment): Promise<void> {
+    await this.kv.set([...COMMENT_KEY_PREFIX, comment.postId, comment.id], comment);
+  }
+
+  async findById(postId: string, id: string): Promise<Comment | null> {
+    const record = await this.kv.get<Comment>([...COMMENT_KEY_PREFIX, postId, id]);
+    return record.value ?? null;
+  }
+
+  async listByPost(postId: string): Promise<Comment[]> {
+    const comments: Comment[] = [];
+    const prefix = [...COMMENT_KEY_PREFIX, postId] as const;
+    for await (const entry of this.kv.list<Comment>({ prefix })) {
+      if (entry.value) {
+        comments.push(entry.value);
+      }
+    }
+
+    return comments;
+  }
+
+  async deleteByPost(postId: string): Promise<void> {
+    const prefix = [...COMMENT_KEY_PREFIX, postId] as const;
+    const ops: Array<Promise<void>> = [];
+    for await (const entry of this.kv.list<Comment>({ prefix })) {
+      ops.push(this.kv.delete(entry.key));
+    }
+    await Promise.all(ops);
+  }
+
+  async countByPost(postId: string): Promise<number> {
+    const prefix = [...COMMENT_KEY_PREFIX, postId] as const;
+    let count = 0;
+    for await (const _ of this.kv.list<Comment>({ prefix })) {
+      count += 1;
+    }
+
+    return count;
+  }
+}
diff --git a/src/infrastructure/kv/kv_post_repository.ts b/src/infrastructure/kv/kv_post_repository.ts
new file mode 100644 (file)
index 0000000..90d31af
--- /dev/null
@@ -0,0 +1,73 @@
+import { ImagePost } from "../../domain/entities/image_post.ts";
+import { PostRepository } from "../../domain/repositories/post_repository.ts";
+
+const POST_KEY_PREFIX = ["post"] as const;
+const POST_INDEX_PREFIX = ["post_index"] as const;
+
+export class KvPostRepository implements PostRepository {
+  constructor(private readonly kv: Deno.Kv) {}
+
+  async create(post: ImagePost): Promise<void> {
+    post.boardId = post.boardId.trim();
+    await this.kv.atomic()
+      .set([...POST_KEY_PREFIX, post.boardId, post.id], post)
+      .set([...POST_INDEX_PREFIX, post.id], { boardId: post.boardId })
+      .commit();
+  }
+
+  async findById(id: string): Promise<ImagePost | null> {
+    const index = await this.kv.get<{ boardId: string }>([...POST_INDEX_PREFIX, id]);
+    if (!index.value) {
+      return null;
+    }
+    const record = await this.kv.get<ImagePost>([...POST_KEY_PREFIX, index.value.boardId, id]);
+    return record.value ?? null;
+  }
+
+  async listAll(): Promise<ImagePost[]> {
+    const posts: ImagePost[] = [];
+    for await (
+      const entry of this.kv.list<ImagePost>({ prefix: POST_KEY_PREFIX })
+    ) {
+      if (entry.value) {
+        posts.push(entry.value);
+      }
+    }
+    return posts;
+  }
+
+  async listByBoard(boardId: string): Promise<ImagePost[]> {
+    const posts: ImagePost[] = [];
+    const prefix = [...POST_KEY_PREFIX, boardId] as const;
+    for await (const entry of this.kv.list<ImagePost>({ prefix })) {
+      if (entry.value) {
+        posts.push(entry.value);
+      }
+    }
+    return posts;
+  }
+
+  async update(post: ImagePost): Promise<void> {
+    const existing = await this.findById(post.id);
+    if (!existing) {
+      await this.create(post);
+      return;
+    }
+    const boardId = post.boardId.trim();
+    await this.kv.atomic()
+      .set([...POST_KEY_PREFIX, boardId, post.id], post)
+      .set([...POST_INDEX_PREFIX, post.id], { boardId })
+      .commit();
+  }
+
+  async delete(id: string): Promise<void> {
+    const indexKey = [...POST_INDEX_PREFIX, id] as const;
+    const index = await this.kv.get<{ boardId: string }>(indexKey);
+    if (index.value) {
+      await this.kv.atomic()
+        .delete([...POST_KEY_PREFIX, index.value.boardId, id])
+        .delete(indexKey)
+        .commit();
+    }
+  }
+}
diff --git a/src/infrastructure/kv/kv_session_store.ts b/src/infrastructure/kv/kv_session_store.ts
new file mode 100644 (file)
index 0000000..9bcdc98
--- /dev/null
@@ -0,0 +1,20 @@
+import { SessionData, SessionStore } from "../../domain/services/session_store.ts";
+
+const SESSION_PREFIX = ["session"] as const;
+
+export class KvSessionStore implements SessionStore {
+  constructor(private readonly kv: Deno.Kv) {}
+
+  async get(sessionId: string): Promise<SessionData | null> {
+    const record = await this.kv.get<SessionData>([...SESSION_PREFIX, sessionId]);
+    return record.value ?? null;
+  }
+
+  async set(sessionId: string, data: SessionData): Promise<void> {
+    await this.kv.set([...SESSION_PREFIX, sessionId], data);
+  }
+
+  async delete(sessionId: string): Promise<void> {
+    await this.kv.delete([...SESSION_PREFIX, sessionId]);
+  }
+}
diff --git a/src/infrastructure/kv/kv_user_repository.ts b/src/infrastructure/kv/kv_user_repository.ts
new file mode 100644 (file)
index 0000000..0d7c06d
--- /dev/null
@@ -0,0 +1,48 @@
+import { User } from "../../domain/entities/user.ts";
+import { UserRepository } from "../../domain/repositories/user_repository.ts";
+
+const USER_KEY_PREFIX = ["user"] as const;
+const USER_USERNAME_INDEX = ["user_username"] as const;
+
+const normalizeUsername = (value: string): string => value.toLowerCase();
+
+export class KvUserRepository implements UserRepository {
+  constructor(private readonly kv: Deno.Kv) {}
+
+  async create(user: User): Promise<void> {
+    const usernameKey = [...USER_USERNAME_INDEX, normalizeUsername(user.username)] as const;
+    const userKey = [...USER_KEY_PREFIX, user.id] as const;
+    const existing = await this.kv.get(usernameKey);
+    if (existing.value) {
+      throw new Error("Username already exists.");
+    }
+    const result = await this.kv.atomic()
+      .check({ key: usernameKey, versionstamp: null })
+      .set(userKey, user)
+      .set(usernameKey, { id: user.id })
+      .commit();
+    if (!result.ok) {
+      throw new Error("Failed to create user.");
+    }
+  }
+
+  async findById(id: string): Promise<User | null> {
+    const entry = await this.kv.get<User>([...USER_KEY_PREFIX, id]);
+    return entry.value ?? null;
+  }
+
+  async findByUsername(username: string): Promise<User | null> {
+    const indexKey = [...USER_USERNAME_INDEX, normalizeUsername(username)] as const;
+    const index = await this.kv.get<{ id: string }>(indexKey);
+    if (!index.value) {
+      return null;
+    }
+    return await this.findById(index.value.id);
+  }
+
+  async isUsernameTaken(username: string): Promise<boolean> {
+    const indexKey = [...USER_USERNAME_INDEX, normalizeUsername(username)] as const;
+    const record = await this.kv.get(indexKey);
+    return record.value !== null;
+  }
+}
diff --git a/src/infrastructure/storage/file_system_image_storage.ts b/src/infrastructure/storage/file_system_image_storage.ts
new file mode 100644 (file)
index 0000000..f91c375
--- /dev/null
@@ -0,0 +1,102 @@
+import { join, normalize, relative } from "jsr:@std/path@^1.0.0";
+import { ImageStorage, SaveImageInput } from "../../domain/services/image_storage.ts";
+
+type FileSystemImageStorageOptions = {
+  root: string;
+  publicBasePath: string;
+};
+
+export class FileSystemImageStorage implements ImageStorage {
+  private readonly root: string;
+  private readonly rootPrefix: string;
+  private readonly publicBasePath: string;
+
+  constructor(options: FileSystemImageStorageOptions) {
+    this.root = normalize(options.root);
+    this.rootPrefix = this.root.endsWith("/") ? this.root : `${this.root}/`;
+    this.publicBasePath = options.publicBasePath.replace(/\/+$/, "");
+  }
+
+  async saveImage(input: SaveImageInput): Promise<string> {
+    await this.ensureRoot();
+    const extension = this.resolveExtension(input.mimeType, input.originalName);
+    const filename = `${input.id}${extension}`;
+    const relativePath = join("images", filename);
+    const absolutePath = this.resolveAbsolute(relativePath);
+    await Deno.mkdir(join(this.root, "images"), { recursive: true });
+    await Deno.writeFile(absolutePath, input.data);
+    return `${this.publicBasePath}/${relativePath}`.replace(/\/+/g, "/");
+  }
+
+  async getImage(path: string): Promise<Uint8Array> {
+    const absolute = this.resolvePublicPath(path);
+    return await Deno.readFile(absolute);
+  }
+
+  async deleteImage(path: string): Promise<void> {
+    const absolute = this.resolvePublicPath(path);
+    try {
+      await Deno.remove(absolute);
+    } catch (error) {
+      if (error instanceof Deno.errors.NotFound) {
+        return;
+      }
+      throw error;
+    }
+  }
+
+  private async ensureRoot() {
+    await Deno.mkdir(this.root, { recursive: true });
+  }
+
+  private resolveExtension(mimeType: string, originalName: string): string {
+    const lowered = mimeType.toLowerCase();
+    if (lowered.includes("jpeg") || lowered.includes("jpg")) {
+      return ".jpg";
+    }
+    if (lowered.includes("png")) {
+      return ".png";
+    }
+    if (lowered.includes("gif")) {
+      return ".gif";
+    }
+    if (lowered.includes("webp")) {
+      return ".webp";
+    }
+    const parts = originalName.split(".");
+    if (parts.length > 1) {
+      return `.${parts.pop()}`;
+    }
+    return ".bin";
+  }
+
+  private resolveAbsolute(relativePath: string): string {
+    const candidate = normalize(join(this.root, relativePath));
+    if (!this.isInsideRoot(candidate)) {
+      throw new Error("Attempt to access path outside storage root.");
+    }
+    return candidate;
+  }
+
+  private resolvePublicPath(publicPath: string): string {
+    const sanitized = publicPath.startsWith(this.publicBasePath)
+      ? publicPath.slice(this.publicBasePath.length)
+      : publicPath;
+    const relativePath = sanitized.replace(/^\/+/, "");
+    return this.resolveAbsolute(relativePath);
+  }
+
+  private isInsideRoot(target: string): boolean {
+    if (target === this.root) {
+      return true;
+    }
+    if (!target.startsWith(this.rootPrefix)) {
+      return false;
+    }
+    const related = relative(this.root, target).replaceAll("\\", "/");
+    if (!related || related === "." || related === "./") {
+      return true;
+    }
+    return !related.split("/").some((segment) => segment === "..");
+  }
+}
diff --git a/src/infrastructure/storage/file_system_image_storage_test.ts b/src/infrastructure/storage/file_system_image_storage_test.ts
new file mode 100644 (file)
index 0000000..e260eb7
--- /dev/null
@@ -0,0 +1,65 @@
+import { FileSystemImageStorage } from "./file_system_image_storage.ts";
+import { assertEquals, assertRejects } from "../../testing/asserts.ts";
+
+const decode = (bytes: Uint8Array) => Array.from(bytes);
+
+Deno.test("FileSystemImageStorage saves, reads, and deletes images", async () => {
+  const tempDir = await Deno.makeTempDir();
+  const storage = new FileSystemImageStorage({
+    root: tempDir,
+    publicBasePath: "/uploads",
+  });
+
+  try {
+    const path = await storage.saveImage({
+      id: "file-1",
+      data: new Uint8Array([1, 2, 3]),
+      mimeType: "image/png",
+      originalName: "example.png",
+    });
+
+    assertEquals(path, "/uploads/images/file-1.png");
+
+    const data = await storage.getImage(path);
+    assertEquals(decode(data), [1, 2, 3]);
+
+    await storage.deleteImage(path);
+    await assertRejects(
+      () => storage.getImage(path),
+      Error,
+    );
+
+    // Deleting again should be a no-op.
+    await storage.deleteImage(path);
+  } finally {
+    await Deno.remove(tempDir, { recursive: true });
+  }
+});
+
+Deno.test("FileSystemImageStorage derives extensions from mime type and filename", async () => {
+  const tempDir = await Deno.makeTempDir();
+  const storage = new FileSystemImageStorage({
+    root: tempDir,
+    publicBasePath: "/uploads",
+  });
+
+  try {
+    const gifPath = await storage.saveImage({
+      id: "file-2",
+      data: new Uint8Array([4]),
+      mimeType: "image/gif",
+      originalName: "animation.gif",
+    });
+    assertEquals(gifPath, "/uploads/images/file-2.gif");
+
+    const fallbackPath = await storage.saveImage({
+      id: "file-3",
+      data: new Uint8Array([5]),
+      mimeType: "application/octet-stream",
+      originalName: "mystery.bin",
+    });
+    assertEquals(fallbackPath, "/uploads/images/file-3.bin");
+  } finally {
+    await Deno.remove(tempDir, { recursive: true });
+  }
+});
diff --git a/src/infrastructure/system/crypto_id_generator.ts b/src/infrastructure/system/crypto_id_generator.ts
new file mode 100644 (file)
index 0000000..8054936
--- /dev/null
@@ -0,0 +1,7 @@
+import { IdGenerator } from "../../application/contracts/id_generator.ts";
+
+export class CryptoIdGenerator implements IdGenerator {
+  generate(): string {
+    return crypto.randomUUID();
+  }
+}
diff --git a/src/infrastructure/system/kv_rate_limiter.ts b/src/infrastructure/system/kv_rate_limiter.ts
new file mode 100644 (file)
index 0000000..f457d23
--- /dev/null
@@ -0,0 +1,46 @@
+import { RateLimiter, RateLimitRule } from "../../domain/services/rate_limiter.ts";
+
+type RateCounterRecord = {
+  count: number;
+  windowExpiresAt: number;
+};
+
+const RATE_LIMIT_PREFIX = ["rate-limit"] as const;
+
+const computeWindow = (now: number, windowMs: number) => {
+  const windowId = Math.floor(now / windowMs);
+  const windowStart = windowId * windowMs;
+  const windowExpiresAt = windowStart + windowMs;
+  return { windowId, windowExpiresAt };
+};
+
+export class KvRateLimiter implements RateLimiter {
+  constructor(private readonly kv: Deno.Kv) {}
+
+  async consume(key: string, rule: RateLimitRule): Promise<boolean> {
+    const now = Date.now();
+    const { windowId, windowExpiresAt } = computeWindow(now, rule.windowMs);
+    const storageKey = [...RATE_LIMIT_PREFIX, key, windowId] as const;
+    const ttl = Math.max(1, windowExpiresAt - now);
+
+    const existing = await this.kv.get<RateCounterRecord>(storageKey);
+    if (!existing.value) {
+      const result = await this.kv.atomic()
+        .check({ key: storageKey, versionstamp: null })
+        .set(storageKey, { count: 1, windowExpiresAt }, { expireIn: ttl })
+        .commit();
+      return result.ok;
+    }
+
+    if (existing.value.count >= rule.limit) {
+      return false;
+    }
+
+    const nextCount = existing.value.count + 1;
+    const result = await this.kv.atomic()
+      .check(existing)
+      .set(storageKey, { count: nextCount, windowExpiresAt }, { expireIn: ttl })
+      .commit();
+    return result.ok;
+  }
+}
diff --git a/src/infrastructure/system/system_clock.ts b/src/infrastructure/system/system_clock.ts
new file mode 100644 (file)
index 0000000..d7dfbc8
--- /dev/null
@@ -0,0 +1,7 @@
+import { Clock } from "../../application/contracts/clock.ts";
+
+export class SystemClock implements Clock {
+  now(): Date {
+    return new Date();
+  }
+}
diff --git a/src/interfaces/http/app.ts b/src/interfaces/http/app.ts
new file mode 100644 (file)
index 0000000..c42fa73
--- /dev/null
@@ -0,0 +1,1164 @@
+import { join, normalize, relative } from "jsr:@std/path@^1.0.0";
+import { Context, Hono } from "@hono/hono";
+import { decodeBase64Url, encodeBase64Url } from "jsr:@std/encoding@^1.0.0/base64url";
+
+import { CreateCommentUseCase } from "../../application/usecases/create_comment_use_case.ts";
+import { CreatePostUseCase } from "../../application/usecases/create_post_use_case.ts";
+import { GetPostUseCase } from "../../application/usecases/get_post_use_case.ts";
+import { ListCommentsUseCase } from "../../application/usecases/list_comments_use_case.ts";
+import { ListPostsUseCase } from "../../application/usecases/list_posts_use_case.ts";
+import { ListArchivedPostsUseCase } from "../../application/usecases/list_archived_posts_use_case.ts";
+import { ListBoardsUseCase } from "../../application/usecases/list_boards_use_case.ts";
+import { CreateBoardRequestUseCase } from "../../application/usecases/create_board_request_use_case.ts";
+import { ToggleBoardRequestVoteUseCase } from "../../application/usecases/toggle_board_request_vote_use_case.ts";
+import { GetBoardBySlugUseCase } from "../../application/usecases/get_board_by_slug_use_case.ts";
+import { GetBoardRequestsOverviewUseCase } from "../../application/usecases/get_board_requests_overview_use_case.ts";
+import { ListBoardAnnouncementsUseCase } from "../../application/usecases/list_board_announcements_use_case.ts";
+import { ListPopularPostsUseCase } from "../../application/usecases/list_popular_posts_use_case.ts";
+import { ApplicationError } from "../../application/errors/application_error.ts";
+import { ImageStorage } from "../../domain/services/image_storage.ts";
+import type { ImagePost } from "../../domain/entities/image_post.ts";
+import type { Comment } from "../../domain/entities/comment.ts";
+import { renderAppPage } from "./views/app_page.ts";
+import { renderHomePage } from "./views/home_page.ts";
+import { RateLimiter } from "../../domain/services/rate_limiter.ts";
+import { RegisterUserUseCase } from "../../application/usecases/register_user_use_case.ts";
+import { AuthenticateUserUseCase } from "../../application/usecases/authenticate_user_use_case.ts";
+import { type SessionData, SessionStore } from "../../domain/services/session_store.ts";
+import { UserRepository } from "../../domain/repositories/user_repository.ts";
+import type { User } from "../../domain/entities/user.ts";
+
+type RateLimitRuleConfig = {
+  limit: number;
+  windowMs: number;
+};
+
+type RateLimitRules = {
+  createPost: RateLimitRuleConfig;
+  createComment: RateLimitRuleConfig;
+  createBoardRequest: RateLimitRuleConfig;
+};
+
+export type AppDependencies = {
+  appName: string;
+  homePageTitle: string;
+  homePageHeading: string;
+  homePageDescription: string;
+  footerText: string;
+  assetsBasePath: string;
+  createPostUseCase: CreatePostUseCase;
+  listPostsUseCase: ListPostsUseCase;
+  listArchivedPostsUseCase: ListArchivedPostsUseCase;
+  getPostUseCase: GetPostUseCase;
+  createCommentUseCase: CreateCommentUseCase;
+  listCommentsUseCase: ListCommentsUseCase;
+  listBoardsUseCase: ListBoardsUseCase;
+  createBoardRequestUseCase: CreateBoardRequestUseCase;
+  toggleBoardRequestVoteUseCase: ToggleBoardRequestVoteUseCase;
+  getBoardBySlugUseCase: GetBoardBySlugUseCase;
+  getBoardRequestsOverviewUseCase: GetBoardRequestsOverviewUseCase;
+  listBoardAnnouncementsUseCase: ListBoardAnnouncementsUseCase;
+  listPopularPostsUseCase: ListPopularPostsUseCase;
+  imageStorage: ImageStorage;
+  publicDir: string;
+  uploadsBasePath: string;
+  rateLimiter: RateLimiter;
+  rateLimits: RateLimitRules;
+  threadTtlDays: number;
+  boardRequestWindowDays: number;
+  sessionSecret: string;
+  sessionCookieSecure: boolean;
+  trustProxy: boolean;
+  registerUserUseCase: RegisterUserUseCase;
+  authenticateUserUseCase: AuthenticateUserUseCase;
+  sessionStore: SessionStore;
+  userRepository: UserRepository;
+  memberBoard: {
+    slug: string;
+    name: string;
+    description: string;
+    themeColor: string;
+    welcomePost?: {
+      title?: string;
+      body: string;
+      permanent?: boolean;
+    };
+  };
+};
+
+const ASSET_CONTENT_TYPE: Record<string, string> = {
+  ".js": "application/javascript; charset=utf-8",
+  ".css": "text/css; charset=utf-8",
+  ".png": "image/png",
+  ".jpg": "image/jpeg",
+  ".jpeg": "image/jpeg",
+  ".gif": "image/gif",
+  ".webp": "image/webp",
+};
+
+const resolveContentType = (filename: string): string => {
+  const lastDotIndex = filename.lastIndexOf(".");
+  if (lastDotIndex === -1) {
+    return "application/octet-stream";
+  }
+  const extension = filename.slice(lastDotIndex);
+  return ASSET_CONTENT_TYPE[extension] ?? "application/octet-stream";
+};
+
+const extractSubPath = (url: string, prefix: string): string | null => {
+  const pathname = new URL(url).pathname;
+  if (!pathname.startsWith(prefix)) {
+    return null;
+  }
+  const relative = pathname.slice(prefix.length).replace(/^\/+/g, "");
+  return relative || null;
+};
+
+const normalizeBasePath = (value: string): { base: string; prefix: string } => {
+  const trimmed = value.trim();
+  const stripped = trimmed.replace(/^\/+/, "").replace(/\/+$/, "");
+  const base = `/${stripped}`;
+  const prefix = `${base}/`;
+  return { base, prefix };
+};
+
+const USER_COOKIE = "uid";
+const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
+const textEncoder = new TextEncoder();
+
+const getCookieValue = (cookies: string | null | undefined, name: string): string | null => {
+  if (!cookies) {
+    return null;
+  }
+  const match = cookies.match(new RegExp(`(?:^|;)\\s*${name}=([^;]+)`));
+  return match?.[1] ?? null;
+};
+
+const isPathInside = (base: string, target: string): boolean => {
+  const normalizedBase = normalize(base);
+  const basePrefix = normalizedBase.endsWith("/") ? normalizedBase : `${normalizedBase}/`;
+  const candidate = normalize(target);
+  if (candidate !== normalizedBase && !candidate.startsWith(basePrefix)) {
+    return false;
+  }
+  const related = relative(normalizedBase, candidate).replaceAll("\\", "/");
+  if (!related || related === "." || related === "./") {
+    return true;
+  }
+  return !related.split("/").some((segment) => segment === "..");
+};
+
+type CookieSigner = {
+  sign(value: string): Promise<string>;
+  verify(value: string, signature: string): Promise<boolean>;
+};
+
+const createCookieSigner = (secret: string): CookieSigner => {
+  const keyPromise = crypto.subtle.importKey(
+    "raw",
+    textEncoder.encode(secret),
+    { name: "HMAC", hash: "SHA-256" },
+    false,
+    ["sign", "verify"],
+  );
+
+  return {
+    sign: async (value: string): Promise<string> => {
+      const key = await keyPromise;
+      const signature = await crypto.subtle.sign("HMAC", key, textEncoder.encode(value));
+      return encodeBase64Url(new Uint8Array(signature));
+    },
+    verify: async (value: string, signature: string): Promise<boolean> => {
+      try {
+        const key = await keyPromise;
+        const signatureBytes = decodeBase64Url(signature);
+        return await crypto.subtle.verify("HMAC", key, signatureBytes, textEncoder.encode(value));
+      } catch (_error) {
+        return false;
+      }
+    },
+  };
+};
+
+const generateSessionId = (): string => {
+  const bytes = new Uint8Array(16);
+  crypto.getRandomValues(bytes);
+  return encodeBase64Url(bytes);
+};
+
+type IdentityOptions = {
+  cookieName: string;
+  secret: string;
+  secure: boolean;
+  trustProxy: boolean;
+  sessionStore: SessionStore;
+  userRepository: UserRepository;
+};
+
+type SessionState = {
+  id: string;
+  data: SessionData;
+  fresh: boolean;
+};
+
+const createIdentityService = (options: IdentityOptions) => {
+  const signer = createCookieSigner(options.secret);
+  const sessionCache = new WeakMap<Context, SessionState>();
+  const accountCache = new WeakMap<Context, User | null>();
+
+  const nowIso = () => new Date().toISOString();
+
+  const setSessionCookie = async (c: Context, sessionId: string) => {
+    const signature = await signer.sign(sessionId);
+    const attributes = [
+      `${options.cookieName}=${sessionId}.${signature}`,
+      "Path=/",
+      "HttpOnly",
+      "SameSite=Lax",
+      `Max-Age=${COOKIE_MAX_AGE_SECONDS}`,
+    ];
+    if (options.secure) {
+      attributes.push("Secure");
+    }
+    c.header("Set-Cookie", attributes.join("; "), { append: true });
+  };
+
+  const ensureSessionState = async (c: Context): Promise<SessionState> => {
+    const cached = sessionCache.get(c);
+    if (cached) {
+      return cached;
+    }
+
+    const cookies = c.req.header("cookie");
+    const existing = getCookieValue(cookies, options.cookieName);
+    let sessionId: string | null = null;
+    if (existing) {
+      const [id, signature] = existing.split(".");
+      if (id && signature && await signer.verify(id, signature)) {
+        sessionId = id;
+      }
+    }
+
+    let fresh = false;
+    if (!sessionId) {
+      sessionId = generateSessionId();
+      fresh = true;
+    }
+
+    await setSessionCookie(c, sessionId);
+
+    let data = await options.sessionStore.get(sessionId);
+    if (!data) {
+      const timestamp = nowIso();
+      data = {
+        userId: null,
+        createdAt: timestamp,
+        updatedAt: timestamp,
+      } satisfies SessionData;
+      await options.sessionStore.set(sessionId, data);
+    }
+
+    const state: SessionState = {
+      id: sessionId,
+      data: { ...data },
+      fresh,
+    };
+    sessionCache.set(c, state);
+    return state;
+  };
+
+  const persistSession = async (state: SessionState) => {
+    state.data.updatedAt = nowIso();
+    await options.sessionStore.set(state.id, { ...state.data });
+  };
+
+  const extractClientIp = (c: Context): string => {
+    if (options.trustProxy) {
+      const forwarded = c.req.header("x-forwarded-for");
+      if (forwarded) {
+        const forwardedIp = forwarded.split(",")[0].trim();
+        if (forwardedIp.length > 0) {
+          return forwardedIp;
+        }
+      }
+      const realIp = c.req.header("x-real-ip");
+      if (realIp?.trim()) {
+        return realIp.trim();
+      }
+    }
+
+    const remoteAddr = (c.env as { connInfo?: { remoteAddr?: Deno.Addr } } | undefined)
+      ?.connInfo?.remoteAddr;
+    if (remoteAddr && typeof remoteAddr === "object" && "hostname" in remoteAddr) {
+      return remoteAddr.hostname;
+    }
+
+    return "unknown";
+  };
+
+  const getCurrentUser = async (c: Context): Promise<User | null> => {
+    if (accountCache.has(c)) {
+      return accountCache.get(c) ?? null;
+    }
+    const session = await ensureSessionState(c);
+    if (!session.data.userId) {
+      accountCache.set(c, null);
+      return null;
+    }
+    const user = await options.userRepository.findById(session.data.userId);
+    if (!user) {
+      session.data.userId = null;
+      await persistSession(session);
+      accountCache.set(c, null);
+      return null;
+    }
+    accountCache.set(c, user);
+    return user;
+  };
+
+  const rotateSession = async (c: Context, account: User | null): Promise<SessionState> => {
+    const previous = await ensureSessionState(c);
+    await options.sessionStore.delete(previous.id);
+    sessionCache.delete(c);
+    accountCache.delete(c);
+
+    const timestamp = nowIso();
+    const data: SessionData = {
+      userId: account ? account.id : null,
+      createdAt: timestamp,
+      updatedAt: timestamp,
+    };
+    const newId = generateSessionId();
+    await setSessionCookie(c, newId);
+    await options.sessionStore.set(newId, data);
+    const state: SessionState = {
+      id: newId,
+      data: { ...data },
+      fresh: true,
+    };
+    sessionCache.set(c, state);
+    accountCache.set(c, account);
+    return state;
+  };
+
+  const signIn = async (c: Context, user: User) => {
+    await rotateSession(c, user);
+  };
+
+  const signOut = async (c: Context) => {
+    await rotateSession(c, null);
+  };
+
+  const identify = async (c: Context): Promise<RateLimitIdentity> => {
+    const session = await ensureSessionState(c);
+    const account = await getCurrentUser(c);
+    const clientIp = extractClientIp(c);
+    const bucket = account
+      ? `user:${account.id}`
+      : session.fresh
+      ? `ip:${clientIp}`
+      : `${session.id}:${clientIp}`;
+    return {
+      sessionId: session.id,
+      accountId: account?.id ?? null,
+      clientIp,
+      bucket,
+    };
+  };
+
+  const getVisitorId = async (c: Context): Promise<string> => {
+    const session = await ensureSessionState(c);
+    return session.id;
+  };
+
+  return {
+    ensureSession: ensureSessionState,
+    identify,
+    getCurrentUser,
+    signIn,
+    signOut,
+    getVisitorId,
+  };
+};
+
+const pullText = (value: unknown): string => {
+  if (typeof value === "string") {
+    return value;
+  }
+  if (Array.isArray(value) && value.length > 0) {
+    const first = value[0];
+    return typeof first === "string" ? first : "";
+  }
+  return "";
+};
+
+const pickFile = (value: unknown): File | null => {
+  if (value instanceof File) {
+    return value;
+  }
+  if (Array.isArray(value)) {
+    for (const entry of value) {
+      if (entry instanceof File) {
+        return entry;
+      }
+    }
+  }
+  return null;
+};
+
+const buildThreadModels = async (
+  posts: ImagePost[],
+  listCommentsUseCase: ListCommentsUseCase,
+): Promise<
+  Array<
+    Omit<ImagePost, "status" | "readOnly" | "archivedAt"> & {
+      status: "active" | "archived";
+      readOnly: boolean;
+      archivedAt: string | null;
+      comments: Comment[];
+    }
+  >
+> => {
+  const threads: Array<
+    Omit<ImagePost, "status" | "readOnly" | "archivedAt"> & {
+      status: "active" | "archived";
+      readOnly: boolean;
+      archivedAt: string | null;
+      comments: Comment[];
+    }
+  > = [];
+
+  for (const post of posts) {
+    const comments = await listCommentsUseCase.execute(post.id);
+    threads.push({
+      ...post,
+      status: (post.status ?? "active") as "active" | "archived",
+      readOnly: post.readOnly ?? false,
+      archivedAt: post.archivedAt ?? null,
+      comments,
+    });
+  }
+
+  return threads;
+};
+
+const parseJsonBody = async <T>(c: Context, errorMessage = "Invalid JSON payload."): Promise<T> => {
+  try {
+    return await c.req.json<T>();
+  } catch (_error) {
+    throw new ApplicationError(errorMessage);
+  }
+};
+
+export const createApp = (deps: AppDependencies) => {
+  const app = new Hono();
+  const identityService = createIdentityService({
+    cookieName: USER_COOKIE,
+    secret: deps.sessionSecret,
+    secure: deps.sessionCookieSecure,
+    trustProxy: deps.trustProxy,
+    sessionStore: deps.sessionStore,
+    userRepository: deps.userRepository,
+  });
+  const { base: assetBasePath, prefix: assetRoutePrefix } = normalizeBasePath(deps.assetsBasePath);
+  const { base: uploadsBasePath, prefix: uploadsRoutePrefix } = normalizeBasePath(
+    deps.uploadsBasePath,
+  );
+  const publicDir = normalize(deps.publicDir);
+
+  const enforceRateLimit = async (
+    action: keyof RateLimitRules,
+    identity: RateLimitIdentity,
+  ): Promise<void> => {
+    const rule = deps.rateLimits[action];
+    const allowed = await deps.rateLimiter.consume(`${action}:${identity.bucket}`, rule);
+    if (!allowed) {
+      throw new ApplicationError("Too many requests. Please slow down.", 429);
+    }
+  };
+
+  const ensureBoardAccess = async (c: Context, boardSlug: string): Promise<User | null> => {
+    const account = await identityService.getCurrentUser(c);
+    if (boardSlug === deps.memberBoard.slug && !account) {
+      throw new ApplicationError("Members only.", 403);
+    }
+    return account;
+  };
+
+  const ensurePostBoardAccess = async (
+    c: Context,
+    postId: string,
+  ): Promise<{ board: { id: string; slug: string }; account: User | null; post: ImagePost }> => {
+    const post = await deps.getPostUseCase.execute(postId);
+    const boards = await deps.listBoardsUseCase.execute();
+    const board = boards.find((entry) => entry.id === post.boardId);
+    if (!board) {
+      throw new ApplicationError("Board not found.", 404);
+    }
+    const account = await ensureBoardAccess(c, board.slug);
+    return { board: { id: board.id, slug: board.slug }, account, post };
+  };
+
+  app.use("*", async (_c, next) => {
+    try {
+      await next();
+    } catch (error) {
+      if (error instanceof ApplicationError) {
+        throw error;
+      }
+      console.error("Unhandled error", error);
+      throw new ApplicationError("Unexpected server error.", 500);
+    }
+  });
+
+  app.onError((error, c) => {
+    if (error instanceof ApplicationError) {
+      return Response.json({ message: error.message }, { status: error.status });
+    }
+    console.error(error);
+    return c.json({ message: "Internal Server Error" }, 500);
+  });
+
+  app.notFound((c) => c.json({ message: "Resource not found" }, 404));
+
+  app.get("/", async (c) => {
+    const account = await identityService.getCurrentUser(c);
+    const visitorId = await identityService.getVisitorId(c);
+    const voteUserId = account?.id ?? visitorId;
+    const url = new URL(c.req.url);
+    const successMessage = url.searchParams.get("success") ?? undefined;
+    const errorMessage = url.searchParams.get("error") ?? undefined;
+    const showRequestForm = url.searchParams.get("showRequestForm") === "1" ||
+      Boolean(errorMessage);
+    const activeAuthFormRaw = url.searchParams.get("auth");
+    const activeAuthForm = activeAuthFormRaw === "register"
+      ? "register"
+      : activeAuthFormRaw === "login"
+      ? "login"
+      : null;
+    const registerUsername = url.searchParams.get("registerUsername") ?? "";
+    const loginUsername = url.searchParams.get("loginUsername") ?? "";
+
+    const requestDefaults: {
+      slug?: string;
+      name?: string;
+      description?: string;
+      themeColor?: string;
+    } = {};
+
+    const slugParam = url.searchParams.get("slug");
+    if (slugParam) {
+      requestDefaults.slug = slugParam;
+    }
+    const nameParam = url.searchParams.get("name");
+    if (nameParam) {
+      requestDefaults.name = nameParam;
+    }
+    const descriptionParam = url.searchParams.get("description");
+    if (descriptionParam) {
+      requestDefaults.description = descriptionParam;
+    }
+    const themeParam = url.searchParams.get("themeColor");
+    if (themeParam) {
+      requestDefaults.themeColor = themeParam;
+    }
+
+    const boards = await deps.listBoardsUseCase.execute();
+    const visibleBoards = account
+      ? boards
+      : boards.filter((board) => board.slug !== deps.memberBoard.slug);
+    const requests = await deps.getBoardRequestsOverviewUseCase.execute(voteUserId);
+    const announcements = await deps.listBoardAnnouncementsUseCase.execute();
+    const popular = await deps.listPopularPostsUseCase.execute(6);
+    const visiblePopular = account
+      ? popular
+      : popular.filter((entry) => entry.boardSlug !== deps.memberBoard.slug);
+
+    return c.html(
+      renderHomePage({
+        title: deps.homePageTitle,
+        heading: deps.homePageHeading,
+        description: deps.homePageDescription,
+        assetBasePath,
+        threadTtlDays: deps.threadTtlDays,
+        requestDurationDays: deps.boardRequestWindowDays,
+        boards: visibleBoards.map((board) => ({
+          id: board.id,
+          slug: board.slug,
+          name: board.name,
+          description: board.description,
+          themeColor: board.themeColor,
+        })),
+        requests: requests
+          .filter((request) => request.status === "open")
+          .map((request) => ({
+            id: request.id,
+            slug: request.slug,
+            name: request.name,
+            description: request.description,
+            themeColor: request.themeColor,
+            voteCount: request.voteCount,
+            deadline: request.deadline,
+            status: request.status,
+            voted: request.voted,
+          })),
+        announcements,
+        popularPosts: visiblePopular.map((entry) => ({
+          id: entry.post.id,
+          boardSlug: entry.boardSlug,
+          boardName: entry.boardName,
+          title: entry.post.title,
+          commentCount: entry.post.commentCount,
+        })),
+        flash: {
+          success: successMessage,
+          error: errorMessage,
+        },
+        showRequestForm,
+        requestFormDefaults: requestDefaults,
+        currentUser: account
+          ? {
+            username: account.username,
+          }
+          : null,
+        memberBoard: deps.memberBoard,
+        activeAuthForm,
+        loginPrefill: loginUsername,
+        registerPrefill: registerUsername,
+        footerText: deps.footerText,
+      }),
+    );
+  });
+
+  app.get(`${assetBasePath}/*`, async (c) => {
+    const resource = extractSubPath(c.req.url, assetRoutePrefix);
+    if (!resource) {
+      return c.json({ message: "Asset path missing" }, 400);
+    }
+    const filePath = normalize(join(publicDir, resource));
+    if (!isPathInside(publicDir, filePath)) {
+      return c.json({ message: "Invalid asset path" }, 400);
+    }
+
+    try {
+      const file = await Deno.readFile(filePath);
+      const source = file.buffer as ArrayBuffer;
+      const body = source.slice(file.byteOffset, file.byteOffset + file.byteLength);
+      return new Response(body, {
+        headers: { "Content-Type": resolveContentType(resource) },
+      });
+    } catch (error) {
+      if (error instanceof Deno.errors.NotFound) {
+        return c.json({ message: "Asset not found" }, 404);
+      }
+      throw error;
+    }
+  });
+
+  app.get(`${uploadsBasePath}/*`, async (c) => {
+    const resource = extractSubPath(c.req.url, uploadsRoutePrefix);
+    if (!resource) {
+      return c.json({ message: "Image path missing" }, 400);
+    }
+    const publicPath = `${uploadsBasePath}/${resource}`.replace(/\/+/g, "/");
+
+    try {
+      const data = await deps.imageStorage.getImage(publicPath);
+      const source = data.buffer as ArrayBuffer;
+      const body = source.slice(data.byteOffset, data.byteOffset + data.byteLength);
+      return new Response(body, {
+        headers: { "Content-Type": resolveContentType(resource) },
+      });
+    } catch (error) {
+      if (error instanceof Deno.errors.NotFound) {
+        return c.json({ message: "Image not found" }, 404);
+      }
+      throw error;
+    }
+  });
+
+  app.get("/api/boards", async (_c) => {
+    const boards = await deps.listBoardsUseCase.execute();
+    return Response.json(boards);
+  });
+
+  app.get("/api/boards/:slug/posts", async (c) => {
+    const board = await deps.getBoardBySlugUseCase.execute(c.req.param("slug"));
+    await ensureBoardAccess(c, board.slug);
+    const posts = await deps.listPostsUseCase.execute(board.id);
+    return c.json(posts);
+  });
+
+  app.get("/api/boards/:slug/posts/archive", async (c) => {
+    const board = await deps.getBoardBySlugUseCase.execute(c.req.param("slug"));
+    await ensureBoardAccess(c, board.slug);
+    const posts = await deps.listArchivedPostsUseCase.execute(board.id);
+    return c.json(posts);
+  });
+
+  app.get("/api/posts/:id", async (c) => {
+    const { post } = await ensurePostBoardAccess(c, c.req.param("id"));
+    return c.json(post);
+  });
+
+  app.post("/api/boards/:slug/posts", async (c) => {
+    const identity = await identityService.identify(c);
+    await enforceRateLimit("createPost", identity);
+    const board = await deps.getBoardBySlugUseCase.execute(c.req.param("slug"));
+    await ensureBoardAccess(c, board.slug);
+    const body = await c.req.parseBody();
+    const title = pullText(body.title ?? "").trim();
+    const description = pullText(body.description ?? "").trim();
+    const imageFile = pickFile(body.image);
+    let imagePayload:
+      | {
+        data: Uint8Array;
+        mimeType: string;
+        originalName: string;
+      }
+      | undefined;
+    if (imageFile && imageFile.size > 0) {
+      const arrayBuffer = await imageFile.arrayBuffer();
+      imagePayload = {
+        data: new Uint8Array(arrayBuffer),
+        mimeType: imageFile.type,
+        originalName: imageFile.name,
+      };
+    }
+
+    const post = await deps.createPostUseCase.execute({
+      boardId: board.id,
+      title: title.length > 0 ? title : undefined,
+      description,
+      image: imagePayload,
+    });
+
+    return c.json(post, 201);
+  });
+
+  app.get("/api/posts/:id/comments", async (c) => {
+    await ensurePostBoardAccess(c, c.req.param("id"));
+    const comments = await deps.listCommentsUseCase.execute(c.req.param("id"));
+    return c.json(comments);
+  });
+
+  app.post("/api/posts/:id/comments", async (c) => {
+    const postId = c.req.param("id");
+    const contentType = c.req.header("content-type") ?? "";
+    const identity = await identityService.identify(c);
+    await enforceRateLimit("createComment", identity);
+    const { account } = await ensurePostBoardAccess(c, postId);
+
+    if (contentType.includes("application/json")) {
+      const payload = await parseJsonBody<{ author?: string; body?: string; parentId?: string }>(
+        c,
+        "Invalid comment payload.",
+      );
+      const comment = await deps.createCommentUseCase.execute({
+        postId,
+        author: payload.author ?? "",
+        body: payload.body ?? "",
+        parentCommentId: payload.parentId ?? undefined,
+        authorUserId: account?.id,
+        authorUsername: account?.username,
+      });
+      return c.json(comment, 201);
+    }
+
+    const body = await c.req.parseBody();
+    const author = pullText(body.author ?? body.name ?? "");
+    const commentBody = pullText(body.body ?? body.comment ?? "");
+    const parentIdRaw = pullText(body.parentId ?? body.parentCommentId ?? "").trim();
+    const imageFile = pickFile(body.image);
+
+    let imagePayload: {
+      data: Uint8Array;
+      mimeType: string;
+      originalName: string;
+    } | undefined;
+
+    if (imageFile && imageFile.size > 0) {
+      const arrayBuffer = await imageFile.arrayBuffer();
+      imagePayload = {
+        data: new Uint8Array(arrayBuffer),
+        mimeType: imageFile.type,
+        originalName: imageFile.name,
+      };
+    }
+
+    const comment = await deps.createCommentUseCase.execute({
+      postId,
+      author,
+      body: commentBody,
+      parentCommentId: parentIdRaw.length > 0 ? parentIdRaw : undefined,
+      image: imagePayload,
+      authorUserId: account?.id,
+      authorUsername: account?.username,
+    });
+    return c.json(comment, 201);
+  });
+
+  app.get("/api/board-requests", async (c) => {
+    const identity = await identityService.identify(c);
+    const userId = identity.accountId ?? identity.sessionId;
+    const requests = await deps.getBoardRequestsOverviewUseCase.execute(userId);
+    return c.json(requests);
+  });
+
+  app.post("/api/board-requests", async (c) => {
+    const identity = await identityService.identify(c);
+    await enforceRateLimit("createBoardRequest", identity);
+    const payload = await parseJsonBody<
+      { slug: string; name: string; description: string; themeColor: string }
+    >(c, "Invalid board request payload.");
+    const request = await deps.createBoardRequestUseCase.execute({
+      slug: payload.slug,
+      name: payload.name,
+      description: payload.description,
+      themeColor: payload.themeColor,
+    });
+    return c.json(request, 201);
+  });
+
+  app.post("/api/board-requests/:id/toggle-vote", async (c) => {
+    const identity = await identityService.identify(c);
+    const userId = identity.accountId ?? identity.sessionId;
+    const result = await deps.toggleBoardRequestVoteUseCase.execute({
+      requestId: c.req.param("id"),
+      userId,
+    });
+    return c.json(result);
+  });
+
+  app.post("/board-requests", async (c) => {
+    const identity = await identityService.identify(c);
+    await enforceRateLimit("createBoardRequest", identity);
+    const body = await c.req.parseBody();
+    const slug = pullText(body.slug).trim();
+    const name = pullText(body.name).trim();
+    const description = pullText(body.description).trim();
+    const themeColor = pullText(body.themeColor).trim();
+
+    try {
+      await deps.createBoardRequestUseCase.execute({
+        slug,
+        name,
+        description,
+        themeColor,
+      });
+      const params = new URLSearchParams({
+        success: "Request submitted for review.",
+        showRequestForm: "1",
+      });
+      return c.redirect(`/?${params.toString()}`, 303);
+    } catch (error) {
+      if (error instanceof ApplicationError) {
+        const params = new URLSearchParams({
+          error: error.message,
+          showRequestForm: "1",
+        });
+        if (slug) params.set("slug", slug);
+        if (name) params.set("name", name);
+        if (description) params.set("description", description);
+        if (themeColor) params.set("themeColor", themeColor);
+        return c.redirect(`/?${params.toString()}`, 303);
+      }
+      throw error;
+    }
+  });
+
+  app.post("/board-requests/:id/toggle-vote", async (c) => {
+    const identity = await identityService.identify(c);
+    const userId = identity.accountId ?? identity.sessionId;
+    const requestId = c.req.param("id");
+    try {
+      const result = await deps.toggleBoardRequestVoteUseCase.execute({
+        requestId,
+        userId,
+      });
+      const params = new URLSearchParams({
+        success: result.voted ? "Vote recorded." : "Vote removed.",
+        showRequestForm: "1",
+      });
+      return c.redirect(`/?${params.toString()}`, 303);
+    } catch (error) {
+      if (error instanceof ApplicationError) {
+        const params = new URLSearchParams({
+          error: error.message,
+          showRequestForm: "1",
+        });
+        return c.redirect(`/?${params.toString()}`, 303);
+      }
+      throw error;
+    }
+  });
+
+  app.post("/auth/register", async (c) => {
+    const body = await c.req.parseBody();
+    const username = pullText(body.username).trim();
+    const password = pullText(body.password);
+    try {
+      const user = await deps.registerUserUseCase.execute({ username, password });
+      await identityService.signIn(c, user);
+      const params = new URLSearchParams({ success: `Welcome, ${user.username}!` });
+      return c.redirect(`/?${params.toString()}`, 303);
+    } catch (error) {
+      if (error instanceof ApplicationError) {
+        const params = new URLSearchParams({ error: error.message, auth: "register" });
+        if (username) {
+          params.set("registerUsername", username);
+        }
+        return c.redirect(`/?${params.toString()}`, 303);
+      }
+      throw error;
+    }
+  });
+
+  app.post("/auth/login", async (c) => {
+    const body = await c.req.parseBody();
+    const username = pullText(body.username).trim();
+    const password = pullText(body.password);
+    try {
+      const user = await deps.authenticateUserUseCase.execute({ username, password });
+      await identityService.signIn(c, user);
+      const params = new URLSearchParams({ success: `Welcome back, ${user.username}!` });
+      return c.redirect(`/?${params.toString()}`, 303);
+    } catch (error) {
+      if (error instanceof ApplicationError) {
+        const params = new URLSearchParams({ error: error.message, auth: "login" });
+        if (username) {
+          params.set("loginUsername", username);
+        }
+        return c.redirect(`/?${params.toString()}`, 303);
+      }
+      throw error;
+    }
+  });
+
+  app.post("/auth/logout", async (c) => {
+    await identityService.signOut(c);
+    const params = new URLSearchParams({ success: "Signed out." });
+    return c.redirect(`/?${params.toString()}`, 303);
+  });
+
+  app.post("/:slug/posts", async (c) => {
+    const slug = c.req.param("slug");
+    if (["assets", "uploads", "api"].includes(slug) || slug === "") {
+      return c.notFound();
+    }
+
+    const identity = await identityService.identify(c);
+    await enforceRateLimit("createPost", identity);
+    const board = await deps.getBoardBySlugUseCase.execute(slug);
+    await ensureBoardAccess(c, board.slug);
+    const body = await c.req.parseBody();
+    const title = pullText(body.title).trim();
+    const description = pullText(body.description).trim();
+    const imageFile = pickFile(body.image);
+
+    try {
+      let imagePayload:
+        | {
+          data: Uint8Array;
+          mimeType: string;
+          originalName: string;
+        }
+        | undefined;
+
+      if (imageFile && imageFile.size > 0) {
+        const arrayBuffer = await imageFile.arrayBuffer();
+        imagePayload = {
+          data: new Uint8Array(arrayBuffer),
+          mimeType: imageFile.type,
+          originalName: imageFile.name,
+        };
+      }
+
+      const post = await deps.createPostUseCase.execute({
+        boardId: board.id,
+        title: title.length > 0 ? title : undefined,
+        description,
+        image: imagePayload,
+      });
+      const params = new URLSearchParams({ success: "Thread posted." });
+      return c.redirect(`/${board.slug}?${params.toString()}#post-${post.id}`, 303);
+    } catch (error) {
+      if (error instanceof ApplicationError) {
+        const params = new URLSearchParams({ error: error.message });
+        return c.redirect(`/${board.slug}?${params.toString()}#post-form`, 303);
+      }
+      throw error;
+    }
+  });
+
+  app.post("/posts/:id/comments", async (c) => {
+    const postId = c.req.param("id");
+    const identity = await identityService.identify(c);
+    await enforceRateLimit("createComment", identity);
+    const body = await c.req.parseBody();
+    const boardSlugRaw = pullText(body.boardSlug).trim();
+    const author = pullText(body.author ?? body.name ?? "");
+    const commentBody = pullText(body.body ?? body.comment ?? "").trim();
+    const parentIdRaw = pullText(body.parentCommentId ?? body.parentId ?? "").trim();
+    const imageFile = pickFile(body.image);
+
+    const { board, account } = await ensurePostBoardAccess(c, postId);
+
+    const redirectSlug = boardSlugRaw || board.slug;
+
+    const targetSlug = redirectSlug || "";
+
+    const buildRedirectUrl = (params: URLSearchParams, anchor?: string) => {
+      const base = targetSlug ? `/${targetSlug}` : "/";
+      const query = params.toString();
+      return `${base}${query ? `?${query}` : ""}${anchor ?? ""}`;
+    };
+
+    try {
+      const imagePayload = imageFile && imageFile.size > 0
+        ? {
+          data: new Uint8Array(await imageFile.arrayBuffer()),
+          mimeType: imageFile.type,
+          originalName: imageFile.name,
+        }
+        : undefined;
+
+      const comment = await deps.createCommentUseCase.execute({
+        postId,
+        author,
+        body: commentBody,
+        parentCommentId: parentIdRaw.length > 0 ? parentIdRaw : undefined,
+        image: imagePayload,
+        authorUserId: account?.id,
+        authorUsername: account?.username,
+      });
+
+      const params = new URLSearchParams({ success: "Reply posted." });
+      return c.redirect(buildRedirectUrl(params, `#comment-${comment.id}`), 303);
+    } catch (error) {
+      if (error instanceof ApplicationError) {
+        const params = new URLSearchParams({ error: error.message });
+        const replyTarget = parentIdRaw.length > 0 ? parentIdRaw : postId;
+        params.set("reply", replyTarget);
+        return c.redirect(buildRedirectUrl(params, `#reply-form-${postId}`), 303);
+      }
+      throw error;
+    }
+  });
+
+  app.get("/:slug/archive", async (c) => {
+    const slug = c.req.param("slug");
+    if (["assets", "uploads", "api"].includes(slug)) {
+      return c.notFound();
+    }
+    try {
+      const board = await deps.getBoardBySlugUseCase.execute(slug);
+      const account = await ensureBoardAccess(c, board.slug);
+      const posts = await deps.listArchivedPostsUseCase.execute(board.id);
+      const threads = await buildThreadModels(posts, deps.listCommentsUseCase);
+      const url = new URL(c.req.url);
+      const flash = {
+        success: url.searchParams.get("success") ?? undefined,
+        error: url.searchParams.get("error") ?? undefined,
+      };
+
+      return c.html(
+        renderAppPage({
+          mode: "boardArchive",
+          title: `${board.name} Archive - ${deps.appName}`,
+          heading: `/${board.slug}/ - Archive`,
+          description: board.description,
+          assetBasePath,
+          board: {
+            id: board.id,
+            slug: board.slug,
+            name: board.name,
+            description: board.description,
+            themeColor: board.themeColor,
+            readOnly: true,
+          },
+          threadTtlDays: deps.threadTtlDays,
+          posts: threads,
+          flash,
+          currentUser: account ? { username: account.username } : null,
+          isMemberBoard: board.slug === deps.memberBoard.slug,
+        }),
+      );
+    } catch (error) {
+      if (error instanceof ApplicationError && error.status === 403) {
+        const params = new URLSearchParams({
+          error: "Members only. Sign in to access this board.",
+        });
+        return c.redirect(`/?${params.toString()}`, 303);
+      }
+      throw error;
+    }
+  });
+
+  app.get("/:slug", async (c) => {
+    const slug = c.req.param("slug");
+    if (["assets", "uploads", "api"].includes(slug) || slug === "") {
+      return c.notFound();
+    }
+    try {
+      const board = await deps.getBoardBySlugUseCase.execute(slug);
+      const account = await ensureBoardAccess(c, board.slug);
+      const posts = await deps.listPostsUseCase.execute(board.id);
+      const threads = await buildThreadModels(posts, deps.listCommentsUseCase);
+      const url = new URL(c.req.url);
+      const flash = {
+        success: url.searchParams.get("success") ?? undefined,
+        error: url.searchParams.get("error") ?? undefined,
+      };
+
+      const replyId = url.searchParams.get("reply") ?? undefined;
+      let replyTarget: { type: "post" | "comment"; id: string } | null = null;
+      if (replyId) {
+        if (threads.some((thread) => thread.id === replyId)) {
+          replyTarget = { type: "post", id: replyId };
+        } else {
+          for (const thread of threads) {
+            if (thread.comments.some((comment) => comment.id === replyId)) {
+              replyTarget = { type: "comment", id: replyId };
+              break;
+            }
+          }
+        }
+      }
+
+      return c.html(
+        renderAppPage({
+          mode: "board",
+          title: `${board.name} - ${deps.appName}`,
+          heading: `/${board.slug}/ - ${board.name}`,
+          description: board.description,
+          assetBasePath,
+          board: {
+            id: board.id,
+            slug: board.slug,
+            name: board.name,
+            description: board.description,
+            themeColor: board.themeColor,
+            readOnly: false,
+          },
+          threadTtlDays: deps.threadTtlDays,
+          posts: threads,
+          flash,
+          replyTarget,
+          currentUser: account ? { username: account.username } : null,
+          isMemberBoard: board.slug === deps.memberBoard.slug,
+        }),
+      );
+    } catch (error) {
+      if (error instanceof ApplicationError && error.status === 403) {
+        const params = new URLSearchParams({
+          error: "Members only. Sign in to access this board.",
+        });
+        return c.redirect(`/?${params.toString()}`, 303);
+      }
+      throw error;
+    }
+  });
+
+  return app;
+};
diff --git a/src/interfaces/http/views/app_page.ts b/src/interfaces/http/views/app_page.ts
new file mode 100644 (file)
index 0000000..c92da77
--- /dev/null
@@ -0,0 +1,429 @@
+import { h } from "preact";
+import type { JSX } from "preact";
+import { type FlashState, renderLayout } from "./shared.ts";
+
+type BoardContext = {
+  id: string;
+  slug: string;
+  name: string;
+  description: string;
+  themeColor: string;
+  readOnly: boolean;
+};
+
+type ThreadComment = {
+  id: string;
+  postId: string;
+  parentCommentId?: string;
+  author: string;
+  body: string;
+  imagePath?: string;
+  createdAt: string;
+};
+
+type Thread = {
+  id: string;
+  title: string;
+  description: string;
+  imagePath: string;
+  createdAt: string;
+  commentCount: number;
+  status: "active" | "archived";
+  readOnly: boolean;
+  archivedAt?: string | null;
+  permanent?: boolean;
+  comments: ThreadComment[];
+};
+
+type ReplyTarget = { type: "post"; id: string } | { type: "comment"; id: string };
+
+export interface RenderBoardPageOptions {
+  mode: "board" | "boardArchive";
+  title: string;
+  heading: string;
+  description: string;
+  assetBasePath: string;
+  board: BoardContext;
+  threadTtlDays: number;
+  posts: Thread[];
+  flash?: FlashState;
+  replyTarget?: ReplyTarget | null;
+  currentUser: { username: string } | null;
+  isMemberBoard: boolean;
+}
+
+type CommentNode = ThreadComment & { children: CommentNode[] };
+
+const shortId = (id: string): string => id.slice(0, 8);
+
+const formatTimestamp = (value: string): string => {
+  const date = new Date(value);
+  if (Number.isNaN(date.getTime())) {
+    return value;
+  }
+  return date.toLocaleString();
+};
+
+const buildCommentTree = (comments: ThreadComment[]): CommentNode[] => {
+  const nodes = new Map<string, CommentNode>();
+  for (const comment of comments) {
+    nodes.set(comment.id, { ...comment, children: [] });
+  }
+
+  const roots: CommentNode[] = [];
+  for (const comment of comments) {
+    const node = nodes.get(comment.id);
+    if (!node) {
+      continue;
+    }
+    if (comment.parentCommentId && nodes.has(comment.parentCommentId)) {
+      nodes.get(comment.parentCommentId)?.children.push(node);
+    } else {
+      roots.push(node);
+    }
+  }
+
+  const sort = (items: CommentNode[]) => {
+    items.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
+    for (const item of items) {
+      sort(item.children);
+    }
+  };
+
+  sort(roots);
+  return roots;
+};
+
+const createCommentForm = (options: {
+  boardSlug: string;
+  postId: string;
+  parentCommentId?: string;
+  formId: string;
+  currentUser: { username: string } | null;
+}) => {
+  const authorId = `${options.formId}-author`;
+  const bodyId = `${options.formId}-body`;
+  const imageId = `${options.formId}-image`;
+
+  return h(
+    "form",
+    {
+      class: "comment-form",
+      method: "post",
+      action: `/posts/${options.postId}/comments`,
+      encType: "multipart/form-data",
+      id: options.formId,
+    },
+    [
+      h("input", { type: "hidden", name: "boardSlug", value: options.boardSlug }),
+      options.parentCommentId &&
+      h("input", { type: "hidden", name: "parentCommentId", value: options.parentCommentId }),
+      options.currentUser
+        ? h("p", { class: "muted" }, `Posting as ${options.currentUser.username}`)
+        : [
+          h("label", { htmlFor: authorId }, "Name"),
+          h("input", {
+            id: authorId,
+            name: "author",
+            placeholder: "Anonymous",
+          }),
+        ],
+      options.currentUser &&
+      h("input", { type: "hidden", name: "author", value: "" }),
+      h("label", { htmlFor: bodyId }, "Reply"),
+      h("textarea", {
+        id: bodyId,
+        name: "body",
+        rows: 3,
+        required: true,
+      }),
+      h("label", { htmlFor: imageId }, "Image"),
+      h("input", {
+        id: imageId,
+        name: "image",
+        type: "file",
+        accept: "image/*",
+      }),
+      h("button", { type: "submit" }, "Submit"),
+    ],
+  );
+};
+
+const renderCommentNode = (
+  node: CommentNode,
+  context: {
+    boardSlug: string;
+    postId: string;
+    commentLookup: Set<string>;
+    replyTargetCommentId?: string;
+  },
+): JSX.Element => {
+  const parentAnchor = node.parentCommentId
+    ? context.commentLookup.has(node.parentCommentId)
+      ? `comment-${node.parentCommentId}`
+      : `post-${node.parentCommentId}`
+    : null;
+  const isHighlighted = context.replyTargetCommentId === node.id;
+  const replies = node.children;
+
+  return h(
+    "div",
+    {
+      class: `reply${isHighlighted ? " highlight" : ""}`,
+      id: `comment-${node.id}`,
+      key: node.id,
+    },
+    [
+      h("header", null, [
+        h("span", { class: "post-meta" }, node.author || "Anonymous"),
+        h("span", { class: "post-date" }, formatTimestamp(node.createdAt)),
+        h("a", { class: "id-link", href: `#comment-${node.id}` }, `No.${shortId(node.id)}`),
+        parentAnchor &&
+        h("span", { class: "parent-ref" }, [
+          ">>",
+          h(
+            "a",
+            { class: "id-link parent-link", href: `#${parentAnchor}` },
+            shortId(node.parentCommentId!),
+          ),
+        ]),
+        replies.length > 0 &&
+        h(
+          "span",
+          { class: "reply-list" },
+          [
+            "Replies:",
+            ...replies.map((child) =>
+              h(
+                "a",
+                { class: "id-link reply-ref", href: `#comment-${child.id}`, key: child.id },
+                shortId(child.id),
+              )
+            ),
+          ],
+        ),
+        h(
+          "a",
+          {
+            class: "id-link",
+            href: `/${context.boardSlug}?reply=${node.id}#reply-form-${context.postId}`,
+          },
+          "Reply",
+        ),
+      ]),
+      node.imagePath &&
+      h("div", { class: "comment-attachment" }, [
+        h(
+          "a",
+          { href: node.imagePath, target: "_blank", rel: "noopener" },
+          h("img", {
+            src: node.imagePath,
+            alt: `reply ${shortId(node.id)} attachment`,
+          }),
+        ),
+      ]),
+      h("div", { class: "thread-text" }, node.body),
+      ...replies.map((child) => renderCommentNode(child, context)),
+    ],
+  );
+};
+
+const renderThread = (
+  post: Thread,
+  context: {
+    board: BoardContext;
+    mode: "board" | "boardArchive";
+    replyTarget?: ReplyTarget | null;
+    currentUser: { username: string } | null;
+  },
+) => {
+  const canReplyToPost = context.mode === "board" && !context.board.readOnly && !post.readOnly &&
+    post.status === "active";
+  const commentLookup = new Set(post.comments.map((comment) => comment.id));
+  const replyCommentId =
+    context.replyTarget?.type === "comment" && commentLookup.has(context.replyTarget.id)
+      ? context.replyTarget.id
+      : undefined;
+  const isReplyingToPost = context.replyTarget?.type === "post" &&
+    context.replyTarget.id === post.id;
+  const commentTree = buildCommentTree(post.comments);
+  const title = post.title?.trim() ?? "";
+  const description = post.description.trim();
+  const hasImage = Boolean(post.imagePath);
+  const replies = post.comments.length === 0
+    ? [h("p", { class: "muted" }, "No replies yet.")]
+    : commentTree.map((node) =>
+      renderCommentNode(node, {
+        boardSlug: context.board.slug,
+        postId: post.id,
+        commentLookup,
+        replyTargetCommentId: replyCommentId,
+      })
+    );
+
+  return h(
+    "article",
+    { class: "thread", id: `post-${post.id}`, key: post.id },
+    [
+      h("header", null, [
+        h("span", { class: "post-meta" }, `/${context.board.slug}/`),
+        title && h("strong", null, title),
+        h("span", { class: "post-date" }, formatTimestamp(post.createdAt)),
+        h(
+          "span",
+          { class: "muted" },
+          `${post.commentCount} repl${post.commentCount === 1 ? "y" : "ies"}`,
+        ),
+        h("span", { class: "muted" }, `No.${shortId(post.id)}`),
+        post.status === "archived" && h("span", { class: "status-pill" }, "Archived"),
+        post.readOnly && h("span", { class: "status-pill" }, "Read-only"),
+        post.permanent && h("span", { class: "status-pill" }, "Pinned"),
+        h(
+          "a",
+          {
+            class: "id-link",
+            href: `/${context.board.slug}?reply=${post.id}#reply-form-${post.id}`,
+          },
+          "Reply to thread",
+        ),
+      ]),
+      (() => {
+        const bodyChildren: JSX.Element[] = [];
+        if (hasImage && post.imagePath) {
+          bodyChildren.push(
+            h("div", { class: "attachment" }, [
+              h(
+                "a",
+                { href: post.imagePath, target: "_blank", rel: "noopener" },
+                h("img", {
+                  src: post.imagePath,
+                  alt: title || `thread ${shortId(post.id)} image`,
+                }),
+              ),
+            ]),
+          );
+        }
+        bodyChildren.push(
+          h("div", { class: "thread-text" }, [
+            description ? description : h("span", { class: "muted" }, "No description."),
+          ]),
+        );
+        return h("div", { class: `thread-body${hasImage ? "" : " no-image"}` }, bodyChildren);
+      })(),
+      h("div", { class: "thread-replies" }, replies),
+      context.mode === "board" && (
+        canReplyToPost
+          ? h("div", { class: "thread-reply", id: `reply-form-${post.id}` }, [
+            h("h3", { class: "section-title" }, "Reply to this thread"),
+            replyCommentId &&
+            h("p", { class: "muted" }, `Replying to No.${shortId(replyCommentId)}`),
+            !replyCommentId && isReplyingToPost &&
+            h("p", { class: "muted" }, "Replying to this thread."),
+            createCommentForm({
+              boardSlug: context.board.slug,
+              postId: post.id,
+              parentCommentId: replyCommentId,
+              formId: `comment-form-${post.id}`,
+              currentUser: context.currentUser,
+            }),
+          ])
+          : h(
+            "p",
+            { class: "muted" },
+            "Thread is read-only.",
+          )
+      ),
+    ],
+  );
+};
+
+const renderPostForm = (board: BoardContext) =>
+  h(
+    "form",
+    {
+      class: "post-form",
+      method: "post",
+      action: `/${board.slug}/posts`,
+      encType: "multipart/form-data",
+      id: "post-form",
+    },
+    [
+      h("h2", { class: "section-title" }, "Start a new thread"),
+      h("div", { class: "form-grid" }, [
+        h("label", { htmlFor: "thread-title" }, "Subject (optional)"),
+        h("input", { id: "thread-title", name: "title" }),
+        h("label", { htmlFor: "thread-description" }, "Comment"),
+        h("textarea", { id: "thread-description", name: "description", rows: 4, required: true }),
+        h("label", { htmlFor: "thread-image" }, "File (optional)"),
+        h("input", {
+          id: "thread-image",
+          type: "file",
+          name: "image",
+          accept: "image/*",
+        }),
+      ]),
+      h("div", { class: "form-actions" }, [
+        h("button", { type: "submit" }, "Post Thread"),
+      ]),
+    ],
+  );
+
+export const renderAppPage = (options: RenderBoardPageOptions): string => {
+  const navLinks = [
+    h("a", { href: "/" }, "Home"),
+    options.mode === "board"
+      ? h("a", { href: `/${options.board.slug}/archive` }, "View archive")
+      : h("a", { href: `/${options.board.slug}` }, "View board"),
+  ];
+
+  const content = h("main", null, [
+    h("section", { class: "board-header" }, [
+      h("div", { class: "board-nav" }, navLinks),
+      h("h1", { class: "board-title" }, options.heading),
+      h("p", { class: "board-desc" }, options.description),
+      h(
+        "p",
+        { class: "muted" },
+        options.mode === "board"
+          ? `Threads archive after ${options.threadTtlDays} day${
+            options.threadTtlDays === 1 ? "" : "s"
+          }.`
+          : "Archive view Â· threads are read-only.",
+      ),
+      options.isMemberBoard && h("span", { class: "status-pill" }, "Members only"),
+      options.currentUser &&
+      h("p", { class: "muted" }, `Signed in as ${options.currentUser.username}.`),
+    ]),
+    options.mode === "board" && !options.board.readOnly
+      ? renderPostForm(options.board)
+      : options.mode === "board" &&
+        h("p", { class: "muted" }, "Posting new threads is disabled for this board."),
+    options.posts.length === 0
+      ? h(
+        "p",
+        { class: "muted" },
+        options.mode === "board"
+          ? "No threads yet. Start the conversation above."
+          : "No archived threads.",
+      )
+      : h("section", { class: "threads" }, [
+        ...options.posts.map((post) =>
+          renderThread(post, {
+            board: options.board,
+            mode: options.mode,
+            replyTarget: options.replyTarget,
+            currentUser: options.currentUser,
+          })
+        ),
+      ]),
+  ]);
+
+  return renderLayout({
+    title: options.title,
+    bodyClass: "board",
+    brandColor: options.board.themeColor,
+    assetBasePath: options.assetBasePath,
+    flash: options.flash,
+    children: content,
+  });
+};
diff --git a/src/interfaces/http/views/home_page.ts b/src/interfaces/http/views/home_page.ts
new file mode 100644 (file)
index 0000000..fc1ae20
--- /dev/null
@@ -0,0 +1,480 @@
+import { h } from "preact";
+import { createAccentStyle, type FlashState, renderLayout, sanitizeHex } from "./shared.ts";
+
+interface BoardSummary {
+  id: string;
+  slug: string;
+  name: string;
+  description: string;
+  themeColor: string;
+}
+
+interface BoardRequestSummary {
+  id: string;
+  slug: string;
+  name: string;
+  description: string;
+  themeColor: string;
+  voteCount: number;
+  deadline: string;
+  status: string;
+  voted: boolean;
+}
+
+interface Announcement {
+  id: string;
+  message: string;
+  createdAt: string;
+}
+
+interface PopularPostSummary {
+  id: string;
+  boardSlug: string;
+  boardName: string;
+  title?: string;
+  commentCount: number;
+}
+
+type RequestFormDefaults = {
+  slug?: string;
+  name?: string;
+  description?: string;
+  themeColor?: string;
+};
+
+export interface RenderHomePageOptions {
+  title: string;
+  heading: string;
+  description: string;
+  assetBasePath: string;
+  threadTtlDays: number;
+  requestDurationDays: number;
+  boards: BoardSummary[];
+  requests: BoardRequestSummary[];
+  announcements: Announcement[];
+  popularPosts: PopularPostSummary[];
+  flash?: FlashState;
+  showRequestForm?: boolean;
+  requestFormDefaults?: RequestFormDefaults;
+  currentUser: { username: string } | null;
+  memberBoard: {
+    slug: string;
+    name: string;
+    description: string;
+    themeColor: string;
+  };
+  activeAuthForm: "login" | "register" | null;
+  loginPrefill?: string;
+  registerPrefill?: string;
+  footerText: string;
+}
+
+const formatTimeRemaining = (deadlineIso: string): string => {
+  const deadline = new Date(deadlineIso).getTime();
+  if (Number.isNaN(deadline)) {
+    return "Unknown deadline";
+  }
+  const diff = deadline - Date.now();
+  if (diff <= 0) {
+    return "Voting closed";
+  }
+  const minutes = Math.floor(diff / (1000 * 60));
+  if (minutes < 60) {
+    return `${minutes} min left`;
+  }
+  const hours = Math.floor(minutes / 60);
+  if (hours < 24) {
+    return `${hours} hr left`;
+  }
+  const days = Math.floor(hours / 24);
+  return `${days} day${days === 1 ? "" : "s"} left`;
+};
+
+const formatDateTime = (value: string): string => {
+  const date = new Date(value);
+  if (Number.isNaN(date.getTime())) {
+    return value;
+  }
+  return date.toLocaleString();
+};
+
+const BulletinBoard = ({ announcements }: { announcements: Announcement[] }) => {
+  if (announcements.length === 0) {
+    return null;
+  }
+  return h("section", { class: "bulletin-board" }, [
+    h("h2", null, "Announcements"),
+    h(
+      "ul",
+      null,
+      announcements.map((entry) =>
+        h("li", { key: entry.id }, [
+          h("p", null, entry.message),
+          h("span", { class: "muted" }, formatDateTime(entry.createdAt)),
+        ])
+      ),
+    ),
+  ]);
+};
+
+const BoardGrid = ({ boards }: { boards: BoardSummary[] }) => {
+  if (boards.length === 0) {
+    return h("section", { class: "board-grid" }, [
+      h("p", { class: "muted" }, "No boards yet. Start a new discussion with a request below."),
+    ]);
+  }
+
+  return h(
+    "section",
+    { class: "board-grid" },
+    boards.map((board) =>
+      h(
+        "a",
+        {
+          key: board.id,
+          class: "board-card",
+          href: `/${board.slug}`,
+          style: createAccentStyle(board.themeColor),
+        },
+        [
+          h(
+            "h3",
+            { style: { color: sanitizeHex(board.themeColor) } },
+            `/${board.slug}/ - ${board.name}`,
+          ),
+          h("p", null, board.description || ""),
+        ],
+      )
+    ),
+  );
+};
+
+const AuthPanel = (
+  { currentUser, memberBoard, activeForm, loginPrefill, registerPrefill }: {
+    currentUser: { username: string } | null;
+    memberBoard: { slug: string; name: string; description: string };
+    activeForm: "login" | "register" | null;
+    loginPrefill?: string;
+    registerPrefill?: string;
+  },
+) => {
+  const registerClass = activeForm === "login" ? "auth-form" : "auth-form active";
+  const loginClass = activeForm === "login" ? "auth-form active" : "auth-form";
+  const summaryLabel = currentUser ? "Member account" : "Create or access your account";
+
+  return h("section", { class: "member-auth" }, [
+    h(
+      "details",
+      {
+        class: "request-panel",
+        open: currentUser ? false : true,
+      },
+      [
+        h("summary", null, summaryLabel),
+        h("div", { class: "requests-body" }, [
+          currentUser
+            ? h("div", { class: "member-card" }, [
+              h("h2", null, `Welcome, ${currentUser.username}!`),
+              h(
+                "p",
+                { class: "muted" },
+                `The private /${memberBoard.slug}/ board is available only to registered members.`,
+              ),
+              h("div", { class: "member-actions" }, [
+                h(
+                  "a",
+                  {
+                    class: "button",
+                    href: `/${memberBoard.slug}`,
+                  },
+                  `Visit /${memberBoard.slug}/`,
+                ),
+                h(
+                  "form",
+                  { method: "post", action: "/auth/logout", class: "logout-form" },
+                  [h("button", { type: "submit" }, "Sign out")],
+                ),
+              ]),
+            ])
+            : h("div", { class: "auth-forms" }, [
+              h("div", { class: "request-list-container" }, [
+                h("h2", null, "Register"),
+                h(
+                  "form",
+                  {
+                    class: registerClass,
+                    method: "post",
+                    action: "/auth/register",
+                  },
+                  [
+                    h("label", null, [
+                      "Username",
+                      h("input", {
+                        name: "username",
+                        required: true,
+                        minLength: 3,
+                        maxLength: 20,
+                        value: registerPrefill ?? "",
+                      }),
+                    ]),
+                    h("label", null, [
+                      "Password",
+                      h("input", {
+                        type: "password",
+                        name: "password",
+                        required: true,
+                        minLength: 4,
+                      }),
+                    ]),
+                    h("button", { type: "submit" }, "Create account"),
+                  ],
+                ),
+              ]),
+              h("div", { class: "request-list-container" }, [
+                h("h2", null, "Sign in"),
+                h(
+                  "form",
+                  {
+                    class: loginClass,
+                    method: "post",
+                    action: "/auth/login",
+                  },
+                  [
+                    h("label", null, [
+                      "Username",
+                      h("input", {
+                        name: "username",
+                        required: true,
+                        value: loginPrefill ?? "",
+                      }),
+                    ]),
+                    h("label", null, [
+                      "Password",
+                      h("input", {
+                        type: "password",
+                        name: "password",
+                        required: true,
+                      }),
+                    ]),
+                    h("button", { type: "submit" }, "Sign in"),
+                  ],
+                ),
+              ]),
+            ]),
+        ]),
+      ],
+    ),
+  ]);
+};
+
+const PopularPosts = ({ posts }: { posts: PopularPostSummary[] }) => {
+  if (posts.length === 0) {
+    return null;
+  }
+
+  return h("section", { class: "popular-posts" }, [
+    h("h2", null, "Popular Threads"),
+    h(
+      "ul",
+      null,
+      posts.map((post) =>
+        h(
+          "li",
+          { key: post.id },
+          [
+            h("a", { href: `/${post.boardSlug}` }, `/${post.boardSlug}/ - ${post.boardName}`),
+            " Â· ",
+            h(
+              "strong",
+              null,
+              post.title?.trim() && post.title.trim().length > 0
+                ? post.title.trim()
+                : "(No subject)",
+            ),
+            " ",
+            h(
+              "span",
+              { class: "muted" },
+              `(${post.commentCount} repl${post.commentCount === 1 ? "y" : "ies"})`,
+            ),
+          ],
+        )
+      ),
+    ),
+  ]);
+};
+
+const BoardRequestForm = ({ defaults }: { defaults?: RequestFormDefaults }) => {
+  const color = sanitizeHex(defaults?.themeColor ?? "#1e3a8a");
+  return h(
+    "form",
+    {
+      class: "request-form",
+      method: "post",
+      action: "/board-requests",
+    },
+    [
+      h("h2", null, "Request a new board"),
+      h("label", null, [
+        "Slug",
+        h("input", {
+          name: "slug",
+          required: true,
+          placeholder: "e.g. tech",
+          value: defaults?.slug ?? "",
+        }),
+      ]),
+      h("label", null, [
+        "Name",
+        h("input", {
+          name: "name",
+          required: true,
+          value: defaults?.name ?? "",
+        }),
+      ]),
+      h("label", null, [
+        "Description",
+        h("textarea", {
+          name: "description",
+          rows: 3,
+          value: defaults?.description ?? "",
+        }),
+      ]),
+      h("label", null, [
+        "Theme color",
+        h("input", {
+          type: "color",
+          name: "themeColor",
+          value: color,
+        }),
+      ]),
+      h(
+        "button",
+        { type: "submit" },
+        "Submit request",
+      ),
+    ],
+  );
+};
+
+const BoardRequestList = ({ requests }: { requests: BoardRequestSummary[] }) => {
+  if (requests.length === 0) {
+    return h("p", { class: "muted" }, "No outstanding requests. Be the first to submit one!");
+  }
+
+  return h(
+    "ul",
+    { class: "request-list" },
+    requests.map((request) => {
+      const accent = createAccentStyle(request.themeColor);
+      const buttonLabel = request.status !== "open" ? "Closed" : request.voted ? "Unvote" : "Vote";
+      return h(
+        "li",
+        { key: request.id, style: accent },
+        [
+          h("div", { class: "request-summary" }, [
+            h(
+              "h3",
+              { style: { color: sanitizeHex(request.themeColor) } },
+              `/${request.slug}/ - ${request.name}`,
+            ),
+            h("p", null, request.description || ""),
+          ]),
+          h("div", { class: "request-meta" }, [
+            h("span", null, `${request.voteCount} vote${request.voteCount === 1 ? "" : "s"}`),
+            h("span", { class: "muted" }, formatTimeRemaining(request.deadline)),
+            request.status === "open"
+              ? h(
+                "form",
+                {
+                  method: "post",
+                  action: `/board-requests/${request.id}/toggle-vote`,
+                },
+                [
+                  h(
+                    "button",
+                    {
+                      type: "submit",
+                      class: request.voted ? "vote-button voted" : "vote-button",
+                    },
+                    buttonLabel,
+                  ),
+                ],
+              )
+              : h(
+                "span",
+                { class: "muted" },
+                request.status === "fulfilled" ? "Fulfilled" : "Closed",
+              ),
+          ]),
+        ],
+      );
+    }),
+  );
+};
+
+export const renderHomePage = (options: RenderHomePageOptions): string => {
+  const requestDetailsOpen = options.showRequestForm ?? false;
+  const content = h("main", { class: "home-main" }, [
+    h("section", { class: "board-header" }, [
+      h("div", { class: "board-nav" }, [h("a", { href: "/" }, "Home")]),
+      h("h1", { class: "board-title" }, options.heading),
+      h("p", { class: "board-desc" }, options.description),
+      h(
+        "p",
+        { class: "muted" },
+        `Threads archive after ${options.threadTtlDays} day${
+          options.threadTtlDays === 1 ? "" : "s"
+        }. Request window: ${options.requestDurationDays} day${
+          options.requestDurationDays === 1 ? "" : "s"
+        }.`,
+      ),
+    ]),
+    h(BulletinBoard, { announcements: options.announcements }),
+    h(BoardGrid, { boards: options.boards }),
+    !options.currentUser &&
+    h(
+      "p",
+      { class: "muted" },
+      `Create an account below to unlock the private /${options.memberBoard.slug}/ board.`,
+    ),
+    h("section", { class: "board-requests" }, [
+      h(
+        "details",
+        { class: "request-panel", open: requestDetailsOpen || options.requests.length > 0 },
+        [
+          h("summary", null, "Community board requests"),
+          h("div", { class: "requests-body" }, [
+            h(BoardRequestForm, { defaults: options.requestFormDefaults }),
+            h("div", { class: "request-list-container" }, [
+              h("h2", null, "Active requests"),
+              h(BoardRequestList, { requests: options.requests }),
+            ]),
+          ]),
+        ],
+      ),
+    ]),
+    h(AuthPanel, {
+      currentUser: options.currentUser,
+      memberBoard: options.memberBoard,
+      activeForm: options.activeAuthForm,
+      loginPrefill: options.loginPrefill,
+      registerPrefill: options.registerPrefill,
+    }),
+    h(PopularPosts, { posts: options.popularPosts }),
+    h(
+      "footer",
+      { class: "site-footer" },
+      options.footerText,
+    ),
+  ]);
+
+  return renderLayout({
+    title: options.title,
+    bodyClass: "home",
+    brandColor: "#0f172a",
+    assetBasePath: options.assetBasePath,
+    flash: options.flash,
+    children: content,
+  });
+};
diff --git a/src/interfaces/http/views/shared.ts b/src/interfaces/http/views/shared.ts
new file mode 100644 (file)
index 0000000..4d9fa32
--- /dev/null
@@ -0,0 +1,145 @@
+import { h } from "preact";
+import { renderToString } from "preact-render-to-string";
+import type { ComponentChildren, JSX, VNode } from "preact";
+
+export interface FlashState {
+  success?: string;
+  error?: string;
+}
+
+type LayoutOptions = {
+  title: string;
+  bodyClass?: string;
+  brandColor?: string;
+  flash?: FlashState;
+  assetBasePath: string;
+  children: ComponentChildren;
+};
+
+const DEFAULT_BRAND = "#0f172a";
+
+export const sanitizeHex = (value: string | undefined): string => {
+  const color = value?.trim();
+  if (!color) {
+    return DEFAULT_BRAND;
+  }
+  if (/^#[0-9a-fA-F]{6}$/.test(color)) {
+    return color.toLowerCase();
+  }
+  return DEFAULT_BRAND;
+};
+
+export const hexToRgb = (hex: string) => {
+  const value = hex.replace("#", "");
+  const int = Number.parseInt(value, 16);
+  return {
+    r: (int >> 16) & 255,
+    g: (int >> 8) & 255,
+    b: int & 255,
+  };
+};
+
+const buildBrandStyle = (hex: string): string => {
+  const color = sanitizeHex(hex);
+  const { r, g, b } = hexToRgb(color);
+  const border = `rgba(${r}, ${g}, ${b}, 0.35)`;
+  return `--brand-color: ${color}; --brand-border: ${border};`;
+};
+
+export const createAccentStyle = (hex: string): Record<string, string> => {
+  const color = sanitizeHex(hex);
+  const { r, g, b } = hexToRgb(color);
+  return {
+    borderColor: `rgba(${r}, ${g}, ${b}, 0.35)`,
+    background: `rgba(${r}, ${g}, ${b}, 0.08)`,
+    color,
+  };
+};
+
+const FLASH_SCRIPT = `(function(){
+  const container = document.querySelector(".flash-container");
+  if (!container) return;
+  container.addEventListener("click", (event) => {
+    const target = event.target;
+    if (!(target instanceof HTMLElement)) {
+      return;
+    }
+    const toast = target.closest("[data-flash-toast]");
+    if (!toast) {
+      return;
+    }
+    toast.remove();
+    if (!container.querySelector("[data-flash-toast]")) {
+      container.remove();
+    }
+  }, { passive: true });
+})();`;
+
+const FlashBanner = ({ flash }: { flash?: FlashState }) => {
+  const messages = [
+    flash?.error ? { type: "error", text: flash.error } : null,
+    flash?.success ? { type: "success", text: flash.success } : null,
+  ].filter((entry): entry is NonNullable<typeof entry> => Boolean(entry));
+
+  if (messages.length === 0) {
+    return null;
+  }
+
+  return h(
+    "div",
+    { class: "flash-container", role: "region", "aria-live": "polite" },
+    messages.map((message, index) =>
+      h(
+        "button",
+        {
+          type: "button",
+          class: `flash-toast flash-${message.type}`,
+          "data-flash-toast": "true",
+          "aria-label": `${message.type === "error" ? "Dismiss error" : "Dismiss message"}`,
+          key: `${message.type}-${index}`,
+        },
+        message.text,
+      )
+    ),
+  );
+};
+
+export const renderLayout = (options: LayoutOptions): string => {
+  const brandColor = sanitizeHex(options.brandColor ?? DEFAULT_BRAND);
+  const assetBasePath = options.assetBasePath.replace(/\/+$/, "");
+  const stylesheetHref = `${assetBasePath}/styles.css`;
+  const hasFlash = Boolean(options.flash?.error || options.flash?.success);
+  const bodyChildren: ComponentChildren[] = [
+    h(FlashBanner, { flash: options.flash }),
+    options.children,
+  ];
+
+  if (hasFlash) {
+    bodyChildren.push(
+      h("script", {
+        dangerouslySetInnerHTML: { __html: FLASH_SCRIPT },
+      }),
+    );
+  }
+
+  const htmlVNode = h("html", { lang: "en" } as JSX.HTMLAttributes<HTMLHtmlElement>, [
+    h("head", null, [
+      h("meta", { charset: "utf-8" }),
+      h("meta", { name: "viewport", content: "width=device-width, initial-scale=1.0" }),
+      h("title", null, options.title),
+      h("link", { rel: "stylesheet", href: stylesheetHref }),
+    ]),
+    h(
+      "body",
+      {
+        class: options.bodyClass ?? "",
+        style: buildBrandStyle(brandColor),
+      },
+      bodyChildren,
+    ),
+  ]) as VNode;
+
+  const html = renderToString(htmlVNode);
+
+  return `<!DOCTYPE html>${html}`;
+};
diff --git a/src/testing/asserts.ts b/src/testing/asserts.ts
new file mode 100644 (file)
index 0000000..16c6590
--- /dev/null
@@ -0,0 +1,144 @@
+const objectToString = Object.prototype.toString;
+
+const isDate = (value: unknown): value is Date =>
+  value instanceof Date || objectToString.call(value) === "[object Date]";
+
+const isTypedArray = (value: unknown): value is ArrayBufferView =>
+  ArrayBuffer.isView(value) && !(value instanceof DataView);
+
+const isPlainObject = (value: unknown): value is Record<string, unknown> => {
+  if (value === null || typeof value !== "object") {
+    return false;
+  }
+  if (Array.isArray(value) || isTypedArray(value) || isDate(value)) {
+    return false;
+  }
+  return Object.getPrototypeOf(value) === Object.prototype;
+};
+
+const deepEqual = (a: unknown, b: unknown, seen = new WeakMap<object, object>()): boolean => {
+  if (Object.is(a, b)) {
+    return true;
+  }
+
+  if (typeof a !== typeof b) {
+    return false;
+  }
+
+  if (a === null || b === null) {
+    return false;
+  }
+
+  if (typeof a !== "object" || typeof b !== "object") {
+    return false;
+  }
+
+  if (seen.get(a as object) === b) {
+    return true;
+  }
+  seen.set(a as object, b as object);
+
+  if (isDate(a) && isDate(b)) {
+    return a.getTime() === b.getTime();
+  }
+
+  if (Array.isArray(a) && Array.isArray(b)) {
+    if (a.length !== b.length) {
+      return false;
+    }
+    for (let i = 0; i < a.length; i += 1) {
+      if (!deepEqual(a[i], b[i], seen)) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  if (isTypedArray(a) && isTypedArray(b)) {
+    if (a.byteLength !== b.byteLength || a.constructor !== b.constructor) {
+      return false;
+    }
+    for (let i = 0; i < (a as Uint8Array).length; i += 1) {
+      if ((a as Uint8Array)[i] !== (b as Uint8Array)[i]) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  if (isPlainObject(a) && isPlainObject(b)) {
+    const keysA = Object.keys(a);
+    const keysB = Object.keys(b);
+    if (keysA.length !== keysB.length) {
+      return false;
+    }
+
+    for (const key of keysA) {
+      if (!Object.prototype.hasOwnProperty.call(b, key)) {
+        return false;
+      }
+      if (
+        !deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key], seen)
+      ) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  return false;
+};
+
+const formatValue = (value: unknown): string => {
+  if (typeof value === "string") {
+    return `"${value}"`;
+  }
+  try {
+    return JSON.stringify(value);
+  } catch {
+    return String(value);
+  }
+};
+
+export const assertEquals = (actual: unknown, expected: unknown, message?: string) => {
+  if (!deepEqual(actual, expected)) {
+    throw new Error(
+      message ??
+        `Assertion failed: expected ${formatValue(expected)}, received ${formatValue(actual)}`,
+    );
+  }
+};
+
+export const assertStrictEquals = (actual: unknown, expected: unknown, message?: string) => {
+  if (!Object.is(actual, expected)) {
+    throw new Error(
+      message ??
+        `Assertion failed: expected ${formatValue(expected)}, received ${formatValue(actual)}`,
+    );
+  }
+};
+
+type ErrorClass<T extends Error = Error> = new (message?: string) => T;
+
+export const assertRejects = async (
+  fn: () => Promise<unknown>,
+  ExpectedError: ErrorClass = Error,
+  messageIncludes?: string,
+): Promise<void> => {
+  try {
+    await fn();
+  } catch (error) {
+    if (!(error instanceof ExpectedError)) {
+      throw new Error(`Assertion failed: expected rejection with ${ExpectedError.name}`);
+    }
+    if (messageIncludes && !(error as Error).message.includes(messageIncludes)) {
+      throw new Error(
+        `Assertion failed: expected error message to include "${messageIncludes}", got "${
+          (error as Error).message
+        }"`,
+      );
+    }
+    return;
+  }
+  throw new Error("Assertion failed: expected promise to reject");
+};
diff --git a/src/testing/fakes.ts b/src/testing/fakes.ts
new file mode 100644 (file)
index 0000000..f4389ff
--- /dev/null
@@ -0,0 +1,305 @@
+import { Clock } from "../application/contracts/clock.ts";
+import { IdGenerator } from "../application/contracts/id_generator.ts";
+import { ApplicationError } from "../application/errors/application_error.ts";
+import { Board } from "../domain/entities/board.ts";
+import { BoardAnnouncement } from "../domain/entities/board_announcement.ts";
+import { BoardRequest } from "../domain/entities/board_request.ts";
+import { Comment } from "../domain/entities/comment.ts";
+import { ImagePost } from "../domain/entities/image_post.ts";
+import { User } from "../domain/entities/user.ts";
+import { BoardRepository } from "../domain/repositories/board_repository.ts";
+import { BoardAnnouncementRepository } from "../domain/repositories/board_announcement_repository.ts";
+import { BoardRequestRepository } from "../domain/repositories/board_request_repository.ts";
+import { BoardRequestVoteRepository } from "../domain/repositories/board_request_vote_repository.ts";
+import { CommentRepository } from "../domain/repositories/comment_repository.ts";
+import { PostRepository } from "../domain/repositories/post_repository.ts";
+import { ImageStorage, SaveImageInput } from "../domain/services/image_storage.ts";
+import { UserRepository } from "../domain/repositories/user_repository.ts";
+
+export class FixedClock implements Clock {
+  constructor(private current: Date) {}
+
+  now(): Date {
+    return new Date(this.current);
+  }
+
+  tickBy(milliseconds: number) {
+    this.current = new Date(this.current.getTime() + milliseconds);
+  }
+
+  set(date: Date) {
+    this.current = new Date(date);
+  }
+}
+
+export class SequenceIdGenerator implements IdGenerator {
+  private index = 0;
+
+  constructor(private readonly prefix = "id") {}
+
+  generate(): string {
+    this.index += 1;
+    return `${this.prefix}-${this.index}`;
+  }
+}
+
+export class InMemoryImageStorage implements ImageStorage {
+  private readonly files = new Map<string, Uint8Array>();
+
+  constructor(private readonly basePath = "/uploads") {}
+
+  saveImage(input: SaveImageInput): Promise<string> {
+    const path = `${this.basePath}/images/${input.id}`;
+    this.files.set(path, input.data);
+    return Promise.resolve(path);
+  }
+
+  getImage(path: string): Promise<Uint8Array> {
+    const data = this.files.get(path);
+    if (!data) {
+      return Promise.reject(new ApplicationError("Image not found.", 404));
+    }
+    return Promise.resolve(data);
+  }
+
+  deleteImage(path: string): Promise<void> {
+    this.files.delete(path);
+    return Promise.resolve();
+  }
+}
+
+export class InMemoryPostRepository implements PostRepository {
+  private readonly posts = new Map<string, ImagePost>();
+
+  create(post: ImagePost): Promise<void> {
+    this.posts.set(post.id, structuredClone(post));
+    return Promise.resolve();
+  }
+
+  findById(id: string): Promise<ImagePost | null> {
+    const post = this.posts.get(id);
+    return Promise.resolve(post ? structuredClone(post) : null);
+  }
+
+  listAll(): Promise<ImagePost[]> {
+    return Promise.resolve(Array.from(this.posts.values()).map((post) => structuredClone(post)));
+  }
+
+  listByBoard(boardId: string): Promise<ImagePost[]> {
+    return Promise.resolve(
+      Array.from(this.posts.values())
+        .filter((post) => post.boardId === boardId)
+        .map((post) => structuredClone(post)),
+    );
+  }
+
+  update(post: ImagePost): Promise<void> {
+    this.posts.set(post.id, structuredClone(post));
+    return Promise.resolve();
+  }
+
+  delete(id: string): Promise<void> {
+    this.posts.delete(id);
+    return Promise.resolve();
+  }
+}
+
+export class InMemoryCommentRepository implements CommentRepository {
+  private readonly comments = new Map<string, Comment>();
+
+  create(comment: Comment): Promise<void> {
+    this.comments.set(this.key(comment.postId, comment.id), structuredClone(comment));
+    return Promise.resolve();
+  }
+
+  findById(postId: string, id: string): Promise<Comment | null> {
+    const comment = this.comments.get(this.key(postId, id));
+    return Promise.resolve(comment ? structuredClone(comment) : null);
+  }
+
+  listByPost(postId: string): Promise<Comment[]> {
+    return Promise.resolve(
+      Array.from(this.comments.values())
+        .filter((comment) => comment.postId === postId)
+        .map((comment) => structuredClone(comment)),
+    );
+  }
+
+  deleteByPost(postId: string): Promise<void> {
+    for (const key of this.comments.keys()) {
+      if (key.startsWith(`${postId}::`)) {
+        this.comments.delete(key);
+      }
+    }
+    return Promise.resolve();
+  }
+
+  countByPost(postId: string): Promise<number> {
+    let count = 0;
+    for (const comment of this.comments.values()) {
+      if (comment.postId === postId) {
+        count += 1;
+      }
+    }
+    return Promise.resolve(count);
+  }
+
+  private key(postId: string, id: string) {
+    return `${postId}::${id}`;
+  }
+}
+
+export class InMemoryBoardRepository implements BoardRepository {
+  private readonly boards = new Map<string, Board>();
+
+  create(board: Board): Promise<void> {
+    this.boards.set(board.id, structuredClone(board));
+    return Promise.resolve();
+  }
+
+  update(board: Board): Promise<void> {
+    this.boards.set(board.id, structuredClone(board));
+    return Promise.resolve();
+  }
+
+  findById(id: string): Promise<Board | null> {
+    const board = this.boards.get(id);
+    return Promise.resolve(board ? structuredClone(board) : null);
+  }
+
+  findBySlug(slug: string): Promise<Board | null> {
+    for (const board of this.boards.values()) {
+      if (board.slug === slug) {
+        return Promise.resolve(structuredClone(board));
+      }
+    }
+    return Promise.resolve(null);
+  }
+
+  list(): Promise<Board[]> {
+    return Promise.resolve(Array.from(this.boards.values()).map((board) => structuredClone(board)));
+  }
+
+  delete(id: string): Promise<void> {
+    this.boards.delete(id);
+    return Promise.resolve();
+  }
+}
+
+export class InMemoryBoardRequestRepository implements BoardRequestRepository {
+  private readonly requests = new Map<string, BoardRequest>();
+
+  create(request: BoardRequest): Promise<void> {
+    this.requests.set(request.id, structuredClone(request));
+    return Promise.resolve();
+  }
+
+  update(request: BoardRequest): Promise<void> {
+    this.requests.set(request.id, structuredClone(request));
+    return Promise.resolve();
+  }
+
+  findById(id: string): Promise<BoardRequest | null> {
+    const request = this.requests.get(id);
+    return Promise.resolve(request ? structuredClone(request) : null);
+  }
+
+  list(): Promise<BoardRequest[]> {
+    return Promise.resolve(
+      Array.from(this.requests.values()).map((request) => structuredClone(request)),
+    );
+  }
+
+  delete(id: string): Promise<void> {
+    this.requests.delete(id);
+    return Promise.resolve();
+  }
+}
+
+const normalizeUsername = (value: string): string => value.toLowerCase();
+
+export class InMemoryUserRepository implements UserRepository {
+  private readonly users = new Map<string, User>();
+  private readonly byUsername = new Map<string, string>();
+
+  create(user: User): Promise<void> {
+    const normalized = normalizeUsername(user.username);
+    if (this.byUsername.has(normalized)) {
+      throw new Error("Username already exists.");
+    }
+    this.users.set(user.id, structuredClone(user));
+    this.byUsername.set(normalized, user.id);
+    return Promise.resolve();
+  }
+
+  findById(id: string): Promise<User | null> {
+    const user = this.users.get(id);
+    return Promise.resolve(user ? structuredClone(user) : null);
+  }
+
+  findByUsername(username: string): Promise<User | null> {
+    const id = this.byUsername.get(normalizeUsername(username));
+    if (!id) {
+      return Promise.resolve(null);
+    }
+    return this.findById(id);
+  }
+
+  isUsernameTaken(username: string): Promise<boolean> {
+    return Promise.resolve(this.byUsername.has(normalizeUsername(username)));
+  }
+}
+
+export class InMemoryBoardRequestVoteRepository implements BoardRequestVoteRepository {
+  private readonly votes = new Set<string>();
+
+  hasVote(requestId: string, userId: string): Promise<boolean> {
+    return Promise.resolve(this.votes.has(this.key(requestId, userId)));
+  }
+
+  addVote(requestId: string, userId: string): Promise<void> {
+    this.votes.add(this.key(requestId, userId));
+    return Promise.resolve();
+  }
+
+  removeVote(requestId: string, userId: string): Promise<void> {
+    this.votes.delete(this.key(requestId, userId));
+    return Promise.resolve();
+  }
+
+  countVotes(requestId: string): Promise<number> {
+    let count = 0;
+    for (const vote of this.votes.values()) {
+      if (vote.startsWith(`${requestId}::`)) {
+        count += 1;
+      }
+    }
+    return Promise.resolve(count);
+  }
+
+  private key(requestId: string, userId: string) {
+    return `${requestId}::${userId}`;
+  }
+}
+
+export class InMemoryBoardAnnouncementRepository implements BoardAnnouncementRepository {
+  private readonly announcements = new Map<string, BoardAnnouncement>();
+
+  list(): Promise<BoardAnnouncement[]> {
+    return Promise.resolve(
+      Array.from(this.announcements.values())
+        .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
+        .map((announcement) => structuredClone(announcement)),
+    );
+  }
+
+  create(announcement: BoardAnnouncement): Promise<void> {
+    this.announcements.set(announcement.id, structuredClone(announcement));
+    return Promise.resolve();
+  }
+
+  delete(id: string): Promise<void> {
+    this.announcements.delete(id);
+    return Promise.resolve();
+  }
+}
diff --git a/storage/.gitignore b/storage/.gitignore
new file mode 100644 (file)
index 0000000..d6b7ef3
--- /dev/null
@@ -0,0 +1,2 @@
+*
+!.gitignore