From 456a6e3ee053577dbb24a0cccc00f66f6471bd5c Mon Sep 17 00:00:00 2001 From: OpenVelo Agent Date: Thu, 23 Jul 2026 10:17:52 +0000 Subject: [PATCH 1/2] docs: update documentation to OKF v0.1 format --- docs/api/blog.md | 71 +++++++++++++++++++++++++ docs/api/rest-overview.md | 5 +- docs/architecture/backend-modules.md | 6 ++- docs/architecture/frontend-structure.md | 20 +++++-- docs/architecture/key-files.md | 23 ++++++-- docs/database/blog-posts.md | 12 +++-- docs/guides/admin-shell.md | 26 ++++----- docs/guides/blog.md | 71 +++++++++++++++++++++++++ docs/guides/landing-page.md | 5 +- docs/index.md | 11 ++-- 10 files changed, 218 insertions(+), 32 deletions(-) create mode 100644 docs/api/blog.md create mode 100644 docs/guides/blog.md diff --git a/docs/api/blog.md b/docs/api/blog.md new file mode 100644 index 0000000..e52369c --- /dev/null +++ b/docs/api/blog.md @@ -0,0 +1,71 @@ +--- +type: api +title: Blog Endpoints +description: Public blog listing and administrator-only post management endpoints. +tags: [api, blog, admin, markdown] +timestamp: 2026-07-23T10:12:24Z +--- + +# Endpoints + +| Method | Path | Access | Behavior | +|---|---|---|---| +| `GET` | `/api/v1/blog/posts` | Public | Returns published posts only, newest publication first. | +| `GET` | `/api/v1/admin/blog/posts` | Admin | Returns drafts and published posts, ordered by most recently updated. | +| `GET` | `/api/v1/admin/blog/posts/:id` | Admin | Returns one post or `404 NOT_FOUND`. | +| `POST` | `/api/v1/admin/blog/posts` | Admin + CSRF | Creates a draft or published post; defaults to draft. | +| `PUT` | `/api/v1/admin/blog/posts/:id` | Admin + CSRF | Partially updates title, Markdown body, or status. | +| `DELETE` | `/api/v1/admin/blog/posts/:id` | Admin + CSRF | Deletes a post and returns `204`; missing posts return `404 NOT_FOUND`. | + +The public route is marked `@Public()`. Admin routes require a Bearer JWT with the administrator role, and unsafe requests use the standard CSRF cookie/header contract from [REST API Overview](/api/rest-overview.md). + +# Schema + +## Public list response + +```json +{ + "posts": [ + { + "id": "post-id", + "title": "Event update", + "publishedAt": "2026-07-23T10:00:00.000Z", + "bodyMd": "# Welcome" + } + ] +} +``` + +Drafts and administrative fields such as `status`, `createdAt`, and `updatedAt` are excluded. + +## Admin post + +| Field | Type | Description | +|---|---|---| +| `id` | string | Server-generated UUID. | +| `title` | string | Trimmed title, required, maximum 200 characters. | +| `bodyMd` | string | Markdown body, maximum 200,000 characters. | +| `status` | `draft` or `published` | Visibility state. | +| `createdAt` | ISO 8601 string | Creation time. | +| `updatedAt` | ISO 8601 string | Last persistence update. | +| `publishedAt` | ISO 8601 string or null | First publication time. | + +`POST` accepts `title`, optional `bodyMd`, and optional `status`. `PUT` accepts any subset of those fields. Validation failures use HTTP 400 with code `VALIDATION_FAILED`. + +# Publication behavior + +* Creating with `status: "published"` sets `publishedAt`; omitted status creates a draft. +* Publishing a never-published draft sets `publishedAt` once. +* Editing a published post does not change `publishedAt`. +* Moving a post back to draft preserves `publishedAt`; republishing retains the original timestamp. +* Only rows currently marked `published` are returned by the public endpoint. + +# Wiring + +`BlogModule` registers `BlogController`/`BlogService` for the public list and `AdminBlogController`/`AdminBlogService` for management. Both services access `BlogPostEntity` through `TypeOrmModule.forFeature([BlogPostEntity])`. The Angular `BlogApiService` calls these routes for the admin page, authenticated Blog page, and landing-page post list. + +# See also + +- [Blog Publishing and Reading](/guides/blog.md) +- [Blog Post Table](/database/blog-posts.md) +- [REST API Overview](/api/rest-overview.md) diff --git a/docs/api/rest-overview.md b/docs/api/rest-overview.md index 27cda81..eaca903 100644 --- a/docs/api/rest-overview.md +++ b/docs/api/rest-overview.md @@ -3,7 +3,7 @@ type: api title: REST API Overview description: Base URL, versioning, auth, CSRF, and the standard error envelope. tags: [api, rest, overview, csrf] -timestamp: 2026-07-23T00:10:00Z +timestamp: 2026-07-23T10:12:24Z --- # Base URL & versioning @@ -98,4 +98,5 @@ statuses and the canonical messages produced by - [Setup Endpoint](/api/setup.md) - [System Endpoints](/api/system.md) - [Uploads Endpoints](/api/uploads.md) -- [Challenges Endpoints](/api/challenges.md) \ No newline at end of file +- [Challenges Endpoints](/api/challenges.md) +- [Blog Endpoints](/api/blog.md) \ No newline at end of file diff --git a/docs/architecture/backend-modules.md b/docs/architecture/backend-modules.md index fa103e3..db816cb 100644 --- a/docs/architecture/backend-modules.md +++ b/docs/architecture/backend-modules.md @@ -3,7 +3,7 @@ type: architecture title: Backend Module Map description: NestJS modules, controllers, services, and how they are wired together. tags: [architecture, backend, nestjs, modules] -timestamp: 2026-07-23T00:10:00Z +timestamp: 2026-07-23T10:12:24Z --- # Module Map @@ -29,7 +29,7 @@ also registers two global providers: | `SystemModule` | `backend/src/modules/system/system.module.ts` | `SystemController` (`/api/v1/bootstrap`, `/event/status`, SSE streams) + `SystemService`. | | `AdminModule` | `backend/src/modules/admin/admin.module.ts` | Four controllers (`AdminController` for `/api/v1/admin/users*`, `AdminGeneralController` for `/api/v1/admin/general/*`, `AdminCategoriesController` for `/api/v1/admin/categories*`, `AdminChallengesController` for `/api/v1/admin/challenges*`) plus their services (`AdminService`, `AdminGeneralService`, `AdminCategoriesService`, `AdminChallengesService`, `ChallengeFilesService`). Gated by `AdminGuard` + `@Roles('admin')`. Imports `TypeOrmModule.forFeature([UserEntity, CategoryEntity, ChallengeEntity, ChallengeFileEntity, SolveEntity])`, `AuthModule`, `UsersModule`, `SettingsModule`, and `CommonModule` (for `SseHubService`, `ThemeLoaderService`). | | `UploadsModule` | `backend/src/modules/uploads/uploads.module.ts` | `UploadsController` (`/api/v1/uploads/*`). Admin-only multipart. | -| `BlogModule` | `backend/src/modules/blog/blog.module.ts` | `BlogController` (`GET /api/v1/blog/posts`) + `BlogService` (lists `status='published'` rows). | +| `BlogModule` | `backend/src/modules/blog/blog.module.ts` | `BlogController`/`BlogService` expose published posts at `GET /api/v1/blog/posts`; `AdminBlogController`/`AdminBlogService` provide guarded CRUD at `/api/v1/admin/blog/posts` using `BlogPostEntity`. | | `ChallengesModule` | `backend/src/modules/challenges/challenges.module.ts` | `ChallengesController` (`/api/v1/challenges/{board,status,:id,:id/solves}`) + `ChallengesEventsController` (authenticated SSE `/api/v1/events`) + `ChallengesService` (board, detail, submit, scoring util). | | `FrontendModule` | `backend/src/frontend/frontend.module.ts` | Registers `SpaFallbackMiddleware` and the uploads static helper. | @@ -47,6 +47,7 @@ also registers two global providers: | `SystemController` | `/api/v1` | Mixed (bootstrap/event/scoreboard/settings are public; `events/status` SSE is JWT-protected) | `backend/src/modules/system/system.controller.ts` | | `UploadsController` | `/api/v1/uploads` | Admin only | `backend/src/modules/uploads/uploads.controller.ts` | | `BlogController` | `/api/v1/blog` | Public | `backend/src/modules/blog/blog.controller.ts` | +| `AdminBlogController` | `/api/v1/admin/blog/posts` | Admin only | `backend/src/modules/blog/admin-blog.controller.ts` | | `ChallengesController` | `/api/v1/challenges` | Authenticated | `backend/src/modules/challenges/challenges.controller.ts` | | `ChallengesEventsController` | `/api/v1/events` | Authenticated (SSE) | `backend/src/modules/challenges/events.controller.ts` | @@ -89,3 +90,4 @@ also registers two global providers: - [System Overview](/architecture/overview.md) - [Key Files Index](/architecture/key-files.md) - [REST API Overview](/api/rest-overview.md) +- [Blog API](/api/blog.md) diff --git a/docs/architecture/frontend-structure.md b/docs/architecture/frontend-structure.md index 898a870..125abc2 100644 --- a/docs/architecture/frontend-structure.md +++ b/docs/architecture/frontend-structure.md @@ -1,9 +1,9 @@ --- type: architecture title: Frontend Structure -description: Angular routes, components, services, guards, interceptors, and authenticated SSE transport. -tags: [architecture, frontend, angular, shell, sse, challenges, scoreboard, notifications] -timestamp: 2026-07-23T05:10:00Z +description: Angular routes, components, services, guards, interceptors, authenticated SSE transport, and blog management and presentation wiring. +tags: [architecture, frontend, angular, shell, sse, challenges, scoreboard, notifications, blog] +timestamp: 2026-07-23T10:12:24Z --- # Routes @@ -17,9 +17,10 @@ Routes live in `frontend/src/app/app.routes.ts`: | `/` | `HomeComponent` | `authGuard` | Authenticated shell and child outlet. | | `/challenges` | `ChallengesPage` | inherited | Full challenges board (category columns, modal, flag submit, live SSE). | | `/scoreboard` | `ScoreboardPage` | inherited | Live scoreboard page with 4 tabs (Ranking / Matrix / Event Log / Score Graph), live SSE `solve` updates. | -| `/blog` | `BlogPage` | inherited | Placeholder blog page. | -| `/admin` | `AdminUsersComponent` | `adminGuard` | Admin-only child route. | +| `/blog` | `BlogPage` | inherited | Loads and renders published posts with loading, error, and empty states. | +| `/admin` | `AdminShellComponent` | `adminGuard` | Admin-only layout; redirects to `/admin/general`. | | `/admin/challenges` | `AdminChallengesComponent` | inherited | Sortable/searchable admin Challenges list, Add/Edit modal, import/export. | +| `/admin/blog` | `AdminBlogComponent` | inherited | Admin post table and create/edit/draft/publish/delete workflow. | # Components and services @@ -45,6 +46,11 @@ Routes live in `frontend/src/app/app.routes.ts`: | `RankingComponent` / `MatrixComponent` / `EventLogComponent` / `ScoreGraphComponent` | `frontend/src/app/features/scoreboard/` | Presentational components for the 4 tabs; each binds a single `@Input()` from `ScoreboardStore` and uses `PLAYER_COLOR_PALETTE` + `stablePlayerColorIndex` to color each player consistently. | | `NotificationService` | `frontend/src/app/core/services/notification.service.ts` | Root-provided signal-backed store of `{ id, kind, message, ts }` records; `error()` / `info()` push, `dismiss(id)` / `clear()` remove. | | `errorNotificationInterceptor` | `frontend/src/app/core/interceptors/error-notification.interceptor.ts` | Functional HTTP interceptor that pushes a friendly message into `NotificationService` for every `HttpErrorResponse`; suppresses duplicate toasts for endpoints with their own error UI (login form, `/challenges/status` snapshot). | +| `BlogApiService` | `frontend/src/app/core/services/blog.service.ts` | Promise-based HTTP client for the published list and admin CRUD routes. | +| `BlogPage` | `frontend/src/app/features/blog/blog.page.ts` | Smart `/blog` page that loads published posts and derives loading, error, empty, and list states. | +| `BlogPresenterComponent` | `frontend/src/app/features/blog/blog-presenter.component.ts` | Shared title/date/sanitized-Markdown renderer used by `/blog` and `/login`. | +| `AdminBlogComponent` | `frontend/src/app/features/admin/blog/blog.component.ts` | Smart `/admin/blog` page coordinating list refresh and create/edit/delete modal state. | +| `BlogFormModalComponent` / `BlogDeleteModalComponent` | `frontend/src/app/features/admin/blog/` | Admin Markdown editor with live preview and destructive-action confirmation. | # Authenticated SSE wiring @@ -81,6 +87,9 @@ on destruction. | `frontend/src/app/core/services/event-status.pure.ts` | Defines the event payload and transport interface. | | `frontend/src/app/features/challenges/challenges.page.ts` | `/challenges` smart page; owns gate logic, modal lifecycle, SSE wiring, and the countdown-zero reload. | | `frontend/src/app/features/challenges/challenges.store.ts` | Signal store backing `/challenges`: board, event state, per-card solve listeners, SSE solve-frame mutation, submit response application, public `reset()`, and per-user `setMyUserId` flush. | +| `frontend/src/app/core/services/blog.service.ts` | Calls public and admin blog endpoints with typed payloads. | +| `frontend/src/app/features/blog/blog-presenter.component.ts` | Shared sanitized Markdown post rendering for authenticated and landing views. | +| `frontend/src/app/features/admin/blog/blog.component.ts` | Coordinates administrator blog CRUD UI and table refreshes. | | `tests/frontend/authenticated-event-source.spec.ts` | Regression coverage for authenticated SSE transport behavior. | # Event-status pure helpers @@ -129,4 +138,5 @@ for the full contract and tester matrix. - [Challenges Board](/guides/challenges-board.md) - [Scoreboard Page](/guides/scoreboard-page.md) - [Notifications](/guides/notifications.md) +- [Blog Publishing and Reading](/guides/blog.md) - [System Overview](/architecture/overview.md) diff --git a/docs/architecture/key-files.md b/docs/architecture/key-files.md index 1378131..08f7bcc 100644 --- a/docs/architecture/key-files.md +++ b/docs/architecture/key-files.md @@ -1,9 +1,9 @@ --- type: architecture title: Key Files Index -description: One-line responsibility for important source and contract-test files, including strict event-window validation, the public bootstrap SSE listener, the challenges full-replace import flow, the authenticated player-facing challenges board, and the per-user `solvedByMe` reset on the session boundary. -tags: [architecture, key-files, event-window, validation, default-challenge-ip, sse, bootstrap, migrations, challenges, import, board, notifications, per-user, session] -timestamp: 2026-07-23T04:05:00Z +description: One-line responsibility for important source and contract-test files, including event-window, challenge, scoreboard, session, notification, and blog flows. +tags: [architecture, key-files, event-window, validation, default-challenge-ip, sse, bootstrap, migrations, challenges, import, board, notifications, per-user, session, blog] +timestamp: 2026-07-23T10:12:24Z --- # Backend @@ -41,6 +41,11 @@ timestamp: 2026-07-23T04:05:00Z | `backend/src/modules/challenges/dto/challenges.dto.ts` | zod contracts (`SolveSubmitBodySchema`, `BoardQuerySchema`, `ChallengeIdParamSchema`) + DTO types for the player-facing board. | | `backend/src/modules/challenges/scoring.util.ts` | Pure `computeLivePoints`, `rankBonusForPosition`, `compareDifficulty`, `computeAwardedPoints` (base + rank bonus), `PLAYER_COLOR_PALETTE` + `stablePlayerColorIndex` (FNV-1a → palette index), and constant-time `safeEqualString` for flag comparison. | | `backend/src/common/errors/error-codes.ts` | Canonical error code map (now includes `EVENT_NOT_RUNNING`, `FLAG_INCORRECT`, `CHALLENGE_DISABLED`). | +| `backend/src/modules/blog/blog.module.ts` | Wires the public and admin blog controllers/services to `BlogPostEntity`. | +| `backend/src/modules/blog/admin-blog.controller.ts` | Registers administrator CRUD routes at `/api/v1/admin/blog/posts`. | +| `backend/src/modules/blog/admin-blog.service.ts` | Lists, creates, updates, publishes/drafts, and deletes posts while preserving first-publication time. | +| `backend/src/modules/blog/dto/blog.dto.ts` | Public/admin response contracts and zod validation for title, body, status, and post id. | +| `tests/backend/blog-admin.spec.ts` | Admin authorization, CRUD, validation, publication-timestamp, deletion, and public draft-hiding contracts. | # Frontend @@ -94,6 +99,16 @@ timestamp: 2026-07-23T04:05:00Z | `frontend/src/app/features/scoreboard/scoreboard.page.ts` | `/scoreboard` smart page (also listed above): gates the four tabs, wires `store.loadAll()` and `store.wireSse(...)` on init, and tears them down via `store.stop()` on destroy. | | `frontend/src/app/core/services/notification.service.ts` | Root-provided signal-backed store of `{ id, kind, message, ts }` records; `error()` / `info()` push, `dismiss(id)` / `clear()` remove. | | `frontend/src/app/core/interceptors/error-notification.interceptor.ts` | Translates `HttpErrorResponse` into friendly messages and pushes them to `NotificationService` (with suppression for `/api/v1/auth/{login,csrf,register}` and `/api/v1/challenges/status`). | +| `frontend/src/app/core/services/blog.service.ts` | Typed Promise-based public blog list and admin CRUD HTTP client. | +| `frontend/src/app/features/blog/blog.page.ts` | `/blog` smart page with loading, error, empty, and published-list states. | +| `frontend/src/app/features/blog/blog-presenter.component.ts` | Shared sanitized Markdown post renderer used by the Blog and landing pages. | +| `frontend/src/app/features/blog/blog.pure.ts` | Pure blog-list state derivation. | +| `frontend/src/app/features/admin/blog/blog.component.ts` | `/admin/blog` post table and create/edit/delete workflow coordinator. | +| `frontend/src/app/features/admin/blog/blog-form-modal.component.ts` | Reactive title/Markdown editor with live preview and draft/publish actions. | +| `frontend/src/app/features/admin/blog/blog-delete-modal.component.ts` | Post deletion confirmation and inline failure UI. | +| `frontend/src/app/features/admin/blog/blog-form.pure.ts` | Admin form validation, form synchronization, request cleanup, and status display helpers. | +| `tests/frontend/blog-page.spec.ts` | Blog page-state and shared sanitized Markdown rendering contracts. | +| `tests/frontend/blog-admin-form.spec.ts` | Admin form validation, prefill/reset, payload, and status-label contracts. | | `tests/frontend/challenges.pure.spec.ts` | Pure helpers: sorting, parsers, friendly error mapping, `formatDdHhMm` (`DD:HH:mm:ss`). | | `tests/frontend/challenges.service.spec.ts` | HTTP-service contract: `getDetail` URL-encodes the id, attaches `?include=solvers`, and forwards `withCredentials: true`. | | `tests/frontend/challenges.store.spec.ts` | Signal store: board mutation, live solve merge, listeners, `stop()`. The "marks solvedByMe" and "does not double-count" tests now call `setMyUserId('me-1')` *before* `load()` to exercise the realistic page-mount sequence. | @@ -118,3 +133,5 @@ timestamp: 2026-07-23T04:05:00Z - [Scoreboard Page Guide](/guides/scoreboard-page.md) - [Challenges Board](/guides/challenges-board.md) - [Notifications](/guides/notifications.md) +- [Blog Publishing and Reading](/guides/blog.md) +- [Blog API](/api/blog.md) diff --git a/docs/database/blog-posts.md b/docs/database/blog-posts.md index 71df2e8..c30ef97 100644 --- a/docs/database/blog-posts.md +++ b/docs/database/blog-posts.md @@ -3,7 +3,7 @@ type: database title: Blog Post Table description: blog_post table — draft/published Markdown posts surfaced on the public landing page. tags: [database, blog, markdown] -timestamp: 2026-07-21T18:28:00Z +timestamp: 2026-07-23T10:12:24Z --- # Schema @@ -27,12 +27,18 @@ timestamp: 2026-07-21T18:28:00Z # Behavior * Only rows with `status = 'published'` are exposed through the public - endpoint `GET /api/v1/blog/posts` (see - [REST API Overview](/api/rest-overview.md)). Drafts are hidden. + endpoint `GET /api/v1/blog/posts` (see [Blog API](/api/blog.md)). Drafts + are hidden from the landing page and authenticated `/blog` page. +* The admin CRUD endpoints under `/api/v1/admin/blog/posts` expose both + statuses and all timestamps to administrators. +* First publication sets `published_at`. Later edits, demotion to draft, + and republication preserve that original timestamp. * `BlogService.listPublished()` orders by `published_at DESC`, falling back to `created_at` when `published_at` is null. # See also - [Database Schema Overview](/database/schema.md) +- [Blog API](/api/blog.md) +- [Blog Publishing and Reading](/guides/blog.md) - [Landing Page Guide](/guides/landing-page.md) diff --git a/docs/guides/admin-shell.md b/docs/guides/admin-shell.md index f032bcb..16b3fff 100644 --- a/docs/guides/admin-shell.md +++ b/docs/guides/admin-shell.md @@ -1,9 +1,9 @@ --- type: guide title: Admin Shell & Side Navigation -description: How an authenticated admin navigates the post-login admin area, the side-nav layout, and how the General and Categories pages are reached. +description: How an authenticated admin navigates the post-login admin area, including General, Challenges, Players, Categories, and Blog management pages. tags: [guide, admin, shell, navigation, tester] -timestamp: 2026-07-22T12:00:00Z +timestamp: 2026-07-23T10:12:24Z --- # When this view is available @@ -47,9 +47,9 @@ The side-nav is defined as a static `ENTRIES` array in |-------------|-----------------------|---------|----------------------------------------------------------------------------------------------| | General | `/admin/general` | yes | [Admin — General Settings](/guides/admin-general-settings.md) (default — `/admin` redirects here). | | Challenges | `/admin/challenges` | yes | [Admin — Challenges](/guides/admin-challenges.md) — sortable list, search, Add/Edit modal, file staging, import/export. | -| Players | `/admin/players` | no | Greyed-out placeholder. No route registered. | -| Blog | `/admin/blog` | no | Greyed-out placeholder. No route registered. | -| System | `/admin/system` | no | Greyed-out placeholder. No route registered. | +| Players | `/admin/players` | yes | User management page. | +| Blog | `/admin/blog` | yes | [Blog Publishing and Reading](/guides/blog.md) — post list with draft/publish, edit, preview, and delete workflows. | +| System | `/admin/system` | no | Greyed-out placeholder. No route registered. | Each `
  • ` carries `data-testid="admin-nav-{id}"`. Disabled entries have `cursor: not-allowed` and `opacity: 0.45`. Clicking a disabled @@ -95,17 +95,18 @@ current URL starts with the entry's path. General is the default child | 4 | `frontend/src/app/features/admin/admin-shell.component.ts` | Renders the aside + body; `ENTRIES` is the static side-nav list. | | 5 | `frontend/src/app/features/admin/general.component.ts` | `AdminGeneralComponent` (default `/admin/general`); embeds `AdminCategoriesComponent`. | | 6 | `frontend/src/app/features/admin/categories/categories.component.ts` | `AdminCategoriesComponent` (`/admin/categories`); also rendered inside the General page. | -| 7 | `frontend/src/app/features/home/home.shell.ts` | `shouldShowAdminNav({isAuthenticated, role})` predicate (exported and unit-tested). | -| 8 | `frontend/src/app/features/home/home.component.ts` | Shell template renders header, conditional nav (`*ngIf="showAdminNav()"`), and ``. | -| 9 | `frontend/src/app/core/services/admin.service.ts` | Typed wrappers for `/api/v1/admin/users`, `/admin/general/*`, `/admin/categories/*`, `/uploads/{logo,category-icon}`. | -| 10 | `backend/src/modules/admin/admin.module.ts` | Registers `AdminController`, `AdminGeneralController`, `AdminCategoriesController` + their services. Imports `AuthModule`, `UsersModule`, `SettingsModule`, `CommonModule`, and `TypeOrmModule.forFeature([UserEntity, CategoryEntity, ChallengeEntity])`. | +| 7 | `frontend/src/app/features/admin/blog/blog.component.ts` | `AdminBlogComponent` (`/admin/blog`); coordinates post listing, editing, publication, and deletion. | +| 8 | `frontend/src/app/features/home/home.shell.ts` | `shouldShowAdminNav({isAuthenticated, role})` predicate (exported and unit-tested). | +| 9 | `frontend/src/app/features/home/home.component.ts` | Shell template renders header, conditional nav (`*ngIf="showAdminNav()"`), and ``. | +| 10 | `frontend/src/app/core/services/blog.service.ts` | Typed wrappers for public blog listing and `/api/v1/admin/blog/posts` CRUD. | +| 11 | `backend/src/modules/blog/blog.module.ts` | Registers public and admin blog controllers/services with the blog repository. | # Notes * The shell deliberately keeps `AdminShellComponent` as a thin layout - owner — child routes (`general`, `categories`) render into its - ``. New admin sub-pages can be added by enabling - entries in `ENTRIES` and registering a child route in `app.routes.ts`. + owner — enabled child routes render into its ``. New admin + sub-pages require both an enabled `ENTRIES` item and a child route in + `app.routes.ts`. * The guard decision function (`decideAdminGuard`) is a pure module so it is straightforward to unit-test without Angular DI. See `tests/frontend/admin-shell.spec.ts` and @@ -124,5 +125,6 @@ current URL starts with the entry's path. General is the default child - [Change Password](/guides/change-password.md) - [Admin — General Settings](/guides/admin-general-settings.md) - [Admin — Categories](/guides/admin-categories.md) +- [Blog Publishing and Reading](/guides/blog.md) - [REST API Overview](/api/rest-overview.md) - [Admin Endpoints](/api/admin.md) diff --git a/docs/guides/blog.md b/docs/guides/blog.md new file mode 100644 index 0000000..ba0e626 --- /dev/null +++ b/docs/guides/blog.md @@ -0,0 +1,71 @@ +--- +type: guide +title: Blog Publishing and Reading +description: How administrators create, edit, publish, and delete Markdown posts and how users read published posts. +tags: [guide, blog, admin, markdown, tester] +timestamp: 2026-07-23T10:12:24Z +--- + +# User views + +Published posts are available in two places: + +| View | Access | Expected content | +|---|---|---| +| `/login` | Unauthenticated users | The landing-page blog section lists published posts below the Login button. | +| `/blog` | Signed-in users | The Blog page loads and lists all published posts. | + +Both views use `BlogPresenterComponent` to show the post title, publication date, and sanitized Markdown body. Draft posts never appear in either public list. The `/blog` page shows `Loading blog posts…` while loading, `No announcements yet.` when no posts are published, and an error message if loading fails. + +# Admin workflow + +1. Sign in as an administrator. +2. Open **Admin**, then select **Blog**, or navigate directly to `/admin/blog`. +3. Confirm the page displays a table with **Title**, **Status**, **Created**, **Updated**, **Published**, and **Actions** columns. If no posts exist, it displays `No posts yet.`. +4. Click **+ New post**. +5. Enter a required title and an optional Markdown body. The live preview updates beside the editor. +6. Click **Save draft** to keep the post hidden from users, or **Publish** to expose it in `/blog` and on `/login`. +7. Confirm a success banner displays `Draft saved.` or `Post published.`, and the refreshed table contains the post with the correct status badge. + +# Editing and deleting + +* Click the pencil action on a row to open the prefilled **Edit post** modal. It shows created, updated, and publication timestamps where available. +* Saving as draft changes the status to **Draft** and hides the post from public lists. Republishing preserves the post's original publication timestamp. +* Click the trash action to open **Delete post**. The confirmation includes the title and warns that deletion cannot be undone. +* Click **Delete** to remove the post. The table refreshes and displays `Post deleted.`. Click **Cancel** or the modal backdrop to leave the post unchanged. + +# Validation and expected errors + +| Input or operation | Expected behavior | +|---|---| +| Empty or whitespace-only title | `Title is required`; save and publish remain unavailable. | +| Title over 200 characters | `Title must be at most 200 characters`. | +| Markdown body over 200,000 characters | `Body must be at most 200000 characters`. | +| API list failure | An error banner replaces the table state. | +| Create or update failure | The form stays open and displays the server message. | +| Delete failure | The confirmation stays open and displays the server message. | + +# Architecture map + +| Layer | File | Responsibility | +|---|---|---| +| Routes | `frontend/src/app/app.routes.ts` | Registers authenticated `/blog` and admin-only `/admin/blog`. | +| Admin page | `frontend/src/app/features/admin/blog/blog.component.ts` | Loads all posts and coordinates create, edit, publish, draft, and delete operations. | +| Admin form | `frontend/src/app/features/admin/blog/blog-form-modal.component.ts` | Reactive title/body editor with sanitized live Markdown preview. | +| Admin form logic | `frontend/src/app/features/admin/blog/blog-form.pure.ts` | Validation, form synchronization, request cleanup, and status labels. | +| Delete confirmation | `frontend/src/app/features/admin/blog/blog-delete-modal.component.ts` | Confirms destructive deletion and displays failures. | +| User page | `frontend/src/app/features/blog/blog.page.ts` | Loads published posts and selects loading, error, empty, or list state. | +| Shared presenter | `frontend/src/app/features/blog/blog-presenter.component.ts` | Renders a published post consistently on `/blog` and `/login`. | +| HTTP client | `frontend/src/app/core/services/blog.service.ts` | Calls public list and admin CRUD endpoints. | +| Backend wiring | `backend/src/modules/blog/blog.module.ts` | Registers public and admin controllers and services with the blog repository. | +| Admin API | `backend/src/modules/blog/admin-blog.controller.ts` | Registers guarded CRUD routes under `/api/v1/admin/blog/posts`. | +| Persistence logic | `backend/src/modules/blog/admin-blog.service.ts` | Queries and mutates `blog_post` rows and controls publication timestamps. | + +The Angular client uses HTTP JSON calls. Existing auth and CSRF interceptors attach the administrator JWT and CSRF token to unsafe requests. The backend controller is protected by `AdminGuard` and `@Roles('admin')`, then delegates to `AdminBlogService`, which uses the TypeORM `BlogPostEntity` repository. + +# See also + +- [Blog API](/api/blog.md) +- [Blog Post Table](/database/blog-posts.md) +- [Landing Page](/guides/landing-page.md) +- [Admin Shell & Side Navigation](/guides/admin-shell.md) diff --git a/docs/guides/landing-page.md b/docs/guides/landing-page.md index 608b812..5e73670 100644 --- a/docs/guides/landing-page.md +++ b/docs/guides/landing-page.md @@ -3,7 +3,7 @@ type: guide title: Landing Page description: How the public landing page renders, how the login + registration modal is opened, what each form does, and how the modal stays in sync with admin `registrationsEnabled` changes via SSE. tags: [guide, landing, login, register, ui, sse, bootstrap] -timestamp: 2026-07-22T16:15:00Z +timestamp: 2026-07-23T10:12:24Z --- # What you see @@ -31,6 +31,8 @@ On a fresh visit the page shows, in order: 5. **Blog list** — `
    ` listing every row returned by `GET /api/v1/blog/posts` (only `status='published'` rows), or the text `No announcements yet.` when the list is empty. + Each row is rendered by the shared `BlogPresenterComponent` with its + title, publication date, and sanitized Markdown body, matching `/blog`. # Guard @@ -144,4 +146,5 @@ response to the unsafe call itself. - [Auth Endpoints](/api/auth.md) - [Database Schema Overview](/database/schema.md) (`blog_post`) - [Frontend Structure](/architecture/frontend-structure.md) +- [Blog Publishing and Reading](/guides/blog.md) - [First-Run Bootstrap](/guides/bootstrap.md) diff --git a/docs/index.md b/docs/index.md index c0db5e0..2b8c2de 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,10 +8,11 @@ HIPCTF is a single-tenant CTF (Capture-The-Flag) platform built as a Node.js monorepo containing a NestJS REST API and an Angular single-page application. It supports user registration, authentication, challenge management, a live scoreboard, an event window with a public countdown, theming, and admin -controls. +controls, administrator-managed Markdown blog publishing, and public and +signed-in blog views. The docs below are organized by purpose so agents can pull just the slice -they need. Last regenerated 2026-07-23T08:42:24Z. +they need. Last regenerated 2026-07-23T10:12:24Z. # Architecture @@ -61,6 +62,7 @@ they need. Last regenerated 2026-07-23T08:42:24Z. streams, public event-window settings, and the authenticated `/events/status` SSE stream. * [Uploads Endpoints](/api/uploads.md) - Admin-only multipart uploads for category icons, challenge files, and validated site logos. +* [Blog Endpoints](/api/blog.md) - Public published-post listing and admin-only post CRUD, validation, and publication behavior. # Guides @@ -70,8 +72,9 @@ they need. Last regenerated 2026-07-23T08:42:24Z. helpers that translate reactive-form state into the inline validation messages on the first-admin modal. * [Admin Shell & Side Navigation](/guides/admin-shell.md) - How admins - navigate the post-login admin area side-nav (General, Challenges, - Players, Blog, System) and reach the enabled child pages. + navigate the post-login admin area side-nav and reach General, + Challenges, Players, and Blog pages. +* [Blog Publishing and Reading](/guides/blog.md) - How administrators create, preview, draft, publish, edit, and delete Markdown posts and how users read them. * [Admin — General Settings](/guides/admin-general-settings.md) - How an admin edits platform-wide settings, including the required and strictly ordered event window, at `/admin/general`. -- 2.52.0 From 51a7754234bb3263ef3106263cd0b96897062bcc Mon Sep 17 00:00:00 2001 From: OpenVelo Agent Date: Thu, 23 Jul 2026 10:17:53 +0000 Subject: [PATCH 2/2] feat: Admin Area Blog and Blog Page --- .kilo/plans/870.md | 82 +++++ .kilo/plans/913-build-fix.md | 70 ---- .../src/modules/blog/admin-blog.controller.ts | 73 ++++ .../src/modules/blog/admin-blog.service.ts | 82 +++++ backend/src/modules/blog/blog.module.ts | 8 +- backend/src/modules/blog/dto/blog.dto.ts | 38 +- frontend/src/app/app.routes.ts | 5 + .../src/app/core/services/blog.service.ts | 74 ++++ .../features/admin/admin-shell.component.ts | 2 +- .../admin/blog/blog-delete-modal.component.ts | 54 +++ .../admin/blog/blog-form-modal.component.ts | 223 +++++++++++ .../app/features/admin/blog/blog-form.pure.ts | 58 +++ .../app/features/admin/blog/blog.component.ts | 253 +++++++++++++ .../features/blog/blog-presenter.component.ts | 40 ++ frontend/src/app/features/blog/blog.page.ts | 54 ++- frontend/src/app/features/blog/blog.pure.ts | 17 + .../features/landing/landing.component.css | 21 ++ .../features/landing/landing.component.html | 6 +- .../app/features/landing/landing.component.ts | 3 +- tests/backend/blog-admin.spec.ts | 347 ++++++++++++++++++ tests/frontend/blog-admin-form.spec.ts | 114 ++++++ tests/frontend/blog-page.spec.ts | 41 +++ 22 files changed, 1580 insertions(+), 85 deletions(-) create mode 100644 .kilo/plans/870.md delete mode 100644 .kilo/plans/913-build-fix.md create mode 100644 backend/src/modules/blog/admin-blog.controller.ts create mode 100644 backend/src/modules/blog/admin-blog.service.ts create mode 100644 frontend/src/app/core/services/blog.service.ts create mode 100644 frontend/src/app/features/admin/blog/blog-delete-modal.component.ts create mode 100644 frontend/src/app/features/admin/blog/blog-form-modal.component.ts create mode 100644 frontend/src/app/features/admin/blog/blog-form.pure.ts create mode 100644 frontend/src/app/features/admin/blog/blog.component.ts create mode 100644 frontend/src/app/features/blog/blog-presenter.component.ts create mode 100644 frontend/src/app/features/blog/blog.pure.ts create mode 100644 tests/backend/blog-admin.spec.ts create mode 100644 tests/frontend/blog-admin-form.spec.ts create mode 100644 tests/frontend/blog-page.spec.ts diff --git a/.kilo/plans/870.md b/.kilo/plans/870.md new file mode 100644 index 0000000..c4ba9f3 --- /dev/null +++ b/.kilo/plans/870.md @@ -0,0 +1,82 @@ +# Fix Plan: TS2459 PostFormSubmitPayload not exported + +## 1. Root Cause + +`frontend/src/app/features/admin/blog/blog-form.pure.ts` declares `PostFormSubmitPayload` and `PostFormMode` as exports, but `frontend/src/app/features/admin/blog/blog-form-modal.component.ts` only **imports** them — it does not re-export them. + +`frontend/src/app/features/admin/blog/blog.component.ts:10` imports `PostFormSubmitPayload` from `./blog-form-modal.component`, which has no such export. Under `ng build --configuration production` (which uses `compilationMode: "full"` Angular strict template/type-check), this is a hard `TS2459` error and the bundle fails. + +The existing codebase convention is the opposite: `category-form-modal.component.ts` declares `CategoryFormSubmit` / `CategoryFormMode` **inside** the modal file and consumers (`categories.component.ts`) import them from the modal component directly. My initial layout diverged from that pattern by colocating the modal-output types in the `.pure.ts` module. + +## 2. Fix (chosen approach: match the existing `CategoryFormSubmit` convention) + +Move the two UI-emit types out of `blog-form.pure.ts` and into `blog-form-modal.component.ts`, then update the two importers. + +### 2.1 `frontend/src/app/features/admin/blog/blog-form-modal.component.ts` (modify) + +Add at the top (right after the existing imports from `./blog-form.pure`): + +```ts +export type PostFormMode = 'create' | 'edit'; + +export interface PostFormSubmitPayload { + mode: PostFormMode; + title: string; + bodyMd: string; + action: 'draft' | 'publish'; +} +``` + +Remove these two names from the import list at line 19-20: + +```ts +// before +import { + PostFormGroup, + PostFormMode, // ← remove + PostFormSubmitPayload, // ← remove + buildPostBody, + syncPostForm, + validatePostForm, +} from './blog-form.pure'; + +// after +import { + PostFormGroup, + buildPostBody, + syncPostForm, + validatePostForm, +} from './blog-form.pure'; +``` + +`PostFormGroup` stays imported from the `.pure.ts` module (it is the pure form-shape type, used internally by the modal). + +### 2.2 `frontend/src/app/features/admin/blog/blog-form.pure.ts` (modify) + +Remove the two declarations (lines 4 and 11-16) so the pure module no longer leaks UI-emit types: + +- Delete `export type PostFormMode = 'create' | 'edit';` +- Delete the entire `export interface PostFormSubmitPayload { ... }` block. + +Keep the rest of the file unchanged: +- `PostFormGroup` +- `MAX_TITLE_LENGTH` / `MAX_BODY_LENGTH` +- `validatePostForm`, `syncPostForm`, `buildPostBody`, `statusBadgeClass`, `statusLabel` + +### 2.3 No other edits required + +- `frontend/src/app/features/admin/blog/blog.component.ts` already imports `PostFormSubmitPayload` from `./blog-form-modal.component` — after step 2.1 that import resolves correctly. No change. +- `tests/frontend/blog-admin-form.spec.ts` does **not** import `PostFormMode` or `PostFormSubmitPayload` (verified — only `PostFormGroup`, `buildPostBody`, `statusBadgeClass`, `statusLabel`, `syncPostForm`, `validatePostForm`). No change. +- `tests/frontend/blog-page.spec.ts` is unaffected. + +## 3. Verification + +After the fix: + +1. `cd /repo && npx ng build --configuration production` must succeed (the original failing command). +2. `cd /repo && npm test` must continue to pass (89 suites, 678 tests). +3. The `BlogFormModalComponent.submit` output still emits the same `PostFormSubmitPayload` shape — `blog.component.ts:onFormSubmit` is unchanged. + +## 4. Why this fix over the alternative + +Alternative considered: change the import in `blog.component.ts` from `'./blog-form-modal.component'` to `'./blog-form.pure'`. This is a one-line change but it diverges from the established `CategoryFormSubmit` pattern (modal-emit types live with the modal), and it forces every future consumer of the form payload to import from the `.pure.ts` module instead of the modal. The chosen fix aligns this feature with the rest of the codebase and keeps the modal's public API self-contained. diff --git a/.kilo/plans/913-build-fix.md b/.kilo/plans/913-build-fix.md deleted file mode 100644 index be74460..0000000 --- a/.kilo/plans/913-build-fix.md +++ /dev/null @@ -1,70 +0,0 @@ -# Implementation Plan: Fix Job 913 Build Error (missing `auth` injection in AdminUsersComponent) - -## 1. Root cause -The `onToggleRole` handler added in the previous turn calls -`this.auth.currentUser()`, `this.auth.applyPeerRoleChange(...)`, and -`this.auth.publishRoleChange(...)` -(`frontend/src/app/features/admin/admin-users.component.ts:244-247`), but -`AdminUsersComponent` never declared an `auth` field. The component only injects -`AdminService` (`admin`) and `NotificationService` (`notify`). TypeScript build -(`ng build --configuration production`) therefore fails with `TS2339: Property -'auth' does not exist on type 'AdminUsersComponent'` at lines 244, 246, 247. - -The Jest tests passed because they exercise pure modules and -`admin-players.pure`, not the component class — so the missing field was not -caught at test time. - -## 2. Required changes (single file) - -**File:** `frontend/src/app/features/admin/admin-users.component.ts` - -1. **Add the import** alongside the existing core-service imports (lines 10-11 - currently import `AdminService` and `NotificationService`): - -```ts -import { AuthService, AdminUser } from '...'; -``` - -The `AdminUser` type can be left on the existing `AdminService` import line. We -must add `AuthService` and import only the value (not the type) — `AuthService` -is the singleton provided in `'root'` by `core/services/auth.service.ts`. - -The accurate edit is: - -- Replace the existing `import { AdminService, AdminUser } from '...admin.service';` - with two separate imports, or add the new import: - -```ts -import { AdminService, AdminUser } from '../../core/services/admin.service'; -import { AuthService } from '../../core/services/auth.service'; -``` - -2. **Inject the service** alongside the two existing injections (lines ~170-171): - -```ts -private readonly admin = inject(AdminService); -private readonly notify = inject(NotificationService); -private readonly auth = inject(AuthService); -``` - -That is the only change required — the existing `onToggleRole` body already -references `this.auth.currentUser`, `this.auth.applyPeerRoleChange`, and -`this.auth.publishRoleChange`, all of which exist on `AuthService`. - -## 3. Verification - -- `npm --workspace frontend run build` should pass (no TypeScript errors). -- `npm test` should still pass — the existing pure tests - (`tests/frontend/admin-players.pure.spec.ts`, - `tests/frontend/auth-session-events.spec.ts`, - `tests/backend/admin-players.spec.ts`) are unaffected by adding the - injection. -- No public API, route, store, or service signature changes are required. - -## 4. Out of scope - -- No backend changes. -- No test additions (the build error is a pure TypeScript declaration - issue; tests already cover the behaviour). -- No re-architecture of cross-tab role-change handling — the previous turn's - implementation is correct, only the missing field declaration is needed. diff --git a/backend/src/modules/blog/admin-blog.controller.ts b/backend/src/modules/blog/admin-blog.controller.ts new file mode 100644 index 0000000..9e3f54d --- /dev/null +++ b/backend/src/modules/blog/admin-blog.controller.ts @@ -0,0 +1,73 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + Post, + Put, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { AdminGuard } from '../../common/guards/admin.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe'; +import { + AdminBlogPost, + CreatePostPayload, + CreatePostSchema, + PostIdParamSchema, + UpdatePostPayload, + UpdatePostSchema, +} from './dto/blog.dto'; +import { AdminBlogService } from './admin-blog.service'; + +@ApiTags('admin') +@ApiBearerAuth() +@Controller('api/v1/admin/blog/posts') +@UseGuards(AdminGuard) +@Roles('admin') +export class AdminBlogController { + constructor(private readonly admin: AdminBlogService) {} + + @Get() + @ApiOperation({ summary: 'List all blog posts (admin only, drafts included)' }) + async list(): Promise { + return this.admin.listAll(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a single blog post by id (admin only)' }) + async getOne( + @Param(new ZodValidationPipe(PostIdParamSchema)) params: { id: string }, + ): Promise { + return this.admin.getById(params.id); + } + + @Post() + @ApiOperation({ summary: 'Create a new blog post (admin only)' }) + async create( + @Body(new ZodValidationPipe(CreatePostSchema)) body: CreatePostPayload, + ): Promise { + return this.admin.create(body); + } + + @Put(':id') + @ApiOperation({ summary: 'Update a blog post (admin only)' }) + async update( + @Param(new ZodValidationPipe(PostIdParamSchema)) params: { id: string }, + @Body(new ZodValidationPipe(UpdatePostSchema)) body: UpdatePostPayload, + ): Promise { + return this.admin.update(params.id, body); + } + + @Delete(':id') + @HttpCode(204) + @ApiOperation({ summary: 'Delete a blog post (admin only)' }) + async remove( + @Param(new ZodValidationPipe(PostIdParamSchema)) params: { id: string }, + ): Promise { + await this.admin.remove(params.id); + } +} diff --git a/backend/src/modules/blog/admin-blog.service.ts b/backend/src/modules/blog/admin-blog.service.ts new file mode 100644 index 0000000..f2a14cc --- /dev/null +++ b/backend/src/modules/blog/admin-blog.service.ts @@ -0,0 +1,82 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { v4 as uuid } from 'uuid'; +import { BlogPostEntity } from '../../database/entities/blog-post.entity'; +import { ApiError } from '../../common/errors/api-error'; +import { AdminBlogPost, CreatePostPayload, UpdatePostPayload } from './dto/blog.dto'; + +@Injectable() +export class AdminBlogService { + constructor( + @InjectRepository(BlogPostEntity) + private readonly posts: Repository, + ) {} + + async listAll(): Promise { + const rows = await this.posts.find({ order: { updatedAt: 'DESC' } }); + return rows.map((r) => this.toView(r)); + } + + async getById(id: string): Promise { + const row = await this.posts.findOne({ where: { id } }); + if (!row) throw ApiError.notFound('Post not found'); + return this.toView(row); + } + + async create(payload: CreatePostPayload): Promise { + const status = payload.status ?? 'draft'; + const row = this.posts.create({ + id: uuid(), + title: payload.title.trim(), + bodyMd: payload.bodyMd ?? '', + status, + publishedAt: status === 'published' ? new Date().toISOString() : null, + }); + await this.posts.save(row); + return this.toView(row); + } + + async update(id: string, payload: UpdatePostPayload): Promise { + const row = await this.posts.findOne({ where: { id } }); + if (!row) throw ApiError.notFound('Post not found'); + + if (payload.title !== undefined) row.title = payload.title.trim(); + if (payload.bodyMd !== undefined) row.bodyMd = payload.bodyMd; + + if (payload.status !== undefined) { + if (payload.status === 'published') { + row.status = 'published'; + // Only stamp publishedAt if this post has never been published before; + // preserve the original publishedAt for re-publications. + if (!row.publishedAt) { + row.publishedAt = new Date().toISOString(); + } + } else if (payload.status === 'draft') { + row.status = 'draft'; + // preserve publishedAt so a later re-publish keeps the original publication timestamp. + } + } + + await this.posts.save(row); + return this.toView(row); + } + + async remove(id: string): Promise { + const row = await this.posts.findOne({ where: { id } }); + if (!row) throw ApiError.notFound('Post not found'); + await this.posts.delete({ id }); + } + + private toView(r: BlogPostEntity): AdminBlogPost { + return { + id: r.id, + title: r.title, + bodyMd: r.bodyMd ?? '', + status: r.status, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + publishedAt: r.publishedAt ?? null, + }; + } +} diff --git a/backend/src/modules/blog/blog.module.ts b/backend/src/modules/blog/blog.module.ts index b2cb9a2..1ae99fd 100644 --- a/backend/src/modules/blog/blog.module.ts +++ b/backend/src/modules/blog/blog.module.ts @@ -3,10 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { BlogPostEntity } from '../../database/entities/blog-post.entity'; import { BlogController } from './blog.controller'; import { BlogService } from './blog.service'; +import { AdminBlogController } from './admin-blog.controller'; +import { AdminBlogService } from './admin-blog.service'; @Module({ imports: [TypeOrmModule.forFeature([BlogPostEntity])], - controllers: [BlogController], - providers: [BlogService], + controllers: [BlogController, AdminBlogController], + providers: [BlogService, AdminBlogService], }) -export class BlogModule {} \ No newline at end of file +export class BlogModule {} diff --git a/backend/src/modules/blog/dto/blog.dto.ts b/backend/src/modules/blog/dto/blog.dto.ts index a5b511c..7d14742 100644 --- a/backend/src/modules/blog/dto/blog.dto.ts +++ b/backend/src/modules/blog/dto/blog.dto.ts @@ -11,4 +11,40 @@ export type PublicBlogPost = z.infer; export const PublicBlogListSchema = z.object({ posts: z.array(PublicBlogPostSchema), }); -export type PublicBlogList = z.infer; \ No newline at end of file +export type PublicBlogList = z.infer; + +export const AdminBlogPostSchema = z.object({ + id: z.string(), + title: z.string(), + bodyMd: z.string(), + status: z.enum(['draft', 'published']), + createdAt: z.string(), + updatedAt: z.string(), + publishedAt: z.string().nullable(), +}); +export type AdminBlogPost = z.infer; + +export const CreatePostSchema = z.object({ + title: z + .string() + .min(1, 'Title is required') + .max(200, 'Title must be at most 200 characters') + .refine((t) => t.trim().length > 0, { message: 'Title is required' }), + bodyMd: z.string().max(200_000, 'Body must be at most 200000 characters').default(''), + status: z.enum(['draft', 'published']).optional(), +}); +export type CreatePostPayload = z.infer; + +export const UpdatePostSchema = z.object({ + title: z + .string() + .min(1, 'Title is required') + .max(200, 'Title must be at most 200 characters') + .refine((t) => t.trim().length > 0, { message: 'Title is required' }) + .optional(), + bodyMd: z.string().max(200_000, 'Body must be at most 200000 characters').optional(), + status: z.enum(['draft', 'published']).optional(), +}); +export type UpdatePostPayload = z.infer; + +export const PostIdParamSchema = z.object({ id: z.string().min(1).max(64) }); diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 00f8b80..22697a7 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -64,6 +64,11 @@ export const APP_ROUTES: Routes = [ loadComponent: () => import('./features/admin/admin-users.component').then((m) => m.AdminUsersComponent), }, + { + path: 'blog', + loadComponent: () => + import('./features/admin/blog/blog.component').then((m) => m.AdminBlogComponent), + }, ], }, ], diff --git a/frontend/src/app/core/services/blog.service.ts b/frontend/src/app/core/services/blog.service.ts new file mode 100644 index 0000000..aebccbd --- /dev/null +++ b/frontend/src/app/core/services/blog.service.ts @@ -0,0 +1,74 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; + +export type BlogStatus = 'draft' | 'published'; + +export interface AdminBlogPost { + id: string; + title: string; + bodyMd: string; + status: BlogStatus; + createdAt: string; + updatedAt: string; + publishedAt: string | null; +} + +export interface CreatePostBody { + title: string; + bodyMd: string; + status?: BlogStatus; +} + +export interface UpdatePostBody { + title?: string; + bodyMd?: string; + status?: BlogStatus; +} + +export interface PublicBlogPost { + id: string; + title: string; + publishedAt: string; + bodyMd: string; +} + +@Injectable({ providedIn: 'root' }) +export class BlogApiService { + private readonly http = inject(HttpClient); + + async listAllPosts(): Promise { + return firstValueFrom( + this.http.get('/api/v1/admin/blog/posts', { withCredentials: true }), + ); + } + + async createPost(body: CreatePostBody): Promise { + return firstValueFrom( + this.http.post('/api/v1/admin/blog/posts', body, { withCredentials: true }), + ); + } + + async updatePost(id: string, body: UpdatePostBody): Promise { + return firstValueFrom( + this.http.put(`/api/v1/admin/blog/posts/${encodeURIComponent(id)}`, body, { + withCredentials: true, + }), + ); + } + + async deletePost(id: string): Promise { + await firstValueFrom( + this.http.delete(`/api/v1/admin/blog/posts/${encodeURIComponent(id)}`, { + withCredentials: true, + }), + ); + } + + async listPublished(): Promise { + const res = await firstValueFrom( + this.http.get<{ posts: PublicBlogPost[] }>('/api/v1/blog/posts', { withCredentials: true }), + ); + return res.posts ?? []; + } +} diff --git a/frontend/src/app/features/admin/admin-shell.component.ts b/frontend/src/app/features/admin/admin-shell.component.ts index b36beff..22e4798 100644 --- a/frontend/src/app/features/admin/admin-shell.component.ts +++ b/frontend/src/app/features/admin/admin-shell.component.ts @@ -13,7 +13,7 @@ const ENTRIES: AdminNavEntry[] = [ { id: 'general', label: 'General', path: '/admin/general', enabled: true }, { id: 'challenges', label: 'Challenges', path: '/admin/challenges', enabled: true }, { id: 'players', label: 'Players', path: '/admin/players', enabled: true }, - { id: 'blog', label: 'Blog', path: '/admin/blog', enabled: false }, + { id: 'blog', label: 'Blog', path: '/admin/blog', enabled: true }, { id: 'system', label: 'System', path: '/admin/system', enabled: false }, ]; diff --git a/frontend/src/app/features/admin/blog/blog-delete-modal.component.ts b/frontend/src/app/features/admin/blog/blog-delete-modal.component.ts new file mode 100644 index 0000000..c0c35bb --- /dev/null +++ b/frontend/src/app/features/admin/blog/blog-delete-modal.component.ts @@ -0,0 +1,54 @@ +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { AdminBlogPost } from '../../../core/services/blog.service'; + +@Component({ + selector: 'app-blog-delete-modal', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule], + styles: [` + .modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 50; } + .modal { background: var(--color-surface, #fff); padding: 16px; border-radius: var(--radius-md, 8px); min-width: 360px; max-width: 90vw; } + .actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; } + .error { color: var(--color-danger, #f00); margin-top: 8px; } + `], + template: ` + @if (open()) { + + } + `, +}) +export class BlogDeleteModalComponent { + readonly open = input(false); + readonly post = input(null); + readonly errorMessage = input(null); + readonly deleting = input(false); + + readonly cancel = output(); + readonly confirm = output(); + + onCancel(): void { + this.cancel.emit(); + } + + onConfirm(): void { + this.confirm.emit(); + } +} diff --git a/frontend/src/app/features/admin/blog/blog-form-modal.component.ts b/frontend/src/app/features/admin/blog/blog-form-modal.component.ts new file mode 100644 index 0000000..1b22d1f --- /dev/null +++ b/frontend/src/app/features/admin/blog/blog-form-modal.component.ts @@ -0,0 +1,223 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + effect, + inject, + input, + output, + signal, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MarkdownService } from '../../../core/services/markdown.service'; +import { AdminBlogPost } from '../../../core/services/blog.service'; +import { + PostFormGroup, + buildPostBody, + syncPostForm, + validatePostForm, +} from './blog-form.pure'; + +export type PostFormMode = 'create' | 'edit'; + +export interface PostFormSubmitPayload { + mode: PostFormMode; + title: string; + bodyMd: string; + action: 'draft' | 'publish'; +} + +@Component({ + selector: 'app-blog-form-modal', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, ReactiveFormsModule], + styles: [` + .modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 50; } + .modal { background: var(--color-surface, #fff); padding: 16px; border-radius: var(--radius-md, 8px); min-width: 480px; max-width: 90vw; max-height: 90vh; overflow: auto; } + .row { margin: 8px 0; display: flex; flex-direction: column; gap: 4px; } + .layout { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } + .preview { border: 1px solid var(--color-secondary, #ccc); padding: 8px; border-radius: var(--radius-sm, 4px); background: var(--color-surface, #fafafa); min-height: 200px; max-height: 320px; overflow: auto; } + .actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; } + .field-error { color: var(--color-danger, #f00); font-size: 12px; } + .editing-meta { font-size: 12px; opacity: 0.7; } + `], + template: ` + @if (open()) { + + } + `, +}) +export class BlogFormModalComponent { + private readonly fb = inject(FormBuilder); + private readonly markdown = inject(MarkdownService); + private readonly destroyRef = inject(DestroyRef); + private readonly cdr = inject(ChangeDetectorRef); + + readonly open = input(false); + readonly mode = input('create'); + readonly post = input(null); + readonly errorMessage = input(null); + readonly saving = input(false); + + readonly cancel = output(); + readonly submit = output(); + + readonly previewHtml = signal(''); + readonly titleTouched = signal(false); + readonly bodyTouched = signal(false); + + readonly form: PostFormGroup = this.fb.nonNullable.group({ + title: this.fb.nonNullable.control(''), + bodyMd: this.fb.nonNullable.control(''), + }); + + constructor() { + effect( + () => { + syncPostForm(this.form, this.mode(), this.post()); + this.titleTouched.set(false); + this.bodyTouched.set(false); + this.previewHtml.set(this.markdown.render(this.form.controls.bodyMd.value ?? '')); + this.cdr.markForCheck(); + }, + { allowSignalWrites: true }, + ); + + this.form.controls.title.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.titleTouched.set(true); + }); + this.form.controls.bodyMd.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((v) => { + this.bodyTouched.set(true); + this.previewHtml.set(this.markdown.render(v ?? '')); + }); + } + + onBodyInput(): void { + // valueChanges already triggers the preview; this is here as an explicit + // hook in case the template wiring changes. + } + + titleError(): string | null { + const v = this.form.controls.title.value ?? ''; + if (!this.titleTouched() && v.length === 0) return null; + const result = validatePostForm({ title: v, bodyMd: this.form.controls.bodyMd.value ?? '' }); + return result.errors.title ?? null; + } + + bodyError(): string | null { + const v = this.form.controls.bodyMd.value ?? ''; + if (!this.bodyTouched() && v.length === 0) return null; + const result = validatePostForm({ title: this.form.controls.title.value ?? '', bodyMd: v }); + return result.errors.bodyMd ?? null; + } + + canSaveDraft(): boolean { + if (this.saving()) return false; + const v = this.form.getRawValue(); + const result = validatePostForm({ title: v.title ?? '', bodyMd: v.bodyMd ?? '' }); + return result.ok; + } + + canPublish(): boolean { + return this.canSaveDraft(); + } + + onCancel(): void { + this.cancel.emit(); + } + + onSubmit(action: 'draft' | 'publish'): void { + this.titleTouched.set(true); + this.bodyTouched.set(true); + const raw = this.form.getRawValue(); + const result = validatePostForm({ title: raw.title ?? '', bodyMd: raw.bodyMd ?? '' }); + if (!result.ok) return; + const cleaned = buildPostBody({ title: raw.title ?? '', bodyMd: raw.bodyMd ?? '' }); + this.submit.emit({ mode: this.mode(), ...cleaned, action }); + } +} diff --git a/frontend/src/app/features/admin/blog/blog-form.pure.ts b/frontend/src/app/features/admin/blog/blog-form.pure.ts new file mode 100644 index 0000000..6a3fa13 --- /dev/null +++ b/frontend/src/app/features/admin/blog/blog-form.pure.ts @@ -0,0 +1,58 @@ +import { FormControl, FormGroup } from '@angular/forms'; +import { AdminBlogPost, BlogStatus } from '../../../core/services/blog.service'; + +export type PostFormGroup = FormGroup<{ + title: FormControl; + bodyMd: FormControl; +}>; + +export const MAX_TITLE_LENGTH = 200; +export const MAX_BODY_LENGTH = 200_000; + +export function validatePostForm(value: { title: string; bodyMd: string }): { + ok: boolean; + errors: { title?: string; bodyMd?: string }; +} { + const errors: { title?: string; bodyMd?: string } = {}; + const title = (value.title ?? '').trim(); + if (title.length === 0) errors.title = 'Title is required'; + else if (title.length > MAX_TITLE_LENGTH) errors.title = `Title must be at most ${MAX_TITLE_LENGTH} characters`; + + const body = value.bodyMd ?? ''; + if (body.length > MAX_BODY_LENGTH) errors.bodyMd = `Body must be at most ${MAX_BODY_LENGTH} characters`; + + return { ok: Object.keys(errors).length === 0, errors }; +} + +export function syncPostForm( + form: PostFormGroup, + mode: 'create' | 'edit', + post: AdminBlogPost | null, +): void { + if (mode === 'edit' && post) { + form.controls.title.setValue(post.title ?? '', { emitEvent: false }); + form.controls.bodyMd.setValue(post.bodyMd ?? '', { emitEvent: false }); + } else { + form.controls.title.setValue('', { emitEvent: false }); + form.controls.bodyMd.setValue('', { emitEvent: false }); + } + form.controls.title.markAsUntouched(); + form.controls.title.markAsPristine(); + form.controls.bodyMd.markAsUntouched(); + form.controls.bodyMd.markAsPristine(); +} + +export function buildPostBody(formValue: { title: string; bodyMd: string }): { + title: string; + bodyMd: string; +} { + return { title: formValue.title.trim(), bodyMd: formValue.bodyMd ?? '' }; +} + +export function statusBadgeClass(status: BlogStatus): string { + return status === 'published' ? 'badge-published' : 'badge-draft'; +} + +export function statusLabel(status: BlogStatus): string { + return status === 'published' ? 'Published' : 'Draft'; +} diff --git a/frontend/src/app/features/admin/blog/blog.component.ts b/frontend/src/app/features/admin/blog/blog.component.ts new file mode 100644 index 0000000..e2e3c5e --- /dev/null +++ b/frontend/src/app/features/admin/blog/blog.component.ts @@ -0,0 +1,253 @@ +import { + ChangeDetectionStrategy, + Component, + OnInit, + inject, + signal, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { BlogApiService, AdminBlogPost } from '../../../core/services/blog.service'; +import { BlogFormModalComponent, PostFormSubmitPayload } from './blog-form-modal.component'; +import { BlogDeleteModalComponent } from './blog-delete-modal.component'; +import { statusBadgeClass, statusLabel } from './blog-form.pure'; + +type ModalMode = 'create' | 'edit' | null; + +@Component({ + selector: 'app-admin-blog', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, BlogFormModalComponent, BlogDeleteModalComponent], + styles: [` + .admin-blog { display: flex; flex-direction: column; gap: 12px; } + .toolbar { display: flex; justify-content: space-between; align-items: center; } + .toolbar h2 { margin: 0; } + .status-banner { padding: 8px; border-radius: var(--radius-sm, 4px); } + .status-banner.success { background: #ecfdf5; color: #065f46; } + .status-banner.error { background: #fee2e2; color: #991b1b; } + .table { width: 100%; border-collapse: collapse; } + .table th, .table td { padding: 8px; border-bottom: 1px solid var(--color-secondary, #ccc); text-align: left; vertical-align: top; } + .badge { padding: 2px 6px; border-radius: 4px; font-size: 12px; } + .badge-draft { background: var(--color-secondary, #ddd); color: #444; } + .badge-published { background: #d1fae5; color: #065f46; } + .row-actions button { margin-left: 4px; } + .empty { text-align: center; padding: 16px; opacity: 0.7; } + button[disabled] { opacity: 0.5; cursor: not-allowed; } + `], + template: ` +
    +
    +

    Blog

    + +
    + + @if (statusMessage()) { +

    + {{ statusMessage() }} +

    + } + @if (loadError()) { + + } + + @if (loading()) { +

    Loading posts…

    + } @else if (posts().length === 0) { +

    No posts yet.

    + } @else { + + + + + + + + + + + + + @for (p of posts(); track p.id) { + + + + + + + + + } + +
    TitleStatusCreatedUpdatedPublishedActions
    {{ p.title }} + + {{ statusLabel(p.status) }} + + {{ p.createdAt | date: 'medium' }}{{ p.updatedAt | date: 'medium' }} + @if (p.publishedAt) { + {{ p.publishedAt | date: 'medium' }} + } @else { + — + } + + + +
    + } + + + + +
    + `, +}) +export class AdminBlogComponent implements OnInit { + private readonly blog = inject(BlogApiService); + + readonly posts = signal([]); + readonly loading = signal(true); + readonly actionInFlight = signal(false); + readonly loadError = signal(null); + readonly statusMessage = signal(null); + + readonly modalMode = signal(null); + readonly editing = signal(null); + readonly deleting = signal(null); + readonly deleteOpen = signal(false); + readonly formError = signal(null); + readonly deleteError = signal(null); + readonly saving = signal(false); + + readonly statusBadgeClass = statusBadgeClass; + readonly statusLabel = statusLabel; + + async ngOnInit(): Promise { + await this.load(); + } + + async load(): Promise { + this.loading.set(true); + this.loadError.set(null); + try { + const list = await this.blog.listAllPosts(); + this.posts.set(list); + } catch (e: any) { + this.loadError.set(e?.error?.message ?? e?.message ?? 'Failed to load posts'); + } finally { + this.loading.set(false); + } + } + + openCreate(): void { + this.editing.set(null); + this.deleting.set(null); + this.deleteOpen.set(false); + this.deleteError.set(null); + this.formError.set(null); + this.modalMode.set('create'); + this.statusMessage.set(null); + } + + openEdit(p: AdminBlogPost): void { + this.deleting.set(null); + this.deleteOpen.set(false); + this.editing.set(p); + this.deleteError.set(null); + this.formError.set(null); + this.modalMode.set('edit'); + this.statusMessage.set(null); + } + + openDelete(p: AdminBlogPost): void { + this.editing.set(null); + this.modalMode.set(null); + this.deleting.set(p); + this.deleteError.set(null); + this.deleteOpen.set(true); + this.statusMessage.set(null); + } + + closeModal(): void { + this.modalMode.set(null); + this.deleteOpen.set(false); + this.editing.set(null); + this.deleting.set(null); + this.formError.set(null); + this.deleteError.set(null); + this.saving.set(false); + } + + async onFormSubmit(payload: PostFormSubmitPayload): Promise { + this.saving.set(true); + this.formError.set(null); + this.actionInFlight.set(true); + try { + const status = payload.action === 'publish' ? 'published' : 'draft'; + if (payload.mode === 'create') { + const created = await this.blog.createPost({ + title: payload.title, + bodyMd: payload.bodyMd, + status, + }); + this.statusMessage.set( + status === 'published' ? 'Post published.' : 'Draft saved.', + ); + this.closeModal(); + await this.load(); + // Highlight row briefly — no-op, but ensure we set posts above. + void created; + } else if (payload.mode === 'edit' && this.editing()) { + const id = this.editing()!.id; + await this.blog.updatePost(id, { + title: payload.title, + bodyMd: payload.bodyMd, + status, + }); + this.statusMessage.set( + status === 'published' ? 'Post published.' : 'Draft saved.', + ); + this.closeModal(); + await this.load(); + } + } catch (e: any) { + this.formError.set(e?.error?.message ?? e?.message ?? 'Failed to save'); + } finally { + this.saving.set(false); + this.actionInFlight.set(false); + } + } + + async onDeleteConfirm(): Promise { + const p = this.deleting(); + if (!p) return; + this.actionInFlight.set(true); + try { + await this.blog.deletePost(p.id); + this.statusMessage.set('Post deleted.'); + this.closeModal(); + await this.load(); + } catch (e: any) { + this.deleteError.set(e?.error?.message ?? e?.message ?? 'Failed to delete'); + } finally { + this.actionInFlight.set(false); + } + } +} diff --git a/frontend/src/app/features/blog/blog-presenter.component.ts b/frontend/src/app/features/blog/blog-presenter.component.ts new file mode 100644 index 0000000..d870020 --- /dev/null +++ b/frontend/src/app/features/blog/blog-presenter.component.ts @@ -0,0 +1,40 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MarkdownService } from '../../core/services/markdown.service'; +import { PublicBlogPost } from '../../core/services/blog.service'; + +@Component({ + selector: 'app-blog-presenter', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule], + styles: [` + .blog-item { display: block; margin: 0 0 16px 0; } + .blog-title { margin: 0 0 4px 0; font-size: 18px; } + .blog-date { display: block; font-size: 12px; opacity: 0.7; margin-bottom: 8px; } + .blog-body { font-size: 14px; } + `], + template: ` +
    +

    + {{ post().title }} +

    + +
    +
    + `, +}) +export class BlogPresenterComponent { + readonly post = input.required(); + private readonly markdown = new MarkdownService(); + + renderedHtml(): string { + return this.markdown.render(this.post()?.bodyMd ?? ''); + } +} diff --git a/frontend/src/app/features/blog/blog.page.ts b/frontend/src/app/features/blog/blog.page.ts index e07a6df..aaebcb2 100644 --- a/frontend/src/app/features/blog/blog.page.ts +++ b/frontend/src/app/features/blog/blog.page.ts @@ -1,14 +1,60 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { BlogApiService, PublicBlogPost } from '../../core/services/blog.service'; +import { BlogPresenterComponent } from './blog-presenter.component'; +import { deriveBlogListState } from './blog.pure'; @Component({ selector: 'app-blog-page', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, BlogPresenterComponent], + styles: [` + .blog-page { display: flex; flex-direction: column; gap: 12px; } + .blog-loading, .blog-error { padding: 8px; } + .blog-error { color: var(--color-danger, #f00); } + .blog-empty { opacity: 0.7; } + .blog-list { display: flex; flex-direction: column; } + `], template: ` -
    +

    Blog

    -

    Blog posts will appear here.

    + + @if (state().kind === 'loading') { +

    Loading blog posts…

    + } @else if (state().kind === 'error') { +

    {{ state().message }}

    + } @else if (state().kind === 'empty') { +

    No announcements yet.

    + } @else { +
    + @for (post of posts(); track post.id) { + + } +
    + }
    `, }) -export class BlogPage {} +export class BlogPage implements OnInit { + private readonly blog = inject(BlogApiService); + + readonly posts = signal([]); + readonly loading = signal(true); + readonly error = signal(null); + + readonly state = computed(() => + deriveBlogListState({ loading: this.loading(), error: this.error(), count: this.posts().length }), + ); + + async ngOnInit(): Promise { + try { + const list = await this.blog.listPublished(); + this.posts.set(list); + } catch (e: any) { + this.error.set(e?.error?.message ?? e?.message ?? 'Failed to load blog posts'); + } finally { + this.loading.set(false); + } + } +} diff --git a/frontend/src/app/features/blog/blog.pure.ts b/frontend/src/app/features/blog/blog.pure.ts new file mode 100644 index 0000000..f0aced4 --- /dev/null +++ b/frontend/src/app/features/blog/blog.pure.ts @@ -0,0 +1,17 @@ +export type BlogListKind = 'loading' | 'error' | 'empty' | 'list'; + +export interface BlogListState { + kind: BlogListKind; + message?: string; +} + +export function deriveBlogListState(input: { + loading: boolean; + error: string | null; + count: number; +}): BlogListState { + if (input.loading) return { kind: 'loading' }; + if (input.error) return { kind: 'error', message: input.error }; + if (input.count === 0) return { kind: 'empty' }; + return { kind: 'list' }; +} diff --git a/frontend/src/app/features/landing/landing.component.css b/frontend/src/app/features/landing/landing.component.css index 1d5ab45..1301d50 100644 --- a/frontend/src/app/features/landing/landing.component.css +++ b/frontend/src/app/features/landing/landing.component.css @@ -64,6 +64,27 @@ line-height: 1.5; } +.landing-blog app-blog-presenter .blog-item { + border-top: 1px solid var(--color-surface, #eee); + padding-top: 1rem; +} + +.landing-blog app-blog-presenter .blog-title { + margin: 0 0 0.25rem 0; + font-size: 1.25rem; +} + +.landing-blog app-blog-presenter .blog-date { + display: block; + color: var(--color-text-muted, #888); + font-size: 0.85rem; + margin-bottom: 0.5rem; +} + +.landing-blog app-blog-presenter .blog-body { + line-height: 1.5; +} + .landing-blog-empty { text-align: center; color: var(--color-text-muted, #888); diff --git a/frontend/src/app/features/landing/landing.component.html b/frontend/src/app/features/landing/landing.component.html index eb65e53..ae14735 100644 --- a/frontend/src/app/features/landing/landing.component.html +++ b/frontend/src/app/features/landing/landing.component.html @@ -20,11 +20,7 @@ @if (landing.posts().length > 0) {
    @for (post of landing.posts(); track post.id) { -
    -

    {{ post.title }}

    - -
    -
    + }
    } @else if (!landing.loading()) { diff --git a/frontend/src/app/features/landing/landing.component.ts b/frontend/src/app/features/landing/landing.component.ts index bbf17b8..9bb5b84 100644 --- a/frontend/src/app/features/landing/landing.component.ts +++ b/frontend/src/app/features/landing/landing.component.ts @@ -16,6 +16,7 @@ import { MarkdownService } from '../../core/services/markdown.service'; import { LandingService } from './landing.service'; import { buildLoginFailureMessage } from './login-modal.service'; import { passwordMatchValidator } from '../setup/setup-create-admin.validators'; +import { BlogPresenterComponent } from '../blog/blog-presenter.component'; type ModalMode = 'closed' | 'login' | 'register'; @@ -23,7 +24,7 @@ type ModalMode = 'closed' | 'login' | 'register'; selector: 'app-landing', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CommonModule, ReactiveFormsModule], + imports: [CommonModule, ReactiveFormsModule, BlogPresenterComponent], templateUrl: './landing.component.html', styleUrls: ['./landing.component.css'], }) diff --git a/tests/backend/blog-admin.spec.ts b/tests/backend/blog-admin.spec.ts new file mode 100644 index 0000000..947adcf --- /dev/null +++ b/tests/backend/blog-admin.spec.ts @@ -0,0 +1,347 @@ +process.env.DATABASE_PATH = ':memory:'; +process.env.THEMES_DIR = './themes'; +process.env.FRONTEND_DIST = './frontend/dist'; + +import { Test } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { HttpAdapterHost } from '@nestjs/core'; +import cookieParser from 'cookie-parser'; +import * as express from 'express'; +import request from 'supertest'; +import { CookieAccessInfo } from 'cookiejar'; +import { AppModule } from '../../backend/src/app.module'; +import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter'; +import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware'; +import { ConfigService } from '@nestjs/config'; +import { initDb } from './db-helper'; +import { BlogPostEntity } from '../../backend/src/database/entities/blog-post.entity'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +async function loginAs(app: INestApplication, username: string, password: string): Promise<{ + agent: ReturnType; + accessToken: string; + csrf: string; +}> { + const agent = request.agent(app.getHttpServer()); + await agent.get('/api/v1/auth/csrf'); + const cookies: any = agent.jar.getCookies(CookieAccessInfo.All); + const csrf = cookies.find((c: any) => c.name === 'csrf')?.value; + const res = await agent + .post('/api/v1/auth/login') + .set('X-CSRF-Token', csrf) + .send({ username, password }) + .expect(201); + return { agent, accessToken: res.body.accessToken as string, csrf }; +} + +async function createPlayer( + app: INestApplication, + adminToken: string, + adminCsrf: string, + username: string, + password: string, +): Promise<{ agent: ReturnType; accessToken: string; csrf: string }> { + const adminAgent = request.agent(app.getHttpServer()); + await adminAgent.get('/api/v1/auth/csrf'); + const adminAgentCookies: any = adminAgent.jar.getCookies(CookieAccessInfo.All); + const adminAgentCsrf = adminAgentCookies.find((c: any) => c.name === 'csrf')?.value; + await adminAgent + .post('/api/v1/admin/users') + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', adminAgentCsrf) + .send({ username, password, role: 'player' }) + .expect(201); + return loginAs(app, username, password); +} + +describe('Admin blog posts API (Job 869)', () => { + let app: INestApplication; + let adminToken: string; + let adminCsrf: string; + let adminAgent: ReturnType; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); + app = moduleRef.createNestApplication(); + app.use(cookieParser()); + app.use(express.json({ limit: '1mb' })); + const csrfMw = new CsrfMiddleware(app.get(ConfigService)); + app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next)); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true })); + const httpAdapterHost = app.get(HttpAdapterHost); + app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost)); + await app.init(); + await initDb(app); + + const server = app.getHttpServer(); + await request(server).post('/api/v1/auth/register-first-admin') + .send({ username: 'admin', password: 'Sup3rSecret!Pass' }) + .expect(201); + + const logged = await loginAs(app, 'admin', 'Sup3rSecret!Pass'); + adminToken = logged.accessToken; + adminCsrf = logged.csrf; + adminAgent = logged.agent; + }); + + afterAll(async () => { + await app.close(); + }); + + it('GET /api/v1/admin/blog/posts without a token returns 401', async () => { + await request(app.getHttpServer()).get('/api/v1/admin/blog/posts').expect(401); + }); + + it('GET /api/v1/admin/blog/posts with a player JWT returns 403', async () => { + const player = await createPlayer(app, adminToken, adminCsrf, 'player1', 'Sup3rSecret!Pass'); + await request(app.getHttpServer()) + .get('/api/v1/admin/blog/posts') + .set('Authorization', `Bearer ${player.accessToken}`) + .expect(403); + }); + + it('POST with status=draft creates a draft with publishedAt=null and POST with status=published stamps publishedAt', async () => { + const draft = await adminAgent + .post('/api/v1/admin/blog/posts') + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', adminCsrf) + .send({ title: 'Draft Post', bodyMd: 'still cooking', status: 'draft' }) + .expect(201); + expect(draft.body.status).toBe('draft'); + expect(draft.body.publishedAt).toBeNull(); + expect(draft.body.title).toBe('Draft Post'); + expect(draft.body.id).toBeDefined(); + expect(draft.body.createdAt).toBeDefined(); + + const published = await adminAgent + .post('/api/v1/admin/blog/posts') + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', adminCsrf) + .send({ title: 'Live Post', bodyMd: '# Hello world', status: 'published' }) + .expect(201); + expect(published.body.status).toBe('published'); + expect(published.body.publishedAt).toBeTruthy(); + expect(typeof published.body.publishedAt).toBe('string'); + }); + + it('POST with empty title returns 400 VALIDATION_FAILED', async () => { + const res = await adminAgent + .post('/api/v1/admin/blog/posts') + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', adminCsrf) + .send({ title: ' ', bodyMd: 'x' }) + .expect(400); + expect(res.body.code).toBe('VALIDATION_FAILED'); + }); + + it('GET /api/v1/admin/blog/posts returns drafts AND published (admin-only view)', async () => { + const res = await adminAgent + .get('/api/v1/admin/blog/posts') + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', adminCsrf) + .expect(200); + expect(Array.isArray(res.body)).toBe(true); + // Both 'Draft Post' and 'Live Post' should appear since we just created them. + const titles = res.body.map((p: any) => p.title); + expect(titles).toEqual(expect.arrayContaining(['Draft Post', 'Live Post'])); + // Every row includes admin-only fields. + for (const p of res.body) { + expect(p.status).toBeDefined(); + expect(p.createdAt).toBeDefined(); + expect(p.updatedAt).toBeDefined(); + } + }); + + it('PUT preserves draft state (publishedAt=null) on a draft edit; PUT to published stamps publishedAt once and preserves it on subsequent edits', async () => { + // 1. Create a draft. + const draft = await adminAgent + .post('/api/v1/admin/blog/posts') + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', adminCsrf) + .send({ title: 'Edit Me Draft', bodyMd: 'v1', status: 'draft' }) + .expect(201); + const draftId = draft.body.id; + + // 2. Edit title/body without changing status -> stays draft, publishedAt stays null. + const edited = await adminAgent + .put(`/api/v1/admin/blog/posts/${draftId}`) + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', adminCsrf) + .send({ title: 'Edit Me Draft (updated)', bodyMd: 'v2' }) + .expect(200); + expect(edited.body.status).toBe('draft'); + expect(edited.body.publishedAt).toBeNull(); + expect(edited.body.bodyMd).toBe('v2'); + + // 3. Publish -> stamps publishedAt. + const firstPublish = await adminAgent + .put(`/api/v1/admin/blog/posts/${draftId}`) + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', adminCsrf) + .send({ status: 'published' }) + .expect(200); + expect(firstPublish.body.status).toBe('published'); + const originalPublishedAt = firstPublish.body.publishedAt; + expect(originalPublishedAt).toBeTruthy(); + + // 4. Wait a few ms and update body again; publishedAt must not change. + await new Promise((r) => setTimeout(r, 5)); + const secondEdit = await adminAgent + .put(`/api/v1/admin/blog/posts/${draftId}`) + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', adminCsrf) + .send({ bodyMd: 'v3 — content edit after publish' }) + .expect(200); + expect(secondEdit.body.status).toBe('published'); + expect(secondEdit.body.publishedAt).toBe(originalPublishedAt); + expect(secondEdit.body.bodyMd).toBe('v3 — content edit after publish'); + + // 5. Demote to draft; publishedAt must be preserved so a later re-publish does not change it. + const demoted = await adminAgent + .put(`/api/v1/admin/blog/posts/${draftId}`) + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', adminCsrf) + .send({ status: 'draft' }) + .expect(200); + expect(demoted.body.status).toBe('draft'); + expect(demoted.body.publishedAt).toBe(originalPublishedAt); + + // 6. Re-publish; publishedAt must STILL be the original timestamp. + const republished = await adminAgent + .put(`/api/v1/admin/blog/posts/${draftId}`) + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', adminCsrf) + .send({ status: 'published' }) + .expect(200); + expect(republished.body.status).toBe('published'); + expect(republished.body.publishedAt).toBe(originalPublishedAt); + }); + + it('DELETE removes the post and a subsequent DELETE returns 404', async () => { + const created = await adminAgent + .post('/api/v1/admin/blog/posts') + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', adminCsrf) + .send({ title: 'To Delete', bodyMd: 'goodbye', status: 'draft' }) + .expect(201); + const id = created.body.id; + + await adminAgent + .delete(`/api/v1/admin/blog/posts/${id}`) + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', adminCsrf) + .expect(204); + + await adminAgent + .delete(`/api/v1/admin/blog/posts/${id}`) + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', adminCsrf) + .expect(404); + }); + + it('public GET /api/v1/blog/posts hides drafts after admin operations', async () => { + // Make sure a draft and a published exist (regression for public list). + await adminAgent + .post('/api/v1/admin/blog/posts') + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', adminCsrf) + .send({ title: 'Hidden Draft', bodyMd: 'should not leak', status: 'draft' }) + .expect(201); + + const res = await request(app.getHttpServer()).get('/api/v1/blog/posts').expect(200); + const titles = res.body.posts.map((p: any) => p.title); + expect(titles).not.toContain('Hidden Draft'); + // No row in the public list should ever include admin-only fields. + for (const p of res.body.posts) { + expect(p.status).toBeUndefined(); + } + }); + + it('PUT with empty title returns 400 VALIDATION_FAILED', async () => { + const created = await adminAgent + .post('/api/v1/admin/blog/posts') + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', adminCsrf) + .send({ title: 'Edit Title Test', bodyMd: '', status: 'draft' }) + .expect(201); + const id = created.body.id; + + const res = await adminAgent + .put(`/api/v1/admin/blog/posts/${id}`) + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', adminCsrf) + .send({ title: '' }) + .expect(400); + expect(res.body.code).toBe('VALIDATION_FAILED'); + }); +}); + +describe('Admin blog posts raw access', () => { + let app: INestApplication; + let adminAgent: ReturnType; + let adminToken: string; + let adminCsrf: string; + + beforeAll(async () => { + process.env.DATABASE_PATH = ':memory:'; + process.env.THEMES_DIR = './themes'; + process.env.FRONTEND_DIST = './frontend/dist'; + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); + app = moduleRef.createNestApplication(); + app.use(cookieParser()); + app.use(express.json({ limit: '1mb' })); + const csrfMw = new CsrfMiddleware(app.get(ConfigService)); + app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next)); + const httpAdapterHost = app.get(HttpAdapterHost); + app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost)); + await app.init(); + await initDb(app); + + const server = app.getHttpServer(); + await request(server).post('/api/v1/auth/register-first-admin') + .send({ username: 'rawadmin', password: 'Sup3rSecret!Pass' }) + .expect(201); + + const logged = await loginAs(app, 'rawadmin', 'Sup3rSecret!Pass'); + adminToken = logged.accessToken; + adminCsrf = logged.csrf; + adminAgent = logged.agent; + + // Seed one published + one draft directly via the entity manager. + const repo = app.get(getRepositoryToken(BlogPostEntity)); + await repo.insert([ + { + id: 'seed-pub', + title: 'Seeded Published', + bodyMd: '# Published', + status: 'published', + publishedAt: '2026-07-21T10:00:00.000Z', + createdAt: '2026-07-20T10:00:00.000Z', + updatedAt: '2026-07-21T10:00:00.000Z', + }, + { + id: 'seed-drf', + title: 'Seeded Draft', + bodyMd: 'wip', + status: 'draft', + publishedAt: null, + createdAt: '2026-07-22T10:00:00.000Z', + updatedAt: '2026-07-22T10:00:00.000Z', + }, + ]); + }); + + afterAll(async () => { + await app.close(); + }); + + it('returns both seeded rows including drafts for the admin', async () => { + const res = await adminAgent + .get('/api/v1/admin/blog/posts') + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', adminCsrf) + .expect(200); + const ids = res.body.map((p: any) => p.id); + expect(ids).toEqual(expect.arrayContaining(['seed-pub', 'seed-drf'])); + }); +}); diff --git a/tests/frontend/blog-admin-form.spec.ts b/tests/frontend/blog-admin-form.spec.ts new file mode 100644 index 0000000..3d53916 --- /dev/null +++ b/tests/frontend/blog-admin-form.spec.ts @@ -0,0 +1,114 @@ +import '@angular/compiler'; +import { FormBuilder } from '@angular/forms'; +import { + PostFormGroup, + buildPostBody, + statusBadgeClass, + statusLabel, + syncPostForm, + validatePostForm, +} from '../../frontend/src/app/features/admin/blog/blog-form.pure'; +import { AdminBlogPost } from '../../frontend/src/app/core/services/blog.service'; + +function buildForm(): PostFormGroup { + const fb = new FormBuilder(); + return fb.nonNullable.group({ + title: fb.nonNullable.control(''), + bodyMd: fb.nonNullable.control(''), + }); +} + +function makePost(overrides: Partial = {}): AdminBlogPost { + return { + id: 'p-1', + title: 'Existing Title', + bodyMd: 'existing body', + status: 'draft', + createdAt: '2026-07-21T10:00:00.000Z', + updatedAt: '2026-07-22T10:00:00.000Z', + publishedAt: null, + ...overrides, + }; +} + +describe('validatePostForm', () => { + it('rejects empty title', () => { + const r = validatePostForm({ title: '', bodyMd: 'x' }); + expect(r.ok).toBe(false); + expect(r.errors.title).toBeDefined(); + }); + + it('rejects whitespace-only title', () => { + const r = validatePostForm({ title: ' \t ', bodyMd: 'x' }); + expect(r.ok).toBe(false); + expect(r.errors.title).toBeDefined(); + }); + + it('rejects title longer than 200 characters', () => { + const r = validatePostForm({ title: 'a'.repeat(201), bodyMd: '' }); + expect(r.ok).toBe(false); + expect(r.errors.title).toBeDefined(); + }); + + it('rejects body longer than 200000 characters', () => { + const r = validatePostForm({ title: 'ok', bodyMd: 'x'.repeat(200_001) }); + expect(r.ok).toBe(false); + expect(r.errors.bodyMd).toBeDefined(); + }); + + it('accepts a valid title and empty body', () => { + const r = validatePostForm({ title: ' Real Title ', bodyMd: '' }); + expect(r.ok).toBe(true); + expect(r.errors.title).toBeUndefined(); + expect(r.errors.bodyMd).toBeUndefined(); + }); +}); + +describe('syncPostForm', () => { + it('prefills the form for edit mode with a post', () => { + const form = buildForm(); + syncPostForm(form, 'edit', makePost()); + expect(form.controls.title.value).toBe('Existing Title'); + expect(form.controls.bodyMd.value).toBe('existing body'); + expect(form.controls.title.pristine).toBe(true); + expect(form.controls.title.touched).toBe(false); + }); + + it('clears the form for create mode with null post', () => { + const form = buildForm(); + syncPostForm(form, 'edit', makePost()); + expect(form.controls.title.value).toBe('Existing Title'); + syncPostForm(form, 'create', null); + expect(form.controls.title.value).toBe(''); + expect(form.controls.bodyMd.value).toBe(''); + }); + + it('re-populates when invoked a second time with a different post', () => { + const form = buildForm(); + syncPostForm(form, 'edit', makePost({ title: 'Alpha' })); + expect(form.controls.title.value).toBe('Alpha'); + syncPostForm(form, 'edit', makePost({ title: 'Bravo', bodyMd: 'b body' })); + expect(form.controls.title.value).toBe('Bravo'); + expect(form.controls.bodyMd.value).toBe('b body'); + }); +}); + +describe('buildPostBody', () => { + it('trims the title and passes body through', () => { + expect(buildPostBody({ title: ' hello ', bodyMd: 'md' })).toEqual({ + title: 'hello', + bodyMd: 'md', + }); + }); +}); + +describe('statusBadgeClass / statusLabel', () => { + it('maps draft → badge-draft and "Draft"', () => { + expect(statusBadgeClass('draft')).toBe('badge-draft'); + expect(statusLabel('draft')).toBe('Draft'); + }); + it('maps published → badge-published and "Published"', () => { + expect(statusBadgeClass('published')).toBe('badge-published'); + expect(statusLabel('published')).toBe('Published'); + }); +}); diff --git a/tests/frontend/blog-page.spec.ts b/tests/frontend/blog-page.spec.ts new file mode 100644 index 0000000..e1b78da --- /dev/null +++ b/tests/frontend/blog-page.spec.ts @@ -0,0 +1,41 @@ +import { deriveBlogListState } from '../../frontend/src/app/features/blog/blog.pure'; +import { renderMarkdownToHtml } from '../../frontend/src/app/core/services/markdown.pure'; + +describe('deriveBlogListState', () => { + it('returns loading when loading is true (regardless of error/posts)', () => { + expect(deriveBlogListState({ loading: true, error: null, count: 0 }).kind).toBe('loading'); + expect(deriveBlogListState({ loading: true, error: 'x', count: 3 }).kind).toBe('loading'); + }); + + it('returns error when not loading and error message set', () => { + const s = deriveBlogListState({ loading: false, error: 'boom', count: 0 }); + expect(s.kind).toBe('error'); + expect(s.message).toBe('boom'); + }); + + it('returns empty when not loading and no posts', () => { + expect(deriveBlogListState({ loading: false, error: null, count: 0 }).kind).toBe('empty'); + }); + + it('returns list when posts exist', () => { + expect(deriveBlogListState({ loading: false, error: null, count: 3 }).kind).toBe('list'); + }); +}); + +describe('shared Markdown presentation', () => { + it('produces the same HTML for the same Markdown body (sanitized, no script)', () => { + const md = '# Title\n\nSome **bold** text and '; + const a = renderMarkdownToHtml(md); + const b = renderMarkdownToHtml(md); + expect(a).toBe(b); + expect(a).toContain('

    Title

    '); + expect(a).toContain('bold'); + expect(a).not.toContain('