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

PathRoleStatus
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

Browser ──Hotwire──► platform (Rails 8.1, Puma) iOS/Android (Expo) ──API──► platform │ ├── PostgreSQL ├── Solid Queue / Cache / Cable (production extra DBs) └── Active Storage (S3 per environment)
LayerChoiceNotes
LanguageRuby 4.0.2platform/.ruby-version
FrameworkRails 8.1.3config.load_defaults 8.1
DatabasePostgreSQL via pgConnection from Rails credentials
Web UIHotwire, import maps, PropshaftStimulus mobile-nav for marketing header; sidebar for the /app icon rail; searchable-selects and form-validation on every shell
CSSTailwind CSS 4 + taskpaws.cssTokens in app/assets/tailwind/application.css
IconsLucide 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 / cableSolid Queue, Cache, CableMemory/async in development
AuthAuthlogicSeparate User and SuperUser; bcrypt. API uses hashed ApiToken Bearer tokens, not cookies
GeocodingGeocoder (intended)Address → lat/lng on Location; PostGIS later if needed
AuditAuditedaudits table; names and email on User/SuperUser
FilesActive StorageDisk now; Amazon S3 adapter defined
i18nen default, esCookie 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.

RouteActionBehavior
GET /main#landingFeatures, pricing, FAQs, trial CTA
GET/POST /loginuser_sessions#new/createStaff session → /app
DELETE /logoutuser_sessions#destroyClears staff session
GET/POST /registerusers#new/createCreates User, provisions Organization + Legal Entity + Location + Staff, session → /app
POST /auth/:providerOmniAuthGoogle, Apple, Facebook for User only
GET/POST /auth/:provider/callbackomniauth_callbacks#createExisting User: session → /app. New visitor finishes on /register
GET /appapp/homes#showWorkspace home. Header shows Location or Organization name and trial days. Sidebar Location switcher when granted more than one site
PATCH /app/workspaceapp/workspaces#updateSet session current Location or Organization scope
GET /app/billingapp/billings#showRedirects to Settings → Billing
POST/DELETE /app/billing/plan_changeapp/billing/plan_changesUpgrade now or schedule/cancel downgrade
GET /app/settingsapp/settings#showOrganization settings: hierarchy, legal entities, locations, Billing (plans, card, upgrades/downgrades, invoices)
PATCH /app/settingsapp/settings#updateRename the current Organization
GET /app/staffsapp/staffs#indexRedirects to Settings → Users & Roles
POST /app/staffsapp/staffs#createOrganization administrator invites a user (pending Staff)
DELETE /app/staffs/:idapp/staffs#destroyWithdraw a pending invite (Staff only; User unchanged)
PATCH /app/staffs/:id/resendapp/staffs#resendRecord another send on a pending invite (token rotated; delivery not built)
PATCH /app/staffs/:id/disableapp/staffs#disableDisable an accepted membership (disabled_at; User unchanged). Last enabled Organization administrator cannot be disabled
PATCH /app/staffs/:id/enableapp/staffs#enableRe-enable a disabled membership
POST/DELETE /app/staff_role_assignmentsapp/staff_role_assignmentsAssign or revoke catalog roles on Staff
Legal entity / location CRUDapp/legal_entities, app/locationsStaff Settings CRUD. Hours and rooms/equipment on the Location ficha
GET /app/patientsapp/patients#indexOrganization-scoped patient list (enabled living by default; disabled / deceased / all)
GET /app/patients/:idapp/patients#showChart tabs: overview, vaccines, conditions, vets, notes
GET /app/scheduleapp/schedules#showLocation appointment calendar
GET /app/whiteboardapp/whiteboards#showLocation Floor / Pipeline visits board
GET/PATCH /app/visits/:id/soap_noteapp/soap_notesPer-visit SOAP (read via patients; write via soap.manage)
GET /app/catalogapp/catalog_items#indexMaster catalog and Location overrides
GET /app/inventoryapp/inventory_items#indexPer-Location on-hand stock
GET/POST /admin/loginadmin/sessions#new/createSuperUserSession only
DELETE /admin/logoutadmin/sessions#destroyClears SuperUser session
GET /adminadmin/homes#showDashboard; tab and scope query params; staff Users rejected
GET /admin/usersadmin/users#indexStaff accounts table (Impersonate)
POST /admin/users/:user_id/impersonationadmin/impersonations#createSession overlay: /app as that User; SuperUser session stays
DELETE /admin/impersonationadmin/impersonations#destroyStop impersonating; back to /admin/users
GET /admin/super_usersadmin/super_users#indexPlatform operators (SuperUser CRUD)
GET /admin/organizationsadmin/organizations#indexOrganizations directory (name, location count, remaining trial days, created)
PATCH /admin/organizations/:idadmin/organizations#updateSet remaining trial days for one Organization
GET /admin/rolesadmin/roles#indexGlobal RBAC role catalog (SuperUser CRUD)
GET /admin/actionsadmin/actions#indexGlobal RBAC action catalog (SuperUser CRUD)
GET /admin/plansadmin/plansSaaS plans; marketing copy via Translations link
GET /admin/translationsadmin/translationsDB locales for plans and app features
GET /admin/auditsadmin/audits#indexAccount field audit log
GET /admin/settingsadmin/settings#showPlatform operator settings
PATCH /admin/settingsadmin/settings#updateDefault free-trial length in days
GET /locale/:localelocales#updateSets locale cookie and redirects back
GET /privacy /termsmain#privacy/termsLegal pages from i18n
POST /facebook/deletionfacebook_deletions#createFacebook data deletion callback
GET /facebook/deletionfacebook_deletions#showConfirmation status page
GET /uprails/health#showLoad 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.

