Files
classroomio/prd/mobile-app/README.md
T
967e81d33f docs(prd): learner-first mobile app PRD and interactive prototypes (#916)
* docs(prd): add learner-first mobile app PRD and interactive prototypes

Draft the ClassroomIO iOS/Android companion as a learner-first product
(attention home, compliance wallet, offline content, push on top of
prd/notification-system) with clickable HTML prototypes as the UX source
of truth.

Co-authored-by: Rotimi Best <[email protected]>

* style(prd): quiet mobile app prototype colours to match @cio/ui

Replace filled red/orange/green surfaces with the dashboard Badge and Alert recipes: 10% tint chips, card-background alerts, and default rows. Keep destructive fills only for actual destroy actions.

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
2026-08-18 12:02:35 +01:00

398 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ClassroomIO Mobile App PRD
## Status
- Draft (prototyped, ready for product review)
## Prototypes — the UX source of truth
**The UX of this feature must be taken from the prototype folder.** Interactive HTML prototypes for every screen live in [`prototypes/mobile-app/`](../../prototypes/mobile-app/) and are the approved design reference for layout, states (locked / overdue / offline / graded / empty), copy, and navigation flow. When this document and a prototype disagree on a UI detail, the prototype wins.
**Start here:**
```
prototypes/mobile-app/index.html
```
Open it in a browser (`open prototypes/mobile-app/index.html`) — it maps both journeys (learner → tutor) and every screen cross-links so you can click through each flow end to end. Each page has a light/dark toggle, renders inside a 390 × 844 device frame, and shows the alternate states of that screen side by side.
The prototypes are built on the real design tokens from `packages/ui/src/index.css` (OKLCH palette, Geist, `--radius: 0.625rem`) via `app-theme.css`, so the app reads as the same product as the dashboard.
| Surface | Files |
| --- | --- |
| Entry | `index.html` (screen map), `auth.html` |
| Learner — core | `home.html`, `learn.html`, `course.html`, `lesson.html`, `exercise.html` |
| Learner — compliance & proof | `compliance.html`, `certificates.html` |
| Learner — engagement | `community.html`, `notifications.html` |
| Learner — platform | `downloads.html`, `profile.html` |
| Tutor companion | `tutor-grading.html`, `tutor-attendance.html`, `tutor-course.html` |
## Purpose
Ship a native iOS and Android app that makes ClassroomIO usable by the people companies actually train — frontline staff, field technicians, distributed employees, partners, and customers — who often have no work laptop and unreliable connectivity. The app is a **learner-first companion**, not a port of the dashboard: it optimizes for "open the app → see what needs my attention → finish it," with a small tutor mode for grading and attendance on the go. Reference experiences: TalentLMS (offline company training), Canvas Student (learner productivity), Teachable (polished consumption).
## Problem Statement
- ClassroomIO is browser-only. A warehouse worker, retail associate, or field technician with no company laptop has no practical way to complete assigned compliance training.
- There is **no push channel**. Deadline, renewal, and assignment nudges exist only as email (`packages/email/src/emails/`), which is the wrong medium for staff who do not check work email daily. Overdue compliance is discovered by the admin, not prevented by the learner.
- There is **no offline path**. Lessons, video (HLS), and exercises all require a live connection, so training cannot happen on a plane, in a basement, on a shop floor, or in a low-coverage region.
- Mobile web is a poor substitute for the parts that matter: background audio, lock-screen video controls, camera/file submissions, biometric re-entry, and a home-screen icon that pulls a learner back in.
- Compliance status — the product's core value — is buried in a dashboard the learner rarely opens. There is no "am I compliant, what expires next, where is my certificate" surface in the learner's pocket.
- Competitors treat mobile as table stakes: Moodle, Canvas, Blackboard, TalentLMS, Thinkific, and Teachable all ship apps, and every one of them offers push notifications; all but Thinkific offer offline content.
## Confirmed Decisions
These were locked from the mobile brainstorm that preceded this PRD. Items marked **(product call)** were decided without an explicit answer and are the ones to veto first.
1. **One role-aware app, not two.** A single binary serves learners and tutors; the tab bar and course screens change by role. Canvas ships separate Student/Teacher apps, but ClassroomIO's tutor surface is far smaller (grade, attend, announce, moderate) and its orgs are smaller, so two store listings would be overhead without benefit. **(product call)**
2. **Learner scope ships first.** Tutor mode is a later release behind the same binary, not a v1 requirement.
3. **Authoring stays on the web.** No course/lesson/exercise creation, media manager, AI course builder, analytics configuration, billing, SSO setup, domains, or API keys on mobile. Admin-only surfaces are intentionally absent.
4. **Expo + React Native**, as a new `apps/mobile` workspace package. It shares `@cio/utils` validation and the Hono RPC types from `@cio/api/rpc-types`, so request/response shapes stay type-checked against the real API.
5. **No new BFF.** Mobile calls the existing API. Where mobile needs something the web does not (a home digest, a device token, a token-authenticated HLS URL), we add narrow endpoints to the existing routers rather than forking business logic.
6. **Auth is Better Auth via `@better-auth/expo`.** The repo already runs `better-auth@^1.4.18` (`packages/db/package.json`). The Expo client plugin stores the session cookie in `expo-secure-store` and handles OAuth deep links; the server adds the matching `expo()` plugin and the `classroomio://` scheme to `trustedOrigins` (`packages/db/src/auth.ts`). Email/password, Google, and enterprise SSO all work through it.
7. **Biometric lock is a re-entry gate, not an auth method.** Face ID / fingerprint unlocks an existing stored session; it never replaces the initial sign-in.
8. **Org context is explicit.** There are no subdomains on mobile, so the user picks an organization after sign-in and the app sends `cio-org-id` on every request, matching how the API authorizes today (`apps/api/src/middlewares/org-team-member.ts`).
9. **No purchasing in the app, in any storefront.** No course checkout, no plan upgrade, no AI token packs. Selling digital content in-app would trigger Apple's Guideline 3.1.1 in-app-purchase requirement outside the US storefront; ClassroomIO's B2B model (training is assigned, not bought by the learner) means we can sidestep IAP entirely. Paid self-serve enrollment stays on the web.
10. **Push notifications are a hard dependency on `prd/notification-system`.** That PRD builds the `notification` table and the `notify()` service; mobile adds a **push transport** (device token registry + Expo Push delivery) and a per-category preference screen on top of it. Mobile must not build a parallel notification system.
11. **Offline v1 is read-only content plus a queued write for completion.** Download lessons, video, audio, PDFs, and transcripts; mark-complete events queue locally and sync on reconnect. **Offline exercises and submissions are v2** — quiz integrity, grading, and conflict resolution need their own design (this is where Canvas and Blackboard also draw the line).
12. **Compliance is the differentiating home surface.** The app opens on what is due, overdue, expiring, or awaiting a retake, and the certificate wallet is a first-class tab-reachable screen — not a page buried under settings.
13. **Branding is dynamic, white-label is later.** v1 themes the app with the org's name, logo, and primary color at runtime (multi-org accounts re-theme on switch). Dedicated per-customer App Store listings are an enterprise follow-up, as Thinkific and TalentLMS both sell them.
14. **The app is cloud-first but self-host compatible.** The sign-in screen accepts a custom server URL so self-hosted deployments can point the same binary at their own API; single-org self-hosted installs skip the org picker.
## Current-State Audit
| Capability | Current state | Notes for mobile |
| --- | --- | --- |
| Native app | **None.** `apps/course-app` is a CLI generator for standalone SvelteKit course sites, not a client | Greenfield; no code to migrate |
| API surface | Hono app with ~24 mounted routers (`apps/api/src/app.ts`) | Reuse as-is; mobile is another client |
| Typed client | `hcWithType` + `InferRequestType`/`InferResponseType` (`apps/api/src/rpc-types.ts`) | Mobile imports the same types; no hand-written DTOs |
| Auth | Better Auth 1.4.18, cookie sessions, 30-day expiry, 1-hour cookie cache, `customSession` injecting `orgRoles` (`packages/db/src/auth.ts`) | Add `expo()` server plugin + scheme in `trustedOrigins`; `orgRoles` already rides the session so RBAC needs no extra call |
| Session endpoint | `GET /session` returns `{ session, user }` (`apps/api/src/app.ts`) | Cold-start hydration |
| Org RBAC | `cio-org-id` header → `orgRoles[orgId]`; ADMIN=1, TUTOR=2, STUDENT=3 (`packages/utils/src/constants/roles.ts`) | Drives the role-aware tab bar |
| Course delivery | `/course/:courseId/{content,section,lesson,exercise,submission,mark,attendance,newsfeed,members,compliance,ai-tutor,presign}` | Covers nearly all of the learner and tutor scope |
| Question types | 16 keys incl. `VIDEO_RECORDING`, `FILE_UPLOAD`, `HOTSPOT`, `MATCHING` (`packages/question-types/src/question-type-keys.ts`) | Each needs a touch renderer; camera/mic permissions required |
| Video | HLS proxied from R2, authorized by an **HMAC cookie** `classroomio_hls_<assetId>` **or** an org-role session (`apps/api/src/routes/hls/hls.ts`) | ⚠️ Native players can't carry per-asset cookies — needs a token-in-URL variant (see Technical Design) |
| Transcripts / media | `/transcripts`, `/media`, presigned uploads (`apps/api/src/routes/course/presign.ts`) | Presign is already the right shape for mobile uploads |
| Compliance | Deadlines, grace, renewals, waivers (`apps/api/src/routes/course/compliance.ts`; `prd/compliance-training-platform [DONE]`) | Data for the home surface exists; needs a learner-scoped aggregate |
| Certificates | `packages/certificates`, issuance + LMS certificates page | Wallet reads the same records |
| Community / newsfeed | `/community`, `/course/:id/newsfeed`, cohort newsfeed | Maps to the Community tab |
| Cohorts | `/cohort` with goals and deadlines | Surfaces as cohort cards + announcements |
| AI lesson tutor | `/course/:courseId/ai-tutor` with fair-use caps (`prd/ai-tutor-fair-use`) | Renders as a bottom sheet in the player |
| **Notifications** | ⚠️ **Not shipped.** `prd/notification-system` is todo (`PRD-TRACKER.md` rank 1); bell icon is a placeholder; only email exists | Hard dependency — see Decision 10 |
| **Offline** | ⚠️ None. No service worker, no sync queue | Built from scratch in the app |
| **Device tokens** | ⚠️ No table | New `device_token` table |
| Deployment modes | `PUBLIC_IS_SELFHOSTED` switches multi-org, licensing, billing (`FEATURE_AUDIT.md` §5) | App must handle both; see Decision 14 |
| Background jobs | BullMQ workers incl. a `notifications` worker (`apps/jobs`) | Push fan-out belongs here, not in the request path |
## Product Goals
1. A learner signs in (password, Google, or enterprise SSO), picks an org, and lands on a home screen that answers "what needs my attention today" in one glance.
2. A learner completes a full course on a phone: read lessons, watch video, take exercises, and get a certificate — without touching a browser.
3. A learner receives a push notification for an assignment, a deadline, a renewal, a grade, or a reply, and taps straight into the relevant screen.
4. A learner downloads a course, completes the lesson content on a plane, and sees their progress sync automatically on reconnect.
5. A learner can always answer "am I compliant, what expires next, where is my certificate" in two taps.
6. A tutor clears a grading queue, takes attendance, posts an announcement, and moderates a thread from a phone.
7. The web dashboard, self-hosted deployments, and existing API behavior are unchanged (zero regression).
## Non-Goals (v1)
- Course, lesson, or exercise **authoring**; media manager; AI course builder.
- Org administration: settings, team/roles, billing, SSO config, custom domains, API keys, widgets.
- **Any purchase flow** (see Decision 9) — no course checkout, plan upgrade, or AI token packs.
- **Offline exercises, quizzes, and submissions** (v2) — offline is read-only content plus queued completion.
- Live-class video conferencing inside the app (join links open the existing external provider).
- Direct messaging between users (ClassroomIO has no DM system today; community Q&A covers discussion).
- SCORM playback (`prd/scorm-support` is todo; not shipped on web either).
- Per-customer white-label App Store listings (enterprise follow-up, Decision 13).
- Tablet-optimized layouts, watch apps, widgets, offline video for public/unenrolled catalog courses.
---
## Functional Requirements
**Information architecture.** Five tabs, role-aware: learners get **Home · Learn · Compliance · Community · Profile**; ADMIN and TUTOR get **Teach** in place of Compliance. Notifications live behind the bell in the nav bar, and Certificates and Downloads are reached from Compliance and Profile.
### 1. Onboarding and authentication — `auth.html`
- **Sign-in**: email + password, "Continue with Google", and "Sign in with SSO" (email-domain discovery via `/sso`). Google and SSO open the system browser and return through the `classroomio://` deep link.
- **Self-hosted**: a "Use a custom server" affordance accepts an API base URL, validated before the credential step.
- **Org picker**: shown after sign-in when the account belongs to more than one org; each row shows the org logo, name, and role. Single-org accounts (including all self-hosted installs) skip it.
- **Biometric**: after the first successful sign-in the app offers Face ID / fingerprint for subsequent launches; declining falls back to the stored session, and a remote sign-out invalidates it.
- **States to build**: idle, invalid credentials, SSO-required-for-this-domain, offline (no network at launch → "you're offline, showing downloaded content" with a path to Downloads), and session-expired re-auth.
- Invitation and password-reset deep links open the app when installed and the web flow otherwise.
### 2. Home — `home.html`
The attention surface. Ordered by urgency, not by recency:
1. **Compliance alert band** — only when something is overdue or inside its grace period; red for overdue, amber for due soon; tapping opens Compliance.
2. **Continue learning** — the single most recent in-progress lesson with a thumbnail, course name, position ("Lesson 4 of 12"), and a progress bar.
3. **Due soon** — deadline-ordered list of exercises and required courses with relative dates ("Due in 2 days").
4. **Newly assigned** — courses assigned since the last open, with an enroll/start affordance.
5. **Cohort announcements** — latest newsfeed items from the learner's cohorts and courses.
6. **Recent feedback** — graded submissions with score and tutor comment preview.
States: fully-caught-up empty state ("Nothing due — you're all caught up"), first-run empty state (no enrollments), and offline state (cached digest with an "Offline — last updated 2h ago" chip).
### 3. Learn — `learn.html`, `course.html`
- **My Learning**: segmented control for In progress / Not started / Completed; rows carry a progress ring, course type chip (Compliance / Cohort / Self-paced), and a download indicator.
- **Explore**: the org's public catalog, enroll-in-place for free/assigned courses; no paid checkout (Decision 9).
- **Cohorts**: cohort cards with goal progress and deadline.
- **Course detail** (`course.html`): header with progress, then the section/lesson outline. Every lesson row shows its state — complete, current, locked (with the reason: "Complete Lesson 3 first"), or downloaded. Compliance courses show a deadline banner. Tabs: Content, Newsfeed, Community, Certificate, and — for compliance courses — Compliance.
- Locked content is enforced by the API, not just hidden (`apps/dashboard/src/lib/features/course/utils/content-lock-utils.ts` is the web equivalent).
### 4. Lesson player — `lesson.html`
- **Video**: HLS playback with captions, playback speed, AirPlay/Chromecast, picture-in-picture, background audio, and lock-screen controls. Position is remembered per lesson and synced across devices.
- **Text, slides, and documents** rendered natively; multilingual lessons respect the learner's chosen language (`lesson-language.ts`).
- **Transcript** in a bottom sheet, tap-to-seek where timestamps exist.
- **AI Lesson Tutor** in a bottom sheet, scoped to the current lesson, showing the fair-use cap state when exhausted (`prd/ai-tutor-fair-use`).
- **Mark complete** advances to the next item; when offline the action is queued and the row shows a "will sync" indicator.
- States: online, downloaded/offline, locked, video-unavailable-offline, and tutor-cap-reached.
### 5. Exercises — `exercise.html`
- Touch renderers for all 16 question types in `packages/question-types`, including `MATCHING` (drag), `ORDERING` (drag), `HOTSPOT` (tap target), `FILE_UPLOAD` (files/photo library/document scan), and `VIDEO_RECORDING` (in-app camera).
- Answers autosave locally as the learner works so a call or app switch never loses progress.
- Submission uses the existing presigned upload path for media (`apps/api/src/routes/course/presign.ts`) with a visible upload progress state and retry.
- Result view: score, per-question correctness where allowed, rubric, tutor feedback, and resubmit when the exercise permits it.
- States: unattempted, in progress, submitted/awaiting grade, graded/passed, graded/failed with retake, and **connection-required** (an explicit blocking state, since exercises are online-only in v1).
### 6. Compliance — `compliance.html`
- Status header: Compliant / Due soon / Overdue / In grace, with the count of requirements behind it.
- Per-requirement rows: course, status, deadline or renewal date, grace-period note, waiver note where one applies, and last completion date.
- Renewal countdown for certifications approaching expiry.
- A "Proof" action jumping to the matching certificate.
- States: all-clear, overdue, in-grace, waived, and no-requirements.
### 7. Certificates — `certificates.html`
- Wallet list of issued certificates with course, issue date, certificate ID, and expiry when applicable.
- Detail view: rendered certificate, share sheet (PDF), save to device, and a verification QR/link.
- Downloaded certificates remain viewable offline.
- States: populated, empty ("Finish a course to earn your first certificate"), and expiring-soon.
### 8. Community — `community.html`
- Org and course Q&A: ask, answer, upvote, and follow threads; course and cohort newsfeed posts with comments.
- A "For you" filter for mentions, replies, and followed threads.
- Compose supports text and image attachments.
- Author-or-team edit/delete rules mirror the API guards (`apps/api/src/routes/community/middlewares/`).
- States: populated, empty, offline (read cached, compose disabled with an explanatory chip).
### 9. Notifications — `notifications.html`
- **Center**: grouped Today / Earlier, unread dots, deep-link on tap, mark-all-read.
- **Categories** (each independently toggleable, push and in-app): new assignment, deadline reminder, overdue, renewal due, new lesson/newsfeed post, exercise graded, community mention or reply, certificate issued, cohort goal approaching.
- **Preferences**: per-category switches plus quiet hours; changes write through to the same preference store the web uses (`prd/email-notification-preferences`).
- The first launch asks for the OS notification permission with an explanatory pre-prompt, not a cold system dialog.
### 10. Downloads and offline — `downloads.html`
- Download a whole course or an individual lesson; per-item progress and cancel.
- Storage manager: total used, per-course breakdown, delete individual or all.
- Settings: Wi-Fi-only downloads (default on), auto-download newly assigned required training (default off).
- **Sync queue** panel: pending completion events with their state (queued / syncing / synced / failed-retry), and a manual "Sync now".
- A persistent offline banner appears app-wide when the device is offline.
- Content that cannot work offline (exercises, live classes, AI tutor, community compose) is visibly marked rather than silently broken — the failure mode TalentLMS solved with an authoring-side compatibility check.
### 11. Profile and settings — `profile.html`
- Profile summary, learning stats, and organization switcher (re-themes the app).
- Links to Certificates, Downloads, Notification preferences.
- App settings: language, theme (system/light/dark), video quality, biometric lock toggle.
- Account: change password (web link), sign out, delete account (web link).
### 12. Tutor mode — `tutor-grading.html`, `tutor-attendance.html`, `tutor-course.html`
Visible only when `orgRoles[activeOrg]` is ADMIN or TUTOR. Staff get **Teach** in place of the learner Compliance tab; their own compliance stays reachable from the Home alert band and Profile.
- **Grading queue**: submissions awaiting grading across courses, oldest first, with course/exercise/learner and waiting time. Review shows the learner's answers (including video/file submissions), the rubric, score entry, written or recorded feedback, and approve / request-resubmission.
- **Attendance**: roster for a live-class session with present/absent/late toggles, bulk actions, and a code or QR check-in.
- **Course snapshot**: enrollment, completion rate, overdue count; post an announcement; moderate community/newsfeed posts (hide, delete, mark answered).
- Explicitly absent: authoring, settings, people management beyond invite, analytics configuration.
### 13. Cross-cutting requirements
- **Accessibility**: full screen-reader labels, Dynamic Type, minimum 44 pt touch targets, and no color-only status encoding (every status carries an icon and text). Canvas advertises VPAT 2.2 conformance; we should not ship below that bar.
- **Localization**: the app reads the same translation catalog the dashboard uses (`apps/dashboard/src/lib/utils/translations/`), covering the 10+ supported languages; no hardcoded strings.
- **Frontline mode**: large-target layout, low-bandwidth media defaults, and QR-code course launch, aimed at shared or shop-floor devices.
- **Security**: session in `expo-secure-store`, biometric re-lock, no analytics of lesson content, and remote sign-out honored on next foreground.
---
## Technical Design
### Workspace
```
apps/mobile/ # @cio/mobile — Expo (React Native)
app/ # expo-router routes, grouped by tab
src/features/<domain>/ # mirrors the dashboard's feature folders
api/ # typed calls via @cio/api/rpc-types
components/
utils/types.ts # request/response types inferred from the API
src/lib/{auth,offline,push,i18n}/
```
`apps/mobile` joins the existing `apps/*` workspace glob (`pnpm-workspace.yaml`). It depends on `@cio/utils` (validation, constants, roles) and `@cio/api` (types only). It must **not** depend on `@cio/ui` (Svelte) or `@cio/db`.
### Data model additions (`packages/db/src/schema.ts`)
```
device_token
id uuid PK · profileId FK(profile, cascade) · token varchar NOT NULL
platform DEVICE_PLATFORM ('ios' | 'android')
provider PUSH_PROVIDER ('expo') -- room for direct FCM/APNs later
appVersion varchar · deviceName varchar · locale varchar
lastSeenAt timestamptz · disabledAt timestamptz nullable
createdAt / updatedAt
unique(token) · index(profileId) · index(profileId, disabledAt)
notification_preference -- extends prd/notification-system
id uuid PK · profileId FK(profile, cascade)
category NOTIFICATION_CATEGORY -- reuses the notification type enum
push boolean default true · inApp boolean default true · email boolean default true
unique(profileId, category)
profile_quiet_hours
profileId FK(profile) PK · startMinute smallint · endMinute smallint · timezone varchar
```
Notes, per repo rules: no relational IDs inside `jsonb`; nothing computed is stored. Download state, sync queue, and playback position live **on the device** (SQLite), not in Postgres — the server keeps only the authoritative completion records it already has. Last-read playback position reuses existing lesson progress rather than a new column.
### Video: token-authenticated HLS
Today `/hls/:assetId/*` authorizes on an HMAC cookie `classroomio_hls_<assetId>` **or** an org-role session (`apps/api/src/routes/hls/hls.ts`). A native player (AVPlayer / ExoPlayer) fetches manifests and segments outside the app's HTTP client, so neither mechanism is reliable on mobile.
```
GET /hls/:assetId/playback-token [authMiddleware + asset org membership]
→ { url: "/hls/<assetId>/master.m3u8?t=<hmac>", expiresIn: 3600 }
hlsRouter authorization order becomes:
1. query param `t` → verifyHlsToken (NEW — same HMAC, same claims)
2. cookie → verifyHlsToken (unchanged, web)
3. session orgRoles → membership check (unchanged)
```
The token is the existing HMAC payload; only the transport is new, so web behavior is untouched. Offline downloads fetch the segment set once and store it locally; expiry is enforced at download time, not playback time, so a downloaded lesson keeps working on a plane.
### Push delivery
Push rides on `prd/notification-system` — mobile adds a transport, not a second notification pipeline.
```
notify(...) # existing service (notification-system PRD)
├─ INSERT notification # in-app center
├─ enqueue email # existing
└─ enqueue push (NEW)
└─ apps/jobs notifications worker:
tokens = active device_token rows for profileId
skip if preference.push = false for category
skip / defer if inside quiet hours
chunk ≤100 → Expo Push API (expo-server-sdk)
on DeviceNotRegistered → set device_token.disabledAt
```
Payload carries `{ type, entityId, deepLink }` so the app can route without a fetch. Expo Push abstracts APNs and FCM behind one endpoint; the `provider` column leaves room to move to direct FCM/APNs if enterprise requirements demand it.
### Offline architecture
| Concern | Approach |
| --- | --- |
| Content cache | SQLite (`expo-sqlite`) for lesson/section/course metadata; file system for video segments, audio, PDFs, images |
| Download unit | Course or lesson; queued, resumable, cancellable; Wi-Fi-only by default |
| Write queue | Append-only local table of intents (`lesson.complete`, `playback.position`), each with an idempotency key |
| Sync | On reconnect and on foreground: replay queue oldest-first; `409`/duplicate responses are treated as success |
| Conflict rule | Completion is monotonic — once complete, always complete; the server value wins for everything else |
| Eviction | Manual delete only in v1 (no silent eviction of content a learner deliberately downloaded) |
Because v1 queues only completion and playback position — never grades or submissions — there is no merge conflict class that can corrupt an assessment record. That is exactly why offline exercises are deferred.
### API additions
All additions follow the repo layering (validation → queries → services → routes) and mount under existing root segments.
| Method | Path | Auth | Purpose |
| --- | --- | --- | --- |
| GET | `/account/home-digest` | auth + org | One call for the Home screen: continue-learning, due-soon, newly-assigned, announcements, feedback, compliance summary |
| GET | `/account/compliance` | auth + org | Learner-scoped compliance across courses (today's data is per-course) |
| POST | `/account/device-tokens` | auth | Register/refresh a push token |
| DELETE | `/account/device-tokens/:token` | auth | Deregister on sign-out |
| GET | `/account/notification-preferences` | auth | Category matrix + quiet hours |
| PUT | `/account/notification-preferences` | auth | Update |
| GET | `/course/:courseId/offline-manifest` | course member | Everything needed to download a course in one call: lessons, media URLs, sizes, checksums |
| POST | `/course/:courseId/sync` | course member | Batch-replay queued completion intents idempotently |
| GET | `/hls/:assetId/playback-token` | auth + asset org | Token-authenticated playback (above) |
| GET | `/account/grading-queue` | org team member | Tutor: submissions awaiting grading across courses |
`/account/home-digest` exists to avoid the 6–8 round trips a cold mobile launch would otherwise need on a slow connection; it is a read-only composition of existing services, not new business logic.
### Client plan
- **Auth**: `@better-auth/expo` client with `scheme: 'classroomio'` and `expo-secure-store`; server adds the `expo()` plugin and the scheme to `trustedOrigins` in `packages/db/src/auth.ts`. `orgRoles` already arrives on the session via `customSession`, so the role-aware tab bar needs no extra request.
- **Types**: every request/response type is inferred from `@cio/api/rpc-types` into `features/<domain>/utils/types.ts` — the same rule the dashboard follows. No hand-maintained mobile DTOs, and no importing from `@cio/db`.
- **Validation**: reuse the Zod schemas in `packages/utils/src/validation/`.
- **State**: TanStack Query with a SQLite-backed persister so cached screens render instantly offline.
- **i18n**: load the dashboard translation catalog; no literal user-facing strings in components.
### Build verification
```bash
export PATH="$HOME/.nvm/versions/node/v20.19.3/bin:$PATH"
pnpm --filter @cio/utils build && pnpm --filter @cio/db build && pnpm --filter @cio/api build
pnpm --filter @cio/mobile typecheck && pnpm --filter @cio/mobile test
pnpm --filter @cio/dashboard^... build && pnpm --filter @cio/dashboard build # regression
pnpm format:check
```
---
## Implementation Order
1. **Unblock notifications.** Ship `prd/notification-system` (table, `notify()`, in-app center). Mobile push has no foundation without it.
2. **Backend for mobile.** `device_token` + preference tables, push transport in the `apps/jobs` notifications worker, `/account/home-digest`, `/account/compliance`, HLS playback token, offline manifest, sync endpoint.
3. **App skeleton.** `apps/mobile` Expo project, auth (password → Google → SSO), org picker, role-aware tab bar, theming from org branding, i18n.
4. **Consumption path.** My Learning → course detail → lesson player (video, text, transcript) → mark complete → certificate. This is the first genuinely useful build.
5. **Assessment path.** All 16 question types, autosave, presigned media upload, results and resubmission.
6. **Compliance and certificates.** Home compliance band, compliance screen, certificate wallet with share/verify.
7. **Push end to end.** Token registration, categories, quiet hours, deep links from every notification type.
8. **Offline.** Download manager, storage settings, write queue, sync-on-reconnect, offline banners and blocked-state messaging.
9. **Engagement.** Community, newsfeed, cohorts, AI lesson tutor sheet.
10. **Tutor mode.** Grading queue, attendance, announcements, moderation.
11. **Store readiness.** Accessibility audit, App Store / Play listings, privacy nutrition labels, data-safety form, beta via TestFlight and Play internal testing.
Releases: 3–6 = Release 1 (learner core), 7–9 = Release 2 (push, offline, engagement), 10 = Release 3 (tutor), then white-label and microlearning.
## Acceptance Criteria
1. A learner can sign in with email/password, Google, and enterprise SSO; multi-org accounts choose an org and every request carries `cio-org-id`.
2. A self-hosted user can point the app at a custom API base URL and sign in; the single-org install skips the org picker.
3. Home shows compliance alerts, continue-learning, due-soon, newly-assigned, announcements, and feedback in one request, and renders a cached version offline with an explicit staleness chip.
4. A learner can complete an entire course on a phone — lessons, video, all 16 exercise question types, and certificate issuance — with progress matching the web dashboard exactly.
5. Video plays via HLS with captions, background audio, lock-screen controls, and PiP; the playback token authorizes a native player, and the existing cookie and session paths still work on web (regression check).
6. Push notifications arrive for every category in §9, respect per-category preferences and quiet hours, and deep-link to the right screen; disabling a category stops both push and in-app for that category.
7. A learner downloads a course, airplane-mode completes its lesson content, and every completion appears on the web dashboard after reconnect, with no duplicates on retry.
8. Exercises, AI tutor, and community compose show explicit "connection required" states offline rather than failing silently.
9. Compliance status, renewal dates, grace periods, and waivers match the web compliance view for the same learner.
10. A tutor sees the Teach tab only with ADMIN or TUTOR in the active org, and can grade a submission, take attendance, post an announcement, and moderate a post.
11. A student-role user has no path to authoring, org settings, billing, or admin data anywhere in the app; the API rejects such calls independently of the UI.
12. No purchase, upgrade, or checkout affordance exists anywhere in the app on any storefront.
13. Accessibility: screen-reader labels on all interactive elements, Dynamic Type honored, 44 pt minimum targets, no color-only status.
14. All user-facing copy comes from translation keys; the app runs in every language the dashboard supports.
15. Zero regression: dashboard, API, and self-hosted deployments behave exactly as before; all builds and `pnpm format:check` pass.
## Risks and Mitigations
- **Notification-system dependency slips.** Mobile's headline retention feature has no foundation. *Mitigation*: sequence it first (Implementation Order 1); if it slips, Release 1 ships without push and the app remains useful for consumption, but do not build a parallel notification path.
- **HLS auth on native players.** Cookie-based gating silently 403s in AVPlayer/ExoPlayer, which is easy to misdiagnose as a media bug. *Mitigation*: the playback-token endpoint is scheduled in Implementation Order 2, before any player work, and the web cookie path is left untouched.
- **Offline scope creep into assessments.** Offline quizzes bring conflict resolution, integrity, and grading-race problems. *Mitigation*: v1 queues only monotonic completion and playback position (Decision 11); revisit with its own PRD.
- **App Store rejection under Guideline 3.1.1.** Any in-app path to buying a course or plan triggers IAP obligations outside the US storefront. *Mitigation*: no purchase affordance at all (Decision 9), enforced by acceptance criterion 12; revisit only with a deliberate IAP design.
- **16 question types is a large surface.** Drag, hotspot, camera, and file types each carry native complexity. *Mitigation*: ship the legacy trio (`RADIO`, `CHECKBOX`, `TEXTAREA`) plus the common objective types first, gate uncommon types behind a "open in browser" fallback until their renderer lands, and never let an unsupported type silently render blank.
- **Storage exhaustion on low-end devices.** Downloaded video can fill a cheap Android phone. *Mitigation*: pre-download size estimate, a visible storage manager, Wi-Fi-only default, and a device-space check before each download.
- **Two clients drifting.** Mobile and web can diverge into different business rules. *Mitigation*: shared Zod validation, types inferred from the live API, and no mobile-only business logic — new capability lands in the API and both clients consume it.
- **Push cost and deliverability at scale.** Expo Push is a third-party hop in the delivery chain. *Mitigation*: the `provider` column and a transport-shaped worker make a move to direct FCM/APNs a swap rather than a rewrite.
- **Self-hosted push.** Self-hosters cannot use our Expo credentials. *Mitigation*: push degrades gracefully to in-app plus email when unconfigured — the same fallback pattern `prd/notification-system` uses for Trigger.dev.