From f72f0626b09f164820896251cf2b0b165fb9edcf Mon Sep 17 00:00:00 2001 From: ThreadCount Date: Thu, 17 Sep 2026 05:49:16 +1000 Subject: [PATCH] ThreadCount Community edition Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 794bab5 on 2026-09-16. Licensed under the Functional Source License (FSL-1.1-ALv2). --- .dockerignore | 12 + .env.example | 52 + .github/workflows/release.yml | 34 + .gitignore | 86 + CHANGELOG.md | 49 + COMMUNITY_VERSION | 1 + CONTRIBUTING.md | 43 + Dockerfile | 50 + LICENSE | 661 +++ README.md | 191 + RELEASE_NOTES.md | 42 + app/api/2fa/route.ts | 121 + app/api/activity/route.ts | 58 + app/api/app-info/route.ts | 26 + app/api/auth/2fa/route.ts | 80 + app/api/auth/forgot/route.ts | 80 + app/api/auth/login/route.ts | 103 + app/api/auth/logout/route.ts | 22 + app/api/auth/lookup/route.ts | 28 + app/api/auth/reset/route.ts | 96 + app/api/auth/signup/route.ts | 100 + app/api/backup/route.ts | 25 + app/api/health/route.ts | 26 + app/api/logo/route.ts | 15 + app/api/lookup/route.ts | 80 + app/api/mutate/route.ts | 40 + app/api/photo/[id]/route.ts | 46 + app/api/requests/route.ts | 220 + app/api/rev/route.ts | 37 + app/api/staff/activate/route.ts | 132 + app/api/staff/decide/route.ts | 87 + app/api/staff/login/route.ts | 45 + app/api/staff/logout/route.ts | 23 + app/api/staff/mutate/route.ts | 56 + app/api/staff/slip/[id]/sig/route.ts | 49 + app/app/activity/page.tsx | 14 + app/app/counter/page.tsx | 8 + app/app/help/[section]/[slug]/page.tsx | 28 + app/app/help/page.tsx | 26 + app/app/issue/page.tsx | 13 + app/app/layout.tsx | 38 + app/app/orders/[id]/page.tsx | 355 ++ app/app/orders/all/page.tsx | 110 + app/app/orders/list/page.tsx | 11 + app/app/orders/page.tsx | 29 + app/app/page.tsx | 44 + app/app/report/page.tsx | 83 + app/app/requests/page.tsx | 142 + app/app/rounds/page.tsx | 73 + app/app/settings/page.tsx | 70 + app/app/staff/[id]/page.tsx | 8 + app/app/staff/page.tsx | 8 + app/app/stock/[id]/page.tsx | 414 ++ app/app/stock/page.tsx | 50 + app/app/stocktake/page.tsx | 15 + app/auth/page.tsx | 44 + app/error.tsx | 44 + app/favicon.ico | Bin 0 -> 25931 bytes app/global-error.tsx | 70 + app/globals.css | 1433 +++++ app/layout.tsx | 55 + app/m/(app)/catalogue/[id]/page.tsx | 466 ++ app/m/(app)/catalogue/new/page.tsx | 136 + app/m/(app)/catalogue/page.tsx | 76 + app/m/(app)/count/[id]/page.tsx | 210 + app/m/(app)/count/[id]/variance/page.tsx | 109 + app/m/(app)/count/page.tsx | 1 + app/m/(app)/done/page.tsx | 27 + app/m/(app)/issue/[staffId]/page.tsx | 7 + app/m/(app)/issue/page.tsx | 6 + app/m/(app)/label/page.tsx | 6 + app/m/(app)/layout.tsx | 28 + app/m/(app)/line/[itemId]/[si]/page.tsx | 98 + app/m/(app)/loading.tsx | 54 + app/m/(app)/more/page.tsx | 6 + app/m/(app)/page.tsx | 34 + app/m/(app)/people/page.tsx | 56 + app/m/(app)/person/[id]/exchange/page.tsx | 7 + app/m/(app)/person/[id]/page.tsx | 92 + app/m/(app)/person/[id]/return/page.tsx | 7 + app/m/(app)/person/[id]/sign/page.tsx | 94 + app/m/(app)/pickups/page.tsx | 6 + app/m/(app)/receive/[id]/page.tsx | 94 + app/m/(app)/receive/page.tsx | 6 + app/m/(app)/reorder/page.tsx | 92 + app/m/(app)/request/[id]/page.tsx | 135 + app/m/(app)/request/[id]/sign/page.tsx | 88 + app/m/(app)/round/[ward]/page.tsx | 88 + app/m/(app)/rounds/page.tsx | 6 + app/m/(app)/scan/page.tsx | 171 + app/m/(app)/search/page.tsx | 8 + app/m/(app)/settings/page.tsx | 91 + app/m/(app)/signed-in/page.tsx | 7 + app/m/(app)/stock/page.tsx | 92 + app/m/(app)/variance/page.tsx | 101 + app/m/(app)/work/page.tsx | 184 + app/m/layout.tsx | 39 + app/m/login/layout.tsx | 13 + app/m/login/page.tsx | 205 + app/m/signup/layout.tsx | 12 + app/m/signup/page.tsx | 11 + app/my/(app)/account/page.tsx | 33 + app/my/(app)/approvals/[id]/page.tsx | 17 + app/my/(app)/approvals/page.tsx | 55 + app/my/(app)/damage/page.tsx | 19 + app/my/(app)/kit/page.tsx | 12 + app/my/(app)/kitcheck/page.tsx | 18 + app/my/(app)/layout.tsx | 108 + app/my/(app)/loading.tsx | 55 + app/my/(app)/messages/page.tsx | 15 + app/my/(app)/not-found.tsx | 29 + app/my/(app)/orders/[id]/code/page.tsx | 28 + app/my/(app)/orders/[id]/messages/page.tsx | 15 + app/my/(app)/orders/[id]/page.tsx | 17 + app/my/(app)/orders/page.tsx | 24 + app/my/(app)/page.tsx | 86 + app/my/(app)/raise/page.tsx | 44 + app/my/(app)/request/page.tsx | 71 + app/my/(app)/round/page.tsx | 32 + app/my/(app)/shelf/page.tsx | 13 + app/my/(app)/team/page.tsx | 55 + app/my/(app)/waitlist/page.tsx | 15 + app/my/(app)/ward/page.tsx | 25 + app/my/approve/page.tsx | 116 + app/my/layout.tsx | 34 + app/my/signin/page.tsx | 231 + app/not-found.tsx | 11 + app/page.tsx | 6 + app/print/labels/page.tsx | 149 + app/print/order-form/page.tsx | 397 ++ app/print/page.tsx | 102 + app/print/shelf/page.tsx | 59 + app/print/supplier-order/page.tsx | 89 + app/reset/layout.tsx | 20 + app/reset/page.tsx | 186 + app/robots.txt/route.ts | 33 + components/Analytics.tsx | 85 + components/AuthForm.tsx | 507 ++ components/AutoPrint.tsx | 34 + components/Camera.tsx | 63 + components/Checklist.tsx | 81 + components/CommandBar.tsx | 305 + components/DemoBanner.tsx | 2 + components/ErrorReporting.tsx | 45 + components/FacilityRules.tsx | 122 + components/HelpMark.tsx | 32 + components/Helpdesk.tsx | 38 + components/MAuth.tsx | 121 + components/MBasket.tsx | 106 + components/MPerson.tsx | 81 + components/MScan.tsx | 160 + components/MSignup.tsx | 170 + components/ManualHome.tsx | 66 + components/ManualSearch.tsx | 94 + components/ManualShell.tsx | 65 + components/ManualToc.tsx | 55 + components/ManualView.tsx | 95 + components/OrderList.tsx | 2 + components/PlanBanner.tsx | 2 + components/PlanChoice.tsx | 3 + components/PlanTab.tsx | 2 + components/Shell.tsx | 324 ++ components/SignFlow.tsx | 151 + components/SourceNotice.tsx | 26 + components/SsoSettings.tsx | 13 + components/Turnstile.tsx | 73 + components/TwoFactor.tsx | 187 + components/counter/AddGarments.tsx | 191 + components/counter/Counter.tsx | 204 + components/counter/Modes.tsx | 192 + components/counter/PersonPanel.tsx | 70 + components/counter/PersonPicker.tsx | 60 + components/counter/PickupCart.tsx | 110 + components/counter/counter.module.css | 94 + components/counter/lib.ts | 74 + components/counter/useCounterCart.ts | 102 + components/dialogs.tsx | 1160 ++++ components/m.tsx | 946 +++ components/m/count/CountPanel.tsx | 52 + components/m/count/lines.ts | 52 + components/m/handback/HandBackTab.tsx | 235 + components/m/issue/HistoryTab.tsx | 94 + components/m/issue/IssueTab.tsx | 165 + components/m/issue/meta.ts | 9 + components/m/stock/CountList.tsx | 65 + components/m/stock/PrintSheet.tsx | 49 + components/m/stock/stockdata.ts | 72 + components/m/today/TodayBanner.tsx | 35 + components/m/today/useToday.ts | 161 + components/m/work/util.ts | 72 + components/my.tsx | 40 + components/orders/OnTheWay.tsx | 48 + components/orders/RecentOrders.tsx | 29 + components/orders/SupplierPanel.tsx | 256 + components/orders/ThisMonth.tsx | 31 + components/orders/ToOrder.tsx | 44 + components/orders/bits.tsx | 38 + components/orders/toOrder.ts | 77 + components/people/DetailsTab.tsx | 169 + components/people/HistoryTab.tsx | 193 + components/people/ManagerBox.tsx | 90 + components/people/Record.tsx | 111 + components/people/Register.tsx | 245 + components/people/ReportsBox.tsx | 75 + components/people/RequestsTab.tsx | 29 + components/people/SelfService.tsx | 71 + components/people/SignedToggle.tsx | 16 + components/people/Summary.tsx | 64 + components/people/UniformTab.tsx | 231 + components/people/shared.tsx | 42 + components/person/HandBackTab.tsx | 2 + components/portal.tsx | 292 + components/reports/MonthEndStrip.tsx | 48 + components/reports/PeopleTab.tsx | 109 + components/reports/SpendTab.tsx | 145 + components/reports/StockTab.tsx | 60 + components/reports/bits.tsx | 37 + components/reports/useReportData.ts | 242 + components/requests/Damage.tsx | 47 + components/requests/KitCheck.tsx | 104 + components/requests/Queries.tsx | 38 + components/requests/RequestList.tsx | 521 ++ components/requests/csv.ts | 94 + components/screens/Account.tsx | 189 + components/screens/Approvals.tsx | 153 + components/screens/ApproveByLink.tsx | 196 + components/screens/CodeFull.tsx | 43 + components/screens/Damage.tsx | 189 + components/screens/Desk.tsx | 340 ++ components/screens/Home.tsx | 182 + components/screens/Kit.tsx | 147 + components/screens/KitCheck.tsx | 186 + components/screens/Messages.tsx | 111 + components/screens/NotificationSettings.tsx | 113 + components/screens/Order.tsx | 239 + components/screens/Orders.tsx | 108 + components/screens/Request.tsx | 360 ++ components/screens/Review.tsx | 219 + components/screens/Round.tsx | 222 + components/screens/Sent.tsx | 81 + components/screens/Shelf.tsx | 101 + components/screens/Team.tsx | 109 + components/screens/Thread.tsx | 135 + components/screens/Waitlist.tsx | 189 + components/screens/Ward.tsx | 38 + components/settings/AuditLog.tsx | 114 + components/settings/CatalogueSection.tsx | 80 + components/settings/DataAudit.tsx | 133 + components/settings/FacilitySection.tsx | 137 + components/settings/IssuingRules.tsx | 36 + components/settings/PeopleSignIn.tsx | 130 + components/settings/PlacesSection.tsx | 95 + components/settings/RouteBoard.tsx | 303 + components/settings/UserDialog.tsx | 42 + components/settings/auditLabels.ts | 141 + components/settings/backup.ts | 31 + components/settings/common.tsx | 118 + components/settings/names.ts | 93 + components/staffnav.tsx | 107 + components/staffui.tsx | 996 ++++ components/stock/CountTab.tsx | 284 + components/stock/Locations.tsx | 80 + components/stock/OnHand.tsx | 258 + components/stock/StockStyles.tsx | 5 + components/stock/url.ts | 14 + components/today/CollectGroup.tsx | 41 + components/today/CountsGroup.tsx | 27 + components/today/Lines.tsx | 16 + components/today/MonthEndPanel.tsx | 56 + components/today/PickGroup.tsx | 74 + components/today/ReceiveGroup.tsx | 37 + components/today/RoundGroup.tsx | 32 + components/today/RunsOutPanel.tsx | 54 + components/today/SetupGroup.tsx | 37 + components/ui.tsx | 319 + docker-compose.yml | 72 + docker/backup.sh | 18 + docs/banner.svg | 15 + docs/manual/AUTHORING.md | 119 + docs/manual/account/delete-an-account.md | 73 + docs/manual/account/export-and-backup.md | 71 + docs/manual/account/plan-and-billing.md | 92 + docs/manual/account/single-sign-on.md | 72 + docs/manual/account/two-factor.md | 64 + docs/manual/account/users.md | 73 + docs/manual/apps/counter-app.md | 113 + docs/manual/apps/scanning-and-browsers.md | 91 + docs/manual/apps/staff-app.md | 90 + docs/manual/counter/delivery-rounds.md | 69 + docs/manual/counter/exchanges-and-returns.md | 81 + docs/manual/counter/issue-a-garment.md | 73 + docs/manual/counter/manager-approvals.md | 75 + docs/manual/counter/pickup-call-list.md | 65 + docs/manual/counter/requests-from-staff.md | 86 + docs/manual/counter/slips-and-signatures.md | 81 + .../people/deactivating-and-deleting.md | 81 + docs/manual/people/entitlement-rule.md | 83 + docs/manual/people/groups-and-routes.md | 80 + docs/manual/people/managers.md | 70 + docs/manual/people/staff-register.md | 74 + docs/manual/reference/csv-templates.md | 119 + docs/manual/reference/glossary.md | 116 + docs/manual/reference/keyboard-and-scanner.md | 93 + docs/manual/reports/cost-centres.md | 62 + docs/manual/reports/journal-export.md | 70 + docs/manual/reports/month-end-pack.md | 68 + docs/manual/reports/the-nine-reports.md | 66 + docs/manual/selfhost/backups.md | 126 + .../selfhost/configuration-reference.md | 99 + docs/manual/selfhost/email.md | 75 + docs/manual/selfhost/first-run.md | 83 + docs/manual/selfhost/install.md | 104 + docs/manual/selfhost/notifications.md | 79 + docs/manual/selfhost/updating.md | 92 + docs/manual/start/set-up-in-an-afternoon.md | 77 + docs/manual/start/the-two-roles.md | 66 + docs/manual/start/threadcount-in-one-page.md | 78 + docs/manual/start/your-first-order.md | 68 + docs/manual/stock/barcodes.md | 62 + docs/manual/stock/catalogue-sizes-and-cuts.md | 68 + docs/manual/stock/order-list.md | 68 + .../manual/stock/receiving-and-back-orders.md | 69 + docs/manual/stock/reorder-levels.md | 62 + docs/manual/stock/stocktakes.md | 68 + docs/manual/stock/suppliers.md | 64 + docs/screenshots/coordinator-dashboard.png | Bin 0 -> 267677 bytes docs/screenshots/counter-desktop.png | Bin 0 -> 238024 bytes docs/screenshots/phone-counter.png | Bin 0 -> 97107 bytes docs/screenshots/reports.png | Bin 0 -> 121326 bytes docs/screenshots/stock.png | Bin 0 -> 354911 bytes docs/self-hosting.md | 127 + instrumentation.ts | 75 + lib/accountmail.ts | 37 + lib/analytics.ts | 177 + lib/approvallink.ts | 243 + lib/audit.ts | 117 + lib/barcode.ts | 114 + lib/billing-mail.ts | 20 + lib/client.tsx | 84 + lib/compute.ts | 1400 +++++ lib/countries.ts | 258 + lib/csrf.ts | 15 + lib/csv.ts | 79 + lib/cycledata.ts | 107 + lib/db.ts | 18 + lib/deskdata.ts | 188 + lib/edition.ts | 16 + lib/errors.ts | 47 + lib/feedback.ts | 85 + lib/glitchtip.ts | 158 + lib/helpdesk.ts | 47 + lib/hosted-defaults.ts | 14 + lib/links.ts | 14 + lib/live.ts | 135 + lib/mail-html.cjs | 139 + lib/mail.ts | 62 + lib/managerdata.ts | 197 + lib/manual-links.ts | 79 + lib/manual.ts | 236 + lib/nativeprint.ts | 88 + lib/nativescan.ts | 131 + lib/opencount.ts | 76 + lib/ops.ts | 3705 ++++++++++++ lib/ops/alerts.ts | 7 + lib/orderdoc.ts | 79 + lib/photo.ts | 37 + lib/photostore.ts | 91 + lib/plan.ts | 142 + lib/portalcounts.ts | 222 + lib/print.ts | 57 + lib/push.ts | 11 + lib/ratelimit.ts | 64 + lib/reset.ts | 65 + lib/scanroute.ts | 68 + lib/search.ts | 79 + lib/session.ts | 94 + lib/sets.ts | 293 + lib/snapshot.ts | 186 + lib/source.ts | 24 + lib/staffauth.ts | 80 + lib/staffclient.tsx | 215 + lib/staffdata.ts | 670 +++ lib/staffops.ts | 816 +++ lib/staffpush.ts | 171 + lib/staffpushserver.ts | 289 + lib/staffreq.ts | 281 + lib/staffsession.ts | 128 + lib/stripe.ts | 4 + lib/switches.ts | 56 + lib/today.ts | 139 + lib/totp.ts | 137 + lib/turnstile.ts | 58 + lib/twofactor.ts | 66 + lib/wakelock.ts | 45 + lib/webscan.ts | 72 + lib/workcount.ts | 28 + next.config.ts | 98 + package-lock.json | 5171 +++++++++++++++++ package.json | 45 + patches/@capacitor+android+6.2.2.patch | 17 + prisma.config.ts | 12 + .../20260827035001_init/migration.sql | 412 ++ .../20260828100000_update_wave/migration.sql | 48 + .../migration.sql | 13 + .../migration.sql | 4 + .../20260829010000_preloved/migration.sql | 34 + .../migration.sql | 18 + .../migration.sql | 3 + .../migration.sql | 3 + .../20260902100000_catalog_type/migration.sql | 3 + .../migration.sql | 20 + .../20260906120000_locations/migration.sql | 32 + .../migration.sql | 25 + .../20260907130000_audit_event/migration.sql | 16 + .../20260907140000_cost_history/migration.sql | 25 + .../migration.sql | 6 + .../20260907160000_two_factor/migration.sql | 21 + .../migration.sql | 34 + .../20260907190000_staff_app/migration.sql | 228 + .../20260907190500_request_seq/migration.sql | 3 + .../migration.sql | 30 + .../migration.sql | 3 + .../migration.sql | 4 + .../migration.sql | 51 + .../20260909005105_facility_rev/migration.sql | 6 + .../migration.sql | 5 + .../migration.sql | 16 + .../migration.sql | 41 + .../migration.sql | 11 + .../migration.sql | 26 + .../migration.sql | 55 + .../migration.sql | 22 + .../migration.sql | 19 + .../20260912030000_operators/migration.sql | 62 + .../migration.sql | 14 + .../migration.sql | 74 + .../migration.sql | 13 + .../migration.sql | 22 + .../20260913010000_facility_sso/migration.sql | 18 + .../20260913120000_plans/migration.sql | 22 + .../migration.sql | 99 + .../20260913200000_checkout/migration.sql | 20 + .../20260913210000_plan_mail/migration.sql | 20 + .../migration.sql | 6 + .../20260915120000_checklist/migration.sql | 3 + .../migration.sql | 24 + .../20260916140000_staff_push/migration.sql | 35 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 1413 +++++ proxy.ts | 60 + public/media/walkthrough-poster.png | Bin 0 -> 142232 bytes public/media/walkthrough.webm | Bin 0 -> 2284992 bytes public/robots.txt | 2 + scripts/approval-mint.cjs | 33 + scripts/check-barcode.ts | 40 + scripts/check-totp.ts | 76 + scripts/e2e-app.sh | 189 + scripts/e2e-audit.sh | 85 + scripts/e2e-bulk.sh | 90 + scripts/e2e-catalogue.sh | 101 + scripts/e2e-community.sh | 49 + scripts/e2e-cost.sh | 78 + scripts/e2e-counter.sh | 109 + scripts/e2e-mobile.sh | 93 + scripts/e2e-orders.sh | 115 + scripts/e2e-photos.sh | 75 + scripts/e2e-preflight.sh | 52 + scripts/e2e-preloved.sh | 119 + scripts/e2e-scan.sh | 242 + scripts/e2e-security.sh | 121 + scripts/e2e-sets.sh | 817 +++ scripts/e2e-staff.sh | 224 + scripts/e2e-staffapp.sh | 1008 ++++ scripts/e2e.sh | 213 + scripts/photos-to-disk.cjs | 60 + scripts/push-probe.ts | 113 + scripts/reset-mint.cjs | 39 + scripts/reset-token.cjs | 27 + scripts/schema-diff.sh | 17 + scripts/totp-code.ts | 14 + tsconfig.json | 36 + 481 files changed, 59411 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 COMMUNITY_VERSION create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 RELEASE_NOTES.md create mode 100644 app/api/2fa/route.ts create mode 100644 app/api/activity/route.ts create mode 100644 app/api/app-info/route.ts create mode 100644 app/api/auth/2fa/route.ts create mode 100644 app/api/auth/forgot/route.ts create mode 100644 app/api/auth/login/route.ts create mode 100644 app/api/auth/logout/route.ts create mode 100644 app/api/auth/lookup/route.ts create mode 100644 app/api/auth/reset/route.ts create mode 100644 app/api/auth/signup/route.ts create mode 100644 app/api/backup/route.ts create mode 100644 app/api/health/route.ts create mode 100644 app/api/logo/route.ts create mode 100644 app/api/lookup/route.ts create mode 100644 app/api/mutate/route.ts create mode 100644 app/api/photo/[id]/route.ts create mode 100644 app/api/requests/route.ts create mode 100644 app/api/rev/route.ts create mode 100644 app/api/staff/activate/route.ts create mode 100644 app/api/staff/decide/route.ts create mode 100644 app/api/staff/login/route.ts create mode 100644 app/api/staff/logout/route.ts create mode 100644 app/api/staff/mutate/route.ts create mode 100644 app/api/staff/slip/[id]/sig/route.ts create mode 100644 app/app/activity/page.tsx create mode 100644 app/app/counter/page.tsx create mode 100644 app/app/help/[section]/[slug]/page.tsx create mode 100644 app/app/help/page.tsx create mode 100644 app/app/issue/page.tsx create mode 100644 app/app/layout.tsx create mode 100644 app/app/orders/[id]/page.tsx create mode 100644 app/app/orders/all/page.tsx create mode 100644 app/app/orders/list/page.tsx create mode 100644 app/app/orders/page.tsx create mode 100644 app/app/page.tsx create mode 100644 app/app/report/page.tsx create mode 100644 app/app/requests/page.tsx create mode 100644 app/app/rounds/page.tsx create mode 100644 app/app/settings/page.tsx create mode 100644 app/app/staff/[id]/page.tsx create mode 100644 app/app/staff/page.tsx create mode 100644 app/app/stock/[id]/page.tsx create mode 100644 app/app/stock/page.tsx create mode 100644 app/app/stocktake/page.tsx create mode 100644 app/auth/page.tsx create mode 100644 app/error.tsx create mode 100644 app/favicon.ico create mode 100644 app/global-error.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/m/(app)/catalogue/[id]/page.tsx create mode 100644 app/m/(app)/catalogue/new/page.tsx create mode 100644 app/m/(app)/catalogue/page.tsx create mode 100644 app/m/(app)/count/[id]/page.tsx create mode 100644 app/m/(app)/count/[id]/variance/page.tsx create mode 100644 app/m/(app)/count/page.tsx create mode 100644 app/m/(app)/done/page.tsx create mode 100644 app/m/(app)/issue/[staffId]/page.tsx create mode 100644 app/m/(app)/issue/page.tsx create mode 100644 app/m/(app)/label/page.tsx create mode 100644 app/m/(app)/layout.tsx create mode 100644 app/m/(app)/line/[itemId]/[si]/page.tsx create mode 100644 app/m/(app)/loading.tsx create mode 100644 app/m/(app)/more/page.tsx create mode 100644 app/m/(app)/page.tsx create mode 100644 app/m/(app)/people/page.tsx create mode 100644 app/m/(app)/person/[id]/exchange/page.tsx create mode 100644 app/m/(app)/person/[id]/page.tsx create mode 100644 app/m/(app)/person/[id]/return/page.tsx create mode 100644 app/m/(app)/person/[id]/sign/page.tsx create mode 100644 app/m/(app)/pickups/page.tsx create mode 100644 app/m/(app)/receive/[id]/page.tsx create mode 100644 app/m/(app)/receive/page.tsx create mode 100644 app/m/(app)/reorder/page.tsx create mode 100644 app/m/(app)/request/[id]/page.tsx create mode 100644 app/m/(app)/request/[id]/sign/page.tsx create mode 100644 app/m/(app)/round/[ward]/page.tsx create mode 100644 app/m/(app)/rounds/page.tsx create mode 100644 app/m/(app)/scan/page.tsx create mode 100644 app/m/(app)/search/page.tsx create mode 100644 app/m/(app)/settings/page.tsx create mode 100644 app/m/(app)/signed-in/page.tsx create mode 100644 app/m/(app)/stock/page.tsx create mode 100644 app/m/(app)/variance/page.tsx create mode 100644 app/m/(app)/work/page.tsx create mode 100644 app/m/layout.tsx create mode 100644 app/m/login/layout.tsx create mode 100644 app/m/login/page.tsx create mode 100644 app/m/signup/layout.tsx create mode 100644 app/m/signup/page.tsx create mode 100644 app/my/(app)/account/page.tsx create mode 100644 app/my/(app)/approvals/[id]/page.tsx create mode 100644 app/my/(app)/approvals/page.tsx create mode 100644 app/my/(app)/damage/page.tsx create mode 100644 app/my/(app)/kit/page.tsx create mode 100644 app/my/(app)/kitcheck/page.tsx create mode 100644 app/my/(app)/layout.tsx create mode 100644 app/my/(app)/loading.tsx create mode 100644 app/my/(app)/messages/page.tsx create mode 100644 app/my/(app)/not-found.tsx create mode 100644 app/my/(app)/orders/[id]/code/page.tsx create mode 100644 app/my/(app)/orders/[id]/messages/page.tsx create mode 100644 app/my/(app)/orders/[id]/page.tsx create mode 100644 app/my/(app)/orders/page.tsx create mode 100644 app/my/(app)/page.tsx create mode 100644 app/my/(app)/raise/page.tsx create mode 100644 app/my/(app)/request/page.tsx create mode 100644 app/my/(app)/round/page.tsx create mode 100644 app/my/(app)/shelf/page.tsx create mode 100644 app/my/(app)/team/page.tsx create mode 100644 app/my/(app)/waitlist/page.tsx create mode 100644 app/my/(app)/ward/page.tsx create mode 100644 app/my/approve/page.tsx create mode 100644 app/my/layout.tsx create mode 100644 app/my/signin/page.tsx create mode 100644 app/not-found.tsx create mode 100644 app/page.tsx create mode 100644 app/print/labels/page.tsx create mode 100644 app/print/order-form/page.tsx create mode 100644 app/print/page.tsx create mode 100644 app/print/shelf/page.tsx create mode 100644 app/print/supplier-order/page.tsx create mode 100644 app/reset/layout.tsx create mode 100644 app/reset/page.tsx create mode 100644 app/robots.txt/route.ts create mode 100644 components/Analytics.tsx create mode 100644 components/AuthForm.tsx create mode 100644 components/AutoPrint.tsx create mode 100644 components/Camera.tsx create mode 100644 components/Checklist.tsx create mode 100644 components/CommandBar.tsx create mode 100644 components/DemoBanner.tsx create mode 100644 components/ErrorReporting.tsx create mode 100644 components/FacilityRules.tsx create mode 100644 components/HelpMark.tsx create mode 100644 components/Helpdesk.tsx create mode 100644 components/MAuth.tsx create mode 100644 components/MBasket.tsx create mode 100644 components/MPerson.tsx create mode 100644 components/MScan.tsx create mode 100644 components/MSignup.tsx create mode 100644 components/ManualHome.tsx create mode 100644 components/ManualSearch.tsx create mode 100644 components/ManualShell.tsx create mode 100644 components/ManualToc.tsx create mode 100644 components/ManualView.tsx create mode 100644 components/OrderList.tsx create mode 100644 components/PlanBanner.tsx create mode 100644 components/PlanChoice.tsx create mode 100644 components/PlanTab.tsx create mode 100644 components/Shell.tsx create mode 100644 components/SignFlow.tsx create mode 100644 components/SourceNotice.tsx create mode 100644 components/SsoSettings.tsx create mode 100644 components/Turnstile.tsx create mode 100644 components/TwoFactor.tsx create mode 100644 components/counter/AddGarments.tsx create mode 100644 components/counter/Counter.tsx create mode 100644 components/counter/Modes.tsx create mode 100644 components/counter/PersonPanel.tsx create mode 100644 components/counter/PersonPicker.tsx create mode 100644 components/counter/PickupCart.tsx create mode 100644 components/counter/counter.module.css create mode 100644 components/counter/lib.ts create mode 100644 components/counter/useCounterCart.ts create mode 100644 components/dialogs.tsx create mode 100644 components/m.tsx create mode 100644 components/m/count/CountPanel.tsx create mode 100644 components/m/count/lines.ts create mode 100644 components/m/handback/HandBackTab.tsx create mode 100644 components/m/issue/HistoryTab.tsx create mode 100644 components/m/issue/IssueTab.tsx create mode 100644 components/m/issue/meta.ts create mode 100644 components/m/stock/CountList.tsx create mode 100644 components/m/stock/PrintSheet.tsx create mode 100644 components/m/stock/stockdata.ts create mode 100644 components/m/today/TodayBanner.tsx create mode 100644 components/m/today/useToday.ts create mode 100644 components/m/work/util.ts create mode 100644 components/my.tsx create mode 100644 components/orders/OnTheWay.tsx create mode 100644 components/orders/RecentOrders.tsx create mode 100644 components/orders/SupplierPanel.tsx create mode 100644 components/orders/ThisMonth.tsx create mode 100644 components/orders/ToOrder.tsx create mode 100644 components/orders/bits.tsx create mode 100644 components/orders/toOrder.ts create mode 100644 components/people/DetailsTab.tsx create mode 100644 components/people/HistoryTab.tsx create mode 100644 components/people/ManagerBox.tsx create mode 100644 components/people/Record.tsx create mode 100644 components/people/Register.tsx create mode 100644 components/people/ReportsBox.tsx create mode 100644 components/people/RequestsTab.tsx create mode 100644 components/people/SelfService.tsx create mode 100644 components/people/SignedToggle.tsx create mode 100644 components/people/Summary.tsx create mode 100644 components/people/UniformTab.tsx create mode 100644 components/people/shared.tsx create mode 100644 components/person/HandBackTab.tsx create mode 100644 components/portal.tsx create mode 100644 components/reports/MonthEndStrip.tsx create mode 100644 components/reports/PeopleTab.tsx create mode 100644 components/reports/SpendTab.tsx create mode 100644 components/reports/StockTab.tsx create mode 100644 components/reports/bits.tsx create mode 100644 components/reports/useReportData.ts create mode 100644 components/requests/Damage.tsx create mode 100644 components/requests/KitCheck.tsx create mode 100644 components/requests/Queries.tsx create mode 100644 components/requests/RequestList.tsx create mode 100644 components/requests/csv.ts create mode 100644 components/screens/Account.tsx create mode 100644 components/screens/Approvals.tsx create mode 100644 components/screens/ApproveByLink.tsx create mode 100644 components/screens/CodeFull.tsx create mode 100644 components/screens/Damage.tsx create mode 100644 components/screens/Desk.tsx create mode 100644 components/screens/Home.tsx create mode 100644 components/screens/Kit.tsx create mode 100644 components/screens/KitCheck.tsx create mode 100644 components/screens/Messages.tsx create mode 100644 components/screens/NotificationSettings.tsx create mode 100644 components/screens/Order.tsx create mode 100644 components/screens/Orders.tsx create mode 100644 components/screens/Request.tsx create mode 100644 components/screens/Review.tsx create mode 100644 components/screens/Round.tsx create mode 100644 components/screens/Sent.tsx create mode 100644 components/screens/Shelf.tsx create mode 100644 components/screens/Team.tsx create mode 100644 components/screens/Thread.tsx create mode 100644 components/screens/Waitlist.tsx create mode 100644 components/screens/Ward.tsx create mode 100644 components/settings/AuditLog.tsx create mode 100644 components/settings/CatalogueSection.tsx create mode 100644 components/settings/DataAudit.tsx create mode 100644 components/settings/FacilitySection.tsx create mode 100644 components/settings/IssuingRules.tsx create mode 100644 components/settings/PeopleSignIn.tsx create mode 100644 components/settings/PlacesSection.tsx create mode 100644 components/settings/RouteBoard.tsx create mode 100644 components/settings/UserDialog.tsx create mode 100644 components/settings/auditLabels.ts create mode 100644 components/settings/backup.ts create mode 100644 components/settings/common.tsx create mode 100644 components/settings/names.ts create mode 100644 components/staffnav.tsx create mode 100644 components/staffui.tsx create mode 100644 components/stock/CountTab.tsx create mode 100644 components/stock/Locations.tsx create mode 100644 components/stock/OnHand.tsx create mode 100644 components/stock/StockStyles.tsx create mode 100644 components/stock/url.ts create mode 100644 components/today/CollectGroup.tsx create mode 100644 components/today/CountsGroup.tsx create mode 100644 components/today/Lines.tsx create mode 100644 components/today/MonthEndPanel.tsx create mode 100644 components/today/PickGroup.tsx create mode 100644 components/today/ReceiveGroup.tsx create mode 100644 components/today/RoundGroup.tsx create mode 100644 components/today/RunsOutPanel.tsx create mode 100644 components/today/SetupGroup.tsx create mode 100644 components/ui.tsx create mode 100644 docker-compose.yml create mode 100755 docker/backup.sh create mode 100644 docs/banner.svg create mode 100644 docs/manual/AUTHORING.md create mode 100644 docs/manual/account/delete-an-account.md create mode 100644 docs/manual/account/export-and-backup.md create mode 100644 docs/manual/account/plan-and-billing.md create mode 100644 docs/manual/account/single-sign-on.md create mode 100644 docs/manual/account/two-factor.md create mode 100644 docs/manual/account/users.md create mode 100644 docs/manual/apps/counter-app.md create mode 100644 docs/manual/apps/scanning-and-browsers.md create mode 100644 docs/manual/apps/staff-app.md create mode 100644 docs/manual/counter/delivery-rounds.md create mode 100644 docs/manual/counter/exchanges-and-returns.md create mode 100644 docs/manual/counter/issue-a-garment.md create mode 100644 docs/manual/counter/manager-approvals.md create mode 100644 docs/manual/counter/pickup-call-list.md create mode 100644 docs/manual/counter/requests-from-staff.md create mode 100644 docs/manual/counter/slips-and-signatures.md create mode 100644 docs/manual/people/deactivating-and-deleting.md create mode 100644 docs/manual/people/entitlement-rule.md create mode 100644 docs/manual/people/groups-and-routes.md create mode 100644 docs/manual/people/managers.md create mode 100644 docs/manual/people/staff-register.md create mode 100644 docs/manual/reference/csv-templates.md create mode 100644 docs/manual/reference/glossary.md create mode 100644 docs/manual/reference/keyboard-and-scanner.md create mode 100644 docs/manual/reports/cost-centres.md create mode 100644 docs/manual/reports/journal-export.md create mode 100644 docs/manual/reports/month-end-pack.md create mode 100644 docs/manual/reports/the-nine-reports.md create mode 100644 docs/manual/selfhost/backups.md create mode 100644 docs/manual/selfhost/configuration-reference.md create mode 100644 docs/manual/selfhost/email.md create mode 100644 docs/manual/selfhost/first-run.md create mode 100644 docs/manual/selfhost/install.md create mode 100644 docs/manual/selfhost/notifications.md create mode 100644 docs/manual/selfhost/updating.md create mode 100644 docs/manual/start/set-up-in-an-afternoon.md create mode 100644 docs/manual/start/the-two-roles.md create mode 100644 docs/manual/start/threadcount-in-one-page.md create mode 100644 docs/manual/start/your-first-order.md create mode 100644 docs/manual/stock/barcodes.md create mode 100644 docs/manual/stock/catalogue-sizes-and-cuts.md create mode 100644 docs/manual/stock/order-list.md create mode 100644 docs/manual/stock/receiving-and-back-orders.md create mode 100644 docs/manual/stock/reorder-levels.md create mode 100644 docs/manual/stock/stocktakes.md create mode 100644 docs/manual/stock/suppliers.md create mode 100644 docs/screenshots/coordinator-dashboard.png create mode 100644 docs/screenshots/counter-desktop.png create mode 100644 docs/screenshots/phone-counter.png create mode 100644 docs/screenshots/reports.png create mode 100644 docs/screenshots/stock.png create mode 100644 docs/self-hosting.md create mode 100644 instrumentation.ts create mode 100644 lib/accountmail.ts create mode 100644 lib/analytics.ts create mode 100644 lib/approvallink.ts create mode 100644 lib/audit.ts create mode 100644 lib/barcode.ts create mode 100644 lib/billing-mail.ts create mode 100644 lib/client.tsx create mode 100644 lib/compute.ts create mode 100644 lib/countries.ts create mode 100644 lib/csrf.ts create mode 100644 lib/csv.ts create mode 100644 lib/cycledata.ts create mode 100644 lib/db.ts create mode 100644 lib/deskdata.ts create mode 100644 lib/edition.ts create mode 100644 lib/errors.ts create mode 100644 lib/feedback.ts create mode 100644 lib/glitchtip.ts create mode 100644 lib/helpdesk.ts create mode 100644 lib/hosted-defaults.ts create mode 100644 lib/links.ts create mode 100644 lib/live.ts create mode 100644 lib/mail-html.cjs create mode 100644 lib/mail.ts create mode 100644 lib/managerdata.ts create mode 100644 lib/manual-links.ts create mode 100644 lib/manual.ts create mode 100644 lib/nativeprint.ts create mode 100644 lib/nativescan.ts create mode 100644 lib/opencount.ts create mode 100644 lib/ops.ts create mode 100644 lib/ops/alerts.ts create mode 100644 lib/orderdoc.ts create mode 100644 lib/photo.ts create mode 100644 lib/photostore.ts create mode 100644 lib/plan.ts create mode 100644 lib/portalcounts.ts create mode 100644 lib/print.ts create mode 100644 lib/push.ts create mode 100644 lib/ratelimit.ts create mode 100644 lib/reset.ts create mode 100644 lib/scanroute.ts create mode 100644 lib/search.ts create mode 100644 lib/session.ts create mode 100644 lib/sets.ts create mode 100644 lib/snapshot.ts create mode 100644 lib/source.ts create mode 100644 lib/staffauth.ts create mode 100644 lib/staffclient.tsx create mode 100644 lib/staffdata.ts create mode 100644 lib/staffops.ts create mode 100644 lib/staffpush.ts create mode 100644 lib/staffpushserver.ts create mode 100644 lib/staffreq.ts create mode 100644 lib/staffsession.ts create mode 100644 lib/stripe.ts create mode 100644 lib/switches.ts create mode 100644 lib/today.ts create mode 100644 lib/totp.ts create mode 100644 lib/turnstile.ts create mode 100644 lib/twofactor.ts create mode 100644 lib/wakelock.ts create mode 100644 lib/webscan.ts create mode 100644 lib/workcount.ts create mode 100644 next.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 patches/@capacitor+android+6.2.2.patch create mode 100644 prisma.config.ts create mode 100644 prisma/migrations/20260827035001_init/migration.sql create mode 100644 prisma/migrations/20260828100000_update_wave/migration.sql create mode 100644 prisma/migrations/20260828160000_issue_cost_backorder_parent/migration.sql create mode 100644 prisma/migrations/20260828230000_demo_and_user_inactive/migration.sql create mode 100644 prisma/migrations/20260829010000_preloved/migration.sql create mode 100644 prisma/migrations/20260829020000_mobile_photos/migration.sql create mode 100644 prisma/migrations/20260829030000_handin_credited/migration.sql create mode 100644 prisma/migrations/20260902020000_barcode_lookup/migration.sql create mode 100644 prisma/migrations/20260902100000_catalog_type/migration.sql create mode 100644 prisma/migrations/20260905090000_contact_message/migration.sql create mode 100644 prisma/migrations/20260906120000_locations/migration.sql create mode 100644 prisma/migrations/20260907120000_password_reset/migration.sql create mode 100644 prisma/migrations/20260907130000_audit_event/migration.sql create mode 100644 prisma/migrations/20260907140000_cost_history/migration.sql create mode 100644 prisma/migrations/20260907150000_photo_on_disk/migration.sql create mode 100644 prisma/migrations/20260907160000_two_factor/migration.sql create mode 100644 prisma/migrations/20260907170000_staff_account/migration.sql create mode 100644 prisma/migrations/20260907190000_staff_app/migration.sql create mode 100644 prisma/migrations/20260907190500_request_seq/migration.sql create mode 100644 prisma/migrations/20260907191000_record_dispute/migration.sql create mode 100644 prisma/migrations/20260908095332_facility_timezone/migration.sql create mode 100644 prisma/migrations/20260908095400_staff_activate_code_at/migration.sql create mode 100644 prisma/migrations/20260908172613_request_lines/migration.sql create mode 100644 prisma/migrations/20260909005105_facility_rev/migration.sql create mode 100644 prisma/migrations/20260909070303_facility_barcode_seq/migration.sql create mode 100644 prisma/migrations/20260911000000_stamp_outstanding_activate_codes/migration.sql create mode 100644 prisma/migrations/20260911010000_uniform_entitlement/migration.sql create mode 100644 prisma/migrations/20260911020000_group_set_cap/migration.sql create mode 100644 prisma/migrations/20260911030000_approval_by_staff/migration.sql create mode 100644 prisma/migrations/20260912000000_allowance_routes/migration.sql create mode 100644 prisma/migrations/20260912010000_garment_groups/migration.sql create mode 100644 prisma/migrations/20260912020000_uniform_style/migration.sql create mode 100644 prisma/migrations/20260912030000_operators/migration.sql create mode 100644 prisma/migrations/20260912040000_operator_recovery/migration.sql create mode 100644 prisma/migrations/20260912050000_ops_ro_grants/migration.sql create mode 100644 prisma/migrations/20260912060000_ops_reveal_grants/migration.sql create mode 100644 prisma/migrations/20260912070000_platform_switches/migration.sql create mode 100644 prisma/migrations/20260913010000_facility_sso/migration.sql create mode 100644 prisma/migrations/20260913120000_plans/migration.sql create mode 100644 prisma/migrations/20260913160000_organisations/migration.sql create mode 100644 prisma/migrations/20260913200000_checkout/migration.sql create mode 100644 prisma/migrations/20260913210000_plan_mail/migration.sql create mode 100644 prisma/migrations/20260915100000_supplier_orders/migration.sql create mode 100644 prisma/migrations/20260915120000_checklist/migration.sql create mode 100644 prisma/migrations/20260916100000_counter_slips/migration.sql create mode 100644 prisma/migrations/20260916140000_staff_push/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 proxy.ts create mode 100644 public/media/walkthrough-poster.png create mode 100644 public/media/walkthrough.webm create mode 100644 public/robots.txt create mode 100644 scripts/approval-mint.cjs create mode 100644 scripts/check-barcode.ts create mode 100644 scripts/check-totp.ts create mode 100755 scripts/e2e-app.sh create mode 100644 scripts/e2e-audit.sh create mode 100644 scripts/e2e-bulk.sh create mode 100644 scripts/e2e-catalogue.sh create mode 100755 scripts/e2e-community.sh create mode 100644 scripts/e2e-cost.sh create mode 100644 scripts/e2e-counter.sh create mode 100644 scripts/e2e-mobile.sh create mode 100755 scripts/e2e-orders.sh create mode 100644 scripts/e2e-photos.sh create mode 100644 scripts/e2e-preflight.sh create mode 100644 scripts/e2e-preloved.sh create mode 100755 scripts/e2e-scan.sh create mode 100644 scripts/e2e-security.sh create mode 100755 scripts/e2e-sets.sh create mode 100755 scripts/e2e-staff.sh create mode 100755 scripts/e2e-staffapp.sh create mode 100644 scripts/e2e.sh create mode 100644 scripts/photos-to-disk.cjs create mode 100644 scripts/push-probe.ts create mode 100644 scripts/reset-mint.cjs create mode 100644 scripts/reset-token.cjs create mode 100644 scripts/schema-diff.sh create mode 100644 scripts/totp-code.ts create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..439222a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +.next +.photos +.git +.claude +docs +# The manual is read by the app at request time (Help), so it has to be in the image. +!docs/manual +patches/*.orig +.env +.env.* +!.env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b49fb8e --- /dev/null +++ b/.env.example @@ -0,0 +1,52 @@ +# Every variable the Community edition reads. docker-compose.yml passes this file to the app and +# fills DATABASE_URL, PORT and PHOTO_DIR itself. +# +# The variables beginning NEXT_PUBLIC_ are compiled into the browser bundle at build time, not read +# at runtime — set them before the first `docker compose up --build`, and rebuild if they change. + +# ---- required ---- +# Signs every session cookie. Generate one per instance (`openssl rand -base64 48`). The server +# refuses to start while it still says change-me. +SESSION_SECRET=change-me +# The bundled database's password (docker-compose.yml only). +POSTGRES_PASSWORD= +# The address people open the app at, e.g. https://uniforms.example.health. Used in emailed links. +NEXT_PUBLIC_SITE_URL=http://localhost:3000 +# Always "community" on your own server. +EDITION=community + +# ---- your documents ---- +# The staff sign-in and the account screens link to a terms page and a privacy notice. Point them +# at your own; until you do they point at threadcount.tech's, which describe the hosted service. +NEXT_PUBLIC_TERMS_URL= +NEXT_PUBLIC_PRIVACY_URL= + +# ---- transactional mail (optional) ---- +# With these unset nothing is sent: password resets and approval links are handled at the counter, +# and the screens say so rather than claiming otherwise. +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_FROM="ThreadCount " + +# ---- optional ---- +# 1 hides the create-account form and refuses the sign-up endpoint. Set it once your facility exists. +SIGNUPS_DISABLED= +# Cloudflare Turnstile on sign-in and sign-up. Set both to enforce it; leave both blank to rely on +# the per-address rate limits alone. +TURNSTILE_SECRET= +NEXT_PUBLIC_TURNSTILE_SITEKEY= +# Your own GlitchTip (Sentry-protocol) DSN for error reports. Blank = nothing is reported anywhere. +NEXT_PUBLIC_GLITCHTIP_DSN= +# Stamped on error reports so a fault can be tied to a build. +NEXT_PUBLIC_RELEASE= +# Host port docker-compose.yml publishes the app on (the container always listens on 3000). +APP_PORT=3000 +# Your own Umami, if any: the tracker script address and the site ids. Blank = no statistics sent. +NEXT_PUBLIC_UMAMI_SRC= +NEXT_PUBLIC_UMAMI_SITE_ID= +NEXT_PUBLIC_UMAMI_APP_ID= +# Your own Chatwoot, if any: a chat widget on the coordinator app. Blank = no widget. +NEXT_PUBLIC_CHATWOOT_URL= +NEXT_PUBLIC_CHATWOOT_TOKEN= diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c2b7eeb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,34 @@ +# Turns a pushed release tag into a GitHub Release, using the notes shipped with that tag. +# Tags are named v.., with .2 or .3 added for a second release on the same day. +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + release: + # Only GitHub creates Releases; a mirror elsewhere that runs Actions skips this job. + if: github.server_url == 'https://github.com' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Publish the release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + if gh release view "$TAG" >/dev/null 2>&1; then + echo "release $TAG already exists" + exit 0 + fi + notes=RELEASE_NOTES.md + if [ ! -s "$notes" ]; then + notes="$RUNNER_TEMP/notes.md" + printf 'ThreadCount Community %s\n' "$TAG" > "$notes" + fi + gh release create "$TAG" --verify-tag --title "ThreadCount Community $TAG" --notes-file "$notes" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1ea670 --- /dev/null +++ b/.gitignore @@ -0,0 +1,86 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# the hosted deploy hardlinks the running build and node_modules in here so a failed deploy can be +# undone, and deletes it again when the deploy ends. While one is in flight it is ~100k untracked +# files sitting in the work tree, which would otherwise drown `git status` on the production box. +/.deploy-prev/ + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +# written by the hosted deploy on the box +.release.json + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts +.env +.env.* +!.env.example + +# Android / Capacitor build output. The project itself is committed; its build products are not. +android/.gradle/ +android/build/ +android/app/build/ +android/capacitor-cordova-android-plugins/build/ +android/local.properties +android/app/src/main/assets/public/ + +# The staff app's native project. Same rules: sources are committed, build output and the +# capacitor-copied web assets are not. +android-staff/.gradle/ +android-staff/build/ +android-staff/app/build/ +android-staff/capacitor-cordova-android-plugins/build/ +android-staff/local.properties +android-staff/app/src/main/assets/public/ +*.aab +*.apk +*.jks +*.keystore +keystore.properties + +# Photo storage: signatures and damage photographs live on disk, not in the repo or the database. +.photos/ +# session scratch, never committed +.scratch/ + +# scratch trees made by the community export +/.community-build.* +.next-build/ +.next-swap/ + +# Firebase config for the staff app: kept outside the checkout, copied in before a build +android-staff/app/google-services.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3862f1f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,49 @@ +# Changelog + +What changed in each Community release, newest first. Each heading is the date a release was cut. Its tag is `v` followed by that date, such as `v2026.09.17`, with `.2` added if a second release went out the same day. + + +## 2026-09-17 + +The first tagged release. Earlier Community builds were published without tags. + +### Licence: now AGPL-3.0-only + +- ThreadCount Community is now under the **GNU Affero General Public License v3**, an OSI-approved + open source licence. It was previously the Functional Source Licence, which was source-available + but not open source. Run it, read it, change it, fork it; the one obligation is the copyleft one, + that a modified version you run for other people must let those people have your changes. +- Releases published before this one remain under the earlier licence. +- The sign-in page and the app now carry the licence's offer of source: the build you are running + and a link to its code. Set `SOURCE_URL` if you run a modified build, so it points at yours. +- `CONTRIBUTING.md` asks contributors for a Developer Certificate of Origin sign-off. It is a + sign-off, not a copyright assignment — you keep the copyright in your work. +- The ThreadCount name and logo are not covered by the licence; a fork needs its own name. +- The README now opens with the live demo and shows what the product looks like. + +### Phone counter (`/m`) +- Rebuilt around one scanner. A staff badge opens that person, a garment opens its stock line, and a shelf label starts a count of that shelf. +- Issuing is a basket of the person's kit in their size, with tops and pants counted against the sets limit as you add. A line that breaks a rule needs a reason. +- A hand-over can be signed on the glass, and the slip can be sent to the person's staff app. +- Hand back takes several garments at once, each with a condition and an optional size swap. +- A Work tab collects approved requests to pick, deliveries to receive, pickups and ward rounds. +- The Android app prints labels through Android's print service. Shelf labels can be printed and scanned to start a count. +- Counting has a hands-free switch, and every gap over the facility threshold needs a reason before the count is committed. + +### Staff app (`/my`) +- Notifications for approved or declined requests, ready to collect, bags on the ward round, kit checks, and for approvers anything waiting on them. They need a Firebase project on your server: see Self-hosting, Notifications in the manual. Without one the app says notifications are not set up. +- A Messages tab listing every thread with the linen room. +- A Team tab for approvers and ward desks: approve from the queue, the ward roster, raising for your team, and the ward round with progress on "Sign for all". +- Asking for a garment is one screen, with what you already hold first and "Same again" to repeat your last request. +- Signed slips show quantities. +- An offline bar keeps what you typed while the signal is gone. + +### Coordinator app (`/app`) +- Rebuilt around seven sections: Today, Counter, Stock, Orders, People, Reports and Settings, with a search-and-scan bar. + +### Sign-in and sign-up +- A terms and privacy tick box on sign-in and on the first sign-up step, shown when your server sets `NEXT_PUBLIC_TERMS_URL` or `NEXT_PUBLIC_PRIVACY_URL`. +- Where Turnstile is configured, its check is visible on the password step and on the last sign-up step. + +### Updating +- This release adds database migrations. They run by themselves during `docker compose up -d --build`. Take a backup first, as the updating guide describes. diff --git a/COMMUNITY_VERSION b/COMMUNITY_VERSION new file mode 100644 index 0000000..492eee3 --- /dev/null +++ b/COMMUNITY_VERSION @@ -0,0 +1 @@ +community v2026.09.17.2 794bab5 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1dce0fb --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,43 @@ +# Contributing + +Bug reports and patches are welcome. The product is written for a real linen room, so the most +useful thing you can send is what went wrong in yours: what you were doing, what you expected, and +what happened instead. + +## Reporting + +- **A bug or a question:** open an issue. +- **A security problem:** email security@threadcount.tech instead. Please don't open a public issue + for it. + +## Sending a patch + +1. Open an issue first for anything beyond a small fix, so the approach can be agreed before you + spend an evening on it. +2. Keep the change focused, and match the surrounding code — comments that explain *why*, plain + words on screen, no dependency added without a reason. +3. Say how you tested it. There is no CI on this repository; a description of what you ran is what + a reviewer has to go on. + +## Sign your commits (DCO) + +Every commit needs a `Signed-off-by` line, which `git commit -s` adds for you: + +``` +Signed-off-by: Your Name +``` + +That line means you agree to the [Developer Certificate of Origin 1.1](https://developercertificate.org/): +that you wrote the patch, or have the right to submit it under this project's licence, and that +you understand it will be public and kept in the record. + +It is a sign-off, not a copyright assignment — you keep the copyright in your work, and it is +contributed under the project's licence (AGPL-3.0-only). + +## Licence + +Contributions are accepted under the [AGPL-3.0-only](LICENSE) licence that covers the Community +edition. The ThreadCount name and logo are not covered by it. + +A commercial licence of the same code is offered to organisations that cannot use the AGPL. If your +patch is merged, it stays under the AGPL here; it is only ever relicensed with your agreement. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..161e50a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +# ThreadCount, Community edition — one image, built where it runs. +# +# Built locally by `docker compose build` rather than pulled, and for a reason: everything that +# starts NEXT_PUBLIC_ is compiled into the browser bundle, so the address people will open the app +# at (NEXT_PUBLIC_SITE_URL) and the optional Turnstile site key have to be known at build time. +# docker-compose.yml passes them in from your .env as build arguments. +# +# Three stages: +# deps — node_modules from the lockfile (patch-package runs in postinstall). +# builder — the Prisma client and the Next build. Also the image `docker compose run migrate` +# uses, because it still has the Prisma CLI. +# runner — Next's standalone output only: no compiler, no CLI, a non-root user. + +FROM node:24-bookworm-slim AS deps +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates && rm -rf /var/lib/apt/lists/* +COPY package.json package-lock.json ./ +COPY patches ./patches +RUN npm ci --ignore-scripts && npx patch-package + +FROM deps AS builder +WORKDIR /app +COPY . . +ARG NEXT_PUBLIC_SITE_URL=http://localhost:3000 +ARG NEXT_PUBLIC_TURNSTILE_SITEKEY= +ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL \ + NEXT_PUBLIC_TURNSTILE_SITEKEY=$NEXT_PUBLIC_TURNSTILE_SITEKEY \ + EDITION=community \ + NEXT_OUTPUT=standalone \ + NEXT_TELEMETRY_DISABLED=1 +RUN npx prisma generate && npm run build + +FROM node:24-bookworm-slim AS runner +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates curl && rm -rf /var/lib/apt/lists/* \ + && groupadd -r threadcount && useradd -r -g threadcount -d /app threadcount \ + && mkdir -p /data/photos && chown -R threadcount:threadcount /data +ENV NODE_ENV=production \ + EDITION=community \ + PHOTO_DIR=/data/photos \ + PORT=3000 \ + HOSTNAME=0.0.0.0 \ + NEXT_TELEMETRY_DISABLED=1 +COPY --from=builder --chown=threadcount:threadcount /app/.next/standalone ./ +COPY --from=builder --chown=threadcount:threadcount /app/.next/static ./.next/static +COPY --from=builder --chown=threadcount:threadcount /app/public ./public +USER threadcount +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD curl -fsS http://127.0.0.1:3000/api/health || exit 1 +CMD ["node", "server.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b9489e6 --- /dev/null +++ b/README.md @@ -0,0 +1,191 @@ +

