chore(ci,docs): GitHub Actions, ADRs, README overhaul, §21 Hardening Release
- .github/workflows/ci.yml: two jobs — frontend (tsc --noEmit, lint, build, Playwright) and backend (Postgres 16 + odoo:19 --test-enable --test-tags encoach_api) — catches regressions before merge. - docs/adr/: start an Architecture Decision Record trail with 0001 canonical directory layout, 0002 JWT refresh flow, 0003 paginated response envelope, 0004 RAG metadata + chunking. - docs/PROJECT_SUMMARY.md §21 Hardening Release: full recap of the AI quality loop, compliance, Paymob, i18n, and CI work shipped in this drop, plus new DB tables, REST routes, frontend routes, verification results, and operator-facing configuration. - README.md refreshed for the v4 split-repo doctrine and the new feature surface. - new_project/DEPRECATED.md: formal retirement notice pointing at backend/ as the canonical tree. Made-with: Cursor
This commit is contained in:
26
docs/adr/0000-template.md
Normal file
26
docs/adr/0000-template.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# ADR {NUMBER}: {TITLE}
|
||||
|
||||
- **Status:** Proposed | Accepted | Superseded by ADR-XXXX
|
||||
- **Date:** YYYY-MM-DD
|
||||
- **Deciders:** @handle1, @handle2
|
||||
|
||||
## Context
|
||||
|
||||
What is the problem? What forces are at play (technical, business, social)?
|
||||
Keep this short; link out to source files or tickets for depth.
|
||||
|
||||
## Decision
|
||||
|
||||
What did we decide to do? Use imperative voice. Be specific enough that a new
|
||||
engineer can understand the scope by reading this section alone.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Positive: …
|
||||
- Negative / trade-offs: …
|
||||
- Follow-up work: …
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Option A** — why it was rejected.
|
||||
- **Option B** — why it was rejected.
|
||||
52
docs/adr/0001-canonical-directory-layout.md
Normal file
52
docs/adr/0001-canonical-directory-layout.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# ADR 0001: Canonical `backend/` and `frontend/` directory layout
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-04-08
|
||||
- **Deciders:** Platform team
|
||||
|
||||
## Context
|
||||
|
||||
The repository historically contained two overlapping copies of server code:
|
||||
|
||||
- `backend/custom_addons/` — Odoo addons, actively developed.
|
||||
- `new_project/custom_addons/` — older snapshot, intermittently used for
|
||||
one-off scripts and diverging subtly from `backend/`.
|
||||
|
||||
Both paths showed up in `odoo.conf` variants, deploy scripts, and developer
|
||||
onboarding docs. Contributors routinely edited the wrong copy, shipped drift,
|
||||
or rediscovered that fixes "weren't taking" because they landed in the stale
|
||||
tree.
|
||||
|
||||
The same ambiguity existed implicitly on the client side: several prototype
|
||||
apps lived under `new_project/frontend/` in addition to the main
|
||||
`frontend/` workspace.
|
||||
|
||||
## Decision
|
||||
|
||||
Declare a single canonical layout:
|
||||
|
||||
- `backend/custom_addons/**` — the only Odoo addons tree. All deployments,
|
||||
tests, and Docker images read from here.
|
||||
- `frontend/**` — the only React/Vite workspace.
|
||||
- `new_project/` — **deprecated**. A `DEPRECATED.md` is committed inside the
|
||||
directory explaining the policy; no new files may be added.
|
||||
|
||||
New `encoach_*` modules and frontend features MUST land under the canonical
|
||||
paths. Existing imports, Dockerfiles, and docs were updated accordingly.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Positive: one obvious place to work, deterministic CI, no drift.
|
||||
- Positive: `PROJECT_SUMMARY.md` and this README become credible onboarding
|
||||
material.
|
||||
- Negative: scripts that hard-coded `new_project/` paths had to be migrated
|
||||
(one-time cost, done).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Delete `new_project/` outright.** Rejected for now because a few historic
|
||||
tarballs and experiment scripts still reference it; leaving the tree with a
|
||||
`DEPRECATED.md` marker lets us retire it in a later cleanup pass without
|
||||
blocking the hardening release.
|
||||
- **Rename `backend/` to `odoo/` to mirror Odoo's own layout.** Rejected
|
||||
because `odoo/` is already used for the upstream Odoo source checkout.
|
||||
66
docs/adr/0002-jwt-refresh-token-flow.md
Normal file
66
docs/adr/0002-jwt-refresh-token-flow.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# ADR 0002: JWT access + refresh tokens with revocation ledger
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-04-09
|
||||
- **Deciders:** Platform team, Security
|
||||
|
||||
## Context
|
||||
|
||||
Originally `/api/login` issued a single long-lived JWT (24h+) stored in
|
||||
`localStorage`. This gave us three problems:
|
||||
|
||||
1. **No revocation.** A leaked token was valid until it expired; there was no
|
||||
server-side way to invalidate it short of rotating the global JWT secret.
|
||||
2. **Silent logouts.** When the token expired mid-session the browser just
|
||||
started receiving 401s with no graceful recovery path.
|
||||
3. **Surface area.** Every endpoint accepted the same kind of token, so a
|
||||
token intended for a refresh use-case could be replayed as a full API
|
||||
credential.
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt a two-token flow:
|
||||
|
||||
- **Access token** — 1 h TTL, stateless, carries `type: "access"`. Sent on
|
||||
every request as `Authorization: Bearer …`. `validate_token()` in
|
||||
`encoach_api.controllers.base` rejects tokens whose `type` is anything other
|
||||
than `"access"`.
|
||||
- **Refresh token** — 7 d TTL, carries `type: "refresh"` and a unique `jti`.
|
||||
Every issued refresh token is logged in a new `encoach.jwt.token` Odoo model
|
||||
(the revocation ledger) with fields for `user_id`, `issued_at`, `expires_at`,
|
||||
`last_used_at`, `revoked`, `user_agent`, `remote_ip`.
|
||||
|
||||
Endpoints:
|
||||
|
||||
- `POST /api/login` — returns `access_token`, `refresh_token`, `expires_in`.
|
||||
- `POST /api/auth/refresh` — validates the refresh token, revokes the old
|
||||
ledger row (rotation), and issues a fresh access + refresh pair.
|
||||
- `POST /api/logout` — revokes the supplied refresh token's ledger row.
|
||||
|
||||
The frontend (`frontend/src/lib/api-client.ts`) handles rotation
|
||||
transparently: on 401 it calls `/api/auth/refresh` once (coalesced across
|
||||
concurrent requests) and retries the original request. If refresh fails, all
|
||||
tokens are cleared and the user is redirected to `/login`.
|
||||
|
||||
A cron (`encoach_api.data.cron`) purges expired ledger rows daily.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Positive: revocation works — logout or compromise clears the server-side
|
||||
ledger entry and the refresh token is instantly unusable.
|
||||
- Positive: short access-token TTL limits the blast radius of a leaked Bearer.
|
||||
- Positive: the refresh flow is invisible to users; no more mid-session
|
||||
logouts.
|
||||
- Negative: one extra DB round-trip per refresh. Mitigated by the short-lived
|
||||
access token and the fact that the ledger is indexed on `jti` + `user_id`.
|
||||
- Follow-up: move ledger cleanup from a time-based cron to an event-based
|
||||
cleanup if the table ever grows past a few hundred thousand rows.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Opaque session tokens with a Redis store.** Rejected — adds an operational
|
||||
dependency (Redis) that the rest of the stack does not yet require, and
|
||||
complicates horizontal scaling.
|
||||
- **Single JWT with short TTL + silent re-login.** Rejected — requires the
|
||||
client to store credentials or an SSO cookie, neither of which we want in
|
||||
`localStorage`.
|
||||
60
docs/adr/0003-paginated-response-envelope.md
Normal file
60
docs/adr/0003-paginated-response-envelope.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# ADR 0003: Canonical paginated response envelope
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-04-09
|
||||
- **Deciders:** Platform team, Frontend team
|
||||
|
||||
## Context
|
||||
|
||||
Different Odoo controllers returned paginated data in at least three shapes:
|
||||
|
||||
- `{ data: [...], total: N, page, limit }`
|
||||
- `{ results: [...], count: N }`
|
||||
- `[...]` (bare array, no totals)
|
||||
|
||||
The frontend grew defensive code paths to handle all three, and every new
|
||||
endpoint risked inventing a fourth shape.
|
||||
|
||||
## Decision
|
||||
|
||||
Every list endpoint MUST return the canonical envelope produced by
|
||||
`encoach_api.controllers.base.paginated_envelope`:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [ … ],
|
||||
"data": [ … ],
|
||||
"total": 123,
|
||||
"page": 1,
|
||||
"size": 20
|
||||
}
|
||||
```
|
||||
|
||||
- `items` is the canonical field name. New code reads from `items`.
|
||||
- `data` mirrors `items` for backwards compatibility with older callers and
|
||||
can be removed once every consumer migrates.
|
||||
- `total` is the total number of matching records across all pages.
|
||||
- `page` is 1-indexed.
|
||||
- `size` is the requested page size (capped server-side).
|
||||
|
||||
On the frontend, `PaginatedResponse<T>` in `frontend/src/types/common.ts`
|
||||
exposes both `items` and an optional `data`, and service methods
|
||||
(`users.service.ts`, `lms.service.ts`, etc.) construct a clean
|
||||
`PaginatedResponse` object from whatever the server returns so that UI code
|
||||
never sees the legacy fields.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Positive: frontend code is simpler and typesafe — read `items`, done.
|
||||
- Positive: OpenAPI spec advertises one consistent shape across endpoints.
|
||||
- Negative: one additional key (`data`) is duplicated in responses. Cheap
|
||||
(same reference, no JSON bloat) and easy to delete later.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Follow JSON:API's `{ data, meta, links }` convention.** Rejected as
|
||||
overkill: we don't use HATEOAS, and the extra indirection would force a
|
||||
rewrite of every existing consumer.
|
||||
- **Return a bare array + pagination headers.** Rejected because Odoo's
|
||||
controller helpers make setting custom headers awkward and because it hides
|
||||
totals from curl/Postman users.
|
||||
67
docs/adr/0004-rag-metadata-and-chunking.md
Normal file
67
docs/adr/0004-rag-metadata-and-chunking.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# ADR 0004: RAG metadata + chunking for vector store
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-04-09
|
||||
- **Deciders:** AI team, Platform team
|
||||
|
||||
## Context
|
||||
|
||||
The first cut of the vector store (`encoach_vector`) stored one embedding
|
||||
per source record, keyed only by `(model, res_id)`. This had two problems:
|
||||
|
||||
1. **Long documents dominated similarity scores.** A 20 000-character lesson
|
||||
would embed as one vector and out-vote shorter, more relevant passages.
|
||||
2. **No tenancy filtering.** Retrieval could not be scoped to a particular
|
||||
course, subject, entity, or taxonomy topic, which meant RAG pulled content
|
||||
from unrelated tenants on multi-entity deployments.
|
||||
|
||||
The quality gate (`encoach_quality_gate`) also needed a way to deduplicate
|
||||
re-ingested content so that re-running the indexer did not explode the table.
|
||||
|
||||
## Decision
|
||||
|
||||
Extend `encoach.vector.embedding` with RAG metadata columns:
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `course_id` | Scope to a specific course. |
|
||||
| `subject_id` | Scope to a subject/domain. |
|
||||
| `entity_id` | Tenancy filter — critical for institutional deployments. |
|
||||
| `taxonomy` | Free-form tag (e.g. `"IELTS/writing/task1"`). |
|
||||
| `content_hash` | SHA-256 of the raw chunk; used for dedup. |
|
||||
| `chunk_index`, `chunk_total` | Position in the parent document. |
|
||||
|
||||
Chunking policy (see `encoach_vector.services.embedding_service`):
|
||||
|
||||
- Content ≤ 2 000 chars → embedded as a single chunk.
|
||||
- Content > 2 000 chars → split on paragraph boundaries with ~200-char
|
||||
overlap, each chunk embedded individually.
|
||||
- Each chunk stores its `content_hash`; the uniqueness constraint is
|
||||
`(model, res_id, chunk_index, content_hash)` so re-indexing is idempotent.
|
||||
|
||||
The indexer (`encoach_vector.services.indexer`) declares per-model metadata
|
||||
mapping (which field feeds `course_id`, which feeds `subject_id`, etc.) so
|
||||
adding a new source model is a single config entry.
|
||||
|
||||
`similarity_search` accepts any subset of the metadata as a filter and
|
||||
applies it as a SQL `WHERE` clause before the vector distance computation.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Positive: retrieval quality improves dramatically on long documents.
|
||||
- Positive: multi-tenant deployments can scope RAG to a single entity.
|
||||
- Positive: re-indexing is safe (idempotent) and cheap.
|
||||
- Negative: the embedding table grows roughly linearly with document length.
|
||||
Mitigated by the `content_hash` dedup and by keeping only the latest
|
||||
revision per source record.
|
||||
- Follow-up: expose a management action to purge embeddings for a retired
|
||||
course or entity.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Use an external vector DB (Pinecone, Weaviate).** Rejected — pgvector is
|
||||
already in the Postgres image, keeping ops surface small. Can be revisited
|
||||
if we outgrow it.
|
||||
- **Chunk-per-sentence instead of paragraph.** Rejected — too many tiny
|
||||
chunks, each losing context; paragraph-sized chunks strike a better
|
||||
recall/precision balance for our domain.
|
||||
22
docs/adr/README.md
Normal file
22
docs/adr/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Architecture Decision Records
|
||||
|
||||
This folder contains lightweight ADRs documenting significant architectural
|
||||
decisions made on the EnCoach platform. Each ADR is numbered, dated, and
|
||||
immutable once "Accepted" — if a decision is revisited, open a new ADR that
|
||||
supersedes the old one instead of rewriting history.
|
||||
|
||||
## Index
|
||||
|
||||
| # | Title | Status |
|
||||
|---|-------|--------|
|
||||
| [0001](0001-canonical-directory-layout.md) | Canonical `backend/` and `frontend/` directory layout | Accepted |
|
||||
| [0002](0002-jwt-refresh-token-flow.md) | JWT access + refresh tokens with revocation ledger | Accepted |
|
||||
| [0003](0003-paginated-response-envelope.md) | Canonical paginated response envelope | Accepted |
|
||||
| [0004](0004-rag-metadata-and-chunking.md) | RAG metadata + chunking for vector store | Accepted |
|
||||
|
||||
## Writing a new ADR
|
||||
|
||||
1. Copy [`0000-template.md`](0000-template.md) to the next number.
|
||||
2. Fill in **Context**, **Decision**, **Consequences**.
|
||||
3. Keep it short (1 page). Link to source files or PRs for detail.
|
||||
4. Update the index above and open a PR.
|
||||
Reference in New Issue
Block a user