# Domain Model

## Entities

| Entity | Table | Meaning |
|---|---|---|
| `User` | `users` | A Telegram user (student or admin). `role` mirrors admin status but `AdminAuthService`/`config admins.ids` is the real source of truth. |
| `UserState` | `user_states` | One active state + JSON context per user (multi-step flow bookkeeping). |
| `Campaign` | `campaigns` | Ad/marketing source, referenced by `users.campaign_id` and `test_sessions.campaign_id` for attribution. |
| `Test` | `tests` | A funnel definition. `flow_type` (default `quiz`) selects which engine drives it; soft-deleted via `deleted_at`. `result_display_mode` (`single`/`dual_on_close`, Phase 5) drives winner selection — see Product Rules §7. `description`/`intro_*` fields are admin-only notes, **never shown to students** (Product Rules §1). |
| `TestProfile` | `test_profiles` | A scoring "dimension"/personality axis for a test (e.g. "Analytical", "Creative"). |
| `Question` | `questions` | One question in a test, ordered by `sort_order`. |
| `QuestionOption` | `question_options` | An answer choice. `next_action` (`next`/`jump`/`end`) drives branching. |
| `OptionProfileScore` | `option_profile_scores` | How many points an option adds to a profile (can be negative). |
| `ResultDefinition` | `result_definitions` | The personalized outcome shown for a winning profile, with CTA fields. |
| `ResultMedia` | `result_media` | Voice/pdf/image/etc. attached to a result, pointing at a `StoredFile`. |
| `StoredFile` | `stored_files` | Metadata for a Telegram `file_id` — **never binary content**. |
| `TestSession` | `test_sessions` | One attempt at a test by a user. Has a public, unguessable `public_token`. |
| `SessionAnswer` | `session_answers` | One picked option per question per session (unique per session+question). |
| `SessionScore` | `session_scores` | Computed per-profile total for a session (written by the Phase 5 scoring engine). |
| `AnalyticsEvent` | `analytics_events` | Append-only funnel event log, no FKs by design. |
| `Setting` | `settings` | Key/value config, typed (`string`/`int`/`bool`/`json`), some public-readable. |
| `AdminAuditLog` | `admin_audit_logs` | Append-only trail of admin actions. |
| `MembershipLog` | `membership_logs` | History of channel-membership checks (`getChatMember` results). |
| `ProcessedCallback` | `processed_callbacks` | Idempotency guard for `callback_query` redelivery. |

Every entity has a `Tfb\Models\*` class with `fromRow(array): self` and (where useful) `toArray(): array`. Models are dumb data holders — no queries, no business rules.

> **Product Rule Delta (Phase 5):** see `docs/PRODUCT_RULES.md` for the full, authoritative list of behavioral rule changes (no intro shown to students, auto-generated slugs/profile codes, result display modes, CTA/content rules, `/start` deep-link policy). Where this document and PRODUCT_RULES.md appear to conflict, PRODUCT_RULES.md wins.

## Phase 5 engines (pure logic, `Tfb\Engines\*`)

| Class | Namespace | Responsibility |
|---|---|---|
| `ProfileScoreEngine` | `Tfb\Engines\Scoring` | Sums `option_profile_scores` rows into per-profile totals. Pure function, no I/O. |
| `WinnerSelector` | `Tfb\Engines\Scoring` | Applies Product Rules §7 to pick primary/secondary profile + a `Selection` (reason + full ranked list, loggable via `toMeta()`). |
| `BranchResolver` | `Tfb\Engines\Flow` | Resolves `next`/`jump`/`end` for a chosen option; rejects self-loops and cross-test jump targets safely (ends the test rather than crashing/looping). |
| `CallbackCodec` | `Tfb\Engines\Flow` | Encodes/decodes compact `callback_data` (`opt:<question_id>:<option_id>`, `test:<action>:<test_id>`) within Telegram's 64-byte limit. Validates shape only — never ownership (see Security invariants below). |
| `StartPayloadParser` | `Tfb\Engines\Flow` | Parses `/start` deep-link payloads (`t_{slug}`, `t_{slug}__c_{campaign}`, `c_{campaign}`) into a `ParsedStartPayload`. |
| `QuizFlowHandler` | `Tfb\Engines\Flow` | The quiz runtime orchestrator implementing `FlowHandlerInterface` for `flow_type = 'quiz'` — owns the full question loop, scoring, result delivery, and membership check hand-off. |