ThreadCount

+ +# ThreadCount + +Uniform stock management for hospital, aged-care and clinic linen rooms. It tracks what is on the +shelf, who was issued what, what each ward was charged, and the supplier orders and stocktakes in +between. It was written by a uniform coordinator for their own linen room. + +This is the Community edition: the same product that runs at [threadcount.tech](https://threadcount.tech), +packaged for a facility or health service that wants to run it on its own server. Every feature a +room uses is in it. Install takes about five minutes on a server that already has Docker; the +steps are below, and the longer guide is [docs/self-hosting.md](docs/self-hosting.md). + +**[Open the live demo](https://threadcount.tech/demo)** — a shared facility with invented data, no +sign-up, rebuilt every twenty minutes. Issue a garment, run a count, look at the reports. + +## What you get + +- **The coordinator app** at `/app`: catalogue, stock, issuing against entitlement, orders and + receiving, stocktakes, nine reports, CSV import and export, full JSON backup and restore. +- **The phone counter** at `/m`: camera barcode scanning, issue at the counter, count by shelf, + pickups and delivery rounds. +- **The staff app** at `/my`: staff see their own kit, request garments, managers approve. +- The two Android apps on Google Play can be pointed at your server (see below). + +Not included: the threadcount.tech website, multi-site health-service features, single sign-on, +and card payments. Those belong to the hosted service. + +## What it looks like + +The demo facility, on a laptop and on a phone. Nothing here is a real room, a real person or a real +supplier — the demo's data is invented and rebuilt every twenty minutes. + +| The coordinator app | The counter | +|---|---| +| [![The dashboard: what is low, what is on order, what was issued today](docs/screenshots/coordinator-dashboard.png)](docs/screenshots/coordinator-dashboard.png) | [![Issuing at the counter against a person's entitlement](docs/screenshots/counter-desktop.png)](docs/screenshots/counter-desktop.png) | +| **Stock** | **Reports** | +| [![Stock by item and size, with reorder levels and what is on order](docs/screenshots/stock.png)](docs/screenshots/stock.png) | [![Reports: spend by ward, issues, variance and the month-end pack](docs/screenshots/reports.png)](docs/screenshots/reports.png) | + +

