# TemseeEdu — System Documentation

This is the technical reference for the TemseeEdu codebase: what it is, how it's built, how the pieces fit together, and how to install, deploy, and extend it. For day-to-day usage instructions written for school staff, see the separate **User Manual**. For narrow contribution conventions (commands, lint, test setup), see `CLAUDE.md`.

---

## 1. What TemseeEdu is

TemseeEdu is a school management system built on Laravel 12 and Filament 4, shipped in two editions from a single codebase:

- **Cloud** — a multi-tenant SaaS. One TemseeEdu deployment serves many schools, each fully isolated in its own database, resolved by domain/subdomain. Schools self-register and pay via Paystack subscriptions; a platform owner manages every tenant from a dedicated console.
- **School Edition** — a self-hosted, single-tenant build for a school that wants (or needs) to run its own instance, typically on shared cPanel hosting. Feature access is gated by a cryptographically signed license file rather than a live subscription check, so the school's day-to-day operation never depends on reaching TemseeEdu's servers.

Which edition is active is controlled by `config('temseeedu.edition')` (`cloud` or `school_edition`), and for School Edition, `config('temseeedu.hosting_mode')` (`standard` or `cpanel`) further adjusts whether the app is allowed to create its own database and which cache/session/queue drivers it assumes are available.

---

## 2. Architecture

### 2.1 Multi-tenancy

Tenancy is implemented with `stancl/tenancy`, resolved by domain (`InitializeTenancyByDomain`):

- **Central domains** (configured in `config/tenancy.php`, e.g. `127.0.0.1`/`localhost` in dev, the real marketing domain in production) serve public registration, billing, and the platform owner's own console.
- **Any other domain** is treated as a tenant (a school) and routed into that school's isolated database.

`routes/web.php` holds central-domain routes (registration, Paystack checkout/webhooks, the offline license-activation API, the self-hosted installer). `routes/tenant.php` holds every per-school route (the public school website, and — via Filament panel registration — the Admin/Teacher/Parent/Student portals), and is only loaded once a tenant has been resolved.

`app/Providers/TenancyServiceProvider.php` wires the tenant lifecycle:

