fix(a11y): resolve serious accessibility issues on search page (GOO-110)

- Add aria-hidden="true" to all decorative inline SVGs (bookmark, view-mode, funnel, checkmark)
- Convert save-search popover to proper dialog: role="dialog", aria-modal, focus trap, Escape key, focus return to trigger
- Add aria-pressed on list/map/split view-mode toggle buttons
- Add aria-expanded + aria-controls on mobile filter toggle button
- Add role="status" + aria-label="Đang tải..." on Suspense fallback

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-24 10:26:50 +07:00
parent 1d26393f16
commit f5118244b7
34 changed files with 2321 additions and 9 deletions

View File

@@ -0,0 +1,129 @@
# `read-models` module
Phase 0 skeleton for the CQRS read-model expansion described in
[RFC-003](../../../docs/adr/0003-cqrs-read-models.md) (the ADR itself
lands with [GOO-193](/GOO/issues/GOO-193); until then RFC-003 lives on
[GOO-94](/GOO/issues/GOO-94)).
## Layout
```
read-models/
domain/
projection-context.ts # ProjectionContext, ProjectableEvent
projection-offset-store.ts # IProjectionOffsetStore port + DI symbol
read-repository.ts # IReadRepository convention marker
application/
projectors/
projector.base.ts # Projector<E> base class
repositories/ # I<Name>ReadRepository interfaces (Phase 2/3)
infrastructure/
refresh/ # mat-view refresh cron (Phase 1)
reconciliation/ # nightly drift checker (Phase 2+)
testing/
in-memory-projection-offset-store.ts # unit-test harness
read-models.module.ts
index.ts
```
This mirrors the layout RFC-003 §5 specifies; intentionally **no
`presentation/`** because read models are infrastructure for other
modules' query handlers, not their own HTTP surface.
## The projector contract
Every read-model projector extends `Projector<E extends DomainEvent>`
and implements:
```ts
@EventsHandler(MyDomainEvent)
export class MyProjector extends Projector<MyDomainEvent> {
readonly handlerName = 'my-projector.v1';
protected async apply(event: MyDomainEvent, ctx: ProjectionContext) {
// write to your read model
}
}
// glue (one of):
@EventsHandler(MyDomainEvent)
export class MyProjectorGlue implements IEventHandler<MyDomainEvent> {
constructor(private readonly projector: MyProjector) {}
handle(event: MyDomainEvent) { return this.projector.dispatch(event); }
}
```
Subclasses MUST:
- set `handlerName` to a **stable string** (rename = re-projection — be deliberate);
- implement `apply(event, ctx)` and treat `ctx.eventId` as the unit of idempotency.
Subclasses MUST NOT:
- call `apply` directly — always go through `dispatch(event)`;
- write to write-model tables — read models are read-only from the API
surface, only mutated by their owning projector or refresh job;
- implement their own deduplication — the base class already does it via
`IProjectionOffsetStore`.
## The offset / idempotency contract
RFC-003 §0 mandates `(eventId, handlerName)` idempotency:
> The `(eventId, handler)` offset table is non-negotiable. Land it in
> Phase 0 with a unit-test harness so every Phase 2/3 projector inherits it.
This module ships the **port** (`IProjectionOffsetStore`,
`PROJECTION_OFFSET_STORE`) and an in-memory implementation for tests.
The Prisma-backed implementation — including the
`projection_offset(event_id, handler_name, applied_at, payload_hash)`
migration and the transactional wrapper — lands with
[GOO-187](/GOO/issues/GOO-187).
The base class enforces the contract by calling `recordIfAbsent` BEFORE
`apply`. Re-deliveries observe `applied: false` and are skipped. The
offset row is intentionally **not rolled back on `apply` failure** in
Phase 0 — this is the conservative choice (RFC-003 §7) and is healed by
the nightly reconciliation job that lands in Phase 2.
`eventId` is currently derived from
`${aggregateId}:${occurredAt.getTime()}:${eventName}` because the
existing `DomainEvent` interface (`apps/api/src/modules/shared/domain/domain-event.ts`)
does not yet carry a stable id. Override `deriveEventId` on your
projector if your event type provides one. The id contract itself is
finalised in [GOO-187](/GOO/issues/GOO-187); Phase 2/3 projectors should
not bake assumptions about its format.
## The repository convention
For each read model:
1. Define `I<Name>ReadRepository` (extending `IReadRepository`) under
`application/repositories/`.
2. Export a paired injection symbol `<NAME>_READ_REPOSITORY`.
3. Implement `Prisma<Name>ReadRepository` under
`infrastructure/repositories/` and bind it in `ReadModelsModule`.
4. Re-export the symbol from the module's `index.ts` so query handlers in
other modules can `@Inject(LISTING_CARD_READ_REPOSITORY)` without
reaching into the read-models module's internals.
Read repositories are READ-ONLY from the perspective of the rest of the
API. The only writers are the projector that owns the read model (Option
C) or the materialized-view refresh job (Option B).
## What Phase 0 is NOT
- No `projection_offset` migration — owned by [GOO-187](/GOO/issues/GOO-187).
- No projectors registered.
- No materialized views or refresh job — Phase 1.
- No reconciliation job — Phase 2.
- No `X-Data-Freshness-Seconds` helper — separate Phase 0 ticket.
- No kill-switch / chaos test — separate Phase 0 ticket.
The skeleton exists so the next batch of PRs is purely additive.
## Coordination
- Parent: [GOO-94](/GOO/issues/GOO-94)
- Sibling (offset table + idempotency harness): [GOO-187](/GOO/issues/GOO-187)
- ADR (write-up): [GOO-193](/GOO/issues/GOO-193)