+ The phone counter: scan a badge or a garment to issue, count a shelf or pick a round +
The phone counter — the camera reads badges, garments and shelf labels. +

+ +## Or let us host it + +If you would rather not run a server, the hosted service is the same product with backups kept +for 35 days, a 99.5% availability target, support response times in writing, and card or invoice +billing. A room under 60 staff records is hosted free; a facility is $129 a month or $1,290 a +year after a 30-day trial with no card. See [threadcount.tech/pricing](https://threadcount.tech/pricing) +and the [Service Level Agreement](https://threadcount.tech/sla). + +## Requirements + +- A Linux server with Docker and Docker Compose (2 CPU, 2 GB RAM is plenty to start). +- A hostname pointing at it, with HTTPS in front (Caddy, nginx or Traefik). The app sets secure + cookies, so sign-in will not work over plain HTTP from another machine. + +## Install + +```sh +git clone https://github.com/pricehq/threadcount-community.git +cd threadcount-community +cp .env.example .env +``` + +Open `.env` and set these four. Everything else can stay blank. + +| Setting | What to put | +|---|---| +| `SESSION_SECRET` | A long random string. `openssl rand -base64 48` makes one. The server refuses to start with the placeholder. | +| `POSTGRES_PASSWORD` | Any long password. It is only used between the two containers. | +| `NEXT_PUBLIC_SITE_URL` | The address people will type, for example `https://uniforms.example.health`. | +| `EDITION` | `community` | + +Then build and start it: + +```sh +docker compose up -d --build +``` + +The first start takes a few minutes: it builds the image, creates the database and applies the +schema. When `docker compose ps` shows the `app` container as healthy, it is ready. + +By default the app listens on **port 3000** on the server (change it with `APP_PORT` in `.env`). +Point your HTTPS proxy at it. A Caddyfile for that is two lines: + +``` +uniforms.example.health { + reverse_proxy 127.0.0.1:3000 +} +``` + +## First run + +There is no default username or password. The first person to sign up creates the facility and +becomes its administrator. + +1. Open your address in a browser. `/` sends you to `/auth`, the sign-in page. +2. Click **Create account**. Enter your name, the facility name, your email and a password. +3. You are now signed in as the facility's admin and land on the dashboard. +4. Go to **Settings**. Name your staff groups first (for example Registered Nurse, Enrolled Nurse, + Support Services). Nothing can be issued until a facility has groups. +5. Still in Settings, open **Data** and load your catalogue, departments, staff register and + opening stock from CSV. Templates for each file are on that screen. +6. Add a second administrator under **Settings → Account → Users** before you sign out. If the + only admin forgets their password and no email is configured, nobody can get back in. +7. Once your facility exists, set `SIGNUPS_DISABLED=1` in `.env` and run `docker compose up -d` + again. Nobody else can create a facility on your server after that. + +Admins and issuers are both created under Settings → Account → Users. An admin can do everything; +an issuer works the counter but cannot change settings, reorder levels or barcodes. + +## Phones and the Android apps + +The phone counter and the staff app are the same server, on a phone: + +- `https://your-host/m` for the counter (camera scanning works in Chrome and Edge) +- `https://your-host/my` for staff + +The Play apps (**ThreadCount** for the counter, **ThreadCount Staff** for staff) can use your server +too. On the app's first screen tap "Server: threadcount.tech · Change", choose Self-hosted and enter +your hostname. The app checks it, saves it on that phone, and opens your server from then on. + +## Email + +Set the `SMTP_*` values in `.env` if you want password resets, manager approval links and +ready-to-collect notices by email. Without them the product still works; those things happen at +the counter, and the screens say so. + +## Backups + +`docker/backup.sh /path/to/backups` dumps the database and the photos into a dated folder and +keeps the last fourteen. Run it nightly from cron and copy the folder off the server. An admin can +also download the whole facility as one file from Settings → Data at any time. + +Restore steps are in [docs/self-hosting.md](docs/self-hosting.md). + +## Updating + +Each release replaces the repository's history rather than adding to it, so a plain `git pull` +refuses to merge. Fetch and move to the release instead. Your `.env` is not tracked and stays put. + +```sh +git fetch origin +git reset --hard origin/main +docker compose up -d --build +``` + +Schema changes are applied automatically before the new version starts. + +## Configuration + +Every setting is listed with a comment in `.env.example`. The ones most people touch: +`NEXT_PUBLIC_TERMS_URL` and `NEXT_PUBLIC_PRIVACY_URL` (point the product's terms and privacy links +at your own documents), `SMTP_*`, `SIGNUPS_DISABLED`, `APP_PORT`, and the two Turnstile keys if you +want Cloudflare's bot check on sign-in. + +`SOURCE_URL` matters if you change the code. The sign-in page and the app show a "Source code" +link, which by default names the release your build came from. **If you modify ThreadCount and run +it for other people, the AGPL asks that those people can get your modified source** — set +`SOURCE_URL` to wherever you keep it and the link follows. + +## Licence + +GNU Affero General Public License, version 3 (AGPL-3.0-only) — an OSI-approved open source +licence. Run it, read it, change it, and share it. The one obligation that matters: if you modify +it and let other people use your modified version over a network, those users must be able to get +your changed source. Running the published version unmodified asks nothing of you. Full text in +[LICENSE](LICENSE). + +This covers the Community edition published at +[github.com/pricehq/threadcount-community](https://github.com/pricehq/threadcount-community). +The hosted service's own code — the website, billing, single sign-on and the tooling behind it — +is not published and is not covered by this licence. The **ThreadCount name and logo** are not +licensed either; a fork needs its own name. + +If the AGPL does not suit your organisation, a commercial licence for the same code is available: +kyle@threadcount.tech. + +## Security + +Found a vulnerability? Email security@threadcount.tech rather than opening a public issue. The +[security page](https://threadcount.tech/security) describes how the hosted service is run and +what the questionnaire answers; a Community instance inherits the same code and the practices in +[docs/self-hosting.md](docs/self-hosting.md) are the ones that matter for yours. + +## Help + +Open an issue on this repository. If you would rather not run a server at all, the hosted service +is at [threadcount.tech](https://threadcount.tech). diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..a46371b --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,42 @@ +The first tagged release. Earlier Community builds were published without tags. + +### Licence: now AGPL-3.0-only + +- ThreadCount Community is now under the **GNU Affero General Public License v3**, an OSI-approved + open source licence. It was previously the Functional Source Licence, which was source-available + but not open source. Run it, read it, change it, fork it; the one obligation is the copyleft one, + that a modified version you run for other people must let those people have your changes. +- Releases published before this one remain under the earlier licence. +- The sign-in page and the app now carry the licence's offer of source: the build you are running + and a link to its code. Set `SOURCE_URL` if you run a modified build, so it points at yours. +- `CONTRIBUTING.md` asks contributors for a Developer Certificate of Origin sign-off. It is a + sign-off, not a copyright assignment — you keep the copyright in your work. +- The ThreadCount name and logo are not covered by the licence; a fork needs its own name. +- The README now opens with the live demo and shows what the product looks like. + +### Phone counter (`/m`) +- Rebuilt around one scanner. A staff badge opens that person, a garment opens its stock line, and a shelf label starts a count of that shelf. +- Issuing is a basket of the person's kit in their size, with tops and pants counted against the sets limit as you add. A line that breaks a rule needs a reason. +- A hand-over can be signed on the glass, and the slip can be sent to the person's staff app. +- Hand back takes several garments at once, each with a condition and an optional size swap. +- A Work tab collects approved requests to pick, deliveries to receive, pickups and ward rounds. +- The Android app prints labels through Android's print service. Shelf labels can be printed and scanned to start a count. +- Counting has a hands-free switch, and every gap over the facility threshold needs a reason before the count is committed. + +### Staff app (`/my`) +- Notifications for approved or declined requests, ready to collect, bags on the ward round, kit checks, and for approvers anything waiting on them. They need a Firebase project on your server: see Self-hosting, Notifications in the manual. Without one the app says notifications are not set up. +- A Messages tab listing every thread with the linen room. +- A Team tab for approvers and ward desks: approve from the queue, the ward roster, raising for your team, and the ward round with progress on "Sign for all". +- Asking for a garment is one screen, with what you already hold first and "Same again" to repeat your last request. +- Signed slips show quantities. +- An offline bar keeps what you typed while the signal is gone. + +### Coordinator app (`/app`) +- Rebuilt around seven sections: Today, Counter, Stock, Orders, People, Reports and Settings, with a search-and-scan bar. + +### Sign-in and sign-up +- A terms and privacy tick box on sign-in and on the first sign-up step, shown when your server sets `NEXT_PUBLIC_TERMS_URL` or `NEXT_PUBLIC_PRIVACY_URL`. +- Where Turnstile is configured, its check is visible on the password step and on the last sign-up step. + +### Updating +- This release adds database migrations. They run by themselves during `docker compose up -d --build`. Take a backup first, as the updating guide describes. diff --git a/app/api/2fa/route.ts b/app/api/2fa/route.ts new file mode 100644 index 0000000..088df20 --- /dev/null +++ b/app/api/2fa/route.ts @@ -0,0 +1,121 @@ +import { NextRequest, NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import QRCode from "qrcode"; +import { prisma } from "@/lib/db"; +import { currentUser } from "@/lib/session"; +import { sameOriginJson } from "@/lib/csrf"; +import { allow, clientIp } from "@/lib/ratelimit"; +import { + decryptSecret, encryptSecret, hashRecoveryCode, newRecoveryCodes, newTotpSecret, otpauthUrl, totpVerify, +} from "@/lib/totp"; +import { recordAuthEvent } from "@/lib/audit"; + +export const dynamic = "force-dynamic"; + +/* Turning a second factor on and off, for your own account only. + * + * Three steps rather than one, because a secret that is stored the moment it is generated leaves + * an account half-enrolled if the person never finishes — and then their next sign-in asks for + * codes from an app they never set up. + * + * setup — generate a secret and show the QR. Stored, but not yet in force. + * enable — prove a code from it works, then switch it on and hand back recovery codes. + * disable — password required, because turning a factor off is a privileged act. + */ +export async function GET() { + const user = await currentUser(); + if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 }); + const u = await prisma.user.findUnique({ where: { id: user.id }, select: { totpEnabledAt: true } }); + const left = await prisma.recoveryCode.count({ where: { userId: user.id, usedAt: null } }); + return NextResponse.json({ enabled: !!u?.totpEnabledAt, enabledAt: u?.totpEnabledAt ?? null, recoveryLeft: left }); +} + +export async function POST(req: NextRequest) { + const csrf = sameOriginJson(req); + if (csrf) return NextResponse.json({ error: csrf }, { status: 403 }); + const user = await currentUser(); + if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 }); + + const ip = clientIp(req.headers); + if (!allow("2fa-manage:" + user.id, 30, 15 * 60 * 1000)) { + return NextResponse.json({ error: "Too many attempts — try again in a few minutes." }, { status: 429 }); + } + + // Turning a second factor on or off is one of the few changes to an account that leaves no trace + // in the records themselves, so it is one of the few worth recording on its own. + const actor = { + facilityId: user.facilityId, userId: user.id, + userName: `${user.first} ${user.last}`.trim() || user.email, + }; + + let body: { action?: unknown; code?: unknown; password?: unknown }; + try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); } + const action = String(body.action ?? ""); + + const u = await prisma.user.findUnique({ + where: { id: user.id }, + select: { id: true, email: true, passwordHash: true, totpSecret: true, totpEnabledAt: true }, + }); + if (!u) return NextResponse.json({ error: "Not signed in" }, { status: 401 }); + + if (action === "setup") { + if (u.totpEnabledAt) return NextResponse.json({ error: "Two-factor is already on for this account." }, { status: 400 }); + const secret = newTotpSecret(); + await prisma.user.update({ where: { id: u.id }, data: { totpSecret: encryptSecret(secret) } }); + const url = otpauthUrl(secret, u.email); + // SVG, generated here rather than in the browser: it keeps a QR library out of the bundle that + // ward phones download, and the secret never has to be handed to client-side code to render. + const qr = await QRCode.toString(url, { type: "svg", margin: 1, width: 220, errorCorrectionLevel: "M" }); + recordAuthEvent(actor, "2fa:setup", ip); + return NextResponse.json({ ok: true, secret, url, qr }); + } + + if (action === "enable") { + if (u.totpEnabledAt) return NextResponse.json({ error: "Two-factor is already on." }, { status: 400 }); + const secret = decryptSecret(u.totpSecret); + if (!secret) return NextResponse.json({ error: "Start the setup again." }, { status: 400 }); + if (!totpVerify(secret, String(body.code ?? ""))) { + return NextResponse.json({ error: "That code isn't right. Use the current one from your app." }, { status: 400 }); + } + const codes = newRecoveryCodes(); + await prisma.$transaction(async (tx) => { + await tx.user.update({ where: { id: u.id }, data: { totpEnabledAt: new Date() } }); + await tx.recoveryCode.deleteMany({ where: { userId: u.id } }); + await tx.recoveryCode.createMany({ data: codes.map((c) => ({ userId: u.id, codeHash: hashRecoveryCode(c) })) }); + }); + recordAuthEvent(actor, "2fa:enable", ip); + // The only time these are ever readable. They are stored hashed, so there is no second chance. + return NextResponse.json({ ok: true, codes }); + } + + if (action === "disable") { + if (!u.totpEnabledAt) return NextResponse.json({ ok: true }); + const pw = String(body.password ?? ""); + if (!pw || !(await bcrypt.compare(pw, u.passwordHash))) { + return NextResponse.json({ error: "That password isn't right." }, { status: 401 }); + } + await prisma.$transaction(async (tx) => { + await tx.user.update({ where: { id: u.id }, data: { totpEnabledAt: null, totpSecret: "" } }); + await tx.recoveryCode.deleteMany({ where: { userId: u.id } }); + }); + recordAuthEvent(actor, "2fa:disable", ip); + return NextResponse.json({ ok: true }); + } + + if (action === "regenerate") { + if (!u.totpEnabledAt) return NextResponse.json({ error: "Two-factor isn't on." }, { status: 400 }); + const pw = String(body.password ?? ""); + if (!pw || !(await bcrypt.compare(pw, u.passwordHash))) { + return NextResponse.json({ error: "That password isn't right." }, { status: 401 }); + } + const codes = newRecoveryCodes(); + await prisma.$transaction(async (tx) => { + await tx.recoveryCode.deleteMany({ where: { userId: u.id } }); + await tx.recoveryCode.createMany({ data: codes.map((c) => ({ userId: u.id, codeHash: hashRecoveryCode(c) })) }); + }); + recordAuthEvent(actor, "2fa:regenerate", ip); + return NextResponse.json({ ok: true, codes }); + } + + return NextResponse.json({ error: "Unknown action" }, { status: 400 }); +} diff --git a/app/api/activity/route.ts b/app/api/activity/route.ts new file mode 100644 index 0000000..dfb4ead --- /dev/null +++ b/app/api/activity/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { currentUser } from "@/lib/session"; + +export const dynamic = "force-dynamic"; + +const PAGE = 100; + +/* The audit trail, read back. + * + * Admin only, and scoped to the caller's own facility by the query rather than by a filter the + * client sends — the client never gets to say which facility it wants. Paged by cursor rather + * than offset so a busy room's log doesn't shift under you as new rows land while you read. + * + * The cursor is (timestamp, id), not timestamp alone. Prisma stores DateTime at millisecond + * precision, and two events sharing a millisecond is ordinary rather than exotic — two coordinators + * saving at once, or two ops committed inside one transaction. A strict `at < cursor` dropped every + * row that shared the last one's millisecond, so the log looked complete with an event missing from + * it, which is the one failure an audit trail cannot have. + */ + +/** `|` — one opaque string, because the client only ever hands it straight back. */ +function readCursor(raw: string | null): { at: Date; id: string } | null { + if (!raw) return null; + const cut = raw.lastIndexOf("|"); + const iso = cut === -1 ? raw : raw.slice(0, cut); + const id = cut === -1 ? "" : raw.slice(cut + 1); + if (Number.isNaN(Date.parse(iso))) return null; + return { at: new Date(iso), id: id.slice(0, 40) }; +} + +export async function GET(req: NextRequest) { + const user = await currentUser(); + if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 }); + // SessionUser.role is the database enum ("ADMIN"), not the snapshot's display form ("Admin"). + if (user.role !== "ADMIN") return NextResponse.json({ error: "Admin only" }, { status: 403 }); + + const cursor = readCursor(req.nextUrl.searchParams.get("before")); + + const rows = await prisma.auditEvent.findMany({ + where: { + facilityId: user.facilityId, + // Everything strictly older, plus the rest of the millisecond we stopped in the middle of. + ...(cursor ? { OR: [{ at: { lt: cursor.at } }, { at: cursor.at, id: { lt: cursor.id } }] } : {}), + }, + orderBy: [{ at: "desc" }, { id: "desc" }], + take: PAGE + 1, + select: { id: true, at: true, userName: true, op: true, target: true }, + }); + + const more = rows.length > PAGE; + const page = rows.slice(0, PAGE); + const last = page[page.length - 1]; + return NextResponse.json({ + events: page.map((r) => ({ id: r.id, at: r.at.toISOString(), who: r.userName, op: r.op, target: r.target })), + nextBefore: more && last ? `${last.at.toISOString()}|${last.id}` : null, + }); +} diff --git a/app/api/app-info/route.ts b/app/api/app-info/route.ts new file mode 100644 index 0000000..9d96c0b --- /dev/null +++ b/app/api/app-info/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import { COMMUNITY } from "@/lib/edition"; +import { SOURCE_URL, editionVersion } from "@/lib/source"; + +export const dynamic = "force-dynamic"; + +/* What the Android apps ask a server before they will point at it. + * + * The apps open threadcount.tech unless told otherwise; a room running the Community edition types + * its own address into the app's first screen, and the app calls this first. It proves the address + * is a ThreadCount server (not a look-alike, not a typo), says which edition and build, and carries + * the oldest app version this build still works with, so an app can say "update me" instead of + * breaking quietly. Public and unauthenticated on purpose: nothing here is about a facility. */ +export async function GET() { + return NextResponse.json({ + product: "threadcount", + edition: COMMUNITY ? "community" : "hosted", + version: editionVersion(), + // The AGPL's offer of source, for an app that wants to show it. Null on the hosted service, + // which is not licensed under the AGPL. + source: COMMUNITY ? SOURCE_URL : null, + paths: { counter: "/m", staff: "/my" }, + // The oldest Play versionCode of each app this server still serves correctly. + minApp: { counter: 9, staff: 7 }, + }, { headers: { "cache-control": "no-store", "access-control-allow-origin": "*" } }); +} diff --git a/app/api/auth/2fa/route.ts b/app/api/auth/2fa/route.ts new file mode 100644 index 0000000..fc8b8cf --- /dev/null +++ b/app/api/auth/2fa/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { cookies } from "next/headers"; +import { REMEMBER_MAX_AGE, pwVersion, setSessionCookie } from "@/lib/session"; +import { sameOriginJson } from "@/lib/csrf"; +import { allow, clientIp } from "@/lib/ratelimit"; +import { decryptSecret, hashRecoveryCode, totpVerify } from "@/lib/totp"; +import { TRUST_COOKIE, TRUST_TTL_MS, mintTrust, readTicket } from "@/lib/twofactor"; +import { recordAuthEvent } from "@/lib/audit"; + +export const dynamic = "force-dynamic"; + +/* Second step of sign-in: the code from the authenticator, or one recovery code. + * + * Rate limited hard. A six-digit code is one in a million per guess, which is only meaningful if + * guessing is expensive — unthrottled, a million tries is minutes of work. */ +export async function POST(req: NextRequest) { + const csrf = sameOriginJson(req); + if (csrf) return NextResponse.json({ error: csrf }, { status: 403 }); + + const ip = clientIp(req.headers); + let body: { ticket?: unknown; code?: unknown; trust?: unknown; remember?: unknown }; + try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); } + + const t = readTicket(String(body.ticket ?? "")); + if (!t) return NextResponse.json({ error: "That sign-in has expired. Start again." }, { status: 400 }); + + // Per account and per address: one stolen ticket can't be brute-forced, and one machine can't + // work through several accounts at once. + if (!allow("2fa-user:" + t.uid, 10, 15 * 60 * 1000) || !allow("2fa-ip:" + ip, 300, 15 * 60 * 1000)) { + return NextResponse.json({ error: "Too many attempts — try again in a few minutes." }, { status: 429 }); + } + + const u = await prisma.user.findUnique({ + where: { id: t.uid }, + select: { id: true, facilityId: true, email: true, first: true, last: true, role: true, inactive: true, passwordHash: true, totpSecret: true, totpEnabledAt: true }, + }); + if (!u || u.inactive || !u.totpEnabledAt) { + return NextResponse.json({ error: "That sign-in has expired. Start again." }, { status: 400 }); + } + // The password changed between the two steps — the ticket is stale for the same reason a session + // would be. + if (pwVersion(u.passwordHash) !== t.pv) { + return NextResponse.json({ error: "That sign-in has expired. Start again." }, { status: 400 }); + } + + const raw = String(body.code ?? "").trim(); + const secret = decryptSecret(u.totpSecret); + let good = !!secret && totpVerify(secret, raw); + let usedRecovery = false; + + if (!good && raw.replace(/[^A-Za-z0-9]/g, "").length >= 10) { + // A recovery code. Single use: consumed in the same conditional update that finds it, so two + // simultaneous attempts can't both spend it. + const hash = hashRecoveryCode(raw); + const hit = await prisma.recoveryCode.findFirst({ where: { userId: u.id, codeHash: hash, usedAt: null }, select: { id: true } }); + if (hit) { + const consumed = await prisma.recoveryCode.updateMany({ where: { id: hit.id, usedAt: null }, data: { usedAt: new Date() } }); + good = consumed.count === 1; + usedRecovery = good; + } + } + + if (!good) return NextResponse.json({ error: "That code isn't right. Try the current one from your app." }, { status: 401 }); + + await setSessionCookie(u.id, u.passwordHash, false, body.remember === true ? REMEMBER_MAX_AGE : undefined); + // "Trust this computer": only ever set here, after a real code, never from the password step. + if (body.trust === true) { + const jar = await cookies(); + jar.set(TRUST_COOKIE, mintTrust(u.id, pwVersion(u.passwordHash)), { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/api/auth", maxAge: Math.floor(TRUST_TTL_MS / 1000) }); + } + // How they got in matters more here than anywhere else: a recovery code means the phone is gone, + // and a run of them means something else is going on. + recordAuthEvent( + { facilityId: u.facilityId, userId: u.id, userName: `${u.first} ${u.last}`.trim() || u.email }, + "auth:signin", ip, usedRecovery ? "recovery" : "totp", + ); + const left = await prisma.recoveryCode.count({ where: { userId: u.id, usedAt: null } }); + return NextResponse.json({ ok: true, name: `${u.first} ${u.last}`, role: u.role, usedRecovery, recoveryLeft: left }); +} diff --git a/app/api/auth/forgot/route.ts b/app/api/auth/forgot/route.ts new file mode 100644 index 0000000..ba9b75c --- /dev/null +++ b/app/api/auth/forgot/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { allow, clientIp, fail, over } from "@/lib/ratelimit"; +import { sameOriginJson } from "@/lib/csrf"; +import { verifyTurnstile } from "@/lib/turnstile"; +import { sendTo, transactionalConfigured } from "@/lib/mail"; +import { RESET_TTL_MS, newResetToken, resetEmail, resetUrl } from "@/lib/reset"; + +export const dynamic = "force-dynamic"; + +/* Request a password reset. + * + * Before this existed a facility whose only admin forgot their password was locked out for good — + * the sign-in screen told them to ask an admin, and they were the admin. Deleting the last admin + * deletes the whole facility, so there was no way back in at all. + * + * The response is identical whether or not the address has an account. Anything else turns this + * into a way to ask "does this hospital use ThreadCount, and is this person a coordinator there?" + */ +export async function POST(req: NextRequest) { + const csrf = sameOriginJson(req); + if (csrf) return NextResponse.json({ error: csrf }, { status: 403 }); + + const ip = clientIp(req.headers); + let body: { email?: unknown; cfToken?: unknown }; + try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); } + const email = String(body.email ?? "").trim().toLowerCase().slice(0, 160); + + // A slow ceiling per IP, so one machine can't walk a staff list to find out which addresses exist + // by watching how long each request takes. The per-address ceiling deliberately lives further + // down, past the bot check — see the note beside it. + if (!allow("forgot-ip:" + ip, 60, 60 * 60 * 1000)) { + return NextResponse.json({ ok: true }); + } + + const cfErr = await verifyTurnstile(body.cfToken, ip); + if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 }); + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return NextResponse.json({ ok: true }); + + const user = await prisma.user.findUnique({ where: { email }, select: { id: true, first: true, inactive: true, ssoBreakGlass: true, facility: { select: { ssoRequired: true } } } }); + // A facility that requires single sign-on has no password door for most of its people, so a + // reset link would be a way round its identity provider. Break-glass admins keep theirs. The + // answer to the caller is the same either way. + const ssoOnly = !!user && user.facility.ssoRequired && !user.ssoBreakGlass; + if (user && !user.inactive && !ssoOnly) { + // The per-address ceiling counts mail actually sent, not requests received, and it is only + // consulted once the bot check has passed. Spent on requests, it handed a stranger a way to + // hold a facility's only coordinator out of their own account: four anonymous posts with no + // Turnstile token filled the bucket, and every later attempt by the coordinator was answered + // with "a reset link is on its way" and no mail. Counted this way the only way to exhaust the + // budget is to have four reset mails delivered to that same inbox, so whoever forgot their + // password always has a working link waiting for them. + const mailKey = "forgot-email:" + email; + if (over(mailKey, 4, 60 * 60 * 1000)) { + console.warn("[forgot] four reset mails already sent this hour — suppressing another for user", user.id); + } else { + const { token, tokenHash } = newResetToken(); + await prisma.$transaction(async (tx) => { + // Asking again supersedes anything outstanding, so a forwarded older email goes dead. + await tx.passwordReset.updateMany({ + where: { userId: user.id, usedAt: null }, + data: { usedAt: new Date() }, + }); + await tx.passwordReset.create({ + data: { userId: user.id, tokenHash, expiresAt: new Date(Date.now() + RESET_TTL_MS), requestIp: ip }, + }); + }); + const { subject, text, html } = resetEmail(user.first, resetUrl(token)); + const sent = await sendTo(email, subject, text, html); + // Only a mail that left the building counts. A send that failed gave the coordinator nothing, + // so charging them for it would shut them out for an hour over a mail outage. + if (sent) fail(mailKey, 60 * 60 * 1000); + else console.error("[forgot] reset requested but mail could not be sent for user", user.id); + } + } + + // Told to the caller regardless, so the answer carries no information about the address. + return NextResponse.json({ ok: true, mail: transactionalConfigured() }); +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..2284f02 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import { prisma } from "@/lib/db"; +import { REMEMBER_MAX_AGE, pwVersion, setSessionCookie } from "@/lib/session"; +import { TRUST_COOKIE, mintTicket, readTrust } from "@/lib/twofactor"; +import { sameOriginJson } from "@/lib/csrf"; +import { clientIp, fail, over } from "@/lib/ratelimit"; +import { signInStaff } from "@/lib/staffauth"; +import { verifyTurnstile } from "@/lib/turnstile"; +import { recordAuthEvent } from "@/lib/audit"; + +export const dynamic = "force-dynamic"; + +// Simple in-memory throttle per IP+email (per process). +const attempts = new Map(); + +/** The trail names the person, not the address they typed — see lib/audit.ts. */ +const actorFor = (u: { id: string; facilityId: string; first: string; last: string; email: string }) => + ({ facilityId: u.facilityId, userId: u.id, userName: `${u.first} ${u.last}`.trim() || u.email }); + +export async function POST(req: NextRequest) { + const csrf = sameOriginJson(req); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 }); + let body: { email?: string; password?: string; cfToken?: string; remember?: unknown }; + try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); } + const email = String(body.email || "").trim().toLowerCase().slice(0, 160); + const password = String(body.password || "").slice(0, 200); + // Spray protection independent of the per-(ip,email) counter below. Both buckets count only the + // attempts that FAILED — a whole hospital signs in from one NAT address at shift change, and a + // ceiling on attempts would have to lock that ward out to be worth anything against an attacker. + const ipKey = clientIp(req.headers); + if (over("login-ip:" + ipKey, 40, 15 * 60 * 1000) || (email && over("login-email:" + email, 25, 15 * 60 * 1000))) return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 }); + if (!email || !password) return NextResponse.json({ error: "Enter your email and password." }, { status: 400 }); + + // nginx appends the real client IP last; earlier entries are client-supplied and spoofable. + const xff = req.headers.get("x-forwarded-for")?.split(",").map((x) => x.trim()).filter(Boolean) || []; + const ip = xff[xff.length - 1] || "local"; + if (attempts.size > 5000) for (const [kk, v] of attempts) if (Date.now() - v.t > 15 * 60 * 1000) attempts.delete(kk); + const k = `${ip}|${email}`; + const a = attempts.get(k); + if (a && a.n >= 8 && Date.now() - a.t < 15 * 60 * 1000) return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 }); + + const cfErr = await verifyTurnstile(body.cfToken, ipKey); if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 }); + const u = await prisma.user.findUnique({ where: { email } }); + /* One box, both kinds of account. + * + * A wearer reaches the product the way anyone else does — the home page, then Log in — and types + * the details they set up in the staff app. So when this address has no coordinator account, the + * register is asked before the answer is called wrong. + * + * A coordinator account always wins: it is the one with the counter, the orders and the register + * behind it, and a coordinator who also wears a uniform can open their own record from inside the + * app. One address therefore has one destination, every time. + * + * This is a lookup, not a second attempt. "Try the coordinator, and if that fails try the staff + * one" would score a failure against every single staff sign-in, and these ceilings count + * failures — behind one hospital's NAT address at shift change that is a locked-out ward. + */ + if (!u) { + const s = await signInStaff(email, password, ipKey, false); + if (s.kind === "ok") return NextResponse.json({ ok: true, name: s.name, staff: true }); + if (s.kind === "error") return NextResponse.json({ error: s.error }, { status: s.status }); + // `none`: no staff account either, so this falls through to the answer below, which counts the + // failure once and says the same thing it has always said. + } + const ok = u ? await bcrypt.compare(password, u.passwordHash) : await bcrypt.compare(password, "$2b$12$C6UzMDM.H6dfI/f/IKcEeO5x3FvDS3kqB6r0Jt3g7Lz0vX4o0JZ1u"); + if (!u || !ok) { + attempts.set(k, { n: (a && Date.now() - a.t < 15 * 60 * 1000 ? a.n : 0) + 1, t: Date.now() }); + fail("login-ip:" + ipKey, 15 * 60 * 1000); + if (email) fail("login-email:" + email, 15 * 60 * 1000); + // An address with no account here is recorded nowhere: there is no facility to file it under, + // and a log of attempts on addresses that don't exist would be a list of other people's email + // addresses that nobody asked us to keep. + if (u) recordAuthEvent(actorFor(u), "auth:signin.failed", ipKey); + return NextResponse.json({ error: "Email or password doesn’t match." }, { status: 401 }); + } + attempts.delete(k); + if (u.inactive) { + // The right password on an account that has been taken away is worth knowing about. + recordAuthEvent(actorFor(u), "auth:signin.refused", ipKey, "inactive"); + return NextResponse.json({ error: "This account has been deactivated. Ask an admin at your facility to reactivate it." }, { status: 403 }); + } + const fac = await prisma.facility.findUnique({ where: { id: u.facilityId }, select: { isDemo: true, ssoEnabled: true, ssoRequired: true } }); + if (fac?.isDemo) return NextResponse.json({ error: "Demo accounts can’t log in here — open the demo from the home page." }, { status: 403 }); + // The facility has decided its people sign in through its own identity provider. The password + // was right, and it is still refused — except for the admin the facility keeps as its fire + // escape. The box sends them on to single sign-on rather than reporting a failure. + if (fac?.ssoEnabled && fac.ssoRequired && !u.ssoBreakGlass) { + recordAuthEvent(actorFor(u), "auth:signin.refused", ipKey, "sso required"); + return NextResponse.json({ error: "Your facility signs in with single sign-on.", ssoRequired: true }, { status: 403 }); + } + // With a second factor on the account the password alone opens nothing. The ticket says only + // "this password was correct", is accepted by no other endpoint, and expires in five minutes. + // A browser that entered a code within the last thirty days and asked to be trusted skips it; + // the trust token is bound to the password version, so a changed password asks again. + const trusted = !!u.totpEnabledAt && readTrust(req.cookies.get(TRUST_COOKIE)?.value, u.id, pwVersion(u.passwordHash)); + if (u.totpEnabledAt && !trusted) { + return NextResponse.json({ need2fa: true, ticket: mintTicket(u.id, pwVersion(u.passwordHash)) }); + } + + await setSessionCookie(u.id, u.passwordHash, false, body.remember === true ? REMEMBER_MAX_AGE : undefined); + recordAuthEvent(actorFor(u), "auth:signin", ipKey, trusted ? "password+trusted" : "password"); + return NextResponse.json({ ok: true, name: `${u.first} ${u.last}`, role: u.role }); +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..4ced033 --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { clearSessionCookie, currentUser } from "@/lib/session"; +import { sameOriginJson } from "@/lib/csrf"; +import { clientIp } from "@/lib/ratelimit"; +import { recordAuthEvent } from "@/lib/audit"; + +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + const csrf = sameOriginJson(req, false); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 }); + // Read the session before dropping it, so the trail can say who left. An unauthenticated call + // still clears the cookie and still answers ok — signing out must never fail. + const user = await currentUser(); + await clearSessionCookie(); + if (user) { + recordAuthEvent( + { facilityId: user.facilityId, userId: user.id, userName: `${user.first} ${user.last}`.trim() || user.email }, + "auth:signout", clientIp(req.headers), + ); + } + return NextResponse.json({ ok: true }); +} diff --git a/app/api/auth/lookup/route.ts b/app/api/auth/lookup/route.ts new file mode 100644 index 0000000..df84cdd --- /dev/null +++ b/app/api/auth/lookup/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { sameOriginJson } from "@/lib/csrf"; +import { allow, clientIp } from "@/lib/ratelimit"; + +export const dynamic = "force-dynamic"; + +/* Which door does this address belong at? + * + * The sign-in screen asks for the address first and only then shows a password box, a single + * sign-on button or a pointer to the staff app. This answers the last of those: an address that + * has no coordinator account but does have a staff-app account belongs in the staff app, and + * telling the person so beats a "wrong password" they can never get past. It answers nothing about + * coordinator accounts — a coordinator address and an unknown address get the same reply, so the + * box cannot be used to test which addresses have one. Throttled per connection like the SSO lookup. */ +export async function POST(req: NextRequest) { + const csrf = sameOriginJson(req); + if (csrf) return NextResponse.json({ error: csrf }, { status: 403 }); + if (!allow("lookup:" + clientIp(req.headers), 60, 15 * 60 * 1000)) return NextResponse.json({ staff: false }); + let body: { email?: unknown }; + try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); } + const email = String(body.email ?? "").trim().toLowerCase().slice(0, 160); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return NextResponse.json({ staff: false }); + const user = await prisma.user.findUnique({ where: { email }, select: { id: true } }); + if (user) return NextResponse.json({ staff: false }); + const acc = await prisma.staffAccount.findUnique({ where: { email }, select: { id: true } }); + return NextResponse.json({ staff: !!acc }); +} diff --git a/app/api/auth/reset/route.ts b/app/api/auth/reset/route.ts new file mode 100644 index 0000000..dc6cd24 --- /dev/null +++ b/app/api/auth/reset/route.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import { prisma } from "@/lib/db"; +import { allow, clientIp } from "@/lib/ratelimit"; +import { sameOriginJson } from "@/lib/csrf"; +import { pwVersion, setSessionCookie } from "@/lib/session"; +import { mintTicket } from "@/lib/twofactor"; +import { hashResetToken } from "@/lib/reset"; +import { recordAuthEvent } from "@/lib/audit"; + +export const dynamic = "force-dynamic"; + +const MIN_PASSWORD = 8; + +/* Complete a password reset. + * + * Changing the hash invalidates every existing session for that user on its own — the session + * cookie carries a version derived from the password hash — so a reset also kicks out whoever + * prompted it, which is the behaviour you want if the reason was a shared or stolen password. */ +export async function POST(req: NextRequest) { + const csrf = sameOriginJson(req); + if (csrf) return NextResponse.json({ error: csrf }, { status: 403 }); + + const ip = clientIp(req.headers); + if (!allow("reset-ip:" + ip, 100, 60 * 60 * 1000)) { + return NextResponse.json({ error: "Too many attempts — try again later." }, { status: 429 }); + } + + let body: { token?: unknown; password?: unknown }; + try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); } + const token = String(body.token ?? "").trim().slice(0, 400); + const password = String(body.password ?? ""); + + if (!token) return NextResponse.json({ error: "That link is incomplete. Ask for a new one." }, { status: 400 }); + if (password.length < MIN_PASSWORD) { + return NextResponse.json({ error: `Use at least ${MIN_PASSWORD} characters.` }, { status: 400 }); + } + + // Looked up by hash, so the raw token never has to be compared against stored material. + const row = await prisma.passwordReset.findUnique({ + where: { tokenHash: hashResetToken(token) }, + select: { + id: true, userId: true, expiresAt: true, usedAt: true, + user: { select: { inactive: true, passwordHash: true, totpEnabledAt: true, facilityId: true, first: true, last: true, email: true } }, + }, + }); + + const dead = !row || row.usedAt || row.expiresAt.getTime() < Date.now() || row.user.inactive; + if (dead) { + return NextResponse.json({ error: "That link has expired or has already been used. Ask for a new one." }, { status: 400 }); + } + + const hash = await bcrypt.hash(password, 12); + await prisma.$transaction(async (tx) => { + // Consume the token in the same write as the password change, so a double submit can't set the + // password twice or leave a live token behind. + const consumed = await tx.passwordReset.updateMany({ + where: { id: row.id, usedAt: null }, + data: { usedAt: new Date() }, + }); + if (consumed.count !== 1) throw new Error("token already consumed"); + await tx.user.update({ where: { id: row.userId }, data: { passwordHash: hash } }); + // Any other outstanding requests for this account die with it. + await tx.passwordReset.updateMany({ where: { userId: row.userId, usedAt: null }, data: { usedAt: new Date() } }); + }).catch(() => null); + + const fresh = await prisma.user.findUnique({ where: { id: row.userId }, select: { passwordHash: true } }); + if (!fresh || fresh.passwordHash !== hash) { + return NextResponse.json({ error: "That link has expired or has already been used. Ask for a new one." }, { status: 400 }); + } + + const actor = { + facilityId: row.user.facilityId, + userId: row.userId, + userName: [row.user.first, row.user.last].filter(Boolean).join(" ").trim() || row.user.email, + }; + + // A second factor is a second factor here too. Control of the mailbox is one proof, and on an + // account with TOTP the front door refuses to open on one proof — so this door must not either, + // or resetting the password would be the supported way around the authenticator, and the new + // password would then be enough to turn it off for good. + // + // The same five-minute ticket the sign-in screen uses, accepted by the same endpoint: nothing new + // to keep, nothing new to get wrong. + if (row.user.totpEnabledAt) { + recordAuthEvent(actor, "auth:password.reset", ip, "email-link"); + return NextResponse.json({ need2fa: true, ticket: mintTicket(row.userId, pwVersion(hash)) }); + } + + // Otherwise sign them straight in: they have just proven control of the mailbox and chosen a + // password, and making them type it again immediately is friction with no security value. + await setSessionCookie(row.userId, hash); + recordAuthEvent(actor, "auth:password.reset", ip, "email-link"); + recordAuthEvent(actor, "auth:signin", ip, "reset"); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/auth/signup/route.ts b/app/api/auth/signup/route.ts new file mode 100644 index 0000000..d26a353 --- /dev/null +++ b/app/api/auth/signup/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import { prisma } from "@/lib/db"; +import { setSessionCookie } from "@/lib/session"; +import { allow, clientIp } from "@/lib/ratelimit"; +import { sameOriginJson } from "@/lib/csrf"; +import { verifyTurnstile } from "@/lib/turnstile"; +import { sendTo, transactionalConfigured } from "@/lib/mail"; +import { switches } from "@/lib/switches"; +import { alertNewSignup } from "@/lib/ops/alerts"; +import { recordAuthEvent } from "@/lib/audit"; +import { TRIAL_DAYS } from "@/lib/plan"; +import { sendBillingMail, templates } from "@/lib/billing-mail"; +import { welcomeEmail } from "@/lib/accountmail"; + +export const dynamic = "force-dynamic"; + +/* Starting staff groups by healthcare setting. Generic titles only — every room renames them. */ +const GROUP_SEEDS: Record = { + hospital: { staffGroups: ["Registered Nurse", "Enrolled Nurse", "Allied Health", "Support Services", "Security"], nursingGroups: ["Registered Nurse", "Enrolled Nurse"], kitGroups: ["Support Services"] }, + aged_care: { staffGroups: ["Registered Nurse", "Enrolled Nurse", "Personal Care Worker", "Hospitality", "Maintenance"], nursingGroups: ["Registered Nurse", "Enrolled Nurse"], kitGroups: ["Hospitality", "Maintenance"] }, + community: { staffGroups: ["Registered Nurse", "Enrolled Nurse", "Allied Health", "Administration"], nursingGroups: ["Registered Nurse", "Enrolled Nurse"], kitGroups: ["Administration"] }, +}; +const STATE_ZONES: Record = { + QLD: "Australia/Brisbane", NSW: "Australia/Sydney", ACT: "Australia/Sydney", VIC: "Australia/Melbourne", TAS: "Australia/Hobart", + SA: "Australia/Adelaide", NT: "Australia/Darwin", WA: "Australia/Perth", NZ: "Pacific/Auckland", +}; + +/* Creating a facility. + * + * The address typed here is not verified, and deliberately isn't: a confirmation step in front of + * a linen room's first ten minutes is a wall, and a facility half-created behind an unclicked link + * is worse than one created. But it is the *only* way back in — /api/auth/forgot answers a + * stranger and the owner identically, so a typo produces no signal at all until the day the + * password is forgotten, and by then the facility is unreachable and undeletable. + * + * So the address is exercised immediately instead. A note goes to it saying, in as many words, + * that this is the address that recovers the account, and the answer here says whether it was + * sent — which is what lets the sign-up screen show the address back and tell someone who never + * receives it what to do about it while they are still signed in and can still act. + */ + +export async function POST(req: NextRequest) { + const sw = await switches(); + if (!sw.signupsOpen) return NextResponse.json({ error: "New facility sign-ups are closed." }, { status: 403 }); + const csrf = sameOriginJson(req); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 }); + if (!allow("signup:" + clientIp(req.headers), 5, 60 * 60 * 1000)) return NextResponse.json({ error: "Too many sign-ups from this connection — try again later." }, { status: 429 }); + let b: Record; + try { b = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); } + const first = String(b.first || "").trim().slice(0, 80), last = String(b.last || "").trim().slice(0, 80); + const facility = String(b.facility || "").trim().slice(0, 120); + const email = String(b.email || "").trim().toLowerCase().slice(0, 160); + const password = String(b.password || ""); + if (!first || !last || !facility) return NextResponse.json({ error: "Name and facility are required." }, { status: 400 }); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return NextResponse.json({ error: "Enter a valid work email." }, { status: 400 }); + if (password.length < 8) return NextResponse.json({ error: "Password must be at least 8 characters." }, { status: 400 }); + const cfErr = await verifyTurnstile(b.cfToken, clientIp(req.headers)); if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 }); + if (await prisma.user.findUnique({ where: { email } })) return NextResponse.json({ error: "That email already has an account — log in instead." }, { status: 409 }); + + const u = await prisma.$transaction(async (tx) => { + // No staff groups: the facility names its own. Any list handed over here would be one employer's + // organisation chart on another employer's register, and a group sitting on a route nobody + // chose decides who is handed a starting kit. Both route lists start empty with it, so until the + // coordinator puts a group on the FTE table or the starting kit, everybody is on manager approval + // and nobody has been promised a kit the counter would not hand over. + // Until plans are live the page still says free, so a facility created today is grandfathered: + // free with everything, for good. Once they are live a new room starts on the plan it chose — + // Hosted Small, free, or a Hosted Facility trial with its end date set now. Anything else + // sent as `plan` is Hosted Small: the free room is the safe misreading. + const trial = sw.plansLive && b.plan === "hosted_facility"; + const planData = !sw.plansLive + ? { grandfathered: true, planStatus: "free" } + : trial + ? { plan: "hosted_facility", planStatus: "trial", trialEndsAt: new Date(Date.now() + TRIAL_DAYS * 86_400_000) } + : { plan: "hosted_small", planStatus: "free" }; + // Two optional answers from the sign-up screen. The setting seeds the staff groups the room + // starts with (renamed or removed freely under Settings); the state sets the time zone counts + // and month-end are read in. Neither is required, and "other"/blank leaves the old defaults. + const seed = GROUP_SEEDS[String(b.setting || "")] || {}; + const timezone = STATE_ZONES[String(b.state || "").toUpperCase()]; + const f = await tx.facility.create({ data: { name: facility, coordinator: `${first} ${last}`, ...planData, ...seed, ...(timezone ? { timezone } : {}) } }); + return tx.user.create({ data: { facilityId: f.id, email, passwordHash: await bcrypt.hash(password, 12), first, last, title: "Uniform Coordinator", role: "ADMIN" } }); + }); + await setSessionCookie(u.id, u.passwordHash); + recordAuthEvent({ facilityId: u.facilityId, userId: u.id, userName: `${first} ${last}`.trim() || email }, "auth:signup", clientIp(req.headers)); + alertNewSignup({ id: u.facilityId, name: facility }); // the facility's name only — never the person + + const em = welcomeEmail(first, facility); + const mailed = await sendTo(email, em.subject, em.text, em.html); + // A room on a trial also gets the trial letter: what the 30 days include, when they end, and + // that no card was taken. Not awaited — the welcome above is the one sign-up waits for. + void (async () => { + const f = await prisma.facility.findUnique({ where: { id: u.facilityId }, select: { planStatus: true, trialEndsAt: true } }); + if (f?.planStatus === "trial" && f.trialEndsAt) await sendBillingMail(u.facilityId, (ctx) => templates.trialStarted(ctx, { first, endsAt: f.trialEndsAt })); + })(); + if (!mailed && transactionalConfigured()) console.error("[signup] welcome mail could not be sent for user", u.id); + // `mailed` is false when no SMTP is configured at all, which is a different thing from a bad + // address — the screen says so rather than pretending the address has been proven. + return NextResponse.json({ ok: true, email, mailed, mail: transactionalConfigured() }); +} diff --git a/app/api/backup/route.ts b/app/api/backup/route.ts new file mode 100644 index 0000000..ba7409b --- /dev/null +++ b/app/api/backup/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { currentUser } from "@/lib/session"; +import { prisma } from "@/lib/db"; +import { exportBackup } from "@/lib/ops"; +import { facilityToday } from "@/lib/compute"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const user = await currentUser(); + if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 }); + if (user.role !== "ADMIN") return NextResponse.json({ error: "Admin only" }, { status: 403 }); + const data = await exportBackup(user); + // The date on the filename is the day where the linen room stands, not where the box is. It has + // to agree with the lastBackup stamp exportBackup writes against the same facility, or a room + // taking a backup at eight in the morning ends up with a file named for yesterday sitting beside + // a settings screen that says it was taken today. + const fac = await prisma.facility.findUniqueOrThrow({ where: { id: user.facilityId }, select: { timezone: true } }); + return new NextResponse(JSON.stringify(data, null, 1), { + headers: { + "content-type": "application/json; charset=utf-8", + "content-disposition": `attachment; filename="threadcount-backup-${facilityToday(fac.timezone)}.json"`, + }, + }); +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..370f22e --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; + +export const dynamic = "force-dynamic"; + +/* Is this server actually able to do its job? + * + * The deploy probes /app, which proves the process is serving HTML — but /app renders a redirect to + * the sign-in page whether or not Prisma can reach the database, so the one failure that takes the + * whole product down is exactly the one that probe cannot see. This asks the database a question + * instead, and answers 503 when it cannot. + * + * No auth and no cache on purpose: it is watched continuously by an uptime monitor with no account, + * and it must never answer from a cached success. It is listed in proxy.ts's `publicApi` for the + * same reason. Nothing about the facility, the schema or the error is returned — a monitor needs a + * status code, and an unauthenticated caller is owed nothing more. + */ +export async function GET() { + try { + await prisma.$queryRaw`SELECT 1`; + return NextResponse.json({ ok: true }, { headers: { "cache-control": "no-store" } }); + } catch (e) { + console.error("[health] database unreachable:", (e as Error).message); + return NextResponse.json({ ok: false }, { status: 503, headers: { "cache-control": "no-store" } }); + } +} diff --git a/app/api/logo/route.ts b/app/api/logo/route.ts new file mode 100644 index 0000000..87caaf0 --- /dev/null +++ b/app/api/logo/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { currentUser } from "@/lib/session"; +import { prisma } from "@/lib/db"; + +export const dynamic = "force-dynamic"; + +/** Serves the signed-in user's facility logo (stored as a data URL). */ +export async function GET() { + const user = await currentUser(); + if (!user) return new NextResponse(null, { status: 401 }); + const fac = await prisma.facility.findUnique({ where: { id: user.facilityId }, select: { logoData: true } }); + const m = /^data:(image\/(?:png|jpeg|jpg|gif|webp));base64,([A-Za-z0-9+/=]+)$/.exec(fac?.logoData || ""); + if (!m) return new NextResponse(null, { status: 404 }); + return new NextResponse(Buffer.from(m[2], "base64"), { headers: { "content-type": m[1], "cache-control": "private, no-cache", "x-content-type-options": "nosniff", "content-security-policy": "sandbox" } }); +} diff --git a/app/api/lookup/route.ts b/app/api/lookup/route.ts new file mode 100644 index 0000000..680fea1 --- /dev/null +++ b/app/api/lookup/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { currentUser } from "@/lib/session"; +import { allow } from "@/lib/ratelimit"; +import { gtinInfo } from "@/lib/compute"; + +export const dynamic = "force-dynamic"; + +export type LookupResult = { + code: string; + gtin: ReturnType; + enabled: boolean; // is public lookup turned on for this facility + found: boolean; + name?: string; + brand?: string; + category?: string; + source?: string; + note?: string; // why there's no result, in plain words +}; + +const TIMEOUT_MS = 4500; +const cache = new Map }>(); +const CACHE_MS = 12 * 60 * 60 * 1000; + +async function getJson(url: string): Promise { + const ac = new AbortController(); + const t = setTimeout(() => ac.abort(), TIMEOUT_MS); + try { + const r = await fetch(url, { signal: ac.signal, headers: { accept: "application/json", "user-agent": "ThreadCount/1.0 (uniform stock management)" }, cache: "no-store" }); + if (!r.ok) return null; + return await r.json(); + } catch { return null; } finally { clearTimeout(t); } +} + +/** UPCitemdb's keyless trial tier — small daily quota per server IP, so misses are expected. */ +async function upcItemDb(gtin: string) { + const j = await getJson(`https://api.upcitemdb.com/prod/trial/lookup?upc=${encodeURIComponent(gtin)}`) as { items?: { title?: string; brand?: string; category?: string }[] } | null; + const it = j?.items?.[0]; + if (!it?.title) return null; + return { name: String(it.title).slice(0, 160), brand: String(it.brand || "").slice(0, 80), category: String(it.category || "").slice(0, 80), source: "UPCitemdb" }; +} + +/** Open Products Facts — the non-food sibling of Open Food Facts; open data, no key. */ +async function openProductsFacts(gtin: string) { + const j = await getJson(`https://world.openproductsfacts.org/api/v2/product/${encodeURIComponent(gtin)}.json?fields=product_name,brands,categories`) as { status?: number; product?: { product_name?: string; brands?: string; categories?: string } } | null; + const pr = j?.product; + if (j?.status !== 1 || !pr?.product_name) return null; + return { name: String(pr.product_name).slice(0, 160), brand: String(pr.brands || "").slice(0, 80), category: String(pr.categories || "").slice(0, 80), source: "Open Products Facts" }; +} + +export async function GET(req: NextRequest) { + const user = await currentUser(); + if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 }); + if (user.role !== "ADMIN") return NextResponse.json({ error: "Admin only" }, { status: 403 }); + + const gtin = gtinInfo(req.nextUrl.searchParams.get("code") || ""); + const base = { code: gtin.code, gtin, found: false } as LookupResult; + if (!gtin.code) return NextResponse.json({ ...base, enabled: false, note: "No barcode given." }); + + const fac = await prisma.facility.findUnique({ where: { id: user.facilityId }, select: { barcodeLookup: true } }); + const enabled = !!fac?.barcodeLookup; + if (!enabled) return NextResponse.json({ ...base, enabled: false, note: "Product lookup is off. Turn it on in Settings → Data if you want ThreadCount to ask a public barcode database for a name." }); + // Only real retail GTINs are worth sending anywhere; a mis-read or an in-house code never matches. + if (!gtin.valid || !["EAN-13", "UPC-A", "EAN-8", "GTIN-14"].includes(gtin.kind)) { + return NextResponse.json({ ...base, enabled, note: gtin.kind ? "The check digit doesn't match, so this wasn't looked up — scan it again." : "Not a standard retail barcode, so there's nothing to look up. Type the details in." }); + } + if (!allow("lookup:" + user.facilityId, 120, 60 * 60 * 1000)) return NextResponse.json({ ...base, enabled, note: "Too many lookups this hour — type the details in for now." }, { status: 429 }); + + const hit = cache.get(gtin.digits); + if (hit && Date.now() - hit.at < CACHE_MS) return NextResponse.json({ ...base, enabled, ...hit.v }); + + let found = await upcItemDb(gtin.digits); + if (!found) found = await openProductsFacts(gtin.digits); + const v = found + ? { found: true, ...found } + : { found: false, note: "No public listing for this barcode — normal for workwear and hospital uniforms. Type the details in once and the barcode stays bound." }; + cache.set(gtin.digits, { at: Date.now(), v }); + if (cache.size > 500) for (const k of [...cache.keys()].slice(0, 100)) cache.delete(k); + return NextResponse.json({ ...base, enabled, ...v }); +} diff --git a/app/api/mutate/route.ts b/app/api/mutate/route.ts new file mode 100644 index 0000000..1ae07a6 --- /dev/null +++ b/app/api/mutate/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { currentUser } from "@/lib/session"; +import { OpError, bumpRev, demoGuard, restoreBackup, runOp } from "@/lib/ops"; +import { sameOriginJson } from "@/lib/csrf"; +import { allow, clientIp } from "@/lib/ratelimit"; +import { recordAudit } from "@/lib/audit"; +import { report } from "@/lib/glitchtip"; + +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + const user = await currentUser(); + if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 }); + const csrf = sameOriginJson(req); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 }); + if (parseInt(req.headers.get("content-length") || "0", 10) > 60 * 1024 * 1024) return NextResponse.json({ error: "Request too large" }, { status: 413 }); + let body: { op?: string; payload?: unknown }; + try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad JSON" }, { status: 400 }); } + const op = String(body.op || ""); + if (!allow("mutate:" + user.id, 600, 60 * 1000)) return NextResponse.json({ error: "Slow down — too many changes in a minute." }, { status: 429 }); + if (op === "photo.put" && !allow("photo:" + user.facilityId, 120, 60 * 60 * 1000)) return NextResponse.json({ error: "Photo limit reached for this hour." }, { status: 429 }); + if ((op === "backup.restore" || op === "import.rows") && !allow("bulk:" + user.id, 20, 10 * 60 * 1000)) return NextResponse.json({ error: "Too many imports — wait a few minutes." }, { status: 429 }); + try { + if (op === "backup.restore") demoGuard(user, op); + const result = op === "backup.restore" ? await restoreBackup(user, body.payload) : await runOp(user, op, body.payload); + // Only after it actually succeeded, and only from here: every one of the 57 ops passes through + // this one function, so the trail can't be forgotten in a new case branch later. + recordAudit(user, op, body.payload, clientIp(req.headers)); + // Handed back so the screen that made this change does not bounce again when it next polls. + const rev = await bumpRev(user.facilityId); + return NextResponse.json({ ok: true, result, rev }); + } catch (e) { + if (e instanceof OpError) return NextResponse.json({ error: e.message }, { status: e.status }); + // Reported from here, not from instrumentation.ts: onRequestError only sees what Next itself + // catches, and an exception caught in this handler never reaches it. Every write in the product + // comes through this line, so without it the whole write path fails invisibly. + report({ error: e, where: "server", url: "/api/mutate", tags: { op } }); + console.error(`[mutate ${op}]`, e); + return NextResponse.json({ error: "Something went wrong — nothing was saved." }, { status: 500 }); + } +} diff --git a/app/api/photo/[id]/route.ts b/app/api/photo/[id]/route.ts new file mode 100644 index 0000000..ea7f060 --- /dev/null +++ b/app/api/photo/[id]/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { currentUser } from "@/lib/session"; +import { parseDataUrl, readPhoto } from "@/lib/photostore"; + +export const dynamic = "force-dynamic"; + +/* Serves a stored capture or signature to a signed-in user of the same facility. + * + * Images live on disk now; rows written before that move still carry a base64 data URL, so both + * are handled and old records keep working without a flag day. */ +export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const user = await currentUser(); + if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 }); + const { id } = await ctx.params; + + // Scoped by facility in the query: a photo id from another room is simply not found. + const ph = await prisma.photo.findFirst({ + where: { id, facilityId: user.facilityId }, + select: { data: true, path: true, mime: true }, + }); + if (!ph) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + let mime = ph.mime; + let bytes: Buffer | null = null; + + if (ph.path) { + bytes = await readPhoto(ph.path); + } else if (ph.data) { + const parsed = parseDataUrl(ph.data); + if (parsed) { mime = parsed.mime; bytes = parsed.bytes; } + } + + if (!bytes) return NextResponse.json({ error: "Not found" }, { status: 404 }); + if (!/^image\/(jpeg|png)$/.test(mime)) return NextResponse.json({ error: "Bad photo" }, { status: 500 }); + + return new NextResponse(new Uint8Array(bytes), { + headers: { + "content-type": mime, + "cache-control": "private, max-age=3600", + "content-disposition": "inline", + "x-content-type-options": "nosniff", + "content-security-policy": "sandbox", + }, + }); +} diff --git a/app/api/requests/route.ts b/app/api/requests/route.ts new file mode 100644 index 0000000..9898440 --- /dev/null +++ b/app/api/requests/route.ts @@ -0,0 +1,220 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { currentUser } from "@/lib/session"; +import { bagLines, linesSummary, reqLines } from "@/lib/staffdata"; +import { decisionSummary, garmentCount } from "@/lib/staffreq"; + +export const dynamic = "force-dynamic"; + +/* The linen room's view of staff requests. + * + * Its own endpoint rather than part of the snapshot, for the same reason the audit trail is: this + * grows without limit, and putting it in the snapshot would make every page in the app heavier + * forever to serve one screen. + */ +export async function GET(req: NextRequest) { + const user = await currentUser(); + if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 }); + + /* One person's requests, or the whole facility's. + * + * `?staff=` is how a staff record asks for its own order-form history. Without it that screen + * pulled the facility's last 400 requests — every line, message and event on each — and kept the + * handful belonging to one person, which is a large answer to a small question on a busy + * register. Worse, that person's older requests fell off the end of the 400 and simply were not + * on their record any more. Scoped, the ceiling is per person, and either way it is reported + * back so a screen can say it has been reached rather than ending a history without a word. + */ + const staffId = (req.nextUrl.searchParams.get("staff") || "").trim().slice(0, 64); + const requestLimit = staffId ? 200 : 400; + + // One more row than is returned, so "there are older ones than these" is something we know + // rather than something guessed from a full page. + const found = await prisma.request.findMany({ + where: { facilityId: user.facilityId, ...(staffId ? { subjectId: staffId } : {}) }, + orderBy: { createdAt: "desc" }, + take: requestLimit + 1, + include: { + lines: { include: { item: { select: { item: true, gender: true, sizes: true } } }, orderBy: { sort: "asc" } }, + subject: { select: { first: true, last: true, num: true, dept: true } }, + messages: { orderBy: { createdAt: "asc" }, select: { id: true, fromStaff: true, authorName: true, body: true, createdAt: true } }, + events: { orderBy: { at: "asc" }, select: { id: true, label: true, meta: true, actorName: true, at: true } }, + }, + }); + const requests = found.slice(0, requestLimit); + const moreRequests = found.length > requestLimit; + + const mapped = requests.map((r) => { + /* Every line, and separately the ones that are actually a pick. + * + * The linen room needs both. `lines` is the record — a declined fleece still belongs on the + * order the wearer will read — while `bag` is the work: what to take off the shelf, put in + * the bag and hand across the counter. Picking from `lines` would put a garment the manager + * refused into somebody's hands, so the two are never the same field. */ + const lines = reqLines(r.lines); + const bag = bagLines(lines); + return { + id: r.id, code: r.code, status: r.status, + staffId: r.subjectId, + staffName: `${r.subject.first} ${r.subject.last}`.trim(), + staffNum: r.subject.num, ward: r.subject.dept, + lines, bag, + summary: linesSummary(lines), garments: garmentCount(bag), lineCount: lines.length, + decision: decisionSummary(lines), + reason: r.reason, note: r.note, + managerName: r.managerName, + /* Who the approver is, not just how their name is spelled. A manager may now approve a + * request raised for herself, and the only thing that can show that happened is this id + * beside the subject's — the name on its own would have any screen comparing two spellings + * of the same person, which is precisely how a self-approval goes unnoticed. */ + managerId: r.managerId, + declineReason: r.declineReason, + route: r.route, collectCode: r.collectCode, holdUntil: r.holdUntil, + signerName: r.signerName, signerRole: r.signerRole, + signedAt: r.signedAt?.toISOString() ?? null, + claimedAt: r.claimedAt?.toISOString() ?? null, + /* Who raised it, and which person on the register that is. + * + * The name alone is not enough for the queue screen: it builds the list of people a stranded + * request can be handed to, and the one name certain to be refused is the person who raised + * it — a manager asking for one of her own reports' garments is exactly why the request + * escalated with nobody to approve it. Told only her name, the screen would have to match + * her by spelling against a ward where two people share one, which is how the wrong person + * drops out of a dropdown. + * + * Only the staff column, because only it can ever name somebody who could approve anything. + * A raise at the counter is stamped with the coordinator's own account instead, and a + * coordinator is not on the ward register at all; a wearer raising for herself is stamped + * with neither. Both arrive here as null, which is right — neither is a name this queue + * could offer. */ + raisedById: r.raisedByStaffId, + raisedByName: r.raisedByName, + createdAt: r.createdAt.toISOString(), + decidedAt: r.decidedAt?.toISOString() ?? null, + messages: r.messages.map((m) => ({ id: m.id, fromStaff: m.fromStaff, authorName: m.authorName, body: m.body, at: m.createdAt.toISOString() })), + events: r.events.map((e) => ({ id: e.id, label: e.label, meta: e.meta, actorName: e.actorName, at: e.at.toISOString() })), + }; + }); + + /* Everything below is the linen room's queue screen — open disputes, the kit check, the + * waitlist, damage nobody has handed back. A staff record asks for one person's order forms and + * reads none of it, so a scoped ask stops here instead of running four more facility-wide + * queries whose answers are thrown away. Those keys are absent from a scoped reply rather than + * empty: an empty list would read as "there are none", which nobody asked and nobody knows. */ + if (staffId) return NextResponse.json({ requests: mapped, requestLimit, moreRequests }); + + const [disputes, cycle, waiting, damage] = await Promise.all([ + prisma.recordDispute.findMany({ + where: { facilityId: user.facilityId, resolvedAt: null }, + orderBy: { createdAt: "desc" }, + take: 100, + include: { staff: { select: { first: true, last: true, num: true, dept: true } } }, + }), + prisma.kitCheck.findFirst({ + where: { facilityId: user.facilityId, closedAt: null }, + orderBy: { openedAt: "desc" }, + select: { id: true, dueBy: true, openedAt: true, openedBy: true, _count: { select: { answers: true } } }, + }), + prisma.waitlistEntry.findMany({ + where: { facilityId: user.facilityId, leftAt: null, acceptedAt: null }, + orderBy: { createdAt: "asc" }, + include: { + staff: { select: { first: true, last: true, num: true, dept: true } }, + item: { select: { item: true, sizes: true } }, + }, + }), + // Damage reports the counter has not yet taken the garment back for. Reporting damage and + // asking for a replacement are two separate acts in the staff app, so a report can arrive with + // no request behind it — and until this list existed nothing in the product ever showed one to + // anybody, which made the Damage screen's promise ("it comes off your record when you hand it + // in at the counter") a promise no screen could keep. + prisma.damageReport.findMany({ + where: { facilityId: user.facilityId, handedInAt: null }, + orderBy: { createdAt: "desc" }, + take: 100, + include: { + staff: { select: { first: true, last: true, num: true, dept: true } }, + issue: { select: { sizeIndex: true, item: { select: { item: true, sizes: true } } } }, + }, + }), + ]); + + /* What the open kit check has actually turned up. + * + * The cycle used to be reported to the linen room as a bare count of answers, which is the one + * thing about it that doesn't matter: nobody opens a kit check to find out how many people + * replied. The answers are the point — every one where somebody could not account for what the + * record says they hold — and until this query existed no screen, export or report in the + * product read them, so the whole cycle collected evidence into a table nothing looked at. + * + * Only the shortfalls, and only for the cycle still open. An answer that matches the record is + * the record agreeing with itself; a closed cycle is history and belongs with the rest of it. + */ + const answers = cycle + ? await prisma.kitCheckAnswer.findMany({ + where: { kitCheckId: cycle.id, confirmed: { lt: prisma.kitCheckAnswer.fields.onRecord } }, + orderBy: { answeredAt: "desc" }, + take: 400, + include: { + staff: { select: { id: true, first: true, last: true, num: true, dept: true } }, + item: { select: { item: true, sizes: true } }, + }, + }) + : []; + + // DamageReport.requestId is a plain column rather than a relation, so the replacement's code is + // looked up here. It is what the linen room actually needs: "torn, and she has asked for R-0042" + // is a different job from "torn, and she has not". + const replacementCodes = new Map(); + const replacementIds = damage.map((d) => d.requestId).filter((x): x is string => !!x); + if (replacementIds.length) { + const reps = await prisma.request.findMany({ + where: { facilityId: user.facilityId, id: { in: replacementIds } }, + select: { id: true, code: true }, + }); + for (const r of reps) replacementCodes.set(r.id, r.code); + } + + return NextResponse.json({ + requests: mapped, requestLimit, moreRequests, + disputes: disputes.map((d) => ({ + id: d.id, body: d.body, + staffName: `${d.staff.first} ${d.staff.last}`.trim(), + staffNum: d.staff.num, ward: d.staff.dept, + at: d.createdAt.toISOString(), + })), + cycle: cycle && { + id: cycle.id, dueBy: cycle.dueBy, openedBy: cycle.openedBy, + openedAt: cycle.openedAt.toISOString(), answers: cycle._count.answers, + }, + shortfalls: answers.map((a) => ({ + id: a.id, + staffId: a.staff.id, + staffName: `${a.staff.first} ${a.staff.last}`.trim(), + staffNum: a.staff.num, ward: a.staff.dept, + item: a.item.item, size: String(a.item.sizes[a.sizeIndex] ?? a.sizeIndex), + onRecord: a.onRecord, confirmed: a.confirmed, short: a.onRecord - a.confirmed, + at: a.answeredAt.toISOString(), + })), + waiting: waiting.map((w) => ({ + id: w.id, + staffName: `${w.staff.first} ${w.staff.last}`.trim(), + staffNum: w.staff.num, ward: w.staff.dept, + item: w.item.item, size: String(w.item.sizes[w.sizeIndex] ?? w.sizeIndex), + since: w.createdAt.toISOString(), + offeredAt: w.offeredAt?.toISOString() ?? null, + })), + damage: damage.map((d) => ({ + id: d.id, kind: d.kind, note: d.note, photoId: d.photoId, + staffId: d.staffId, + staffName: `${d.staff.first} ${d.staff.last}`.trim(), + staffNum: d.staff.num, ward: d.staff.dept, + // The garment comes off the Issue the report was raised against. That issue can be deleted + // (a wipe, a correction) and the column is SetNull, so an older report may name no garment. + item: d.issue ? d.issue.item.item : "", + size: d.issue ? String(d.issue.item.sizes[d.issue.sizeIndex] ?? d.issue.sizeIndex) : "", + requestCode: d.requestId ? replacementCodes.get(d.requestId) ?? "" : "", + at: d.createdAt.toISOString(), + })), + }); +} diff --git a/app/api/rev/route.ts b/app/api/rev/route.ts new file mode 100644 index 0000000..95a3819 --- /dev/null +++ b/app/api/rev/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { currentUser } from "@/lib/session"; +import { currentStaff } from "@/lib/staffsession"; + +export const dynamic = "force-dynamic"; + +/* "Has anything changed?", answered in one integer. + * + * Every screen in the product already knows how to reload itself — a mutation ends in + * router.refresh(). What it could not know was that somebody ELSE had changed something, so a + * phone left open on a ward showed whatever the catalogue looked like when it was opened, and a + * coordinator adding a garment at the desk had to tell the counter to pull down to refresh. + * + * The obvious fix — poll the snapshot and diff it — is the expensive one: that is the facility's + * catalogue, staff register, stock and history, re-read on a timer by every open device to learn, + * almost always, that nothing happened. This returns the counter that the three mutating routes + * bump, so the cost of asking is a primary-key lookup, and the cost of the real reload is paid only + * when the number has actually moved. + * + * Both session kinds answer here. A coordinator at the desk and a wearer on a ward are watching the + * same facility, and there is nothing in a bare revision number to keep apart — it says that + * something changed, never what. Anyone with no session at all gets 401 rather than a number, + * because even "this facility is busy" is not ours to hand out. + */ +export async function GET(_req: NextRequest) { + const user = await currentUser(); + const facilityId = user?.facilityId || (await currentStaff())?.facilityId; + if (!facilityId) return NextResponse.json({ error: "Not signed in" }, { status: 401 }); + + const f = await prisma.facility.findUnique({ where: { id: facilityId }, select: { rev: true } }); + if (!f) return NextResponse.json({ error: "Not signed in" }, { status: 401 }); + + // Never from a cache: a stale revision is indistinguishable from nothing having happened, which + // is the one wrong answer this endpoint can give. + return NextResponse.json({ rev: f.rev }, { headers: { "cache-control": "no-store" } }); +} diff --git a/app/api/staff/activate/route.ts b/app/api/staff/activate/route.ts new file mode 100644 index 0000000..87adbe5 --- /dev/null +++ b/app/api/staff/activate/route.ts @@ -0,0 +1,132 @@ +import { NextRequest, NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import { prisma } from "@/lib/db"; +import { sameOriginJson } from "@/lib/csrf"; +import { allow, clientIp } from "@/lib/ratelimit"; +import { normaliseCode, setStaffCookie } from "@/lib/staffsession"; +import { verifyTurnstile } from "@/lib/turnstile"; +import { recordAuthEvent } from "@/lib/audit"; +import { bumpRev } from "@/lib/ops"; +import { SLIP_DAYS, facilityToday, slipLive } from "@/lib/compute"; + +export const dynamic = "force-dynamic"; + +const MIN_PW = 8; + +/* Claiming your own record with the code the linen room printed for you. + * + * The code alone identifies the person, because it is presented before we know anything about them + * — there is no facility to scope it to and no email to look up yet. That is why it is globally + * unique, why it is 58 bits wide, and why this route is throttled to the point where working + * through the space is not a strategy. + * + * It is spent in the same update that finds it, so two people racing the same slip can't both + * claim the record; the loser gets the ordinary "code isn't right" message. + * + * It also goes stale on its own after fourteen days, because the far more likely way a slip is + * misused is not a guessed code but a printed one nobody ever collected. + */ +export async function POST(req: NextRequest) { + const csrf = sameOriginJson(req); + if (csrf) return NextResponse.json({ error: csrf }, { status: 403 }); + + const ip = clientIp(req.headers); + if (!allow("staff-activate:" + ip, 200, 15 * 60 * 1000)) { + return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 }); + } + + let body: { code?: unknown; email?: unknown; password?: unknown; cfToken?: unknown; agreed?: unknown }; + try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); } + + const code = normaliseCode(String(body.code ?? "")); + const email = String(body.email ?? "").trim().toLowerCase().slice(0, 160); + const password = String(body.password ?? "").slice(0, 200); + + if (!code) return NextResponse.json({ error: "That code isn't right. It's twelve characters, in three groups." }, { status: 400 }); + if (!/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(email)) return NextResponse.json({ error: "Enter an email address you can get to." }, { status: 400 }); + if (password.length < MIN_PW) return NextResponse.json({ error: `Use at least ${MIN_PW} characters for your password.` }, { status: 400 }); + // The agreement is collected where the account is created. The screen's tick is what sets it, + // and the door checks it too so a client that skips the box gets the same answer. + if (body.agreed !== true) return NextResponse.json({ error: "Tick the box to agree to the terms of use and privacy policy." }, { status: 400 }); + + // Checked before the code is looked up, so a bot working through the code space is stopped by + // Cloudflare rather than by the per-IP throttle alone. + const cfErr = await verifyTurnstile(body.cfToken, ip); + if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 }); + + const staff = await prisma.staff.findUnique({ + where: { activateCode: code }, + select: { id: true, facilityId: true, first: true, last: true, inactive: true, activateCodeAt: true, account: { select: { id: true } }, facility: { select: { timezone: true } } }, + }); + // One message for every way this can fail, so the response can't be used to tell a real code from + // a spent one. + const nope = () => NextResponse.json({ error: "That code isn't right, or it has already been used. Ask the linen room for a new one." }, { status: 400 }); + if (!staff || staff.inactive || staff.account) return nope(); + + /* An old slip is refused whether or not anyone ever claimed it. It is a bearer token on paper: + * whoever picks one out of a folder months later can bind their own email and password to this + * person's record and from then on be them — their issues, their requests, their signature on the + * ward round, and their approvals queue if they manage anyone. Fourteen days is long enough for + * someone on leave to come back to it and short enough that a forgotten one is dead by the time + * it turns up. + * + * An unstamped code counts as stale: its age is unknown, so it has to be assumed old. This leans + * on staff.selfCode in lib/ops.ts stamping activateCodeAt as it prints — if that stamp ever stops + * being written, every new slip is dead on arrival. + * + * This says plainly that the slip has expired rather than joining the deliberately vague message + * above. Landing here means the code was right, and a code is 58 bits behind a throttle and a + * Turnstile — so anyone who gets this far is holding a real slip and needs to be told that a + * reprint, not a retype, is the fix. + * + * The age is asked of slipLive() in lib/compute, the same test the staff register and the requests + * queue use to say whether a slip is still worth chasing, counted in whole days on the facility's + * own calendar. The day a coordinator's screen calls a slip expired is therefore the day this + * refuses it — never a few hours later, with a nurse who was told it was dead finding it still + * works, or one who was told it was fine being turned away. */ + const tz = staff.facility.timezone; + if (!slipLive(staff.activateCodeAt, facilityToday(tz), tz)) { + return NextResponse.json( + { error: `That code was printed ${SLIP_DAYS} or more days ago, so it has expired. Ask the linen room to print you a new slip.` }, + { status: 400 }, + ); + } + + // The email has to be free across staff accounts. Coordinator accounts live in a different table + // and a person may legitimately be both — a linen-room supervisor who also wears the uniform. + const taken = await prisma.staffAccount.findUnique({ where: { email }, select: { id: true } }); + if (taken) return NextResponse.json({ error: "That email is already on an account here. Sign in instead." }, { status: 400 }); + + const passwordHash = await bcrypt.hash(password, 12); + + // Spend the code first, conditionally. If it has gone in the meantime, nothing was created. + // The stamp goes with the code, so a spent row can't be read as a slip still waiting out there. + const spent = await prisma.staff.updateMany({ where: { id: staff.id, activateCode: code }, data: { activateCode: null, activateCodeAt: null } }); + if (spent.count !== 1) return nope(); + + let account; + try { + account = await prisma.staffAccount.create({ + data: { facilityId: staff.facilityId, staffId: staff.id, email, passwordHash }, + select: { id: true, passwordHash: true }, + }); + } catch { + // The code is gone but the account didn't happen — put the code back rather than stranding + // someone with a dead slip. The original print date goes back with it: a failed attempt is not + // a reprint and must not restart the fourteen days. + await prisma.staff.update({ where: { id: staff.id }, data: { activateCode: code, activateCodeAt: staff.activateCodeAt } }).catch(() => {}); + return NextResponse.json({ error: "That didn't work — try again." }, { status: 500 }); + } + + await setStaffCookie(account.id, account.passwordHash); + recordAuthEvent( + { facilityId: staff.facilityId, userId: staff.id, userName: `${staff.first} ${staff.last}`.trim() || email }, + "staff:activate", ip, + ); + // The fourth door that changes a facility's data, and the only one outside the three mutate + // routes. Without this the coordinator standing over the nurse while she activates keeps seeing + // "code outstanding" until some unrelated edit moves the revision, and reissues a code that + // can't be reissued. + await bumpRev(staff.facilityId); + return NextResponse.json({ ok: true, name: `${staff.first} ${staff.last}` }); +} diff --git a/app/api/staff/decide/route.ts b/app/api/staff/decide/route.ts new file mode 100644 index 0000000..1309061 --- /dev/null +++ b/app/api/staff/decide/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sameOriginJson } from "@/lib/csrf"; +import { allow, clientIp } from "@/lib/ratelimit"; +import { readApprovalToken } from "@/lib/approvallink"; +import { StaffOpError, decideRequest } from "@/lib/staffops"; +import { recordFor } from "@/lib/audit"; +import { bumpRev } from "@/lib/ops"; + +export const dynamic = "force-dynamic"; + +/* Deciding a request from the emailed link, without signing in. + * + * This is a POST and only a POST. The link in the email is a GET that renders /my/approve, and the + * decision is made from that page — because corporate mail scanners and link-preview crawlers + * fetch every URL in every message, and a GET that approved a uniform request would be approved by + * the mail gateway before the manager ever saw it. + * + * The decision itself is decideRequest()'s, not this route's. A request now carries a line per + * garment, and settling it means settling every line and then rolling the request up from them; + * an approval made here that moved only the request would leave every line `awaiting`, so the + * linen room's bag would come out empty and the wearer's order would show no decision at all. + * There is no room on this page for a garment-by-garment answer — there is no signed-in person to + * check one against — so it takes the whole-request shorthand, `approveAll`, which is the reason + * that argument exists. + * + * Single use falls out of the state machine rather than a table of spent tokens: decideRequest's + * update is conditional on the request still being `awaiting`, so the approve link and the decline + * link in the same email both stop working the moment either is used. + */ +export async function POST(req: NextRequest) { + const csrf = sameOriginJson(req); + if (csrf) return NextResponse.json({ error: csrf }, { status: 403 }); + + const ip = clientIp(req.headers); + if (!allow("staff-decide:" + ip, 200, 15 * 60 * 1000)) { + return NextResponse.json({ error: "Too many attempts — try again in a few minutes." }, { status: 429 }); + } + + let body: { token?: unknown; action?: unknown; reason?: unknown }; + try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); } + + const claim = readApprovalToken(String(body.token ?? "")); + if (!claim) return NextResponse.json({ error: "That link has expired. Open the app and use the approvals queue." }, { status: 400 }); + + const action = String(body.action ?? ""); + if (action !== "approve" && action !== "decline") return NextResponse.json({ error: "Unknown action" }, { status: 400 }); + + let done: Awaited>; + try { + done = await decideRequest({ + requestId: claim.rid, + managerId: claim.mid, + approveAll: action === "approve", + reason: body.reason, + }); + } catch (e) { + if (!(e instanceof StaffOpError)) throw e; + /* The refusals are worded for somebody standing in their mail client, not in the app. + * + * A 403 here is decideRequest re-reading the register and finding the manager off it, or the + * wearer off it — the check that makes a fortnight-old token in a mailbox that has since been + * closed or handed on safe. Neither the sacked manager nor a stranger reading their mail is + * told which of the two it was; "ask the linen room" is where that conversation belongs. + * + * A 409 is the link already spent, and keeps the `already` flag the page reads to show the + * decision that was made rather than an error. */ + if (e.status === 403) return NextResponse.json({ error: "That link is no longer valid — ask the linen room." }, { status: 403 }); + if (e.status === 404) return NextResponse.json({ error: "That request is no longer there." }, { status: 404 }); + if (e.status === 409) return NextResponse.json({ error: "That request has already been decided.", already: true }, { status: 409 }); + return NextResponse.json({ error: e.message }, { status: e.status }); + } + + // Filed under the manager's own Staff id, exactly as the in-app approval is, so the log names + // the same person either way; the op says which door the decision came through, because "an + // email link, from an address we can't see" is part of the answer to who authorised this. + recordFor( + { facilityId: done.facilityId, userId: claim.mid, userName: done.managerName }, + done.status === "accepted" ? "staff:request.approve.email" : "staff:request.decline.email", + { id: claim.rid }, ip, + ); + + // The third door into the facility's data, so the third place the revision has to move: a manager + // approving from their mail is exactly the change the linen room's screen is waiting to see. + await bumpRev(done.facilityId); + + return NextResponse.json({ ok: true, status: done.status, notified: done.notified }); +} diff --git a/app/api/staff/login/route.ts b/app/api/staff/login/route.ts new file mode 100644 index 0000000..1419734 --- /dev/null +++ b/app/api/staff/login/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sameOriginJson } from "@/lib/csrf"; +import { clientIp } from "@/lib/ratelimit"; +import { signInStaff, staffThrottled } from "@/lib/staffauth"; +import { verifyTurnstile } from "@/lib/turnstile"; + +export const dynamic = "force-dynamic"; + +/* The staff app's own door: the printed slip, the Play app's welcome, and /my/signin. + * + * The Log in box on the website reaches the same register through lib/staffauth.ts, so what counts + * as a match, what a deactivated record is told, and what lands in the audit trail are decided in + * one place for both. This route is the HTTP shape of it: the origin check, the security check, and + * the throttle asked in that order. */ +export async function POST(req: NextRequest) { + const csrf = sameOriginJson(req); + if (csrf) return NextResponse.json({ error: csrf }, { status: 403 }); + + let body: { email?: unknown; password?: unknown; cfToken?: unknown }; + try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); } + const email = String(body.email ?? "").trim().toLowerCase().slice(0, 160); + const password = String(body.password ?? "").slice(0, 200); + + const ip = clientIp(req.headers); + // Asked before the security check, because a Turnstile token is good for one use and somebody who + // is already throttled should not spend theirs to be told so. + if (staffThrottled(email, ip)) { + return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 }); + } + if (!email || !password) return NextResponse.json({ error: "Enter your email and password." }, { status: 400 }); + + // The same bot check the coordinator door has. A ward account opens one person's uniform record, + // and a manager's opens the approvals queue, so leaving this to the in-memory throttles alone + // meant a list of hospital addresses and enough patience was the whole attack. + const cfErr = await verifyTurnstile(body.cfToken, ip); + if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 }); + + // `true`: this is the register's own door, so an address with no account here is a plain wrong + // answer and is counted as one. + const r = await signInStaff(email, password, ip, true); + if (r.kind === "ok") return NextResponse.json({ ok: true, name: r.name }); + if (r.kind === "error") return NextResponse.json({ error: r.error }, { status: r.status }); + // Unreachable at this door: `none` is only returned when the caller asked not to be counted. + return NextResponse.json({ error: "Email or password doesn’t match." }, { status: 401 }); +} diff --git a/app/api/staff/logout/route.ts b/app/api/staff/logout/route.ts new file mode 100644 index 0000000..1516be5 --- /dev/null +++ b/app/api/staff/logout/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sameOriginJson } from "@/lib/csrf"; +import { clearStaffCookie, currentStaff } from "@/lib/staffsession"; +import { clientIp } from "@/lib/ratelimit"; +import { recordAuthEvent } from "@/lib/audit"; + +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + const csrf = sameOriginJson(req); + if (csrf) return NextResponse.json({ error: csrf }, { status: 403 }); + // Read the session before dropping it, so the trail can say who left. Signing out still succeeds + // when there was nothing to sign out of. + const sess = await currentStaff(); + await clearStaffCookie(); + if (sess) { + recordAuthEvent( + { facilityId: sess.facilityId, userId: sess.staffId, userName: `${sess.first} ${sess.last}`.trim() || sess.email }, + "staff:signout", clientIp(req.headers), + ); + } + return NextResponse.json({ ok: true }); +} diff --git a/app/api/staff/mutate/route.ts b/app/api/staff/mutate/route.ts new file mode 100644 index 0000000..5f314f2 --- /dev/null +++ b/app/api/staff/mutate/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { currentStaff } from "@/lib/staffsession"; +import { StaffOpError, runStaffOp } from "@/lib/staffops"; +import { sameOriginJson } from "@/lib/csrf"; +import { allow, clientIp } from "@/lib/ratelimit"; +import { recordStaffAudit } from "@/lib/audit"; +import { bumpRev } from "@/lib/ops"; +import { report } from "@/lib/glitchtip"; + +export const dynamic = "force-dynamic"; + +/* The one door for everything a wearer, manager or ward clerk changes. + * + * Separate from /api/mutate, and reached only with a staff session. The two never share a handler: + * a single endpoint that accepted either kind of caller would put the whole coordinator op table + * one authorisation slip away from a wearer's phone. + */ +export async function POST(req: NextRequest) { + const sess = await currentStaff(); + if (!sess) return NextResponse.json({ error: "Not signed in" }, { status: 401 }); + const csrf = sameOriginJson(req); + if (csrf) return NextResponse.json({ error: csrf }, { status: 403 }); + + let body: { op?: string; payload?: unknown }; + try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad JSON" }, { status: 400 }); } + const op = String(body.op || ""); + + if (!allow("staff-mutate:" + sess.accountId, 120, 60 * 1000)) { + return NextResponse.json({ error: "Slow down — too many changes in a minute." }, { status: 429 }); + } + // Requests are the expensive ones: each sends an email to a manager. A tighter budget stops a + // stuck retry loop turning into a mailbox full of the same approval. + // damage.report and waitlist.accept raise a request (and mail the manager) through the same + // path, so they draw on the same budget — otherwise the loop just picks a different door. + if (["request.create", "damage.report", "waitlist.accept"].includes(op) && !allow("staff-request:" + sess.staffId, 12, 60 * 60 * 1000)) { + return NextResponse.json({ error: "That's a lot of requests in an hour — talk to the linen room." }, { status: 429 }); + } + + const ip = clientIp(req.headers); + try { + const payload = (body.payload || {}) as Record; + const result = await runStaffOp(sess, op, payload); + // The same discipline as /api/mutate, and for the same reason: a uniform issued to a ward is + // authorised here as often as it is in the linen room, and "who approved this" is the question + // the trail exists to answer. Recorded only after the op actually succeeded. + recordStaffAudit(sess, op, payload, ip, result); + // Handed back so the screen that made this change does not bounce again when it next polls. + const rev = await bumpRev(sess.facilityId); + return NextResponse.json({ ok: true, result, rev }); + } catch (e) { + if (e instanceof StaffOpError) return NextResponse.json({ error: e.message }, { status: e.status }); + report({ error: e, where: "server", url: "/api/staff/mutate", tags: { op } }); + console.error(`[staff mutate ${op}]`, e, "ip=", ip); + return NextResponse.json({ error: "Something went wrong — nothing was saved." }, { status: 500 }); + } +} diff --git a/app/api/staff/slip/[id]/sig/route.ts b/app/api/staff/slip/[id]/sig/route.ts new file mode 100644 index 0000000..b68c659 --- /dev/null +++ b/app/api/staff/slip/[id]/sig/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { currentStaff } from "@/lib/staffsession"; +import { parseDataUrl, readPhoto } from "@/lib/photostore"; + +export const dynamic = "force-dynamic"; + +/* The signature on one of a staff member's own signed slips, for the staff app. + * + * Only the caller's own slip, and only one the counter sent to their app (toStaff). Any other id is + * simply not found, whoever's it is. Served with the same headers as /api/photo/[id]. */ +export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const sess = await currentStaff(); + if (!sess) return NextResponse.json({ error: "Not signed in" }, { status: 401 }); + const { id } = await ctx.params; + + const slip = await prisma.slip.findFirst({ + where: { id: String(id || "").slice(0, 40), staffId: sess.staffId, facilityId: sess.facilityId, toStaff: true }, + select: { sigId: true, facilityId: true }, + }); + if (!slip?.sigId) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const ph = await prisma.photo.findFirst({ + where: { id: slip.sigId, facilityId: slip.facilityId }, + select: { data: true, path: true, mime: true }, + }); + if (!ph) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + let mime = ph.mime; + let bytes: Buffer | null = null; + if (ph.path) { + bytes = await readPhoto(ph.path); + } else if (ph.data) { + const parsed = parseDataUrl(ph.data); + if (parsed) { mime = parsed.mime; bytes = parsed.bytes; } + } + if (!bytes) return NextResponse.json({ error: "Not found" }, { status: 404 }); + if (!/^image\/(jpeg|png)$/.test(mime)) return NextResponse.json({ error: "Bad photo" }, { status: 500 }); + + return new NextResponse(new Uint8Array(bytes), { + headers: { + "content-type": mime, + "cache-control": "private, max-age=3600", + "content-disposition": "inline", + "x-content-type-options": "nosniff", + "content-security-policy": "sandbox", + }, + }); +} diff --git a/app/app/activity/page.tsx b/app/app/activity/page.tsx new file mode 100644 index 0000000..8deb9b7 --- /dev/null +++ b/app/app/activity/page.tsx @@ -0,0 +1,14 @@ +import { redirect } from "next/navigation"; + +/* The activity log now lives in Settings › Data & audit log. next.config.ts redirects this path as + * well; this stub keeps old links working if that map ever changes. Query strings carry over. */ +export default async function Activity({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) { + const sp = await searchParams; + const q = new URLSearchParams(); + for (const [k, v] of Object.entries(sp)) { + if (k === "tab" || v === undefined) continue; + for (const one of Array.isArray(v) ? v : [v]) q.append(k, one); + } + q.set("tab", "audit"); + redirect(`/app/settings?${q.toString()}`); +} diff --git a/app/app/counter/page.tsx b/app/app/counter/page.tsx new file mode 100644 index 0000000..1781993 --- /dev/null +++ b/app/app/counter/page.tsx @@ -0,0 +1,8 @@ +"use client"; +import { Suspense } from "react"; +import Counter from "@/components/counter/Counter"; + +// useSearchParams (?staff=, ?mode=) needs a Suspense boundary for static rendering. +export default function CounterPage() { + return ; +} diff --git a/app/app/help/[section]/[slug]/page.tsx b/app/app/help/[section]/[slug]/page.tsx new file mode 100644 index 0000000..0aa803a --- /dev/null +++ b/app/app/help/[section]/[slug]/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import ManualShell from "@/components/ManualShell"; +import { ManualArticle } from "@/components/ManualView"; +import { findPage, neighbours } from "@/lib/manual"; + +/* One manual page inside the app: the same Markdown the website renders at /docs, framed by the + * app's own shell. The help mark on each screen links straight here. */ + +type Params = { params: Promise<{ section: string; slug: string }> }; + +export async function generateMetadata({ params }: Params): Promise { + const { section, slug } = await params; + const p = findPage(section, slug); + return { title: p ? `${p.title} · Help` : "Help", robots: { index: false, follow: false } }; +} + +export default async function HelpPage({ params }: Params) { + const { section, slug } = await params; + const p = findPage(section, slug); + if (!p) notFound(); + const { prev, next } = neighbours(p); + return ( + + + + ); +} diff --git a/app/app/help/page.tsx b/app/app/help/page.tsx new file mode 100644 index 0000000..fed9f65 --- /dev/null +++ b/app/app/help/page.tsx @@ -0,0 +1,26 @@ +import ManualHome from "@/components/ManualHome"; +import ManualShell from "@/components/ManualShell"; +import FacilityRules from "@/components/FacilityRules"; + +export const metadata = { title: "Help", robots: { index: false, follow: false } }; + +/* Help inside the app: the manual's front page, with this facility's own rules above it. The rules + * panel reads the facility's settings, so a figure a coordinator has changed is the figure shown; + * the manual pages quote the defaults and say where each one is changed. */ +export default function Help() { + return ( + + +
+

