diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a3855e8b..6cb2441ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- **Time, scheduling and pay for hourly staff (#640, #642, #644).** Staff clock in at the register or the counter tablet with a **4-digit PIN** (`/clock` kiosk and the Clock button on the POS strip; registered store devices only, or a signed-in phone), take **breaks and meals**, fix a forgotten clock-out in two taps, and see their day on **My time** (`/employee`): today's shift, this period's hours, Schedule, Time off and Pay. **Schedule**: managers build the week by drag and drop with conflict chips (time off, availability, hours, rest, minors, blackouts) and a labour-cost-vs-sales line, save templates, post the week to staff; staff pick up open shifts, give away or swap shifts with guardrails and manager approval. **Time off** in four taps with balances, accrual, blackout dates and notice. **Pay** shows hours net of unpaid breaks, estimated gross, **card tips** attributed from the Dejavoo terminal and settled against Authorize.net batches (owner's pool rule: shared evenly among everyone clocked in, admins excluded), declared **cash tips**, bonuses and the next payday; English and Spanish. Managers get **timesheets with exceptions first** and bulk approve, one **Approvals** inbox (punch fixes, time off, trades, timesheets), a **Tips** report with reconciliation, **Pay periods** with lock and a provider-agnostic payroll CSV, per-store **Time rules**, **Clock devices**, store-scoped roles, leave accrual and offboarding. **Push notifications** (APNs for the iPhone app, Web Push for the installed web app) for posted or changed schedules, decisions, trade offers, missed clock-outs and breaks, once `APNS_*`/`VAPID_*` keys are set. The iPhone app 1.1.0 opens on a **Time** tab and is packaged for App Store resubmission (`ios/AppStore/checklist.md`). Docs: `docs/time-and-pay/`. - **Learning center: a reusable LMS, and its first course, “How our card payments work”.** **Me → Learning center** (`/learning-center`, every signed-in account, including staff in their own shell) lists each person's courses with a progress ring, required-by dates and earned badges. A course is lessons → quiz questions as workspace data (`lms_*` tables, seeded from `db/seeds/lms/*.yml` on first visit or `bin/rails lms:seed`), so the next course (HR policies, POS training) is a YAML file. Lessons are 2–4 minutes, with tap-to-define glossary words, callouts, step cards and responsive HTML diagrams (the life of a tap, who is who, where the fees go, tips, void vs refund, a dispute timeline, PCI scope, first moves when the terminal misbehaves). Each lesson ends with a quick check; every answer must be right, wrong ones show why, and attempts are kept. Finishing every lesson awards a printable badge. Progress saves on every view and answer. **Who takes it**: rules by role (required, due within N days) enrol people now and as accounts are added; managers with `hr.write` also assign named people, edit lessons and quizzes (with preview) and keep the **support contacts** shown inside the course current; **Team completion** (`hr.read`) shows everyone against every course with filters and a CSV. English UI strings have a Spanish translation file; content carries a locale. See `docs/lms/`. - **Extra Dejavoo terminals, one per register or event.** Checkout setup → **Extra Dejavoo terminals**: add a terminal's TPN and SPIn auth key for a register name, **Check** it (a status lookup that cannot charge), and that register's card sales, refunds and batch close use it; other registers keep the main TPN. Each checkout remembers its terminal, so refunds go back to the same one. Retired terminals keep their keys for refunds of past sales. **Close terminal batch** closes every terminal's batch. - **Dejavoo FEED: every Dejavoo transaction and batch reaches Rupert.** Point iPOSpays Settings → FEED at the store's POS webhook URL with Basic Authentication (API key `rupert`, secret = the **Dejavoo FEED key** in checkout setup). Rupert stores each sale, refund, void and closed batch (amounts, batch, card brand and last four; never card number, name, email, phone or card token), matches it to the Rupert sale, refund or online payment it belongs to, and lists the ones run outside Rupert in checkout setup. HMAC signatures are also accepted under either of Dejavoo's published field orders. diff --git a/deploy/ser8/tests/test_environment_reference.py b/deploy/ser8/tests/test_environment_reference.py index 775177d05..92ee68eb7 100644 --- a/deploy/ser8/tests/test_environment_reference.py +++ b/deploy/ser8/tests/test_environment_reference.py @@ -16,6 +16,10 @@ # RUPERT_* identifiers that are not environment variables. NOT_ENV = { 'RUPERT_SOURCES': 'Ruby constant in web/app/services/swipesimple_tender_match.rb', + # xcodebuild test-runner inputs for the iPhone UI tests (ios/RupertApp/UITests), never server settings. + 'RUPERT_UI_EMAIL': 'ios/RupertApp/UITests/TimeFlowsUITests.swift', + 'RUPERT_UI_PASSWORD': 'ios/RupertApp/UITests/TimeFlowsUITests.swift', + 'RUPERT_UI_SHOTS': 'ios/RupertApp/UITests/TimeFlowsUITests.swift', } SKIP_SUFFIXES = ('.md', '.html', '.png', '.jpg', '.svg', '.ico', '.lock', '.pdf') diff --git a/docs/ops/environment-reference.md b/docs/ops/environment-reference.md index e9e3ce3db..26f8a5dc6 100644 --- a/docs/ops/environment-reference.md +++ b/docs/ops/environment-reference.md @@ -43,6 +43,9 @@ Two kinds of configuration exist. **Boot-time variables** live in `/etc/rupert.e | --- | --- | --- | | `RUPERT_BUZZ_ENABLED` | off | `1` turns on the optional Buzz module ([web/engines/buzz/README.md](../../web/engines/buzz/README.md)) | | `RUPERT_EMPLOYEE_APP_ENABLED` | `0` in production, `1` elsewhere | `1` allows employee-app (staff mobile) sign-in; see [staff-app.md](../module-guides/staff-app.md) | +| `APNS_KEY_ID`, `APNS_TEAM_ID`, `APNS_KEY_P8`, `APNS_TOPIC`, `APNS_ENVIRONMENT` | unset (push off) | Push notifications to the Rupert iPhone app (`PushNotifier`, token-based APNs auth). `APNS_KEY_P8` is the contents of the App Store Connect `.p8` key (Keys → Apple Push Notifications service), `APNS_TOPIC` the bundle id `com.rupertpos.Rupert`, `APNS_ENVIRONMENT` `sandbox` for TestFlight/dev builds or `production`. All four of the first keys must be set for APNs to be used; the bell works without them. Platform-level: boot-time only, never a workspace setting | +| `RUPERT_DEV_ORIGIN` | unset | Development only, read by the iPhone app's DEBUG builds (`ios/Sources/Rupert/Instance.swift`): points the whole app at a local Rupert (`http://127.0.0.1:3005` in the `RupertApp (local)` Xcode scheme and the Simulator UI tests). Never set on a server; release builds ignore it | +| `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT` | unset (push off) | Web Push for the installed staff web app (`/employee`). Generate once with `bin/rails runner 'k = WebPush.generate_key; puts k.public_key, k.private_key'`; `VAPID_SUBJECT` is `mailto:` the support mailbox. Platform-level, boot-time only | | `RUPERT_GOOGLE_OAUTH_HOST` | `HOST` in production; any host elsewhere | The one host whose Google callback is registered. Every Google sign-in and sign-up is sent there, so other hostnames and the iPhone app need no callback of their own. A bare hostname or a URL | | `RUPERT_SWIPESIMPLE_INBOX` | unset (off) | Absolute directory that `SwipesimpleInboxJob` imports reports from; only for an explicit trusted producer, not for webpage sync. See [swipesimple-reporting.md](swipesimple-reporting.md) | | `RUPERT_BACKUP_STATUS_DIR` | `web/storage/backups` | Where System → Backups reads the status JSON written by the backup scripts; must match their `STATUS_DIR` / `STATUS_PATH`. See [backups.md](backups.md) | diff --git a/docs/time-and-pay/DECISIONS.md b/docs/time-and-pay/DECISIONS.md index 979031ff2..1e12f22a4 100644 --- a/docs/time-and-pay/DECISIONS.md +++ b/docs/time-and-pay/DECISIONS.md @@ -52,6 +52,7 @@ to Square. | Time off | 7 days notice requested (warning); blackout dates flagged, not blocked | Managers decide; the system informs. | | Sick accrual | Off (0 per hour) | Most state laws use 1 h per 30 h worked; the state presets set that. | | Vacation | 80 h/year grant; sick 40 h/year; carry-over cap 40 h | Matches the existing `LeaveBalance` defaults. The grant lands on **January 1** (the first `LeaveAccrualJob` run of the year), not the hire anniversary, so every balance resets on the same day as the leave year. On that run last year's unused vacation and sick hours carry over up to the cap; the excess is written to the ledger as a `carryover_cap` line so nothing disappears silently. Sick hours earned on hours worked are credited daily from paid clock hours since the last run (`employees.sick_accrued_through`). Every change is a `People::LeaveAccrual` line visible on the staff member's page. | + | Tips | Pool, admins excluded, per store, cash tips declared by the worker at clock-out | Owner rule plus a declared-cash step so the record is complete for tax reporting. | ## Card tip attribution (`Tips::Attribution`) @@ -111,3 +112,30 @@ All new screens: 44 px targets, Dynamic Type friendly (rem sizes), VoiceOver lab contrast checked with `bin/check-palette-contrast`, dark mode via tokens. Spanish strings for the employee-facing clock, time-off and pay screens live in `config/locales/es.yml`; a worker's `locale` is set from their profile. + +## App Store (iPhone app 1.1.0) + +- **Time is the first tab.** Hourly staff open the app to clock in; `/employee` is the tab's root. + Search left the tab bar (five is iOS's limit before a "More" tab) and stays in every page's top bar. +- **Push notifications are opt-in with a soft ask.** After the first signed-in page the app explains + the five kinds in its own words, then shows the system prompt. "Not now" is remembered; iOS + Settings owns the switch afterwards. Every kind can be muted at `/employee/notifications/settings` + (`User#notification_prefs`), which `TimeNotifier` honours for the bell and push alike. +- **APNs and VAPID keys are platform secrets**, read from `/etc/rupert.env` only (never a + workspace's managed settings): one app bundle serves every workspace. Until they are set, the + bell carries every message and nothing is enqueued (`PushNotifier.configured?`). +- **A push payload is data.** The app opens only the `url` field, and only when it resolves to a + Rupert host (`PushRegistration.url(in:)`); nothing else in the payload is acted on. +- **Sign in with Apple is not added.** Guideline 4.8 applies to apps that use a third-party login + to *set up or authenticate the primary account*. Rupert accounts are created by the business + (email/password, Rupert's own system); Google is an alternative way to sign in to that same + account and sign-up is hidden in the app. Apple's stated exceptions ("exclusively uses your + company's own account setup and sign-in systems", enterprise accounts) cover this. If App Review + disagrees, the fallback is to add Sign in with Apple as a second alternative login rather than + argue twice; this is recorded in `ios/AppStore/checklist.md` item 19. +- **Account deletion covers staff accounts.** The `employee` role could not reach + `/account/delete` before (allowlist); it can now, and deletion also removes push tokens and the + clock PIN while keeping the worker's hours for the business (FLSA retention). +- **Session awareness in every layout.** The app watches `` to restart + its tabs after a sign-in; the employee and POS layouts now carry it, so a sign-in on the Time tab + no longer leaves the other tabs on the sign-in page. diff --git a/docs/time-and-pay/DEPLOYS.md b/docs/time-and-pay/DEPLOYS.md index e7953cdc0..81c3dd60e 100644 --- a/docs/time-and-pay/DEPLOYS.md +++ b/docs/time-and-pay/DEPLOYS.md @@ -28,3 +28,27 @@ Production: Incus container `rupert` on SER8 (`ssh ser8`, then `incus exec ruper - Post-deploy: checkout clean (no schema.rb drift this time), `rupert-logs errors` empty for the window, `rupert-web` and `rupert-jobs` active, `/login` and `/up` 200 (staff pages redirect to sign-in as expected). No user-facing change in this deploy. + +## 2026-11-06 14:12 UTC — PR #642 clock, scheduling, tips, manager (`eb246e60`) — deployed, healthy + +- Pre-deploy: manual `sqlite3 .backup` to `/var/backups/rupert-manual/production-20261106-140603-pre-pr642.sqlite3` + (sha256 `756b98a1…`), integrity ok; deployer backup `production-20261106-141146-b7fdbf63-09c7fa9af6f4b569.sqlite3`. +- Install: autodeploy, 1 additive migration (`pay_period_adjustments`, `leave_accruals`, two employee columns), `/up` healthy, + smoke passed, `X-Rupert-Build: eb246e60`, checkout clean afterwards (the FEED index name in `schema.rb` now matches what + migrate generates, so the Nov 3 drift cannot recur). +- Live smoke test (Playwright against https://herbalhealers.rupertpos.com) with two temporary accounts created in the + workspace for the test (`smoke-staff@`, `smoke-manager@rupertpos.com`): staff **clock in → break → end break → clock out** + on `/employee` (EST times shown), `/employee/schedule`, `/employee/time-off`, `/employee/pay`, `/time_clock` all render; + manager `/people/approvals`, `/people/timesheets`, `/people/schedule` render. `/people/tips`, `/people/pay_periods`, + `/settings/time_rules`, `/people/clock_devices` refused the `manager` role as designed (payroll/settings/hr.write are + admin permissions). `rupert-logs errors` empty for the window. The smoke punch and break were deleted, the smoke worker + terminated and both accounts deactivated (owner may delete them from Accounts). +- Host disk: 99% used (15 GB free) on SER8's root volume; the deployer keeps five 300 MB backups. Worth a cleanup soon. + +## 2026-11-06 15:00 UTC — host note (owner) + +The owner's session freed 49 GB on SER8 (root volume now 93%, ~64 GB free) and set a keep-newest-10 retention on +`rupert-model-audit`. Rules from here on: check `df -h /` before any deploy or backup and do not deploy under ~25 GB free; +create no large artifacts on SER8; rely on the deployer's own pre-deploy backup (verified after the install with +`PRAGMA integrity_check`) instead of extra manual copies. The two manual copies taken earlier today were removed. Deploys +are serialized by the updater's own `flock` on `/run/rupert-autodeploy.lock` and always come from `origin/main`. diff --git a/docs/time-and-pay/REPORT.md b/docs/time-and-pay/REPORT.md index fce0e1367..fe8c4cb69 100644 --- a/docs/time-and-pay/REPORT.md +++ b/docs/time-and-pay/REPORT.md @@ -60,9 +60,27 @@ clocked-in staff at that store/time; owner's pool rule, admins excluded, whole c Authorize.net settled transactions and Dejavoo batch close, mirrors refunds/voids/adjustments as negative rows and carries money that settles after a lock into the next open period. -## Production status +## Production status (SER8, herbalhealers.rupertpos.com) -_(see DEPLOYS.md)_ +Three PRs merged to `main` and installed by the autodeployer, each preceded by a verified SQLite backup and +followed by a live smoke test; details and receipts in [DEPLOYS.md](DEPLOYS.md). + +| PR | Build | What | Result | +| --- | --- | --- | --- | +| #640 | `b7fdbf63` | Foundation (3 additive migrations) | Healthy | +| #642 | `eb246e60` | Clock, scheduling, tips, manager pages (1 additive migration) | Healthy; live clock in → break → clock out verified with a temporary worker, then cleaned up | +| #644 | see DEPLOYS.md | Push notifications, iPhone 1.1.0, App Store package | see DEPLOYS.md | + +Operational findings the owner should act on: + +1. **Encrypted backups have been failing** since at least Nov 1 (no active backup key). Create the key at + **Settings → Backups** and keep the recovery kit. Until then only the deployer's pre-deploy copies and the + manual copies in `/var/backups/rupert-manual` exist. +2. **Push needs keys**: `APNS_KEY_ID`, `APNS_TEAM_ID`, `APNS_KEY_P8`, `APNS_TOPIC`, `APNS_ENVIRONMENT` (and optionally + `VAPID_*`) in `/etc/rupert.env`; the bell works without them. +3. **SER8 root volume is 99% full** (15 GB free). +4. Pick the stores' **state preset** at Settings → Time rules (defaults are federal) and register each register browser + or counter tablet at **Team → Clock devices** so PIN punches work without a sign-in. ## Payroll provider recommendation @@ -99,4 +117,17 @@ Sources: [Homebase: best payroll providers 2026](https://wf.joinhomebase.com/blo ## What's left -_(filled in at the end)_ +- **Owner clicks**: App Store Connect submission (checklist item 22), Push Notifications capability and APNs key, + backup key, time-rules preset, clock-device registration, payroll provider sign-up. +- **Live tip attribution has no real data yet**: the Dejavoo FEED and Authorize.net reporting connectors are configured + per the payments docs but the terminal programme is still in sandbox (`docs/integrations/dejavoo-spin.md`); the first + live batches will exercise `Tips::Reconciliation` for real. Watch **Team → Tips** for unassigned rows that first week. +- **Clerk ids on the terminal**: for the `performedBy` evidence path, staff must key the same number on the terminal as + `employees.employee_number`; otherwise attribution falls back to the checkout cashier or clocked-in staff (still correct + under the pool rule). +- **Photo capture at the clock** is designed but off (privacy); GPS is deliberately not used. +- **Expo staff app** (`mobile/`) keeps working against the extended API but gets no new screens; the iPhone app is the one + being shipped. +- Pre-existing flaky test `TeamChatRestartTest#SIGKILL…` fails under full-suite load and passes alone; unrelated to this work. +- Nice-to-haves noted during review: a plain `‹ period ›` stepper on the Pay page (today it is a date field + Apply, which + works but is one tap more), and a "Next thing to do" item for pending approvals (NextThings only reads MCP tools today). diff --git a/docs/time-and-pay/screenshots/final-mgr-dark-people_approvals-1440.png b/docs/time-and-pay/screenshots/final-mgr-dark-people_approvals-1440.png new file mode 100644 index 000000000..7bf95b5d5 Binary files /dev/null and b/docs/time-and-pay/screenshots/final-mgr-dark-people_approvals-1440.png differ diff --git a/docs/time-and-pay/screenshots/final-mgr-dark-people_clock_devices-1440.png b/docs/time-and-pay/screenshots/final-mgr-dark-people_clock_devices-1440.png new file mode 100644 index 000000000..102c1538b Binary files /dev/null and b/docs/time-and-pay/screenshots/final-mgr-dark-people_clock_devices-1440.png differ diff --git a/docs/time-and-pay/screenshots/final-mgr-dark-people_pay_periods-1440.png b/docs/time-and-pay/screenshots/final-mgr-dark-people_pay_periods-1440.png new file mode 100644 index 000000000..8922639dd Binary files /dev/null and b/docs/time-and-pay/screenshots/final-mgr-dark-people_pay_periods-1440.png differ diff --git a/docs/time-and-pay/screenshots/final-mgr-dark-people_schedule-1440.png b/docs/time-and-pay/screenshots/final-mgr-dark-people_schedule-1440.png new file mode 100644 index 000000000..0567762bd Binary files /dev/null and b/docs/time-and-pay/screenshots/final-mgr-dark-people_schedule-1440.png differ diff --git a/docs/time-and-pay/screenshots/final-mgr-dark-people_timesheets-1440.png b/docs/time-and-pay/screenshots/final-mgr-dark-people_timesheets-1440.png new file mode 100644 index 000000000..7b7a52e98 Binary files /dev/null and b/docs/time-and-pay/screenshots/final-mgr-dark-people_timesheets-1440.png differ diff --git a/docs/time-and-pay/screenshots/final-mgr-dark-people_tips-1440.png b/docs/time-and-pay/screenshots/final-mgr-dark-people_tips-1440.png new file mode 100644 index 000000000..40d8cb048 Binary files /dev/null and b/docs/time-and-pay/screenshots/final-mgr-dark-people_tips-1440.png differ diff --git a/docs/time-and-pay/screenshots/final-mgr-dark-settings_time_rules-1440.png b/docs/time-and-pay/screenshots/final-mgr-dark-settings_time_rules-1440.png new file mode 100644 index 000000000..5f4d0f90e Binary files /dev/null and b/docs/time-and-pay/screenshots/final-mgr-dark-settings_time_rules-1440.png differ diff --git a/docs/time-and-pay/screenshots/final-mgr-people_approvals-1440.png b/docs/time-and-pay/screenshots/final-mgr-people_approvals-1440.png new file mode 100644 index 000000000..7f54ffd71 Binary files /dev/null and b/docs/time-and-pay/screenshots/final-mgr-people_approvals-1440.png differ diff --git a/docs/time-and-pay/screenshots/final-mgr-people_clock_devices-1440.png b/docs/time-and-pay/screenshots/final-mgr-people_clock_devices-1440.png new file mode 100644 index 000000000..6d9cd9ef4 Binary files /dev/null and b/docs/time-and-pay/screenshots/final-mgr-people_clock_devices-1440.png differ diff --git a/docs/time-and-pay/screenshots/final-mgr-people_pay_periods-1440.png b/docs/time-and-pay/screenshots/final-mgr-people_pay_periods-1440.png new file mode 100644 index 000000000..e7919ae42 Binary files /dev/null and b/docs/time-and-pay/screenshots/final-mgr-people_pay_periods-1440.png differ diff --git a/docs/time-and-pay/screenshots/final-mgr-people_schedule-1440.png b/docs/time-and-pay/screenshots/final-mgr-people_schedule-1440.png new file mode 100644 index 000000000..20b0cffae Binary files /dev/null and b/docs/time-and-pay/screenshots/final-mgr-people_schedule-1440.png differ diff --git a/docs/time-and-pay/screenshots/final-mgr-people_timesheets-1440.png b/docs/time-and-pay/screenshots/final-mgr-people_timesheets-1440.png new file mode 100644 index 000000000..ad813f557 Binary files /dev/null and b/docs/time-and-pay/screenshots/final-mgr-people_timesheets-1440.png differ diff --git a/docs/time-and-pay/screenshots/final-mgr-people_tips-1440.png b/docs/time-and-pay/screenshots/final-mgr-people_tips-1440.png new file mode 100644 index 000000000..d46a8eaef Binary files /dev/null and b/docs/time-and-pay/screenshots/final-mgr-people_tips-1440.png differ diff --git a/docs/time-and-pay/screenshots/final-mgr-settings_time_rules-1440.png b/docs/time-and-pay/screenshots/final-mgr-settings_time_rules-1440.png new file mode 100644 index 000000000..500c86edb Binary files /dev/null and b/docs/time-and-pay/screenshots/final-mgr-settings_time_rules-1440.png differ diff --git a/docs/time-and-pay/screenshots/final-staff-dark-employee-390.png b/docs/time-and-pay/screenshots/final-staff-dark-employee-390.png new file mode 100644 index 000000000..8f4f6c374 Binary files /dev/null and b/docs/time-and-pay/screenshots/final-staff-dark-employee-390.png differ diff --git a/docs/time-and-pay/screenshots/final-staff-dark-employee_devices-390.png b/docs/time-and-pay/screenshots/final-staff-dark-employee_devices-390.png new file mode 100644 index 000000000..09039758d Binary files /dev/null and b/docs/time-and-pay/screenshots/final-staff-dark-employee_devices-390.png differ diff --git a/docs/time-and-pay/screenshots/final-staff-dark-employee_notifications_settings-390.png b/docs/time-and-pay/screenshots/final-staff-dark-employee_notifications_settings-390.png new file mode 100644 index 000000000..e08238eeb Binary files /dev/null and b/docs/time-and-pay/screenshots/final-staff-dark-employee_notifications_settings-390.png differ diff --git a/docs/time-and-pay/screenshots/final-staff-dark-employee_pay-390.png b/docs/time-and-pay/screenshots/final-staff-dark-employee_pay-390.png new file mode 100644 index 000000000..17f4659f9 Binary files /dev/null and b/docs/time-and-pay/screenshots/final-staff-dark-employee_pay-390.png differ diff --git a/docs/time-and-pay/screenshots/final-staff-dark-employee_schedule-390.png b/docs/time-and-pay/screenshots/final-staff-dark-employee_schedule-390.png new file mode 100644 index 000000000..8d9156cf4 Binary files /dev/null and b/docs/time-and-pay/screenshots/final-staff-dark-employee_schedule-390.png differ diff --git a/docs/time-and-pay/screenshots/final-staff-dark-employee_time_off-390.png b/docs/time-and-pay/screenshots/final-staff-dark-employee_time_off-390.png new file mode 100644 index 000000000..9778024a8 Binary files /dev/null and b/docs/time-and-pay/screenshots/final-staff-dark-employee_time_off-390.png differ diff --git a/docs/time-and-pay/screenshots/final-staff-dark-employee_trades-390.png b/docs/time-and-pay/screenshots/final-staff-dark-employee_trades-390.png new file mode 100644 index 000000000..753cc3c8b Binary files /dev/null and b/docs/time-and-pay/screenshots/final-staff-dark-employee_trades-390.png differ diff --git a/docs/time-and-pay/screenshots/final-staff-dark-time_clock-390.png b/docs/time-and-pay/screenshots/final-staff-dark-time_clock-390.png new file mode 100644 index 000000000..1ca831eb4 Binary files /dev/null and b/docs/time-and-pay/screenshots/final-staff-dark-time_clock-390.png differ diff --git a/docs/time-and-pay/screenshots/final-staff-employee-390.png b/docs/time-and-pay/screenshots/final-staff-employee-390.png new file mode 100644 index 000000000..e20562d8f Binary files /dev/null and b/docs/time-and-pay/screenshots/final-staff-employee-390.png differ diff --git a/docs/time-and-pay/screenshots/final-staff-employee_devices-390.png b/docs/time-and-pay/screenshots/final-staff-employee_devices-390.png new file mode 100644 index 000000000..542eb2e14 Binary files /dev/null and b/docs/time-and-pay/screenshots/final-staff-employee_devices-390.png differ diff --git a/docs/time-and-pay/screenshots/final-staff-employee_notifications_settings-390.png b/docs/time-and-pay/screenshots/final-staff-employee_notifications_settings-390.png new file mode 100644 index 000000000..05fbf1b1b Binary files /dev/null and b/docs/time-and-pay/screenshots/final-staff-employee_notifications_settings-390.png differ diff --git a/docs/time-and-pay/screenshots/final-staff-employee_pay-390.png b/docs/time-and-pay/screenshots/final-staff-employee_pay-390.png new file mode 100644 index 000000000..f7b238268 Binary files /dev/null and b/docs/time-and-pay/screenshots/final-staff-employee_pay-390.png differ diff --git a/docs/time-and-pay/screenshots/final-staff-employee_schedule-390.png b/docs/time-and-pay/screenshots/final-staff-employee_schedule-390.png new file mode 100644 index 000000000..73be504c5 Binary files /dev/null and b/docs/time-and-pay/screenshots/final-staff-employee_schedule-390.png differ diff --git a/docs/time-and-pay/screenshots/final-staff-employee_time_off-390.png b/docs/time-and-pay/screenshots/final-staff-employee_time_off-390.png new file mode 100644 index 000000000..c76073306 Binary files /dev/null and b/docs/time-and-pay/screenshots/final-staff-employee_time_off-390.png differ diff --git a/docs/time-and-pay/screenshots/final-staff-employee_trades-390.png b/docs/time-and-pay/screenshots/final-staff-employee_trades-390.png new file mode 100644 index 000000000..500d897b9 Binary files /dev/null and b/docs/time-and-pay/screenshots/final-staff-employee_trades-390.png differ diff --git a/docs/time-and-pay/screenshots/final-staff-time_clock-390.png b/docs/time-and-pay/screenshots/final-staff-time_clock-390.png new file mode 100644 index 000000000..e88ec863c Binary files /dev/null and b/docs/time-and-pay/screenshots/final-staff-time_clock-390.png differ diff --git a/docs/time-and-pay/screenshots/ios-01-push-soft-ask.png b/docs/time-and-pay/screenshots/ios-01-push-soft-ask.png new file mode 100644 index 000000000..d8e2152e5 Binary files /dev/null and b/docs/time-and-pay/screenshots/ios-01-push-soft-ask.png differ diff --git a/docs/time-and-pay/screenshots/ios-02-time-signed-out.png b/docs/time-and-pay/screenshots/ios-02-time-signed-out.png new file mode 100644 index 000000000..bf0cf02d0 Binary files /dev/null and b/docs/time-and-pay/screenshots/ios-02-time-signed-out.png differ diff --git a/docs/time-and-pay/screenshots/ios-03-sign-in-page.png b/docs/time-and-pay/screenshots/ios-03-sign-in-page.png new file mode 100644 index 000000000..4d02e08d4 Binary files /dev/null and b/docs/time-and-pay/screenshots/ios-03-sign-in-page.png differ diff --git a/docs/time-and-pay/screenshots/ios-04-sign-in-form.png b/docs/time-and-pay/screenshots/ios-04-sign-in-form.png new file mode 100644 index 000000000..ac2095f64 Binary files /dev/null and b/docs/time-and-pay/screenshots/ios-04-sign-in-form.png differ diff --git a/docs/time-and-pay/screenshots/ios-05-time-home.png b/docs/time-and-pay/screenshots/ios-05-time-home.png new file mode 100644 index 000000000..707ad4632 Binary files /dev/null and b/docs/time-and-pay/screenshots/ios-05-time-home.png differ diff --git a/docs/time-and-pay/screenshots/ios-06-schedule.png b/docs/time-and-pay/screenshots/ios-06-schedule.png new file mode 100644 index 000000000..bc5cff5ee Binary files /dev/null and b/docs/time-and-pay/screenshots/ios-06-schedule.png differ diff --git a/docs/time-and-pay/screenshots/ios-07-time-off.png b/docs/time-and-pay/screenshots/ios-07-time-off.png new file mode 100644 index 000000000..d4f5e2f1a Binary files /dev/null and b/docs/time-and-pay/screenshots/ios-07-time-off.png differ diff --git a/docs/time-and-pay/screenshots/ios-08-pay.png b/docs/time-and-pay/screenshots/ios-08-pay.png new file mode 100644 index 000000000..252c95502 Binary files /dev/null and b/docs/time-and-pay/screenshots/ios-08-pay.png differ diff --git a/docs/time-and-pay/screenshots/ios-09-notifications.png b/docs/time-and-pay/screenshots/ios-09-notifications.png new file mode 100644 index 000000000..843c68ecd Binary files /dev/null and b/docs/time-and-pay/screenshots/ios-09-notifications.png differ diff --git a/docs/time-and-pay/screenshots/ios-10-app-settings.png b/docs/time-and-pay/screenshots/ios-10-app-settings.png new file mode 100644 index 000000000..b679601ec Binary files /dev/null and b/docs/time-and-pay/screenshots/ios-10-app-settings.png differ diff --git a/ios/.gitignore b/ios/.gitignore index bd352f946..dd80e4d3d 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -3,3 +3,4 @@ xtool/ *.ipa .bsp/ .swiftpm/ +RupertApp/RupertApp.xcodeproj/ diff --git a/ios/AppStore/checklist.md b/ios/AppStore/checklist.md index 208620983..fa11dfc86 100644 --- a/ios/AppStore/checklist.md +++ b/ios/AppStore/checklist.md @@ -1,30 +1,40 @@ -# From TestFlight to the App Store +# Resubmitting 1.1.0 (time, scheduling and pay) to the App Store -What App Store review needs that TestFlight (internal testers) did not. **Done** = in this repo or in -the build; **Owner** = a click in App Store Connect or a secret only the owner has. +One page: everything App Review needs, who does it, and where it lives. **Done** = in this repo or in +the build; **Owner** = a click in App Store Connect or a secret only the owner has. The last line is +the one click nobody but the owner makes. + +## Build and ship + +| # | Item | Status | Where | +|---|---|---|---| +| 1 | Version 1.1.0, new build number | Done: `CFBundleShortVersionString` 1.1.0; build number is the UTC time at build | `Info.plist`, `ship.sh` | +| 2 | Time tab first, five tabs, Clock in quick action | Done | `Sources/Rupert/Tabs.swift`, `Info.plist` | +| 3 | Push notifications in the app (soft ask, APNs token to `/native/push_devices`, tap opens the page) | Done | `Sources/Rupert/PushRegistration.swift` | +| 4 | Push Notifications capability on the App ID + `aps-environment` entitlement in the signed build | **Owner**: tick Push Notifications on the `com.rupertpos.Rupert` App ID (Certificates, Identifiers & Profiles) and regenerate the distribution profile; `ship.sh`/Xcode then signs with `RupertApp/RupertApp.entitlements` (production in archives) | Apple Developer | +| 5 | APNs key on the server | **Owner**: create a Key (Apple Push Notifications service) in App Store Connect → Users and Access → Keys, download the `.p8` once, set `APNS_KEY_ID`, `APNS_TEAM_ID`, `APNS_KEY_P8`, `APNS_TOPIC=com.rupertpos.Rupert`, `APNS_ENVIRONMENT=production` in `/etc/rupert.env` on SER8 and restart `rupert-web` and `rupert-jobs`. Until then the app still works: notifications show in the bell only | `docs/ops/environment-reference.md` | +| 6 | Web Push keys (installed web app, optional) | **Owner**: `bin/rails runner 'k = WebPush.generate_key; puts k.public_key, k.private_key'`, set `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT=mailto:sdolbec@rupertpos.com` | same | +| 7 | Unit tests (Swift) and the Simulator flow | Done: `xcodebuild test -scheme Rupert …` (package) and `RupertApp/UITests` (sign in, Time pages, soft ask, settings) | `Tests/`, `RupertApp/UITests/` | +| 8 | Build on a Mac for TestFlight | Done: `cd ios/RupertApp && xcodegen generate`, open `RupertApp.xcodeproj`, Product → Archive (or keep using `ship.sh` on Linux) | `RupertApp/project.yml` | +| 9 | Export compliance | Done: `ITSAppUsesNonExemptEncryption = NO` (HTTPS only), no question per build | `Info.plist` | + +## Listing and privacy | # | Item | Status | Where | |---|---|---|---| -| 1 | Name, subtitle, description, keywords, promotional text | Done (text) · Owner pastes | `listing.md` | -| 2 | Category Business / Productivity | Owner picks | `listing.md` | -| 3 | Support URL https://pos.rupertpos.com/support | Done (page) | `web/app/views/public_pages/support.html.erb` | -| 4 | Marketing URL https://rupertpos.com | Already live | landing page | -| 5 | Privacy policy URL https://pos.rupertpos.com/privacy | Done (page) | `web/app/views/public_pages/privacy.html.erb` | -| 6 | App Privacy answers ("nutrition label") | Owner clicks, answers below | App Store Connect → App Privacy | -| 7 | Screenshots: iPhone 6.9" 1320×2868, iPad 13" 2064×2752 | Done | `screenshots/` | -| 8 | Age rating questionnaire → 13+ (see below) | Owner clicks, answers below | App Information → Age Rating | -| 9 | Export compliance | Done: `ITSAppUsesNonExemptEncryption = NO` in Info.plist (HTTPS only), so no question per build | `Info.plist` | -| 10 | Price: Free · Availability: all countries (or United States only) | Owner clicks | Pricing and Availability | -| 11 | Review notes and demo login | Notes done · demo shop task done · Owner runs it and types the password | `review-notes.md`, `rupert:app_review_demo` | -| 12 | Account deletion in the app (5.1.1(v)) | Done: account menu → Delete my account; App settings → Delete account | `AccountDeletionsController` | -| 13 | No purchases in the app (3.1.1) | Done: billing hidden and refused in the app; sign-up hidden in the app | `_shell.html.erb`, `BillingController`, `sessions/new` | -| 14 | Sign in with Apple (4.8) | Not needed: the app has no sign-up; it signs in accounts a business already made | `sessions/new` | -| 15 | Minimum functionality, web wrapper risk (4.2) | Done: native features below | `ios/Sources/Rupert` | -| 16 | Privacy manifest: required-reason APIs | Done: UserDefaults CA92.1 added | `PrivacyInfo.xcprivacy` | -| 17 | Usage strings for camera, photos, microphone, Face ID | Done | `Info.plist` | -| 18 | Version 1.0.0, new build number | Done: build number is the UTC time at build | `ship.sh` | -| 19 | Support mailbox | Default is sdolbec@rupertpos.com (the only rupertpos.com mailbox); set `RUPERT_SUPPORT_EMAIL` to change it | `/etc/rupert.env` | -| 20 | Submit for review | Owner only | App Store Connect | +| 10 | What's new, description, keywords | Done (text) · Owner pastes | `listing.md` | +| 11 | Screenshots: iPhone 6.9" 1320×2868, iPad 13" 2064×2752 | Done: regenerated 2026-11-06 on the merged clock, schedule, time-off and pay pages (`screenshots/make-screenshots.sh`, signs in as the worker for the Time pages) | `screenshots/` | +| 12 | App Privacy answers | Owner clicks; answers below (unchanged by push: an APNs token is not collected data under Apple's labels) | App Store Connect → App Privacy | +| 13 | Privacy manifest | Done: UserDefaults CA92.1 only; push and notifications need no required-reason API | `PrivacyInfo.xcprivacy` | +| 14 | Usage strings | Done: camera, photos, microphone, Face ID unchanged; notifications need none (the system prompt has its own text; the app explains first) | `Info.plist` | +| 15 | Age rating | Owner clicks: unchanged, 13+ | App Information | +| 16 | Review notes and demo logins | Done · Owner runs `rupert:app_review_demo` on SER8 and types the two passwords | `review-notes.md` | +| 17 | Account deletion in the app (5.1.1(v)) | Done, and now works for staff accounts too; deletes push tokens and the clock PIN | `AccountDeletionsController`, `AccountDeletion` | +| 18 | No purchases, no sign-up in the app (3.1.1) | Done, unchanged | `sessions/new`, `BillingController` | +| 19 | Sign in with Apple (4.8) | Not required: the app only signs in accounts a business already created (Rupert's own email/password system; Google is an alternative sign-in for existing accounts, not account creation). Apple's 4.8 exception for apps that "exclusively use your company's own account setup and sign-in systems" and for enterprise accounts applies. If review disagrees, reply in Resolution Center with this and, only then, add Sign in with Apple (see `docs/time-and-pay/DECISIONS.md`, "App Store") | `sessions/new` | +| 20 | Minimum functionality (4.2) | Done: native features below | `Sources/Rupert` | +| 21 | Support mailbox | Default `sdolbec@rupertpos.com`; `RUPERT_SUPPORT_EMAIL` to change | `/etc/rupert.env` | +| 22 | **Submit for Review** | **Owner only** | App Store Connect | ## App Privacy answers @@ -38,37 +48,39 @@ Apple counts what Rupert's server collects through the app, not only the app's o | Contact Info → Email Address | Yes | Yes | No | App Functionality | | User Content → Photos or Videos | Yes (attachments) | Yes | No | App Functionality | | User Content → Customer Support | Yes (Report a problem) | Yes | No | App Functionality | -| User Content → Other User Content (chat, Ask Rupert, business records) | Yes | Yes | No | App Functionality | +| User Content → Other User Content (chat, Ask Rupert, business records, hours and time-off requests) | Yes | Yes | No | App Functionality | | Identifiers → User ID | Yes | Yes | No | App Functionality | | Diagnostics → Other Diagnostic Data (page details sent with Report a problem) | Yes | Yes | No | App Functionality | -Everything else: **not collected** (location, contacts, health, financial info of the user, browsing -history, search history, purchases, sensitive info, audio data, advertising data, crash data, -performance data). Tracking: **No**. Payments happen on the website, through Stripe, not in the app. +Everything else: **not collected**: no precise or coarse location (the clock uses a PIN and a +registered store device, never GPS), no contacts, health, financial info of the user, browsing or +search history, purchases, sensitive info, audio data, advertising data, crash data, performance +data. Tracking: **No**. Payments happen on the website, through Stripe, not in the app. The APNs +device token is a delivery address held by Apple's push service and Rupert's server; Apple's +privacy label guidance does not treat it as a collected data type. ## Age rating answers -Updated 2026-11-02 (`docs/ios/app-review-readiness.md`): merchants' catalogs in the app can list -vapes and hemp/THC products, so **Alcohol, Tobacco, or Drug Use or References: Infrequent** (13+; -choose 18+ if hemp and vape shops become the main customers). Every other content question -(violence, sexual content, profanity, horror, gambling, contests, medical): **None**. Unrestricted web access: **No** (the app only opens Rupert's own -sites; other links open in a Safari view). User-generated content: Team chat is private to a -business's own staff, not public: answer **No** for "user-generated content shared publicly". -Messaging and chat: **Yes** if asked (private team chat). Result: **13+**. +Merchants' catalogs in the app can list vapes and hemp/THC products, so **Alcohol, Tobacco, or Drug +Use or References: Infrequent** (13+). Every other content question: **None**. Unrestricted web +access: **No**. User-generated content shared publicly: **No** (Team chat is private to a business's +own staff). Messaging and chat: **Yes**. Result: **13+**. ## Guideline 4.2: why this is more than a website -Web wrappers get rejected when they add nothing a browser does not. Rupert's app adds: +1. **Push notifications** for schedule posted or changed, requests decided, shift offers, forgotten + clock-outs and break reminders (UNUserNotificationCenter, APNs), with a tap opening the page. +2. **Camera barcode scanning** for inventory counts (VisionKit `DataScannerViewController`). +3. **Face ID / Touch ID / passcode lock** (LocalAuthentication), with a cover in the app switcher. +4. **Native App settings screen** (SwiftUI): lock, workspace, help, privacy, account deletion, version. +5. **Home Screen quick actions**: Clock in, Team chat, Ask Rupert, Notifications. +6. **Native tab bar** with its own back stack per tab, native sheets for forms, pull to refresh. +7. **System Google sign-in sheet** (ASWebAuthenticationSession) with a PKCE code handoff. +8. **Native top bar** that follows the page's colours in all five appearances. -1. **Camera barcode scanning** for inventory counts (VisionKit `DataScannerViewController`), with a - haptic on each read. Not possible in mobile Safari with the same reliability. -2. **Face ID / Touch ID / passcode lock** (LocalAuthentication), with a cover in the app switcher. -3. **Native App settings screen** (SwiftUI): lock, workspace, help, privacy, account deletion, version. -4. **Home Screen quick actions** to Team chat, Ask Rupert, Notifications and Search. -5. **Native tab bar** with its own back stack per tab, native modal sheets for forms, pull to refresh. -6. **System Google sign-in sheet** (ASWebAuthenticationSession) with a PKCE code handoff. -7. **Native top bar** that follows the page's colours in all five appearances. +## After the other time & pay streams merge -If review still answers 4.2, the next step is **push notifications** for Rupert's notifications -(needs an APNs key from the owner and a server sender). Reply in Resolution Center with the list -above first. +- Regenerate `screenshots/` (item 11) and the `docs/time-and-pay/screenshots/ios-*.png` set + (`RupertApp/UITests` with `TEST_RUNNER_RUPERT_UI_SHOTS`). +- Re-read `review-notes.md` steps against the live demo workspace once (PIN clock on the POS page, + time off, schedule, pay). diff --git a/ios/AppStore/listing.md b/ios/AppStore/listing.md index a7741c929..806bb4bc8 100644 --- a/ios/AppStore/listing.md +++ b/ios/AppStore/listing.md @@ -17,12 +17,26 @@ Paste these into App Store Connect → TM-Rupert (Apple ID 6826466832). Characte The App Store name must be unique. The record is called "TM-Rupert" today; change it to the name above, or keep "TM-Rupert" if Apple says the name is taken. -## Version 1.0.0 +## Version 1.1.0 + +**What's new in this version [4000]** + +``` +Time, schedule and pay, in the first tab. +• Clock in and out, take breaks and meals, and fix a punch with a reason. +• See your published shifts, pick up open shifts, offer or swap yours. +• Request time off in seconds and watch its status. +• Pay: hours this period, estimated gross, card and cash tips, next payday. +• Push notifications when your schedule is posted, a request is decided, a shift is offered, or you forgot to clock out. +• Managers: approvals, timesheets and schedules on the phone. +``` + +## Version 1.0.0 (previous) **Promotional text [170]** ``` -Run your shop from your phone: today's sales, stock counts with the camera, Team chat and Ask Rupert, in one place. +Clock in, see your shifts, request time off and check your pay, plus sales, stock counts with the camera, Team chat and Ask Rupert. ``` **Description [4000]** @@ -42,6 +56,13 @@ TEAM CHAT AND ASK RUPERT • Talk with your team in Team chat, with photos and files. • Ask Rupert questions about your business in plain words: "How did we do last Saturday?" or "What's running low?" +TIME, SCHEDULE AND PAY FOR YOUR TEAM +• Clock in and out in one tap, with breaks and meals; a PIN clock on the shop's register. +• Your shifts, open shifts to pick up, swaps and give-aways with your manager's approval. +• Time off requests and balances, and push notifications when your schedule is posted or a request is decided. +• Your pay at a glance: hours, estimated gross, card and cash tips, next payday. +• Managers approve corrections, time off, trades and timesheets from the phone. + EVERYTHING IN ONE PLACE • Sales and customers from the systems your shop already uses, such as Shopify and Square. • Inventory, purchasing and counts across locations. @@ -50,7 +71,7 @@ EVERYTHING IN ONE PLACE BUILT FOR THE SHOP FLOOR • Lock the app with Face ID, so a shared phone does not show your numbers. -• Home Screen quick actions jump straight to Team chat, Ask Rupert, Notifications or Search. +• Home Screen quick actions jump straight to Clock in, Team chat, Ask Rupert or Notifications. • Follows your workspace's colours and appearance. Rupert is for businesses that use Rupert. Sign in with the account your business gave you, with Google or with email and password. @@ -59,7 +80,7 @@ Rupert is for businesses that use Rupert. Sign in with the account your business **Keywords [100]** ``` -retail,inventory,pos,shop,store,sales,stock count,barcode,small business,timesheet,team chat +retail,inventory,pos,shop,sales,stock count,barcode,small business,time clock,schedule,timesheet,tips ``` **Support URL:** https://pos.rupertpos.com/support diff --git a/ios/AppStore/review-notes.md b/ios/AppStore/review-notes.md index dee082f0d..710885c8d 100644 --- a/ios/AppStore/review-notes.md +++ b/ios/AppStore/review-notes.md @@ -1,8 +1,9 @@ # App Review information -App Store Connect → version 1.0.0 → App Review Information. +App Store Connect → version 1.1.0 → App Review Information. -**Sign-in required:** yes. +**Sign-in required:** yes. Two accounts: a manager (the review account) and an hourly worker, so +the reviewer sees both sides of the time and pay features. | Field | Value | |---|---| @@ -10,22 +11,53 @@ App Store Connect → version 1.0.0 → App Review Information. | Password | (the owner sets it; see owner steps) | | Contact | the owner's name, phone and email | -**Notes** (paste as is): +**Notes** (paste as is; fill in the worker password where marked): ``` -Rupert is back-office software for independent retail shops (sales, inventory, staff, bookkeeping). Accounts are created by a business, so we made a demo shop for you: "Juniper & Pine Market", filled with sample data. No real customer data is in it. +Rupert is back-office software for independent retail shops (sales, inventory, staff, time and pay, bookkeeping). Accounts are created by a business, so we made a demo shop for you: "Juniper & Pine Market", filled with sample data. No real customer or employee data is in it. -To sign in: on the first screen, tap "Sign in with email and password", then use the user name and password above. (Google sign-in is for business Google accounts.) +TWO LOGINS +- Manager: the user name and password above (Sam Porter, admin of the demo shop). +- Hourly worker: sam.okafor@juniper-pine.example, password: . The worker's clock PIN is 2580. -Native features to try: -1. Barcode scanning with the camera (VisionKit): Home → menu → Inventory → Counts → open the draft count → Scan → "Scan with camera". Point it at any product barcode; the code is saved to the count. -2. Face ID lock: tap the person icon at the top right of the Home tab → App settings → "Require Face ID". Leave and return to the app; it asks for Face ID and hides its pages in the app switcher. -3. Home Screen quick actions: touch and hold the Rupert icon → Team Chat, Ask Rupert, Notifications or Search. -4. Native tab bar with a separate navigation stack per tab, native sheets for forms, pull to refresh, and Google sign-in in the system authentication sheet (ASWebAuthenticationSession). +To sign in: on the first screen, tap "Sign in with email and password", then enter the details. (Google sign-in is for business Google accounts.) + +WHAT'S NEW IN 1.1.0: TIME, SCHEDULE AND PAY (the Time tab, first in the tab bar) +As the worker: +1. Time tab → "Clock in" (one tap). The card shows the running time; "Break" / "Meal" and "Clock out" are on the same card. On the shop's register (POS → clock button) the same worker clocks in with PIN 2580. +2. Schedule: next week's published shifts, the team's shifts, and an open Saturday shift with "Pick up". +3. Time off: balances, a request form (pick Vacation / Sick / Personal, dates, send), one approved and one waiting request. +4. Pay: this period's hours, estimated gross, card tips shared with a colleague and declared cash tips, next payday. Estimated vs final is labelled. +5. Timesheets: last week's sheet, submitted for the manager. +As the manager (review account): Home → menu → Team → Approvals shows the worker's waiting request and timesheet with Approve / Deny. + +PUSH NOTIFICATIONS +After the first sign-in the app explains what notifications are for and then shows the system prompt ("Turn on"). They cover: schedule posted or changed, a request decided, a shift offered, a forgotten clock-out, a break reminder. Each kind can be switched off in Time → Settings → Notifications. If the server's push key is not yet installed on the review date, the same messages appear in the Notifications tab (the bell), which is the fallback the app is designed around. + +OTHER NATIVE FEATURES +- Barcode scanning with the camera (VisionKit): Home → menu → Inventory → Counts → open the draft count → Scan → "Scan with camera". +- Face ID lock: person icon at the top right of a tab's first page → App settings → "Require Face ID". +- Home Screen quick actions: touch and hold the Rupert icon → Clock in, Team Chat, Ask Rupert or Notifications. +- Native tab bar with a separate navigation stack per tab, native sheets for forms, pull to refresh, Google sign-in in the system authentication sheet (ASWebAuthenticationSession). Ask Rupert (the Ask Rupert tab) is an AI assistant for questions about using Rupert and about the shop's own records. The panel says which AI model writes the answers and that the question and the records it looks up are sent to that service. The demo shop's data is fictional. -Account deletion: person icon on the Home tab → "Delete account", or the account menu (initials, top right of a page) → "Delete my account". Please do not delete the review account, or the next reviewer cannot sign in. +Account deletion: person icon on a tab's first page → "Delete account", or the account menu (initials, top right of a page) → "Delete my account". It works for both logins. Please do not delete the review accounts, or the next reviewer cannot sign in. -Subscriptions: the app sells nothing. Businesses subscribe on our website; the app does not show prices, plans or purchase links (guideline 3.1.3(c), enterprise services sold to organizations). Account sign-up is not offered in the app; it signs in accounts a business already has, so Sign in with Apple is not required (4.8). +Subscriptions: the app sells nothing. Businesses subscribe on our website; the app does not show prices, plans or purchase links (guideline 3.1.3(c), enterprise services sold to organizations). Account sign-up is not offered in the app; it signs in accounts a business already has (our own email/password accounts; Google is an alternative sign-in for those same accounts), so Sign in with Apple is not required (4.8). ``` + +## Owner steps + +1. On SER8, run the demo task with both passwords (never printed or stored in Git): + + ``` + REVIEW_PASSWORD='…' REVIEW_WORKER_PASSWORD='…' bin/rails rupert:app_review_demo + ``` + + Add `REVIEW_DEMO_RESET=1` to rebuild the sample data with today's dates before a new review. + The task is idempotent: running it again refreshes nothing it already made and keeps the + passwords it is given. +2. Paste the notes above, replacing ``, and type the manager password in the + User name / Password fields. +3. Check the two logins once in the TestFlight build before pressing Submit for Review. diff --git a/ios/AppStore/screenshots/ipad-13-1-home.png b/ios/AppStore/screenshots/ipad-13-1-home.png deleted file mode 100644 index 711e02fdd..000000000 Binary files a/ios/AppStore/screenshots/ipad-13-1-home.png and /dev/null differ diff --git a/ios/AppStore/screenshots/ipad-13-1-time.png b/ios/AppStore/screenshots/ipad-13-1-time.png new file mode 100644 index 000000000..74118d024 Binary files /dev/null and b/ios/AppStore/screenshots/ipad-13-1-time.png differ diff --git a/ios/AppStore/screenshots/ipad-13-2-inventory.png b/ios/AppStore/screenshots/ipad-13-2-inventory.png deleted file mode 100644 index 4b34c1795..000000000 Binary files a/ios/AppStore/screenshots/ipad-13-2-inventory.png and /dev/null differ diff --git a/ios/AppStore/screenshots/ipad-13-4-team-chat.png b/ios/AppStore/screenshots/ipad-13-4-team-chat.png deleted file mode 100644 index 5ea119fc8..000000000 Binary files a/ios/AppStore/screenshots/ipad-13-4-team-chat.png and /dev/null differ diff --git a/ios/AppStore/screenshots/ipad-13-5-home.png b/ios/AppStore/screenshots/ipad-13-5-home.png new file mode 100644 index 000000000..b33976d09 Binary files /dev/null and b/ios/AppStore/screenshots/ipad-13-5-home.png differ diff --git a/ios/AppStore/screenshots/ipad-13-5-notifications.png b/ios/AppStore/screenshots/ipad-13-5-notifications.png deleted file mode 100644 index 0adcb231e..000000000 Binary files a/ios/AppStore/screenshots/ipad-13-5-notifications.png and /dev/null differ diff --git a/ios/AppStore/screenshots/ipad-13-6-inventory.png b/ios/AppStore/screenshots/ipad-13-6-inventory.png new file mode 100644 index 000000000..5715b53e8 Binary files /dev/null and b/ios/AppStore/screenshots/ipad-13-6-inventory.png differ diff --git a/ios/AppStore/screenshots/ipad-13-8-team-chat.png b/ios/AppStore/screenshots/ipad-13-8-team-chat.png new file mode 100644 index 000000000..a5749905a Binary files /dev/null and b/ios/AppStore/screenshots/ipad-13-8-team-chat.png differ diff --git a/ios/AppStore/screenshots/ipad-13-9-notifications.png b/ios/AppStore/screenshots/ipad-13-9-notifications.png new file mode 100644 index 000000000..7201290d2 Binary files /dev/null and b/ios/AppStore/screenshots/ipad-13-9-notifications.png differ diff --git a/ios/AppStore/screenshots/iphone-69-1-home.png b/ios/AppStore/screenshots/iphone-69-1-home.png deleted file mode 100644 index ecf20e92b..000000000 Binary files a/ios/AppStore/screenshots/iphone-69-1-home.png and /dev/null differ diff --git a/ios/AppStore/screenshots/iphone-69-1-time.png b/ios/AppStore/screenshots/iphone-69-1-time.png new file mode 100644 index 000000000..f88afe37c Binary files /dev/null and b/ios/AppStore/screenshots/iphone-69-1-time.png differ diff --git a/ios/AppStore/screenshots/iphone-69-2-inventory.png b/ios/AppStore/screenshots/iphone-69-2-inventory.png deleted file mode 100644 index 58711d092..000000000 Binary files a/ios/AppStore/screenshots/iphone-69-2-inventory.png and /dev/null differ diff --git a/ios/AppStore/screenshots/iphone-69-2-schedule.png b/ios/AppStore/screenshots/iphone-69-2-schedule.png new file mode 100644 index 000000000..6c8c03281 Binary files /dev/null and b/ios/AppStore/screenshots/iphone-69-2-schedule.png differ diff --git a/ios/AppStore/screenshots/iphone-69-3-scan-count.png b/ios/AppStore/screenshots/iphone-69-3-scan-count.png deleted file mode 100644 index 6717b7933..000000000 Binary files a/ios/AppStore/screenshots/iphone-69-3-scan-count.png and /dev/null differ diff --git a/ios/AppStore/screenshots/iphone-69-3-time-off.png b/ios/AppStore/screenshots/iphone-69-3-time-off.png new file mode 100644 index 000000000..b4852ca40 Binary files /dev/null and b/ios/AppStore/screenshots/iphone-69-3-time-off.png differ diff --git a/ios/AppStore/screenshots/iphone-69-4-pay.png b/ios/AppStore/screenshots/iphone-69-4-pay.png new file mode 100644 index 000000000..a7438558f Binary files /dev/null and b/ios/AppStore/screenshots/iphone-69-4-pay.png differ diff --git a/ios/AppStore/screenshots/iphone-69-4-team-chat.png b/ios/AppStore/screenshots/iphone-69-4-team-chat.png deleted file mode 100644 index 410acc7ec..000000000 Binary files a/ios/AppStore/screenshots/iphone-69-4-team-chat.png and /dev/null differ diff --git a/ios/AppStore/screenshots/iphone-69-5-home.png b/ios/AppStore/screenshots/iphone-69-5-home.png new file mode 100644 index 000000000..800290fd5 Binary files /dev/null and b/ios/AppStore/screenshots/iphone-69-5-home.png differ diff --git a/ios/AppStore/screenshots/iphone-69-5-notifications.png b/ios/AppStore/screenshots/iphone-69-5-notifications.png deleted file mode 100644 index 8bd5699e0..000000000 Binary files a/ios/AppStore/screenshots/iphone-69-5-notifications.png and /dev/null differ diff --git a/ios/AppStore/screenshots/iphone-69-6-inventory.png b/ios/AppStore/screenshots/iphone-69-6-inventory.png new file mode 100644 index 000000000..628275325 Binary files /dev/null and b/ios/AppStore/screenshots/iphone-69-6-inventory.png differ diff --git a/ios/AppStore/screenshots/iphone-69-7-scan-count.png b/ios/AppStore/screenshots/iphone-69-7-scan-count.png new file mode 100644 index 000000000..d02ab9c06 Binary files /dev/null and b/ios/AppStore/screenshots/iphone-69-7-scan-count.png differ diff --git a/ios/AppStore/screenshots/iphone-69-8-team-chat.png b/ios/AppStore/screenshots/iphone-69-8-team-chat.png new file mode 100644 index 000000000..a5e91ff9f Binary files /dev/null and b/ios/AppStore/screenshots/iphone-69-8-team-chat.png differ diff --git a/ios/AppStore/screenshots/iphone-69-9-notifications.png b/ios/AppStore/screenshots/iphone-69-9-notifications.png new file mode 100644 index 000000000..45c6876c8 Binary files /dev/null and b/ios/AppStore/screenshots/iphone-69-9-notifications.png differ diff --git a/ios/AppStore/screenshots/make-screenshots.mjs b/ios/AppStore/screenshots/make-screenshots.mjs index b8c9f8a22..e8e5388af 100755 --- a/ios/AppStore/screenshots/make-screenshots.mjs +++ b/ios/AppStore/screenshots/make-screenshots.mjs @@ -24,8 +24,10 @@ import { fileURLToPath } from "node:url"; const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || "playwright"); const EMAIL = "appreview@rupertpos.com"; +// The hourly worker the Time tab screenshots sign in as (rupert:app_review_demo, REVIEW_WORKER_PASSWORD or the review password). +const WORKER_EMAIL = "sam.okafor@juniper-pine.example"; const ua = (device, os) => - `Rupert iOS/1.0.0 (1); Hotwire Native iOS; Turbo Native iOS; Mozilla/5.0 (${device}; CPU ${os} 18_0 like Mac OS X) ` + + `Rupert iOS/1.1.0 (1); Hotwire Native iOS; Turbo Native iOS; Mozilla/5.0 (${device}; CPU ${os} 18_0 like Mac OS X) ` + "AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"; const ACCENT = "#0E6F72"; // AccentColor.colorset (light): the window tint, so the selected tab's colour. const INACTIVE = "#8E8E93"; // systemGray, unselected tab items. @@ -42,15 +44,20 @@ const DEVICES = { "ipad-13": { width: 1032, height: 1376, scale: 2, status: 24, nav: 50, tabs: 0, uaDevice: "iPad", uaOs: "OS", floating: true }, }; -const TABS = [["home", "Home"], ["chat", "Team Chat"], ["ask", "Ask Rupert"], ["inbox", "Notifications"], ["search", "Search"]]; +const TABS = [["time", "Time"], ["home", "Home"], ["chat", "Team Chat"], ["ask", "Ask Rupert"], ["inbox", "Notifications"]]; -// file stem, path ({count} is the draft count id), selected tab, devices, element to scroll to the top (optional) +// file stem, path ({count} is the draft count id), selected tab, devices, element to scroll to the top (optional), account +// (default the admin reviewer; "worker" is the hourly worker, whose Time tab is the first thing a staff member sees) const SCREENS = [ - ["1-home", "/", "home", ["iphone-69", "ipad-13"]], - ["2-inventory", "/inventory", "home", ["iphone-69", "ipad-13"], ".stock-viewbar"], - ["3-scan-count", "/inventory_counts/{count}/scan", "home", ["iphone-69"], '[data-inventory-count-scanner-target="form"]'], - ["4-team-chat", "/chat", "chat", ["iphone-69", "ipad-13"]], - ["5-notifications", "/notifications", "inbox", ["iphone-69", "ipad-13"]], + ["1-time", "/employee", "time", ["iphone-69", "ipad-13"], null, "worker"], + ["2-schedule", "/employee/schedule", "time", ["iphone-69"], null, "worker"], + ["3-time-off", "/employee/time-off", "time", ["iphone-69"], null, "worker"], + ["4-pay", "/employee/pay", "time", ["iphone-69"], null, "worker"], + ["5-home", "/", "home", ["iphone-69", "ipad-13"]], + ["6-inventory", "/inventory", "home", ["iphone-69", "ipad-13"], ".stock-viewbar"], + ["7-scan-count", "/inventory_counts/{count}/scan", "home", ["iphone-69"], '[data-inventory-count-scanner-target="form"]'], + ["8-team-chat", "/chat", "chat", ["iphone-69", "ipad-13"]], + ["9-notifications", "/notifications", "inbox", ["iphone-69", "ipad-13"]], ]; // Simple glyphs in the spirit of the SF Symbols the app uses (Tabs.swift), on a 28 x 28 grid. @@ -61,6 +68,7 @@ const star = (x, y, r) => { `C${x - k} ${y + k} ${x - k} ${y + k} ${x - r} ${y}C${x - k} ${y - k} ${x - k} ${y - k} ${x} ${y - r}z" fill="currentColor"/>`; }; const ICONS = { + time: ``, home: ``, chat: `` + ``, @@ -168,7 +176,7 @@ function frameHtml(dev, png, info, selected, root) { const FORBIDDEN = ["localhost", "127.0.0.1", "Herbal Healers", "Exception", "Routing Error", "We're sorry", "Something went wrong"]; -async function capture(browser, name, dev, { baseUrl, password, countId, out }) { +async function signedIn(browser, dev, baseUrl, email, password) { const viewport = { width: dev.width, height: dev.height - dev.status - dev.nav - dev.tabs }; const context = await browser.newContext({ viewport, deviceScaleFactor: dev.scale, isMobile: true, hasTouch: true, userAgent: ua(dev.uaDevice, dev.uaOs), @@ -177,14 +185,23 @@ async function capture(browser, name, dev, { baseUrl, password, countId, out }) await context.addInitScript(NATIVE_BRIDGE); const page = await context.newPage(); await page.goto(`${baseUrl}/login`, { waitUntil: "networkidle" }); - await page.fill('input[name="email"]', EMAIL); + await page.evaluate(() => document.querySelector("details.login-password")?.setAttribute("open", "")); + await page.fill('input[name="email"]', email); await page.fill('input[name="password"]', password); await Promise.all([page.waitForURL((url) => !url.pathname.startsWith("/login")), page.click('input[type="submit"][value="Sign in"]')]); + return { context, page }; +} + +async function capture(browser, name, dev, { baseUrl, password, workerPassword, countId, out }) { + const admin = await signedIn(browser, dev, baseUrl, EMAIL, password); + const worker = await signedIn(browser, dev, baseUrl, WORKER_EMAIL, workerPassword); + const context = admin.context; const framer = await browser.newContext({ viewport: { width: dev.width, height: dev.height }, deviceScaleFactor: dev.scale }); const framePage = await framer.newPage(); - for (const [stem, path, tab, devices, scrollTo] of SCREENS) { + for (const [stem, path, tab, devices, scrollTo, account] of SCREENS) { if (!devices.includes(name)) continue; + const page = account === "worker" ? worker.page : admin.page; await page.goto(baseUrl + path.replace("{count}", countId), { waitUntil: "networkidle" }); await page.waitForTimeout(900); // charts and Stimulus controllers settle await page.evaluate((selector) => { @@ -202,19 +219,21 @@ async function capture(browser, name, dev, { baseUrl, password, countId, out }) if (!info.nativeApp) problems.push("body is not .native-app"); if (problems.length) console.error(`warning: ${name}-${stem} (${page.url()}): ${problems.join(", ")}`); const png = await page.screenshot({ animations: "disabled", caret: "hide" }); - const root = path === "/" || tab !== "home"; + const root = path === "/" || path === "/employee" || tab !== "home"; await framePage.setContent(frameHtml(dev, png, info, tab, root), { waitUntil: "load" }); const target = join(out, `${name}-${stem}.png`); await framePage.screenshot({ path: target, animations: "disabled" }); console.log(`${name}-${stem}.png: ${info.title}`); } await context.close(); + await worker.context.close(); await framer.close(); } const args = Object.fromEntries(process.argv.slice(2).reduce((pairs, arg, i, all) => arg.startsWith("--") ? [...pairs, [arg.slice(2), all[i + 1]]] : pairs, [])); const password = process.env.REVIEW_PASSWORD; +const workerPassword = process.env.REVIEW_WORKER_PASSWORD || password; if (!password) { console.error("Set REVIEW_PASSWORD to the review account's password."); process.exit(1); } if (!args["count-id"]) { console.error("Pass --count-id ."); process.exit(1); } const out = args.out || dirname(fileURLToPath(import.meta.url)); @@ -222,7 +241,7 @@ mkdirSync(out, { recursive: true }); const browser = await chromium.launch(); try { for (const name of args.device ? [args.device] : Object.keys(DEVICES)) { - await capture(browser, name, DEVICES[name], { baseUrl: (args["base-url"] || "http://127.0.0.1:3990").replace(/\/$/, ""), password, countId: args["count-id"], out }); + await capture(browser, name, DEVICES[name], { baseUrl: (args["base-url"] || "http://127.0.0.1:3990").replace(/\/$/, ""), password, workerPassword, countId: args["count-id"], out }); } } finally { await browser.close(); diff --git a/ios/Info.plist b/ios/Info.plist index dadc595c6..755609584 100644 --- a/ios/Info.plist +++ b/ios/Info.plist @@ -5,7 +5,7 @@ CFBundleDisplayName Rupert CFBundleShortVersionString - 1.0.0 + 1.1.0 RupertControlPlaneURL https://pos.rupertpos.com ITSAppUsesNonExemptEncryption @@ -49,6 +49,14 @@ Rupert uses Face ID to unlock the app when you turn on Require Face ID in App settings. UIApplicationShortcutItems + + UIApplicationShortcutItemType + com.rupertpos.Rupert.time + UIApplicationShortcutItemTitle + Clock in + UIApplicationShortcutItemIconSymbolName + clock + UIApplicationShortcutItemType com.rupertpos.Rupert.chat @@ -73,15 +81,12 @@ UIApplicationShortcutItemIconSymbolName bell - - UIApplicationShortcutItemType - com.rupertpos.Rupert.search - UIApplicationShortcutItemTitle - Search - UIApplicationShortcutItemIconSymbolName - magnifyingglass - + NSAppTransportSecurity + + NSAllowsLocalNetworking + + NSMicrophoneUsageDescription Rupert uses the microphone when you record a video to attach to a chat message. diff --git a/ios/README.md b/ios/README.md index c51ce2f49..e4a1f17df 100644 --- a/ios/README.md +++ b/ios/README.md @@ -78,14 +78,46 @@ offline and uploads it. It never submits for review. ## Tests -`Tests/RupertTests` covers the session watcher, the bottom inset, Rupert's own sites and the -Info.plist privacy keys. On a Mac: `xcodebuild test -scheme Rupert -destination 'platform=iOS Simulator,name=iPhone 17'`. +`Tests/RupertTests` covers the session watcher, the bottom inset, Rupert's own sites, the Time tab, +notification deep links and the Info.plist privacy keys. On a Mac: +`xcodebuild test -scheme Rupert -destination 'platform=iOS Simulator,name=iPhone 18 Pro'`. App Review readiness and partner notes: `docs/ios/app-review-readiness.md`. -## Build on a Mac +## Build and run on a Mac (Simulator, TestFlight) -Open `ios/Package.swift` in Xcode 26 or later, or use any Hotwire Native app template and add -these sources. Nothing here is Linux-specific. +`RupertApp/project.yml` describes an Xcode project that compiles the same `Sources/Rupert` into a +runnable app (the package alone is a library). Generate it with [xcodegen](https://github.com/yonaskolb/XcodeGen); +the `.xcodeproj` is not committed. + +```bash +brew install xcodegen +cd ios/RupertApp && xcodegen generate +xcodebuild build -scheme RupertApp -destination 'platform=iOS Simulator,name=iPhone 18 Pro' CODE_SIGNING_ALLOWED=NO +xcrun simctl install booted /Build/Products/Debug-iphonesimulator/Rupert.app +SIMCTL_CHILD_RUPERT_DEV_ORIGIN=http://127.0.0.1:3000 xcrun simctl launch booted com.rupertpos.Rupert +``` + +`RUPERT_DEV_ORIGIN` (debug builds only, `Instance.swift`) points the whole app at a local Rupert; +the "RupertApp (local)" scheme sets it to `http://127.0.0.1:3005`. `RupertApp/UITests` signs in as +the demo worker and walks the Time tab, saving a screenshot of each step: + +```bash +TEST_RUNNER_RUPERT_UI_EMAIL=sam.okafor@juniper-pine.example TEST_RUNNER_RUPERT_UI_PASSWORD=… \ +TEST_RUNNER_RUPERT_UI_SHOTS=/abs/dir xcodebuild test -scheme RupertApp -only-testing:RupertAppUITests \ + -destination 'platform=iOS Simulator,name=iPhone 18 Pro' CODE_SIGNING_ALLOWED=NO +``` + +For TestFlight from a Mac: open `RupertApp.xcodeproj`, pick the team, Product → Archive. The +entitlements file turns on push (`aps-environment`); the App ID needs the Push Notifications +capability (checklist item 4). `ship.sh` on Linux remains the other path. + +## Push notifications + +`PushRegistration.swift`: after the first signed-in page the app explains what the notifications +are for ("Stay in the loop"), then asks iOS; the APNs token goes to `POST /native/push_devices` +with the session cookie and is forgotten on sign-out. A tapped notification opens its `url` in the +Time tab. The server side (`PushNotifier`, APNs token auth) needs `APNS_*` keys in `/etc/rupert.env`; +without them the bell alone carries every message. ## App Store diff --git a/ios/RupertApp/DevSupport/BundleModule.swift b/ios/RupertApp/DevSupport/BundleModule.swift new file mode 100644 index 000000000..58940cd7f --- /dev/null +++ b/ios/RupertApp/DevSupport/BundleModule.swift @@ -0,0 +1,7 @@ +import Foundation + +// SwiftPM generates `Bundle.module` for the package's resources. In the Xcode app target the same +// sources are compiled straight into the app, whose resources live in the main bundle. +extension Bundle { + static var module: Bundle { .main } +} diff --git a/ios/RupertApp/RupertApp.entitlements b/ios/RupertApp/RupertApp.entitlements new file mode 100644 index 000000000..f983ea5db --- /dev/null +++ b/ios/RupertApp/RupertApp.entitlements @@ -0,0 +1,9 @@ + + + + + + aps-environment + development + + diff --git a/ios/RupertApp/UITests/TimeFlowsUITests.swift b/ios/RupertApp/UITests/TimeFlowsUITests.swift new file mode 100644 index 000000000..136c52aea --- /dev/null +++ b/ios/RupertApp/UITests/TimeFlowsUITests.swift @@ -0,0 +1,119 @@ +import XCTest + +/// Drives the real app against a local Rupert (RUPERT_DEV_ORIGIN) through the staff sign-in and +/// the Time tab's pages, saving a screenshot of each step. Used to verify the flows an App Store +/// reviewer will take and to produce docs/time-and-pay/screenshots/ios-*.png. +/// +/// RUPERT_UI_EMAIL=sam.okafor@juniper-pine.example RUPERT_UI_PASSWORD=… RUPERT_UI_SHOTS=/abs/dir \ +/// xcodebuild test -scheme RupertApp -destination 'platform=iOS Simulator,name=iPhone 18 Pro' -only-testing:RupertAppUITests +/// +/// Without RUPERT_UI_EMAIL the test only checks that the app shows the Time tab and the sign-in page. +final class TimeFlowsUITests: XCTestCase { + private var app: XCUIApplication! + private var shotDir: String? + private var step = 0 + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + let env = ProcessInfo.processInfo.environment + app.launchEnvironment["RUPERT_DEV_ORIGIN"] = env["RUPERT_DEV_ORIGIN"] ?? "http://127.0.0.1:3005" + shotDir = env["RUPERT_UI_SHOTS"] + app.launch() + } + + func testTimeTabSignInAndPages() throws { + let env = ProcessInfo.processInfo.environment + // XCTest dismisses system-style alerts on its own; record the push soft ask before it does. + addUIInterruptionMonitor(withDescription: "push soft ask") { [weak self] alert in + guard alert.staticTexts["Stay in the loop"].exists else { return false } + self?.shoot("push-soft-ask") + alert.buttons["Not now"].tap() + return true + } + let tabBar = app.tabBars.firstMatch + XCTAssertTrue(tabBar.waitForExistence(timeout: 20)) + XCTAssertTrue(tabBar.buttons["Time"].exists, "Time is a tab") + XCTAssertTrue(tabBar.buttons["Home"].exists) + XCTAssertFalse(tabBar.buttons["Search"].exists, "Search left the bar to make room for Time") + + // A previous run may have left the person signed in: sign out through the page first, which + // also exercises the session watcher (tabs restart) and forgetting the push token. + let signOut = app.webViews.buttons["Sign out"].firstMatch + if signOut.waitForExistence(timeout: 8) { + // Sign out sits in the page footer inside the app (the web brand row is hidden there). + var swipes = 0 + while !signOut.isHittable && swipes < 8 { app.webViews.firstMatch.swipeUp(); swipes += 1 } + signOut.tap() + sleep(4) + } + // Signed out: the Time tab shows My time with a Sign in button. + let signIn = app.webViews.links["Sign in"].firstMatch + XCTAssertTrue(signIn.waitForExistence(timeout: 30), "the signed-out Time page offers Sign in\n\(app.webViews.firstMatch.debugDescription.prefix(3000))") + shoot("time-signed-out") + + guard let email = env["RUPERT_UI_EMAIL"], let password = env["RUPERT_UI_PASSWORD"] else { return } + signIn.tap() + sleep(3) + shoot("sign-in-page") + let emailField = app.webViews.textFields.firstMatch + if !emailField.waitForExistence(timeout: 5) { + let toggle = app.webViews.descendants(matching: .any).matching(NSPredicate(format: "label CONTAINS 'mail and password'")).firstMatch + if toggle.waitForExistence(timeout: 10) { toggle.tap() } + } + XCTAssertTrue(emailField.waitForExistence(timeout: 15), "email field\n\(app.webViews.firstMatch.debugDescription.prefix(4000))") + emailField.tap() + emailField.typeText(email) + let passwordField = app.webViews.secureTextFields.firstMatch + passwordField.tap() + passwordField.typeText(password) + shoot("sign-in-form") + app.webViews.buttons["Sign in"].firstMatch.tap() + + // The soft ask for notifications appears once after sign-in. + sleep(6) + _ = app.tabBars.firstMatch.exists // a query lets the interruption monitor see the alert + sleep(2) + XCTAssertTrue(app.webViews.staticTexts.matching(NSPredicate(format: "label BEGINSWITH 'Hi,'")).firstMatch.waitForExistence(timeout: 30), + "the Time tab lands on the staff home after sign-in") + shoot("time-home") + + // The staff nav pills are on every Time page. Timesheets sits past the right edge on a phone + // (XCUITest cannot judge hittability of an off-screen web link), so this walks the four on screen. + for (label, pill) in [("schedule", "Schedule"), ("time-off", "Time off"), ("pay", "Pay")] { + let link = app.webViews.links[pill].firstMatch + guard link.waitForExistence(timeout: 10) else { continue } + link.tap() + sleep(3) + shoot(label) + } + + tabBar.buttons["Notifications"].tap() + sleep(3) + shoot("notifications") + + // App settings sheet: lock, workspace, delete account. + tabBar.buttons["Time"].tap() + sleep(1) + tabBar.buttons["Time"].tap() // tapping the selected tab again pops to its first page + sleep(3) + let settings = app.buttons["app-settings"].firstMatch + XCTAssertTrue(settings.waitForExistence(timeout: 15), "App settings button on the Time tab's first page") + settings.tap() + sleep(2) + shoot("app-settings") + app.buttons["Done"].firstMatch.tap() + } + + private func shoot(_ name: String) { + step += 1 + let screenshot = XCUIScreen.main.screenshot() + let attachment = XCTAttachment(screenshot: screenshot) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + guard let shotDir else { return } + let url = URL(fileURLWithPath: shotDir).appendingPathComponent(String(format: "ios-%02d-%@.png", step, name)) + try? screenshot.pngRepresentation.write(to: url) + } +} diff --git a/ios/RupertApp/project.yml b/ios/RupertApp/project.yml new file mode 100644 index 000000000..33b89152e --- /dev/null +++ b/ios/RupertApp/project.yml @@ -0,0 +1,88 @@ +# A Mac-side Xcode project for running the Rupert iPhone app in the Simulator and on a device with +# Xcode (the Linux build uses xtool and Package.swift; both share Sources/Rupert and Info.plist). +# +# brew install xcodegen +# cd ios/RupertApp && xcodegen generate +# open RupertApp.xcodeproj # or: xcodebuild -scheme RupertApp -destination 'platform=iOS Simulator,name=iPhone 18 Pro' build +# +# The generated RupertApp.xcodeproj is not committed (ios/.gitignore). The "RupertApp (local)" +# scheme points the app at a Rupert dev server with RUPERT_DEV_ORIGIN (debug builds only, Instance.swift). +name: RupertApp +options: + bundleIdPrefix: com.rupertpos + deploymentTarget: + iOS: "17.0" + xcodeVersion: "26.0" + createIntermediateGroups: true +packages: + HotwireNative: + url: https://github.com/hotwired/hotwire-native-ios + exactVersion: 1.3.1 +targets: + RupertApp: + type: application + platform: iOS + sources: + - path: ../Sources/Rupert + excludes: + - "Resources/**" + - "Assets.xcassets" + - path: ../Sources/Rupert/Assets.xcassets + - path: ../Sources/Rupert/Resources/path-configuration.json + - path: ../Sources/Rupert/Resources/PrivacyInfo.xcprivacy + - path: DevSupport + dependencies: + - package: HotwireNative + product: HotwireNative + settings: + base: + PRODUCT_NAME: Rupert + PRODUCT_BUNDLE_IDENTIFIER: com.rupertpos.Rupert + INFOPLIST_FILE: ../Info.plist + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_KEY_CFBundleDisplayName: Rupert + INFOPLIST_KEY_UILaunchScreen_Generation: YES + MARKETING_VERSION: 1.1.0 + CURRENT_PROJECT_VERSION: 1 + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor + SWIFT_VERSION: "5.0" + TARGETED_DEVICE_FAMILY: "1,2" + CODE_SIGN_ENTITLEMENTS: RupertApp.entitlements + CODE_SIGN_STYLE: Automatic + OTHER_SWIFT_FLAGS: "-DRUPERT_XCODE_APP" + RupertAppUITests: + type: bundle.ui-testing + platform: iOS + sources: + - path: UITests + dependencies: + - target: RupertApp + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.rupertpos.RupertUITests + SWIFT_VERSION: "5.0" + GENERATE_INFOPLIST_FILE: YES +schemes: + RupertApp: + build: + targets: + RupertApp: all + RupertAppUITests: [test] + run: + config: Debug + environmentVariables: + RUPERT_DEV_ORIGIN: "" + test: + targets: + - RupertAppUITests + environmentVariables: + RUPERT_DEV_ORIGIN: "http://127.0.0.1:3005" + RupertApp (local): + build: + targets: + RupertApp: all + run: + config: Debug + environmentVariables: + RUPERT_DEV_ORIGIN: "http://127.0.0.1:3005" diff --git a/ios/Sources/Rupert/AppDelegate.swift b/ios/Sources/Rupert/AppDelegate.swift index 1dbd06856..63f5ddc0a 100644 --- a/ios/Sources/Rupert/AppDelegate.swift +++ b/ios/Sources/Rupert/AppDelegate.swift @@ -8,9 +8,23 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { configureAppearance() configureHotwire() + PushRegistration.shared.attach() return true } + // MARK: Push notifications (PushRegistration) + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + PushRegistration.shared.register(deviceToken: deviceToken) + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + // The Simulator and builds without the push entitlement land here; the bell still works. +#if DEBUG + print("push: registration failed: \(error.localizedDescription)") +#endif + } + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { diff --git a/ios/Sources/Rupert/Banner.swift b/ios/Sources/Rupert/Banner.swift index b2316489c..e890ac4c2 100644 --- a/ios/Sources/Rupert/Banner.swift +++ b/ios/Sources/Rupert/Banner.swift @@ -114,8 +114,10 @@ final class BannerWebViewController: HotwireWebViewController { publishBottomInset() // A sign-in or sign-out in this page restarts every tab (SessionWatcher). if Instance.isRupert(webView.url) { - webView.evaluateJavaScript(SessionWatcher.readScript) { value, _ in + webView.evaluateJavaScript(SessionWatcher.readScript) { [weak self] value, _ in SessionWatcher.shared.observe(value) + // Signed in: offer push notifications once (PushRegistration explains why first). + if let self, SessionWatcher.shared.state == .signedIn { PushRegistration.shared.askIfNeeded(from: self) } } } } diff --git a/ios/Sources/Rupert/Instance.swift b/ios/Sources/Rupert/Instance.swift index 1d881e9f4..0aac6a3ec 100644 --- a/ios/Sources/Rupert/Instance.swift +++ b/ios/Sources/Rupert/Instance.swift @@ -8,10 +8,28 @@ import Foundation /// served by the control plane itself stay on it. enum Instance { static let controlPlane: URL = { + if let dev = developmentOrigin { return dev } let configured = Bundle.main.object(forInfoDictionaryKey: "RupertControlPlaneURL") as? String return configured.flatMap(httpsOrigin) ?? URL(string: "https://pos.rupertpos.com")! }() + /// Debug builds only: `RUPERT_DEV_ORIGIN=http://127.0.0.1:3005` in the Xcode scheme's + /// environment points the whole app at a local Rupert (plain http is allowed only here). + /// Release builds ignore it, so it can never ship on. + static var developmentOrigin: URL? { +#if DEBUG + guard let value = ProcessInfo.processInfo.environment["RUPERT_DEV_ORIGIN"], let url = URL(string: value), + let scheme = url.scheme, ["http", "https"].contains(scheme), let host = url.host, !host.isEmpty else { return nil } + var components = URLComponents() + components.scheme = scheme + components.host = host + components.port = url.port + return components.url +#else + return nil +#endif + } + private static let currentKey = "rupert.currentInstanceOrigin" /// The instance the tabs show. The control plane until a workspace handoff moves the app. @@ -42,7 +60,9 @@ enum Instance { /// Rupert's own sites stay inside the app: rupertpos.com, its subdomains, the control plane and /// the current instance (an isolated workspace may live on its own domain). static func isRupert(_ url: URL?) -> Bool { - guard let url, url.scheme == "https", let host = url.host?.lowercased() else { return false } + guard let url, let host = url.host?.lowercased() else { return false } + if let dev = developmentOrigin, url.scheme == dev.scheme, host == dev.host?.lowercased(), url.port == dev.port { return true } + guard url.scheme == "https" else { return false } return host == "rupertpos.com" || host.hasSuffix(".rupertpos.com") || host == controlPlane.host?.lowercased() || host == current.host?.lowercased() } diff --git a/ios/Sources/Rupert/PushRegistration.swift b/ios/Sources/Rupert/PushRegistration.swift new file mode 100644 index 000000000..7db230897 --- /dev/null +++ b/ios/Sources/Rupert/PushRegistration.swift @@ -0,0 +1,155 @@ +import UIKit +import UserNotifications + +/// Push notifications for time & pay: schedule posted or changed, a request decided, a shift offered, +/// a forgotten clock-out, a break due. Rupert sends them through APNs (`PushNotifier` on the server) +/// to the token this app registers at `POST /native/push_devices` for the signed-in person. +/// +/// The ask is soft: the first time a signed-in page renders, the app explains what the +/// notifications are for and only then shows the system prompt. "Not now" is remembered; the +/// person can turn them on later from Notifications in the Time tab (the page says to use iPhone +/// Settings), and iOS Settings → Rupert controls them from then on. +final class PushRegistration: NSObject, UNUserNotificationCenterDelegate { + static let shared = PushRegistration() + + private let askedKey = "rupert.push.asked" + private let tokenKey = "rupert.push.tokenDigest" + private var pendingURL: URL? + + /// Called by SceneController: open a URL in the Time tab. + @MainActor var openInApp: ((URL) -> Void)? + + override private init() { + super.init() + } + + func attach() { + UNUserNotificationCenter.current().delegate = self + } + + /// Asks once, after a signed-in Rupert page has rendered, from the page's own view controller. + @MainActor + func askIfNeeded(from controller: UIViewController) { + guard SessionWatcher.shared.state == .signedIn else { return } + let center = UNUserNotificationCenter.current() + center.getNotificationSettings { [weak self] settings in + guard let self else { return } +#if DEBUG + NSLog("push: authorization status %d, asked %d", settings.authorizationStatus.rawValue, UserDefaults.standard.bool(forKey: self.askedKey) ? 1 : 0) +#endif + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + DispatchQueue.main.async { UIApplication.shared.registerForRemoteNotifications() } + case .notDetermined: + guard !UserDefaults.standard.bool(forKey: self.askedKey) else { return } + DispatchQueue.main.async { self.softAsk(from: controller) } + case .denied: + break + @unknown default: + break + } + } + } + + @MainActor + private func softAsk(from controller: UIViewController) { + // The page that noticed the sign-in is being replaced (tabs restart); only a page that is + // on screen can present, and the question counts as asked once it is answered. + guard controller.viewIfLoaded?.window != nil, controller.presentedViewController == nil else { +#if DEBUG + NSLog("push: soft ask skipped, page not on screen (%@)", String(describing: type(of: controller))) +#endif + return + } + let alert = UIAlertController( + title: "Stay in the loop", + message: "Get told when your schedule is posted, a request is approved, a shift is offered, or you forgot to clock out. You choose which ones in Notifications.", + preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "Not now", style: .cancel) { [askedKey] _ in + UserDefaults.standard.set(true, forKey: askedKey) + }) + alert.addAction(UIAlertAction(title: "Turn on", style: .default) { [askedKey] _ in + UserDefaults.standard.set(true, forKey: askedKey) + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in + guard granted else { return } + DispatchQueue.main.async { UIApplication.shared.registerForRemoteNotifications() } + } + }) + controller.present(alert, animated: true) + } + + /// The APNs token arrived (AppDelegate): send it to Rupert with the session cookie. + func register(deviceToken: Data) { + let token = deviceToken.map { String(format: "%02x", $0) }.joined() + var request = URLRequest(url: Instance.url("/native/push_devices")) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("Rupert iOS/\(Instance.appVersion)", forHTTPHeaderField: "User-Agent") + let body: [String: String] = [ + "platform": "ios", "token": token, "app_version": Instance.appVersion, + "environment": Self.isSandboxBuild ? "sandbox" : "production", + ] + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + // The web views' session cookie lives in the shared cookie storage; the task shares it. + URLSession.shared.dataTask(with: request) { [weak self] data, _, _ in + guard let self, let data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let digest = json["token_digest"] as? String else { return } + UserDefaults.standard.set(digest, forKey: self.tokenKey) + }.resume() + } + + /// Signed out: tell Rupert to stop sending to this phone. + func forget() { + guard let digest = UserDefaults.standard.string(forKey: tokenKey) else { return } + var request = URLRequest(url: Instance.url("/native/push_devices/\(digest)")) + request.httpMethod = "DELETE" + URLSession.shared.dataTask(with: request).resume() + UserDefaults.standard.removeObject(forKey: tokenKey) + } + + /// Debug and TestFlight-less builds talk to the APNs sandbox; App Store builds to production. + static var isSandboxBuild: Bool { +#if DEBUG + return true +#else + return false +#endif + } + + // MARK: UNUserNotificationCenterDelegate + + /// A notification arrived while the app is open: show it as a banner anyway (the bell page is + /// not always the one on screen). + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.banner, .badge, .sound]) + } + + /// Tapped: open the page the notification points at, in the Time tab. + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) { + if let url = Self.url(in: response.notification.request.content.userInfo) { + DispatchQueue.main.async { [weak self] in + if let open = self?.openInApp { open(url) } else { self?.pendingURL = url } + } + } + completionHandler() + } + + /// Where a notification wants to go: Rupert's `url` field on the current instance. Other hosts + /// and schemes are ignored (a payload is data, not a command). + static func url(in userInfo: [AnyHashable: Any]) -> URL? { + guard let path = userInfo["url"] as? String, !path.isEmpty else { return nil } + let url = Instance.url(path) + return Instance.isRupert(url) || url.host == Instance.current.host ? url : nil + } + + /// A tap that arrived before the window existed. + @MainActor + func flushPending() { + guard let url = pendingURL, let open = openInApp else { return } + pendingURL = nil + open(url) + } +} diff --git a/ios/Sources/Rupert/Resources/path-configuration.json b/ios/Sources/Rupert/Resources/path-configuration.json index 1bc5f82ac..38f271fc0 100644 --- a/ios/Sources/Rupert/Resources/path-configuration.json +++ b/ios/Sources/Rupert/Resources/path-configuration.json @@ -4,6 +4,8 @@ { "patterns": [".*"], "properties": { "context": "default", "pull_to_refresh_enabled": true } }, { "patterns": ["/new$", "/new\\?", "/edit$", "/edit\\?"], "properties": { "context": "modal", "pull_to_refresh_enabled": false } }, { "patterns": ["^/login$", "^/logout$"], "properties": { "context": "default", "presentation": "replace_root", "pull_to_refresh_enabled": false } }, - { "patterns": ["^/chat", "^/ask_rupert", "^/sales/pos", "^/pos"], "properties": { "pull_to_refresh_enabled": false } } + { "patterns": ["^/chat", "^/ask_rupert", "^/sales/pos", "^/pos"], "properties": { "pull_to_refresh_enabled": false } }, + { "patterns": ["^/employee/time-off/new", "^/employee/trades/new", "^/employee/trades/[^/]+/(accept|swap)", "^/time_clock/shifts/[^/]+/fix", "^/time_clock/shifts/[^/]+/correction", "^/employee/availability", "^/employee/tips/declare"], "properties": { "context": "modal", "pull_to_refresh_enabled": false } }, + { "patterns": ["^/clock", "^/ctrax_pos"], "properties": { "pull_to_refresh_enabled": false } } ] } diff --git a/ios/Sources/Rupert/SceneController.swift b/ios/Sources/Rupert/SceneController.swift index c68fb2abd..e35ef4d63 100644 --- a/ios/Sources/Rupert/SceneController.swift +++ b/ios/Sources/Rupert/SceneController.swift @@ -22,6 +22,13 @@ final class SceneController: UIResponder, UIWindowSceneDelegate { AppSettings.openInApp = { [weak self] url in self?.tabBarController.activeNavigator.route(url) } + // A tapped notification opens its page in the Time tab. + PushRegistration.shared.openInApp = { [weak self] url in + guard let self else { return } + self.select(HotwireTab.timeTabIndex) + self.tabBarController.activeNavigator.route(url) + } + PushRegistration.shared.flushPending() if let shortcut = connectionOptions.shortcutItem { _ = open(shortcut) } @@ -32,7 +39,9 @@ final class SceneController: UIResponder, UIWindowSceneDelegate { WorkspaceHandoff.shared.onOpened = { [weak self] origin, landing in self?.show(instance: origin, landing: landing) } - SessionWatcher.shared.onChange = { [weak self] _ in + SessionWatcher.shared.onChange = { [weak self] state in + // Signed out: Rupert must stop pushing to this phone. + if state == .signedOut { PushRegistration.shared.forget() } // The change is noticed while the old page is still finishing its visit; replacing the // tabs from inside that callback left the new Home tab without a visit in release builds. DispatchQueue.main.async { self?.restartTabs() } @@ -73,6 +82,13 @@ final class SceneController: UIResponder, UIWindowSceneDelegate { let index = tabBarController.selectedIndex tabBarController.load(HotwireTab.all(on: Instance.current)) select(index) + // Just signed in: offer push notifications from the fresh page once it is on screen. + if SessionWatcher.shared.state == .signedIn { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + guard let self, let top = self.tabBarController.selectedViewController else { return } + PushRegistration.shared.askIfNeeded(from: (top as? UINavigationController)?.topViewController ?? top) + } + } } /// Point every tab at `instance` (after a sign-in or a workspace handoff) and open `landing` diff --git a/ios/Sources/Rupert/Tabs.swift b/ios/Sources/Rupert/Tabs.swift index 36c52abea..d82077ddf 100644 --- a/ios/Sources/Rupert/Tabs.swift +++ b/ios/Sources/Rupert/Tabs.swift @@ -4,9 +4,16 @@ import UIKit /// The native tab bar, on whichever instance the app is pointed at. These replace the web top /// bar's search, Ask Rupert, Team chat and notification buttons, which Rupert hides inside the app /// (`.native-app` in application.css). Signed out, every tab shows Rupert's sign-in page. +/// +/// Time comes first: for hourly staff the app is their clock, schedule, time off and pay +/// (`/employee`, the staff workspace), and `/login` already lands staff accounts there. Search +/// stays reachable from the top bar of every page; five tabs is the most iOS shows without a +/// "More" tab, so it left the bar when Time joined. extension HotwireTab { static func all(on base: URL) -> [HotwireTab] { [ + HotwireTab(id: "time", title: "Time", image: UIImage(systemName: "clock"), + url: Instance.url("/employee", on: base)), // /login sends a signed-in person to their start page, so Home never opens on a page // the app cannot show (the signed-out marketing page at / has no Turbo). HotwireTab(id: "home", title: "Home", image: UIImage(systemName: "house"), @@ -17,8 +24,9 @@ extension HotwireTab { url: Instance.url("/ask_rupert", on: base)), HotwireTab(id: "inbox", title: "Notifications", image: UIImage(systemName: "bell"), url: Instance.url("/notifications", on: base)), - HotwireTab(id: "search", title: "Search", image: UIImage(systemName: "magnifyingglass"), - url: Instance.url("/search", on: base)), ] } + + /// The tab a notification tap or a push deep link opens in. + static let timeTabIndex = 0 } diff --git a/ios/Tests/RupertTests/AppReviewReadinessTests.swift b/ios/Tests/RupertTests/AppReviewReadinessTests.swift index f8d488037..4b60d05d3 100644 --- a/ios/Tests/RupertTests/AppReviewReadinessTests.swift +++ b/ios/Tests/RupertTests/AppReviewReadinessTests.swift @@ -1,3 +1,4 @@ +import HotwireNative import XCTest @testable import Rupert @@ -136,4 +137,50 @@ final class InfoPlistTests: XCTestCase { XCTAssertEqual(info["ITSAppUsesNonExemptEncryption"] as? Bool, false) XCTAssertEqual(info["RupertControlPlaneURL"] as? String, "https://pos.rupertpos.com") } + + func testVersionIsTheTimeAndPayRelease() throws { + let info = try plist() + XCTAssertEqual(info["CFBundleShortVersionString"] as? String, "1.1.0") + } + + func testQuickActionsMatchTheTabs() throws { + let info = try plist() + let items = try XCTUnwrap(info["UIApplicationShortcutItems"] as? [[String: Any]]) + let types = items.compactMap { ($0["UIApplicationShortcutItemType"] as? String)?.components(separatedBy: ".").last } + let tabs = HotwireTab.all(on: URL(string: "https://pos.rupertpos.com")!).map(\.id) + for type in types { + XCTAssertTrue(tabs.contains(type), "quick action \(type) has no tab to open") + } + XCTAssertTrue(types.contains("time"), "Clock in is a Home Screen quick action") + } +} + +/// The Time tab and push notifications (time & pay). +final class TimeTabTests: XCTestCase { + func testTimeIsTheFirstTabAndThereAreFive() { + let tabs = HotwireTab.all(on: URL(string: "https://pos.rupertpos.com")!) + XCTAssertEqual(tabs.count, 5, "iOS shows at most five tabs without a More tab") + XCTAssertEqual(tabs.first?.id, "time") + XCTAssertEqual(tabs.first?.url.path, "/employee") + XCTAssertEqual(HotwireTab.timeTabIndex, 0) + XCTAssertFalse(tabs.contains { $0.id == "search" }) + } + + func testNotificationPayloadsOnlyOpenRupertPages() { + XCTAssertEqual(PushRegistration.url(in: ["url": "/employee/schedule"])?.path, "/employee/schedule") + XCTAssertEqual(PushRegistration.url(in: ["url": "/employee/schedule"])?.host, Instance.current.host) + XCTAssertNil(PushRegistration.url(in: ["url": "https://evil.example/phish"])) + XCTAssertNil(PushRegistration.url(in: ["url": ""])) + XCTAssertNil(PushRegistration.url(in: [:])) + } + + func testBundledPathConfigurationOpensTimeFormsAsSheets() throws { + let url = try XCTUnwrap(Bundle.module.url(forResource: "path-configuration", withExtension: "json")) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any]) + let rules = try XCTUnwrap(json["rules"] as? [[String: Any]]) + let modal = rules.filter { ($0["properties"] as? [String: Any])?["context"] as? String == "modal" } + .flatMap { $0["patterns"] as? [String] ?? [] } + XCTAssertTrue(modal.contains { $0.contains("/employee/time-off/new") }) + XCTAssertTrue(modal.contains { $0.contains("/time_clock/shifts/") }) + } } diff --git a/mobile/README.md b/mobile/README.md index 974193efc..a6684adb0 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -2,6 +2,14 @@ Native Expo/React Native client for a Rupert instance. The instance itself supplies the installable web app at `/employee`; there is no separate native-client web deployment. +## Push notifications + +This Expo client does not register for push (no `expo-notifications` dependency, by choice: the +staff API it uses is kill-switched in production). Time & pay notifications (schedule posted or +changed, approvals, shift offers, forgotten clock-outs, break reminders) reach staff through the +Rupert iPhone app (`ios/`, APNs) and the installed staff web app at `/employee` (Web Push, turned +on from Settings → Notifications). The in-app bell shows every message regardless. + ## Development and validation Use Node 24 and the committed lockfile: diff --git a/web/Gemfile b/web/Gemfile index 22c46c985..2833de402 100644 --- a/web/Gemfile +++ b/web/Gemfile @@ -134,3 +134,7 @@ gem "ruby_llm", "~> 2.1" # QR codes linking receipts to lab certificates (COAs), drawn as inline SVG. gem "rqrcode", "~> 3.1" + +# Push notifications for the iPhone app (APNs, token auth over HTTP/2) and installed web apps (Web Push, VAPID). +gem "apnotic", "~> 1.8" +gem "web-push", "~> 3.0" diff --git a/web/Gemfile.lock b/web/Gemfile.lock index 9b9ca083c..9a1648825 100644 --- a/web/Gemfile.lock +++ b/web/Gemfile.lock @@ -79,6 +79,10 @@ GEM uri (>= 0.13.1) addressable (2.9.2) public_suffix (>= 2.0.2, < 8.0) + apnotic (1.8.0) + base64 + connection_pool (>= 2, < 4) + net-http2 (>= 0.18.3, < 2) ast (2.4.3) base64 (0.3.0) bcrypt (3.1.22) @@ -177,6 +181,7 @@ GEM hash_diff (1.1.1) hashdiff (1.2.1) htmlentities (4.4.2) + http-2 (1.3.0) httparty (0.24.3) csv mini_mime (>= 1.0.0) @@ -225,6 +230,8 @@ GEM net-http (0.10.0) net-protocol (>= 0.3.0) uri (>= 0.11.1) + net-http2 (0.19.0) + http-2 (>= 1.0) net-imap (0.6.8) date net-protocol @@ -491,6 +498,9 @@ GEM actionview (>= 8.0.0) bindex (>= 0.4.0) railties (>= 8.0.0) + web-push (3.1.0) + jwt (~> 3.0) + openssl (>= 3.0) webmock (3.34.0) addressable (>= 2.8.0) crack (>= 0.3.2) @@ -517,6 +527,7 @@ PLATFORMS DEPENDENCIES aasm + apnotic (~> 1.8) bcrypt (~> 3.1.7) bootsnap caxlsx_rails @@ -559,6 +570,7 @@ DEPENDENCIES turbo-rails tzinfo-data web-console + web-push (~> 3.0) webmock CHECKSUMS @@ -576,6 +588,7 @@ CHECKSUMS activestorage (8.1.4.1) sha256=0f4e2f114f3584723a80a2c089aacc6170e765a0b2c385c9782e26ed78f05c66 activesupport (8.1.4.1) sha256=16ac4f341ca65c85a1fcdb8ac73f68891aae9e3c4a6f9c3a27daed8b00017ed7 addressable (2.9.2) sha256=9117c7e7d6ee07230e75ce41fb9401ae55354dc654fcd0e862ba41d539e9a610 + apnotic (1.8.0) sha256=2638d2820d7a2ae94fc46cd38bdee47bb87d3823c13623d1d44393ba973002e9 ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 @@ -625,6 +638,7 @@ CHECKSUMS hash_diff (1.1.1) sha256=8b987e4cd5799c70b9b9761f75a75a3b89d47b91994da6b07c92b9b5352f04a3 hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1 htmlentities (4.4.2) sha256=bbafbdf69f2eca9262be4efef7e43e6a1de54c95eb600f26984f71d2fe96c5c3 + http-2 (1.3.0) sha256=58fc8636c2b937b651853e9f6d9ada9fdaa8bf80b5a7d0333ce15653e5ca6ac1 httparty (0.24.3) sha256=5c95cf29ca70d5bfd834e56140ddfca6e94166c3b870cde9c25d157cdb5a00d2 i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a @@ -648,6 +662,7 @@ CHECKSUMS multi_xml (0.9.1) sha256=7ce766b59c17241ed62976caeae1fae9b2431b263398c35396239a68c4a64e57 multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8 net-http (0.10.0) sha256=1a2ff6e37a05724973c43f2bf7bf105f59c36a4cd0a7e9b1c021a0a566987c27 + net-http2 (0.19.0) sha256=84c03b8dbff7e82ef7ff06958c228554cd6963e19f65a5bd10f4473662048ab2 net-imap (0.6.8) sha256=eb30bb62227b64a3af6b518b04b2375113d69f68f15de59b0193b07d98acf6d9 net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 net-protocol (0.4.0) sha256=f9f90c8c6f64794a45160bf422177a336db12809de3beeeaa26b88fcd2e8c853 @@ -757,6 +772,7 @@ CHECKSUMS uri (1.1.2) sha256=89292e98eabbef6248b0a0ed5e7e398e152f83f17560023a6d94a7f492b169fb useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 web-console (4.3.0) sha256=e13b71301cdfc2093f155b5aa3a622db80b4672d1f2f713119cc7ec7ac6a6da4 + web-push (3.1.0) sha256=501ab00340c58fb70dabda59f28a86b3cccb0930c6f1f30721b2a5b24de7187d webmock (3.34.0) sha256=72eed29dc8c00148d592acaedaaa6b869e2e397de57dfc8602b84aa834f46b33 websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146 websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 diff --git a/web/app/assets/tailwind/application.css b/web/app/assets/tailwind/application.css index 6baa42bcb..d9c85b0a5 100644 --- a/web/app/assets/tailwind/application.css +++ b/web/app/assets/tailwind/application.css @@ -2610,3 +2610,9 @@ body.tour-active { overflow-x:hidden; } four utilities past the edge (the approvals inbox is worked from a phone). They stay one tap away in the account menu and on the release page. */ @media (max-width: 639px) { .header-utilities .header-release, .header-utilities .header-gate { display:none; } } + +/* ---- time & pay: staff pages inside the iPhone app ---- */ +/* The app's own navigation bar carries the title, account and tab bar, so the web brand row + (brand, bell, help, name, Sign out) and the build strip step aside; the page pills stay. */ +.native-app .workspace-brand-row, +.native-app .workspace-header .build-version { display:none !important; } diff --git a/web/app/controllers/application_controller.rb b/web/app/controllers/application_controller.rb index 6926bd54c..b75f5a3c9 100644 --- a/web/app/controllers/application_controller.rb +++ b/web/app/controllers/application_controller.rb @@ -42,7 +42,9 @@ def restrict_employee_workspace %w[index show badge quiz].include?(action_name) when 'sessions', 'oauth', 'employee_app', 'employee_workspace', 'employee_pay', 'employee_training', 'employee_preview', 'customer_displays', 'feedback_reports', 'employee_trades', 'employee_availability', # time & pay: schedule - 'next_things' # the Next thing to do bar: an employee sees only their own tasks there (NextThings) + 'next_things', # the Next thing to do bar: an employee sees only their own tasks there (NextThings) + 'employee_notifications', 'native/push_devices', # time & pay: ios — own notification choices and push devices + 'account_deletions' # App Store 5.1.1(v): every account, staff included, can delete itself from the app true when 'employee_access' %w[edit update].include?(action_name) diff --git a/web/app/controllers/employee_notifications_controller.rb b/web/app/controllers/employee_notifications_controller.rb new file mode 100644 index 000000000..5ef3f7da6 --- /dev/null +++ b/web/app/controllers/employee_notifications_controller.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +# A person's own notification choices (which time & pay events reach them) and the installed web +# app's Web Push subscription. Both act on Current.user alone; no other person's settings are read. +class EmployeeNotificationsController < ApplicationController + layout 'employee' + before_action { response.headers['Cache-Control'] = 'no-store' } + + KIND_LABELS = { + 'schedule_posted' => ['Schedule posted', 'When a week of shifts is published.'], + 'schedule_changed' => ['Schedule changed', 'When one of your shifts moves or is cancelled.'], + 'approvals' => ['Approvals', 'When a time-off request, correction or trade is decided, or (for managers) waits for you.'], + 'trade_offers' => ['Shift offers', 'When a colleague offers a shift or an open shift needs cover.'], + 'missed_clock_out' => ['Forgotten clock-out', 'When a shift of yours is still open long after it should have ended.'], + 'break_due' => ['Break reminders', 'When it is time for a break or a meal on a long shift.'], + 'timesheet' => ['Timesheets', 'When your timesheet is approved or returned.'] + }.freeze + + def settings + @prefs = Current.user.notification_prefs + @devices = PushDevice.active.where(user_id: Current.user.id).order(:platform, :created_at) + @vapid_public_key = PushNotifier.web_push_configured? ? ENV.fetch('VAPID_PUBLIC_KEY', nil) : nil + end + + def update_settings + Current.user.notification_prefs = params.fetch(:prefs, {}).permit(*User::NOTIFICATION_KINDS).to_h + Current.user.save! + redirect_to employee_notification_settings_path, notice: 'Notification choices saved.' + end + + # POST /employee/push_subscriptions {subscription: {endpoint, keys: {p256dh, auth}}} from the installed web app. + def subscribe + subscription = params.require(:subscription) + endpoint = subscription[:endpoint].to_s + keys = subscription[:keys].respond_to?(:to_unsafe_h) ? subscription[:keys].to_unsafe_h.slice('p256dh', 'auth') : {} + if endpoint.blank? || !endpoint.start_with?('https://') || keys.size != 2 || keys.values.any?(&:blank?) + return render(json: { error: 'A push subscription needs an https endpoint and keys.' }, status: :unprocessable_entity) + end + + device = PushDevice.register!(user: Current.user, platform: 'web', token: endpoint, keys: keys, app_version: 'web') + render json: { id: device.id, push_configured: PushNotifier.configured? }, status: :created + end + + def unsubscribe + device = PushDevice.find_by(user_id: Current.user.id, id: params[:id]) + device&.disable!('turned off') + redirect_to employee_notification_settings_path, notice: 'Push turned off for that device.' + end +end diff --git a/web/app/controllers/native/configurations_controller.rb b/web/app/controllers/native/configurations_controller.rb index e8febbf94..2baa2f1d7 100644 --- a/web/app/controllers/native/configurations_controller.rb +++ b/web/app/controllers/native/configurations_controller.rb @@ -14,7 +14,12 @@ class ConfigurationsController < ApplicationController # Signing in or out replaces the whole stack instead of piling pages behind it. { patterns: ['^/login$', '^/logout$'], properties: { context: 'default', presentation: 'replace_root', pull_to_refresh_enabled: false } }, # Pages that poll or scroll their own content keep pull-to-refresh out of the way. - { patterns: ['^/chat', '^/ask_rupert', '^/sales/pos', '^/pos'], properties: { pull_to_refresh_enabled: false } } + { patterns: ['^/chat', '^/ask_rupert', '^/sales/pos', '^/pos'], properties: { pull_to_refresh_enabled: false } }, + # Time & pay (Time tab): request and fix forms open as sheets; the clock pages keep their own state. + { patterns: ['^/employee/time-off/new', '^/employee/trades/new', '^/employee/trades/[^/]+/(accept|swap)', '^/time_clock/shifts/[^/]+/fix', + '^/time_clock/shifts/[^/]+/correction', '^/employee/availability', '^/employee/tips/declare'], + properties: { context: 'modal', pull_to_refresh_enabled: false } }, + { patterns: ['^/clock', '^/ctrax_pos'], properties: { pull_to_refresh_enabled: false } } ].freeze def ios diff --git a/web/app/controllers/native/push_devices_controller.rb b/web/app/controllers/native/push_devices_controller.rb new file mode 100644 index 000000000..b971cb571 --- /dev/null +++ b/web/app/controllers/native/push_devices_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Native + # The iPhone app registers its APNs device token for the signed-in person + # (POST /native/push_devices {platform, token, environment, app_version}) and forgets it on sign-out + # (DELETE /native/push_devices/:token_digest). The request comes from the app's own HTTP client + # with the session cookie, never from a page, so there is no CSRF token to check. + class PushDevicesController < ApplicationController + skip_forgery_protection + before_action { response.headers['Cache-Control'] = 'no-store' } + + def create + platform = params[:platform].to_s + token = params[:token].to_s.strip + if !PushDevice::PLATFORMS.include?(platform) || token.blank? || token.length > 4000 + return render(json: { error: 'platform and token are required' }, status: :unprocessable_entity) + end + + device = PushDevice.register!(user: Current.user, platform: platform, token: token, environment: params[:environment], app_version: params[:app_version]) + render json: { id: device.id, token_digest: device.token_digest, push_configured: PushNotifier.configured? }, status: :created + end + + def destroy + device = PushDevice.find_by(user_id: Current.user.id, token_digest: params[:token_digest].to_s) + device&.disable!('signed out') + head :no_content + end + end +end diff --git a/web/app/javascript/controllers/push_subscribe_controller.js b/web/app/javascript/controllers/push_subscribe_controller.js new file mode 100644 index 000000000..c569ff80e --- /dev/null +++ b/web/app/javascript/controllers/push_subscribe_controller.js @@ -0,0 +1,46 @@ +import { Controller } from "@hotwired/stimulus" + +// Subscribes the installed staff web app to Web Push (VAPID) and posts the subscription to Rupert. +export default class extends Controller { + static targets = ["status", "button"] + static values = { key: String, url: String, worker: String } + + connect() { + if (!("serviceWorker" in navigator) || !("PushManager" in window)) { + this.statusTarget.textContent = "This browser cannot receive push notifications. Install the app from Safari's Share menu on iPhone, or use Chrome." + this.buttonTarget.hidden = true + } + } + + async subscribe() { + this.buttonTarget.disabled = true + try { + const permission = await Notification.requestPermission() + if (permission !== "granted") { + this.statusTarget.textContent = "Notifications are blocked for this site. Allow them in your browser settings to turn push on." + return + } + await navigator.serviceWorker.register(this.workerValue, { scope: "/" }) + const ready = await navigator.serviceWorker.ready + const subscription = await ready.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: this.decodeKey(this.keyValue) }) + const token = document.querySelector('meta[name="csrf-token"]')?.content + const response = await fetch(this.urlValue, { + method: "POST", credentials: "same-origin", + headers: { "Content-Type": "application/json", "X-CSRF-Token": token, Accept: "application/json" }, + body: JSON.stringify({ subscription: subscription.toJSON() }) + }) + if (!response.ok) throw new Error(`Rupert answered ${response.status}`) + this.statusTarget.textContent = "Push notifications are on for this device." + this.buttonTarget.hidden = true + } catch (error) { + this.statusTarget.textContent = `Could not turn on push: ${error.message}` + this.buttonTarget.disabled = false + } + } + + decodeKey(base64) { + const padding = "=".repeat((4 - (base64.length % 4)) % 4) + const raw = atob((base64 + padding).replace(/-/g, "+").replace(/_/g, "/")) + return Uint8Array.from(raw, (c) => c.charCodeAt(0)) + } +} diff --git a/web/app/javascript/employee_service_worker.js b/web/app/javascript/employee_service_worker.js index cb72f8a9e..cda0b7feb 100644 --- a/web/app/javascript/employee_service_worker.js +++ b/web/app/javascript/employee_service_worker.js @@ -8,3 +8,21 @@ self.addEventListener('fetch', event => { { status: 503, headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' } } ))); }); + +// Web Push (time & pay): show the notification and open its page on tap. Nothing is cached. +self.addEventListener('push', event => { + let data = {}; + try { data = event.data ? event.data.json() : {}; } catch (_error) { data = { title: 'Rupert', body: event.data && event.data.text() }; } + const title = data.title || 'Rupert'; + const options = { body: data.body || '', data: { url: data.url || '/employee' }, tag: data.kind || 'rupert', icon: '/employee/icon-192.png?v=rupert-dog-1', badge: '/employee/icon-192.png?v=rupert-dog-1' }; + event.waitUntil(self.registration.showNotification(title, options)); +}); +self.addEventListener('notificationclick', event => { + event.notification.close(); + const url = (event.notification.data && event.notification.data.url) || '/employee'; + event.waitUntil(self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clients => { + const open = clients.find(client => 'focus' in client); + if (open) { open.navigate(url); return open.focus(); } + return self.clients.openWindow(url); + })); +}); diff --git a/web/app/services/account_deletion.rb b/web/app/services/account_deletion.rb index 950b3a798..7a72a832a 100644 --- a/web/app/services/account_deletion.rb +++ b/web/app/services/account_deletion.rb @@ -34,6 +34,11 @@ def delete!(confirmation:) @user.google_login_identities.destroy_all ApiAccessToken.where(user_id: @user.id, revoked_at: nil).update_all(revoked_at: now) EmployeeAppSession.where(user_id: @user.id, revoked_at: nil).update_all(revoked_at: now) + # Push tokens are delivery addresses for this person's phone: gone with the account. + PushDevice.tenant_unscoped(@user.tenant_id).where(user_id: @user.id).delete_all + # The worker record stays (hours and pay history belong to the business) but can no longer punch. + People::Employee.tenant_unscoped(@user.tenant_id).where(user_id: @user.id) + .update_all(pin_digest: nil, pin_set_at: nil, pin_failed_count: 0, pin_locked_until: nil, updated_at: now) @user.user_permissions.destroy_all @user.update!( name: 'Deleted user', diff --git a/web/app/services/push_notifier.rb b/web/app/services/push_notifier.rb index f4418f855..afd97f0b1 100644 --- a/web/app/services/push_notifier.rb +++ b/web/app/services/push_notifier.rb @@ -1,14 +1,130 @@ # frozen_string_literal: true -# Push delivery. Stub until the notifications workstream wires APNs (token auth: APNS_KEY_ID, -# APNS_TEAM_ID, APNS_KEY_P8, APNS_TOPIC) and Web Push (VAPID keys). Keeps the interface stable: -# `configured?` gates enqueueing, `deliver(notification)` sends to every active PushDevice of the user. +# Push delivery for one in-app Notification: APNs for the iPhone app (token-based auth over HTTP/2 +# through apnotic) and Web Push (VAPID) for the installed staff web app. Both are configured by +# boot-time environment variables in /etc/rupert.env (docs/ops/environment-reference.md): +# +# APNS_KEY_ID, APNS_TEAM_ID, APNS_KEY_P8 (the .p8 contents), APNS_TOPIC (com.rupertpos.Rupert), +# APNS_ENVIRONMENT (sandbox | production) +# VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT (mailto: or https: URL) +# +# Until a provider is configured, `configured?` is false and PushDeliveryJob is never enqueued: the +# bell alone carries the message. A device the provider rejects for good (BadDeviceToken, +# Unregistered, 410 Gone) is disabled with the reason, never deleted. class PushNotifier - def self.configured? - false - end + APNS_KEYS = %w[APNS_KEY_ID APNS_TEAM_ID APNS_KEY_P8 APNS_TOPIC].freeze + VAPID_KEYS = %w[VAPID_PUBLIC_KEY VAPID_PRIVATE_KEY VAPID_SUBJECT].freeze + DEAD_APNS_REASONS = %w[BadDeviceToken Unregistered DeviceTokenNotForTopic].freeze + + Result = Struct.new(:sent, :disabled, :failed, keyword_init: true) + + class << self + def configured? + apns_configured? || web_push_configured? + end + + def apns_configured? = APNS_KEYS.all? { |key| setting(key).present? } + def web_push_configured? = VAPID_KEYS.all? { |key| setting(key).present? } + + # Sends `notification` to every active device of its user. Returns a Result with counts. + def deliver(notification) + result = Result.new(sent: 0, disabled: 0, failed: 0) + devices = PushDevice.active.where(user_id: notification.user_id).to_a + return result if devices.empty? + + badge = Notification.where(user_id: notification.user_id).unread.count + ios = devices.select { |d| d.platform == 'ios' } + web = devices.select { |d| d.platform == 'web' } + deliver_apns(ios, notification, badge, result) if ios.any? && apns_configured? + deliver_web(web, notification, badge, result) if web.any? && web_push_configured? + result + end + + # The payload both channels carry: title, body, a deep link and the kind (thread) it belongs to. + def payload(notification, badge) + { title: notification.title.to_s, body: notification.body.to_s, url: notification.url.to_s, kind: notification.kind.to_s, + notification_id: notification.id, badge: badge } + end + + private + + # Platform-level keys (one app bundle, one VAPID pair for all workspaces): boot-time + # environment only (/etc/rupert.env), never a tenant's managed settings. + def setting(key) + ENV.fetch(key, nil).presence + end + + # ---- APNs --------------------------------------------------------------------------------- + + def deliver_apns(devices, notification, badge, result) + connection = apns_connection + devices.each { |device| apns_push(connection, device, notification, badge, result) } + rescue StandardError => e + # The connection itself failed (DNS, TLS, a bad key): every device counts as a failed send. + result.failed += devices.size + Rails.logger.warn("push: apns connection error #{e.class}: #{e.message}") + ensure + connection&.close + end + + def apns_connection + options = { auth_method: :token, cert_path: StringIO.new(setting('APNS_KEY_P8').to_s), key_id: setting('APNS_KEY_ID'), team_id: setting('APNS_TEAM_ID') } + setting('APNS_ENVIRONMENT').to_s == 'production' ? Apnotic::Connection.new(options) : Apnotic::Connection.development(options) + end + + def apns_push(connection, device, notification, badge, result) + data = payload(notification, badge) + push = Apnotic::Notification.new(device.token) + push.topic = setting('APNS_TOPIC') + push.alert = { title: data[:title], body: data[:body] } + push.badge = badge + push.sound = 'default' + push.thread_id = data[:kind] + push.category = 'rupert.time' + push.custom_payload = { url: data[:url], kind: data[:kind], notification_id: data[:notification_id] } + push.expiration = 1.day.from_now.to_i + response = connection.push(push) + if response.nil? + result.failed += 1 + elsif response.ok? + result.sent += 1 + device.update_columns(last_seen_at: Time.current) + elsif response.status.to_i == 410 || DEAD_APNS_REASONS.include?(response.body.to_h['reason']) + device.disable!(response.body.to_h['reason'] || 'gone') + result.disabled += 1 + else + result.failed += 1 + Rails.logger.warn("push: apns #{response.status} #{response.body.to_h['reason']} for device #{device.id}") + end + rescue StandardError => e + result.failed += 1 + Rails.logger.warn("push: apns error #{e.class}: #{e.message}") + end + + # ---- Web Push ---------------------------------------------------------------------------- - def self.deliver(_notification) - nil + def deliver_web(devices, notification, badge, result) + devices.each do |device| + keys = device.keys + WebPush.payload_send(message: payload(notification, badge).to_json, endpoint: device.token, p256dh: keys['p256dh'], auth: keys['auth'], + vapid: { subject: setting('VAPID_SUBJECT'), public_key: setting('VAPID_PUBLIC_KEY'), private_key: setting('VAPID_PRIVATE_KEY') }, ttl: 86_400) + result.sent += 1 + device.update_columns(last_seen_at: Time.current) + rescue WebPush::ExpiredSubscription, WebPush::InvalidSubscription => e + device.disable!(e.class.name.demodulize) + result.disabled += 1 + rescue WebPush::ResponseError => e + if e.response.respond_to?(:code) && %w[404 410].include?(e.response.code.to_s) + device.disable!("gone #{e.response.code}") + result.disabled += 1 + else + result.failed += 1 + Rails.logger.warn("push: web push #{e.message} for device #{device.id}") + end + rescue StandardError => e + result.failed += 1 + Rails.logger.warn("push: web push error #{e.class}: #{e.message}") + end + end end end diff --git a/web/app/views/employee_app/devices.html.erb b/web/app/views/employee_app/devices.html.erb index fb828e9b5..dbf4f6317 100644 --- a/web/app/views/employee_app/devices.html.erb +++ b/web/app/views/employee_app/devices.html.erb @@ -1,5 +1,7 @@ <% content_for :title, t('time.clock.settings.title') %> -<%= page_header t('time.clock.settings.title'), subtitle: t('time.clock.settings.subtitle') %> +<%= page_header t('time.clock.settings.title'), subtitle: t('time.clock.settings.subtitle') do %> + <%= link_to 'Notifications', employee_notification_settings_path, class: 'btn-ghost' %> +<% end %> <% if @worker %>

<%= t('time.clock.settings.language') %>

diff --git a/web/app/views/employee_notifications/settings.html.erb b/web/app/views/employee_notifications/settings.html.erb new file mode 100644 index 000000000..fb2026ff1 --- /dev/null +++ b/web/app/views/employee_notifications/settings.html.erb @@ -0,0 +1,33 @@ +<% content_for :title, 'Notifications' %> +<%= page_header 'Notifications', subtitle: 'Choose what reaches you, on this phone and in the bell.' %> +<%= form_with url: employee_notification_settings_path, method: :patch, class: 'card card-pad space-y-4' do |f| %> +

Tell me when

+
    + <% EmployeeNotificationsController::KIND_LABELS.each do |kind, (label, hint)| %> +
  • + + + > +
  • + <% end %> +
+
<%= f.submit 'Save', class: 'btn-primary', data: { turbo_submits_with: 'Saving…' } %>
+<% end %> +
+

Push to this device

+ <% if hotwire_native_app? %> +

The Rupert app on this iPhone sends these as notifications. Manage them in iPhone Settings → Notifications → Rupert.

+ <% else %> + <%= render 'shared/push_subscribe', vapid_public_key: @vapid_public_key %> + <% end %> + <% if @devices.any? %> +
    + <% @devices.each do |device| %> +
  • + <%= device.platform == 'ios' ? 'iPhone app' : 'Web app' %><% if device.last_seen_at %> · last seen <%= l(device.last_seen_at, format: :short) %><% end %> + <%= button_to 'Turn off', unsubscribe_employee_push_path(device), class: 'btn-ghost btn-sm' %> +
  • + <% end %> +
+ <% end %> +
diff --git a/web/app/views/employee_workspace/time_off.html.erb b/web/app/views/employee_workspace/time_off.html.erb index 747f7f815..f032b3b96 100644 --- a/web/app/views/employee_workspace/time_off.html.erb +++ b/web/app/views/employee_workspace/time_off.html.erb @@ -71,10 +71,10 @@
<%= link_to '‹', personal_time_off_path(month: @month.prev_month.strftime('%Y-%m')), class: 'btn-ghost btn-sm', aria: { label: t('time.time_off.previous_month') } %>

<%= l(@month, format: '%B %Y') %>

<%= link_to '›', personal_time_off_path(month: @month.next_month.strftime('%Y-%m')), class: 'btn-ghost btn-sm', aria: { label: t('time.time_off.next_month') } %>
-
- <% (1..7).each do |i| %>
<%= t('date.abbr_day_names')[i % 7] %>
<% end %> +
+ <% (1..7).each do |i| %><% end %> <% (@month.beginning_of_week..@month.end_of_month.end_of_week).each do |day| %> -
+
<%= day.day %> <% if @summary.daily_hours(day).positive? %>

<%= number_with_precision(@summary.daily_hours(day), precision: 2, strip_insignificant_zeros: true) %>h

<% elsif @summary.clock_hours(day).positive? %>

<%= number_with_precision(@summary.clock_hours(day), precision: 2, strip_insignificant_zeros: true) %>h

<% end %> <% @summary.daily_requests(day).each do |leave| %>

<%= t("time.time_off.short.#{leave.leave_type}") %><%= leave.requested? ? t('time.time_off.status.requested') : t('time.time_off.status.approved') %>

<% end %> diff --git a/web/app/views/layouts/employee.html.erb b/web/app/views/layouts/employee.html.erb index 6ed2e4cae..887852436 100644 --- a/web/app/views/layouts/employee.html.erb +++ b/web/app/views/layouts/employee.html.erb @@ -1,11 +1,13 @@ - + <%= render "layouts/favicon" %> <%= safe_join([content_for(:title), 'Rupert'].compact, " · ") %> + <%# The iPhone app watches this to restart its tabs after a sign-in or sign-out (SessionWatcher). %> + <% if hotwire_native_app? %><% end %> <%= render 'layouts/theme' %> @@ -17,11 +19,11 @@ <%= javascript_importmap_tags %> <%= yield :head %> - data-feedback-url-value="<%= feedback_reports_path %>" data-feedback-build-value="<%= BuildInfo.current.short_revision %>" data-feedback-request-id-value="<%= request.request_id %>"<% end %> class="min-h-screen bg-cream text-ink"> + data-feedback-url-value="<%= feedback_reports_path %>" data-feedback-build-value="<%= BuildInfo.current.short_revision %>" data-feedback-request-id-value="<%= request.request_id %>"<% end %> class="min-h-screen bg-cream text-ink<%= ' native-app' if hotwire_native_app? %>"> Skip to content
<%= render "layouts/build_version" %> -
+
<%= link_to employee_app_path(preview: @preview ? 1 : nil), class: 'app-brand text-lg font-semibold text-olive-deep', aria: { label: 'My time home' } do %> <%= render "shared/rupert_face", size: 40 %> Rupert @@ -50,11 +52,12 @@
<%# Next thing to do (NextThingsController): the person's own late and due-today tasks; empty when nothing is waiting. %> <% if Current.user && !@preview %><% end %> - <%= render 'shared/workspace_help' if Current.user && !@preview %> <% if notice %>

<%= notice %>

<% end %> <% if alert %><% end %> <% if @preview %>

Staff preview · Example data only. Browse freely; saving, clocking and sending are disabled. <%= link_to 'Close preview', employee_app_path, class: 'underline' %>

<% end %> <%= yield %> + <%# Page help is a quiet line under the content (docs/ui-standards.md §2), never above the clock. %> + <%= render 'shared/workspace_help' if Current.user && !@preview %>
<%= render 'shared/help_panel' if Current.user && !@preview %> <%= render 'shared/feedback_panel' if Current.user && !@preview %> @@ -63,6 +66,8 @@ <%= link_to 'Privacy & support', employee_privacy_path(preview: @preview ? 1 : nil), class: 'underline' %> <%= link_to t('time.clock.settings.title'), employee_devices_path, class: 'underline' if Current.user && !@preview %> <% if Current.user&.can_manage_accounts? %><%= link_to 'Back to management', root_path, class: 'underline' %><% end %> + <%# Sign out also lives here because the brand row (with its Sign out) is hidden inside the iPhone app. %> + <% if Current.user && !@preview %><%= button_to 'Sign out', logout_path, method: :delete, class: 'underline', form_class: 'contents' %><% end %> diff --git a/web/app/views/layouts/pos.html.erb b/web/app/views/layouts/pos.html.erb index 903d33945..fa6f64940 100644 --- a/web/app/views/layouts/pos.html.erb +++ b/web/app/views/layouts/pos.html.erb @@ -8,6 +8,7 @@ <%= safe_join([content_for(:title), "Rupert"].compact, " · ") %> + <% if hotwire_native_app? %><% end %> <%= render "layouts/theme" %> diff --git a/web/app/views/shared/_push_subscribe.html.erb b/web/app/views/shared/_push_subscribe.html.erb new file mode 100644 index 000000000..4fd0fee16 --- /dev/null +++ b/web/app/views/shared/_push_subscribe.html.erb @@ -0,0 +1,9 @@ +<%# Web Push for the installed staff web app. Shown only when VAPID keys are configured; the button asks the browser once. %> +<% if vapid_public_key.present? %> +
+

Get schedule, approval and clock-out notifications on this device.

+ +
+<% else %> +

Push notifications for the web app are not set up yet. The bell at the top still shows everything.

+<% end %> diff --git a/web/bin/check-a11y b/web/bin/check-a11y new file mode 100755 index 000000000..f26ecbfb8 --- /dev/null +++ b/web/bin/check-a11y @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Accessibility audit of the staff (Time tab) pages with axe-core, at phone width, light and dark. +# Fails on any serious or critical violation. Nothing is installed into the repo: Playwright and +# axe-core go into a scratch folder ($A11Y_DIR, default a temp dir) with npm. +# +# bin/check-a11y # against http://127.0.0.1:3000 as the demo worker +# BASE=http://127.0.0.1:3005 EMAIL=… PASSWORD=… bin/check-a11y /employee /employee/schedule +# +# Needs a running Rails server with the review demo seeded (REVIEW_PASSWORD=… bin/rails rupert:app_review_demo) +# unless EMAIL/PASSWORD point at another worker account. +set -euo pipefail + +BASE=${BASE:-http://127.0.0.1:3000} +EMAIL=${EMAIL:-sam.okafor@juniper-pine.example} +PASSWORD=${PASSWORD:-${REVIEW_PASSWORD:-}} +A11Y_DIR=${A11Y_DIR:-$(mktemp -d)} +[ -n "$PASSWORD" ] || { echo "Set PASSWORD (or REVIEW_PASSWORD) for $EMAIL" >&2; exit 2; } + +PAGES=("$@") +[ ${#PAGES[@]} -gt 0 ] || PAGES=(/employee /employee/schedule /employee/time-off /employee/pay /employee/notifications/settings /employee/devices /time_clock /notifications) + +if [ ! -d "$A11Y_DIR/node_modules/playwright" ]; then + (cd "$A11Y_DIR" && npm init -y >/dev/null && npm install --no-audit --no-fund playwright@1.58.0 axe-core@4.11.0 >/dev/null && npx playwright install chromium >/dev/null) +fi + +cat >"$A11Y_DIR/audit.mjs" <<'EOF' +import { chromium } from "playwright"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +const require = createRequire(import.meta.url); +const axeSource = readFileSync(require.resolve("axe-core/axe.min.js"), "utf8"); +const [base, email, password, ...pages] = process.argv.slice(2); +const browser = await chromium.launch(); +let failures = 0; +for (const scheme of ["light", "dark"]) { + const ctx = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, colorScheme: scheme }); + const page = await ctx.newPage(); + await page.goto(`${base}/login`, { waitUntil: "networkidle" }); + await page.evaluate(() => document.querySelector("details.login-password")?.setAttribute("open", "")); + await page.fill("input[name=email]", email); + await page.fill("input[name=password]", password); + await Promise.all([page.waitForURL((u) => !u.pathname.startsWith("/login")), page.click("input[type=submit], button[type=submit]")]); + for (const path of pages) { + await page.goto(base + path, { waitUntil: "networkidle" }); + await page.addScriptTag({ content: axeSource }); + const result = await page.evaluate(async () => await window.axe.run(document, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21aa", "best-practice"] } })); + const bad = result.violations.filter((v) => ["serious", "critical"].includes(v.impact)); + const minor = result.violations.length - bad.length; + console.log(`${scheme.padEnd(5)} ${path.padEnd(36)} ${bad.length} serious/critical, ${minor} other`); + for (const v of bad) { + failures++; + console.log(` ${v.impact.toUpperCase()} ${v.id}: ${v.help} (${v.nodes.length} nodes)`); + for (const node of v.nodes.slice(0, 3)) console.log(` ${node.target.join(" ")}`); + } + } + await ctx.close(); +} +await browser.close(); +process.exit(failures ? 1 : 0); +EOF +cd "$A11Y_DIR" && node audit.mjs "$BASE" "$EMAIL" "$PASSWORD" "${PAGES[@]}" diff --git a/web/config/locales/es.yml b/web/config/locales/es.yml new file mode 100644 index 000000000..a947698f4 --- /dev/null +++ b/web/config/locales/es.yml @@ -0,0 +1,52 @@ +# Spanish for the staff-facing pages (a worker's People::Employee#locale). Rails dates, times and +# common model messages here; each time & pay screen keeps its own strings in time_*.es.yml. +# Management pages stay in English (ApplicationController#switch_locale). +es: + date: + abbr_day_names: [dom, lun, mar, mié, jue, vie, sáb] + abbr_month_names: [~, ene, feb, mar, abr, may, jun, jul, ago, sep, oct, nov, dic] + day_names: [domingo, lunes, martes, miércoles, jueves, viernes, sábado] + month_names: [~, enero, febrero, marzo, abril, mayo, junio, julio, agosto, septiembre, octubre, noviembre, diciembre] + formats: + default: "%d/%m/%Y" + long: "%-d de %B de %Y" + short: "%-d %b" + order: + - :day + - :month + - :year + time: + am: am + pm: pm + formats: + default: "%a, %-d %b %Y %H:%M" + short: "%-d %b %H:%M" + long: "%A, %-d de %B de %Y" + time: "%-I:%M %p" + datetime: "%A, %-d %b %Y · %-I:%M %p" + number: + format: + delimiter: "," + separator: "." + currency: + format: + format: "%u%n" + unit: "$" + errors: + messages: + blank: "no puede estar en blanco" + invalid: "no es válido" + taken: "ya está en uso" + too_long: "es demasiado largo (máximo %{count} caracteres)" + not_a_number: "no es un número" + greater_than: "debe ser mayor que %{count}" + less_than_or_equal_to: "debe ser menor o igual que %{count}" + helpers: + submit: + create: "Guardar" + update: "Guardar" + submit: "Guardar" + activerecord: + errors: + messages: + record_invalid: "La validación falló: %{errors}" diff --git a/web/config/routes.rb b/web/config/routes.rb index a111013d3..ed3e8845d 100644 --- a/web/config/routes.rb +++ b/web/config/routes.rb @@ -156,6 +156,16 @@ defaults: { format: :json } post '/native/session', to: 'native/sessions#create', as: :native_session + # ---- time & pay: ios ---- + # The iPhone app's APNs token, the installed web app's Web Push subscription, and a person's own notification choices. + post '/native/push_devices', to: 'native/push_devices#create', as: :native_push_devices + delete '/native/push_devices/:token_digest', to: 'native/push_devices#destroy', as: :native_push_device + get '/employee/notifications/settings', to: 'employee_notifications#settings', as: :employee_notification_settings + patch '/employee/notifications/settings', to: 'employee_notifications#update_settings' + post '/employee/push_subscriptions', to: 'employee_notifications#subscribe', as: :employee_push_subscriptions + post '/employee/push_subscriptions/:id/unsubscribe', to: 'employee_notifications#unsubscribe', as: :unsubscribe_employee_push + # ---- end time & pay: ios ---- + get '/workspace', to: 'workspaces#show', as: :workspace post '/workspace/session', to: 'workspaces#session_start', as: :workspace_session get '/instance/health', to: 'instance_health#show' diff --git a/web/lib/tasks/app_review_demo.rake b/web/lib/tasks/app_review_demo.rake index 575d8b7c1..d34040edc 100644 --- a/web/lib/tasks/app_review_demo.rake +++ b/web/lib/tasks/app_review_demo.rake @@ -30,8 +30,12 @@ module AppReviewDemo PEOPLE = [ { key: :maya, email: 'maya.chen@juniper-pine.example', name: 'Maya Chen', role: 'admin' }, { key: :theo, email: 'theo.alvarez@juniper-pine.example', name: 'Theo Alvarez', role: 'admin' }, - { key: :priya, email: 'priya.shah@juniper-pine.example', name: 'Priya Shah', role: 'cashier' } + { key: :priya, email: 'priya.shah@juniper-pine.example', name: 'Priya Shah', role: 'cashier' }, + # The hourly worker the reviewer can also sign in as (Time tab): their password is REVIEW_WORKER_PASSWORD, + # or the review password when that is not set. Never printed. + { key: :worker, email: 'sam.okafor@juniper-pine.example', name: 'Sam Okafor', role: 'employee' } ].freeze + WORKER_PIN = '2580' # handle, product title, variant title, type, price, shop shelf, back stockroom CATALOG = [ @@ -61,7 +65,7 @@ module AppReviewDemo module_function - def run!(password:, reset: false, now: Time.current) + def run!(password:, reset: false, now: Time.current, worker_password: nil) raise ArgumentError, "REVIEW_PASSWORD must be at least #{MIN_PASSWORD} characters" if password.to_s.length < MIN_PASSWORD tenant = ensure_tenant! @@ -69,6 +73,7 @@ module AppReviewDemo Current.set(tenant: tenant, user: user) do wipe!(tenant) if reset people = ensure_people!(tenant).merge(sam: user) + people[:worker].update!(password: worker_password.presence || password) locations = ensure_locations! variants = ensure_catalog!(locations, now) orders = ensure_sales!(variants, locations[:shop], now) @@ -76,6 +81,7 @@ module AppReviewDemo tasks = ensure_tasks!(people, now) ensure_notifications!(user, people, tasks, count, now) ensure_chat!(people, now) + ensure_time!(people, locations[:shop], now) DataCache.bump! settle_reviewer!(user) { tenant: tenant, user: user, orders: orders, count: count } @@ -296,6 +302,150 @@ module AppReviewDemo end # Deletes the demo workspace's rows, table by table, matching only its tenant id. + # ---- Time & pay (the Time tab) ------------------------------------------------------------- + # Staff records for Maya (manager, admin: no tip share), Priya and the worker, hourly rates, the + # worker's clock PIN, two weeks of punches with a meal break, next week's published shifts, time + # off (one approved, one waiting), this week's tips and a submitted timesheet. Keyed so a second + # run changes nothing it already made. + def ensure_time!(people, shop, now) + staff = { + maya: staff!(people[:maya], 'Maya', 'Chen', 2600, number: '1001'), + priya: staff!(people[:priya], 'Priya', 'Shah', 1900, number: '1002'), + worker: staff!(people[:worker], 'Sam', 'Okafor', 1850, number: '1003') + } + staff[:worker].set_pin!(WORKER_PIN) unless staff[:worker].pin_set? + staff.each_value { |employee| employee.update!(location_ids: [shop.id]) if employee.location_ids.empty? } + ensure_punches!(staff, shop, now) + ensure_shifts!(staff, shop, now) + ensure_time_off!(staff, people[:maya], now) + ensure_tips!(staff, shop, now) + ensure_timesheet!(staff[:worker], now) + end + + def staff!(user, first, last, rate_cents, number:) + employee = People::Employee.find_or_initialize_by(user_id: user.id) + if employee.new_record? + employee.assign_attributes(first_name: first, last_name: last, email: user.email, employee_number: number, + employment_type: 'part_time', hire_date: Date.new(2025, 3, 3)) + employee.save! + end + People::PayRate.find_or_create_by!(employee_id: employee.id, pay_type: 'hourly', effective_on: Date.new(2025, 3, 3)) do |rate| + rate.hourly_rate_cents = rate_cents + rate.pay_frequency = 'weekly' + end + employee + end + + # Weekday shifts over the last two weeks, 9 to 5 with a 30-minute meal; yesterday has a rest break too. + def ensure_punches!(staff, shop, now) + zone = BusinessClock.zone(tenant: Current.tenant, location: shop) + (1..14).each do |days_ago| + day = now.in_time_zone(zone).to_date - days_ago + next if day.saturday? || day.sunday? + + { worker: [9, 17], priya: [10, 18] }.each do |key, (from, to)| + employee = staff[key] + token = "demo-#{key}-#{day.iso8601}" + next if ClockShift.exists?(request_token: token) + + started = zone.local(day.year, day.month, day.day, from, [2, 0, 4, 1][days_ago % 4]) + ended = zone.local(day.year, day.month, day.day, to, [1, 6, 0, 3][days_ago % 4]) + shift = ClockShift.create!(user: employee.user, employee: employee, employee_key: employee.id.to_s, employee_name: employee.name, + employment_type: employee.employment_type, request_token: token, started_at: started, ended_at: ended, + source: 'pos', location_id: shop.id, register_name: 'Front register', device_label: 'Front register') + shift.breaks.create!(kind: 'meal', paid: false, started_at: started + 4.hours, ended_at: started + 4.hours + 30.minutes) + shift.breaks.create!(kind: 'rest', paid: true, started_at: started + 2.hours, ended_at: started + 2.hours + 10.minutes) if days_ago == 1 + end + end + end + + # Next week's shifts, published, plus one open shift to pick up. + def ensure_shifts!(staff, shop, now) + zone = BusinessClock.zone(tenant: Current.tenant, location: shop) + week = (now.in_time_zone(zone).to_date + 7).beginning_of_week + pattern = { worker: [[0, 9, 17], [1, 9, 17], [3, 12, 20], [4, 9, 17]], priya: [[0, 10, 18], [2, 10, 18], [3, 9, 17], [5, 10, 16]], maya: [[1, 8, 16], [2, 8, 16], [4, 8, 16]] } + pattern.each do |key, days| + days.each do |(offset, from, to)| + day = week + offset + starts_at = zone.local(day.year, day.month, day.day, from) + next if People::ScheduledShift.exists?(employee_id: staff[key].id, starts_at: starts_at) + + People::ScheduledShift.create!(employee: staff[key], starts_at: starts_at, ends_at: zone.local(day.year, day.month, day.day, to), + role_label: key == :maya ? 'Manager' : 'Counter', location_id: shop.id, break_minutes: 30, published_at: now - 2.days, created_by: staff[:maya].user) + end + end + saturday = week + 5 + open_start = zone.local(saturday.year, saturday.month, saturday.day, 12) + unless People::ScheduledShift.exists?(employee_id: nil, starts_at: open_start) + People::ScheduledShift.create!(employee: nil, starts_at: open_start, ends_at: open_start + 6.hours, role_label: 'Counter', + location_id: shop.id, break_minutes: 0, published_at: now - 2.days, created_by: staff[:maya].user, notes: 'Holiday market weekend: extra hands at the counter.') + end + People::SchedulePublication.find_or_create_by!(location_id: shop.id, week_start: week) do |publication| + publication.published_at = now - 2.days + publication.published_by = staff[:maya].user + publication.shift_count = People::ScheduledShift.between(week, week + 6).count + end + end + + def ensure_time_off!(staff, maya, now) + worker = staff[:worker] + approved = People::LeaveRequest.find_or_initialize_by(employee_id: worker.id, request_key: 'demo-worker-approved') + if approved.new_record? + approved.assign_attributes(leave_type: 'vacation', starts_on: now.to_date + 21, ends_on: now.to_date + 23, hours: 24, reason: 'Long weekend away.') + approved.save! + approved.approve! + approved.update!(reviewed_by: maya.id, reviewed_at: now - 1.day) + end + pending = People::LeaveRequest.find_or_initialize_by(employee_id: worker.id, request_key: 'demo-worker-pending') + if pending.new_record? + pending.assign_attributes(leave_type: 'personal', starts_on: now.to_date + 10, ends_on: now.to_date + 10, hours: 8, reason: 'Dentist in the morning.') + pending.save! + end + worker.leave_balance('vacation').tap { |b| b.update!(used_hours: 24) if b.used_hours.zero? } + end + + # Card tips shared between the two staff on shift and a cash tip the worker declared, this week. + def ensure_tips!(staff, shop, now) + zone = BusinessClock.zone(tenant: Current.tenant, location: shop) + (1..5).each do |days_ago| + day = now.in_time_zone(zone).to_date - days_ago + next if day.saturday? || day.sunday? + + ref = "demo-tip-#{day.iso8601}" + next if TipAllocation.exists?(source_ref: ref) + + at = zone.local(day.year, day.month, day.day, 13, 15) + gross = 1200 + (days_ago * 325) + %i[worker priya].each_with_index do |key, index| + employee = staff[key] + shift = ClockShift.find_by(employee_id: employee.id, request_token: "demo-#{key}-#{day.iso8601}") + TipAllocation.create!(employee: employee, clock_shift: shift, location_id: shop.id, source_kind: 'manual', source_ref: ref, + tip_method: 'pool', status: days_ago > 2 ? 'settled' : 'estimated', amount_cents: (gross / 2) + (index.zero? ? gross % 2 : 0), + gross_tip_cents: gross, share_count: 2, occurred_at: at, business_date: day, evidence: 'time_register', + settled_at: days_ago > 2 ? at + 1.day : nil, note: 'Card tips from the front register, shared evenly.') + end + next unless days_ago == 1 + + TipAllocation.create!(employee: staff[:worker], location_id: shop.id, source_kind: 'declared', source_ref: "#{ref}-cash", tip_method: 'cash', + status: 'settled', amount_cents: 1400, occurred_at: at + 4.hours, business_date: day, evidence: 'declared', created_by: staff[:worker].user, + note: 'Cash tips declared at clock-out.') + end + end + + # Last week's timesheet, built from the punches and sent for review. + def ensure_timesheet!(worker, now) + rules = PayRules.current + period_start, period_end = rules.period_range(now.to_date - 7) + return if People::Timesheet.exists?(employee_id: worker.id, period_start: period_start) + + sheet = People::Timesheet.create!(employee: worker, period_start: period_start, period_end: period_end, notes: 'Covered the Thursday delivery.') + ClockShift.where(employee_id: worker.id).where.not(ended_at: nil).where(started_at: period_start.in_time_zone..(period_end + 1).in_time_zone).find_each do |shift| + sheet.entries.create!(worked_on: shift.started_at.to_date, hours: shift.paid_hours.round(2), work_type: 'regular', clock_shift_id: shift.id) + end + sheet.submit! if sheet.entries.any? + sheet.update!(submitted_at: now - 1.day) if sheet.submitted? + end + def wipe!(tenant) connection = ActiveRecord::Base.connection tables = connection.tables.reject { |table| KEEP_ON_RESET.include?(table) } @@ -320,10 +470,11 @@ namespace :rupert do abort("Set REVIEW_PASSWORD to the review account's password (at least #{AppReviewDemo::MIN_PASSWORD} characters).") end - result = AppReviewDemo.run!(password: password, reset: ENV['REVIEW_DEMO_RESET'].present?) + result = AppReviewDemo.run!(password: password, reset: ENV['REVIEW_DEMO_RESET'].present?, worker_password: ENV.fetch('REVIEW_WORKER_PASSWORD', nil)) puts "#{AppReviewDemo::NAME} ready (workspace #{AppReviewDemo::SUBDOMAIN})." puts "Sign in as #{result[:user].email} (admin of this workspace only) with the password you set." puts "Added #{result[:orders]} sample sales; draft count #{result[:count].id}." + puts "Worker sign-in for the Time tab: sam.okafor@juniper-pine.example (REVIEW_WORKER_PASSWORD, or the review password); clock PIN #{AppReviewDemo::WORKER_PIN}." rescue ArgumentError => e abort(e.message) end diff --git a/web/test/integration/employee_workspace_test.rb b/web/test/integration/employee_workspace_test.rb index 86c6b0bad..795682603 100644 --- a/web/test/integration/employee_workspace_test.rb +++ b/web/test/integration/employee_workspace_test.rb @@ -91,7 +91,7 @@ def sign_in(user = @user) sign_in get employee_time_off_path, params: { month: '2026-10', employee_id: @other.id } assert_response :success - assert_select '[role="table"][aria-label="Your calendar"]' + assert_select '[role="list"][aria-label="Your calendar"]' assert_includes response.body, 'My vacation' assert_select '[data-balance="vacation"]', '40 h' refute_includes response.body, 'PRIVATE OTHER LEAVE' diff --git a/web/test/integration/native_app_test.rb b/web/test/integration/native_app_test.rb index 9aae08f42..a5a181fb4 100644 --- a/web/test/integration/native_app_test.rb +++ b/web/test/integration/native_app_test.rb @@ -56,6 +56,17 @@ def handed_off(name) assert_not_includes response.body, 'class="min-h-full native-app"' end + test 'every layout the Time tab shows tells the app whether the person is signed in' do + native = { 'User-Agent' => 'Mozilla/5.0 (iPhone) Hotwire Native iOS; Turbo Native iOS; bridge-components: []' } + get employee_app_path, headers: native + assert_includes response.body, '' + post login_path, params: { email: @admin.email, password: 'password123' } + get employee_app_path, headers: native + assert_includes response.body, '' + get employee_app_path + assert_not_includes response.body, 'rupert-session', 'browsers do not get the tag' + end + test 'Google in the sheet hands a one-time code to the app, not a session' do sign_in_through_sheet code = handed_off('code') diff --git a/web/test/integration/notification_settings_test.rb b/web/test/integration/notification_settings_test.rb new file mode 100644 index 000000000..f5dd888c4 --- /dev/null +++ b/web/test/integration/notification_settings_test.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'test_helper' + +# A person's own notification choices: every kind on by default, turned off per kind, honoured by +# TimeNotifier so a muted kind reaches neither the bell nor a phone. +class NotificationSettingsTest < ActionDispatch::IntegrationTest + setup do + @tenant = tenants(:default_tenant) + Current.tenant = @tenant + @worker_user = User.create!(tenant: @tenant, name: 'Quiet Worker', email: 'quiet-worker@example.com', role: 'employee', password: 'password123') + People::Employee.create!(user: @worker_user, first_name: 'Quiet', last_name: 'Worker', employment_type: 'part_time') + sign_in @worker_user + end + + teardown { Current.reset } + + test 'an employee opens the settings page and turns a kind off' do + get employee_notification_settings_path + assert_response :success + assert_select 'h1', 'Notifications' + assert_select 'input[name^="prefs["][type=checkbox][checked]', User::NOTIFICATION_KINDS.size + assert_select 'form[action=?]', employee_notification_settings_path + patch employee_notification_settings_path, params: { prefs: { break_due: '0', schedule_posted: '1' } } + assert_redirected_to employee_notification_settings_path + prefs = @worker_user.reload.notification_prefs + assert_equal false, prefs['break_due'] + assert_equal true, prefs['schedule_posted'] + assert_equal true, prefs['approvals'], 'a kind the form did not mention keeps its default (the form always sends every kind)' + end + + test 'a muted kind is not delivered' do + @worker_user.update!(notification_prefs: { 'break_due' => '0' }) + Current.reset + Current.tenant = @tenant + assert_no_difference -> { Notification.where(user_id: @worker_user.id).count } do + TimeNotifier.break_due(@worker_user, shift: Struct.new(:id).new(1), kind: 'meal', url: '/employee') + end + assert_difference -> { Notification.where(user_id: @worker_user.id).count }, 1 do + TimeNotifier.schedule_posted(@worker_user, week_start: Date.current, url: '/employee/schedule', shift_count: 2) + end + end +end diff --git a/web/test/integration/push_devices_test.rb b/web/test/integration/push_devices_test.rb new file mode 100644 index 000000000..5723ff6e5 --- /dev/null +++ b/web/test/integration/push_devices_test.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'test_helper' + +# The iPhone app's APNs token and the installed web app's Web Push subscription are tied to the +# signed-in person's session; they are addresses, so sign-out and account deletion drop them. +class PushDevicesTest < ActionDispatch::IntegrationTest + setup do + @tenant = tenants(:default_tenant) + Current.tenant = @tenant + @worker_user = User.create!(tenant: @tenant, name: 'Push Worker', email: 'push-worker@example.com', role: 'employee', password: 'password123') + @worker = People::Employee.create!(user: @worker_user, first_name: 'Push', last_name: 'Worker', employment_type: 'part_time') + @worker.set_pin!('2580') + end + + teardown { Current.reset } + + test 'the app registers its token for the signed-in person and forgets it on sign-out' do + post native_push_devices_path, params: { platform: 'ios', token: 'abc123' }, as: :json + assert_redirected_to login_path + assert_equal 0, PushDevice.count, 'needs a session' + sign_in @worker_user + post native_push_devices_path, params: { platform: 'ios', token: 'abc123', environment: 'sandbox', app_version: '1.1.0 (20)' }, as: :json + assert_response :created + body = response.parsed_body + device = PushDevice.find(body['id']) + assert_equal @worker_user.id, device.user_id + assert_equal 'sandbox', device.environment + assert_equal false, body['push_configured'] + post native_push_devices_path, params: { platform: 'ios', token: 'abc123' }, as: :json + assert_equal 1, PushDevice.where(user_id: @worker_user.id).count, 'registering again updates in place' + delete native_push_device_path(body['token_digest']) + assert_response :no_content + assert_equal 'signed out', device.reload.failure_reason + post native_push_devices_path, params: { platform: 'android', token: 'x' }, as: :json + assert_response :unprocessable_entity + end + + test 'the web app subscribes with an endpoint and keys' do + sign_in @worker_user + post employee_push_subscriptions_path, params: { subscription: { endpoint: 'https://push.example/sub/1', keys: { p256dh: 'p', auth: 'a' } } }, as: :json + assert_response :created + device = PushDevice.find(response.parsed_body['id']) + assert_equal 'web', device.platform + assert_equal({ 'p256dh' => 'p', 'auth' => 'a' }, device.keys) + post employee_push_subscriptions_path, params: { subscription: { endpoint: 'http://insecure.example', keys: { p256dh: 'p', auth: 'a' } } }, as: :json + assert_response :unprocessable_entity + post unsubscribe_employee_push_path(device) + assert_redirected_to employee_notification_settings_path + assert device.reload.disabled_at.present? + end + + test 'deleting the account removes push devices and the clock PIN' do + sign_in @worker_user + PushDevice.register!(user: @worker_user, platform: 'ios', token: 'to-delete') + delete own_account_path, params: { confirmation: 'DELETE' } + assert_redirected_to login_path + assert_equal 0, PushDevice.unscoped.where(user_id: @worker_user.id).count + assert_not @worker.reload.pin_set? + end + + test 'nobody sees or disables another person’s device' do + other = User.create!(tenant: @tenant, name: 'Other', email: 'other-push-worker@example.com', role: 'employee', password: 'password123') + device = PushDevice.register!(user: other, platform: 'ios', token: 'theirs') + sign_in @worker_user + post unsubscribe_employee_push_path(device) + assert_nil device.reload.disabled_at + get employee_notification_settings_path + assert_response :success + assert_no_match 'last seen', response.body + end +end diff --git a/web/test/integration/square_timecard_history_test.rb b/web/test/integration/square_timecard_history_test.rb index 37fb29736..842c0a487 100644 --- a/web/test/integration/square_timecard_history_test.rb +++ b/web/test/integration/square_timecard_history_test.rb @@ -49,7 +49,7 @@ def source(id, team) assert_includes response.body, '8h' get employee_time_off_path, params: { month: '2026-10' } assert_response :success - assert_select '[role="cell"][aria-label="2026-10-05"]', text: /8h/ + assert_select '[role="listitem"][data-date="2026-10-05"]', text: /8h/ assert_no_difference ['SquareTimecard.count', 'SquareTimecardDay.count', 'People::TimesheetEntry.count'] do post people_square_timesheets_path assert_response :not_found diff --git a/web/test/integration/worker_locale_test.rb b/web/test/integration/worker_locale_test.rb new file mode 100644 index 000000000..38b9353fe --- /dev/null +++ b/web/test/integration/worker_locale_test.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'test_helper' + +# A worker's language follows their staff record, never the request: Spanish staff pages render +# with lang="es" and Spanish dates; management pages and other people stay in English. +class WorkerLocaleTest < ActionDispatch::IntegrationTest + setup do + @tenant = tenants(:default_tenant) + Current.tenant = @tenant + @user = User.create!(tenant: @tenant, name: 'Lucía Ortiz', email: 'lucia@example.com', role: 'employee', password: 'password123') + @worker = People::Employee.create!(user: @user, first_name: 'Lucía', last_name: 'Ortiz', employment_type: 'part_time', locale: 'es') + end + + teardown { Current.reset } + + test 'the staff home renders in the worker language' do + sign_in @user + get employee_app_path + assert_response :success + assert_select 'html[lang=es]' + get employee_app_path, headers: { 'Accept-Language' => 'fr' } + assert_select 'html[lang=es]' # the request language never decides + end + + test 'a worker without a language and a manager stay in English' do + @worker.update!(locale: nil) + sign_in @user + get employee_app_path + assert_select 'html[lang=en]' + assert_equal :en, I18n.locale, 'the locale is restored after the request' + sign_in users(:regular_user) + get root_path + assert_select 'html[lang=en]' + end + + test 'an unknown language falls back to English' do + @worker.update!(locale: 'xx') + sign_in @user + get employee_app_path + assert_select 'html[lang=en]' + end +end diff --git a/web/test/lib/app_review_demo_task_test.rb b/web/test/lib/app_review_demo_task_test.rb index 17219e3c9..1d231f2a6 100644 --- a/web/test/lib/app_review_demo_task_test.rb +++ b/web/test/lib/app_review_demo_task_test.rb @@ -82,9 +82,32 @@ def in_demo(&) = Current.set(tenant: demo_tenant, &) end end + test 'the Time tab has a worker with a PIN, punches, shifts, time off, tips and a timesheet to review' do + output = run_task('REVIEW_PASSWORD' => PASSWORD, 'REVIEW_WORKER_PASSWORD' => "#{PASSWORD}-worker", 'REVIEW_DEMO_RESET' => nil) + assert_not_includes output, "#{PASSWORD}-worker" + worker_user = User.find_by!(email: 'sam.okafor@juniper-pine.example') + assert_equal 'employee', worker_user.role + assert worker_user.authenticate("#{PASSWORD}-worker") + in_demo do + worker = People::Employee.find_by!(user_id: worker_user.id) + assert worker.pin_set? + assert worker.verify_pin(AppReviewDemo::WORKER_PIN) + assert worker.current_pay_rate.hourly_rate_cents.positive? + assert_operator ClockShift.where(employee_id: worker.id).count, :>=, 8 + assert ClockShift.where(employee_id: worker.id).all? { |shift| shift.breaks.any? { |b| b.kind == 'meal' } } + assert People::ScheduledShift.published.where(employee_id: worker.id).exists? + assert People::ScheduledShift.open_shifts.exists?, 'an open shift to pick up' + assert_equal %w[approved requested].sort, People::LeaveRequest.where(employee_id: worker.id).pluck(:status).sort + assert TipAllocation.where(employee_id: worker.id, tip_method: 'pool').exists? + assert TipAllocation.where(employee_id: worker.id, tip_method: 'cash').exists? + assert_not TipAllocation.where(employee_id: People::Employee.where(user_id: User.where(role: 'admin').select(:id)).select(:id)).exists?, 'admins get no tip share' + assert People::Timesheet.where(employee_id: worker.id, status: 'submitted').exists? + end + end + test 'running again adds nothing and keeps the password it is given' do run_task('REVIEW_PASSWORD' => PASSWORD, 'REVIEW_DEMO_RESET' => nil) - counts = -> { in_demo { [User.where(tenant_id: demo_tenant.id).count, Location.count, ShopifyVariant.count, InventoryLevel.count, Core::Order.count, Core::OrderLine.count, InventoryCountItem.count, Projects::Task.count, Notification.count, TeamChat::Message.count, TeamChat::Room.count] } } + counts = -> { in_demo { [User.where(tenant_id: demo_tenant.id).count, Location.count, ShopifyVariant.count, InventoryLevel.count, Core::Order.count, Core::OrderLine.count, InventoryCountItem.count, Projects::Task.count, Notification.count, TeamChat::Message.count, TeamChat::Room.count, ClockShift.count, ClockBreak.count, People::ScheduledShift.count, People::LeaveRequest.count, TipAllocation.count, People::Timesheet.count] } } before = counts.call run_task('REVIEW_PASSWORD' => "#{PASSWORD}-new", 'REVIEW_DEMO_RESET' => nil) diff --git a/web/test/services/push_notifier_test.rb b/web/test/services/push_notifier_test.rb new file mode 100644 index 000000000..2a43ba586 --- /dev/null +++ b/web/test/services/push_notifier_test.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require 'test_helper' + +# Push delivery to the iPhone app (APNs, stubbed at the apnotic connection) and the installed web +# app (Web Push, stubbed at the HTTP layer). Dead devices are disabled, never deleted. +class PushNotifierTest < ActiveSupport::TestCase + APNS_ENV = { 'APNS_KEY_ID' => 'ABC123DEFG', 'APNS_TEAM_ID' => 'TEAM123456', 'APNS_KEY_P8' => "-----BEGIN PRIVATE KEY-----\nMIGT\n-----END PRIVATE KEY-----", + 'APNS_TOPIC' => 'com.rupertpos.Rupert', 'APNS_ENVIRONMENT' => 'sandbox' }.freeze + VAPID_ENV = { 'VAPID_PUBLIC_KEY' => 'BDd3_hVL9fZi9Ybo2UUzA284WG5FZR30_95YeZJsiApwXKpNcF1rRPF3foIiBHXRdJI2Qhumhf6_LFTeZaNndIo', + 'VAPID_PRIVATE_KEY' => 'xx', 'VAPID_SUBJECT' => 'mailto:support@example.com' }.freeze + + FakeResponse = Struct.new(:status, :body) do + def ok? = status.to_s == '200' + end + + class FakeConnection + attr_reader :pushed + + def initialize(responses) + @responses = responses + @pushed = [] + end + + def push(notification) + @pushed << notification + @responses.shift + end + + def close; end + end + + setup do + @tenant = tenants(:default_tenant) + Current.tenant = @tenant + @user = users(:regular_user) + @notification = Notification.create!(user_id: @user.id, kind: 'schedule_posted', title: 'Your schedule is posted', body: '3 shifts', url: '/employee/schedule') + end + + teardown { Current.reset } + + test 'nothing is configured by default so the job is never enqueued' do + with_env(APNS_KEYS_BLANK) do + assert_not PushNotifier.configured? + assert_not PushDeliveryJob.enabled? + end + end + + test 'apns sends to every active iPhone and disables tokens the provider rejects for good' do + good = PushDevice.register!(user: @user, platform: 'ios', token: 'good-token') + dead = PushDevice.register!(user: @user, platform: 'ios', token: 'dead-token') + PushDevice.register!(user: @user, platform: 'ios', token: 'off-token').disable!('earlier') + connection = FakeConnection.new([FakeResponse.new('200', {}), FakeResponse.new('410', { 'reason' => 'Unregistered' })]) + with_env(APNS_ENV) do + assert PushNotifier.configured? + assert PushDeliveryJob.enabled? + Apnotic::Connection.expects(:development).returns(connection) + result = PushNotifier.deliver(@notification) + assert_equal 1, result.sent + assert_equal 1, result.disabled + assert_equal 0, result.failed + end + assert_equal 2, connection.pushed.size + push = connection.pushed.first + assert_equal 'com.rupertpos.Rupert', push.topic + assert_equal({ title: 'Your schedule is posted', body: '3 shifts' }, push.alert) + assert_equal '/employee/schedule', push.custom_payload[:url] + assert_equal 'schedule_posted', push.thread_id + assert_equal 1, push.badge, 'badge is the unread count' + assert_nil good.reload.disabled_at + assert_equal 'Unregistered', dead.reload.failure_reason + assert dead.disabled_at.present? + end + + test 'apns errors are counted as failures and never raise' do + PushDevice.register!(user: @user, platform: 'ios', token: 'any-token') + with_env(APNS_ENV) do + Apnotic::Connection.expects(:development).raises(SocketError.new('no route')) + result = PushNotifier.deliver(@notification) + assert_equal 1, result.failed + end + end + + test 'web push posts the payload to the subscription endpoint and drops gone subscriptions' do + alive = PushDevice.register!(user: @user, platform: 'web', token: 'https://push.example/alive', keys: { 'p256dh' => 'p', 'auth' => 'a' }) + gone = PushDevice.register!(user: @user, platform: 'web', token: 'https://push.example/gone', keys: { 'p256dh' => 'p', 'auth' => 'a' }) + with_env(VAPID_ENV) do + WebPush.expects(:payload_send).twice.with { |args| args[:endpoint] == alive.token || args[:endpoint] == gone.token } + .returns(nil).then.raises(WebPush::ExpiredSubscription.new(Struct.new(:code, :body).new('410', ''), 'push.example')) + result = PushNotifier.deliver(@notification) + assert_equal 1, result.sent + assert_equal 1, result.disabled + end + assert_nil alive.reload.disabled_at + assert_equal 'ExpiredSubscription', gone.reload.failure_reason + end + + test 'the delivery job runs inside the notification workspace' do + PushDevice.register!(user: @user, platform: 'ios', token: 'job-token') + with_env(APNS_ENV) do + Apnotic::Connection.expects(:development).returns(FakeConnection.new([FakeResponse.new('200', {})])) + Current.reset + PushDeliveryJob.perform_now(@notification.id) + end + end + + APNS_KEYS_BLANK = (PushNotifier::APNS_KEYS + PushNotifier::VAPID_KEYS).index_with { nil }.freeze + + private + + def with_env(values) + saved = values.keys.index_with { |key| ENV.fetch(key, nil) } + values.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value } + yield + ensure + saved.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value } + end +end