Engineering
Architecture
How TaskPaws is put together: the Rails platform, the React Native clients, and the contracts between them. Record decisions that affect more than one app or that would be expensive to reverse.
Apps
| Path | Role | Status |
|---|---|---|
platform/ | Fullstack Rails: public web, workspace, Admin, API, jobs, database. Source of truth for domain behavior. | Marketing, auth, tenancy, /app clinic workspace (patients, schedule, whiteboard, SOAP, catalog, inventory, settings), Admin console, staff JSON API. |
mobile/ | React Native / Expo for iOS and Android. Same organization workflows against the Rails API. | Expo SDK 57 scaffold only |
Staff get the same functionality as the web, packaged as Expo rather than a browser UI. Super users use the web Admin console. Domain behavior stays in platform/ models; the API is a JSON adapter beside the Hotwire UI.
Stack
| Layer | Choice | Notes |
|---|---|---|
| Language | Ruby 4.0.2 | platform/.ruby-version |
| Framework | Rails 8.1.3 | config.load_defaults 8.1 |
| Database | PostgreSQL via pg | Connection from Rails credentials |
| Web UI | Hotwire, import maps, Propshaft | Stimulus mobile-nav for marketing header; sidebar for the /app icon rail; searchable-selects and form-validation on every shell |
| CSS | Tailwind CSS 4 + taskpaws.css | Tokens in app/assets/tailwind/application.css |
| Icons | Lucide webfont (lucide-static 0.546.0) | Replaces Phosphor. Views still call icon_tag with material-style names; Icon emits lucide icon-…. Guide: docs/architecture/icons.md |
| Jobs / cache / cable | Solid Queue, Cache, Cable | Memory/async in development |
| Auth | Authlogic | Separate User and SuperUser; bcrypt. API uses hashed ApiToken Bearer tokens, not cookies |
| Geocoding | Geocoder (intended) | Address → lat/lng on Location; PostGIS later if needed |
| Audit | Audited | audits table; names and email on User/SuperUser |
| Files | Active Storage | Disk now; Amazon S3 adapter defined |
| i18n | en default, es | Cookie from /locale/:locale, else Accept-Language via AppLocale |
Web surface
MainController serves marketing and legal. Staff land on the /app workspace shell after UserSession. SuperUsers land on the Admin console after SuperUserSession. Staff JSON clients use /api/v1.
| Route | Action | Behavior |
|---|---|---|
GET / | main#landing | Features, pricing, FAQs, trial CTA |
GET/POST /login | user_sessions#new/create | Staff session → /app |
DELETE /logout | user_sessions#destroy | Clears staff session |
GET/POST /register | users#new/create | Creates User, provisions Organization + Legal Entity + Location + Staff, session → /app |
POST /auth/:provider | OmniAuth | Google, Apple, Facebook for User only |
GET/POST /auth/:provider/callback | omniauth_callbacks#create | Existing User: session → /app. New visitor finishes on /register |
GET /app | app/homes#show | Workspace home. Header shows Location or Organization name and trial days. Sidebar Location switcher when granted more than one site |
PATCH /app/workspace | app/workspaces#update | Set session current Location or Organization scope |
GET /app/billing | app/billings#show | Redirects to Settings → Billing |
POST/DELETE /app/billing/plan_change | app/billing/plan_changes | Upgrade now or schedule/cancel downgrade |
GET /app/settings | app/settings#show | Organization settings: hierarchy, legal entities, locations, Billing (plans, card, upgrades/downgrades, invoices) |
PATCH /app/settings | app/settings#update | Rename the current Organization |
GET /app/staffs | app/staffs#index | Redirects to Settings → Users & Roles |
POST /app/staffs | app/staffs#create | Organization administrator invites a user (pending Staff) |
DELETE /app/staffs/:id | app/staffs#destroy | Withdraw a pending invite (Staff only; User unchanged) |
PATCH /app/staffs/:id/resend | app/staffs#resend | Record another send on a pending invite (token rotated; delivery not built) |
PATCH /app/staffs/:id/disable | app/staffs#disable | Disable an accepted membership (disabled_at; User unchanged). Last enabled Organization administrator cannot be disabled |
PATCH /app/staffs/:id/enable | app/staffs#enable | Re-enable a disabled membership |
POST/DELETE /app/staff_role_assignments | app/staff_role_assignments | Assign or revoke catalog roles on Staff |
| Legal entity / location CRUD | app/legal_entities, app/locations | Staff Settings CRUD. Hours and rooms/equipment on the Location ficha |
GET /app/patients | app/patients#index | Organization-scoped patient list (enabled living by default; disabled / deceased / all) |
GET /app/patients/:id | app/patients#show | Chart tabs: overview, vaccines, conditions, vets, notes |
GET /app/schedule | app/schedules#show | Location appointment calendar |
GET /app/whiteboard | app/whiteboards#show | Location Floor / Pipeline visits board |
GET/PATCH /app/visits/:id/soap_note | app/soap_notes | Per-visit SOAP (read via patients; write via soap.manage) |
GET /app/catalog | app/catalog_items#index | Master catalog and Location overrides |
GET /app/inventory | app/inventory_items#index | Per-Location on-hand stock |
GET/POST /admin/login | admin/sessions#new/create | SuperUserSession only |
DELETE /admin/logout | admin/sessions#destroy | Clears SuperUser session |
GET /admin | admin/homes#show | Dashboard; tab and scope query params; staff Users rejected |
GET /admin/users | admin/users#index | Staff accounts table (Impersonate) |
POST /admin/users/:user_id/impersonation | admin/impersonations#create | Session overlay: /app as that User; SuperUser session stays |
DELETE /admin/impersonation | admin/impersonations#destroy | Stop impersonating; back to /admin/users |
GET /admin/super_users | admin/super_users#index | Platform operators (SuperUser CRUD) |
GET /admin/organizations | admin/organizations#index | Organizations directory (name, location count, remaining trial days, created) |
PATCH /admin/organizations/:id | admin/organizations#update | Set remaining trial days for one Organization |
GET /admin/roles | admin/roles#index | Global RBAC role catalog (SuperUser CRUD) |
GET /admin/actions | admin/actions#index | Global RBAC action catalog (SuperUser CRUD) |
GET /admin/plans | admin/plans | SaaS plans; marketing copy via Translations link |
GET /admin/translations | admin/translations | DB locales for plans and app features |
GET /admin/audits | admin/audits#index | Account field audit log |
GET /admin/settings | admin/settings#show | Platform operator settings |
PATCH /admin/settings | admin/settings#update | Default free-trial length in days |
GET /locale/:locale | locales#update | Sets locale cookie and redirects back |
GET /privacy /terms | main#privacy/terms | Legal pages from i18n |
POST /facebook/deletion | facebook_deletions#create | Facebook data deletion callback |
GET /facebook/deletion | facebook_deletions#show | Confirmation status page |
GET /up | rails/health#show | Load balancer health |
Landing marketing uses Active Record Plan, AppFeature, and Translation (Admin /admin/translations). FAQs stay in YAML via PricingFaq. Shared UI helpers: SocialLogin, LegalDocument, MarketingUi, AppUi, AdminUi, Admin::Dashboard, Admin::AuditLog / AuditEntry / AuditChange, Admin::PlatformSettings, AppLocale, Api::V1::SessionPayload. Auth and tenancy are Active Record: User, SuperUser, ApiToken, Organization, LegalEntity, Location, Staff, PlatformSetting, RBAC models, sessions, SocialAuth, SocialIdentity. Facebook deletion uses FacebookSignedRequest and FacebookDeletion.
Status vocabulary
These words are not interchangeable. Use the code names in docs, wiki, UI copy, and APIs.
| Term | Meaning | Code |
|---|---|---|
| Enable / disable | Policy switch: allowed to work or appear in pickers | disabled_at on Patient, Client, Staff, and CatalogItem; enabled?; Location catalog override uses nullable enabled |
| Active | Real-time running state | Treatment plan status: "active"; visit on_floor? |
| Available | Room or equipment in service | location_resources.available |
| Admin active users | Recency, not enable/disable | active_users = signed in within 7 days |
Section design
Every /app section follows the pattern set by Patients (/app/patients). Copy it instead of inventing a new layout. The full component rules live in docs/architecture/design-system.md.
| Piece | Partial | Rule |
|---|---|---|
| Page header | app/shared/_page_header | Title, one-line lead, optional back eyebrow, actions right. One per page |
| Section tabs | app/shared/_subnav in page-header :actions | Always right-aligned with the title row. Pass :wrap_class => nil so pills sit in PAGE_ACTIONS, not a full-width bar under the lead. Examples: patient chart, location ficha, settings billing sections |
| Filters and search | app/shared/_subnav / table_search | Full-width bar below the header (SUBNAV_BAR) when the row includes search or filter pills. With :path they navigate; without it they switch client-side panels (subnav Stimulus controller) |
| Identity card | e.g. app/patients/_identity | Show-page header: avatar, name, badges, then vitals and alerts |
| Vitals strip | app/shared/_vitals | Four stable facts in a hairline grid, label above value |
| Section card | app/shared/_section | Icon tile, title, lead, content. Rendered as a layout with a block |
| Facts | app/shared/_fact | Label / value pair inside AppUi::FACT_GRID |
| Empty state | app/shared/_empty_state | Neutral tile, one sentence, optional action. Never leave a card blank |
| Form save bar | app/shared/_form_actions | Sticky bar: hint, Cancel, one primary submit. Fields use AppUi::FIELD wrappers inside FORM_BODY |
Tones carry meaning, not decoration: neutral (facts), brand (identity), success (verified, opted in), caution (needs attention soon), danger (expired, blocking). The tone is decided in Ruby (PatientVaccine#ui_tone, PatientTag#ui_tone, PatientHistory::Entry#tone) and rendered through AppUi.chip, chip_solid, tile, dot, panel, and rail_dot. Each helper emits one tp-* class plus one tp-tone-* class; taskpaws.css derives fill, border, and ink from a single --tp-tone with color-mix, so adding a tone is one CSS block.
Views stay declarative: aggregation lives in POROs (PatientDirectory, PatientSummary) and labels live on the record (Patient#age_label, #sex_label, #species_label, #weight_label). Chart dates use the :chart_date i18n format.
Data and secrets
PostgreSQL in every environment. Host, port, username, and password come from Rails.application.credentials.dig(:database, :production) and are reused; only the database name changes.
| Environment | Databases |
|---|---|
| development | taskpaws_development |
| test | taskpaws_test |
| production | taskpaws, taskpaws_cache, taskpaws_queue, taskpaws_cable |
Application tables in db/schema.rb include auth/tenancy, RBAC, clients, patients, appointments/series, visits, soap_notes, catalog, inventory, and related join tables.
Secrets stay in config/credentials.yml.enc (needs gitignored config/master.key). Credential keys: database, amazon.s3, amazon.ses, amazon.sms, super_user, google, facebook, apple, active_record_encryption. Edit with bin/rails credentials:edit from platform/.
OAuth redirect URIs (local): http://localhost:3000/auth/google_oauth2/callback, /auth/facebook/callback, /auth/apple/callback.
Storage
config/storage.yml defines Disk (local, test) as unused fallbacks and Amazon S3 (amazon). Every environment uses :amazon. Buckets follow taskpaws-<%= Rails.env %> in us-east-1 (taskpaws-development, taskpaws-test, taskpaws-production).
Tenancy
The tenant is an Organization. It owns 1..N Legal Entities (fiscal identity) and 1..N Locations (physical sites). Each Location belongs to exactly one Legal Entity. Location is not the legal or billing identity. User is an independent login. A Staff record is membership in an Organization. Founder /register calls Organization.provision!.
Day-to-day clinic work is scoped to a Location. Patient charts and master catalog are Organization-scoped. Invoice headers and payouts follow the Location’s Legal Entity.
Product rules: Organization structure. Access: Staff access.
The header shows the current Location or Organization name and remaining trial days while on trial. After expiry, a banner links to Settings → Billing. Catalog and inventory appear in the sidebar when the Staff has catalog/inventory read. Settings appears when Staff#can_view_settings?; the Billing tab covers SaaS subscribe, card, plan upgrades/downgrades, and invoices.
Authentication
Two actor types, both Authlogic. Separate models and sessions. Do not collapse them into one table with a flag.
| Actor | Model | Scope | Surfaces |
|---|---|---|---|
| Login identity | User | Independent; clinic access through Staff | /app, /api/v1 |
| Organization membership | Staff | One User + one Organization; pending until accepted; invitation_sent_at / invitation_sent_count; disabled_at suspends without touching User | Founder register and Staff.invite! |
| Platform operator | SuperUser | Entire system | /admin; may impersonate a staff User into /app |
Mandatory attributes: first_name, last_name, email, password. The first SuperUser is seeded from db/seeds.rb; additional operators are created at /admin/super_users. New social visitors finish on /register (clinic name required). Existing social Users log in without provisioning. Social-only Users get a random Authlogic password they never see.
Not built: invite email / accept-by-token page, geocoding, Admin tenant CRUD, Customer onboarding, social login on the API, SuperUser on the API, partner API keys, CORS. Location switcher / current Location on /app, SuperUser CRUD, RBAC catalog CRUD, Staff Settings CRUD, Staff role assignment, staff invite, per-org trial days, clinic workflows (patients, schedule, visits, SOAP, catalog, inventory), and the staff JSON API ship.
Admin console
Dedicated Rails namespace at /admin. Only SuperUser sessions. Lists platform records, CRUDs SuperUsers, CRUDs the global RBAC catalog (Roles, Actions), sets remaining trial days per Organization plus the default free-trial length, and lists SaaS billing transactions. Changing an Organization’s plan_id while subscribed runs Billing::PlanChange. SuperUsers can impersonate a staff User from /admin/users (session overlay; no UserSession). It does not create or edit tenant structure (founder register and staff Settings provision them). Next: tenant CRUD from Admin, and opening any organization workspace as a SuperUser (elevated).
Live boards
Schedule and Whiteboard subscribe to ClinicBoardChannel while the board is open. The stream is the Organization, the workspace Location, and the Staff. An appointment, series, or visit change broadcasts { "action": "refresh" }. The web page then Turbo-refreshes. Each browser keeps its own socket.
Mobile, when those screens ship, uses that same signal and then reloads over the existing HTTP load. Subscribe only while the board is open and a Location is selected. Hold the reload while a draft is open. Load once when the app returns to the foreground, because iOS suspends the socket. Pull-to-refresh stays. Push does not paint the board.
The cable connection today trusts the web session cookie. The phone must send its ApiToken as Authorization: Bearer on the handshake (ApiToken.lookup). Do not put the token in the cable URL, and do not send persistence_token. GET /api/v1/home still needs the current Staff id before the client can subscribe. Full contract: docs/architecture/live-boards.md.
Hands-off uploads
Every staff file field offers one menu: use this device (file browser on desktop, camera on a phone or tablet) or show a QR code. Any phone scans the code and uploads without signing in. The file appears live on the open form over PhotoUploadChannel and is attached to the record only when staff save, so it works on new records too. This is the single approach for profiles (Client and Patient photos, shipped), documents (vaccine certificates, consents, external results), and captures (SOAP, observation, and sample photos).
Each PhotoUploadSession issues two signed_id tokens. The upload token lives in the QR, expires in 15 minutes, and works once. The claim token stays in the staff browser, watches the cable stream, and attaches the blob on save, only within the same Organization. Unclaimed sessions are purged hourly after a day. Documents add PDFs and captures allow several files per code; both reuse the same session rather than a parallel mechanism. Full contract and extension recipe: docs/architecture/hands-off-uploads.md.
API and mobile
Mobile and other systems talk to platform/ over HTTP as a User. Super users are a web concern. Cookie UserSession stays on /login; the API never sets it. Api::BaseController inherits ActionController::API.
| Method | Path | Auth | Result |
|---|---|---|---|
POST | /api/v1/session | none | 201 { token, user, organizations } |
GET | /api/v1/session | Authorization: Bearer | 200 { user, organizations } |
DELETE | /api/v1/session | Bearer | 204; destroys that token |
GET | /api/v1/home | Bearer | user, organization or null, location or null, capabilities |
GET | /api/v1/billing | Bearer + accepted Staff | trial, plan, credits, subscription (incl. pending plan change) |
GET / PATCH | /api/v1/organization | settings viewer / org manager | hierarchy, counts, rename |
GET / POST / PATCH / DELETE | /api/v1/legal_entities | org manager | CRUD; last entity and locations-present 422 |
GET / POST / PATCH / DELETE | /api/v1/locations | settings viewer; create/destroy org manager; patch may_manage_location? | CRUD; last location 422 |
GET / POST / DELETE / PATCH | /api/v1/staffs | assign roles / invite | list; invite; resend/withdraw pending; disable/enable (pending_days, invitation_sent_count, disabled) |
POST / DELETE | /api/v1/staff_role_assignments | may_assign? / may_revoke? | 201 / 204 |
Login body is { "email", "password" }. ApiToken.authenticate uses User#valid_password?. The plaintext token is returned once; api_tokens.token_digest stores SHA256 of the token plus secret_key_base. Never put the token in the query string. organizations is enabled Staff only; empty array is valid. Org-scoped resources accept optional X-Organization-Id (default: primary enabled Staff) and optional X-Location-Id (must be granted). Session create/show/destroy ignore those headers. Errors are { "error": { "code", "message" } }: 401 unauthorized, 403 forbidden, 404 not found, 400 bad request, 422 unprocessable.
Not in this slice: social login on the API, SuperUser, partner API keys, register, CORS. Expo SDK 57, React 19.2, React Native 0.86 live in mobile/. Native ios/ and android/ are gitignored until prebuild. Schedule and Whiteboard live updates: Live boards.
Background work
Solid Queue. Procfile.dev runs web + Tailwind watcher; run bin/jobs separately in development. Production recurring work in config/recurring.yml currently only clears finished Solid Queue jobs.
Internationalization
en default, es supported. Cookie from the marketing nav, /app profile menu, or Admin account menu (GET /locale/:locale) wins over Accept-Language. html[lang] follows the locale.
Two string systems: YAML UI copy in en.yml / es.yml (legal in legal.*.yml); landing plan/feature marketing in the translations table, edited at Admin /admin/translations. Mobile chrome uses mobile/src/i18n/locales/*.json. Full create/maintain guide for Views, API, and Mobile: see docs/architecture/translations.md in the repo.
Tests and CI
bin/ci runs setup, RuboCop (omakase), bundler-audit, importmap audit, Brakeman, Rails tests, and a seed replant. Controller coverage: marketing/legal, register, login, logout, OmniAuth mocks, /api/v1 (session, home, billing, organization, legal entities, locations, staff invite, role assignment), /app (greeting, header org name, trial header and expired banner), organization settings (hierarchy map, legal entities, locations, role assignment, staff invite), locale switch, Admin dashboard/users/super_users/organizations/roles/actions/audits/settings.
Conventions
- All documentation is written in English.
- Fat models, thin controllers. POROs are fine when there is no table yet.
- Prefer objects over helpers and service objects.
- Hash rockets in Ruby (
:key => value). - Snake case for Ruby files and identifiers.