# 08 · SchoolEye Live — Onboarding Guide (Operator, School Admin, Parent)

A step-by-step, "OS-installer" style manual. Follow the parts in order. Each step says
**what to click, what to type, and what to press to save**. Every part ends with an
**Errors & fixes** table drawn from real installs.

> **Screenshots.** Where you see `📷 SCREENSHOT [name]` capture that screen and save it as
> `doc/screenshots/<name>.png`. The caption under it lists exactly what must be visible in
> the frame. Screens marked *(optional)* help but are not required.

---

## Who does what

| Role | Person | Does |
|------|--------|------|
| **Operator** | SchoolEye team (you) | Stands up the server, creates the school record, creates the school's first **Super Admin**, hands over the login. Part A. |
| **School Admin** | Principal / Vice-Principal | Configures the school: profile, class-sections, staff logins, rooms, cameras, roster, schedule. Parts B–D. |
| **On-site installer** | CCTV vendor or school IT (with the Admin on a call) | Installs the SchoolEye Agent on one always-on PC, pairs it, maps cameras. Part C. |
| **Parent** | Parent / guardian | Uses the parent site to watch. Part E. No install — it's a website. |

```
OPERATOR ─ Part A ─▶ hands Super Admin login to the school
                         │
SCHOOL ADMIN ─ Part B ─▶ school profile + class-sections + staff logins
             ─ Part C ─▶ agent install + camera → room mapping   (with on-site installer)
             ─ Part D ─▶ roster import + approvals + schedule + enable rooms
                         │
PARENT ─ Part E ─▶ pick school → identify → OTP → watch
```

---
---

# PART A · Operator — provision a school on the server

**Prerequisite:** a Linux VPS reachable on a public IP, a domain you control, SSH root access.
This part was done once for `baratlegal.com` on `147.93.45.158`; repeat it per environment.
The full server build (Docker/Postgres/Redis/MediaMTX) is in `02-server-setup.md` — this
section is the **app + first-admin** layer that sits on top.

## A0. Layout on the server

```
/opt/schooleye/
├── web-parent/     Next.js parent site   → listens on 127.0.0.1:3000
├── web-admin/      Next.js admin site    → listens on 127.0.0.1:3002
├── shared-libs/    imported by both
└── db/migrations/  0001_init.sql, 0002_parent_prefs.sql, 0003_support_ticket.sql
```

Two `systemd` units run them: `schooleye-parent.service`, `schooleye-admin.service`
(`node server.mjs` each). Nginx terminates TLS and reverse-proxies:

| Public path | Upstream |
|-------------|----------|
| `https://DOMAIN/`        | `127.0.0.1:3000` (parent) |
| `https://DOMAIN/admin/`  | `127.0.0.1:3002/admin/` (admin) |

## A1. Point DNS at the VPS

In your DNS host (Hostinger, Cloudflare, …), for the apex domain:

