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,
};
}
}
+4 -2
View File
@@ -3,10 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { BlogPostEntity } from '../../database/entities/blog-post.entity'; import { BlogPostEntity } from '../../database/entities/blog-post.entity';
import { BlogController } from './blog.controller'; import { BlogController } from './blog.controller';
import { BlogService } from './blog.service'; import { BlogService } from './blog.service';
import { AdminBlogController } from './admin-blog.controller';
import { AdminBlogService } from './admin-blog.service';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([BlogPostEntity])], imports: [TypeOrmModule.forFeature([BlogPostEntity])],
controllers: [BlogController], controllers: [BlogController, AdminBlogController],
providers: [BlogService], providers: [BlogService, AdminBlogService],
}) })
export class BlogModule {} export class BlogModule {}
+36
View File
@@ -12,3 +12,39 @@ export const PublicBlogListSchema = z.object({
posts: z.array(PublicBlogPostSchema), 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)
+2 -1
View File
@@ -3,7 +3,7 @@ type: api
title: REST API Overview title: REST API Overview
description: Base URL, versioning, auth, CSRF, and the standard error envelope. description: Base URL, versioning, auth, CSRF, and the standard error envelope.
tags: [api, rest, overview, csrf] tags: [api, rest, overview, csrf]
timestamp: 2026-07-23T00:10:00Z timestamp: 2026-07-23T10:12:24Z
--- ---
# Base URL & versioning # Base URL & versioning
@@ -99,3 +99,4 @@ statuses and the canonical messages produced by
- [System Endpoints](/api/system.md) - [System Endpoints](/api/system.md)
- [Uploads Endpoints](/api/uploads.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 title: Backend Module Map
description: NestJS modules, controllers, services, and how they are wired together. description: NestJS modules, controllers, services, and how they are wired together.
tags: [architecture, backend, nestjs, modules] tags: [architecture, backend, nestjs, modules]
timestamp: 2026-07-23T00:10:00Z timestamp: 2026-07-23T10:12:24Z
--- ---
# Module Map # 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`. | | `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`). | | `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. | | `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). | | `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. | | `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` | | `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` | | `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` | | `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` | | `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` | | `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) - [System Overview](/architecture/overview.md)
- [Key Files Index](/architecture/key-files.md) - [Key Files Index](/architecture/key-files.md)
- [REST API Overview](/api/rest-overview.md) - [REST API Overview](/api/rest-overview.md)
- [Blog API](/api/blog.md)
+15 -5
View File
@@ -1,9 +1,9 @@
--- ---
type: architecture type: architecture
title: Frontend Structure title: Frontend Structure
description: Angular routes, components, services, guards, interceptors, and authenticated SSE transport. 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] tags: [architecture, frontend, angular, shell, sse, challenges, scoreboard, notifications, blog]
timestamp: 2026-07-23T05:10:00Z timestamp: 2026-07-23T10:12:24Z
--- ---
# Routes # Routes
@@ -17,9 +17,10 @@ Routes live in `frontend/src/app/app.routes.ts`:
| `/` | `HomeComponent` | `authGuard` | Authenticated shell and child outlet. | | `/` | `HomeComponent` | `authGuard` | Authenticated shell and child outlet. |
| `/challenges` | `ChallengesPage` | inherited | Full challenges board (category columns, modal, flag submit, live SSE). | | `/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. | | `/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. | | `/blog` | `BlogPage` | inherited | Loads and renders published posts with loading, error, and empty states. |
| `/admin` | `AdminUsersComponent` | `adminGuard` | Admin-only child route. | | `/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/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 # 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. | | `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. | | `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). | | `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 # 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/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.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/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. | | `tests/frontend/authenticated-event-source.spec.ts` | Regression coverage for authenticated SSE transport behavior. |
# Event-status pure helpers # Event-status pure helpers
@@ -129,4 +138,5 @@ for the full contract and tester matrix.
- [Challenges Board](/guides/challenges-board.md) - [Challenges Board](/guides/challenges-board.md)
- [Scoreboard Page](/guides/scoreboard-page.md) - [Scoreboard Page](/guides/scoreboard-page.md)
- [Notifications](/guides/notifications.md) - [Notifications](/guides/notifications.md)
- [Blog Publishing and Reading](/guides/blog.md)
- [System Overview](/architecture/overview.md) - [System Overview](/architecture/overview.md)
+20 -3
View File
@@ -1,9 +1,9 @@
--- ---
type: architecture type: architecture
title: Key Files Index 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. 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] tags: [architecture, key-files, event-window, validation, default-challenge-ip, sse, bootstrap, migrations, challenges, import, board, notifications, per-user, session, blog]
timestamp: 2026-07-23T04:05:00Z timestamp: 2026-07-23T10:12:24Z
--- ---
# Backend # 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/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/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/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 # 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/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/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/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.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.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. | | `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) - [Scoreboard Page Guide](/guides/scoreboard-page.md)
- [Challenges Board](/guides/challenges-board.md) - [Challenges Board](/guides/challenges-board.md)
- [Notifications](/guides/notifications.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 title: Blog Post Table
description: blog_post table — draft/published Markdown posts surfaced on the public landing page. description: blog_post table — draft/published Markdown posts surfaced on the public landing page.
tags: [database, blog, markdown] tags: [database, blog, markdown]
timestamp: 2026-07-21T18:28:00Z timestamp: 2026-07-23T10:12:24Z
--- ---
# Schema # Schema
@@ -27,12 +27,18 @@ timestamp: 2026-07-21T18:28:00Z
# Behavior # Behavior
* Only rows with `status = 'published'` are exposed through the public * Only rows with `status = 'published'` are exposed through the public
endpoint `GET /api/v1/blog/posts` (see endpoint `GET /api/v1/blog/posts` (see [Blog API](/api/blog.md)). Drafts
[REST API Overview](/api/rest-overview.md)). Drafts are hidden. 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 * `BlogService.listPublished()` orders by `published_at DESC`, falling
back to `created_at` when `published_at` is null. back to `created_at` when `published_at` is null.
# See also # See also
- [Database Schema Overview](/database/schema.md) - [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) - [Landing Page Guide](/guides/landing-page.md)
+14 -12
View File
@@ -1,9 +1,9 @@
--- ---
type: guide type: guide
title: Admin Shell & Side Navigation 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] tags: [guide, admin, shell, navigation, tester]
timestamp: 2026-07-22T12:00:00Z timestamp: 2026-07-23T10:12:24Z
--- ---
# When this view is available # 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). | | 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. | | 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. | | Players | `/admin/players` | yes | User management page. |
| Blog | `/admin/blog` | no | Greyed-out placeholder. No route registered. | | 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. | | System | `/admin/system` | no | Greyed-out placeholder. No route registered. |
Each `<li>` carries `data-testid="admin-nav-{id}"`. Disabled entries Each `<li>` carries `data-testid="admin-nav-{id}"`. Disabled entries
have `cursor: not-allowed` and `opacity: 0.45`. Clicking a disabled 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. | | 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`. | | 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. | | 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). | | 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.component.ts` | Shell template renders header, conditional nav (`*ngIf="showAdminNav()"`), and `<router-outlet>`. | | 8 | `frontend/src/app/features/home/home.shell.ts` | `shouldShowAdminNav({isAuthenticated, role})` predicate (exported and unit-tested). |
| 9 | `frontend/src/app/core/services/admin.service.ts` | Typed wrappers for `/api/v1/admin/users`, `/admin/general/*`, `/admin/categories/*`, `/uploads/{logo,category-icon}`. | | 9 | `frontend/src/app/features/home/home.component.ts` | Shell template renders header, conditional nav (`*ngIf="showAdminNav()"`), and `<router-outlet>`. |
| 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])`. | | 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 # Notes
* The shell deliberately keeps `AdminShellComponent` as a thin layout * The shell deliberately keeps `AdminShellComponent` as a thin layout
owner — child routes (`general`, `categories`) render into its owner — enabled child routes render into its `<router-outlet>`. New admin
`<router-outlet>`. New admin sub-pages can be added by enabling sub-pages require both an enabled `ENTRIES` item and a child route in
entries in `ENTRIES` and registering a child route in `app.routes.ts`. `app.routes.ts`.
* The guard decision function (`decideAdminGuard`) is a pure module so * The guard decision function (`decideAdminGuard`) is a pure module so
it is straightforward to unit-test without Angular DI. See it is straightforward to unit-test without Angular DI. See
`tests/frontend/admin-shell.spec.ts` and `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) - [Change Password](/guides/change-password.md)
- [Admin — General Settings](/guides/admin-general-settings.md) - [Admin — General Settings](/guides/admin-general-settings.md)
- [Admin — Categories](/guides/admin-categories.md) - [Admin — Categories](/guides/admin-categories.md)
- [Blog Publishing and Reading](/guides/blog.md)
- [REST API Overview](/api/rest-overview.md) - [REST API Overview](/api/rest-overview.md)
- [Admin Endpoints](/api/admin.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 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. 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] tags: [guide, landing, login, register, ui, sse, bootstrap]
timestamp: 2026-07-22T16:15:00Z timestamp: 2026-07-23T10:12:24Z
--- ---
# What you see # 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 5. **Blog list**`<section data-testid="landing-blog">` listing every
row returned by `GET /api/v1/blog/posts` (only `status='published'` row returned by `GET /api/v1/blog/posts` (only `status='published'`
rows), or the text `No announcements yet.` when the list is empty. 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 # Guard
@@ -144,4 +146,5 @@ response to the unsafe call itself.
- [Auth Endpoints](/api/auth.md) - [Auth Endpoints](/api/auth.md)
- [Database Schema Overview](/database/schema.md) (`blog_post`) - [Database Schema Overview](/database/schema.md) (`blog_post`)
- [Frontend Structure](/architecture/frontend-structure.md) - [Frontend Structure](/architecture/frontend-structure.md)
- [Blog Publishing and Reading](/guides/blog.md)
- [First-Run Bootstrap](/guides/bootstrap.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. monorepo containing a NestJS REST API and an Angular single-page application.
It supports user registration, authentication, challenge management, a live It supports user registration, authentication, challenge management, a live
scoreboard, an event window with a public countdown, theming, and admin 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 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 # Architecture
@@ -61,6 +62,7 @@ they need. Last regenerated 2026-07-23T08:42:24Z.
streams, public event-window settings, and the authenticated streams, public event-window settings, and the authenticated
`/events/status` SSE stream. `/events/status` SSE stream.
* [Uploads Endpoints](/api/uploads.md) - Admin-only multipart uploads for category icons, challenge files, and validated site logos. * [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 # Guides
@@ -70,8 +72,9 @@ they need. Last regenerated 2026-07-23T08:42:24Z.
helpers that translate reactive-form state into the inline validation helpers that translate reactive-form state into the inline validation
messages on the first-admin modal. messages on the first-admin modal.
* [Admin Shell & Side Navigation](/guides/admin-shell.md) - How admins * [Admin Shell & Side Navigation](/guides/admin-shell.md) - How admins
navigate the post-login admin area side-nav (General, Challenges, navigate the post-login admin area side-nav and reach General,
Players, Blog, System) and reach the enabled child pages. 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 — General Settings](/guides/admin-general-settings.md) - How an
admin edits platform-wide settings, including the required and strictly admin edits platform-wide settings, including the required and strictly
ordered event window, at `/admin/general`. ordered event window, at `/admin/general`.
+5
View File
@@ -64,6 +64,11 @@ export const APP_ROUTES: Routes = [
loadComponent: () => loadComponent: () =>
import('./features/admin/admin-users.component').then((m) => m.AdminUsersComponent), 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: 'general', label: 'General', path: '/admin/general', enabled: true },
{ id: 'challenges', label: 'Challenges', path: '/admin/challenges', enabled: true }, { id: 'challenges', label: 'Challenges', path: '/admin/challenges', enabled: true },
{ id: 'players', label: 'Players', path: '/admin/players', 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 }, { 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({ @Component({
selector: 'app-blog-page', selector: 'app-blog-page',
standalone: true, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, 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: ` template: `
<section class="page-blog"> <section class="blog-page" data-testid="blog-page">
<h2>Blog</h2> <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> </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; 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 { .landing-blog-empty {
text-align: center; text-align: center;
color: var(--color-text-muted, #888); color: var(--color-text-muted, #888);
@@ -20,11 +20,7 @@
@if (landing.posts().length > 0) { @if (landing.posts().length > 0) {
<section class="landing-blog" data-testid="landing-blog"> <section class="landing-blog" data-testid="landing-blog">
@for (post of landing.posts(); track post.id) { @for (post of landing.posts(); track post.id) {
<article class="landing-blog-item"> <app-blog-presenter [post]="post" />
<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>
} }
</section> </section>
} @else if (!landing.loading()) { } @else if (!landing.loading()) {
@@ -16,6 +16,7 @@ import { MarkdownService } from '../../core/services/markdown.service';
import { LandingService } from './landing.service'; import { LandingService } from './landing.service';
import { buildLoginFailureMessage } from './login-modal.service'; import { buildLoginFailureMessage } from './login-modal.service';
import { passwordMatchValidator } from '../setup/setup-create-admin.validators'; import { passwordMatchValidator } from '../setup/setup-create-admin.validators';
import { BlogPresenterComponent } from '../blog/blog-presenter.component';
type ModalMode = 'closed' | 'login' | 'register'; type ModalMode = 'closed' | 'login' | 'register';
@@ -23,7 +24,7 @@ type ModalMode = 'closed' | 'login' | 'register';
selector: 'app-landing', selector: 'app-landing',
standalone: true, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, ReactiveFormsModule], imports: [CommonModule, ReactiveFormsModule, BlogPresenterComponent],
templateUrl: './landing.component.html', templateUrl: './landing.component.html',
styleUrls: ['./landing.component.css'], 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);
});
});