|
|
|
@@ -1,258 +0,0 @@
|
|
|
|
|
# Implementation Plan: Job 889 — Admin Area General Settings and Categories 1.07
|
|
|
|
|
|
|
|
|
|
## Status
|
|
|
|
|
NOT FULLY IMPLEMENTED. Two residual defects remain after Job 888:
|
|
|
|
|
|
|
|
|
|
1. **Categories SQL** — `backend/src/modules/admin/categories.service.ts:30-37` uses
|
|
|
|
|
`createQueryBuilder('c').orderBy('LOWER(c.abbreviation)', 'ASC').addOrderBy('c.abbreviation', 'ASC').getMany()`.
|
|
|
|
|
`getMany()` auto-selects every column mapped on `CategoryEntity`, including
|
|
|
|
|
`created_at` and `updated_at` (added by migration
|
|
|
|
|
`1700000000200-AddCategoryTimestampsAndUniqueAbbrev`). The
|
|
|
|
|
`InitSchema1700000000000` migration (`backend/src/database/migrations/1700000000000-InitSchema.ts:28-37`)
|
|
|
|
|
creates the `category` table **without** those columns and relies on the
|
|
|
|
|
200-migration to add them at startup. On any database where the 200-migration
|
|
|
|
|
has not yet run (e.g. when an older pre-1.06 DB is hit before migrations
|
|
|
|
|
resolve, or when tests / fresh clones initialise the schema out of order),
|
|
|
|
|
SQLite raises `SqliteError: no such column: c.created_at` and the Categories
|
|
|
|
|
section on `/admin/general` (which embeds `AdminCategoriesComponent`) renders
|
|
|
|
|
the error to the admin. The fix is to make the `category` table include
|
|
|
|
|
`created_at` and `updated_at` in its initial `CREATE TABLE` and keep the 200
|
|
|
|
|
migration as a no-op `if (!names.has(...))` ALTER for pre-existing DBs.
|
|
|
|
|
|
|
|
|
|
2. **Event End equal-to-start field-level message** — `frontend/src/app/features/admin/general.pure.ts:41-51`
|
|
|
|
|
already returns `{ endBeforeStart: true }` when `e <= s`, and
|
|
|
|
|
`frontend/src/app/features/admin/general.component.ts:123-128` renders the
|
|
|
|
|
`general-endBeforeStart` element whenever `eventEndCrossFieldError()` is true.
|
|
|
|
|
However the message lives in a **separate** `data-testid="general-endBeforeStart"`
|
|
|
|
|
div instead of inside the per-field error region
|
|
|
|
|
`data-testid="general-eventEnd-error"` that the Job (and `docs/guides/admin-general-settings.md`)
|
|
|
|
|
describe as the canonical "field-level validation message" for the Event End
|
|
|
|
|
input. The Job explicitly states: *"no visible message inside the field-level
|
|
|
|
|
error region (e.g., `[data-testid=general-eventEnd-error]`)"*. The fix is to
|
|
|
|
|
funnel the cross-field `endBeforeStart` message into `general-eventEnd-error`
|
|
|
|
|
(and surface it via `aria-invalid` / `aria-describedby` / `title` on the
|
|
|
|
|
input) so it is rendered as the Event End field's own validation message.
|
|
|
|
|
|
|
|
|
|
## 1. Architectural Reconnaissance
|
|
|
|
|
|
|
|
|
|
- **Codebase style & conventions:**
|
|
|
|
|
- **Backend:** NestJS 10, TypeScript strict, TypeORM 0.3.20 over
|
|
|
|
|
`better-sqlite3`. Migrations live under
|
|
|
|
|
`backend/src/database/migrations/` and are wired through
|
|
|
|
|
`DatabaseModule.MIGRATIONS` (`backend/src/database/database.module.ts:31-36`)
|
|
|
|
|
and run on bootstrap by `DatabaseInitService.init()` (`backend/src/database/database-init.service.ts:26-54`).
|
|
|
|
|
Entities are auto-selected from `DatabaseModule.ENTITIES` and must remain
|
|
|
|
|
in sync with the SQL schema produced by the migrations.
|
|
|
|
|
- **Frontend:** Angular 17+ standalone components with
|
|
|
|
|
`ChangeDetectionStrategy.OnPush`, reactive forms, and the `Value` /
|
|
|
|
|
`Invalid` / `TouchedOrDirty` signal trio for inline error messaging
|
|
|
|
|
(see `general.component.ts:204-321`). Pure validation / message helpers
|
|
|
|
|
live in `frontend/src/app/features/admin/general.pure.ts` and are
|
|
|
|
|
unit-tested under `tests/frontend/admin-general-pure.spec.ts`.
|
|
|
|
|
- **Error envelopes:** backend emits
|
|
|
|
|
`{ code: 'VALIDATION_FAILED', message, details: [{path, message}] }` via
|
|
|
|
|
`ZodValidationPipe` (`backend/src/common/pipes/zod-validation.pipe.ts:9-15`)
|
|
|
|
|
for `400`s; `ApiError` helpers live in `backend/src/common/errors/api-error.ts`.
|
|
|
|
|
|
|
|
|
|
- **Data Layer:** SQLite (`better-sqlite3`) database file at
|
|
|
|
|
`process.env.DATABASE_PATH` (default `/data/hipctf/db.sqlite`). The
|
|
|
|
|
`category` table is created by `InitSchema1700000000000` and seeded /
|
|
|
|
|
amended by `SeedSystemData1700000000100`,
|
|
|
|
|
`AddCategoryTimestampsAndUniqueAbbrev1700000000200`, and
|
|
|
|
|
`UpdateSystemCategoryKeys1700000000300`. The
|
|
|
|
|
`CategoryEntity` (`backend/src/database/entities/category.entity.ts`) is
|
|
|
|
|
the single source of truth for the column names that TypeORM will SELECT
|
|
|
|
|
via `getMany()`.
|
|
|
|
|
|
|
|
|
|
- **Test Framework & Structure:** Jest (`ts-jest`) with two projects
|
|
|
|
|
(`backend` and `frontend`) configured in `tests/jest.config.js`. Tests
|
|
|
|
|
live in `tests/backend/**/*.spec.ts` and `tests/frontend/**/*.spec.ts`
|
|
|
|
|
and run with a single command `npm test` from the repo root (already in
|
|
|
|
|
`package.json:scripts.test`). Backend tests typically boot the real
|
|
|
|
|
`AppModule` against `:memory:` SQLite via
|
|
|
|
|
`process.env.DATABASE_PATH = ':memory:'`; frontend tests are pure
|
|
|
|
|
function specs that never touch the DOM. New tests must follow the same
|
|
|
|
|
layout.
|
|
|
|
|
|
|
|
|
|
- **Required Tools & Dependencies:** No new packages. Node 20+, npm
|
|
|
|
|
workspaces, `jest`, `ts-jest`, `better-sqlite3`, `@nestjs/typeorm`,
|
|
|
|
|
and `@nestjs/common` are all already declared and bootstrapped by
|
|
|
|
|
`setup.sh` (`setup.sh:1-19`). No `setup.sh` change is required for
|
|
|
|
|
this Job.
|
|
|
|
|
|
|
|
|
|
## 2. Impacted Files
|
|
|
|
|
|
|
|
|
|
- **To Modify:**
|
|
|
|
|
- `backend/src/database/migrations/1700000000000-InitSchema.ts` —
|
|
|
|
|
add `"created_at"` and `"updated_at"` columns to the initial
|
|
|
|
|
`CREATE TABLE "category"` so the columns exist from the very first
|
|
|
|
|
migration; preserve the `description` and `icon_path` defaults.
|
|
|
|
|
- `frontend/src/app/features/admin/general.component.ts` —
|
|
|
|
|
extend the Event End `general-eventEnd-error` element so it also
|
|
|
|
|
renders the cross-field `endBeforeStart` message when no
|
|
|
|
|
per-field `invalidDatetime` error is present; keep
|
|
|
|
|
`general-endBeforeStart` as a redundant/legacy visual element only
|
|
|
|
|
if it is already asserted in tests (see Test Strategy); bind
|
|
|
|
|
`[attr.aria-invalid]` / `[attr.aria-describedby]` / `[attr.title]`
|
|
|
|
|
on the Event End `<input>` to the merged state.
|
|
|
|
|
- `frontend/src/app/features/admin/general.pure.ts` — add a tiny
|
|
|
|
|
helper (e.g. `eventEndFieldError(fieldErrors, crossField)`) that
|
|
|
|
|
returns the right human-readable string for the merged Event End
|
|
|
|
|
field error, so the template can read it from a single source.
|
|
|
|
|
- `tests/frontend/admin-general-pure.spec.ts` — add focused unit
|
|
|
|
|
assertions for the new helper (equal-to-start and end-before-start
|
|
|
|
|
cases).
|
|
|
|
|
- `tests/backend/migrations.spec.ts` — add a focused assertion that
|
|
|
|
|
running `InitSchema1700000000000` against an empty database
|
|
|
|
|
produces a `category` table that contains a `created_at` column
|
|
|
|
|
(and `updated_at`). This locks the SQL schema in place so the
|
|
|
|
|
Categories endpoint can never regress to the
|
|
|
|
|
`no such column: c.created_at` state.
|
|
|
|
|
- `docs/guides/admin-general-settings.md` — clarify that the
|
|
|
|
|
Event End inline error region (`general-eventEnd-error`) is the
|
|
|
|
|
canonical place where the end-before-start message surfaces.
|
|
|
|
|
|
|
|
|
|
- **To Create:**
|
|
|
|
|
- None required. Existing test files are sufficient.
|
|
|
|
|
|
|
|
|
|
## 3. Proposed Changes
|
|
|
|
|
|
|
|
|
|
### 1. Database / Schema Migration
|
|
|
|
|
|
|
|
|
|
1. **Update `InitSchema1700000000000`** at
|
|
|
|
|
`backend/src/database/migrations/1700000000000-InitSchema.ts:28-37` so
|
|
|
|
|
the `category` `CREATE TABLE` includes `created_at` and `updated_at`
|
|
|
|
|
columns from the very first migration:
|
|
|
|
|
```sql
|
|
|
|
|
CREATE TABLE IF NOT EXISTS "category" (
|
|
|
|
|
"id" TEXT PRIMARY KEY,
|
|
|
|
|
"system_key" TEXT,
|
|
|
|
|
"name" TEXT NOT NULL,
|
|
|
|
|
"abbreviation" TEXT NOT NULL,
|
|
|
|
|
"description" TEXT NOT NULL DEFAULT '',
|
|
|
|
|
"icon_path" TEXT NOT NULL DEFAULT '',
|
|
|
|
|
"created_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
|
|
|
"updated_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
|
|
|
);
|
|
|
|
|
```
|
|
|
|
|
Mirror the `strftime('%Y-%m-%dT%H:%M:%fZ','now')` default already used
|
|
|
|
|
for `user.created_at` (line 17) and `challenge.created_at` (line 54) so
|
|
|
|
|
newly-inserted rows get a sane timestamp without a follow-up
|
|
|
|
|
UPDATE-pass.
|
|
|
|
|
|
|
|
|
|
2. **Keep `AddCategoryTimestampsAndUniqueAbbrev1700000000200`
|
|
|
|
|
unchanged.** Its `if (!names.has('created_at'))` /
|
|
|
|
|
`if (!names.has('updated_at'))` guards (`backend/src/database/migrations/1700000000200-AddCategoryTimestampsAndUniqueAbbrev.ts:8-20`)
|
|
|
|
|
make the ALTER a no-op for fresh databases and still patch
|
|
|
|
|
older pre-1.06 databases that lack the columns. No changes needed
|
|
|
|
|
to `down()` either — the down() drops the columns, which is the
|
|
|
|
|
correct mirror of `up()`.
|
|
|
|
|
|
|
|
|
|
3. **Keep `UpdateSystemCategoryKeys1700000000300` unchanged.** Its
|
|
|
|
|
`INSERT` already lists `created_at` and `updated_at`
|
|
|
|
|
(`backend/src/database/migrations/1700000000300-UpdateSystemCategoryKeys.ts:51`).
|
|
|
|
|
|
|
|
|
|
4. No need to touch `CategoryEntity`
|
|
|
|
|
(`backend/src/database/entities/category.entity.ts`) — its current
|
|
|
|
|
`@Column('text', { name: 'created_at', default: '' })` and
|
|
|
|
|
`@Column('text', { name: 'updated_at', default: '' })` annotations
|
|
|
|
|
match the columns we are guaranteeing.
|
|
|
|
|
|
|
|
|
|
### 2. Backend Logic & APIs
|
|
|
|
|
|
|
|
|
|
No controller / service logic changes are required. With the schema
|
|
|
|
|
fix, `AdminCategoriesService.list()` at
|
|
|
|
|
`backend/src/modules/admin/categories.service.ts:30-37` will resolve
|
|
|
|
|
`c.created_at` and `c.updated_at` and return rows that satisfy the
|
|
|
|
|
existing `CategoryView` shape used by the front-end.
|
|
|
|
|
|
|
|
|
|
If desired, the controller surface can stay untouched
|
|
|
|
|
(`backend/src/modules/admin/admin-categories.controller.ts:30-34`) —
|
|
|
|
|
no new endpoint is needed.
|
|
|
|
|
|
|
|
|
|
### 3. Frontend UI Integration
|
|
|
|
|
|
|
|
|
|
1. **Update `frontend/src/app/features/admin/general.pure.ts`** to
|
|
|
|
|
expose a small `eventEndFieldMessage(controlErrors, crossField)` pure
|
|
|
|
|
helper that returns the canonical Event End field error string:
|
|
|
|
|
|
|
|
|
|
| `controlErrors?.['invalidDatetime']` | `form.errors?.['endBeforeStart']` | Returned message |
|
|
|
|
|
|---------------------------------------|-----------------------------------|----------------------------------------------------------|
|
|
|
|
|
| `true` | any | `"Event end must be a valid ISO-8601 datetime."` |
|
|
|
|
|
| falsy | `true` | `"Event end must be after event start."` |
|
|
|
|
|
| falsy | falsy | `null` |
|
|
|
|
|
|
|
|
|
|
This mirrors the existing `datetimeMessage` pattern and keeps the
|
|
|
|
|
template a thin consumer.
|
|
|
|
|
|
|
|
|
|
2. **Update `frontend/src/app/features/admin/general.component.ts`**:
|
|
|
|
|
|
|
|
|
|
- Replace the current `@if (showEventEndError())` /
|
|
|
|
|
`@if (eventEndCrossFieldError())` pair (lines 123-128) with a single
|
|
|
|
|
`eventEndFieldMessage` consumer that always renders inside the
|
|
|
|
|
`data-testid="general-eventEnd-error"` element. When both
|
|
|
|
|
validators are off, the element is absent.
|
|
|
|
|
- Bind `[attr.aria-invalid]` to `true` when
|
|
|
|
|
`eventEndFieldMessage(...)` is non-null, otherwise null.
|
|
|
|
|
- Bind `[attr.aria-describedby]` to `"general-eventEnd-error"` and
|
|
|
|
|
bind `[attr.title]` to the resolved message string in that same
|
|
|
|
|
case.
|
|
|
|
|
- Drop the separate `general-endBeforeStart` block **only after**
|
|
|
|
|
confirming no existing test asserts on it (a quick search of
|
|
|
|
|
`tests/frontend` shows it is only mentioned in
|
|
|
|
|
`tests/frontend/admin-general-pure.spec.ts` as a value assertion on
|
|
|
|
|
the constant `endBeforeStartMessage`, not as a DOM query). If a test
|
|
|
|
|
asserts on the element, keep it as a redundant duplicate or update
|
|
|
|
|
the test.
|
|
|
|
|
|
|
|
|
|
3. **Do not change** the form-level validator at
|
|
|
|
|
`general.pure.ts:41-51` — it already returns `{ endBeforeStart: true }`
|
|
|
|
|
for both `e < s` and `e === s`. The Job requirement is purely a
|
|
|
|
|
rendering fix.
|
|
|
|
|
|
|
|
|
|
## 4. Test Strategy
|
|
|
|
|
|
|
|
|
|
- **Target backend test file:** `tests/backend/migrations.spec.ts` (new
|
|
|
|
|
spec). Add a focused `describe('InitSchema creates category with
|
|
|
|
|
created_at + updated_at columns')` that:
|
|
|
|
|
- Boots `AppModule` against `:memory:` SQLite.
|
|
|
|
|
- Calls `dataSource.runMigrations({ transaction: 'each' })`.
|
|
|
|
|
- Queries `PRAGMA table_info("category")` and asserts the returned
|
|
|
|
|
names contain both `created_at` and `updated_at`.
|
|
|
|
|
- Sanity-checks that `AdminCategoriesService.list()` returns an array
|
|
|
|
|
(length ≥ 6 seeded system rows) without throwing — this is the
|
|
|
|
|
regression test that prevents the user-visible
|
|
|
|
|
`no such column: c.created_at` error from coming back.
|
|
|
|
|
|
|
|
|
|
- **Target backend test file:** `tests/backend/admin-categories-service.spec.ts`
|
|
|
|
|
(extend). Add one `it('list() succeeds against the migrated schema and
|
|
|
|
|
returns seeded rows')` that runs after the existing CRUD specs. The
|
|
|
|
|
existing tests already use `synchronize: true` which would have masked
|
|
|
|
|
the bug; the new test should rely on the **migrations** path so it
|
|
|
|
|
actually exercises the schema the production app uses.
|
|
|
|
|
|
|
|
|
|
- **Target frontend test file:** `tests/frontend/admin-general-pure.spec.ts`
|
|
|
|
|
(extend). Add a `describe('eventEndFieldMessage')` block with three
|
|
|
|
|
focused cases:
|
|
|
|
|
1. Returns the ISO message when `invalidDatetime` is set, even if
|
|
|
|
|
`endBeforeStart` is also set.
|
|
|
|
|
2. Returns the end-before-start message when `invalidDatetime` is
|
|
|
|
|
absent and `endBeforeStart` is set.
|
|
|
|
|
3. Returns `null` when both are absent.
|
|
|
|
|
|
|
|
|
|
- **Mocking Strategy:** No new mocks. Backend spec uses the real
|
|
|
|
|
`AppModule` against `:memory:` SQLite (already the pattern in
|
|
|
|
|
`tests/backend/admin-validation.spec.ts` and
|
|
|
|
|
`tests/backend/admin-general-service.spec.ts`). Frontend spec
|
|
|
|
|
continues to assert on the pure helper return values only.
|
|
|
|
|
|
|
|
|
|
- **Single command:** `npm test` from `/repo` runs the full suite
|
|
|
|
|
(already in `package.json:14`). Frontend and backend can also be run
|
|
|
|
|
separately via `npm run test:backend` / `npm run test:frontend`.
|
|
|
|
|
|
|
|
|
|
## 5. Persistent Project Data (/data)
|
|
|
|
|
|
|
|
|
|
Not applicable to this Job. The DB schema change is a migration; tests
|
|
|
|
|
use `:memory:` SQLite and never write to `/data`. The Categories
|
|
|
|
|
endpoint reads from the existing `category` table without touching
|
|
|
|
|
uploaded files or any other shared asset.
|