1. **Delete** any `ALIAS` or `CNAME` record on the root name and on `www`.
   The apex can hold **A records only** — an `ALIAS`+`A` or `CNAME`+`A` pair on the same
   name is rejected (see A-errors #1).
2. **Add** two `A` records:

   | Type | Name | Value | TTL |
   |------|------|-------|-----|
   | A | `@`   | `<VPS_IP>` | 3600 |
   | A | `www` | `<VPS_IP>` | 3600 |

3. Save. Wait 3–10 min, then verify from anywhere:

   ```bash
   nslookup DOMAIN            # must return <VPS_IP>
   ```

📷 SCREENSHOT [a1-dns-records] — *the DNS table showing both A records = VPS IP, and no ALIAS/CNAME on `@` or `www`.*

## A2. Get the code onto the server

```bash
ssh root@<VPS_IP>
mkdir -p /opt/schooleye && cd /opt/schooleye
git clone https://github.com/<owner>/<repo>.git .      # or rsync your build up
# result: /opt/schooleye/web-parent , /opt/schooleye/web-admin , /opt/schooleye/shared-libs
```

## A3. Environment files

Create `/opt/schooleye/web-parent/.env.local` **and** `/opt/schooleye/web-admin/.env.local`
with the **same** values (both apps share one database, one Redis, one secret set):

```ini
APP_BASE_URL=https://DOMAIN
NODE_ENV=production

# 32-byte base64 each — generate with: openssl rand -base64 32
SESSION_SECRET=__generate__
PAIRING_SECRET=__generate__
PLAYBACK_TOKEN_SECRET=__generate__

# In-app "Help & setup guide" footer link — point it at wherever THIS guide is
# published for your deployment (internal wiki, GitHub blob URL, a hosted copy).
NEXT_PUBLIC_HELP_URL=https://help.DOMAIN

# NOTE: keep this password in sync with the real Postgres role password (A-errors #2).
# Avoid @ : / in the password — they break the URL. Use letters+digits only.
DATABASE_URL=postgresql://schooleye:SchoolEye123@127.0.0.1:5432/schooleye
REDIS_URL=redis://127.0.0.1:6379

MEDIAMTX_API=http://127.0.0.1:9997
MEDIAMTX_HLS_BASE=https://live.DOMAIN
MEDIAMTX_SRT_HOST=<VPS_IP>
MEDIAMTX_SRT_PORT=8890

# Optional for a POC — OTP falls back to server logs if unset:
RESEND_API_KEY=
EMAIL_FROM=SchoolEye <no-reply@DOMAIN>
MSG91_AUTH_KEY=
MSG91_SENDER_ID=SCHEYE
```

> **Always use `127.0.0.1`, never `localhost`.** On most VPS images `localhost` resolves to
> the IPv6 `::1` first, but Postgres/Redis/Node bind IPv4 only — you get
> `connection refused` / `password authentication failed` that looks like a credentials bug
> but isn't (A-errors #3).

## A4. Create the Postgres role and database

```bash
sudo -u postgres psql <<'SQL'
CREATE ROLE schooleye LOGIN PASSWORD 'SchoolEye123';
CREATE DATABASE schooleye OWNER schooleye;
SQL

# if you later see "peer authentication failed", switch local auth to md5:
#   edit /etc/postgresql/*/main/pg_hba.conf →  local  all  all  md5
#   sudo systemctl restart postgresql

# verify the exact string from .env.local works:
psql "postgresql://schooleye:SchoolEye123@127.0.0.1:5432/schooleye" -c '\conninfo'
```

## A5. Run migrations (creates every table)

```bash
cd /opt/schooleye/db/migrations
for f in 0001_init.sql 0002_parent_prefs.sql 0003_support_ticket.sql; do
  echo "== $f =="
  psql "postgresql://schooleye:SchoolEye123@127.0.0.1:5432/schooleye" -f "$f"
done

# confirm:
psql "postgresql://schooleye:SchoolEye123@127.0.0.1:5432/schooleye" -c '\dt' | grep -E 'school|admin_user|room|camera|roster_entry'
```

If any table is missing here, **stop** — every later step will fail with
`relation "…" does not exist`.

## A6. Build both apps

```bash
# next.config.mjs must NOT contain  output: 'standalone'  — server.mjs is incompatible
# with it and you get 404 + "MIME type text/plain" on every /_next/ chunk (A-errors #7).
sed -i "/output: 'standalone'/d" /opt/schooleye/web-parent/next.config.mjs
sed -i "/output: 'standalone'/d" /opt/schooleye/web-admin/next.config.mjs

# the admin app is served under /admin/ — two settings are required in
# web-admin/next.config.mjs (A-errors #8 and #9):
#   assetPrefix: '/admin',            → assets are requested at /admin/_next/... not the root
#   skipTrailingSlashRedirect: true,  → Nginx owns /admin <-> /admin/; without this Next
#                                        also redirects and the two loop forever
grep -q "assetPrefix" /opt/schooleye/web-admin/next.config.mjs || \
  sed -i "s/const nextConfig = {/const nextConfig = {\n  assetPrefix: '\/admin',/" \
    /opt/schooleye/web-admin/next.config.mjs
grep -q "skipTrailingSlashRedirect" /opt/schooleye/web-admin/next.config.mjs || \
  sed -i "s/assetPrefix: '\/admin',/assetPrefix: '\/admin',\n  skipTrailingSlashRedirect: true,/" \
    /opt/schooleye/web-admin/next.config.mjs

cd /opt/schooleye/web-parent && npm ci && npm run build
cd /opt/schooleye/web-admin  && npm ci && npm run build
```

## A7. systemd units

`/etc/systemd/system/schooleye-parent.service`

```ini
[Unit]
Description=SchoolEye Live - Parent App
After=network.target postgresql.service redis-server.service

[Service]
Type=simple
WorkingDirectory=/opt/schooleye/web-parent
Environment=NODE_ENV=production
Environment=PORT=3000
ExecStart=/usr/bin/node server.mjs
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target
```

`/etc/systemd/system/schooleye-admin.service` — identical, but
`WorkingDirectory=/opt/schooleye/web-admin` and `Environment=PORT=3002`.

```bash
sudo systemctl daemon-reload
sudo systemctl enable --now schooleye-parent schooleye-admin
curl -s http://127.0.0.1:3000/api/healthz   # {"ok":true,"db":true,"redis":true}
curl -s http://127.0.0.1:3002/api/healthz   # {"ok":true,"db":true,"redis":true}
```

## A8. Nginx reverse proxy

`/etc/nginx/sites-available/schooleye`

```nginx
server {
    listen 80;
    server_name DOMAIN www.DOMAIN;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }

    # bare /admin -> one clean redirect to /admin/ (Next no longer bounces it back,
    # because web-admin has skipTrailingSlashRedirect: true)
    location = /admin { return 308 /admin/; }

    location /admin/ {
        proxy_pass http://127.0.0.1:3002/admin/;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
```

```bash
sudo ln -sf /etc/nginx/sites-available/schooleye /etc/nginx/sites-enabled/schooleye
sudo rm -f /etc/nginx/sites-enabled/default          # remove any stale/other symlinks
sudo nginx -t && sudo systemctl reload nginx
curl -s http://DOMAIN/api/healthz                    # works once DNS (A1) has propagated
```

## A9. TLS (HTTPS)

```bash
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d DOMAIN -d www.DOMAIN
#   Redirect HTTP→HTTPS? choose  2 (Yes)
sudo ss -tlnp | grep ':443'                          # nginx must be listening
curl -s https://DOMAIN/api/healthz
```

> The **admin session cookie is `Secure`** — it is only sent over HTTPS. On plain `http://`
> you log in, then bounce straight back to the login screen (A-errors #12). HTTPS is not
> optional even for a POC.

## A10. Create the school + its first Super Admin

The database is empty. This script inserts one `school` row and one `admin_user` with a
password hash (argon2id, matching the app) and **no** TOTP yet — the admin enrols TOTP on
first login.

```bash
cd /opt/schooleye/web-admin       # run from here so @node-rs/argon2 resolves
npm i pg uuid --no-save           # only if not already present

node <<'EOJS'
import { hash as argon2 } from '@node-rs/argon2';
import pkg from 'pg'; const { Client } = pkg;
import { randomUUID } from 'node:crypto';

const SCHOOL_NAME = 'Green Valley School';
const SCHOOL_CITY = 'Chennai';
const ADMIN_NAME  = 'Mr. Sharma';
const ADMIN_EMAIL = 'principal@greenvalley.edu';
const ADMIN_PASS  = 'ChangeMe#2026Now';           // policy: 12+ chars, upper+lower+digit+symbol, not the name/email

const c = new Client({ connectionString: 'postgresql://schooleye:SchoolEye123@127.0.0.1:5432/schooleye' });
await c.connect();

const schoolId = randomUUID();
await c.query(
  `INSERT INTO school (id, name, city) VALUES ($1,$2,$3)`,
  [schoolId, SCHOOL_NAME, SCHOOL_CITY],
);

const pwHash = await argon2(ADMIN_PASS, { memoryCost: 19456, timeCost: 2, parallelism: 1 });
await c.query(
  `INSERT INTO admin_user (id, school_id, email, name, password_hash, role, status)
   VALUES ($1,$2,$3,$4,$5,'super_admin','active')`,
  [randomUUID(), schoolId, ADMIN_EMAIL, ADMIN_NAME, pwHash],
);

await c.end();
console.log('School:', schoolId, '\nLogin :', ADMIN_EMAIL, '/', ADMIN_PASS);
EOJS
```

**Hand-off to the school:** the URL `https://DOMAIN/admin/login`, the email, and the
temporary password. Tell them to bring a phone with an authenticator app (Google
Authenticator, Authy, Microsoft Authenticator) to first login.

## A · Errors & fixes

| # | Symptom | Cause | Fix |
|---|---------|-------|-----|
| 1 | DNS host rejects the record: *"RRset … ALIAS must not be used with A"* or *"CNAME must not be used with any other type on the same name"* | The apex/`www` already has an `ALIAS` or `CNAME`; you can't add an `A` beside it | **Delete** the `ALIAS`/`CNAME` first, wait 30 s, then add the `A` record |
| 2 | App logs: `password authentication failed for user "schooleye"` even though the DB is up | `DATABASE_URL` password ≠ the real Postgres role password | Reset one to match: `sudo -u postgres psql -c "ALTER USER schooleye WITH PASSWORD 'SchoolEye123';"` then fix both `.env.local` files, `systemctl restart schooleye-parent schooleye-admin` |
| 3 | `connection refused` / auth fails only from the app, but `psql` on the box works | `.env.local` says `localhost` → resolves to IPv6 `::1`; Postgres/Redis listen on IPv4 `127.0.0.1` | Use `127.0.0.1` everywhere in `.env.local` and in Nginx `proxy_pass` |
| 4 | `psql: Peer authentication failed` | `pg_hba.conf` has `local … peer` | Change the `local all all` line to `md5`, `systemctl restart postgresql`; or connect via `-h 127.0.0.1` |
| 5 | `relation "school" does not exist` at runtime | Migrations never ran (or ran against a different DB) | Re-run A5 against the exact `DATABASE_URL`; confirm with `\dt` |
| 6 | `error: column "city" of relation "school" does not exist` in the create-admin script | Script `INSERT` lists columns your schema doesn't have | Use the A10 script as written — it inserts only `id,name,city` for `school` |
| 7 | Browser: every `/_next/static/chunks/*.js` is **404** and *"Refused to execute script … MIME type ('text/plain')"* | `next.config.mjs` has `output: 'standalone'`; `server.mjs` can't serve those assets | Delete that line, `npm run build`, restart the service |
| 8 | Admin page loads HTML but all JS/CSS 404 at `https://DOMAIN/_next/...` (no `/admin` prefix) | `web-admin` doesn't know it's mounted under `/admin/` | Add `assetPrefix: '/admin'` to `web-admin/next.config.mjs`, rebuild, restart |
| 9 | `https://DOMAIN/admin` (or clicking **Dashboard**) → `ERR_TOO_MANY_REDIRECTS`; Network tab shows `/admin/` **308** ↔ `/admin` **301** | The admin app's routes live at `app/admin/*`, so it serves `/admin/…` from its own root **and** Nginx mounts it at `/admin/` — both try to normalise the trailing slash, in opposite directions | (a) add `skipTrailingSlashRedirect: true` to `web-admin/next.config.mjs`; (b) in Nginx add `location = /admin { return 308 /admin/; }` above the `location /admin/` block; rebuild admin, `nginx -t && systemctl reload nginx`. Do **not** replace `app/admin/page.tsx` with a redirect stub — it is the real Dashboard; `git checkout -- app/admin/page.tsx` if a stub got left there |
| 10 | After entering the correct 2FA code the browser loops back to `/admin` and fails | `app/admin/api/auth/2fa/verify/route.ts` returns `next: '/admin'` and `/admin` was looping (see #9) | Fix #9 first; `next: '/admin'` is correct once the loop is gone (it's the real Dashboard). If you still want a specific landing page, set `next: '/admin/agents'` |
| 11 | Browser keeps requesting **old** chunk filenames that no longer exist | Stale `.next` served after a rebuild | `systemctl stop schooleye-admin && rm -rf .next && npm run build && systemctl start schooleye-admin` |
| 12 | Login succeeds (`200`, `needsEnrolment`), page immediately returns to `/admin/login` | You're on `http://` — the `Secure` session cookie is dropped | Use `https://` (finish A9) |
| 13 | `https://DOMAIN` *"took too long to respond"* | DNS still points at the old host, or Nginx isn't on `:443` | `nslookup DOMAIN`; `sudo ss -tlnp | grep ':443'`; re-run `certbot --nginx` → option **1 (reinstall)** if the cert exists but Nginx lost the SSL block |
| 14 | `next build`: *"next start does not work with output: standalone"* | leftover `output: 'standalone'` | same as #7 |
| 15 | `Cannot find package 'pg'` when running the create-admin script | ran it from `/tmp` or a dir without `node_modules` | `cd /opt/schooleye/web-admin` first, `npm i pg uuid --no-save` |
| 16 | `EADDRINUSE :::3002` on `systemctl start` | a previous `node` still holds the port | `sudo fuser -k 3002/tcp` then start; or pick another port in the unit + Nginx |
| 17 | `nginx -t`: *open() "/etc/nginx/sites-enabled/…" failed* | a dangling symlink to a deleted config | `sudo rm -f /etc/nginx/sites-enabled/<stale>` and reload |
| 18 | `git push` → *"Authentication failed"* | GitHub no longer accepts passwords | create a Personal Access Token (scope `repo`) and use it as the password |

---
---

# PART B · School Admin — first login and school setup

You need: the login URL, your email + temporary password (from the Operator), and a phone
with an authenticator app.

## B1. Sign in

1. Open **`https://DOMAIN/admin/login`**.
2. **Email** — type your admin email.
3. **Password** — type the temporary password.
4. Press **Continue**.

📷 SCREENSHOT [b1-login] — *the "SchoolEye · School admin / Sign in" card with Email and Password fields.*

**Password rules** (for when you change it later): at least **12 characters**, with an
uppercase letter, a lowercase letter, a digit, and a symbol; it must **not** contain your
name or the local part of your email.

## B2. Set up two-factor sign-in (one time, mandatory)

After Continue you land on **"Set up two-factor sign-in"**.

1. In your authenticator app choose **Add account → Enter a setup key** (a.k.a. "manual").
2. **Account name:** your email. **Key:** copy the **Setup key** shown on the page
   (e.g. `LQBBKWTKDACX2FIG`). **Type:** Time-based. **Digits:** 6. **Period:** 30.
   *(If your app scans QR codes, expand "Show the otpauth:// URL" and paste that instead.)*
3. **Write down the 10 recovery codes** shown in the orange box — they are shown **once**.
   Keep them where you keep the school's other master credentials.
4. The app now shows a rotating **6-digit code**. Type it into the six boxes on the page.
5. On success you're taken into the dashboard (**Agent status** screen).

📷 SCREENSHOT [b2-2fa-setup] — *the setup key, the collapsed otpauth URL, and the recovery-codes box all visible.*

> If the code is rejected as **"That code is not right"** even though it's fresh, the
> server clock and your phone clock disagree. Fixes in **B-errors #1**.

## B3. Fill in the school profile

Go to **Settings** (left nav; Super Admin only can edit — Admins see it read-only).

Under **School profile**:

| Field | What to enter | Notes |
|-------|---------------|-------|
| **Name** | Full school name as parents know it | Shown in the parent school-picker |
| **City** | City / town | Shown next to the name: *"Green Valley School — Chennai"* |
| **CCTV-policy document URL** | A link to your CCTV/privacy policy (Google Drive, your website) | No file upload in the MVP — paste a link |
| **Class-sections (comma-separated)** | e.g. `Nursery, LKG, UKG, 1-A, 1-B, 2-A, 2-B` | Drives the roster importer **and** the parent "Class" dropdown. Get this right before importing the roster. |

Press **Save profile**. You should see *"School profile saved."*

📷 SCREENSHOT [b3-settings-profile] — *School profile card with all four fields filled and the class-sections list visible.*

## B4. Add staff logins (optional, Super Admin only)

Still in **Settings → Admin users → Add admin**.

| Field | Enter |
|-------|-------|
| **Name** | Staff member's name |
| **Email** | Their work email (this is their login) |
| **Role** | see table below |
| **Temporary password** | 12+ chars, same policy as B1 |

| Role | Give to | Can do | Cannot do |
|------|---------|--------|-----------|
| **Super Admin** | Principal | Everything, incl. adding/removing admins, editing the school profile | — |
| **Admin** | Vice-Principal | Rooms, cameras, agents, roster, approvals, schedule, audit, billing | Manage admin users; edit school profile |
| **Staff** | Office / IT coordinator | Roster, approvals, parent list, support tickets | Rooms/cameras/schedule; sees audit read-only |
| **Viewer** | Class teacher | Read-only screens | Any change; **parent email/phone are masked** for this role |

Each new admin repeats **B1–B2** (their own password change + their own authenticator) on
first login. Two-factor is mandatory for everyone.

📷 SCREENSHOT [b4-add-admin] *(optional)* — *the Add admin modal with Name / Email / Role / Password.*

## B · Errors & fixes

| # | Symptom | Cause | Fix |
|---|---------|-------|-----|
| 1 | 2FA code always *"not right"*, even a brand-new one | Server ↔ phone clock skew (TOTP tolerates ~30 s) | On the server: `sudo timedatectl set-ntp true` then `sudo systemctl restart schooleye-admin`. On the phone: Settings → Date & Time → **Set automatically** |
| 2 | 2FA still fails after the clock is fixed | The stored secret and your app disagree (e.g. you enrolled, then the account was reset) | Operator runs `UPDATE admin_user SET totp_secret_enc = NULL WHERE email = '<you>';` — then delete the old entry in your app, sign in again, re-enrol with the new key |
| 3 | Locked out (lost phone **and** recovery codes) | — | Operator runs the `UPDATE … totp_secret_enc = NULL` above; you re-enrol on next login |
| 4 | "Set up two-factor" shows *"Preparing your setup key…"* forever | `/admin/api/auth/2fa/setup` failed (Redis down, or `SESSION_SECRET` missing) | Operator: `redis-cli ping` → expect `PONG`; check both `.env.local` have the three secrets; restart the service |
| 5 | *"That email or password is not right."* on B1 with the handed-over password | Password policy rejected the temp password at creation, so the hash was never written / typo in the create script | Operator re-runs the A10 script with a policy-valid password |
| 6 | Signed in, but **Settings** shows everything greyed out | You are an **Admin**, not **Super Admin** | A Super Admin must make the change, or promote you in Settings → Admin users |
| 7 | Left nav is missing **Rooms / Cameras / Schedule** | Your role is **Staff** or **Viewer** | Expected — those need **Admin+**. Use an Admin login |

---
---

# PART C · Agent install & camera mapping (Admin + on-site installer)

The **SchoolEye Agent** runs on **one always-on computer at the school** and bridges the
CCTV (RTSP) to the cloud (SRT). It opens **no inbound ports**, writes **no video to disk**,
logs **no RTSP passwords**. Full reference: `05-agent-install.md`.

## C0. Pre-visit checklist (do this on a call — don't schedule an install until all pass)

- [ ] The NVR/DVR exposes **RTSP** per channel (Hikvision, Dahua, CP Plus, Uniview all do).
      Get from the CCTV vendor: **NVR IP**, a **view-only** username/password, the **RTSP URL
      pattern**.
- [ ] Sub-stream codec is **H.264** (or H.265 **and** the host PC can hardware-transcode —
      an Intel CPU from the last ~6 years with integrated graphics).
- [ ] A **host PC** on 24×7: Windows 10/11 or Ubuntu, ~4 GB free RAM. If none exists, take a
      ₹12–15k mini-PC.
- [ ] Host has **outbound** internet on **TCP 443** and **UDP 8890** to `live.DOMAIN`. No
      inbound rules.
- [ ] **Upload bandwidth:** ≥ **2 Mbps per camera** you'll stream, with headroom. 6 cameras
      → ≥ 15 Mbps upload. Run a speed test on the host.
- [ ] Rooms to cover are **classrooms / common areas** — never toilets, changing rooms, sick
      bay, counselling, staff room. Teacher consent per room is on file.

## C1. Generate a pairing code (Admin, in the browser)

1. Left nav → **Agent status** (`/admin/agents`).
2. Click **Generate pairing code** (or **Get installer**).
3. Copy the code. It is **single-use** and expires in **~30 minutes**.

📷 SCREENSHOT [c1-pairing-code] — *the Agent status screen with a freshly generated pairing code.*

## C2. Install on the host PC

### Windows

1. Copy `schooleye-agent-windows-amd64.zip` to the host (USB or download).
2. **Unzip.** Right-click **`install.bat` → Run as administrator**. Approve the UAC prompt.
3. When prompted, **paste the pairing code**. Press **Enter**.
4. It downloads ffmpeg, registers the **`SchoolEyeAgent`** Windows service, runs a self-test.
5. Verify: PowerShell → `Get-Service SchoolEyeAgent` shows **Running**.

Re-pair later: `powershell -ExecutionPolicy Bypass -File install.ps1 -Pair NEWCODE`
Uninstall: `powershell -ExecutionPolicy Bypass -File install.ps1 -Uninstall`

### Linux / macOS

```bash
tar xzf schooleye-agent-linux-amd64.tar.gz && cd schooleye-agent-*
sudo ./install.sh          # paste the pairing code when prompted
systemctl status schooleye-agent    # (Linux) → active (running)
```

Re-pair: `sudo ./install.sh --pair NEWCODE` · Uninstall: `sudo ./install.sh --uninstall`

## C3. Confirm the agent is online

Back in **Agent status**, within ~30 s the host appears **online** with its hostname.

📷 SCREENSHOT [c3-agent-online] — *Agent status listing the host as online.*

## C4. Map each camera to a room

Left nav → **Cameras** (`/admin/cameras`). The agent reports the NVR's channels here.

For each channel, click **Edit** and set:

| Field | What to enter |
|-------|---------------|
| **Label** | Human name, e.g. `Grade 1-A front` |
| **Room** | Pick the room (create rooms first — B/PART D step D-rooms — or "— unmapped —" for now) |
| **RTSP channel** | The channel/path token if discovery didn't fill it (e.g. `101`, `Streaming/Channels/101`) |
| **Codec** | `H.264` / `H.265` / `Unknown` |
| **RTSP URL (write-only)** | Full `rtsp://user:pass@nvrip:554/…` **only if** discovery couldn't authenticate. Stored **encrypted**, never shown again. Leave blank to keep the existing one. |

Press **Save**. Then click **Test** on that row — the agent pulls ~5 seconds and reports
**pass/fail**. No video is ever shown to the admin.

📷 SCREENSHOT [c4-camera-edit] — *the Edit camera modal with Label, Room, Channel, Codec, and the write-only RTSP field.*
📷 SCREENSHOT [c4-camera-test] *(optional)* — *a camera row after Test showing a pass result.*

## C · Errors & fixes

| # | Symptom | Cause | Fix |
|---|---------|-------|-----|
| 1 | Agent never shows online | Pairing code expired / mistyped | Generate a fresh code (C1), re-pair: `install.ps1 -Pair CODE` / `./install.sh --pair CODE` |
| 2 | Was online, now offline | Host PC slept or powered off | Set the host to **never sleep**; disable Windows fast-startup. It reconnects on boot |
| 3 | Camera **Test** fails | Wrong RTSP URL / credentials | In **VLC on the host**: *Media → Open Network Stream →* the exact `rtsp://…`. Fix user/pass or the path, re-enter under **Cameras → Edit** |
| 4 | Test fails only for some cameras | Those are **H.265** and the host can't hardware-transcode | Check `agent.log` for `hwaccel`; set Intel `qsv` / NVIDIA `nvenc`; if neither, move the agent to a mini-PC |
| 5 | Parent sees *"camera offline"* though the agent is online | Outbound **UDP 8890** blocked by the school firewall | `agent.log` should show `srt: connected`; if not, ask school IT to allow outbound UDP 8890 to `live.DOMAIN` |
| 6 | Installer: *"could not obtain ffmpeg"* | School proxy / no direct internet | Set proxy env vars before running, or drop a static `ffmpeg` binary into the install dir and re-run |
| 7 | High CPU on the host while parents watch | Software H.265→H.264 transcode | Enable hardware encode, or lower `media.max_height` to 480, or mini-PC |
| 8 | Video starts then drops after ~10 s | NVR RTSP connection limit hit | Make sure the school's own viewer isn't maxing the NVR; consider pulling from cameras directly instead of the NVR |

---
---

# PART D · School Admin — roster, approvals, schedule, go live

## D1. Add rooms

Left nav → **Rooms** (`/admin/rooms`) → **Add room**.

| Field | Enter |
|-------|-------|
| **Room name** | e.g. `Grade 1-A` |
| **Type** | **Classroom** or **Common area** — only these two types can ever go live |

Press **Add**. Repeat per room. Then, per room, on its detail page (click the room name):

- attach **teacher consent** (record that the class teacher agreed),
- optionally set a **room viewing window** that overrides the school schedule.

📷 SCREENSHOT [d1-add-room] — *the Add room modal (name + type).*

## D2. Import the roster (CSV)

Left nav → **Roster** (`/admin/roster`).

1. Click **Download template**. It contains exactly these columns:

   ```
   class_section,student_full_name,parent1_name,parent1_email,parent1_phone,parent2_name,parent2_email,parent2_phone
   ```

   Example row:

   ```
   1-A,Aarav Sharma,Rohit Sharma,rohit.sharma@example.com,+919876543210,Meera Sharma,meera.sharma@example.com,
   ```

2. Fill it in a spreadsheet. Rules:
   - `class_section` **must** be one of the class-sections you set in **B3** (exact text).
   - `student_full_name` — full name; parents will type this to identify themselves.
   - At least **one** parent contact per row (`parent1_email` **or** `parent1_phone`).
     Phone in international form: `+91XXXXXXXXXX`. Parent 2 is optional.
   - Save as **CSV** (UTF-8).

3. Click **Import CSV**, choose the file. A **preview** shows: rows to add, rows to update,
   rows skipped, and any per-line issues (wrong class-section, missing name, bad email).
4. Fix issues in the sheet and re-import if needed. When the preview is clean, click
   **Commit** — you'll see *"Imported: N added, M updated, K skipped."*

📷 SCREENSHOT [d2-roster-preview] — *the import preview with add/update/skip counts and the issues list.*

**Re-importing is safe:** matching rows update in place; you won't get duplicates.

## D3. Clear the approvals queue

Left nav → **Approvals** (`/admin/approvals`).

- **Exact** name matches when a parent identifies themselves are **granted automatically** —
  they never appear here.
- Only **near-matches** (spelling differs) land in this queue. Each card shows the student,
  class, which parent slot, where the code would be sent, and how old the request is
  (green < 24 h, amber < 48 h, red = overdue).
- For each: **Confirm** (grant access), **Fix roster** (correct the spelling instead), or
  **Reject**.
- A **family flag** (e.g. custody note) is shown and is **not** overridden by Confirm.

📷 SCREENSHOT [d3-approvals] *(optional)* — *one approval card with the Confirm / Fix roster / Reject buttons.*

## D4. Set the viewing schedule

Left nav → **Schedule** (`/admin/schedule`).

- Set the **school-wide** weekly windows — the hours when parents may watch (e.g.
  Mon–Fri 08:30–15:30). Outside these, viewing is closed for everyone.
- Add **closures** for holidays.
- A room can override this on its own page (D1).

Press **Save schedule**. The banner shows the current state — *"viewing open now"* /
*"viewing closed now / Opens 08:30"*.

📷 SCREENSHOT [d4-schedule] — *the weekly grid with windows set and the "viewing open/closed now" banner.*

## D5. Enable rooms (go live)

Back to **Rooms**. A room's **Enable** button only works when **all** of these are true:

1. the room type is **Classroom** or **Common area**,
2. it has at least one **camera** whose health is **online**,
3. **teacher consent** is on file,
4. the school (or room) **viewing window** exists.

Click **Enable**. The row shows **on**. Parents matched to students in that room can now
watch during the window.

To pause a room, click **Turn off** and type a short **reason** — parents see *"turned off
by the school"* and any active viewers drop within ~3 seconds.

📷 SCREENSHOT [d5-room-enabled] — *the Rooms table with a room showing camera "online", consent "on file", and Enabled = on.*

## D6. Verify end to end

- **Cameras** → the room's camera is **online**, **Test** passes.
- **Schedule** → banner says *viewing open now* (or test inside a window).
- Do a **parent test run** (Part E) with a real roster row and a contact you can receive
  the OTP on. You should get a code, sign in, and see live video.

## D · Errors & fixes

| # | Symptom | Cause | Fix |
|---|---------|-------|-----|
| 1 | CSV import: *"Unknown class-section 'Grade1A' on line 5"* | `class_section` doesn't match Settings exactly | Make the sheet's value identical to a class-section in **B3** (case, spaces, hyphens) |
| 2 | Import: *"line 8: needs at least one parent contact"* | Both `parent1_email` and `parent1_phone` are blank | Add one; phone as `+91XXXXXXXXXX` |
| 3 | Parent says *"check your email"* but never gets a code, and they **are** on the roster | Their typed name is a **near-match**, not exact | **Approvals** → confirm the waiting item, or **Fix roster** to correct the spelling |
| 4 | Parent gets the code but lands on *"Your school is finishing setup for this classroom."* | Their child's **room isn't enabled**, or has no online camera | Finish D5 for that room |
| 5 | **Enable** on a room does nothing / stays off | One of the four gates fails | Check the row: camera **online**? consent **on file**? window set? type is Classroom/Common? |
| 6 | Parent: *"viewing is closed"* during school hours | Schedule window wrong, or a **closure** covers today, or the room override is narrower | **Schedule** → fix the window / remove the closure; check the room's override |
| 7 | OTP email lands in spam or never arrives | Sending domain not verified (SPF/DKIM), or `RESEND_API_KEY` unset | Verify the domain with your email provider; for a POC without a key, the OTP is written to the server log — `journalctl -u schooleye-parent | grep -i otp` |
| 8 | Roster import preview shows everything as **skipped** | File isn't CSV, or headers were renamed | Re-download the template; keep the header row exactly; export as CSV UTF-8 |
| 9 | Teacher (Viewer role) can't see parent phone/email on the roster | By design — data minimisation for that role | Use a Staff or Admin login for contact details |

---
---

# PART E · Parent — how to watch

No app to install. It's a website that works in any modern phone or desktop browser.

## E1. Open the site & pick your school

Go to **`https://DOMAIN/`**.

1. **School** dropdown → choose your school (shown as *"Name — City"*).
2. Press **Continue**.

If it says *"SchoolEye Live isn't active at any school yet"*, the school hasn't finished
setup — check back later.

📷 SCREENSHOT [e1-school-select] — *the "SchoolEye Live" page with the School dropdown open.*

## E2. Identify your child

On **"Find your child"**:

| Field | Enter |
|-------|-------|
| **Class** | Your child's class-section (dropdown) |
| **Your name** | Your name **as the school has it** on the roster |
| **Child's full name** | Your child's full name **as on the roster** |

Press **Send me a code**. A 6-digit code goes to the **email or phone the school has on
file** for you — you don't type your contact here.

📷 SCREENSHOT [e2-identify] — *Class / Your name / Child's full name filled, "Send me a code" button.*

## E3. Enter the code

On **"Enter your code"**: type the **6-digit code**. It expires in **10 minutes**.

- **Resend code** — after a short cooldown, if it didn't arrive.
- **Use a different name** — go back if you mistyped your or your child's name.
- After **5 wrong tries** you're sent back to the start; just request a new code.

📷 SCREENSHOT [e3-enter-code] — *the six code boxes and the "Code expires in mm:ss" line.*

## E4. Watch

You land on **"Hi \<name\>"**.

- **One child:** the live player loads straight away.
- **More than one child:** tap the child's name to switch.
- If viewing is closed you'll see the school's own words (e.g. *"turned off by the school"*,
  *"viewing opens at 08:30"*) — this text comes from the school, not from us.
- The video has a moving watermark. It pauses if you switch away from the tab and resumes
  when you come back.

📷 SCREENSHOT [e4-watch] — *the player area with the child-name header (blur the actual video).*

## E5. Account & privacy

Bottom of the watch page → **Account**:

- **Name / Contact / School** — read-only; the office changes these.
- **"Email me once a day when viewing opens"** — optional daily reminder.
- **Language** — English / हिन्दी.
- **Delete my data** — two-step, confirmed by a fresh OTP; removes your access and personal
  data, and emails you a confirmation.

📷 SCREENSHOT [e5-account] *(optional)* — *the Account card (read-only identity + the daily-email checkbox + language toggle).*

## E · Errors & fixes

| # | Symptom | Cause | Fix |
|---|---------|-------|-----|
| 1 | *"We couldn't load the list of schools."* | Network, or the site is down | Check your connection and refresh; try mobile data vs Wi-Fi |
| 2 | After **Send me a code**: *"check your email/phone"* but nothing arrives, and you're sure you're on the roster | Your typed name is a near-match → it's in the school's **Approvals** queue | Contact the school office; once they confirm, request the code again |
| 3 | No code at all, name is definitely exact | School hasn't added a contact for you, or it's wrong on the roster | Ask the office to check your email/phone on the roster |
| 4 | Code says *"expired"* | Older than 10 minutes | Tap **Resend code** and use the newest one only |
| 5 | Code always *"not right"* | You're using a code from an earlier email/SMS | Only the **most recent** code works — use that one |
| 6 | *"Too many attempts. Start again."* | 5 wrong entries | You're back at the identify screen; request a fresh code |
| 7 | *"Your school is finishing setup for this classroom."* | Your child's room isn't live yet | Wait for the school; nothing to do on your side |
| 8 | Spinner, video never starts | The classroom camera is offline right now | Try later; if it persists for a day, tell the school |
| 9 | *"Signed in on another device"* | One device per child at a time | Close the other tab/phone and reload |
| 10 | Works on Android, not on an old iPhone | Very old iOS build | Update iOS, or use another device / desktop browser |
| 11 | Video is ~20–30 s behind real time | Weak upload at the school, or a low-latency setting off | Nothing on your side — report to the school if it's always this bad |

---
---

# Appendix 1 · Screenshot shot-list

Save each as `doc/screenshots/<name>.png`. Frame tightly on the described content; blur any
real student faces or parent contact details before committing.

| Name | Part | Must show |
|------|------|-----------|
| `a1-dns-records` | A1 | DNS table: two `A` records → VPS IP; no `ALIAS`/`CNAME` on `@`/`www` |
| `b1-login` | B1 | Admin sign-in card (Email, Password, Continue) |
| `b2-2fa-setup` | B2 | Setup key + otpauth URL + the 10 recovery codes |
| `b3-settings-profile` | B3 | School profile: Name, City, policy URL, class-sections; "saved" toast |
| `b4-add-admin` | B4 | Add admin modal (Name, Email, Role, Password) — *optional* |
| `c1-pairing-code` | C1 | Agent status with a generated pairing code |
| `c3-agent-online` | C3 | Agent status listing the host **online** |
| `c4-camera-edit` | C4 | Edit camera modal (Label, Room, Channel, Codec, write-only RTSP) |
| `c4-camera-test` | C4 | Camera row with a passing **Test** — *optional* |
| `d1-add-room` | D1 | Add room modal (name + type) |
| `d2-roster-preview` | D2 | CSV import preview: add/update/skip counts + issues |
| `d3-approvals` | D3 | One approval card (Confirm / Fix roster / Reject) — *optional* |
| `d4-schedule` | D4 | Weekly grid with windows + "viewing open/closed now" banner |
| `d5-room-enabled` | D5 | Rooms table: camera online, consent on file, Enabled = on |
| `e1-school-select` | E1 | Parent home with School dropdown |
| `e2-identify` | E2 | "Find your child" — Class, Your name, Child's full name |
| `e3-enter-code` | E3 | Six code boxes + expiry countdown |
| `e4-watch` | E4 | Player area + child-name header (blur the video) |
| `e5-account` | E5 | Account card: read-only identity + daily-email + language — *optional* |

---

# Appendix 2 · Quick command reference (Operator)

```bash
# health
curl -s http://127.0.0.1:3000/api/healthz ; echo
curl -s http://127.0.0.1:3002/api/healthz ; echo
curl -s https://DOMAIN/api/healthz ; echo

# services
sudo systemctl status  schooleye-parent schooleye-admin
sudo systemctl restart schooleye-parent schooleye-admin
sudo journalctl -u schooleye-admin -n 100 --no-pager
sudo journalctl -u schooleye-parent | grep -i otp        # POC OTP fallback

# rebuild after a code change
cd /opt/schooleye/web-admin && rm -rf .next && npm run build && sudo systemctl restart schooleye-admin

# nginx / TLS
sudo nginx -t && sudo systemctl reload nginx
sudo ss -tlnp | grep -E ':(80|443)'
sudo certbot certificates
sudo certbot --nginx -d DOMAIN -d www.DOMAIN            # option 1 = reinstall into nginx

# database
psql "postgresql://schooleye:SchoolEye123@127.0.0.1:5432/schooleye" -c '\dt'
sudo -u postgres psql -c "ALTER USER schooleye WITH PASSWORD 'SchoolEye123';"

# reset one admin's 2FA (they re-enrol on next login)
psql "postgresql://schooleye:SchoolEye123@127.0.0.1:5432/schooleye" \
  -c "UPDATE admin_user SET totp_secret_enc = NULL WHERE email = 'principal@greenvalley.edu';"

# clock (fixes TOTP 'bad code')
sudo timedatectl set-ntp true
```

---

# Appendix 3 · Required environment variables

| Variable | Required | Example / note |
|----------|----------|----------------|
| `APP_BASE_URL` | yes | `https://DOMAIN` |
| `NODE_ENV` | yes | `production` |
| `SESSION_SECRET` | yes | ≥ 32 bytes — `openssl rand -base64 32` |
| `PAIRING_SECRET` | yes | ≥ 32 bytes |
| `PLAYBACK_TOKEN_SECRET` | yes | ≥ 32 bytes |
| `DATABASE_URL` | yes | `postgresql://schooleye:PASS@127.0.0.1:5432/schooleye` — no `@:/` in `PASS` |
| `REDIS_URL` | yes | `redis://127.0.0.1:6379` |
| `MEDIAMTX_API` | yes | `http://127.0.0.1:9997` |
| `MEDIAMTX_HLS_BASE` | yes | `https://live.DOMAIN` |
| `MEDIAMTX_SRT_HOST` | yes | `<VPS_IP>` |
| `MEDIAMTX_SRT_PORT` | no | default `8890` |
| `NEXT_PUBLIC_HELP_URL` | no | in-app **Help & setup guide** footer link; default `https://help.schooleye.in`. Set it to where this guide is published. Build-time — rebuild both apps after changing it. |
| `RESEND_API_KEY` | no | email OTP; unset → OTP printed to the parent-app log |
| `EMAIL_FROM` | no | default `SchoolEye <no-reply@schooleye.in>` |
| `MSG91_AUTH_KEY` | no | SMS OTP |
| `MSG91_SENDER_ID` | no | default `SCHEYE` |
| `RAZORPAY_KEY_ID` / `RAZORPAY_KEY_SECRET` | no | billing only |
| `SENTRY_DSN` | no | error reporting |

---

---

## Footer · Need more help?

If a step here didn't work and the **Errors & fixes** table for that part didn't resolve it:

| You are a… | Do this |
|------------|---------|
| **Parent** | Contact your **school office** — they manage your name, contact and access. They can re-check your roster entry and the Approvals queue. |
| **School Admin / staff** | Email **support@schooleye.in** with: your school name, the screen you're on, what you clicked, and the exact error text. Attach a screenshot if you can. |
| **Operator** | Start with [`06-troubleshooting.md`](./06-troubleshooting.md) (symptom → cause → fix), then capture `journalctl -u schooleye-parent -u schooleye-admin --since=1h` and the browser Network HAR before escalating. |

**This guide is the canonical instruction set.** The in-app footer link — *"Help & setup
guide"* on every parent and admin page — points here via `NEXT_PUBLIC_HELP_URL`. Publish this
file somewhere your users can reach (internal wiki, GitHub blob URL, or a hosted copy) and set
that variable to its URL, then rebuild both apps.

Read the part for your role top-to-bottom before you start an action — each step says what to
type and what to press to save, and every part ends with the errors you're most likely to hit.

*Related docs: [`00-INDEX.md`](./00-INDEX.md) · [`01-provision-vm.md`](./01-provision-vm.md) ·
[`02-server-setup.md`](./02-server-setup.md) · [`03-mediamtx.md`](./03-mediamtx.md) ·
[`04-deploy.md`](./04-deploy.md) · [`05-agent-install.md`](./05-agent-install.md) ·
[`06-troubleshooting.md`](./06-troubleshooting.md) · [`07-system-design.md`](./07-system-design.md)*

*Last updated: 2026-09-09.*