- **`TenantCreated`** runs a pipeline of jobs: `CreateDatabase` (skipped entirely when `hosting_mode = cpanel`, since shared hosting mostly disallows `CREATE DATABASE` from the app — the operator creates that database by hand first) → `MigrateDatabase` → `SeedDatabase` → `CreateTenantStorageDirectories` → `MigrateTenantModules` (module migrations run separately from the core migration set).
- **`TenancyBootstrapped`** (every request/job that enters a tenant context) re-applies the tenant's own settings on top of a cached ".env baseline": Spatie permission cache key, the `public` filesystem disk URL, the Filament theme-mode setting, and — critically — the tenant's SMTP configuration if one is set in `school_settings` (switching `mail.default` to `smtp` and pointing it at the school's own mail server; without this, "configured" SMTP would silently do nothing, since the shipped default mailer is `log`).
- **`RevertedToCentralContext`** resets all of the above back to central/baseline values.

### 2.2 Filament panels

Five Filament panels, each its own `PanelProvider` under `app/Providers/Filament/`, registered in `bootstrap/providers.php`:

| Panel | Path | Guard | Who it's for |
|---|---|---|---|
| Admin | `/admin` | `admin` | The school's own back-office — the default panel. Discovers resources/pages/widgets from both `app/Filament/**` and every `app-modules/*/app/Filament/**`. |
| Temsee (Super) | `/temsee` | `admin` | The **platform owner's** own console — provisioning schools, subscriptions, plugin license approvals, platform-wide audit log/error events. Resources live under `App\Filament\Super\*`. Runs against the **central** database, not a tenant's. |
| Teacher | `/teacher` | `admin` | Teaching staff — classes, attendance, score entry, timetable, invigilation duties, assignments. |
| Parent | `/parent` | `parent` | Guardians — their children's results, invoices/payments, documents, contacting the school. |
| Student | `/portal` | `student` | Students — their own results, attendance, exams, timetable, documents. |

Admin and Teacher share the `admin` guard, which is also where 2FA is enforced (`App\Http\Middleware\RequireTwoFactor` — students and parents don't need it). Every tenant-facing panel is additionally gated by `VerifyLicense` (School Edition licensing check) alongside the standard Filament auth/tenancy middleware stack.

Panels declare tenancy middleware explicitly (`InitializeTenancyByDomain`, `PreventAccessFromCentralDomains`). See `FILAMENT_PLUGIN_INTEGRATION.md` for which third-party Filament plugins are approved and why (plugins requiring Filament 5, or anything that would expose server tooling inside a tenant panel, are deliberately excluded).

### 2.3 Feature modules (`app-modules/`)

Optional modules, managed by `nwidart/laravel-modules` and toggled in `modules_statuses.json`, merged into the root Composer autoload via `wikimedia/composer-merge-plugin`. Each follows the standard Laravel-modules skeleton (`app/`, `config/`, `database/migrations`, `routes/`, `resources/views`, its own `tests/`). A module's Filament UI (resources/pages) is discovered explicitly per-panel inside that panel's `PanelProvider` — adding a new module resource requires that discovery call to exist in every panel it should appear in.

Currently shipped and active:

| Module | What it provides |
|---|---|
| **Results** | Subjects, grade entry (see §2.6), grade scales, report cards, publishing, and the student/parent result views. Also owns homework submissions. |
| **Attendance** | Daily attendance records, class timetables, teacher timetable views. |
| **Payments** | Fee categories, invoices, Paystack checkout/receipts, payment records, parent fee views. |
| **Exams** | Exam sessions, per-subject/date exam schedules, hall-ticket generation. |
| **Library** | Book catalogue, loans/returns, overdue tracking. |
| **Transport** | Routes, vehicles, maintenance logs, student route assignments. |
| **Hostel** | Boarding houses, rooms, allocations, visitor logs. |
| **Inventory** | Asset/stock register, transactions, low-stock monitoring. |
| **Payroll** | Payroll periods/records, allowances, deductions, approvals. |
| **Accounting** | Chart of accounts, bank accounts, ledger entries. |
| **Alumni** | Graduate records, contact details, engagement history. |

`config('temseeedu.plugin_catalog')` is the canonical catalog (name, description, cloud/school-edition pricing, which subscription plan tier unlocks it) shown in the in-app Plugin Marketplace. A handful of catalog entries exist for **planned, not-yet-built** modules (`is_available: false` — CSSPS admissions import, NaCCA curriculum mapping, WAEC candidate management, NTC teacher licensing, GhanaPost GPS addressing); these appear in the roadmap but have no corresponding `app-modules/` directory yet.

Per-module access is gated by `App\Services\PluginLicenseManager::verify($slug)` — see §2.5.

### 2.4 Core domain model

The pieces almost everything else hangs off of:

- **`Tenant` / `Domain`** (central DB) — one school, resolved by its domain(s).
- **`AcademicYear` → `Term`** — a school year (e.g. "2025/2026") containing terms, each with `start_date`/`end_date`. Chronological `start_date` ordering — not the term's name — is the source of truth for "which term is first/second/third" wherever that matters (see the eduassess sync in §2.7).
- **`Classes`** — a form/grade level (e.g. "JHS 1"), optionally with a class teacher.
- **`Student`**, **`ParentUser`** (guardians, many-to-many with students via `student_guardian`), **`User`** (staff/admin/teacher — the `admin` guard).
- **`Subject`**, **`Result`** — a result row supports **two scoring models** on the same table: the simple GES-standard `class_score`(30%)/`exam_score`(70%) split, or a 9-component continuous-assessment breakdown (`ica1, ica2, icp1, icp2, gp1, gp2, practical, mid_term, end_term` — weights and max scores in `config('temseeedu.assessment_components')`). `Result::computeTotal()` uses the 9-component model whenever any of those fields are set, otherwise falls back to the simple split — see `computeTotalFromComponents()`'s docblock for the exact two-stage rounding formula, verified cell-by-cell against a real school export.
- **`GradeScale`** — score-range → letter grade / remark mapping, school-configurable.
- **`Application`** — an admissions application, with its own pipeline/status tracking and Paystack-backed application-fee payment.
- **`Announcement`** — a school notice, with `target_audience` (`all`/`students`/`parents`/`staff`/`class`) controlling who it reaches and through which channels (see §2.8).
- **`NavigationMenuItem`** — the public website's CMS-managed header/footer navigation, additive on top of built-in defaults (see §2.9).
- **`Event`**, **`News`**, **`Page`**, **`GalleryItem`** — public-website CMS content.

### 2.5 Editions & licensing

`config/temseeedu.php` is the single source of truth for edition/licensing config.

**Cloud**: plugin access is checked live against the central `school_plugins` table (`tenant_id` + `plugin_slug` + `status = active`, cached 6h).

**School Edition**: there is no live check against a vendor server for day-to-day operation. Instead:

1. The school enters their license key on the **License Activation** page (Admin → System). This POSTs to `TEMSEEEDU_LICENSE_SERVER_URL` — the vendor's own **central (cloud edition)** instance, the only place `LicenseServerController::activate()` is allowed to run (`edition !== 'cloud'` → `404`). ⚠️ **`scripts/build-school-edition.sh` does not currently populate `TEMSEEEDU_LICENSE_SERVER_URL` or `TEMSEEEDU_LICENSE_PUBLIC_KEY` in the shipped `.env.example`** — these must be set by hand before a build is handed to a school, or activation will silently try to POST to the school's own domain and fail.
2. On success, the vendor server returns an **RS256-signed JWS token** (private key only ever exists on the vendor's own server) containing the authorized plugin list, expiry, and a domain lock. It's cached locally at `storage/app/license.json`.
3. From then on, `PluginLicenseManager::verify($slug)` reads and cryptographically verifies that local token — no network call needed. A `TEMSEEEDU_PLUGIN_*_KEY` env-var fallback exists but **only works in `local`/`testing` environments**, never in production — this is deliberate, since a plain env var is readable/editable by whoever hosts the install.

### 2.6 Score entry & the eduassess integration

Every manual score-entry surface (`ResultResource`, the admin Bulk Score Entry action, the teacher `MyScoreEntry` page) shares one helper, `App\Support\AssessmentComponentFields`, so the 9-component fields and the total/grade recompute logic exist in exactly one place rather than drifting across three copies. Score sheets can also be bulk-imported from the school's standard Excel Continuous Assessment Sheet format via `AssessmentSheetImporter`.

`App\Services\EduassessSyncService` pushes results to [eduassess](https://github.com/Linxford/eduassess), an external results system some schools already use, via its bulk results API. Two translation steps are load-bearing here and were the cause of a real production incident (duplicate rows) before being fixed:

- **Term**: eduassess expects `term1`/`term2`/`term3`, not whatever a school calls its terms — translated by chronological `start_date` position within the academic year, never by name string.
- **Academic year**: eduassess expects a hyphenated `"2025-2026"`; TemseeEdu stores `"2025/2026"` — translated only when the value actually matches the slash format, left untouched otherwise.

A student must have an `eduassess_student_number` set (populated automatically by the Assessment Sheet importer, or set manually) before their results sync; students without one are skipped and reported, not guessed at.

### 2.7 Notifications: email, SMS, in-app, and desktop push

Four independent channels:

- **Email** — routed through whichever mailer `mail.default` currently resolves to. Per-tenant SMTP (School Settings → Notifications) overrides this at tenant-boot time (see §2.1); if no SMTP host is set, mail silently only writes to the `log` driver (nothing is actually delivered) — the School Settings page has a **Test Email** action specifically to catch this before it surprises anyone.
- **SMS** — `App\Services\SmsService::send()`, provider-agnostic (Arkesel, Hubtel, or a fully custom HTTP endpoint with a configurable payload template), Ghana E.164 phone normalization, per-message delivery logging to `MessageLog`. Wired into: results-published notifications to parents, fee/debtor reminders, announcement broadcasts, event reminders, payment confirmations. A **Test SMS** action exists in School Settings too (note: unlike Test Email, it saves the entire settings form before testing, since `SmsService` reads live from the database).
- **In-app (database) notifications** — standard Filament/Laravel database notifications, shown via the bell icon in every panel.
- **Desktop/browser push** — an opt-in banner (Teacher/Parent/Student panels only, not Admin) that uses the native browser `Notification` API, listening for Filament's own `notificationSent` JS event to mirror any Filament toast as a desktop notification.

None of this is useful to a school until SMTP is actually configured and SMS is turned on with real provider credentials in School Settings — those are two separate opt-in toggles, not defaults.

### 2.8 Public website

Each tenant gets a full public marketing/informational website at their domain root, driven by `App\Http\Controllers\PublicWebsiteController` and `resources/views/public/**`, themeable (3 themes under `public/themes/{classic,compact,modern}`, chosen per-school). Covers: home, news, notices, events (split into Upcoming/Past), gallery, leadership & staff, academic programmes, arbitrary CMS pages, contact form, prospectus download, and the full admissions flow (apply online, pay the application fee, track status by reference, download the filled application). Header/footer navigation is CMS-editable (`NavigationMenuItem`, admin-side "Menu Builder") and always **additive** on top of sensible built-in defaults — adding a custom link was previously replacing every default link, which is why "additive, not replacing" matters here specifically.

### 2.9 Other conventions worth knowing

- Rich text editing uses an in-house `App\Filament\Forms\Components\CmsRichEditor` instead of a third-party TinyMCE-style plugin.
- Audit logging (`AuditLogResource`, the `Auditable` trait) and database backup/restore (`DatabaseBackup` page + `BackupManager`) are in-house rather than third-party, specifically to keep them tenant-isolated. Audit log entries have no actor for anything done outside an authenticated web request (console commands, tinker, queue jobs run without an authenticated guard) — that's expected, not a bug, and the Actor column shows "System" for those rows.
- An AI Assistant page exists (`App\Filament\Pages\AiAssistant`), gated by `TEMSEEEDU_AI_ENABLED` and a per-tenant monthly request quota.

---

## 3. Repository layout

The repo root **is** the Laravel application root (matches how it's actually deployed — see `plesk_deployment_guide.md`, which clones straight into the web root with document root `.../public`). There's no `app/` wrapper directory; `app/` here is Laravel's own `App\` namespace, same as any Laravel project.

- `app-modules/` — feature modules, see §2.3.
- `dist/` — **generated build artifact** (`scripts/build-school-edition.sh` output), git-ignored. Never hand-edit; edit the source and rebuild.
- Root-level `*.md` files (`TemseeEdu_Master_PRD.md`, `ONBOARDING_HANDBOOK.md`, `FILAMENT_PLUGIN_INTEGRATION.md`, `deployment_guide.md`, `plesk_deployment_guide.md`, this file) — product/ops reference docs, not code.

---

## 4. Installation & deployment

There are three ways to run the installer, all wrapping the same `App\Services\Installer::install()`:

| Path | When to use it |
|---|---|
| Web wizard, `/install` | The default entry point for School Edition — no shell access needed. Has a "Hosting Type" step: choosing cPanel shows the exact tenant-database name to pre-create and validates it exists before letting the install proceed. |
| `php artisan temsee:install` | SSH-friendly equivalent of the web wizard — same validation, every value can be passed as a flag for a fully scripted run (`--no-interaction-defaults`). |
| `php artisan temsee:cpanel-install-school` | The cPanel-specific path, for when the codebase is already deployed and you just need to (re)provision the tenant — clearer prerequisite errors than the general installer, since it's built around exactly the "database must already exist" constraint. |

**Shared cPanel hosting** cannot run `CREATE DATABASE` from the app — `hosting_mode = cpanel` disables that automatic step entirely (see §2.1), so **two MySQL databases must be created by hand in cPanel first**: a central one (any name) and a tenant one, named exactly `{TENANCY_DB_PREFIX}{tenant-id}{TENANCY_DB_SUFFIX}` (defaults to `tenant<tenant-id>` on a plain git clone; a package built by `build-school-edition.sh` ships with both blanked, so just `<tenant-id>` there — the web wizard computes and displays the exact name live either way). `php artisan temsee:cpanel-check --fix` verifies/repairs the rest of a cPanel deployment (file cache/session/queue drivers, writable directories, published Livewire/exception-renderer assets).

`./deploy.sh` is the all-in-one deploy/redeploy script — `--fresh` bootstraps a brand-new box (clone, dependencies, installer), the default mode redeploys an already-installed site idempotently (migrations, asset build, cache rebuild, cron setup). `--cpanel` switches on every cPanel-specific behavior, including forwarding `--hosting-mode=cpanel` into the installer when combined with `--fresh`.

See `deployment_guide.md` and `plesk_deployment_guide.md` for full step-by-step hosting instructions (Plesk-oriented; the cPanel path is covered in `deployment_guide.md` §6).

`scripts/build-school-edition.sh [version]` produces the distributable School Edition zip. `SKIP_ENCODING=1` gives a plain-copy local/demo QA build; without it, the script requires SourceGuardian's `sgenc` and is the real release path. Both paths exclude `storage/` (real tenant data has no business in a distributable package), stray root-level spreadsheet files, and leftover test-tenant SQLite files — then recreate the empty directory skeleton the app needs at boot.

---

## 5. Development

```bash
composer install && npm install
composer run dev       # app, queue listener, log tailer, Vite — all together
composer run test      # clears config cache first
vendor/bin/pint         # Laravel Pint, project style
vendor/bin/pint --dirty
```

Tests run against a real MySQL database (`temseeedu_testing`, see `phpunit.xml`/`.env.testing`), not SQLite — the test database must exist and be migrated first.

---

## 6. Security notes

- Tenant databases are fully isolated — one school's data is never queryable from another tenant's context.
- Every parent/student-facing controller that serves a specific record (receipts, report cards, exam slips, documents) must independently re-verify the record belongs to the authenticated parent's own children / the authenticated student themselves — route-model binding alone does **not** enforce that, and this exact gap has been the source of real bugs (a parent could previously view/pay another family's invoice by changing a URL).
- School Edition license verification is asymmetric-key-signed specifically so the verifying side (the customer's own server) never holds the ability to forge a token — there's deliberately no symmetric-key fallback for this reason.
- 2FA is enforced for the `admin` guard (Admin + Teacher panels) but not for parents/students.