TermMeaningCode
Enable / disablePolicy switch: allowed to work or appear in pickersdisabled_at on Patient, Client, Staff, and CatalogItem; enabled?; Location catalog override uses nullable enabled
ActiveReal-time running stateTreatment plan status: "active"; visit on_floor?
AvailableRoom or equipment in servicelocation_resources.available
Admin active usersRecency, not enable/disableactive_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.

PiecePartialRule
Page headerapp/shared/_page_headerTitle, one-line lead, optional back eyebrow, actions right. One per page
Section tabsapp/shared/_subnav in page-header :actionsAlways 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 searchapp/shared/_subnav / table_searchFull-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 carde.g. app/patients/_identityShow-page header: avatar, name, badges, then vitals and alerts
Vitals stripapp/shared/_vitalsFour stable facts in a hairline grid, label above value
Section cardapp/shared/_sectionIcon tile, title, lead, content. Rendered as a layout with a block
Factsapp/shared/_factLabel / value pair inside AppUi::FACT_GRID
Empty stateapp/shared/_empty_stateNeutral tile, one sentence, optional action. Never leave a card blank
Form save barapp/shared/_form_actionsSticky 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.

EnvironmentDatabases
developmenttaskpaws_development
testtaskpaws_test
productiontaskpaws, 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!.

Organization ├── 1 ──* Legal Entity (EIN, legal name, bank, merchant account) │ └── 1 ──* Location │ ├── address, geolocation (lat/lng; empty until geocoded) │ └── time_zone (ActiveSupport::TimeZone name) ├── 1 ──* Staff (membership; role assignments; pending / disabled) │ └── belongs_to User │ └── accepted_at / invitation_token / invitation_sent_at / invitation_sent_count / disabled_at │ └── StaffRoleAssignment → Role (global catalog) └── 1 ──* CatalogItem (master or exclusive; inherit-or-override; stock per Location) └── 1 ──* Client / Patient (disabled_at; patient also deceased_at) └── appointments, visits → soap_notes User (login; no organization_id) SuperUser (platform operator; no organization_id)

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.

ActorModelScopeSurfaces
Login identityUserIndependent; clinic access through Staff/app, /api/v1
Organization membershipStaffOne User + one Organization; pending until accepted; invitation_sent_at / invitation_sent_count; disabled_at suspends without touching UserFounder register and Staff.invite!
Platform operatorSuperUserEntire 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.

MethodPathAuthResult
POST/api/v1/sessionnone201 { token, user, organizations }
GET/api/v1/sessionAuthorization: Bearer200 { user, organizations }
DELETE/api/v1/sessionBearer204; destroys that token
GET/api/v1/homeBeareruser, organization or null, location or null, capabilities
GET/api/v1/billingBearer + accepted Stafftrial, plan, credits, subscription (incl. pending plan change)
GET / PATCH/api/v1/organizationsettings viewer / org managerhierarchy, counts, rename
GET / POST / PATCH / DELETE/api/v1/legal_entitiesorg managerCRUD; last entity and locations-present 422
GET / POST / PATCH / DELETE/api/v1/locationssettings viewer; create/destroy org manager; patch may_manage_location?CRUD; last location 422
GET / POST / DELETE / PATCH/api/v1/staffsassign roles / invitelist; invite; resend/withdraw pending; disable/enable (pending_days, invitation_sent_count, disabled)
POST / DELETE/api/v1/staff_role_assignmentsmay_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