## Session rules (enforced by `SessionService` + `TestSessionRepository`)

1. A user may have **at most one `in_progress` session per test** at a time. This is a service-level policy (no DB unique constraint enforces it directly, because history must keep multiple completed/abandoned sessions).
2. Starting a new attempt while one is `in_progress` **abandons** the old one first (`SessionService::startNew()` calls `TestSessionRepository::abandon()` before creating the new row). The old session is never silently orphaned.
3. Sessions idle longer than `settings.session_ttl_hours` (default 24) are expired **lazily** via `SessionService::expireStaleSessions()` — a single bulk `UPDATE`, not a per-row loop, and not a cron job. Call sites (Phase 6+) should call this opportunistically (e.g. on `/start`), not on every webhook request.
4. `session_answers` has `UNIQUE(session_id, question_id)`; re-answering a question **updates** the existing row (`ON DUPLICATE KEY UPDATE`), it does not create a duplicate.
5. `session_scores` is fully replaced (delete+insert in one transaction) by `SessionScoreRepository::replaceForSession()` whenever the scoring engine recomputes — simpler and safer than diffing individual profile rows.
6. `duration_seconds` is computed server-side from `started_at` to "now" (UTC), never trusted from client/session context.

## Test soft-delete rule

`tests.deleted_at` is set instead of deleting the row
(`TestRepository::softDelete()`), because `test_sessions.test_id` has
`ON DELETE RESTRICT` — a hard delete would fail anyway once any
session exists. All read methods (`findById`, `findBySlug`, `listActive`, `listAll`) filter `deleted_at IS NULL`; only `findByIdIncludingDeleted()` (for historical/reporting reads) does not.

## Settings keys (seeded by Phase 2 `Tfb\Install\Seeder`, extended in Phase 5)

| Key | Type | Purpose |
|---|---|---|
| `welcome_text` | string | Generic welcome copy (not sent by `/start` anymore in Phase 5 — see `start_without_test_text` below; kept for possible reuse) |
| `join_prompt` / `join_success` / `join_fail` | string | Channel-join funnel copy, used by `QuizFlowHandler`'s membership check |
| `maintenance_text` | string | Shown to non-admins during maintenance |
| `banned_text` | string | Shown to banned users |
| `cancel_done` | string | `/cancel` reply |
| `resume_prompt` | string | Legacy/generic resume copy (superseded by `start_resume_prompt` for the quiz flow) |
| `generic_error` | string | Safe fallback error copy |
| `channel_join_text` / `channel_not_joined_text` | string | Legacy join-gate copy (kept for compatibility) |
| `cta_fallback_text` | string | Fallback CTA button text when a result has no content but a usable CTA |
| `support_username` | string | Support contact, shown in error/help copy |
| `soft_join_fail_open` | bool | If Telegram's membership check fails, let the user through anyway? **(Phase 6)** now wired into `QuizFlowHandler` via `MembershipService::checkLiveDetailed()` — only applies on a genuine transport/API failure, never on a confirmed "left"/"kicked" status. |
| `session_ttl_hours` | int | See "Session rules" above |
| `resume_enabled` | bool | Whether `/start` on an in_progress test offers resume/restart (Product Rules §11) |
| `force_join_enabled` | bool | **(Legacy, non-functional since Update Pack F05)** Previously ANDed with `tests.force_channel_join` to gate result delivery on channel membership. Update Pack F05 removed ALL blocking/gating behavior from the student path permanently (see `docs/PRODUCT_RULES.md` §26) — this setting and the `tests.force_channel_join` column are kept only for backward-compatible storage/display in the admin settings screen and are never read by `QuizFlowHandler` or any other runtime code path. Membership is tracked purely for analytics via `MembershipEventHandler`/`MembershipService`, never as a gate. |
| `default_threshold` | int | Default `tests.threshold` for new tests (installer-set) |
| `html_messages` | bool | Whether bot replies use `parse_mode=HTML` |
| `start_without_test_text` | string | **(Phase 5)** Sent when `/start` has no valid test payload (Product Rules §10) |
| `start_resume_prompt` | string | **(Phase 5)** Resume/restart prompt copy for `QuizFlowHandler` |
| `question_progress_enabled` | bool | **(Phase 5)** Whether to prefix questions with "سوال n از m" |
| `membership_recheck_button_text` | string | **(Phase 5)** Text for the "عضو شدم" re-check button |
| `default_result_display_mode` | string | **(Phase 5)** Reserved default for Phase 6 test-creation UI (not read by the runtime yet — `tests.result_display_mode` column default is the actual runtime default) |
| `default_dual_threshold` | int | **(Phase 5)** Fallback threshold when `tests.threshold` is null |
| `enable_default_test_on_start` | bool | **(Phase 6)** Opt-in switch: if on AND a default active test exists, a deep-link-less `/start` auto-starts it instead of only showing `start_without_test_text` (Product Rule §4.11 — must always be an explicit admin choice, never implicit). Editable from the Telegram admin builder's ⚙️ تنظیمات screen. |
| `maintenance_enabled` | bool | **(Phase 7)** DB-backed maintenance switch, ORed with the `app.maintenance` config flag in `WebhookKernel::build()` — now editable from the Telegram admin panel (previously config-file-only). |
| `log_level` | string | **(Phase 7)** Overrides `app.log_level` (from `config/app.php`) once the DB/settings cache is reachable, via `FileLogger::setMinimumLevel()`. Must be one of `debug`/`info`/`warning`/`error` (validated at the controller layer, not just the DB `type` column). Editable from the admin panel; see `docs/LOGGING_AND_DIAGNOSTICS_FA.md`. |

