1256 lines
67 KiB
Markdown
1256 lines
67 KiB
Markdown
# Balinyaar — Database Model
|
||
|
||
## Platform Summary
|
||
|
||
Balinyaar is a hybrid home nursing marketplace in Iran. Independent nurses (and in future, nursing company employees) register, list configurable services with their own pricing, and undergo a multi-step verification pipeline. Families search, pick a nurse and service variant, submit a booking request, and pay through the platform after the nurse accepts. The platform holds payment in escrow and releases weekly payouts to nurses after service completion is confirmed via EVV. All post-booking communication runs through a ticket system inspectable by admins.
|
||
|
||
---
|
||
|
||
## Design Principles
|
||
|
||
1. **Monetary values** stored as `BIGINT` in Iranian Rials (IRR). Toman conversion is a display concern only.
|
||
2. **PII fields** (national ID, IBAN, phone, addresses, clinical data) are marked **(encrypted)** — column-level or application-level encryption; actual mechanism is implementation-specific.
|
||
3. **Soft deletes** on `users` and `nurse_profiles` via `deleted_at`. Audit and payment records are never deleted.
|
||
4. **Audit trail** is append-only. All state transitions on bookings, payments, verifications, and reviews produce a row in `audit_logs`.
|
||
5. **Enum-like fields** use `NVARCHAR(50)` with application-enforced constraints. Admin-managed catalog tables (ServiceCategory, VerificationStepType) are rows, not enums — they can be extended without schema changes.
|
||
6. **Platform fee** is captured at booking time and never derived from current config, so historical records remain accurate after fee changes.
|
||
7. **Snapshot fields** (e.g., `variant_snapshot_json`, `address_snapshot_json` on bookings) protect historical accuracy from future edits to linked records.
|
||
8. **Multi-language** — all admin-managed catalog tables carry `name_fa` / `name_en` pairs.
|
||
9. **All timestamps** are `DATETIME2(7)` in UTC. Persian calendar display is a UI concern.
|
||
10. **String fields** use `NVARCHAR` throughout to support Persian/Arabic characters.
|
||
|
||
---
|
||
|
||
## Entity Catalog
|
||
|
||
---
|
||
|
||
### Domain 1 — Identity & Access
|
||
|
||
---
|
||
|
||
#### `users`
|
||
|
||
The single identity record for every human actor on the platform: nurses, customers, and admin staff. Role determines which profile sub-table is populated. Phone number is the primary login credential (OTP); email is secondary. National ID is populated only after the KYC step completes.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `email` | NVARCHAR(255) UNIQUE NULL | **(encrypted)** — optional at registration; required for admin accounts |
|
||
| `phone` | NVARCHAR(20) UNIQUE NOT NULL | **(encrypted)** — used for OTP login and Shahkar matching |
|
||
| `national_id` | NVARCHAR(20) UNIQUE NULL | **(encrypted)** — populated after KYC identity step passes |
|
||
| `national_id_verified_at` | DATETIME2 NULL | |
|
||
| `first_name` | NVARCHAR(100) NOT NULL | |
|
||
| `last_name` | NVARCHAR(100) NOT NULL | |
|
||
| `avatar_url` | NVARCHAR(512) NULL | Object-storage URL |
|
||
| `role` | NVARCHAR(20) NOT NULL | `nurse` / `customer` / `admin` |
|
||
| `is_active` | BIT NOT NULL DEFAULT 1 | False = account suspended (not deleted) |
|
||
| `email_verified_at` | DATETIME2 NULL | |
|
||
| `phone_verified_at` | DATETIME2 NULL | Set on first successful OTP |
|
||
| `last_login_at` | DATETIME2 NULL | |
|
||
| `last_login_ip` | NVARCHAR(45) NULL | IPv4 or IPv6 |
|
||
| `preferred_language` | NVARCHAR(5) NOT NULL DEFAULT 'fa' | `fa` / `en` |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
| `updated_at` | DATETIME2 NOT NULL | |
|
||
| `deleted_at` | DATETIME2 NULL | Soft delete — NULL = active |
|
||
|
||
---
|
||
|
||
#### `user_sessions`
|
||
|
||
Auth session records for refresh token management. Each login creates a session. Logging out or detecting a stolen token invalidates the session.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `user_id` | BIGINT FK → users NOT NULL | |
|
||
| `refresh_token_hash` | NVARCHAR(64) NOT NULL | SHA-256 of the issued refresh token |
|
||
| `device_info` | NVARCHAR(500) NULL | User-agent string |
|
||
| `ip_address` | NVARCHAR(45) NULL | |
|
||
| `is_revoked` | BIT NOT NULL DEFAULT 0 | |
|
||
| `revoked_at` | DATETIME2 NULL | |
|
||
| `expires_at` | DATETIME2 NOT NULL | |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
#### `roles`
|
||
|
||
RBAC roles for admin staff. Nurses and customers do not use this table — their permissions are determined by the `users.role` field.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `name` | NVARCHAR(50) UNIQUE NOT NULL | `super_admin` / `admin` / `support` / `finance` / `moderator` |
|
||
| `description` | NVARCHAR(500) NULL | |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
#### `user_roles`
|
||
|
||
Assignment of admin roles to admin users. A user may hold multiple roles. Revoked roles retain history.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `user_id` | BIGINT FK → users NOT NULL | Must be a user with role=admin |
|
||
| `role_id` | BIGINT FK → roles NOT NULL | |
|
||
| `granted_by_user_id` | BIGINT FK → users NULL | |
|
||
| `granted_at` | DATETIME2 NOT NULL | |
|
||
| `revoked_at` | DATETIME2 NULL | NULL = still active |
|
||
|
||
---
|
||
|
||
#### `nurse_profiles`
|
||
|
||
Extended data for users with `role = nurse`. Separated from `users` to avoid a bloated base table. Denormalized aggregates (`average_rating`, `total_completed_bookings`) are maintained on every review publication and booking completion — never calculated on-the-fly in search queries.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `user_id` | BIGINT FK → users UNIQUE NOT NULL | 1:1 |
|
||
| `bio` | NVARCHAR(2000) NULL | Displayed on nurse profile page |
|
||
| `years_of_experience` | SMALLINT NULL | |
|
||
| `education_level` | NVARCHAR(50) NULL | `bachelor` / `master` / `phd` / `associate` |
|
||
| `education_field` | NVARCHAR(200) NULL | e.g., "پرستاری — دانشگاه تهران" |
|
||
| `specializations_json` | NVARCHAR(MAX) NULL | JSON array of specialization tag strings |
|
||
| `is_verified` | BIT NOT NULL DEFAULT 0 | True only when all required verification steps pass |
|
||
| `verification_status` | NVARCHAR(30) NOT NULL DEFAULT 'not_started' | `not_started` / `pending` / `in_review` / `approved` / `rejected` / `suspended` |
|
||
| `is_accepting_bookings` | BIT NOT NULL DEFAULT 0 | Nurse can toggle without losing verified status |
|
||
| `average_rating` | DECIMAL(3,2) NULL | Denormalized; recalculated on each published review |
|
||
| `total_reviews` | INT NOT NULL DEFAULT 0 | Denormalized count of published reviews |
|
||
| `total_completed_bookings` | INT NOT NULL DEFAULT 0 | Denormalized |
|
||
| `response_rate` | DECIMAL(5,2) NULL | % of booking requests responded to before expiry |
|
||
| `avg_response_time_hours` | DECIMAL(5,2) NULL | Rolling average for display on profile |
|
||
| `profile_completion_score` | SMALLINT NOT NULL DEFAULT 0 | 0–100, shown in backoffice to surface incomplete profiles |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
| `updated_at` | DATETIME2 NOT NULL | |
|
||
| `deleted_at` | DATETIME2 NULL | Soft delete |
|
||
|
||
---
|
||
|
||
#### `customer_profiles`
|
||
|
||
Extended profile for users with `role = customer`. Intentionally lightweight — most customer data lives in their bookings and patients.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `user_id` | BIGINT FK → users UNIQUE NOT NULL | 1:1 |
|
||
| `national_id_verified_at` | DATETIME2 NULL | Anti-fraud KYC for high-value customers (optional at launch) |
|
||
| `default_emergency_contact_name` | NVARCHAR(200) NULL | **(encrypted)** — overridable per booking |
|
||
| `default_emergency_contact_phone` | NVARCHAR(20) NULL | **(encrypted)** |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
| `updated_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
#### `patients`
|
||
|
||
The person receiving care. Fully separated from the customer because the payer (adult child, spouse) is often not the patient (elderly parent, newborn, post-surgical adult). A customer may register multiple patients. Patient medical data is encrypted at rest.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `customer_id` | BIGINT FK → customer_profiles NOT NULL | Record owner |
|
||
| `display_name` | NVARCHAR(200) NOT NULL | e.g., "پدر", "مادر بزرگ", "نوزاد" |
|
||
| `first_name` | NVARCHAR(100) NULL | |
|
||
| `last_name` | NVARCHAR(100) NULL | |
|
||
| `birth_date` | DATE NULL | |
|
||
| `gender` | NVARCHAR(10) NULL | `male` / `female` |
|
||
| `blood_type` | NVARCHAR(5) NULL | A+, A-, B+, B-, O+, O-, AB+, AB- |
|
||
| `initial_medical_notes` | NVARCHAR(MAX) NULL | **(encrypted)** — baseline context set by the family |
|
||
| `is_active` | BIT NOT NULL DEFAULT 1 | Soft-archivable |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
| `updated_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
#### `customer_addresses`
|
||
|
||
Saved service locations for a customer. Used in booking requests to tell the nurse where to go. The full address is encrypted. Coordinates are stored for EVV distance matching at check-in.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `customer_id` | BIGINT FK → customer_profiles NOT NULL | |
|
||
| `title` | NVARCHAR(100) NOT NULL | e.g., "خانه", "خانهی پدری" |
|
||
| `address_line1` | NVARCHAR(500) NOT NULL | **(encrypted)** |
|
||
| `address_line2` | NVARCHAR(500) NULL | **(encrypted)** |
|
||
| `city_id` | BIGINT FK → cities NOT NULL | |
|
||
| `district_id` | BIGINT FK → districts NULL | |
|
||
| `postal_code` | NVARCHAR(20) NULL | |
|
||
| `latitude` | DECIMAL(10,7) NULL | For EVV match tolerance check |
|
||
| `longitude` | DECIMAL(10,7) NULL | |
|
||
| `access_notes` | NVARCHAR(500) NULL | **(encrypted)** — door code, building instructions, unit number |
|
||
| `is_primary` | BIT NOT NULL DEFAULT 0 | One primary per customer |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
| `updated_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
#### `nurse_bank_accounts`
|
||
|
||
Banking details for nurse payout. IBAN (Sheba) is encrypted. Multiple accounts allowed; exactly one marked primary receives weekly payouts. Verified by admin before first payout.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `nurse_id` | BIGINT FK → nurse_profiles NOT NULL | |
|
||
| `bank_name` | NVARCHAR(100) NOT NULL | |
|
||
| `account_holder_name` | NVARCHAR(200) NOT NULL | **(encrypted)** |
|
||
| `iban` | NVARCHAR(34) NOT NULL | **(encrypted)** — IR + 24 digits (Sheba) |
|
||
| `is_primary` | BIT NOT NULL DEFAULT 0 | |
|
||
| `is_verified` | BIT NOT NULL DEFAULT 0 | Admin confirmed the account exists and matches nurse identity |
|
||
| `verified_by_admin_id` | BIGINT FK → users NULL | |
|
||
| `verified_at` | DATETIME2 NULL | |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
| `updated_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
### Domain 2 — Geographic Data
|
||
|
||
---
|
||
|
||
#### `provinces`
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `name_fa` | NVARCHAR(100) NOT NULL | |
|
||
| `name_en` | NVARCHAR(100) NOT NULL | |
|
||
| `is_active` | BIT NOT NULL DEFAULT 1 | |
|
||
|
||
---
|
||
|
||
#### `cities`
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `province_id` | BIGINT FK → provinces NOT NULL | |
|
||
| `name_fa` | NVARCHAR(100) NOT NULL | |
|
||
| `name_en` | NVARCHAR(100) NOT NULL | |
|
||
| `is_active` | BIT NOT NULL DEFAULT 1 | |
|
||
| `sort_order` | SMALLINT NOT NULL DEFAULT 0 | For ordered dropdowns |
|
||
|
||
---
|
||
|
||
#### `districts`
|
||
|
||
Sub-city units (محله / منطقه). Granularity varies: in Tehran these map to the 22 official municipal districts; in smaller cities they may be major neighborhoods. Districts are optional — nurses can cover a whole city without specifying districts.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `city_id` | BIGINT FK → cities NOT NULL | |
|
||
| `name_fa` | NVARCHAR(200) NOT NULL | |
|
||
| `name_en` | NVARCHAR(200) NULL | |
|
||
| `is_active` | BIT NOT NULL DEFAULT 1 | |
|
||
|
||
---
|
||
|
||
#### `nurse_service_areas`
|
||
|
||
Defines where a nurse is willing to travel. A nurse can cover multiple cities and optionally specific districts within each. A row with `district_id = NULL` means the nurse covers the entire city. This table drives the geographic filter in nurse search.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `nurse_id` | BIGINT FK → nurse_profiles NOT NULL | |
|
||
| `city_id` | BIGINT FK → cities NOT NULL | |
|
||
| `district_id` | BIGINT FK → districts NULL | NULL = entire city |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
|
||
**Index:** `(nurse_id, city_id, district_id)` UNIQUE — prevents duplicate area declarations.
|
||
|
||
---
|
||
|
||
### Domain 3 — Nurse Services & Pricing
|
||
|
||
The service model has three admin-defined layers (category → option groups → option values) and two nurse-defined layers (variant → variant options). This design lets admins add new configurable dimensions (e.g., "shift length", "number of patients") without schema changes, and lets each nurse price every combination they choose to offer independently.
|
||
|
||
---
|
||
|
||
#### `service_categories`
|
||
|
||
Admin-managed top-level care types. These are the primary search dimension for customers.
|
||
|
||
Examples: *مراقبت از سالمند* (Elderly Care), *مراقبت پس از جراحی* (Post-Surgery Recovery), *مراقبت از نوزاد* (Infant/Newborn Care), *مدیریت بیماری مزمن* (Chronic Illness Management), *همراهی و کمک روزمره* (Companionship/Daily Living — future tier).
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `name_fa` | NVARCHAR(200) NOT NULL | |
|
||
| `name_en` | NVARCHAR(200) NOT NULL | |
|
||
| `description_fa` | NVARCHAR(1000) NULL | Shown to customers in search |
|
||
| `icon_url` | NVARCHAR(512) NULL | |
|
||
| `is_active` | BIT NOT NULL DEFAULT 1 | |
|
||
| `sort_order` | SMALLINT NOT NULL DEFAULT 0 | |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
| `updated_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
#### `service_option_groups`
|
||
|
||
Admin-defined configurable dimensions per category. Each group represents one axis the nurse can configure. A NULL `service_category_id` means the option group applies across all categories (e.g., shift type).
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `service_category_id` | BIGINT FK → service_categories NULL | NULL = cross-category |
|
||
| `name_fa` | NVARCHAR(200) NOT NULL | e.g., "تعداد بیمار", "نوع شیفت" |
|
||
| `name_en` | NVARCHAR(200) NOT NULL | |
|
||
| `description_fa` | NVARCHAR(500) NULL | Instructions shown to nurse when setting up a listing |
|
||
| `is_required` | BIT NOT NULL DEFAULT 1 | If true, nurse must select a value from this group per variant |
|
||
| `sort_order` | SMALLINT NOT NULL DEFAULT 0 | |
|
||
| `is_active` | BIT NOT NULL DEFAULT 1 | |
|
||
|
||
---
|
||
|
||
#### `service_option_values`
|
||
|
||
Concrete choices within an option group. The nurse selects from these when creating a variant.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `option_group_id` | BIGINT FK → service_option_groups NOT NULL | |
|
||
| `label_fa` | NVARCHAR(200) NOT NULL | e.g., "۱ نفر", "۲ نفر", "شبانهروزی" |
|
||
| `label_en` | NVARCHAR(200) NULL | |
|
||
| `sort_order` | SMALLINT NOT NULL DEFAULT 0 | |
|
||
| `is_active` | BIT NOT NULL DEFAULT 1 | |
|
||
|
||
---
|
||
|
||
#### `nurse_service_variants`
|
||
|
||
The atomic bookable unit on the platform. Each variant is a specific offering by a nurse: a service category with a particular combination of option values, at a specific price. These rows are indexed for search and displayed on the nurse's public profile. A nurse may have many variants per category (one per combination they choose to offer).
|
||
|
||
Example: *Nurse A → Elderly Care → 2 patients → Full-day shift → 3,200,000 IRR per day*
|
||
|
||
The `display_name` is auto-generated from the selected option labels but can be customized by the nurse. The `price_unit` determines how the price is shown (per hour, per session, per day, etc.). For hourly variants, `estimated_duration_hours` gives the minimum or typical commitment so customers can estimate total cost.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `nurse_id` | BIGINT FK → nurse_profiles NOT NULL | |
|
||
| `service_category_id` | BIGINT FK → service_categories NOT NULL | |
|
||
| `display_name` | NVARCHAR(300) NOT NULL | Shown on profile and search results |
|
||
| `price` | BIGINT NOT NULL | In IRR. Never null — every variant must have a price. |
|
||
| `price_unit` | NVARCHAR(20) NOT NULL | `per_hour` / `per_session` / `per_half_day` / `per_day` / `per_24h` |
|
||
| `estimated_duration_hours` | DECIMAL(5,2) NULL | For hourly variants, helps customer estimate total cost |
|
||
| `description` | NVARCHAR(1000) NULL | Nurse's optional notes about this specific variant |
|
||
| `is_active` | BIT NOT NULL DEFAULT 1 | Nurse can deactivate without deleting; deactivated variants cannot be booked |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
| `updated_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
#### `nurse_service_variant_options`
|
||
|
||
Records which option values apply to a variant. Together, these rows define the full configuration of a variant. A variant covering two option groups (e.g., patient count + shift type) will have two rows here.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `variant_id` | BIGINT FK → nurse_service_variants NOT NULL | |
|
||
| `option_group_id` | BIGINT FK → service_option_groups NOT NULL | |
|
||
| `option_value_id` | BIGINT FK → service_option_values NOT NULL | |
|
||
|
||
**Constraint:** `(variant_id, option_group_id)` UNIQUE — one value per dimension per variant.
|
||
|
||
---
|
||
|
||
#### `nurse_availability_slots`
|
||
|
||
Recurring weekly availability windows declared by the nurse. These inform the customer's search (e.g., "available Saturdays 8am–6pm") but are **soft constraints** — the nurse still accepts or rejects each individual booking request. They do not block booking requests for times outside these slots; they are guidance only.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `nurse_id` | BIGINT FK → nurse_profiles NOT NULL | |
|
||
| `day_of_week` | TINYINT NOT NULL | 0=Saturday, 1=Sunday, 2=Monday, 3=Tuesday, 4=Wednesday, 5=Thursday, 6=Friday (Shamsi week) |
|
||
| `start_time` | TIME NOT NULL | |
|
||
| `end_time` | TIME NOT NULL | |
|
||
| `is_active` | BIT NOT NULL DEFAULT 1 | |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
#### `nurse_availability_exceptions`
|
||
|
||
Date-specific overrides to the recurring weekly schedule. Used for vacations, public holidays the nurse wants to block, or one-time extra availability on a normally-off day.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `nurse_id` | BIGINT FK → nurse_profiles NOT NULL | |
|
||
| `exception_date` | DATE NOT NULL | |
|
||
| `is_available` | BIT NOT NULL | False = day blocked; True = day open (even if absent from weekly slots) |
|
||
| `reason` | NVARCHAR(200) NULL | e.g., "تعطیلات عید نوروز" |
|
||
|
||
**Constraint:** `(nurse_id, exception_date)` UNIQUE.
|
||
|
||
---
|
||
|
||
### Domain 4 — Nurse Verification
|
||
|
||
The verification pipeline is a sequence of steps. Each step type is admin-configurable (new steps can be added as rows, no code change). Steps can be automated (KYC API call) or manual (admin reviews uploaded document). The overall `nurse_verifications` record aggregates the step outcomes into a single status.
|
||
|
||
---
|
||
|
||
#### `nurse_verifications`
|
||
|
||
Master verification record for a nurse — one row per nurse, created when the nurse first submits for verification. Every status transition here is captured in `audit_logs`. When all required `verification_steps` reach `passed`, this record's status moves to `approved` and `nurse_profiles.is_verified` flips to true.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `nurse_id` | BIGINT FK → nurse_profiles UNIQUE NOT NULL | 1:1 |
|
||
| `status` | NVARCHAR(30) NOT NULL DEFAULT 'not_started' | `not_started` / `pending` / `in_review` / `approved` / `rejected` / `suspended` |
|
||
| `submitted_at` | DATETIME2 NULL | First submission timestamp |
|
||
| `approved_at` | DATETIME2 NULL | |
|
||
| `rejected_at` | DATETIME2 NULL | |
|
||
| `suspended_at` | DATETIME2 NULL | |
|
||
| `rejection_reason` | NVARCHAR(1000) NULL | Shown to the nurse in the app |
|
||
| `reviewed_by_admin_id` | BIGINT FK → users NULL | Last admin to act on this record |
|
||
| `internal_notes` | NVARCHAR(2000) NULL | Admin-only; not shown to nurse |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
| `updated_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
#### `verification_step_types`
|
||
|
||
Admin-configurable catalog of what steps exist in the pipeline. Adding a new step type (e.g., "professional liability insurance") requires only inserting a row here — no code change needed. The `code` is a stable machine key referenced by application logic for automated steps.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `code` | NVARCHAR(100) UNIQUE NOT NULL | e.g., `identity_kyc`, `moh_license`, `ino_membership`, `criminal_record`, `bank_account_verification` |
|
||
| `name_fa` | NVARCHAR(200) NOT NULL | Shown in backoffice |
|
||
| `name_en` | NVARCHAR(200) NOT NULL | |
|
||
| `description_fa` | NVARCHAR(500) NULL | Instructions shown to nurse for this step |
|
||
| `is_required` | BIT NOT NULL DEFAULT 1 | Non-required steps are advisory; they don't block approval |
|
||
| `is_automated` | BIT NOT NULL DEFAULT 0 | If true, platform calls an external API for this step |
|
||
| `automation_provider` | NVARCHAR(100) NULL | e.g., `finnotech`, `u_id`, `jibbit`, `manual` |
|
||
| `requires_document_upload` | BIT NOT NULL DEFAULT 0 | If true, nurse must upload at least one document |
|
||
| `sort_order` | SMALLINT NOT NULL DEFAULT 0 | Processing order shown to nurse |
|
||
| `is_active` | BIT NOT NULL DEFAULT 1 | |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
#### `verification_steps`
|
||
|
||
One row per step per nurse. Tracks individual step status as the nurse moves through the pipeline. The `external_response_json` stores the raw API response for automated steps — critical for dispute and compliance audit. For manual steps, `reviewed_by_admin_id` and `review_notes` capture who reviewed and what they decided.
|
||
|
||
`expires_at` supports time-limited steps: the criminal record certificate (گواهی عدم سوء پیشینه) is valid for a limited period; when it expires, the step reverts to `pending` and a support alert is raised.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `nurse_verification_id` | BIGINT FK → nurse_verifications NOT NULL | |
|
||
| `step_type_id` | BIGINT FK → verification_step_types NOT NULL | |
|
||
| `status` | NVARCHAR(30) NOT NULL DEFAULT 'pending' | `pending` / `submitted` / `processing` / `passed` / `failed` / `expired` |
|
||
| `is_automated` | BIT NOT NULL | Snapshot from step_type at creation; stored so historical records survive step_type edits |
|
||
| `external_provider` | NVARCHAR(100) NULL | KYC vendor that processed this step |
|
||
| `external_reference_id` | NVARCHAR(200) NULL | Provider's session/transaction ID |
|
||
| `external_response_json` | NVARCHAR(MAX) NULL | Full API response for audit |
|
||
| `reviewed_by_admin_id` | BIGINT FK → users NULL | NULL for automated steps |
|
||
| `review_notes` | NVARCHAR(1000) NULL | Admin rationale for pass/fail decision |
|
||
| `submitted_at` | DATETIME2 NULL | When nurse submitted this step |
|
||
| `completed_at` | DATETIME2 NULL | When step reached terminal state |
|
||
| `expires_at` | DATETIME2 NULL | For time-limited steps (e.g., criminal record cert) |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
| `updated_at` | DATETIME2 NOT NULL | |
|
||
|
||
**Constraint:** `(nurse_verification_id, step_type_id)` UNIQUE.
|
||
|
||
---
|
||
|
||
#### `verification_documents`
|
||
|
||
Documents uploaded as evidence for a verification step. File contents live in object storage (S3-compatible). This table holds the metadata, reference key, and integrity hash. Access to the actual file must be through signed/time-limited URLs — never a direct public URL.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `verification_step_id` | BIGINT FK → verification_steps NOT NULL | |
|
||
| `document_type` | NVARCHAR(50) NOT NULL | `national_id_front` / `national_id_back` / `moh_license` / `ino_certificate` / `criminal_record` / `selfie` / `liveness_video` |
|
||
| `storage_key` | NVARCHAR(512) NOT NULL | Object-storage path — access-controlled, no public URL |
|
||
| `file_hash` | NVARCHAR(64) NOT NULL | SHA-256 of raw file bytes for integrity verification |
|
||
| `file_size_bytes` | INT NULL | |
|
||
| `mime_type` | NVARCHAR(100) NULL | |
|
||
| `is_valid` | BIT NULL | NULL = pending review; True/False after admin assessment |
|
||
| `uploaded_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
### Domain 5 — Booking & Scheduling
|
||
|
||
The booking lifecycle has two distinct phases: the **request phase** (before payment) and the **booking phase** (after payment). Separating them into two tables keeps each table focused and makes lifecycle transitions explicit. A booking only exists if payment succeeded.
|
||
|
||
**Request lifecycle:**
|
||
`pending_nurse_response` → nurse accepts → `accepted_awaiting_payment` → payment received → `converted` (Booking created)
|
||
`pending_nurse_response` → nurse rejects → `rejected_by_nurse`
|
||
`pending_nurse_response` → deadline passes → `expired_no_response`
|
||
`accepted_awaiting_payment` → 30-min payment window passes → `payment_deadline_expired`
|
||
Any pre-payment state → customer cancels → `cancelled_by_customer`
|
||
|
||
**Booking lifecycle:**
|
||
`pending_payment` → payment captured → `confirmed`
|
||
`confirmed` → nurse checks in → `in_progress`
|
||
`in_progress` → nurse checks out → `completed`
|
||
`confirmed` → cancelled before service → `cancelled`
|
||
`completed` → disputed → `disputed`
|
||
`disputed` → resolved → `closed`
|
||
|
||
---
|
||
|
||
#### `booking_requests`
|
||
|
||
A customer's intent to book a nurse for a specific service, date, and time slot. Created when the customer submits a request. Multiple requests can exist for the same customer/nurse pair (e.g., first one rejected, a new one submitted later). No money is involved at this stage.
|
||
|
||
The `nurse_response_deadline_at` is computed from `platform_configs.nurse_response_deadline_hours` at creation time and stored so it is immune to config changes. Similarly, `payment_deadline_at` is set when the nurse accepts and reflects the 30-minute window from that moment.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `customer_id` | BIGINT FK → customer_profiles NOT NULL | |
|
||
| `nurse_id` | BIGINT FK → nurse_profiles NOT NULL | |
|
||
| `patient_id` | BIGINT FK → patients NOT NULL | |
|
||
| `variant_id` | BIGINT FK → nurse_service_variants NOT NULL | The specific service + options the customer wants |
|
||
| `customer_address_id` | BIGINT FK → customer_addresses NOT NULL | |
|
||
| `requested_date` | DATE NOT NULL | |
|
||
| `requested_time_start` | TIME NOT NULL | |
|
||
| `requested_time_end` | TIME NOT NULL | |
|
||
| `customer_notes` | NVARCHAR(1000) NULL | Additional context the customer sends with the request (visible to nurse) |
|
||
| `status` | NVARCHAR(50) NOT NULL DEFAULT 'pending_nurse_response' | See lifecycle above |
|
||
| `nurse_response_deadline_at` | DATETIME2 NOT NULL | After this, job transitions to `expired_no_response` automatically |
|
||
| `payment_deadline_at` | DATETIME2 NULL | Set when nurse accepts; 30-minute countdown starts |
|
||
| `nurse_rejection_reason` | NVARCHAR(500) NULL | Optional message from nurse on rejection |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
| `updated_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
#### `bookings`
|
||
|
||
Confirmed service engagement. Created when nurse accepts AND payment is successfully captured within the deadline. This is the source of truth for the actual service event. The `variant_snapshot_json` and `address_snapshot_json` freeze the service details and address at booking time, protecting historical records from future edits to those referenced entities.
|
||
|
||
The fee fields capture the exact split at the moment of booking: `gross_amount` (what the customer pays), `platform_fee_amount` (platform's share), and `nurse_payout_amount` (what the nurse will receive). The `platform_fee_rate` is stored so auditors can reconstruct how the split was calculated even after the fee schedule changes.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `booking_request_id` | BIGINT FK → booking_requests UNIQUE NOT NULL | 1:1 — the request that created this booking |
|
||
| `customer_id` | BIGINT FK → customer_profiles NOT NULL | Denormalized for query performance |
|
||
| `nurse_id` | BIGINT FK → nurse_profiles NOT NULL | |
|
||
| `patient_id` | BIGINT FK → patients NOT NULL | |
|
||
| `variant_id` | BIGINT FK → nurse_service_variants NOT NULL | Reference — see snapshot below for historical accuracy |
|
||
| `variant_snapshot_json` | NVARCHAR(MAX) NOT NULL | Full JSON of variant + option labels at booking time |
|
||
| `customer_address_id` | BIGINT FK → customer_addresses NOT NULL | |
|
||
| `address_snapshot_json` | NVARCHAR(MAX) NOT NULL | **(encrypted)** — full address at booking time |
|
||
| `scheduled_date` | DATE NOT NULL | |
|
||
| `scheduled_time_start` | TIME NOT NULL | |
|
||
| `scheduled_time_end` | TIME NOT NULL | |
|
||
| `gross_amount` | BIGINT NOT NULL | Total charged to customer (IRR) |
|
||
| `platform_fee_amount` | BIGINT NOT NULL | Platform's cut |
|
||
| `platform_fee_rate` | DECIMAL(5,4) NOT NULL | e.g., 0.1500 = 15% — captured at booking time |
|
||
| `nurse_payout_amount` | BIGINT NOT NULL | gross_amount - platform_fee_amount |
|
||
| `status` | NVARCHAR(30) NOT NULL DEFAULT 'pending_payment' | See lifecycle above |
|
||
| `confirmed_at` | DATETIME2 NULL | When payment was captured |
|
||
| `cancelled_at` | DATETIME2 NULL | |
|
||
| `cancellation_reason` | NVARCHAR(500) NULL | |
|
||
| `cancelled_by` | NVARCHAR(20) NULL | `customer` / `nurse` / `admin` / `system` |
|
||
| `completed_at` | DATETIME2 NULL | When booking status moved to completed |
|
||
| `payout_released` | BIT NOT NULL DEFAULT 0 | True when this booking is included in a processed payout batch |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
| `updated_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
#### `booking_care_instructions`
|
||
|
||
Clinical and logistical context provided by the family at booking time. Separated from `bookings` to keep the financial/scheduling table clean and because this data has stricter access controls (encrypted, visible only to the assigned nurse and admin). The nurse reads this before the visit.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `booking_id` | BIGINT FK → bookings UNIQUE NOT NULL | 1:1 |
|
||
| `current_conditions` | NVARCHAR(MAX) NULL | **(encrypted)** — active diagnoses |
|
||
| `current_medications` | NVARCHAR(MAX) NULL | **(encrypted)** — drug names, doses, schedule |
|
||
| `allergies` | NVARCHAR(1000) NULL | **(encrypted)** |
|
||
| `special_instructions` | NVARCHAR(2000) NULL | **(encrypted)** — care protocol, family preferences |
|
||
| `emergency_contact_name` | NVARCHAR(200) NULL | **(encrypted)** |
|
||
| `emergency_contact_phone` | NVARCHAR(20) NULL | **(encrypted)** |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
| `updated_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
#### `visit_verifications`
|
||
|
||
Electronic Visit Verification (EVV). The nurse clocks in and out via the app; GPS coordinates and timestamps are recorded. This is the authoritative record of whether a visit occurred and for how long. It is required for payout release. If a nurse does not check in by a configurable threshold after the scheduled start time, a `support_alerts` record is created and the family is notified.
|
||
|
||
`check_in_address_match` is a computed boolean: did the nurse's GPS at check-in fall within an acceptable radius of the booking address? This flag is advisory for admin — a mismatch triggers a review but does not automatically cancel the booking.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `booking_id` | BIGINT FK → bookings UNIQUE NOT NULL | 1:1 |
|
||
| `check_in_at` | DATETIME2 NULL | |
|
||
| `check_in_latitude` | DECIMAL(10,7) NULL | |
|
||
| `check_in_longitude` | DECIMAL(10,7) NULL | |
|
||
| `check_in_address_match` | BIT NULL | Did GPS match booking address within tolerance? |
|
||
| `check_out_at` | DATETIME2 NULL | |
|
||
| `check_out_latitude` | DECIMAL(10,7) NULL | |
|
||
| `check_out_longitude` | DECIMAL(10,7) NULL | |
|
||
| `actual_duration_minutes` | INT NULL | Computed: check_out_at - check_in_at |
|
||
| `status` | NVARCHAR(20) NOT NULL DEFAULT 'pending' | `pending` / `checked_in` / `completed` / `missed` / `disputed` |
|
||
| `nurse_checkout_notes` | NVARCHAR(500) NULL | Optional note from nurse at checkout |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
| `updated_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
### Domain 6 — Payments
|
||
|
||
---
|
||
|
||
#### `payment_gateways`
|
||
|
||
Configuration for each PSP connected to the platform. Credentials are encrypted. Multiple gateways may be active simultaneously (e.g., standard IPG for direct payment, a BNPL gateway for installments, a failover gateway). The `type` field determines which payment flow applies.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `code` | NVARCHAR(50) UNIQUE NOT NULL | e.g., `zarinpal`, `idpay`, `payping`, `bnpl_hamrah`, `bnpl_toranj` |
|
||
| `name` | NVARCHAR(100) NOT NULL | Display name |
|
||
| `type` | NVARCHAR(30) NOT NULL | `standard` / `bnpl` |
|
||
| `is_active` | BIT NOT NULL DEFAULT 1 | |
|
||
| `config_json` | NVARCHAR(MAX) NULL | **(encrypted)** — merchant ID, API keys, webhook secret, callback URLs |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
| `updated_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
#### `payment_transactions`
|
||
|
||
Records every payment attempt against a booking. A booking may have multiple rows here (failed first attempt, successful retry). The row where `status = 'succeeded'` is the one that triggers booking confirmation. The `gateway_response_json` stores the full PSP response — essential for dispute, chargebacks, and Shaparak reconciliation.
|
||
|
||
`ip_address` and `user_agent` at payment time are retained for fraud detection and dispute evidence.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `booking_id` | BIGINT FK → bookings NOT NULL | |
|
||
| `customer_id` | BIGINT FK → customer_profiles NOT NULL | |
|
||
| `gateway_id` | BIGINT FK → payment_gateways NOT NULL | |
|
||
| `amount` | BIGINT NOT NULL | Amount charged in this transaction (IRR) |
|
||
| `currency` | NVARCHAR(5) NOT NULL DEFAULT 'IRR' | |
|
||
| `status` | NVARCHAR(30) NOT NULL DEFAULT 'initiated' | `initiated` / `pending` / `succeeded` / `failed` / `cancelled` / `refunded` / `partially_refunded` |
|
||
| `gateway_transaction_id` | NVARCHAR(200) NULL | PSP's transaction identifier |
|
||
| `gateway_reference_code` | NVARCHAR(200) NULL | Shaparak reference code — the definitive payment proof |
|
||
| `gateway_response_code` | NVARCHAR(50) NULL | PSP result code |
|
||
| `gateway_response_json` | NVARCHAR(MAX) NULL | Full PSP response |
|
||
| `is_installment` | BIT NOT NULL DEFAULT 0 | True if funded via a BNPL plan |
|
||
| `initiated_at` | DATETIME2 NOT NULL | When the payment flow was started |
|
||
| `paid_at` | DATETIME2 NULL | When PSP confirmed success |
|
||
| `failed_at` | DATETIME2 NULL | |
|
||
| `failure_reason` | NVARCHAR(500) NULL | Human-readable from PSP |
|
||
| `ip_address` | NVARCHAR(45) NULL | Customer's IP at payment time |
|
||
| `user_agent` | NVARCHAR(500) NULL | |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
| `updated_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
#### `refunds`
|
||
|
||
Refund requests and their resolution. Refunds are always admin-initiated — there is no customer self-service refund. They are typically triggered by a family request communicated through a support ticket. Default refund is 100% of the payment; admin may set a partial amount (e.g., cancellation fee deducted).
|
||
|
||
The `ticket_id` links to the support conversation where the refund was discussed, providing full context for dispute evidence.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `payment_transaction_id` | BIGINT FK → payment_transactions NOT NULL | The original successful payment being refunded |
|
||
| `booking_id` | BIGINT FK → bookings NOT NULL | |
|
||
| `requested_by_customer_id` | BIGINT FK → customer_profiles NOT NULL | |
|
||
| `ticket_id` | BIGINT FK → tickets NULL | Support conversation that led to this refund |
|
||
| `amount` | BIGINT NOT NULL | Refund amount (IRR). Default = full gross_amount. |
|
||
| `refund_percentage` | DECIMAL(5,2) NOT NULL | For audit display (e.g., 100.00, 50.00) |
|
||
| `reason_category` | NVARCHAR(50) NOT NULL | `nurse_no_show` / `quality_complaint` / `scheduling_error` / `customer_cancellation` / `platform_error` / `other` |
|
||
| `reason_notes` | NVARCHAR(1000) NULL | |
|
||
| `status` | NVARCHAR(30) NOT NULL DEFAULT 'pending' | `pending` / `approved` / `rejected` / `processing` / `completed` / `failed` |
|
||
| `approved_by_admin_id` | BIGINT FK → users NULL | |
|
||
| `approved_at` | DATETIME2 NULL | |
|
||
| `rejected_by_admin_id` | BIGINT FK → users NULL | |
|
||
| `rejected_at` | DATETIME2 NULL | |
|
||
| `rejection_reason` | NVARCHAR(500) NULL | Communicated to customer |
|
||
| `gateway_refund_reference` | NVARCHAR(200) NULL | PSP's refund transaction ID |
|
||
| `processed_at` | DATETIME2 NULL | When PSP confirmed refund success |
|
||
| `admin_notes` | NVARCHAR(1000) NULL | Internal only — not shown to customer |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
| `updated_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
#### `nurse_payout_batches`
|
||
|
||
Weekly aggregation of all amounts owed to nurses for completed, unpaid bookings. Admin initiates a batch (manually or via a scheduled job). Each batch covers a specific period and produces one `nurse_payouts` row per nurse with earnings in that window.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `period_start` | DATE NOT NULL | |
|
||
| `period_end` | DATE NOT NULL | |
|
||
| `total_amount` | BIGINT NOT NULL | Sum of all nurse_payout_amount in this batch |
|
||
| `payout_count` | INT NOT NULL | Number of individual nurse payouts in this batch |
|
||
| `status` | NVARCHAR(30) NOT NULL DEFAULT 'draft' | `draft` / `processing` / `completed` / `partially_failed` / `failed` |
|
||
| `initiated_by_admin_id` | BIGINT FK → users NULL | |
|
||
| `processed_at` | DATETIME2 NULL | |
|
||
| `failure_notes` | NVARCHAR(1000) NULL | |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
| `updated_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
#### `nurse_payouts`
|
||
|
||
One row per nurse per batch. Records the exact amount transferred, which bank account was used (snapshot, not FK, because account may change), and the transfer reference for bank reconciliation.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `batch_id` | BIGINT FK → nurse_payout_batches NOT NULL | |
|
||
| `nurse_id` | BIGINT FK → nurse_profiles NOT NULL | |
|
||
| `bank_account_id` | BIGINT FK → nurse_bank_accounts NOT NULL | Primary account at time of payout |
|
||
| `iban_snapshot` | NVARCHAR(34) NOT NULL | **(encrypted)** — IBAN at time of payout; in case account is later changed |
|
||
| `amount` | BIGINT NOT NULL | Total paid to nurse in this batch (IRR) |
|
||
| `booking_count` | INT NOT NULL | How many bookings this covers |
|
||
| `status` | NVARCHAR(30) NOT NULL DEFAULT 'pending' | `pending` / `processing` / `paid` / `failed` |
|
||
| `transfer_reference` | NVARCHAR(200) NULL | Bank/ACH reference for reconciliation |
|
||
| `paid_at` | DATETIME2 NULL | |
|
||
| `failure_reason` | NVARCHAR(500) NULL | |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
#### `nurse_payout_booking_links`
|
||
|
||
Join table linking each payout to the specific bookings it covers. Enables per-booking reconciliation and prevents double-paying the same booking in a future batch.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `payout_id` | BIGINT FK → nurse_payouts NOT NULL | |
|
||
| `booking_id` | BIGINT FK → bookings UNIQUE NOT NULL | UNIQUE ensures a booking can only appear in one payout |
|
||
| `amount` | BIGINT NOT NULL | nurse_payout_amount for this booking |
|
||
|
||
---
|
||
|
||
### Domain 7 — BNPL / Installments
|
||
|
||
When a customer pays via BNPL, the third-party provider approves the installment plan and pays the platform. The customer repays the BNPL provider directly. From the platform's perspective, the `payment_transactions` row is `succeeded` with `is_installment = true`. The tables below track the BNPL plan details for reconciliation, reporting, and potential future collections escalation.
|
||
|
||
> **⚠️ Open decision before implementation:** Two BNPL settlement models exist and the choice affects this schema:
|
||
>
|
||
> - **Immediate full settlement** — the provider pays the platform the full booking amount (possibly net of their own fee) in one transfer at approval time. In this case `payment_transactions.amount` is the booking's `gross_amount`, and `installment_plans.total_amount` may be *higher* (the provider charges the customer more than the booking price to cover their financing fee). The current schema handles this correctly — the two amounts live in separate fields on separate tables.
|
||
>
|
||
> - **Tranched settlement** — the provider pays the platform in installments as the customer pays them. In this case a single `payment_transactions` row is insufficient; you would need either multiple rows in `payment_transactions` (one per provider settlement) or a dedicated `bnpl_settlement_entries` table linked to `installment_plans`. The current schema does **not** handle this — it would require an additive migration.
|
||
>
|
||
> Confirm which model your chosen BNPL provider uses before building the payment integration.
|
||
|
||
---
|
||
|
||
#### `installment_plans`
|
||
|
||
Metadata about a BNPL approval linked to a specific payment transaction.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `payment_transaction_id` | BIGINT FK → payment_transactions UNIQUE NOT NULL | 1:1 |
|
||
| `bnpl_provider_code` | NVARCHAR(50) NOT NULL | Matches `payment_gateways.code` |
|
||
| `external_plan_id` | NVARCHAR(200) NOT NULL | Provider's plan identifier |
|
||
| `total_amount` | BIGINT NOT NULL | Total the customer owes the provider (IRR), may include provider fee |
|
||
| `installment_count` | TINYINT NOT NULL | Number of installments |
|
||
| `monthly_fee_rate` | DECIMAL(5,4) NULL | Provider's monthly fee rate |
|
||
| `plan_start_date` | DATE NOT NULL | |
|
||
| `plan_end_date` | DATE NOT NULL | |
|
||
| `status` | NVARCHAR(30) NOT NULL DEFAULT 'pending_approval' | `pending_approval` / `approved` / `rejected` / `active` / `completed` / `defaulted` |
|
||
| `approved_at` | DATETIME2 NULL | |
|
||
| `last_webhook_at` | DATETIME2 NULL | When last webhook was received from provider |
|
||
| `webhook_payload_json` | NVARCHAR(MAX) NULL | Last webhook payload |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
| `updated_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
#### `installment_entries`
|
||
|
||
Individual installment records. Updated via webhook from the BNPL provider when a payment occurs or is missed.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `plan_id` | BIGINT FK → installment_plans NOT NULL | |
|
||
| `installment_number` | TINYINT NOT NULL | 1-based |
|
||
| `amount` | BIGINT NOT NULL | Amount due for this installment (IRR) |
|
||
| `due_date` | DATE NOT NULL | |
|
||
| `paid_at` | DATETIME2 NULL | NULL = not yet paid |
|
||
| `status` | NVARCHAR(20) NOT NULL DEFAULT 'upcoming' | `upcoming` / `due` / `paid` / `overdue` / `defaulted` |
|
||
| `external_reference` | NVARCHAR(200) NULL | Provider's reference for this specific installment |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
| `updated_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
### Domain 8 — Messaging (Ticket System)
|
||
|
||
There is no live chat and no direct messaging between nurse and customer. All post-booking communication runs through a structured ticket system where admin can read every message. This is intentional: it protects vulnerable patients, creates a paper trail for disputes, and prevents disintermediation. Tickets can also be used by customers or nurses to contact support for any reason.
|
||
|
||
---
|
||
|
||
#### `tickets`
|
||
|
||
A support or communication thread. May be linked to a booking, refund, or stand alone. Admin assigns, responds, and resolves tickets. Customers and nurses communicate through messages on their assigned ticket.
|
||
|
||
The `reference_code` is a human-readable identifier (e.g., `TKT-2025-001234`) communicated to users for support follow-up.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `reference_code` | NVARCHAR(30) UNIQUE NOT NULL | Human-readable code for support communication |
|
||
| `subject` | NVARCHAR(300) NOT NULL | |
|
||
| `type` | NVARCHAR(50) NOT NULL | `booking_coordination` / `booking_issue` / `refund_request` / `nurse_complaint` / `review_dispute` / `verification_question` / `general` |
|
||
| `status` | NVARCHAR(30) NOT NULL DEFAULT 'open' | `open` / `pending_customer` / `pending_nurse` / `pending_admin` / `resolved` / `closed` |
|
||
| `priority` | NVARCHAR(20) NOT NULL DEFAULT 'medium' | `low` / `medium` / `high` / `urgent` |
|
||
| `booking_id` | BIGINT FK → bookings NULL | Context link — not required |
|
||
| `refund_id` | BIGINT FK → refunds NULL | |
|
||
| `created_by_user_id` | BIGINT FK → users NOT NULL | Who opened the ticket |
|
||
| `assigned_to_admin_id` | BIGINT FK → users NULL | Current admin owner |
|
||
| `resolved_at` | DATETIME2 NULL | |
|
||
| `closed_at` | DATETIME2 NULL | |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
| `updated_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
#### `ticket_participants`
|
||
|
||
Controls who has access to a ticket. Every ticket has at minimum its creator and an admin. Post-booking tickets may include both the customer and the nurse.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `ticket_id` | BIGINT FK → tickets NOT NULL | |
|
||
| `user_id` | BIGINT FK → users NOT NULL | |
|
||
| `participant_role` | NVARCHAR(20) NOT NULL | `customer` / `nurse` / `admin` / `support` |
|
||
| `added_at` | DATETIME2 NOT NULL | |
|
||
| `removed_at` | DATETIME2 NULL | NULL = still active participant |
|
||
|
||
**Constraint:** `(ticket_id, user_id)` UNIQUE.
|
||
|
||
---
|
||
|
||
#### `ticket_messages`
|
||
|
||
Messages within a ticket. `is_internal = true` messages are admin-only notes — they are never shown to the customer or nurse. This allows admins to coordinate internally without leaving the ticket context.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `ticket_id` | BIGINT FK → tickets NOT NULL | |
|
||
| `sender_user_id` | BIGINT FK → users NOT NULL | |
|
||
| `body` | NVARCHAR(MAX) NOT NULL | |
|
||
| `is_internal` | BIT NOT NULL DEFAULT 0 | True = admin-only note |
|
||
| `attachments_json` | NVARCHAR(MAX) NULL | JSON array of {storage_key, filename, mime_type} |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
### Domain 9 — Reviews & Patient Records
|
||
|
||
---
|
||
|
||
#### `review_tags_master`
|
||
|
||
Admin-managed standardized tags for reviews. Allows quantitative aggregation of qualitative feedback (e.g., "% of reviews tagged 'punctual'"). Nurses can later see their most common tags on their profile.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `name_fa` | NVARCHAR(100) NOT NULL | e.g., "وقتشناس", "مهربان", "متخصص", "غیرحرفهای" |
|
||
| `name_en` | NVARCHAR(100) NULL | |
|
||
| `sentiment` | NVARCHAR(10) NOT NULL | `positive` / `negative` / `neutral` |
|
||
| `is_active` | BIT NOT NULL DEFAULT 1 | |
|
||
| `sort_order` | SMALLINT NOT NULL DEFAULT 0 | |
|
||
|
||
---
|
||
|
||
#### `reviews`
|
||
|
||
Post-booking review by the customer about the nurse. Only one review per booking. Submitted reviews enter `pending_moderation` and are not shown publicly until an admin (or AI moderator) approves them. A rating of 1 or 2 with negative content automatically triggers a `support_alerts` row so the support team can investigate.
|
||
|
||
When a review is published, `nurse_profiles.average_rating` and `total_reviews` are updated.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `booking_id` | BIGINT FK → bookings UNIQUE NOT NULL | One review per booking |
|
||
| `customer_id` | BIGINT FK → customer_profiles NOT NULL | |
|
||
| `nurse_id` | BIGINT FK → nurse_profiles NOT NULL | Denormalized for query performance |
|
||
| `rating` | TINYINT NOT NULL | 1–5 |
|
||
| `body` | NVARCHAR(2000) NULL | Free-text content |
|
||
| `status` | NVARCHAR(30) NOT NULL DEFAULT 'pending_moderation' | `pending_moderation` / `published` / `rejected` / `hidden` |
|
||
| `moderated_by_admin_id` | BIGINT FK → users NULL | Admin or AI agent that approved/rejected |
|
||
| `moderation_notes` | NVARCHAR(500) NULL | Internal reason for rejection or hiding |
|
||
| `published_at` | DATETIME2 NULL | |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
| `updated_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
#### `review_tag_links`
|
||
|
||
Tags applied to a specific review.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `review_id` | BIGINT FK → reviews NOT NULL | |
|
||
| `tag_id` | BIGINT FK → review_tags_master NOT NULL | |
|
||
|
||
**Constraint:** `(review_id, tag_id)` UNIQUE.
|
||
|
||
---
|
||
|
||
#### `patient_care_records`
|
||
|
||
Nurse-authored clinical notes written after completing a booking. These accumulate over time and form a longitudinal care history for the patient. When a new nurse receives a booking request for the same patient, they can read this history to understand prior care, medications, and observations — enabling continuity of care across multiple nurses.
|
||
|
||
Access to these records is strictly controlled: only the patient's owning customer, nurses with a confirmed upcoming or in-progress booking for that patient, and admin may read them. All fields are encrypted.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `patient_id` | BIGINT FK → patients NOT NULL | |
|
||
| `booking_id` | BIGINT FK → bookings NOT NULL | The specific visit that generated this record |
|
||
| `nurse_id` | BIGINT FK → nurse_profiles NOT NULL | Who authored the record |
|
||
| `observations` | NVARCHAR(MAX) NULL | **(encrypted)** — general notes on patient state during the visit |
|
||
| `vitals_json` | NVARCHAR(MAX) NULL | **(encrypted)** — structured: {blood_pressure, temperature, pulse, oxygen_saturation, ...} |
|
||
| `medications_json` | NVARCHAR(MAX) NULL | **(encrypted)** — medications administered or observed |
|
||
| `conditions_note` | NVARCHAR(MAX) NULL | **(encrypted)** — new or changed conditions noted by the nurse |
|
||
| `recorded_at` | DATETIME2 NOT NULL | When nurse submitted this record |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
### Domain 10 — Notifications
|
||
|
||
---
|
||
|
||
#### `notifications`
|
||
|
||
In-app notification records for all user types. No push notifications at launch — these are polled on page load or explicitly fetched. The `data_json` carries a typed payload that the front-end uses to navigate to the relevant entity (e.g., open a specific booking, ticket, or payout screen).
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `user_id` | BIGINT FK → users NOT NULL | Recipient |
|
||
| `type` | NVARCHAR(80) NOT NULL | e.g., `booking_request_received`, `booking_accepted`, `booking_rejected`, `booking_request_expired`, `payment_succeeded`, `payment_failed`, `booking_confirmed`, `review_published`, `review_rejected`, `payout_processed`, `ticket_new_message`, `verification_update`, `nurse_no_show_alert` |
|
||
| `title_fa` | NVARCHAR(300) NOT NULL | |
|
||
| `body_fa` | NVARCHAR(500) NULL | |
|
||
| `data_json` | NVARCHAR(MAX) NULL | e.g., {"entity_type": "booking", "entity_id": 1234} |
|
||
| `is_read` | BIT NOT NULL DEFAULT 0 | |
|
||
| `read_at` | DATETIME2 NULL | |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
#### `support_alerts`
|
||
|
||
Internal alerts visible only to the support and admin team. Not shown to customers or nurses. Automatically generated by platform rules (low review ratings, EVV no-shows, payment anomalies) or manually by admin. Each alert has an owner and a resolution trail.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `alert_type` | NVARCHAR(50) NOT NULL | `low_rating_review` / `nurse_no_show` / `payment_anomaly` / `verification_step_expired` / `evv_location_mismatch` / `fraud_signal` |
|
||
| `severity` | NVARCHAR(20) NOT NULL | `info` / `warning` / `critical` |
|
||
| `entity_type` | NVARCHAR(50) NOT NULL | e.g., `review`, `booking`, `payment_transaction`, `verification_step` |
|
||
| `entity_id` | BIGINT NOT NULL | |
|
||
| `description` | NVARCHAR(500) NOT NULL | Human-readable summary |
|
||
| `status` | NVARCHAR(20) NOT NULL DEFAULT 'open' | `open` / `acknowledged` / `resolved` / `dismissed` |
|
||
| `assigned_to_admin_id` | BIGINT FK → users NULL | NULL = unassigned |
|
||
| `resolved_at` | DATETIME2 NULL | |
|
||
| `resolved_by_admin_id` | BIGINT FK → users NULL | |
|
||
| `resolution_notes` | NVARCHAR(500) NULL | |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
| `updated_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
### Domain 11 — Audit & Logging
|
||
|
||
---
|
||
|
||
#### `audit_logs`
|
||
|
||
Immutable, append-only record of all state-changing operations on sensitive entities. Every insert, update, or status change on bookings, payments, refunds, verifications, reviews, and user accounts produces a row here. This table must never be updated or deleted — archive to cold storage after a retention period.
|
||
|
||
`changed_fields_json` stores just the names of fields that changed, enabling fast filtering ("show me all booking status changes") without parsing full old/new value diffs.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `entity_type` | NVARCHAR(100) NOT NULL | Table name: `bookings`, `payment_transactions`, `refunds`, `nurse_verifications`, `verification_steps`, `reviews`, `users`, `nurse_profiles` |
|
||
| `entity_id` | BIGINT NOT NULL | PK of the affected row |
|
||
| `action` | NVARCHAR(20) NOT NULL | `created` / `updated` / `status_changed` / `deleted` |
|
||
| `old_values_json` | NVARCHAR(MAX) NULL | Full row snapshot before change (NULL for creates) |
|
||
| `new_values_json` | NVARCHAR(MAX) NULL | Full row snapshot after change (NULL for deletes) |
|
||
| `changed_fields_json` | NVARCHAR(MAX) NULL | JSON array of field names that changed |
|
||
| `performed_by_user_id` | BIGINT FK → users NULL | NULL for system/background jobs |
|
||
| `performed_by_role` | NVARCHAR(50) NULL | Captured at action time |
|
||
| `ip_address` | NVARCHAR(45) NULL | |
|
||
| `user_agent` | NVARCHAR(500) NULL | |
|
||
| `request_id` | NVARCHAR(100) NULL | Correlation ID from API request for distributed tracing |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
#### `system_events`
|
||
|
||
Higher-volume behavioral event log for analytics, funnel analysis, and future fraud detection feature engineering. Distinct from `audit_logs` — audit is for compliance and accountability; system_events is for product analytics and monitoring. May be partitioned by month or archived to a data warehouse.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `event_type` | NVARCHAR(100) NOT NULL | e.g., `nurse_search_performed`, `nurse_profile_viewed`, `booking_request_submitted`, `payment_initiated`, `verification_step_started` |
|
||
| `user_id` | BIGINT FK → users NULL | NULL for anonymous or system events |
|
||
| `session_id` | NVARCHAR(100) NULL | App session identifier |
|
||
| `payload_json` | NVARCHAR(MAX) NULL | Event-specific properties (search filters, variant viewed, etc.) |
|
||
| `source` | NVARCHAR(30) NOT NULL | `api` / `background_job` / `admin_panel` / `webhook` |
|
||
| `ip_address` | NVARCHAR(45) NULL | |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
### Domain 12 — Platform Configuration
|
||
|
||
---
|
||
|
||
#### `platform_configs`
|
||
|
||
Key-value store for runtime-tunable business parameters. Changing a parameter (e.g., raising the platform fee, adjusting the nurse response deadline) does not require a deployment — admin edits a row and the application picks it up. Values are stored as strings; the `data_type` field tells the application how to parse them.
|
||
|
||
Representative keys and their purpose:
|
||
|
||
| Key | Description |
|
||
|---|---|
|
||
| `platform_fee_rate` | Decimal — e.g., `0.1500` (15%). Used for new bookings only. |
|
||
| `booking_payment_deadline_minutes` | Integer — how long the customer has to pay after nurse accepts. Default 30. |
|
||
| `nurse_response_deadline_hours` | Integer — how long before a booking request auto-expires. |
|
||
| `nurse_payout_interval_days` | Integer — how many days after period_end before a batch is created. |
|
||
| `default_refund_percentage` | Decimal — default % refunded when admin approves. Default 100. |
|
||
| `min_rating_for_support_alert` | Integer — reviews with rating ≤ this value trigger a support_alert. Default 2. |
|
||
| `evv_location_tolerance_meters` | Integer — GPS match tolerance for check-in address verification. |
|
||
| `review_requires_admin_approval` | Boolean — whether all reviews need moderation before publish. |
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `key` | NVARCHAR(100) UNIQUE NOT NULL | |
|
||
| `value` | NVARCHAR(500) NOT NULL | |
|
||
| `data_type` | NVARCHAR(20) NOT NULL | `integer` / `decimal` / `boolean` / `string` / `json` |
|
||
| `description` | NVARCHAR(500) NULL | |
|
||
| `updated_by_admin_id` | BIGINT FK → users NULL | |
|
||
| `updated_at` | DATETIME2 NOT NULL | |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
### Domain 13 — Future Extensions (modeled now, inactive at launch)
|
||
|
||
These tables are defined now to avoid a painful migration when the features ship. They carry no data at launch but the FK relationships are already established so the rest of the schema doesn't need to change.
|
||
|
||
---
|
||
|
||
#### `organizations`
|
||
|
||
Nursing companies that may register on the platform and add their employed nurses. Each nurse remains individually verified and responsible. The organization acts as a sponsor/guarantor for its nurses and may have a separate dashboard.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `name` | NVARCHAR(300) NOT NULL | |
|
||
| `type` | NVARCHAR(30) NOT NULL | `nursing_company` / `staffing_agency` |
|
||
| `moh_license_number` | NVARCHAR(100) NULL | MoH establishment permit (پروانه تأسیس) |
|
||
| `admin_user_id` | BIGINT FK → users NOT NULL | Organization's admin account |
|
||
| `is_verified` | BIT NOT NULL DEFAULT 0 | Platform-verified |
|
||
| `verified_at` | DATETIME2 NULL | |
|
||
| `is_active` | BIT NOT NULL DEFAULT 1 | |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
| `updated_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
#### `organization_nurses`
|
||
|
||
Membership link between a nurse and an organization. A nurse can belong to at most one active organization at a time (enforced by checking `left_at IS NULL`).
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `organization_id` | BIGINT FK → organizations NOT NULL | |
|
||
| `nurse_id` | BIGINT FK → nurse_profiles NOT NULL | |
|
||
| `joined_at` | DATETIME2 NOT NULL | |
|
||
| `left_at` | DATETIME2 NULL | NULL = current member |
|
||
| `status` | NVARCHAR(20) NOT NULL | `active` / `suspended` / `left` |
|
||
|
||
---
|
||
|
||
#### `fraud_flags`
|
||
|
||
Signals from a future fraud detection service. Modeled now so the schema is ready to receive data when the detection layer is built, requiring no migration. The `confidence_score` field anticipates an ML model output.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `entity_type` | NVARCHAR(50) NOT NULL | `user` / `payment_transaction` / `booking` |
|
||
| `entity_id` | BIGINT NOT NULL | |
|
||
| `flag_type` | NVARCHAR(100) NOT NULL | e.g., `identity_mismatch`, `unusual_payment_velocity`, `address_anomaly`, `kyc_response_inconsistency` |
|
||
| `severity` | NVARCHAR(20) NOT NULL | `low` / `medium` / `high` |
|
||
| `confidence_score` | DECIMAL(5,4) NULL | 0.0000–1.0000 from detection model |
|
||
| `details_json` | NVARCHAR(MAX) NULL | Evidence from the detection model |
|
||
| `status` | NVARCHAR(30) NOT NULL DEFAULT 'open' | `open` / `investigating` / `confirmed_fraud` / `false_positive` / `resolved` |
|
||
| `assigned_to_admin_id` | BIGINT FK → users NULL | |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
| `resolved_at` | DATETIME2 NULL | |
|
||
|
||
---
|
||
|
||
#### `recurring_booking_schedules`
|
||
|
||
Future feature: allows families to establish a repeating care pattern (e.g., every Monday and Thursday morning with the same nurse). At launch, all bookings are one-off. The `rrule` field uses the RFC 5545 recurrence rule format, which is widely supported by scheduling libraries.
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | BIGINT PK IDENTITY | |
|
||
| `customer_id` | BIGINT FK → customer_profiles NOT NULL | |
|
||
| `nurse_id` | BIGINT FK → nurse_profiles NOT NULL | |
|
||
| `variant_id` | BIGINT FK → nurse_service_variants NOT NULL | |
|
||
| `patient_id` | BIGINT FK → patients NOT NULL | |
|
||
| `customer_address_id` | BIGINT FK → customer_addresses NOT NULL | |
|
||
| `rrule` | NVARCHAR(500) NOT NULL | RFC 5545 recurrence rule, e.g., `FREQ=WEEKLY;BYDAY=MO,TH` |
|
||
| `scheduled_time_start` | TIME NOT NULL | |
|
||
| `scheduled_time_end` | TIME NOT NULL | |
|
||
| `effective_from` | DATE NOT NULL | |
|
||
| `effective_until` | DATE NULL | NULL = indefinite |
|
||
| `is_active` | BIT NOT NULL DEFAULT 1 | |
|
||
| `created_at` | DATETIME2 NOT NULL | |
|
||
|
||
---
|
||
|
||
## Relationship Summary
|
||
|
||
| Relationship | Type | Notes |
|
||
|---|---|---|
|
||
| `users` → `nurse_profiles` | 1:1 | role = nurse |
|
||
| `users` → `customer_profiles` | 1:1 | role = customer |
|
||
| `customer_profiles` → `patients` | 1:N | One family, many patients |
|
||
| `customer_profiles` → `customer_addresses` | 1:N | |
|
||
| `nurse_profiles` → `nurse_bank_accounts` | 1:N | One primary at a time |
|
||
| `nurse_profiles` → `nurse_service_variants` | 1:N | Each variant is one bookable offering |
|
||
| `nurse_service_variants` → `nurse_service_variant_options` | 1:N | Defines the option combination |
|
||
| `service_option_groups` → `service_option_values` | 1:N | |
|
||
| `nurse_profiles` → `nurse_service_areas` | 1:N | Many city/district pairs |
|
||
| `nurse_profiles` → `nurse_verifications` | 1:1 | |
|
||
| `nurse_verifications` → `verification_steps` | 1:N | One per step type |
|
||
| `verification_steps` → `verification_documents` | 1:N | |
|
||
| `booking_requests` → `bookings` | 1:1 | Created on nurse accept + payment |
|
||
| `bookings` → `booking_care_instructions` | 1:1 | |
|
||
| `bookings` → `visit_verifications` | 1:1 | EVV record |
|
||
| `bookings` → `payment_transactions` | 1:N | Multiple attempts allowed |
|
||
| `payment_transactions` → `refunds` | 1:1 | One refund per transaction |
|
||
| `payment_transactions` → `installment_plans` | 1:1 | Only if is_installment = true |
|
||
| `installment_plans` → `installment_entries` | 1:N | One per installment |
|
||
| `nurse_payout_batches` → `nurse_payouts` | 1:N | One per nurse per batch |
|
||
| `nurse_payouts` → `nurse_payout_booking_links` | 1:N | Detailed booking breakdown |
|
||
| `nurse_payout_booking_links` → `bookings` | N:1 | Each booking in exactly one payout |
|
||
| `bookings` → `reviews` | 1:1 | One review per booking |
|
||
| `reviews` → `review_tag_links` | 1:N | |
|
||
| `patients` → `patient_care_records` | 1:N | Grows with every visit |
|
||
| `tickets` → `ticket_participants` | 1:N | |
|
||
| `tickets` → `ticket_messages` | 1:N | |
|
||
| `tickets` ↔ `refunds` | 1:1 (optional) | Refund conversation |
|
||
| Sensitive entities → `audit_logs` | *:N | Append-only change log |
|
||
|
||
---
|
||
|
||
## Key Design Decisions
|
||
|
||
**Why `booking_requests` and `bookings` are separate tables**
|
||
A booking request may be rejected, expire, or have its payment deadline lapse without a booking ever being created. Merging these would require many nullable fields and complex status logic. Keeping them separate makes each table's invariants clear: a `bookings` row always has a successful payment.
|
||
|
||
**Why `variant_snapshot_json` exists on `bookings`**
|
||
If a nurse later edits or deletes a variant, historical bookings must still display what the customer paid for. The FK to `nurse_service_variants` is kept for joins but `variant_snapshot_json` is the authoritative record for display and dispute.
|
||
|
||
**Why monetary amounts are stored as BIGINT (IRR)**
|
||
Rials are the smallest unit; there are no decimal subdivisions in practice. Storing as BIGINT eliminates floating-point precision issues. All division (for fee calculation) is done in application code with explicit rounding rules.
|
||
|
||
**Why `platform_fee_rate` is stored per booking**
|
||
The platform's fee schedule will change over time. Deriving the split from `platform_configs` would corrupt historical reporting. Every booking stores the rate that was in effect at the moment of confirmation.
|
||
|
||
**Why the verification pipeline is data-driven**
|
||
`verification_step_types` rows define what steps exist and whether they're automated. Adding a new regulatory requirement (e.g., professional liability insurance in year 2) requires one INSERT, not a schema migration and code deploy.
|
||
|
||
**Why there is no direct nurse ↔ customer messaging**
|
||
Platform safety design: all communication is ticket-based and admin-readable. This protects vulnerable patients, provides dispute evidence, and prevents disintermediation. Post-booking coordination (nurse asking about the patient's room location, customer asking about arrival time) happens through a ticket of type `booking_coordination`.
|
||
|
||
**Why `patient_care_records` are nurse-authored and patient-scoped**
|
||
Clinical notes build a longitudinal record for the patient, not the booking. When a different nurse takes over care, they read the history before accepting the booking request, making care continuity possible without the family having to repeat everything.
|
||
|
||
|
||
# Advices
|
||
Several things stand out after sitting with the full model:
|
||
|
||
1. The nurse search query will be expensive from day one
|
||
|
||
Finding nurses requires joining nurse_profiles (verified + accepting) → nurse_service_variants (category + price range) → nurse_service_variant_options → nurse_service_areas (city/district). That's already 4 joins before any sorting by rating or availability. At even modest scale this becomes slow.
|
||
|
||
Advice: build a denormalized nurse_search_index table that is updated on writes (when a nurse edits a variant, changes their area, or gets a new review). This table stores one row per variant with all search-relevant fields flat. Read path hits one table; write path maintains it. Much cheaper than adding Elasticsearch to the stack at your current stage.
|
||
|
||
2. The two-stage clinical disclosure is implicit — make it explicit
|
||
|
||
Right now booking_requests.customer_notes (1000 chars, unencrypted) is what the nurse sees when deciding to accept. The full booking_care_instructions (encrypted, medications, conditions, etc.) only exists after the booking is confirmed. This boundary is correct and security-smart, but it's nowhere stated in the model.
|
||
|
||
If you don't document this explicitly in the API contract, a developer will one day accidentally expose the full clinical record to a nurse at the request stage — before any commitment. Write this rule into the model comments and enforce it at the application layer, not just by convention.
|
||
|
||
3. Dispute window vs payout timing can overlap
|
||
|
||
A booking can be disputed after the weekly payout batch already ran and the nurse was paid. The current model has no clawback mechanism — nurse_payouts has no way to record that money needs to come back. Consider adding clawback_amount and clawback_status to nurse_payouts, or simply enforce a policy that payout only runs for bookings where the dispute window has closed (e.g., 72 hours post-completion). Either way, document the rule.
|
||
|
||
4. The ticket system has an urgent on-site gap
|
||
|
||
The ticket system is async. If a nurse arrives and the patient doesn't answer the door, or there's a medical emergency, there is no real-time channel in the model. The only outlet is the emergency contact phone number buried in booking_care_instructions. This is probably acceptable for v1 — but be intentional about it. The operational playbook for nurses should explicitly say "in an emergency, call the emergency contact number from the app, then open a ticket." Otherwise nurses will try to find the family's phone number by other means, which breaks the platform's communication control.
|
||
|
||
5. Three tables will eat your disk quietly
|
||
|
||
audit_logs, system_events, and notifications grow without any natural bound. Before you hit production, decide:
|
||
|
||
audit_logs — partition by month, archive after 2–3 years to cold storage
|
||
system_events — same, or pipe directly to a data warehouse and don't keep them in SQL Server at all
|
||
notifications — add a job that hard-deletes read notifications older than 90 days
|
||
If you don't plan this before launch, you'll be doing emergency archiving under pressure later.
|
||
|
||
6. is_verified on nurse_profiles is a derived value — protect it
|
||
|
||
This flag is true when all required verification steps pass. But it lives as a column that could theoretically be set independently. If a bug or a rogue admin sets is_verified = 1 without the steps passing, an unvetted nurse becomes bookable. Enforce this transactionally: the only code path that sets is_verified = true should be the one that also checks all required verification_steps.status = 'passed', inside the same transaction. Never expose a direct update API for this field. |