00This facility’s rules

+

Read from your settings, so these are the figures the counter applies today.

+ +
+
+
+ ); +} diff --git a/app/app/issue/page.tsx b/app/app/issue/page.tsx new file mode 100644 index 0000000..5432bdd --- /dev/null +++ b/app/app/issue/page.tsx @@ -0,0 +1,13 @@ +import { redirect } from "next/navigation"; + +/* Issue Stock became the Counter. Old links, bookmarks and badge scans keep their query string. */ +export default async function IssueRedirect({ searchParams }: { searchParams: Promise> }) { + const sp = await searchParams; + const q = new URLSearchParams(); + for (const [k, v] of Object.entries(sp)) { + if (Array.isArray(v)) v.forEach((x) => q.append(k, x)); + else if (v !== undefined) q.set(k, v); + } + const qs = q.toString(); + redirect(`/app/counter${qs ? `?${qs}` : ""}`); +} diff --git a/app/app/layout.tsx b/app/app/layout.tsx new file mode 100644 index 0000000..49ab2da --- /dev/null +++ b/app/app/layout.tsx @@ -0,0 +1,38 @@ +import { redirect } from "next/navigation"; +import { currentUser } from "@/lib/session"; +import { buildSnapshot } from "@/lib/snapshot"; +import { prisma } from "@/lib/db"; +import { SnapshotProvider } from "@/lib/client"; +import type { ServerCounts } from "@/lib/portalcounts"; +import Shell from "@/components/Shell"; +import Analytics from "@/components/Analytics"; +import Helpdesk from "@/components/Helpdesk"; +import SourceNotice from "@/components/SourceNotice"; + +export const dynamic = "force-dynamic"; + +export default async function AppLayout({ children }: { children: React.ReactNode }) { + const user = await currentUser(); + if (!user) redirect("/auth"); + const facilityId = user.facilityId; + // The four rail counts the snapshot cannot make: requests, record queries and damage live in + // their own tables and are never loaded into the snapshot. mutate()'s router.refresh() re-runs + // this layout, so the badges follow every write. + const [snap, pick, stranded, queries, damage] = await Promise.all([ + buildSnapshot(user), + prisma.request.count({ where: { facilityId, status: "accepted" } }), + prisma.request.count({ where: { facilityId, status: "awaiting", managerName: "" } }), + prisma.recordDispute.count({ where: { facilityId, resolvedAt: null } }), + prisma.damageReport.count({ where: { facilityId, handedInAt: null } }), + ]); + const serverCounts: ServerCounts = { pick, stranded, queries, damage }; + return ( + + {children} + + + {/* Community only: the AGPL's offer of source, to the people using this server. */} + + + ); +} diff --git a/app/app/orders/[id]/page.tsx b/app/app/orders/[id]/page.tsx new file mode 100644 index 0000000..43a6e6f --- /dev/null +++ b/app/app/orders/[id]/page.tsx @@ -0,0 +1,355 @@ +"use client"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; +import { useDerived, useSnap } from "@/lib/client"; +import { PageHead, Empty, Field, ItemSizePicker, LiveRegion } from "@/components/ui"; +import { ReceiveDialog } from "@/components/dialogs"; +import { Figures, MoreMenu, Panel, QtyStepper, Tag } from "@/components/portal"; +import { Crumb, OrdersStyles } from "@/components/orders/bits"; +import { viewPhoto } from "@/lib/photo"; +import { key, supplierCodeOf, ccBudgetNote, ccFor, ccOfOrder, csvOf, daysBetween, fmtDate, isOverdue, label, money, orderTotal, staffName, statusTag, supplierInfo, csvEsc } from "@/lib/compute"; +import { downloadCsv, esc, openPrintWindow } from "@/lib/print"; + +export default function OrderDetail() { + const { id } = useParams<{ id: string }>(); + const { s, isAdmin, mutate } = useSnap(); + const { byId, staffById } = useDerived(); + const router = useRouter(); + const o = s.orders.find((x) => x.id === id); + const [rcv, setRcv] = useState(false); + const [err, setErr] = useState(""); + const [pick, setPick] = useState(""); + const [priceDraft, setPriceDraft] = useState>({}); + const [draft, setDraft] = useState>({}); + // What the coordinator has tapped on the quantity steppers but the server hasn't confirmed yet. + // The state is what the screen shows; the ref is what the next tap adds to while it is set, + // because it is current the instant a tap happens, where the state and the snapshot are both a + // render (or a whole round trip) behind. + const [qtyDraft, setQtyDraft] = useState>({}); + const qtyWanted = useRef>({}); + // What the snapshot said about a line as its write came back, and the lines the latest snapshot + // has. Both are read by the backstop below, from a timer: a timer armed two renders ago still + // closes over that render's copy of the order, and judging the screen out of date from a copy + // that is itself out of date is exactly how the pre-tap quantity gets back under a finger. + const qtySeen = useRef>({}); + const snapLines = useRef(o?.lines); + // The field edits a debounce is still sitting on, so leaving the page can send them (see below). + const fieldWanted = useRef>({}); + const timers = useRef>>({}); + /* Anything still in a debounce when this screen goes away is sent, not thrown away. + * + * Clearing the timers on unmount was silent data loss: tap + on a quantity, or type the invoice + * number, then click straight through to another page inside the debounce window and the change + * vanished — it was on the screen as the coordinator left, and the supplier got the old figure. + * The writes go out bare because the component is already gone: there is nothing left to show an + * error in, and the record is one refresh away for whoever opens it next. */ + const flush = useRef<() => void>(() => {}); + useEffect(() => { + flush.current = () => { + for (const [k, v] of Object.entries(fieldWanted.current)) void mutate("order.update", { id, [k]: v }); + for (const [lineId, qty] of Object.entries(qtyWanted.current)) void mutate("order.lineQty", { id, lineId, qty }); + }; + }); + useEffect(() => { const t = timers.current, f = flush; return () => { Object.values(t).forEach(clearTimeout); f.current(); }; }, []); + useEffect(() => { snapLines.current = o?.lines; }); + // Hand a line back to the snapshot once the refreshed snapshot agrees with what was tapped (or + // the line is gone). Waiting for agreement rather than for the write to return matters: the + // provider re-renders on its own the moment a write lands, still carrying the old snapshot, and + // dropping the tapped number there would flick the counter back to the old quantity and again + // look like the taps had been lost. + useEffect(() => { + setQtyDraft((d) => { + const n = Object.fromEntries(Object.entries(d).filter(([k, v]) => { + const line = o?.lines.find((l) => l.id === k); + return qtyWanted.current[k] !== undefined || (!!line && line.qty !== v); + })); + return Object.keys(n).length === Object.keys(d).length ? d : n; + }); + }); + + const [mailMsg, setMailMsg] = useState(""); + + if (!o) return
All orders
; + + const st = o.staffId ? staffById[o.staffId] : undefined; + const overdue = isOverdue(o, s.today); + const forLabel = o.orderFor === "Stock" ? "For stock" : "For " + staffName(st, "staff member"); + const ccCode = ccOfOrder(s, o, staffById); + const ccNote = ccBudgetNote(s, byId, staffById, ccCode, " (incl. this one)"); + + function saveField(k: string, v: string) { + setDraft((d) => ({ ...d, [k]: v })); + fieldWanted.current[k] = v; + clearTimeout(timers.current[k]); + timers.current[k] = setTimeout(async () => { + // Off the pending list the moment it is on its way: a keystroke that lands after this point + // has already put its own value back and scheduled its own timer. + if (fieldWanted.current[k] === v) delete fieldWanted.current[k]; + const r = await mutate("order.update", { id: o!.id, [k]: v }); + if (!r.ok) setErr(r.error); + }, 400); + } + const val = (k: keyof typeof o) => (draft[k] !== undefined ? draft[k] : String(o[k] ?? "")); + /* The order as the coordinator can actually see it: the taps and the typing still sitting in a + * debounce, laid over the snapshot that has not caught up with them yet. + * + * Everything that puts this order in front of a person reads it — the lines, the total, the + * printed purchase order, the CSV, the receive dialog — so what leaves the building says what the + * screen said when it was asked for. Printing from the snapshot sent the supplier a tunic count + * one tap behind. Sending the pending write first would not have fixed it: the refreshed snapshot + * lands some time after the write returns, and the print window has to open on the click itself + * or the browser blocks it. Actions the server answers out of its own copy go through flushQty() + * instead — that is what the database has to be right about. */ + const onScreen = { ...o, ref: val("ref"), invoice: val("invoice"), tracking: val("tracking"), expected: val("expected"), supplier: val("supplier"), notes: val("notes"), lines: o.lines.map((l) => (qtyDraft[l.id] !== undefined ? { ...l, qty: qtyDraft[l.id] } : l)) }; + async function act(op: string, payload: unknown) { setErr(""); const r = await mutate(op, payload); if (!r.ok) setErr(r.error); return r.ok; } + /* Steppers count from what has been tapped, never from the snapshot. + * + * order.lineQty takes an absolute quantity and the snapshot only catches up once a write comes + * back, so reading l.qty on every tap meant six quick taps on the size-14 tunic all posted + * qty: 2: the line settled at 2 or 3 and the purchase order went to the supplier four tunics + * short. Each tap now adds to the pending figure and the debounce sends whatever it reached. + * + * `shown` is the number on the screen, which is the one the coordinator is counting from. It + * matters in the gap between a write landing and the refreshed snapshot arriving: the pending + * figure is cleared the moment the write returns, so a tap in that gap would otherwise fall back + * to the snapshot and count from the old quantity again — the very defect this exists to stop. + * The pending figure still wins where it exists, because two taps in one frame both read the same + * already-rendered number. */ + function bumpQty(lineId: string, shown: number, by: number) { + clearTimeout(timers.current["qtyclear:" + lineId]); + const next = (qtyWanted.current[lineId] ?? shown) + by; + qtyWanted.current[lineId] = next; + setQtyDraft((d) => ({ ...d, [lineId]: next })); + clearTimeout(timers.current["qty:" + lineId]); + timers.current["qty:" + lineId] = setTimeout(() => { void sendQty(lineId); }, 300); + } + async function sendQty(lineId: string) { + const want = qtyWanted.current[lineId]; + if (want === undefined) return true; + clearTimeout(timers.current["qty:" + lineId]); + const ok = await act("order.lineQty", { id: o!.id, lineId, qty: want }); + // A tap that landed while this write was in the air has already raised the target; leaving it + // pending lets the timer that tap scheduled send the higher number instead of losing it here. + if (qtyWanted.current[lineId] === want) { + delete qtyWanted.current[lineId]; + // Nothing was saved, so the tapped number must come off the screen now rather than sit there + // above the error looking like a quantity the supplier is going to be sent. + if (!ok) dropPendingQty(lineId); + else { + qtySeen.current[lineId] = snapLines.current?.find((l) => l.id === lineId)?.qty ?? want; + timers.current["qtyclear:" + lineId] = setTimeout(() => dropOverriddenQty(lineId, want), 2000); + } + } + return ok; + } + /* The backstop for the case where the snapshot never comes to agree — someone else editing the + * same draft line. Without it this screen would keep showing our number over theirs. + * + * It runs on a clock, so it must never act on a snapshot that is merely late. Dropping the draft + * the moment the two seconds were up put the pre-tap quantity back on the screen whenever the + * refreshed snapshot was slower than that, and the next tap counted on from it — the miscount all + * of this exists to stop. A snapshot still showing the figure it had when our write came back, + * and not the figure we wrote, has not caught up yet: the tapped number stays and this waits + * another two seconds. Once it moves — to ours, or to whatever the other coordinator saved — the + * draft has nothing left to protect and goes. */ + function dropOverriddenQty(lineId: string, wrote: number) { + const line = snapLines.current?.find((l) => l.id === lineId); + if (line && line.qty !== wrote && line.qty === qtySeen.current[lineId]) { timers.current["qtyclear:" + lineId] = setTimeout(() => dropOverriddenQty(lineId, wrote), 2000); return; } + dropPendingQty(lineId); + } + // Send anything still sitting in the debounce before an action the server answers out of its own + // copy of the order — it reads the lines the database holds, not the ones on this screen — or + // before one that closes the draft to edits and would have the pending write refused. + async function flushQty() { + for (const lineId of Object.keys(qtyWanted.current)) if (!(await sendQty(lineId))) return false; + return true; + } + function dropPendingQty(lineId: string) { + clearTimeout(timers.current["qty:" + lineId]); + clearTimeout(timers.current["qtyclear:" + lineId]); + delete qtyWanted.current[lineId]; + delete qtySeen.current[lineId]; + setQtyDraft((d) => { const n = { ...d }; delete n[lineId]; return n; }); + } + async function removeLine(lineId: string) { dropPendingQty(lineId); await act("order.lineRemove", { id: o!.id, lineId }); } + /* Every line is priced the way orderTotal() prices it — delivered units at the cost the delivery + * was invoiced at, whatever is still outstanding at today's catalogue price — so the rows a + * coordinator ticks off against the invoice add up to the total printed under them. Pricing the + * rows from the catalogue while the total came from orderTotal() left the two visibly disagreeing + * as soon as a delivery arrived at a different price, on the one screen where that sum is checked. + * + * The amounts come out of orderTotal() itself rather than a second copy of its arithmetic: what + * line n contributes is the total of the first n lines less the total of the first n−1. Asking it + * about a line on its own would not do, because it draws each delivery down across the lines in + * order — two lines for the same size would then both claim the same delivery. */ + const lineAmt: Record = {}; + let runTotal = 0; + for (let i = 0; i < onScreen.lines.length; i++) { const t = orderTotal({ ...onScreen, lines: onScreen.lines.slice(0, i + 1) }, byId); lineAmt[onScreen.lines[i].id] = t - runTotal; runTotal = t; } + const unitOf = (l: { id: string; itemId: string; qty: number }) => (l.qty > 0 ? lineAmt[l.id] / l.qty : byId[l.itemId]?.cost || 0); + const received = (itemId: string, size: string) => o.receipts.reduce((t, r) => t + r.lines.filter((x) => x.itemId === itemId && x.size === size).reduce((a, x) => a + x.qty, 0), 0); + + /* What the delivery docket gets checked against: the units this order asked for and the units + that have actually turned up. Both are read off the same lines the total is priced from, so the + figure above the table can never disagree with the table. */ + const units = onScreen.lines.reduce((t, l) => t + l.qty, 0); + const got = o.receipts.reduce((t, rc) => t + rc.lines.reduce((n, l) => n + l.qty, 0), 0); + const total = orderTotal(onScreen, byId); + + const ev: { date: string; what: string; sub: string; photoId?: string | null }[] = [{ date: o.date, what: "Order created", sub: o.replenish ? "Auto-built replenishment draft" : o.source }]; + if (o.status !== "Draft" && o.status !== "Cancelled") ev.push({ date: o.date, what: "Placed with " + onScreen.supplier + (onScreen.ref ? " — ref " + onScreen.ref : ""), sub: "" }); + for (const rc of o.receipts) ev.push({ photoId: rc.photoId, date: rc.date, what: "Delivery received" + (rc.invoice ? " — invoice " + rc.invoice : ""), sub: rc.lines.map((x) => `${label(byId[x.itemId])} ${x.size} ×${x.qty}${x.dest === "pickup" ? " → pickup" : " → shelf"}`).join(", ") + (rc.note ? " · " + rc.note : "") }); + if (o.status === "Cancelled") ev.push({ date: "", what: "Order cancelled", sub: "" }); + const backOrders = s.orders.filter((x) => x.parentId === o.id); + const parent = o.parentId ? s.orders.find((x) => x.id === o.parentId) : undefined; + + function printPO() { + const sp = supplierInfo(s, onScreen.supplier); + const rows = onScreen.lines.map((l) => { const it = byId[l.itemId]; return `${esc(label(it))}${esc(it?.sku || "—")}${esc(l.size)}${l.qty}${esc(money(unitOf(l)))}${esc(money(lineAmt[l.id]))}`; }).join(""); + const css = ".hd{display:flex;justify-content:space-between;align-items:flex-end;border-bottom:2px solid #201e1d;padding-bottom:8px}.hd h1{border:none;padding:0;font-size:20px}.meta2{display:grid;grid-template-columns:1fr 1fr;gap:4px 24px;margin:12px 0;font-size:12px;line-height:1.7}.tot{text-align:right;font-size:16px;font-weight:800;margin-top:10px}.notes{margin-top:14px;font-size:12px;color:#444}"; + const body = `

Purchase order — ${esc(onScreen.code)}

${esc(s.settings.facility)} · ${esc(s.settings.location)}
` + + `
Supplier: ${esc(onScreen.supplier)}${sp && (sp.contact || sp.phone) ? " · " + esc([sp.contact, sp.phone].filter(Boolean).join(" · ")) : ""}
Date: ${esc(fmtDate(onScreen.date || s.today))}
Supplier ref: ${esc(onScreen.ref || "—")}
Expected: ${esc(onScreen.expected ? fmtDate(onScreen.expected) : "—")}
Account: ${esc(sp?.account || "—")} · ${esc(forLabel)}
Cost centre: ${esc(ccCode || "—")}
` + + `${rows}
ItemSKUSizeQtyUnitTotal
Total ${esc(money(orderTotal(onScreen, byId)))}
` + + (onScreen.notes ? `
Notes: ${esc(onScreen.notes)}
` : "") + `
Ordered by ____________________    Date ____________
`; + openPrintWindow(onScreen.code, body, { page: "size:A4;margin:16mm", css, width: 780, height: 920 }); + } + function exportCsv() { + downloadCsv((onScreen.code + (onScreen.ref ? "-" + onScreen.ref.replace(/[^A-Za-z0-9-]+/g, "_") : "")).toLowerCase() + ".csv", `Order,${csvEsc(onScreen.code)}\nSupplier,${csvEsc(onScreen.supplier)}\nRef,${csvEsc(onScreen.ref)}\n\n` + csvOf(["Item", "Supplier code", "SKU", "Size", "Qty", "Unit cost", "Total"], onScreen.lines.map((l) => { const it = byId[l.itemId]; return [label(it), supplierCodeOf(s, key(l.itemId, it ? it.sizes.map(String).indexOf(l.size) : -1)), it?.sku || "", l.size, l.qty, +unitOf(l).toFixed(2), lineAmt[l.id].toFixed(2)]; }))); + } + async function emailSupplier() { + setMailMsg("Sending…"); + const r = await mutate<{ sentTo: string }>("order.email", { id: o!.id }); + setMailMsg(r.ok ? `Sent to ${r.result.sentTo}` : r.error); + } + async function duplicate() { + if (!(await flushQty())) return; + const r = await mutate<{ id: string }>("order.duplicate", { id: o!.id }); + if (!r.ok) { setErr(r.error); return; } + router.push(`/app/orders/${r.result.id}`); + } + + return ( +
+ + {forLabel} · {onScreen.supplier} · placed {fmtDate(o.date)}{o.replenish ? " · replenishment" : ""}{parent && <> · back order of {parent.code}}} + below={
{overdue && Overdue}{o.status}
} + > + {o.status === "Draft" && isAdmin && } + {["Ordered", "Shipped", "Back Order"].includes(o.status) && } + {isAdmin && { void mutate("order.printed", { id: o.id }); }}>Order sheet} + { void act("order.status", { id: o.id, status: "Shipped" }); }, hidden: !(isAdmin && ["Ordered", "Back Order"].includes(o.status)) }, + { label: "Duplicate", onSelect: () => { void duplicate(); } }, + { label: "Cancel order", danger: true, hidden: !(isAdmin && ["Draft", "Ordered", "Back Order", "Shipped"].includes(o.status)), onSelect: async () => { if (confirm(`Cancel ${o.code}?`) && await flushQty()) act("order.status", { id: o.id, status: "Cancelled" }); } }, + ]} /> +
+ + + + {onScreen.lines.length} line{onScreen.lines.length === 1 ? "" : "s"} }, + { value: `${got} of ${units}`, label: "Units received" }, + { value: onScreen.expected ? fmtDate(onScreen.expected) : "—", label: "Expected", flag: overdue, note: overdue ? {daysBetween(onScreen.expected, s.today)} day{daysBetween(onScreen.expected, s.today) === 1 ? "" : "s"} overdue : undefined }, + ]} /> +
+
+ +
+ {([["ref", "Supplier order no.", "text", "e.g. NW-48211"], ["invoice", "Invoice no.", "text", "e.g. INV-102938"], ["tracking", "Tracking no.", "text", "e.g. 34XY990812"], ["expected", "Expected delivery", "date", ""]] as const).map(([k, lbl, type, ph]) => ( + {(c) => saveField(k, e.target.value)} />} + ))} + + {(c) => s.settings.suppliers.length ? : saveField("supplier", e.target.value)} />} + + + {(c) => ( + + )} + + + {(c) => ( + + )} + + {ccNote &&
{ccNote}
} + {(c) => saveField("notes", e.target.value)} />} +
+
+
+ {units} unit{units === 1 ? "" : "s"} ordered} + foot={<>Total{money(total)}}> +
+ {onScreen.lines.map((l) => { + const it = byId[l.itemId]; const rec = received(l.itemId, l.size); const cKey = l.id; + const catCost = it ? it.cost : 0; + // What this line is actually worth per unit once a delivery has been invoiced. + const unit = unitOf(l); + return ( +
+
+
{label(it)} · {l.size}
+
{supplierCodeOf(s, key(l.itemId, it ? it.sizes.map(String).indexOf(l.size) : -1)) && {supplierCodeOf(s, key(l.itemId, it ? it.sizes.map(String).indexOf(l.size) : -1))}}{rec ? <> · received {rec} : ""}{Math.abs(unit - catCost) > 0.004 && · invoice price}
+
+
+ {o.status === "Draft" + ? bumpQty(l.id, l.qty, n - l.qty)} /> + : ×{l.qty}} + @ $ + setPriceDraft({ ...priceDraft, [cKey]: e.target.value.replace(/[^0-9.]/g, "") })} + onBlur={async () => { const v = parseFloat(priceDraft[cKey]); if (!isNaN(v) && v >= 0 && Math.abs(v - catCost) > 0.004 && it) { await act("catalog.update", { id: it.id, cost: v }); } const d = { ...priceDraft }; delete d[cKey]; setPriceDraft(d); }} /> + {o.status === "Draft" && o.lines.length > 1 && } +
+
{money(l.qty * unit)}
+
+ ); + })} +
+ {o.status === "Draft" && ( +
+ Add a line + { if (await flushQty()) act("order.lineAdd", { id: o.id, itemId: it.id, size: it.sizes[si], qty: 1 }); }} /> +
+ )} +
+ {backOrders.length > 0 &&
Back order{backOrders.length > 1 ? "s" : ""}: {backOrders.map((b) => {b.code})}
} +
+
+
+ + {ev.map((e, i) => ( +
+
{e.date ? fmtDate(e.date) : "—"}
+
+
{e.what}{e.photoId && }
+ {e.sub &&
{e.sub}
} +
+
+ ))} +
+ {st && ( + +
+
{staffName(st)} {st.num}
+
{st.dept} · {st.phone || "no phone"}
+
+
+ )} +
+
+ {rcv && { setRcv(false); router.refresh(); }} />} +
+ ); +} diff --git a/app/app/orders/all/page.tsx b/app/app/orders/all/page.tsx new file mode 100644 index 0000000..693a837 --- /dev/null +++ b/app/app/orders/all/page.tsx @@ -0,0 +1,110 @@ +"use client"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { useDerived, useSnap } from "@/lib/client"; +import { Field, PageHead } from "@/components/ui"; +import { Panel, Seg, SelectButton, Tag } from "@/components/portal"; +import { ccOfOrder, csvOf, daysBetween, fmtDate, isOverdue, isPlacedOpen, label, money, orderTotal, staffName, statusTag } from "@/lib/compute"; +import { downloadCsv } from "@/lib/print"; +import { Crumb, NewOrder, OrdersStyles, shortDate } from "@/components/orders/bits"; + +const STATUSES = ["All", "Draft", "Open", "Received"] as const; + +/* Every order: the filters, the status segment and the CSV that used to sit on the Ordering screen. */ +export default function OrderLedgerPage() { + const { s } = useSnap(); + const { byId, staffById } = useDerived(); + const [dlg, setDlg] = useState(false); + const [q, setQ] = useState(""); + const [sup, setSup] = useState(""); + const [status, setStatus] = useState<(typeof STATUSES)[number]>("All"); + const [from, setFrom] = useState(""); + const [to, setTo] = useState(""); + const [item, setItem] = useState(""); + + const supOpts = useMemo(() => [{ value: "", label: "All suppliers" }, ...[...new Set(s.orders.map((o) => o.supplier).filter(Boolean))].sort().map((x) => ({ value: x, label: x }))], [s.orders]); + const itemOpts = useMemo(() => { + const ids = new Set(); + for (const o of s.orders) for (const l of o.lines) ids.add(l.itemId); + return [{ value: "", label: "All garments" }, ...[...ids].map((id) => byId[id]).filter(Boolean).sort((a, b) => label(a).localeCompare(label(b))).map((it) => ({ value: it.id, label: label(it) }))]; + }, [s.orders, byId]); + + const rank = (o: (typeof s.orders)[number]) => (o.status === "Draft" ? 0 : isPlacedOpen(o) ? 1 : o.status === "Received" ? 2 : 3); + const ql = q.trim().toLowerCase(); + const orders = s.orders.filter((o) => { + if (sup && o.supplier !== sup) return false; + if (status === "Draft" && o.status !== "Draft") return false; + if (status === "Open" && !isPlacedOpen(o)) return false; + if (status === "Received" && o.status !== "Received") return false; + if (from && o.date < from) return false; + if (to && o.date > to) return false; + if (item && !o.lines.some((l) => l.itemId === item)) return false; + if (ql) { + const st = o.staffId ? staffById[o.staffId] : undefined; + const hay = `${o.code} ${o.ref} ${o.invoice} ${o.tracking} ${o.supplier} ${staffName(st)} ${o.lines.map((l) => label(byId[l.itemId])).join(" ")}`.toLowerCase(); + if (!hay.includes(ql)) return false; + } + return true; + }).sort((a, b) => rank(a) - rank(b) || (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)); + + const narrowed = ql !== "" || sup !== "" || status !== "All" || from !== "" || to !== "" || item !== ""; + + /* The rows on screen, filters applied. Value is orderTotal() so the file agrees with the screen and + Reports; dates stay ISO so a spreadsheet sorts them; notes stay out. */ + function exportCsv() { + const cols = ["Order no.", "Status", "Ordered", "Supplier", "Ordered for", "Staff member", "Supplier ref", "Invoice", "Tracking", "Cost centre", "Replenishment", "Expected", "Days overdue", "Received", "Lines", "Units ordered", "Units received", "Value"]; + downloadCsv(`threadcount-orders-${s.today}.csv`, csvOf(cols, orders.map((o) => { + const st = o.staffId ? staffById[o.staffId] : undefined; + const units = o.lines.reduce((t, l) => t + l.qty, 0); + const got = o.receipts.reduce((t, rc) => t + rc.lines.reduce((n, l) => n + l.qty, 0), 0); + return [o.code, o.status, o.date, o.supplier, o.orderFor === "Stock" ? "Stock" : "Staff member", staffName(st), o.ref, o.invoice, o.tracking, ccOfOrder(s, o, staffById), o.replenish ? "Yes" : "No", o.expected, isOverdue(o, s.today) ? daysBetween(o.expected, s.today) : "", o.received, o.lines.length, units, got, +orderTotal(o, byId).toFixed(2)]; + }))); + } + + return ( +
+ + {orders.length} of {s.orders.length}}> + + + + +
+ setQ(e.target.value)} /> + + + {(c) => setFrom(e.target.value)} />} + {(c) => setTo(e.target.value)} />} + +
+ + {orders.length === 0 &&
{s.orders.length === 0 ? "No orders yet." : "No orders match."}
} + {orders.map((o) => { + const st = o.staffId ? staffById[o.staffId] : undefined; + const overdue = isOverdue(o, s.today); + const late = overdue ? daysBetween(o.expected, s.today) : 0; + const due = overdue + ? late === 1 ? "due yesterday" : `${late} days overdue` + : isPlacedOpen(o) && o.expected ? (o.expected === s.today ? "due today" : `due ${shortDate(o.expected)}`) : ""; + return ( + +
+
{o.code}
+
+ {o.orderFor === "Stock" ? "For stock" : "For " + staffName(st, "staff member")} · {o.supplier} · {shortDate(o.date)}{o.ref ? " · ref " + o.ref : ""} + {due && <> · {due}} +
+
+ {o.replenish && Replenishment} + {overdue && Overdue} + {o.status} + {money(orderTotal(o, byId))} + + + ); + })} +
+ {dlg && setDlg(false)} />} +
+ ); +} diff --git a/app/app/orders/list/page.tsx b/app/app/orders/list/page.tsx new file mode 100644 index 0000000..f1f269a --- /dev/null +++ b/app/app/orders/list/page.tsx @@ -0,0 +1,11 @@ +import { redirect } from "next/navigation"; + +/* The order list is now the To order column on /app/orders. next.config.ts redirects as well; this + * stub keeps an old bookmark working if that map ever changes. */ +export default async function OrderListPage({ searchParams }: { searchParams: Promise> }) { + const sp = await searchParams; + const qs = new URLSearchParams(); + for (const [k, v] of Object.entries(sp)) for (const x of Array.isArray(v) ? v : v === undefined ? [] : [v]) qs.append(k, x); + const q = qs.toString(); + redirect(q ? `/app/orders?${q}` : "/app/orders"); +} diff --git a/app/app/orders/page.tsx b/app/app/orders/page.tsx new file mode 100644 index 0000000..451e396 --- /dev/null +++ b/app/app/orders/page.tsx @@ -0,0 +1,29 @@ +"use client"; +import { useState } from "react"; +import { useSnap } from "@/lib/client"; +import { PageHead } from "@/components/ui"; +import ToOrder from "@/components/orders/ToOrder"; +import OnTheWay from "@/components/orders/OnTheWay"; +import ThisMonth from "@/components/orders/ThisMonth"; +import RecentOrders from "@/components/orders/RecentOrders"; +import { NewOrder, OrdersStyles } from "@/components/orders/bits"; + +export default function OrdersPage() { + const { isAdmin } = useSnap(); + const [dlg, setDlg] = useState(null); + return ( +
+ + + + + +
+
{isAdmin ? : }
+
+
+
+ {dlg && setDlg(null)} initOrderFor={dlg === "staff" ? "Staff Member" : undefined} />} +
+ ); +} diff --git a/app/app/page.tsx b/app/app/page.tsx new file mode 100644 index 0000000..176bc4c --- /dev/null +++ b/app/app/page.tsx @@ -0,0 +1,44 @@ +"use client"; +import Link from "next/link"; +import { PageHead, Empty } from "@/components/ui"; +import { usePortalCounts } from "@/lib/portalcounts"; +import { todayHeadLine } from "@/lib/today"; +import SetupGroup from "@/components/today/SetupGroup"; +import CollectGroup from "@/components/today/CollectGroup"; +import RoundGroup from "@/components/today/RoundGroup"; +import PickGroup from "@/components/today/PickGroup"; +import ReceiveGroup from "@/components/today/ReceiveGroup"; +import CountsGroup from "@/components/today/CountsGroup"; +import RunsOutPanel from "@/components/today/RunsOutPanel"; +import MonthEndPanel from "@/components/today/MonthEndPanel"; + +/* Today: the work queue. Each group is a thing somebody has to go and do, and a group with nothing + in it is not drawn. Membership and the head count both come from lib/portalcounts.ts, the same + numbers the rail badge shows. */ +export default function TodayPage() { + const { today } = usePortalCounts(); + const column: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 18, minWidth: 0 }; + + return ( +
+ + Open the counter + +
+
+ + + + + + + {today.total === 0 && Nothing in the queue.} +
+
+ + +
+
+
+ ); +} diff --git a/app/app/report/page.tsx b/app/app/report/page.tsx new file mode 100644 index 0000000..b2953bb --- /dev/null +++ b/app/app/report/page.tsx @@ -0,0 +1,83 @@ +"use client"; +import { Suspense, useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useSnap } from "@/lib/client"; +import { monthLabel } from "@/lib/compute"; +import { PageHead } from "@/components/ui"; +import { Icon, Seg } from "@/components/portal"; +import { useReportData } from "@/components/reports/useReportData"; +import MonthEndStrip from "@/components/reports/MonthEndStrip"; +import SpendTab, { JOURNAL_ID } from "@/components/reports/SpendTab"; +import StockTab from "@/components/reports/StockTab"; +import PeopleTab from "@/components/reports/PeopleTab"; + +const TABS = ["spend", "stock", "people"] as const; +type Tab = (typeof TABS)[number]; +const LABELS: Record = { spend: "Spend", stock: "Stock", people: "People" }; +/* The nine reports' old names, so a link to one of them still lands on the tab that holds it. */ +const LEGACY: Record = { + overview: "spend", journal: "spend", + valuation: "stock", shrinkage: "stock", "top-stock": "stock", topstock: "stock", suppliers: "stock", + exceptions: "people", approvals: "people", "pre-loved": "people", preloved: "people", +}; + +/* Screen-local layout. Scoped to .tc-rep so nothing leaks outside this screen. */ + +export default function ReportPage() { + return ; +} + +function ReportInner() { + const { s } = useSnap(); + const router = useRouter(); + const sp = useSearchParams(); + const thisMonth = s.today.slice(0, 7); + const rawTab = (sp.get("tab") || "").toLowerCase(); + const tab: Tab = (TABS as readonly string[]).includes(rawTab) ? (rawTab as Tab) : LEGACY[rawTab] ?? "spend"; + const rawMonth = sp.get("month") || ""; + const month = /^\d{4}-(0[1-9]|1[0-2])$/.test(rawMonth) ? rawMonth : thisMonth; + const d = useReportData(month); + const [jump, setJump] = useState(false); + + function go(next: { tab?: Tab; month?: string }) { + const t = next.tab ?? tab, m = next.month ?? month; + const q = new URLSearchParams(); + q.set("tab", t); + if (m !== thisMonth) q.set("month", m); + router.replace(`/app/report?${q.toString()}`, { scroll: false }); + } + + useEffect(() => { + if (!jump || tab !== "spend") return; + document.getElementById(JOURNAL_ID)?.scrollIntoView({ block: "start" }); + setJump(false); + }, [jump, tab]); + + const exportCsv = tab === "spend" ? d.csv.overview : tab === "stock" ? d.csv.valuation : d.csv.exceptions; + + return ( +
+ + + + + + + + + + +
+ { setJump(true); if (tab !== "spend") go({ tab: "spend" }); }} /> +
+ go({ tab: t })} /> +
+ {tab === "spend" && go({ month: m })} />} + {tab === "stock" && } + {tab === "people" && } +
+
+ ); +} diff --git a/app/app/requests/page.tsx b/app/app/requests/page.tsx new file mode 100644 index 0000000..af73f5b --- /dev/null +++ b/app/app/requests/page.tsx @@ -0,0 +1,142 @@ +"use client"; +/* The full request queue: /app/requests?filter=&staff=&ward=&open= + * + * Approval is the ward's and fulfilment is the linen room's; this screen is the linen room's side. + * The rows come from components/requests/RequestList, the same list the staff record and Today + * use. One payload is fetched and every filter, count and export is a narrowing of it. */ +import { Suspense, useEffect, useMemo } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useSnap } from "@/lib/client"; +import { Empty, ErrorLine, PageHead } from "@/components/ui"; +import { MonoNum, Seg } from "@/components/portal"; +import RequestList, { + REQUEST_FILTERS, REQUEST_FILTER_LABEL, RequestStyles, isRowFilter, requestCounts, requestsFor, scopePayload, + useRequestActions, useRequests, type RequestFilter, +} from "@/components/requests/RequestList"; +import Queries from "@/components/requests/Queries"; +import Damage from "@/components/requests/Damage"; +import KitCheck from "@/components/requests/KitCheck"; +import { exportCount, exportRequestsCsv } from "@/components/requests/csv"; + +// useSearchParams needs a Suspense boundary for static rendering. +export default function RequestsPage() { + return ; +} + +const isFilter = (v: string | null): v is RequestFilter => !!v && (REQUEST_FILTERS as readonly string[]).includes(v); + +function RequestsInner() { + const { s } = useSnap(); + const router = useRouter(); + const pathname = usePathname(); + const sp = useSearchParams(); + const rawFilter = sp.get("filter"); + const staffId = sp.get("staff") || undefined; + const ward = sp.get("ward") || undefined; + const openId = sp.get("open"); + + const { data, error, reload } = useRequests(); + const { act, error: actError } = useRequestActions(reload); + + const person = staffId ? s.staff.find((x) => x.id === staffId) : undefined; + const scope = useMemo(() => ({ staffId, ward }), [staffId, ward]); + const scoped = useMemo(() => (data ? scopePayload(data, scope, person?.num) : null), [data, scope, person?.num]); + const counts = useMemo(() => (scoped ? requestCounts(scoped) : null), [scoped]); + + /* A deep link to one request with no filter named lands on the first filter that holds it, so + ?open= always shows the row expanded. */ + const filter: RequestFilter = useMemo(() => { + if (isFilter(rawFilter)) return rawFilter; + if (openId && scoped) { + for (const f of ["todo", "open", "all"] as const) if (requestsFor(scoped.requests, f).some((r) => r.id === openId)) return f; + } + return "todo"; + }, [rawFilter, openId, scoped]); + + function setParams(next: Record) { + const q = new URLSearchParams(sp.toString()); + for (const [k, v] of Object.entries(next)) { if (v === null) q.delete(k); else q.set(k, v); } + const qs = q.toString(); + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); + } + + // An unknown ?filter= is dropped rather than left in the address bar disagreeing with the screen. + useEffect(() => { + if (!rawFilter || isFilter(rawFilter)) return; + const q = new URLSearchParams(window.location.search); + q.delete("filter"); + const qs = q.toString(); + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); + }, [rawFilter, router, pathname]); + + const showing = staffId ? (person ? `${person.first} ${person.last}`.trim() : data?.requests.find((r) => r.staffId === staffId)?.staffName || "one person") : ward; + const more = data?.moreRequests ? "+" : ""; + const segCounts = counts + ? Object.fromEntries(REQUEST_FILTERS.map((f) => [f, `${counts[f]}${isRowFilter(f) ? more : ""}`])) as Record + : undefined; + + const shown = scoped ? exportCount(filter, scoped) : 0; + const narrowed = !!showing || (filter !== "all" && filter !== "cycles"); + + return ( +
+ + + + + + {showing && ( +
+ Showing {showing} + +
+ )} + + {!data ? ( + error ? ( + <> + +
+ + ) : Loading… + ) : ( +
+ {error && } + {counts && counts.noapprover > 0 && filter !== "noapprover" && ( +
+ + + +
+ )} + +
+ setParams({ filter: f })} style={{ flexWrap: "nowrap", width: "max-content" }} /> +
+ + {actError && } + + {isRowFilter(filter) ? ( + + ) : filter === "queries" ? ( + + ) : filter === "damage" ? ( + + ) : ( + + )} +
+ )} +
+ ); +} diff --git a/app/app/rounds/page.tsx b/app/app/rounds/page.tsx new file mode 100644 index 0000000..6433b99 --- /dev/null +++ b/app/app/rounds/page.tsx @@ -0,0 +1,73 @@ +"use client"; +import { useEffect, useMemo, useState } from "react"; +import { useDerived, useSnap } from "@/lib/client"; +import { PageHead, Empty, LiveRegion } from "@/components/ui"; +import { Panel, QueueRow, Seg } from "@/components/portal"; +import { DeliverDialog } from "@/components/dialogs"; +import type { PickupRec } from "@/lib/compute"; +import { plural, roundSheet } from "@/lib/today"; +import { Lines } from "@/components/today/Lines"; + +// Delivery rounds: every uncollected pickup by ward, handed over on the floor with an on-screen +// signature and a handover photo (DeliverDialog). + +const ALL = "__all__"; + +export default function RoundsPage() { + const { s } = useSnap(); + const { byId, staffById } = useDerived(); + const [deliver, setDeliver] = useState(null); + const [msg, setMsg] = useState(""); + const [ward, setWard] = useState(ALL); + useEffect(() => { const w = new URLSearchParams(window.location.search).get("ward"); if (w) setWard(w); }, []); + + const sheet = useMemo(() => roundSheet(s, byId, staffById), [s, byId, staffById]); + const bags = sheet.reduce((t, w) => t + w.rows.length, 0); + const garments = sheet.reduce((t, w) => t + w.garments, 0); + const current = ward !== ALL && sheet.some((w) => w.ward === ward) ? ward : ALL; + const shown = current === ALL ? sheet : sheet.filter((w) => w.ward === current); + + function choose(w: string) { + setWard(w); + const u = new URL(window.location.href); + if (w === ALL) u.searchParams.delete("ward"); else u.searchParams.set("ward", w); + window.history.replaceState(null, "", u.pathname + u.search); + } + + const opts = [ALL, ...sheet.map((w) => w.ward)]; + const labels: Record = { [ALL]: "All" }; + const counts: Record = { [ALL]: bags }; + for (const w of sheet) counts[w.ward] = w.rows.length; + + return ( +
+ {plural(bags, "bag")} · {plural(sheet.length, "ward")} · {plural(garments, "garment")}} /> + + {sheet.length > 1 && ( +
+ +
+ )} + {bags === 0 && Nothing waiting for delivery.} +
+ {shown.map((w) => ( + {w.cc || "—"} · {w.rows.length} to deliver}> + {w.rows.map((r) => ( + {r.phone} : r.phone) : undefined} + meta={<> · {r.p.orderCode}} + actions={} + /> + ))} + + ))} +
+ {deliver && setDeliver(null)} onDone={(m) => setMsg(m)} />} +
+ ); +} diff --git a/app/app/settings/page.tsx b/app/app/settings/page.tsx new file mode 100644 index 0000000..44835ea --- /dev/null +++ b/app/app/settings/page.tsx @@ -0,0 +1,70 @@ +"use client"; +/* Settings: seven sections in a side list, each a ?tab= of its own so deep links and the help mark + * land on the right one. Old tab names (general, account, locations, activity…) still resolve. */ +import { Suspense, useEffect, useState } from "react"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { useSnap } from "@/lib/client"; +import { PageHead } from "@/components/ui"; +import dynamic from "next/dynamic"; +import { CSV_TEMPLATES } from "@/lib/csv"; +import { SECTIONS, SettingsStyles, resolveSection, type SectionId } from "@/components/settings/common"; +import FacilitySection from "@/components/settings/FacilitySection"; +import IssuingRules from "@/components/settings/IssuingRules"; +import CatalogueSection from "@/components/settings/CatalogueSection"; +import PlacesSection from "@/components/settings/PlacesSection"; +import PeopleSignIn from "@/components/settings/PeopleSignIn"; +import DataAudit from "@/components/settings/DataAudit"; + +// Plan pulls in Stripe; load it only when the Plan section is open. +const PlanTab = dynamic(() => import("@/components/PlanTab"), { ssr: false }); + +// useSearchParams needs a Suspense boundary for static rendering. +export default function SettingsPage() { + return ; +} + +function SettingsInner() { + const { s, isAdmin } = useSnap(); + const sp = useSearchParams(); + const planShown = isAdmin && !!s.plan?.live && !s.demo; + // #hash forms from old links (/app/settings#account). + const [hash, setHash] = useState(""); + useEffect(() => { + const read = () => setHash(window.location.hash.replace("#", "")); + read(); + window.addEventListener("hashchange", read); + return () => window.removeEventListener("hashchange", read); + }, []); + + const tabParam = sp.get("tab"); + const importParam = sp.get("import") || ""; + const importKind = CSV_TEMPLATES[importParam] ? importParam : undefined; + const resolved = resolveSection(tabParam || hash || (importKind ? "data" : "")); + let section: SectionId = resolved.section; + if (section === "plan" && !planShown) section = "facility"; + const sections = SECTIONS.filter((x) => x.id !== "plan" || planShown); + + return ( +
+ + +
+ +
+ {section === "facility" && } + {section === "issuing" && } + {section === "catalogue" && } + {section === "places" && } + {section === "people" && } + {section === "data" && } + {section === "plan" && planShown && } +
+
+
+ ); +} diff --git a/app/app/staff/[id]/page.tsx b/app/app/staff/[id]/page.tsx new file mode 100644 index 0000000..2d4e602 --- /dev/null +++ b/app/app/staff/[id]/page.tsx @@ -0,0 +1,8 @@ +"use client"; +import { Suspense } from "react"; +import StaffRecord from "@/components/people/Record"; + +// The record's tab and edit mode live in the address; useSearchParams needs a Suspense boundary. +export default function StaffProfile() { + return ; +} diff --git a/app/app/staff/page.tsx b/app/app/staff/page.tsx new file mode 100644 index 0000000..0a1c89e --- /dev/null +++ b/app/app/staff/page.tsx @@ -0,0 +1,8 @@ +"use client"; +import { Suspense } from "react"; +import Register from "@/components/people/Register"; + +// Register reads its filters from the address; useSearchParams needs a Suspense boundary. +export default function StaffPage() { + return ; +} diff --git a/app/app/stock/[id]/page.tsx b/app/app/stock/[id]/page.tsx new file mode 100644 index 0000000..61a2481 --- /dev/null +++ b/app/app/stock/[id]/page.tsx @@ -0,0 +1,414 @@ +"use client"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { Fragment, useEffect, useMemo, useState } from "react"; +import { useDerived, useSnap } from "@/lib/client"; +import { PageHead, Empty, ErrorLine, Field, Notice } from "@/components/ui"; +import { AdjustDialog, DuplicateItemDialog, GROUPS_HINT, GroupsPicker, ScanVariantsDialog } from "@/components/dialogs"; +import { Icon, MoreMenu, Panel, QtyStepper, Tag } from "@/components/portal"; +import { StockStyles } from "@/components/stock/StockStyles"; +import { wholeMoney } from "@/components/stock/url"; +import { bcBound, countsAsIssued, fmtDate, forecastFor, forecastLabel, fyStart, garmentGroups, genderLabel, issueCost, itemOrderHistory, key, lastCountMap, locTree, money, onOrderMap, onhand, plOf, reorderAt, staffName, statusTag, supplierCodeOf, touched } from "@/lib/compute"; + +export default function GarmentPage() { + const { id } = useParams<{ id: string }>(); + const { s, isAdmin, mutate } = useSnap(); + const { L, byId, staffById } = useDerived(); + const it = s.catalog.find((x) => x.id === id); + const [edit, setEdit] = useState(false); + const [f, setF] = useState({ item: "", sku: "", supplier: "", cost: "", gender: "Unisex", groups: [] as string[], notes: "" }); + const [newSize, setNewSize] = useState(""); + const [err, setErr] = useState(""); + const [msg, setMsg] = useState(""); + const [adjust, setAdjust] = useState<{ itemId: string; si: number } | null>(null); + const [scanSizes, setScanSizes] = useState(false); + const [dup, setDup] = useState(false); + // What is typed into each size's barcode box, until it is saved: a label code is as often read out + // and typed as it is scanned. + const [codes, setCodes] = useState>({}); + const [rowErr, setRowErr] = useState<{ si: number; msg: string } | null>(null); + const [flash, setFlash] = useState(null); + + // Arriving from "Create and scan sizes" (?scan=1) opens the scanner straight away; ?size= + // (a scanned garment, a size cell) brings that size's row into view and marks it for two seconds. + useEffect(() => { + const sp = new URLSearchParams(window.location.search); + if (sp.get("scan") === "1") { + if (isAdmin && it) setScanSizes(true); + sp.delete("scan"); + window.history.replaceState(null, "", window.location.pathname + (sp.toString() ? "?" + sp.toString() : "")); + } + const size = sp.get("size"); + if (size === null || !it) return; + const si = parseInt(size, 10); + if (!(si >= 0 && si < it.sizes.length)) return; + const t0 = window.setTimeout(() => { + const el = document.getElementById(`size-row-${si}`); + if (el) { el.scrollIntoView({ block: "center" }); el.focus({ preventScroll: true }); } + setFlash(si); + }, 50); + const t1 = window.setTimeout(() => setFlash(null), 2050); + return () => { window.clearTimeout(t0); window.clearTimeout(t1); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, !!it]); + + const d = useMemo(() => { + if (!it) return null; + const oo = onOrderMap(s, byId).byKey; + const lastCount = lastCountMap(s); + const sizes = it.sizes.map((sz, si) => { const k = key(it.id, si); return { si, size: String(sz), key: k, oh: onhand(s, L, k), ro: reorderAt(s, k), touched: touched(s, L, k), barcode: bcBound(s, it, si), pl: plOf(s, k), onOrd: oo[k] || 0, last: lastCount[k] || "" }; }); + const tot = sizes.reduce((t, v) => t + v.oh, 0); + const value = sizes.reduce((t, v) => t + Math.max(0, v.oh), 0) * it.cost; + const fy = fyStart(s.today); + const fyList = s.issues.filter((i) => i.itemId === it.id && i.date >= fy && countsAsIssued(i)); + const fyIssued = fyList.reduce((t, i) => t + i.qty, 0); + const fySpend = fyList.reduce((t, i) => t + i.qty * issueCost(i, byId), 0); + const onOrder = sizes.reduce((t, v) => t + v.onOrd, 0); + const hist: { date: string; kind: string; cls: string; desc: string }[] = []; + for (const i of s.issues) if (i.itemId === it.id) { + hist.push({ date: i.date, kind: i.direct ? "Collected" : "Issued", cls: "tag tag-neutral", desc: `${it.sizes[i.si]} ×${i.qty} — ${staffName(staffById[i.staffId], "—")}` }); + if (i.returned) hist.push({ date: i.returned.date, kind: i.returned.cond.replace("Returned - ", "Returned – "), cls: "tag tag-outline", desc: `${it.sizes[i.si]} ×${i.qty} — ${staffName(staffById[i.staffId], "—")}` }); + } + for (const o of s.orders) for (const rc of o.receipts) for (const l of rc.lines) if (l.itemId === it.id) hist.push({ date: rc.date, kind: "Received", cls: "tag tag-accent", desc: `${l.size} ×${l.qty} — ${o.code}${l.dest === "shelf" ? " → shelf" : " → staff pickup"}` }); + // A counted correction isn't a write-off: it's the shelf disagreeing with the ledger, either way. + for (const m of s.moves) if (m.itemId === it.id) hist.push({ date: m.date, kind: m.reason === "Counted correction" ? "Counted" : m.qty < 0 ? "Write-off" : "Added", cls: "tag tag-outline", desc: `${it.sizes[m.si] ?? ""} ${m.reason === "Counted correction" ? (m.qty < 0 ? "−" : "+") + Math.abs(m.qty) : "×" + Math.abs(m.qty)}${m.reason ? " — " + m.reason : ""}` }); + hist.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)); + return { sizes, tot, value, fyIssued, fySpend, onOrder, hist: hist.slice(0, 25) }; + }, [it, s, L, byId, staffById]); + + if (!it || !d) { + return ( +
+ + + + That garment isn't in the catalogue. +
+ ); + } + + const tagged = garmentGroups(it.groups); + const startEdit = () => { setF({ item: it.item, sku: it.sku, supplier: it.supplier, cost: String(it.cost), gender: it.gender, groups: tagged, notes: it.notes }); setErr(""); setEdit(true); }; + const invalid = !f.item.trim() || !(parseFloat(f.cost) >= 0) || f.cost === ""; + async function save() { + if (invalid) return; + const r = await mutate("catalog.update", { id: it!.id, item: f.item, sku: f.sku, supplier: f.supplier, cost: parseFloat(f.cost), gender: f.gender, groups: f.groups, notes: f.notes }); + if (!r.ok) { setErr(r.error); return; } + setEdit(false); + } + async function act(op: string, payload: unknown) { setErr(""); setRowErr(null); setMsg(""); const r = await mutate(op, payload); if (!r.ok) setErr(r.error); } + const sizeInvalid = !newSize.trim() || it.sizes.map(String).includes(newSize.trim()); + // Adding a size does not mint a barcode: most sizes arrive with the supplier's own number. + async function addSize() { + if (sizeInvalid) return; + setErr(""); + const r = await mutate("catalog.update", { id: it!.id, addSize: newSize.trim() }); + if (!r.ok) { setErr(r.error); return; } + setNewSize(""); + } + // A size row's own refusal belongs on that row. + function rowFail(si: number, m: string) { setErr(""); setRowErr({ si, msg: m }); } + async function saveCode(si: number, size: string, bound: string, force = false) { + const code = (codes[si] ?? bound).trim(); + if (code === bound) { setCodes((c) => ({ ...c, [si]: bound })); return; } + // Clearing the box is how a wrong code comes off. + if (!code) { if (confirm(`Unbind ${bound} from size ${size}?`)) await unbindCode(si, bound); return; } + setErr(""); setRowErr(null); + const r = await mutate("barcode.bind", { code, itemId: it!.id, si, force }); + if (!r.ok) { + // "Already on another garment" is the one refusal force may clear. + if (!force && r.error.includes("re-bind to move it") && confirm(`${r.error}\n\nMove ${code} onto ${it!.item} · size ${size}?`)) { await saveCode(si, size, bound, true); return; } + rowFail(si, r.error); return; + } + setCodes((c) => ({ ...c, [si]: code })); + } + async function unbindCode(si: number, code: string) { + setErr(""); setRowErr(null); + const r = await mutate("barcode.unbind", { code }); + if (!r.ok) { rowFail(si, r.error); return; } + setCodes((c) => ({ ...c, [si]: "" })); + } + // Our own number for sizes that arrived without one. Only fills gaps; the server has the last word. + async function generateAll() { + const missing = d!.sizes.filter((v) => !v.barcode).length; + if (missing && !confirm(`Generate a barcode for the ${missing} size${missing === 1 ? "" : "s"} on ${it!.item} with none? Sizes with a supplier’s code keep it.`)) return; + setErr(""); setRowErr(null); setMsg(""); + const r = await mutate<{ made: { si: number; size: string; code: string }[]; count: number }>("barcode.generate", { itemId: it!.id }); + if (!r.ok) { setErr(r.error); return; } + setCodes({}); + setMsg(`Generated ${r.result.count} barcode${r.result.count === 1 ? "" : "s"} — size${r.result.count === 1 ? "" : "s"} ${r.result.made.map((m) => m.size).join(", ")}.`); + } + async function generateOne(si: number, size: string) { + setErr(""); setRowErr(null); setMsg(""); + const r = await mutate<{ made: { si: number; size: string; code: string }[]; count: number }>("barcode.generate", { itemId: it!.id, si }); + if (!r.ok) { rowFail(si, r.error); return; } + setCodes((c) => { const n = { ...c }; delete n[si]; return n; }); + setMsg(`Size ${size} now carries ${r.result.made.map((m) => m.code).join(", ")}.`); + } + async function removeSize(si: number, size: string) { + if (!confirm(`Remove size ${size} from ${it!.item}? Its reorder level and barcode go with it.`)) return; + setErr(""); setRowErr(null); + const r = await mutate("catalog.removeSize", { id: it!.id, si }); + if (!r.ok) { rowFail(si, r.error); return; } + // Every size above the removed one shifts down a place. + setCodes({}); + } + // One label per garment on hand across the sizes that carry a code, so the count goes on the menu + // item and into the question before the print dialog opens. + const labelled = d.sizes.filter((v) => v.barcode).length; + const labels = d.sizes.reduce((t, v) => t + (v.barcode ? Math.max(0, v.oh) : 0), 0); + function printLabels() { + if (labels && !confirm(`Print ${labels} label${labels === 1 ? "" : "s"} for ${it!.item}? One for every garment on hand, across the ${labelled} size${labelled === 1 ? "" : "s"} carrying a barcode.`)) return; + window.open(`/print/labels?item=${encodeURIComponent(it!.id)}`, "_blank", "noopener"); + } + + const locOpts = locTree(s).map(({ loc, depth }) => ({ id: loc.id, name: " ".repeat(depth * 2) + loc.name })); + const sp = s.supplierDir.find((x) => x.name === it.supplier); + const hist = itemOrderHistory(s, it.id); + const prices = s.costs.filter((c) => c.itemId === it.id).sort((a, b) => b.at.localeCompare(a.at)); + const colCount = 10; + + return ( +
+ + + {tagged.length ? tagged.map((g) => {g}) : All groups} + {it.gender !== "Unisex" && {genderLabel(it.gender)}} + {it.supplier || "No supplier"} + {it.archived && Discontinued} + + } + > +
+ {d.tot} + on hand · {wholeMoney(d.value)} +
+ {isAdmin && (!edit ? ( + <> + + + setDup(true) }, + it.archived + ? { label: "Reinstate", onSelect: () => act("catalog.update", { id: it.id, archived: false }) } + : { label: "Discontinue", danger: true, onSelect: () => act("catalog.update", { id: it.id, archived: true }) }, + ]} /> + + ) : ( + <> + + + + ))} +
+ + + + +
+
+ {edit && ( + +
+ {(c) => setF({ ...f, item: e.target.value })} />} + {(c) => setF({ ...f, sku: e.target.value })} />} + = 0) ? "Give a number, or 0." : undefined}>{(c) => setF({ ...f, cost: e.target.value.replace(/[^0-9.]/g, "") })} />} + {(c) => <> setF({ ...f, supplier: e.target.value })} />{s.settings.suppliers.map((x) => } + {(c) => } + setF({ ...f, groups })} groups={s.settings.staffGroups} hint={GROUPS_HINT} /> + {(c) =>