Additionally, ten settings (`start_without_test_text`, `default_threshold`,
`force_join_enabled`, `enable_default_test_on_start`, `maintenance_text`,
`join_success`, `join_fail`, `maintenance_enabled`, `support_username`,
`log_level`) are exposed for direct editing from inside
Telegram via `Tfb\Support\AdminEditableSettings` — a fixed, index-stable
whitelist (never the full `settings` table) so an admin can never
accidentally corrupt an internal-only key. See `docs/ADMIN_GUIDE.md`.


Read via `Tfb\Services\SettingsService::get/getBool/getInt/getJson`. Write via `SettingsService::set()`, which invalidates the cache immediately (see `docs/ARCHITECTURE.md` performance section for why a file cache exists at all). Installs created before Phase 7 get these new keys automatically via `Tfb\Install\Migrator::upgrade()` (idempotent, see `bin/migrate.php`).

## Analytics event names (`Tfb\Support\AnalyticsEvents`)

`bot_started`, `bot_start_deep_link`, `test_started`, `question_viewed`
(reserved, not yet written), `question_answered`, `test_completed`,
`test_drop` (reserved, not yet written), `result_shown`, `voice_sent`
(reserved — media sends are currently reflected via `file_sent`),
`file_sent`, `channel_join_prompted`, `channel_joined`,
`membership_pass`, `membership_fail`, `cta_clicked` (reserved, not
yet written — no click-tracking redirect exists), `user_blocked_bot`,
`user_unblocked_bot`.

Always reference these constants, never hardcode the string — reporting (Phase 10) depends on the names never drifting.

## State names

