67 KiB
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
- Monetary values stored as
BIGINTin Iranian Rials (IRR). Toman conversion is a display concern only. - PII fields (national ID, IBAN, phone, addresses, clinical data) are marked (encrypted) — column-level or application-level encryption; actual mechanism is implementation-specific.
- Soft deletes on
usersandnurse_profilesviadeleted_at. Audit and payment records are never deleted. - Audit trail is append-only. All state transitions on bookings, payments, verifications, and reviews produce a row in
audit_logs. - 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. - Platform fee is captured at booking time and never derived from current config, so historical records remain accurate after fee changes.
- Snapshot fields (e.g.,
variant_snapshot_json,address_snapshot_jsonon bookings) protect historical accuracy from future edits to linked records. - Multi-language — all admin-managed catalog tables carry
name_fa/name_enpairs. - All timestamps are
DATETIME2(7)in UTC. Persian calendar display is a UI concern. - String fields use
NVARCHARthroughout 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.amountis the booking'sgross_amount, andinstallment_plans.total_amountmay 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_transactionsrow is insufficient; you would need either multiple rows inpayment_transactions(one per provider settlement) or a dedicatedbnpl_settlement_entriestable linked toinstallment_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:
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.