AI Implementation feature(869): Admin Area Blog and Blog Page #58

Merged
m0rph3us1987 merged 2 commits from feature-869-1784800013922 into dev 2026-07-23 10:17:55 +00:00
32 changed files with 1798 additions and 117 deletions
+82
View File
@@ -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.
-70
View File
@@ -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.
@@ -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<AdminBlogPost[]> {
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<AdminBlogPost> {
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<AdminBlogPost> {
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<AdminBlogPost> {
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<void> {
await this.admin.remove(params.id);
}
}
@@ -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<BlogPostEntity>,
) {}
async listAll(): Promise<AdminBlogPost[]> {
const rows = await this.posts.find({ order: { updatedAt: 'DESC' } });
return rows.map((r) => this.toView(r));
}
async getById(id: string): Promise<AdminBlogPost> {
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<AdminBlogPost> {
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<AdminBlogPost> {
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<void> {
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,
};
}
}
+5 -3
View File
@@ -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 {}
export class BlogModule {}
+37 -1
View File
@@ -11,4 +11,40 @@ export type PublicBlogPost = z.infer<typeof PublicBlogPostSchema>;
export const PublicBlogListSchema = z.object({
posts: z.array(PublicBlogPostSchema),
});
export type PublicBlogList = z.infer<typeof PublicBlogListSchema>;
export type PublicBlogList = z.infer<typeof PublicBlogListSchema>;
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<typeof AdminBlogPostSchema>;
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<typeof CreatePostSchema>;
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<typeof UpdatePostSchema>;
export const PostIdParamSchema = z.object({ id: z.string().min(1).max(64) });
+71
View File
@@ -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)
+3 -2
View File
@@ -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)
- [Challenges Endpoints](/api/challenges.md)
- [Blog Endpoints](/api/blog.md)
+4 -2
View File
@@ -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)
+15 -5
View File
@@ -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)
+20 -3
View File
@@ -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)
+9 -3
View File
@@ -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)
+14 -12
View File
@@ -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 `<li>` 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 `<router-outlet>`. |
| 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 `<router-outlet>`. |
| 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
`<router-outlet>`. 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 `<router-outlet>`. 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)
+71
View File
@@ -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)
+4 -1
View File
@@ -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**`<section data-testid="landing-blog">` 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)
+7 -4
View File
@@ -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`.
+5
View File
@@ -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),
},
],
},
],
@@ -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<AdminBlogPost[]> {
return firstValueFrom(
this.http.get<AdminBlogPost[]>('/api/v1/admin/blog/posts', { withCredentials: true }),
);
}
async createPost(body: CreatePostBody): Promise<AdminBlogPost> {
return firstValueFrom(
this.http.post<AdminBlogPost>('/api/v1/admin/blog/posts', body, { withCredentials: true }),
);
}
async updatePost(id: string, body: UpdatePostBody): Promise<AdminBlogPost> {
return firstValueFrom(
this.http.put<AdminBlogPost>(`/api/v1/admin/blog/posts/${encodeURIComponent(id)}`, body, {
withCredentials: true,
}),
);
}
async deletePost(id: string): Promise<void> {
await firstValueFrom(
this.http.delete<void>(`/api/v1/admin/blog/posts/${encodeURIComponent(id)}`, {
withCredentials: true,
}),
);
}
async listPublished(): Promise<PublicBlogPost[]> {
const res = await firstValueFrom(
this.http.get<{ posts: PublicBlogPost[] }>('/api/v1/blog/posts', { withCredentials: true }),
);
return res.posts ?? [];
}
}
@@ -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 },
];
@@ -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()) {
<div class="modal-backdrop" data-testid="post-delete-backdrop" (click)="onCancel()">
<div class="modal" (click)="$event.stopPropagation()" data-testid="post-delete-modal">
<h3>Delete post</h3>
@if (post(); as p) {
<p data-testid="post-delete-name">
Delete <b>{{ p.title }}</b>? This cannot be undone.
</p>
}
@if (errorMessage()) {
<p class="error" data-testid="post-delete-error">{{ errorMessage() }}</p>
}
<div class="actions">
<button type="button" (click)="onCancel()" data-testid="post-delete-cancel">Cancel</button>
<button type="button" [disabled]="deleting()" (click)="onConfirm()" data-testid="post-delete-ok">Delete</button>
</div>
</div>
</div>
}
`,
})
export class BlogDeleteModalComponent {
readonly open = input(false);
readonly post = input<AdminBlogPost | null>(null);
readonly errorMessage = input<string | null>(null);
readonly deleting = input(false);
readonly cancel = output<void>();
readonly confirm = output<void>();
onCancel(): void {
this.cancel.emit();
}
onConfirm(): void {
this.confirm.emit();
}
}
@@ -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()) {
<div class="modal-backdrop" data-testid="post-form-backdrop" (click)="onCancel()">
<form
class="modal"
[formGroup]="form"
(click)="$event.stopPropagation()"
(submit)="$event.preventDefault()"
data-testid="post-form-modal"
>
<h3>{{ mode() === 'create' ? 'New post' : 'Edit post' }}</h3>
@if (post(); as p) {
@if (mode() === 'edit') {
<p class="editing-meta">
Created: {{ p.createdAt | date: 'medium' }} ·
Updated: {{ p.updatedAt | date: 'medium' }}
@if (p.publishedAt) {
· Published: {{ p.publishedAt | date: 'medium' }}
}
</p>
}
}
<div class="row">
<label for="pf-title">Title (required)</label>
<input
id="pf-title"
type="text"
formControlName="title"
data-testid="pf-title"
[attr.aria-invalid]="titleError() ? 'true' : null"
[attr.aria-describedby]="titleError() ? 'pf-title-error' : null"
/>
@if (titleError()) {
<span class="field-error" id="pf-title-error" data-testid="pf-title-error">{{ titleError() }}</span>
}
</div>
<div class="layout">
<div class="row">
<label for="pf-body">Markdown body</label>
<textarea
id="pf-body"
rows="14"
formControlName="bodyMd"
data-testid="pf-body"
(input)="onBodyInput()"
></textarea>
@if (bodyError()) {
<span class="field-error" data-testid="pf-body-error">{{ bodyError() }}</span>
}
</div>
<div class="row">
<label>Live preview</label>
<div class="preview" data-testid="pf-preview" [innerHTML]="previewHtml()"></div>
</div>
</div>
@if (errorMessage()) {
<p class="field-error" data-testid="pf-error" style="margin-top: 8px;">{{ errorMessage() }}</p>
}
<div class="actions">
<button type="button" (click)="onCancel()" data-testid="pf-cancel">Cancel</button>
<button
type="button"
[disabled]="!canSaveDraft()"
(click)="onSubmit('draft')"
data-testid="pf-save-draft"
>Save draft</button>
<button
type="button"
[disabled]="!canPublish()"
(click)="onSubmit('publish')"
data-testid="pf-publish"
>Publish</button>
</div>
</form>
</div>
}
`,
})
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<PostFormMode>('create');
readonly post = input<AdminBlogPost | null>(null);
readonly errorMessage = input<string | null>(null);
readonly saving = input(false);
readonly cancel = output<void>();
readonly submit = output<PostFormSubmitPayload>();
readonly previewHtml = signal<string>('');
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 });
}
}
@@ -0,0 +1,58 @@
import { FormControl, FormGroup } from '@angular/forms';
import { AdminBlogPost, BlogStatus } from '../../../core/services/blog.service';
export type PostFormGroup = FormGroup<{
title: FormControl<string>;
bodyMd: FormControl<string>;
}>;
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';
}
@@ -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: `
<section class="admin-blog" data-testid="admin-blog">
<div class="toolbar">
<h2>Blog</h2>
<button type="button" (click)="openCreate()" [disabled]="actionInFlight()" data-testid="blog-add">
+ New post
</button>
</div>
@if (statusMessage()) {
<p class="status-banner success" role="status" aria-live="polite" data-testid="blog-status">
{{ statusMessage() }}
</p>
}
@if (loadError()) {
<p class="status-banner error" role="alert" aria-live="assertive" data-testid="blog-error">
{{ loadError() }}
</p>
}
@if (loading()) {
<p data-testid="blog-loading">Loading posts…</p>
} @else if (posts().length === 0) {
<p class="empty" data-testid="blog-empty">No posts yet.</p>
} @else {
<table class="table" data-testid="blog-table">
<thead>
<tr>
<th>Title</th>
<th>Status</th>
<th>Created</th>
<th>Updated</th>
<th>Published</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (p of posts(); track p.id) {
<tr [attr.data-testid]="'blog-row-' + p.id">
<td>{{ p.title }}</td>
<td>
<span class="badge" [class]="statusBadgeClass(p.status)" [attr.data-testid]="'blog-status-' + p.id">
{{ statusLabel(p.status) }}
</span>
</td>
<td>{{ p.createdAt | date: 'medium' }}</td>
<td>{{ p.updatedAt | date: 'medium' }}</td>
<td>
@if (p.publishedAt) {
{{ p.publishedAt | date: 'medium' }}
} @else {
}
</td>
<td class="row-actions">
<button type="button" (click)="openEdit(p)" [disabled]="actionInFlight()" [attr.data-testid]="'blog-edit-' + p.id">✎</button>
<button type="button" (click)="openDelete(p)" [disabled]="actionInFlight()" [attr.data-testid]="'blog-delete-' + p.id">🗑</button>
</td>
</tr>
}
</tbody>
</table>
}
<app-blog-form-modal
[open]="modalMode() !== null"
[mode]="modalMode() === 'edit' ? 'edit' : 'create'"
[post]="editing()"
[errorMessage]="formError()"
[saving]="saving()"
(cancel)="closeModal()"
(submit)="onFormSubmit($event)"
/>
<app-blog-delete-modal
[open]="deleteOpen()"
[post]="deleting()"
[errorMessage]="deleteError()"
[deleting]="deleting() !== null"
(cancel)="closeModal()"
(confirm)="onDeleteConfirm()"
/>
</section>
`,
})
export class AdminBlogComponent implements OnInit {
private readonly blog = inject(BlogApiService);
readonly posts = signal<AdminBlogPost[]>([]);
readonly loading = signal(true);
readonly actionInFlight = signal(false);
readonly loadError = signal<string | null>(null);
readonly statusMessage = signal<string | null>(null);
readonly modalMode = signal<ModalMode>(null);
readonly editing = signal<AdminBlogPost | null>(null);
readonly deleting = signal<AdminBlogPost | null>(null);
readonly deleteOpen = signal(false);
readonly formError = signal<string | null>(null);
readonly deleteError = signal<string | null>(null);
readonly saving = signal(false);
readonly statusBadgeClass = statusBadgeClass;
readonly statusLabel = statusLabel;
async ngOnInit(): Promise<void> {
await this.load();
}
async load(): Promise<void> {
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<void> {
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<void> {
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);
}
}
}
@@ -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: `
<article class="blog-item" [attr.data-testid]="'blog-presenter-' + post().id">
<h2 class="blog-title" [attr.data-testid]="'blog-presenter-title-' + post().id">
{{ post().title }}
</h2>
<time class="blog-date" [attr.data-testid]="'blog-presenter-date-' + post().id">
{{ post().publishedAt | date: 'medium' }}
</time>
<div
class="blog-body"
[attr.data-testid]="'blog-presenter-body-' + post().id"
[innerHTML]="renderedHtml()"
></div>
</article>
`,
})
export class BlogPresenterComponent {
readonly post = input.required<PublicBlogPost>();
private readonly markdown = new MarkdownService();
renderedHtml(): string {
return this.markdown.render(this.post()?.bodyMd ?? '');
}
}
+50 -4
View File
@@ -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: `
<section class="page-blog">
<section class="blog-page" data-testid="blog-page">
<h2>Blog</h2>
<p>Blog posts will appear here.</p>
@if (state().kind === 'loading') {
<p class="blog-loading" data-testid="blog-loading">Loading blog posts…</p>
} @else if (state().kind === 'error') {
<p class="blog-error" data-testid="blog-error">{{ state().message }}</p>
} @else if (state().kind === 'empty') {
<p class="blog-empty" data-testid="blog-empty">No announcements yet.</p>
} @else {
<div class="blog-list" data-testid="blog-list">
@for (post of posts(); track post.id) {
<app-blog-presenter [post]="post" />
}
</div>
}
</section>
`,
})
export class BlogPage {}
export class BlogPage implements OnInit {
private readonly blog = inject(BlogApiService);
readonly posts = signal<PublicBlogPost[]>([]);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly state = computed(() =>
deriveBlogListState({ loading: this.loading(), error: this.error(), count: this.posts().length }),
);
async ngOnInit(): Promise<void> {
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);
}
}
}
@@ -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' };
}
@@ -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);
@@ -20,11 +20,7 @@
@if (landing.posts().length > 0) {
<section class="landing-blog" data-testid="landing-blog">
@for (post of landing.posts(); track post.id) {
<article class="landing-blog-item">
<h2 class="landing-blog-title">{{ post.title }}</h2>
<time class="landing-blog-date">{{ post.publishedAt | date: 'medium' }}</time>
<div class="landing-blog-body" [innerHTML]="markdown.render(post.bodyMd)"></div>
</article>
<app-blog-presenter [post]="post" />
}
</section>
} @else if (!landing.loading()) {
@@ -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'],
})
+347
View File
@@ -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<typeof request.agent>;
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<typeof request.agent>; 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<typeof request.agent>;
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<typeof request.agent>;
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<any>(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']));
});
});
+114
View File
@@ -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> = {}): 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');
});
});
+41
View File
@@ -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 <script>alert(1)</script>';
const a = renderMarkdownToHtml(md);
const b = renderMarkdownToHtml(md);
expect(a).toBe(b);
expect(a).toContain('<h1>Title</h1>');
expect(a).toContain('<strong>bold</strong>');
expect(a).not.toContain('<script>');
});
it('strips dangerous javascript: hrefs in both contexts', () => {
const md = '[click](javascript:alert(1))';
const a = renderMarkdownToHtml(md);
expect(a).not.toMatch(/href="javascript:/i);
});
});