- `Tfb\Support\StudentStates`: `IDLE`, `AWAITING_TEST_START`, `IN_TEST` (used by `QuizFlowHandler` while a session is in progress), `AWAITING_CHANNEL_JOIN`, `VIEWING_RESULT`
- `Tfb\Support\AdminStates` **(fully rewritten in Phase 6 — the Phase 4 stub's 4 placeholder constants are gone; extended in Phase 7)**: every "waiting for admin text/media input" state used by the Telegram admin builder — `TEST_CREATE_TITLE`, `TEST_EDIT_TITLE`, `TEST_EDIT_THRESHOLD`, `PROFILE_ADD_TITLE`, `PROFILE_EDIT_TITLE`, `QUESTION_ADD_TEXT`, `QUESTION_EDIT_TEXT`, `OPTION_ADD_TEXT`, `OPTION_EDIT_TEXT`, `OPTION_SCORES_EDIT` (draft-then-commit scoring UI), `RESULT_EDIT_TITLE` (reserved, unused — see `docs/PROJECT_STATUS.md`), `RESULT_EDIT_TEXT`, `RESULT_WAIT_VOICE`, `RESULT_WAIT_FILE`, `RESULT_CTA_TEXT`, `RESULT_CTA_URL`, `SETTINGS_EDIT_WAIT`, `FILE_WAIT_UPLOAD`, and **(Phase 7)** `USER_SEARCH_QUERY`, `USER_SEND_MESSAGE`, `CAMPAIGN_ADD_TITLE`/`CAMPAIGN_ADD_CODE`/`CAMPAIGN_ADD_SOURCE`, `CAMPAIGN_EDIT_TITLE`, `RESTORE_WAIT_FILE`, `RESTORE_WAIT_CONFIRM_PHRASE`. Pure navigation (menus/lists/detail views) is stateless, driven entirely by `callback_data` — only "waiting for the next message" steps get a `user_states` row. `AdminStates::all()` is used by `AdminMessageDispatcher` to detect "is this Telegram user currently mid-admin-wizard?" before routing a plain-text/media message.

`Tfb\Services\StateService` provides `get`/`set`/`mergeContext`/`resetToIdle` as raw primitives on top of `user_states`. `QuizFlowHandler` uses `IN_TEST` (with `{test_id, session_id}` context) while a quiz is in progress and resets to `IDLE` on completion; every `AdminStates` state carries the entity id(s) the pending input applies to in its context (e.g. `{"test_id": 5}`), set by the controller right before prompting.

## Phase 6/7 admin services (`Tfb\Services\*`, `Tfb\Controllers\Admin\*`)

| Class | Responsibility |
|---|---|
| `OptionScoringService` | Pure draft math (`loadDraft`/`increment`/`decrement`, floor-0) + `commit()` for the +/- scoring UI. State-storage-agnostic — the controller owns persisting the draft in `user_states.context_json`. |
| `TestValidationService` | Read-only activation gatekeeper (`validate($test): {blocking, warnings}`) — see "Test soft-delete rule" section for the analogous pattern; re-runnable anytime, never mutates. |
| `AdminFileUploadService` | Telegram-specific: copies an admin's voice/document into `channels.files` via `TelegramClient::copyMessage()`, then calls `FileService::storeMetadata()` with the **original** file_id (valid across any chat the bot has touched). |
| `BackupService` **(Phase 7)** | Pure-PDO logical backup/restore: `createBackup()` (SHOW CREATE TABLE + batched INSERTs), `restoreFromFile()` (quote-aware statement splitting + execution), `resolveBackupPath()` (path-traversal-proof filename resolution), `validateBackupFile()` (marker + size cap). |
| `AdminCallbackDispatcher` | Implements `Tfb\Bot\CallbackHandlerInterface`; the single choke point for `adm:` callback_query routing + admin-auth enforcement, across all 14 entities (menu/t/p/q/o/r/s/f/u/c/a/b/l/h). |
| `AdminMessageDispatcher` | The single choke point for routing a plain-text/voice/document message to whichever `Admin*Controller::handle*()` is waiting, based on `AdminStates::all()`. |
| `Admin{Menu,Test,Profile,Question,Option,Result,Settings,FileLibrary}Controller` | One controller per entity (kept thin per `docs/CODING_GUIDE.md`) — `AdminOptionController` additionally owns the live scoring UI since it operates on the same entity (an option). |
| `Admin{User,Campaign,Analytics,Backup,Logs,Health}Controller` **(Phase 7)** | Ops layer: user search/ban/messaging, campaign CRUD + attribution stats, date-ranged analytics reports, backup/restore, log tailing, and a self-diagnostic health screen. |

Reserved prefixes for compact `callback_data` payloads:
`test` (test-scoped actions: `join_check`/`resume`/`restart`, see
`CallbackCodec::decodeTestAction()`), `opt` (option answers, see
`CallbackCodec::decodeOption()`), `res` (reserved, still unused — no
student-facing "review past result" UI exists yet), `adm` (Telegram
admin builder — see `Tfb\Engines\Flow\AdminCallbackCodec`, format
`"adm:<entity>.<action>:<id>:<id2>"` with entities
`menu`/`t`/`p`/`q`/`o`/`r`/`s`/`f` (Phase 6) and
`u`/`c`/`a`/`b`/`l`/`h` (Phase 7, users/campaigns/analytics/backup/
logs/health); every callback is re-validated
against a fresh DB lookup + `AdminAuthService::isAdmin()` in

`AdminCallbackDispatcher`, exactly like the student `CallbackCodec`'s
"shape-only, never authorization" rule).

