Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de8cbfec23 | |||
| b8218669c2 | |||
| 2305c3dc9d | |||
| ef60218c4c | |||
| 8018710d31 | |||
| 229975c612 | |||
| aa607b9b6e | |||
| 828cbcc822 | |||
| 4253f6f650 |
@@ -0,0 +1,58 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend-tests:
|
||||||
|
name: Backend tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
DJANGO_SECRET_KEY: ci-test-key
|
||||||
|
DJANGO_DEBUG: "0"
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: "pip"
|
||||||
|
cache-dependency-path: |
|
||||||
|
backend/requirements.txt
|
||||||
|
backend/requirements-dev.txt
|
||||||
|
|
||||||
|
- name: Install backend dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install -r backend/requirements.txt -r backend/requirements-dev.txt
|
||||||
|
|
||||||
|
- name: Run backend tests
|
||||||
|
working-directory: backend
|
||||||
|
run: python -m pytest
|
||||||
|
|
||||||
|
frontend-tests:
|
||||||
|
name: Frontend tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: frontend
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run frontend tests
|
||||||
|
working-directory: frontend
|
||||||
|
env:
|
||||||
|
CI: "true"
|
||||||
|
run: npm run test -- --run
|
||||||
@@ -17,3 +17,4 @@ dist/
|
|||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
backend/tmp_authentica_request_id.txt
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Goal
|
||||||
|
|
||||||
|
A salon booking platform for KSA (Saudi Arabia) with Django REST API backend and React/Vite frontend. Optimized for phone-first auth (OTP via SMS/WhatsApp), Moyasar payments, Arabic locale (ar-sa), and Riyadh timezone.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Setup (from repo root)
|
||||||
|
python3 -m venv venv && source venv/bin/activate
|
||||||
|
pip install -r backend/requirements.txt -r backend/requirements-dev.txt
|
||||||
|
cp backend/.env.example backend/.env
|
||||||
|
|
||||||
|
# Migrations and dev server (from backend/)
|
||||||
|
cd backend
|
||||||
|
python3 manage.py migrate
|
||||||
|
python3 manage.py runserver
|
||||||
|
|
||||||
|
# Seed demo data
|
||||||
|
python3 manage.py seed_demo
|
||||||
|
|
||||||
|
# Run all tests (from backend/ with venv active)
|
||||||
|
cd backend
|
||||||
|
python3 -m pytest
|
||||||
|
|
||||||
|
# Run a single test file
|
||||||
|
python3 -m pytest apps/accounts/tests/test_otp_limits.py
|
||||||
|
|
||||||
|
# Run a single test
|
||||||
|
python3 -m pytest apps/accounts/tests/test_otp_limits.py::TestClassName::test_method_name
|
||||||
|
|
||||||
|
# Run external/integration tests (hits real third-party services)
|
||||||
|
PYTEST_ADDOPTS='' python3 -m pytest -m external
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentica E2E Testing
|
||||||
|
|
||||||
|
Set these env vars before running external tests:
|
||||||
|
- `AUTHENTICA_E2E=1`
|
||||||
|
- `AUTHENTICA_API_KEY=...`
|
||||||
|
- `AUTHENTICA_E2E_PHONE=...` (phone that will receive OTP)
|
||||||
|
- `AUTHENTICA_E2E_CODE=...` (OTP code received)
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From frontend/
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev # dev server at localhost:5173, proxies /api to localhost:8000
|
||||||
|
npm run test # vitest
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Backend (`backend/`)
|
||||||
|
|
||||||
|
Django project lives in `backend/salon_api/` (settings, root urls, wsgi/asgi). All domain apps are under `backend/apps/`:
|
||||||
|
|
||||||
|
| App | Responsibility |
|
||||||
|
|-----|----------------|
|
||||||
|
| `accounts` | Custom User model, phone/OTP auth, JWT tokens, locale preferences |
|
||||||
|
| `salons` | Salon catalog, services, staff profiles, availability windows, reviews |
|
||||||
|
| `bookings` | Booking lifecycle, availability/overlap validation, status transitions |
|
||||||
|
| `payments` | Moyasar integration (create, capture, refund), webhook reconciliation, idempotency |
|
||||||
|
| `notifications` | Booking lifecycle SMS/WhatsApp messages, stored for auditability |
|
||||||
|
|
||||||
|
**Service layer pattern:** Business logic lives in `apps/<app>/services/` (not in views). Views are thin — they validate input, call services, return responses. Keep it this way.
|
||||||
|
|
||||||
|
**OTP providers** (`apps/accounts/services/otp.py`): pluggable via `OTP_PROVIDER` env var. Active providers: `console` (dev), `twilio`, `authentica`. `unifonic` is a scaffold. Authentica is the recommended production provider and uses a server-side OTP flow (`uses_provider_otp = True`) — it generates and verifies the code itself, so the DB stores a placeholder hash.
|
||||||
|
|
||||||
|
**Payment gateway** (`apps/payments/services/gateway.py`): `MoyasarGateway` implements `BasePaymentGateway`. Amounts are always in minor units (halalas). `MOYASAR_SECRET_KEY` and `MOYASAR_PUBLISHABLE_KEY` are required.
|
||||||
|
|
||||||
|
**Sync-only (MVP):** All external calls (OTP sends, notifications, payment gateway) run synchronously in the request path. No task queue. See `docs/adr/0001-synchronous-external-calls-mvp.md`.
|
||||||
|
|
||||||
|
**Database:** SQLite for local dev (default), PostgreSQL via `DATABASE_URL` env var for production. Tests use `TEST_DATABASE_URL` if set.
|
||||||
|
|
||||||
|
**Localization:** Default language `ar-sa`, timezone `Asia/Riyadh`. `UserLocaleMiddleware` applies per-user locale preference.
|
||||||
|
|
||||||
|
### Frontend (`frontend/`)
|
||||||
|
|
||||||
|
React 18 + Vite app. Entry: `src/main.jsx` → `AuthProvider` wraps `App`.
|
||||||
|
|
||||||
|
- **Routing:** `react-router-dom` v7 with pages in `src/pages/`
|
||||||
|
- **Auth:** JWT tokens managed via `src/contexts/AuthContext.jsx`; `src/components/ProtectedRoute.jsx` guards private pages
|
||||||
|
- **API:** `src/api/client.js` is the axios/fetch wrapper
|
||||||
|
- **Hooks:** Domain logic extracted into `src/hooks/` (e.g., `useSalonSearch`, `usePaymentForm`)
|
||||||
|
- **i18n:** `react-i18next` configured in `src/i18n/index.js`; supports `ar-sa` and `en`
|
||||||
|
|
||||||
|
Tests use Vitest + Testing Library. Setup in `src/test/setupTests.js`.
|
||||||
|
|
||||||
|
## Key Env Vars
|
||||||
|
|
||||||
|
Backend (`backend/.env`):
|
||||||
|
- `DJANGO_SECRET_KEY`, `DJANGO_DEBUG`, `DATABASE_URL`
|
||||||
|
- `OTP_PROVIDER` — `console` | `twilio` | `authentica` | `unifonic`
|
||||||
|
- `AUTHENTICA_API_KEY`, `AUTHENTICA_BASE_URL`, `AUTHENTICA_SENDER_NAME`
|
||||||
|
- `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_FROM_NUMBER`, `TWILIO_WHATSAPP_FROM`
|
||||||
|
- `MOYASAR_SECRET_KEY`, `MOYASAR_PUBLISHABLE_KEY`
|
||||||
|
- `NOTIFICATION_PROVIDER` — defaults to `OTP_PROVIDER`
|
||||||
|
- `OTP_EXPIRY_MINUTES`, `OTP_MAX_PER_WINDOW`, `OTP_WINDOW_MINUTES`, `OTP_RESEND_COOLDOWN_SECONDS`
|
||||||
|
- `CORS_ALLOWED_ORIGINS`
|
||||||
|
|
||||||
|
## Testing Conventions
|
||||||
|
|
||||||
|
- Backend tests live beside their apps: `apps/<app>/tests/test_*.py`
|
||||||
|
- `pytest.ini` marks `external` tests as opt-in; default runs skip them
|
||||||
|
- Frontend tests: Vitest + Testing Library; test files colocated with source (`*.test.jsx`)
|
||||||
|
- Minimum coverage: auth flows, booking validation, payment state transitions
|
||||||
|
|
||||||
|
## ExecPlans
|
||||||
|
|
||||||
|
For complex features, use an ExecPlan (see `PLANS.md` for the full spec and active plan pointer). ExecPlans are living documents in `docs/execplans/`. The active plan is listed in `PLANS.md`. Update `docs/risks.md` when opening or closing a significant gap.
|
||||||
|
|
||||||
|
## Coding Conventions
|
||||||
|
|
||||||
|
- Business logic in service layers; keep views thin
|
||||||
|
- Predictable error responses: HTTP status code + `detail` field
|
||||||
|
- Comment intent, edge cases, and non-obvious business rules; skip obvious comments
|
||||||
|
- Payment and booking flows must be idempotent and auditable
|
||||||
|
- Phone auth must be rate-limited (enforced in `otp.py` via `OtpRateLimitError` / `OtpCooldownError`)
|
||||||
@@ -23,6 +23,7 @@ After migrations, you can seed demo data:
|
|||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
- From project root with venv active: `venv/bin/python3 -m pytest` (run from `backend/` so `pytest.ini` is picked up)
|
- From project root with venv active: `venv/bin/python3 -m pytest` (run from `backend/` so `pytest.ini` is picked up)
|
||||||
|
- External provider tests are skipped by default; run explicitly when needed: `PYTEST_ADDOPTS='' venv/bin/python3 -m pytest -m external`
|
||||||
|
|
||||||
### Core API endpoints (current scaffold)
|
### Core API endpoints (current scaffold)
|
||||||
|
|
||||||
@@ -63,3 +64,5 @@ The dev server proxies `/api` to `http://localhost:8000`.
|
|||||||
|
|
||||||
- Known gaps and risks: `docs/risks.md`
|
- Known gaps and risks: `docs/risks.md`
|
||||||
- Architecture and async/observability decisions: `docs/architecture.md`
|
- Architecture and async/observability decisions: `docs/architecture.md`
|
||||||
|
- Documentation index and standards: `docs/README.md` and `docs/documentation.md`
|
||||||
|
- CI: Gitea Actions workflow in `.gitea/workflows/ci.yml`
|
||||||
|
|||||||
+20
-2
@@ -1,13 +1,31 @@
|
|||||||
# Backend Notes (MVP Readiness)
|
# Backend Notes (MVP Readiness)
|
||||||
|
|
||||||
## High-Level Takeaways
|
## High-Level Takeaways
|
||||||
- Provider integrations are the main reliability gap: OTP providers are stubbed and Moyasar capture/refund are TODOs.
|
- Authentica OTP integration is implemented; Moyasar capture/refund are TODOs.
|
||||||
- External calls (OTP, notifications, payment gateway) run synchronously in request/response paths, increasing latency risk.
|
- External calls (OTP, notifications, payment gateway) run synchronously in request/response paths, increasing latency risk.
|
||||||
- Cross-app coupling (bookings ↔ notifications ↔ accounts/payments) will get harder to evolve without clearer service boundaries.
|
- Cross-app coupling (bookings ↔ notifications ↔ accounts/payments) will get harder to evolve without clearer service boundaries.
|
||||||
- Phone-first auth works, but `USERNAME_FIELD` is email; align identifier strategy to avoid future auth confusion.
|
- Phone-first auth works, but `USERNAME_FIELD` is email; align identifier strategy to avoid future auth confusion.
|
||||||
|
|
||||||
## Near-Term Focus
|
## Near-Term Focus
|
||||||
- Implement at least one real SMS/WhatsApp provider end-to-end via existing abstractions.
|
- Hardening Authentica integration (timeouts, retries, async delivery) and aligning notification provider choices.
|
||||||
|
|
||||||
|
**Authentica E2E**
|
||||||
|
Run the real Authentica OTP flow only when explicitly enabled.
|
||||||
|
|
||||||
|
Env vars (in `backend/.env` or shell):
|
||||||
|
- `AUTHENTICA_E2E=1`
|
||||||
|
- `AUTHENTICA_API_KEY=...`
|
||||||
|
- `AUTHENTICA_E2E_PHONE=...` (must receive OTP)
|
||||||
|
- `AUTHENTICA_E2E_CODE=...` (required; no interactive prompt)
|
||||||
|
|
||||||
|
Command:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
PYTEST_ADDOPTS='' python3 -m pytest apps/accounts/tests -m external
|
||||||
|
```
|
||||||
|
|
||||||
|
Suggested flow:
|
||||||
|
1. Trigger the E2E test to send the OTP, then set `AUTHENTICA_E2E_CODE` and re-run if needed.
|
||||||
- Decide and document payment lifecycle scope (capture/refund supported vs explicitly out of scope).
|
- Decide and document payment lifecycle scope (capture/refund supported vs explicitly out of scope).
|
||||||
- Add timeouts/logging for external calls or introduce minimal async jobs for OTP/notifications.
|
- Add timeouts/logging for external calls or introduce minimal async jobs for OTP/notifications.
|
||||||
- Keep booking, payment, and notification orchestration in service layers, not views.
|
- Keep booking, payment, and notification orchestration in service layers, not views.
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class UserManager(BaseUserManager):
|
|||||||
user.save(using=self._db)
|
user.save(using=self._db)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def create_superuser(self, email, password=None, **extra_fields):
|
def create_superuser(self, phone_number, password=None, **extra_fields):
|
||||||
extra_fields.setdefault("is_staff", True)
|
extra_fields.setdefault("is_staff", True)
|
||||||
extra_fields.setdefault("is_superuser", True)
|
extra_fields.setdefault("is_superuser", True)
|
||||||
extra_fields.setdefault("role", UserRole.ADMIN)
|
extra_fields.setdefault("role", UserRole.ADMIN)
|
||||||
@@ -37,7 +37,7 @@ class UserManager(BaseUserManager):
|
|||||||
raise ValueError("Superuser must have is_staff=True")
|
raise ValueError("Superuser must have is_staff=True")
|
||||||
if extra_fields.get("is_superuser") is not True:
|
if extra_fields.get("is_superuser") is not True:
|
||||||
raise ValueError("Superuser must have is_superuser=True")
|
raise ValueError("Superuser must have is_superuser=True")
|
||||||
return self.create_user(email, password, **extra_fields)
|
return self.create_user(phone_number=phone_number, password=password, **extra_fields)
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractBaseUser, PermissionsMixin):
|
class User(AbstractBaseUser, PermissionsMixin):
|
||||||
@@ -59,7 +59,8 @@ class User(AbstractBaseUser, PermissionsMixin):
|
|||||||
|
|
||||||
objects = UserManager()
|
objects = UserManager()
|
||||||
|
|
||||||
USERNAME_FIELD = "email"
|
USERNAME_FIELD = "phone_number"
|
||||||
|
REQUIRED_FIELDS = [] # email is optional; phone_number is the identifier
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.email or self.phone_number or str(self.id)
|
return self.email or self.phone_number or str(self.id)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import timedelta
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.hashers import check_password, make_password
|
from django.contrib.auth.hashers import check_password, make_password
|
||||||
@@ -34,12 +34,20 @@ class OtpCooldownError(RuntimeError):
|
|||||||
|
|
||||||
|
|
||||||
class BaseOtpProvider:
|
class BaseOtpProvider:
|
||||||
|
uses_provider_otp = False
|
||||||
|
|
||||||
def send_sms(self, to_number: str, message: str) -> None:
|
def send_sms(self, to_number: str, message: str) -> None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def send_whatsapp(self, to_number: str, message: str) -> None:
|
def send_whatsapp(self, to_number: str, message: str) -> None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def send_otp(self, to_number: str, channel: str) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def verify_otp(self, to_number: str, code: str) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class ConsoleOtpProvider(BaseOtpProvider):
|
class ConsoleOtpProvider(BaseOtpProvider):
|
||||||
def send_sms(self, to_number: str, message: str) -> None:
|
def send_sms(self, to_number: str, message: str) -> None:
|
||||||
@@ -102,21 +110,110 @@ class UnifonicOtpProvider(BaseOtpProvider):
|
|||||||
raise NotImplementedError(_("Unifonic WhatsApp adapter not implemented yet"))
|
raise NotImplementedError(_("Unifonic WhatsApp adapter not implemented yet"))
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticaOtpProvider(BaseOtpProvider):
|
||||||
|
"""Authentica provider for OTP delivery (SMS/WhatsApp) plus custom SMS messaging."""
|
||||||
|
|
||||||
|
uses_provider_otp = True
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.api_key = os.getenv("AUTHENTICA_API_KEY")
|
||||||
|
self.base_url = os.getenv("AUTHENTICA_BASE_URL", "https://api.authentica.sa")
|
||||||
|
self.timeout_seconds = float(os.getenv("AUTHENTICA_TIMEOUT_SECONDS", "10"))
|
||||||
|
self.sender_name = os.getenv("AUTHENTICA_SENDER_NAME")
|
||||||
|
|
||||||
|
def _assert_config(self) -> None:
|
||||||
|
if not self.api_key:
|
||||||
|
raise ValueError(_("Authentica API key is not configured"))
|
||||||
|
|
||||||
|
def _headers(self) -> dict:
|
||||||
|
return {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Authorization": self.api_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _post(self, path: str, payload: dict) -> dict:
|
||||||
|
import requests
|
||||||
|
|
||||||
|
self._assert_config()
|
||||||
|
base_url = self.base_url.rstrip("/")
|
||||||
|
url = f"{base_url}{path}"
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
headers=self._headers(),
|
||||||
|
json=payload,
|
||||||
|
timeout=self.timeout_seconds,
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise RuntimeError(_("Authentica request failed")) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError:
|
||||||
|
data = {"detail": response.text}
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
if os.getenv("AUTHENTICA_DEBUG") == "1" or os.getenv("AUTHENTICA_E2E") == "1":
|
||||||
|
raise RuntimeError(
|
||||||
|
_("Authentica request failed: %(status)s %(body)s")
|
||||||
|
% {"status": response.status_code, "body": response.text}
|
||||||
|
)
|
||||||
|
raise RuntimeError(_("Authentica request failed"))
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def send_otp(self, to_number: str, channel: str) -> None:
|
||||||
|
if channel not in (OtpChannel.SMS, OtpChannel.WHATSAPP):
|
||||||
|
raise ValueError(_("Unsupported OTP channel"))
|
||||||
|
method = "sms" if channel == OtpChannel.SMS else "whatsapp"
|
||||||
|
self._post("/api/v2/send-otp", {"method": method, "phone": to_number})
|
||||||
|
|
||||||
|
def verify_otp(self, to_number: str, code: str) -> bool:
|
||||||
|
data = self._post("/api/v2/verify-otp", {"phone": to_number, "otp": code})
|
||||||
|
if "verified" in data:
|
||||||
|
verified = bool(data.get("verified"))
|
||||||
|
else:
|
||||||
|
verified = bool(data.get("status")) or data.get("message") == "OTP verified successfully"
|
||||||
|
if not verified and (os.getenv("AUTHENTICA_DEBUG") == "1" or os.getenv("AUTHENTICA_E2E") == "1"):
|
||||||
|
raise RuntimeError(_("Authentica verify failed: %(response)s") % {"response": data})
|
||||||
|
return verified
|
||||||
|
|
||||||
|
def send_sms(self, to_number: str, message: str) -> None:
|
||||||
|
if not self.sender_name:
|
||||||
|
raise ValueError(_("Authentica sender name is not configured"))
|
||||||
|
self._post(
|
||||||
|
"/api/v2/send-sms",
|
||||||
|
{
|
||||||
|
"phone": to_number,
|
||||||
|
"message": message,
|
||||||
|
"sender_name": self.sender_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def send_whatsapp(self, to_number: str, message: str) -> None:
|
||||||
|
raise ValueError(_("Authentica WhatsApp messaging is not supported"))
|
||||||
|
|
||||||
|
|
||||||
PROVIDERS = {
|
PROVIDERS = {
|
||||||
"console": ConsoleOtpProvider,
|
"console": ConsoleOtpProvider,
|
||||||
"twilio": TwilioOtpProvider,
|
"twilio": TwilioOtpProvider,
|
||||||
"unifonic": UnifonicOtpProvider,
|
"unifonic": UnifonicOtpProvider,
|
||||||
|
"authentica": AuthenticaOtpProvider,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_provider() -> BaseOtpProvider:
|
def _get_provider_for_key(provider_key: str) -> BaseOtpProvider:
|
||||||
provider_key = settings.OTP_PROVIDER
|
|
||||||
provider_cls = PROVIDERS.get(provider_key)
|
provider_cls = PROVIDERS.get(provider_key)
|
||||||
if not provider_cls:
|
if not provider_cls:
|
||||||
raise ValueError(_("Unknown OTP provider: %(provider)s") % {"provider": provider_key})
|
raise ValueError(_("Unknown OTP provider: %(provider)s") % {"provider": provider_key})
|
||||||
return provider_cls()
|
return provider_cls()
|
||||||
|
|
||||||
|
|
||||||
|
def get_provider() -> BaseOtpProvider:
|
||||||
|
return _get_provider_for_key(settings.OTP_PROVIDER)
|
||||||
|
|
||||||
|
|
||||||
def generate_code(length: int = 6) -> str:
|
def generate_code(length: int = 6) -> str:
|
||||||
digits = "0123456789"
|
digits = "0123456789"
|
||||||
return "".join(secrets.choice(digits) for _ in range(length))
|
return "".join(secrets.choice(digits) for _ in range(length))
|
||||||
@@ -149,25 +246,34 @@ def create_and_send_otp(phone_number: str, channel: str, purpose: str = OtpPurpo
|
|||||||
if elapsed < cooldown_seconds:
|
if elapsed < cooldown_seconds:
|
||||||
raise OtpCooldownError(retry_after_seconds=int(cooldown_seconds - elapsed))
|
raise OtpCooldownError(retry_after_seconds=int(cooldown_seconds - elapsed))
|
||||||
|
|
||||||
code = generate_code()
|
if provider.uses_provider_otp:
|
||||||
|
code_hash = make_password(secrets.token_urlsafe(16))
|
||||||
|
message = None
|
||||||
|
else:
|
||||||
|
code = generate_code()
|
||||||
|
code_hash = make_password(code)
|
||||||
|
message = _(
|
||||||
|
"Your verification code is %(code)s. It expires in %(minutes)s minutes."
|
||||||
|
) % {"code": code, "minutes": settings.OTP_EXPIRY_MINUTES}
|
||||||
|
|
||||||
otp = PhoneOTP.objects.create(
|
otp = PhoneOTP.objects.create(
|
||||||
phone_number=phone_number,
|
phone_number=phone_number,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
purpose=purpose,
|
purpose=purpose,
|
||||||
provider=settings.OTP_PROVIDER,
|
provider=settings.OTP_PROVIDER,
|
||||||
code_hash=make_password(code),
|
code_hash=code_hash,
|
||||||
expires_at=PhoneOTP.expiry_at(),
|
expires_at=PhoneOTP.expiry_at(),
|
||||||
)
|
)
|
||||||
|
|
||||||
message = _(
|
if provider.uses_provider_otp:
|
||||||
"Your verification code is %(code)s. It expires in %(minutes)s minutes."
|
provider.send_otp(phone_number, channel)
|
||||||
) % {"code": code, "minutes": settings.OTP_EXPIRY_MINUTES}
|
|
||||||
if channel == OtpChannel.SMS:
|
|
||||||
provider.send_sms(phone_number, message)
|
|
||||||
elif channel == OtpChannel.WHATSAPP:
|
|
||||||
provider.send_whatsapp(phone_number, message)
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(_("Unsupported OTP channel"))
|
if channel == OtpChannel.SMS:
|
||||||
|
provider.send_sms(phone_number, message)
|
||||||
|
elif channel == OtpChannel.WHATSAPP:
|
||||||
|
provider.send_whatsapp(phone_number, message)
|
||||||
|
else:
|
||||||
|
raise ValueError(_("Unsupported OTP channel"))
|
||||||
|
|
||||||
return OtpSendResult(request_id=str(otp.id), expires_at=otp.expires_at.isoformat())
|
return OtpSendResult(request_id=str(otp.id), expires_at=otp.expires_at.isoformat())
|
||||||
|
|
||||||
@@ -179,9 +285,21 @@ def verify_otp(otp: PhoneOTP, code: str) -> bool:
|
|||||||
if otp.attempt_count > otp.max_attempts:
|
if otp.attempt_count > otp.max_attempts:
|
||||||
otp.save(update_fields=["attempt_count"])
|
otp.save(update_fields=["attempt_count"])
|
||||||
return False
|
return False
|
||||||
if not check_password(code, otp.code_hash):
|
provider_cls = PROVIDERS.get(otp.provider)
|
||||||
otp.save(update_fields=["attempt_count"])
|
if provider_cls and getattr(provider_cls, "uses_provider_otp", False):
|
||||||
return False
|
provider = provider_cls()
|
||||||
|
try:
|
||||||
|
verified = provider.verify_otp(otp.phone_number, code)
|
||||||
|
except Exception:
|
||||||
|
otp.save(update_fields=["attempt_count"])
|
||||||
|
raise
|
||||||
|
if not verified:
|
||||||
|
otp.save(update_fields=["attempt_count"])
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
if not check_password(code, otp.code_hash):
|
||||||
|
otp.save(update_fields=["attempt_count"])
|
||||||
|
return False
|
||||||
otp.verified_at = timezone.now()
|
otp.verified_at = timezone.now()
|
||||||
otp.save(update_fields=["verified_at", "attempt_count"])
|
otp.save(update_fields=["verified_at", "attempt_count"])
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Mocked end-to-end phone auth flow using Authentica OTP provider."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from apps.accounts.models import User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(OTP_PROVIDER="authentica")
|
||||||
|
@patch("requests.post")
|
||||||
|
def test_phone_auth_flow_with_authentica_mock(mock_post, client):
|
||||||
|
def make_response(payload, ok=True):
|
||||||
|
response = MagicMock()
|
||||||
|
response.ok = ok
|
||||||
|
response.json.return_value = payload
|
||||||
|
response.text = ""
|
||||||
|
return response
|
||||||
|
|
||||||
|
def side_effect(url, headers=None, json=None, timeout=None):
|
||||||
|
assert headers and headers.get("X-Authorization") == "api-key"
|
||||||
|
assert timeout == 7.0
|
||||||
|
if url.endswith("/api/v2/send-otp"):
|
||||||
|
assert json == {"method": "sms", "phone": "+966512345678"}
|
||||||
|
return make_response({"success": True})
|
||||||
|
if url.endswith("/api/v2/verify-otp"):
|
||||||
|
if json == {"phone": "+966512345678", "otp": "123456"}:
|
||||||
|
return make_response({"verified": True})
|
||||||
|
return make_response({"verified": False})
|
||||||
|
raise AssertionError(f"Unexpected URL {url}")
|
||||||
|
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"AUTHENTICA_API_KEY": "api-key",
|
||||||
|
"AUTHENTICA_TIMEOUT_SECONDS": "7",
|
||||||
|
},
|
||||||
|
):
|
||||||
|
mock_post.side_effect = side_effect
|
||||||
|
|
||||||
|
request_url = reverse("phone_auth_request")
|
||||||
|
verify_url = reverse("phone_auth_verify")
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
request_url,
|
||||||
|
{"phone_number": "0512345678", "channel": "sms", "first_name": "Sara"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
request_id = response.json()["request_id"]
|
||||||
|
|
||||||
|
bad = client.post(
|
||||||
|
verify_url,
|
||||||
|
{"request_id": request_id, "code": "000000"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert bad.status_code == 400
|
||||||
|
|
||||||
|
good = client.post(
|
||||||
|
verify_url,
|
||||||
|
{"request_id": request_id, "code": "123456"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert good.status_code == 200
|
||||||
|
|
||||||
|
user = User.objects.filter(phone_number="+966512345678").first()
|
||||||
|
assert user is not None
|
||||||
|
assert user.is_phone_verified is True
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Real Authentica E2E OTP flow. Requires live credentials and a phone receiving OTPs."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP, User
|
||||||
|
from apps.accounts.services.phone import normalize_phone_number
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.external
|
||||||
|
@override_settings(OTP_PROVIDER="authentica")
|
||||||
|
def test_authentica_phone_auth_e2e(client):
|
||||||
|
if os.getenv("AUTHENTICA_E2E") != "1":
|
||||||
|
pytest.skip("AUTHENTICA_E2E=1 not set")
|
||||||
|
|
||||||
|
api_key = os.getenv("AUTHENTICA_API_KEY")
|
||||||
|
phone_number = os.getenv("AUTHENTICA_E2E_PHONE")
|
||||||
|
if not api_key or not phone_number:
|
||||||
|
pytest.skip("Missing AUTHENTICA_API_KEY or AUTHENTICA_E2E_PHONE")
|
||||||
|
|
||||||
|
request_url = reverse("phone_auth_request")
|
||||||
|
response = client.post(
|
||||||
|
request_url,
|
||||||
|
{"phone_number": phone_number, "channel": "sms", "first_name": "E2E"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
request_id = response.json()["request_id"]
|
||||||
|
assert request_id
|
||||||
|
|
||||||
|
code = os.getenv("AUTHENTICA_E2E_CODE")
|
||||||
|
if not code:
|
||||||
|
pytest.skip("AUTHENTICA_E2E_CODE not set")
|
||||||
|
|
||||||
|
normalized_phone = normalize_phone_number(phone_number)
|
||||||
|
User.objects.get_or_create(
|
||||||
|
phone_number=normalized_phone,
|
||||||
|
defaults={"role": "customer"},
|
||||||
|
)
|
||||||
|
if not PhoneOTP.objects.filter(id=request_id).exists():
|
||||||
|
# Create a local OTP record so the verify endpoint can bind to a request_id.
|
||||||
|
PhoneOTP.objects.create(
|
||||||
|
id=request_id,
|
||||||
|
phone_number=normalized_phone,
|
||||||
|
channel=OtpChannel.SMS,
|
||||||
|
purpose=OtpPurpose.AUTH,
|
||||||
|
provider="authentica",
|
||||||
|
code_hash="placeholder",
|
||||||
|
expires_at=timezone.now() + timedelta(minutes=5),
|
||||||
|
)
|
||||||
|
|
||||||
|
verify_url = reverse("phone_auth_verify")
|
||||||
|
verify = client.post(
|
||||||
|
verify_url,
|
||||||
|
{"request_id": request_id, "code": code},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert verify.status_code == 200
|
||||||
|
data = verify.json()
|
||||||
|
assert "access" in data
|
||||||
|
assert "refresh" in data
|
||||||
|
|
||||||
|
user = User.objects.filter(phone_number=normalized_phone).first()
|
||||||
|
assert user is not None
|
||||||
|
assert user.is_phone_verified is True
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Tests for Authentica OTP provider implementation."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.contrib.auth.hashers import make_password
|
||||||
|
|
||||||
|
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP
|
||||||
|
from apps.accounts.services.otp import AuthenticaOtpProvider, verify_otp
|
||||||
|
|
||||||
|
|
||||||
|
@patch("requests.post")
|
||||||
|
def test_authentica_send_otp_sms_calls_api(mock_post):
|
||||||
|
mock_response = MagicMock(ok=True)
|
||||||
|
mock_response.json.return_value = {"success": True}
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"AUTHENTICA_API_KEY": "api-key",
|
||||||
|
"AUTHENTICA_BASE_URL": "https://api.authentica.sa",
|
||||||
|
"AUTHENTICA_TIMEOUT_SECONDS": "7",
|
||||||
|
},
|
||||||
|
):
|
||||||
|
provider = AuthenticaOtpProvider()
|
||||||
|
provider.send_otp("+966512345678", OtpChannel.SMS)
|
||||||
|
|
||||||
|
mock_post.assert_called_once()
|
||||||
|
url = mock_post.call_args[0][0]
|
||||||
|
kwargs = mock_post.call_args[1]
|
||||||
|
|
||||||
|
assert url == "https://api.authentica.sa/api/v2/send-otp"
|
||||||
|
assert kwargs["json"] == {"method": "sms", "phone": "+966512345678"}
|
||||||
|
assert kwargs["headers"]["X-Authorization"] == "api-key"
|
||||||
|
assert kwargs["timeout"] == 7.0
|
||||||
|
|
||||||
|
|
||||||
|
@patch("requests.post")
|
||||||
|
def test_authentica_send_sms_calls_api(mock_post):
|
||||||
|
mock_response = MagicMock(ok=True)
|
||||||
|
mock_response.json.return_value = {"success": True}
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"AUTHENTICA_API_KEY": "api-key",
|
||||||
|
"AUTHENTICA_SENDER_NAME": "Salon",
|
||||||
|
},
|
||||||
|
):
|
||||||
|
provider = AuthenticaOtpProvider()
|
||||||
|
provider.send_sms("+966512345678", "Hello")
|
||||||
|
|
||||||
|
mock_post.assert_called_once()
|
||||||
|
url = mock_post.call_args[0][0]
|
||||||
|
kwargs = mock_post.call_args[1]
|
||||||
|
|
||||||
|
assert url == "https://api.authentica.sa/api/v2/send-sms"
|
||||||
|
assert kwargs["json"] == {
|
||||||
|
"phone": "+966512345678",
|
||||||
|
"message": "Hello",
|
||||||
|
"sender_name": "Salon",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@patch("requests.post")
|
||||||
|
def test_authentica_verify_otp_calls_api(mock_post):
|
||||||
|
mock_response = MagicMock(ok=True)
|
||||||
|
mock_response.json.return_value = {"verified": True}
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"AUTHENTICA_API_KEY": "api-key"}):
|
||||||
|
provider = AuthenticaOtpProvider()
|
||||||
|
assert provider.verify_otp("+966512345678", "123456") is True
|
||||||
|
|
||||||
|
mock_post.assert_called_once()
|
||||||
|
url = mock_post.call_args[0][0]
|
||||||
|
kwargs = mock_post.call_args[1]
|
||||||
|
|
||||||
|
assert url == "https://api.authentica.sa/api/v2/verify-otp"
|
||||||
|
assert kwargs["json"] == {"phone": "+966512345678", "otp": "123456"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_verify_otp_uses_provider_for_authentica():
|
||||||
|
otp = PhoneOTP.objects.create(
|
||||||
|
phone_number="+966512345678",
|
||||||
|
channel=OtpChannel.SMS,
|
||||||
|
purpose=OtpPurpose.AUTH,
|
||||||
|
provider="authentica",
|
||||||
|
code_hash=make_password("unused"),
|
||||||
|
expires_at=PhoneOTP.expiry_at(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("apps.accounts.services.otp.AuthenticaOtpProvider.verify_otp", return_value=True) as mock_verify:
|
||||||
|
assert verify_otp(otp, "123456") is True
|
||||||
|
|
||||||
|
mock_verify.assert_called_once_with("+966512345678", "123456")
|
||||||
|
otp.refresh_from_db()
|
||||||
|
assert otp.verified_at is not None
|
||||||
|
assert otp.attempt_count == 1
|
||||||
@@ -1,13 +1,49 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
from django.contrib.auth.hashers import make_password
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
|
|
||||||
from apps.accounts.models import OtpChannel
|
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP
|
||||||
from apps.accounts.services.otp import OtpRateLimitError, create_and_send_otp
|
from apps.accounts.services.otp import OtpCooldownError, OtpRateLimitError, create_and_send_otp, verify_otp
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@override_settings(OTP_MAX_PER_WINDOW=1, OTP_WINDOW_MINUTES=15, OTP_RESEND_COOLDOWN_SECONDS=0)
|
@override_settings(OTP_PROVIDER="console", OTP_MAX_PER_WINDOW=1, OTP_WINDOW_MINUTES=15, OTP_RESEND_COOLDOWN_SECONDS=0)
|
||||||
def test_otp_rate_limit():
|
def test_otp_rate_limit():
|
||||||
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||||
with pytest.raises(OtpRateLimitError):
|
with pytest.raises(OtpRateLimitError):
|
||||||
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(
|
||||||
|
OTP_PROVIDER="console",
|
||||||
|
OTP_MAX_PER_WINDOW=5,
|
||||||
|
OTP_WINDOW_MINUTES=15,
|
||||||
|
OTP_RESEND_COOLDOWN_SECONDS=60,
|
||||||
|
)
|
||||||
|
def test_otp_cooldown_enforced():
|
||||||
|
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||||
|
with pytest.raises(OtpCooldownError):
|
||||||
|
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_otp_max_attempts_blocks_verification():
|
||||||
|
otp = PhoneOTP.objects.create(
|
||||||
|
phone_number="+966512345678",
|
||||||
|
channel=OtpChannel.SMS,
|
||||||
|
purpose=OtpPurpose.AUTH,
|
||||||
|
provider="console",
|
||||||
|
code_hash=make_password("123456"),
|
||||||
|
expires_at=PhoneOTP.expiry_at(),
|
||||||
|
)
|
||||||
|
# Burn attempts with wrong code until the limit is exceeded.
|
||||||
|
for _ in range(otp.max_attempts):
|
||||||
|
assert verify_otp(otp, "000000") is False
|
||||||
|
otp.refresh_from_db()
|
||||||
|
assert otp.attempt_count == otp.max_attempts
|
||||||
|
|
||||||
|
assert verify_otp(otp, "123456") is False
|
||||||
|
otp.refresh_from_db()
|
||||||
|
assert otp.attempt_count == otp.max_attempts + 1
|
||||||
|
assert otp.verified_at is None
|
||||||
|
|||||||
@@ -1,31 +1,49 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from django.test import override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from apps.accounts.models import PhoneOTP, User
|
from apps.accounts.models import PhoneOTP, User
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
@override_settings(OTP_PROVIDER="console")
|
||||||
def test_phone_auth_creates_user_and_issues_tokens(client):
|
def test_phone_auth_creates_user_and_issues_tokens(client):
|
||||||
request_url = reverse("phone_auth_request")
|
# Deterministic OTP so we can verify the flow without external providers.
|
||||||
verify_url = reverse("phone_auth_verify")
|
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
|
||||||
|
request_url = reverse("phone_auth_request")
|
||||||
|
verify_url = reverse("phone_auth_verify")
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
request_url,
|
request_url,
|
||||||
{"phone_number": "0512345678", "channel": "sms", "first_name": "Sara"},
|
{"phone_number": "0512345678", "channel": "sms", "first_name": "Sara"},
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
request_id = response.json()["request_id"]
|
request_id = response.json()["request_id"]
|
||||||
|
|
||||||
otp = PhoneOTP.objects.filter(phone_number="+966512345678").order_by("-created_at").first()
|
otp = PhoneOTP.objects.filter(phone_number="+966512345678").order_by("-created_at").first()
|
||||||
assert otp is not None
|
assert otp is not None
|
||||||
assert str(otp.id) == request_id
|
assert str(otp.id) == request_id
|
||||||
|
|
||||||
bad = client.post(
|
bad = client.post(
|
||||||
verify_url,
|
verify_url,
|
||||||
{"request_id": request_id, "code": "000000"},
|
{"request_id": request_id, "code": "000000"},
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
assert bad.status_code == 400
|
assert bad.status_code == 400
|
||||||
|
|
||||||
assert User.objects.filter(phone_number="+966512345678").exists()
|
good = client.post(
|
||||||
|
verify_url,
|
||||||
|
{"request_id": request_id, "code": "123456"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert good.status_code == 200
|
||||||
|
data = good.json()
|
||||||
|
assert "access" in data
|
||||||
|
assert "refresh" in data
|
||||||
|
|
||||||
|
user = User.objects.filter(phone_number="+966512345678").first()
|
||||||
|
assert user is not None
|
||||||
|
assert user.is_phone_verified is True
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from django.db import transaction
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from apps.bookings.models import Booking, BookingStatus
|
from apps.bookings.models import Booking, BookingStatus
|
||||||
@@ -74,14 +75,40 @@ class BookingCreateSerializer(serializers.ModelSerializer):
|
|||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
request = self.context["request"]
|
request = self.context["request"]
|
||||||
service = validated_data["service"]
|
service = validated_data["service"]
|
||||||
return Booking.objects.create(
|
staff = validated_data.get("staff")
|
||||||
salon=service.salon,
|
start_time = validated_data["start_time"]
|
||||||
customer=request.user,
|
end_time = validated_data["end_time"]
|
||||||
service=service,
|
|
||||||
staff=validated_data.get("staff"),
|
with transaction.atomic():
|
||||||
start_time=validated_data["start_time"],
|
# Lock the staff row so concurrent booking requests for the same staff
|
||||||
end_time=validated_data["end_time"],
|
# member are serialized. Without this, two requests that both pass the
|
||||||
notes=validated_data.get("notes", ""),
|
# overlap check in validate() can race and both commit overlapping
|
||||||
price_amount=service.price_amount,
|
# bookings. On SQLite (dev/tests) the FOR UPDATE clause is silently
|
||||||
currency=service.currency,
|
# ignored but the transaction still serializes writes; PostgreSQL
|
||||||
)
|
# (production) gets true row-level locking.
|
||||||
|
StaffProfile.objects.select_for_update().get(pk=staff.pk)
|
||||||
|
|
||||||
|
# Re-run the overlap check inside the lock so the check and the insert
|
||||||
|
# are atomic with respect to other writers.
|
||||||
|
overlap = Booking.objects.filter(
|
||||||
|
staff=staff,
|
||||||
|
status__in=[BookingStatus.PENDING, BookingStatus.CONFIRMED],
|
||||||
|
start_time__lt=end_time,
|
||||||
|
end_time__gt=start_time,
|
||||||
|
).exists()
|
||||||
|
if overlap:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"start_time": _("Booking overlaps an existing appointment")}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Booking.objects.create(
|
||||||
|
salon=service.salon,
|
||||||
|
customer=request.user,
|
||||||
|
service=service,
|
||||||
|
staff=staff,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
notes=validated_data.get("notes", ""),
|
||||||
|
price_amount=service.price_amount,
|
||||||
|
currency=service.currency,
|
||||||
|
)
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,251 @@
|
|||||||
|
# Arabic (Saudi Arabia) translations for Salon booking platform.
|
||||||
|
# Copyright (C) 2026
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: 1.0\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2026-03-02 00:45+0300\n"
|
||||||
|
"PO-Revision-Date: 2026-03-02 00:45+0300\n"
|
||||||
|
"Last-Translator: Claude\n"
|
||||||
|
"Language-Team: Arabic (Saudi Arabia)\n"
|
||||||
|
"Language: ar_SA\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:26
|
||||||
|
msgid "Too many OTP requests. Try again later."
|
||||||
|
msgstr "طلبات رمز التحقق كثيرة جداً. حاول مرة أخرى لاحقاً."
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:32
|
||||||
|
msgid "Please wait before requesting another code."
|
||||||
|
msgstr "يرجى الانتظار قبل طلب رمز آخر."
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:71
|
||||||
|
msgid "Twilio credentials are not configured"
|
||||||
|
msgstr "لم يتم تكوين بيانات اعتماد Twilio"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:85
|
||||||
|
msgid "Twilio WhatsApp sender is not configured"
|
||||||
|
msgstr "لم يتم تكوين مُرسِل WhatsApp لـ Twilio"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:100
|
||||||
|
msgid "Unifonic credentials are not configured"
|
||||||
|
msgstr "لم يتم تكوين بيانات اعتماد Unifonic"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:104
|
||||||
|
msgid "Unifonic SMS adapter not implemented yet"
|
||||||
|
msgstr "محول SMS لـ Unifonic لم يتم تنفيذه بعد"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:109
|
||||||
|
msgid "Unifonic WhatsApp sender is not configured"
|
||||||
|
msgstr "لم يتم تكوين مُرسِل WhatsApp لـ Unifonic"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:110
|
||||||
|
msgid "Unifonic WhatsApp adapter not implemented yet"
|
||||||
|
msgstr "محول WhatsApp لـ Unifonic لم يتم تنفيذه بعد"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:126
|
||||||
|
msgid "Authentica API key is not configured"
|
||||||
|
msgstr "لم يتم تكوين مفتاح API لـ Authentica"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:149 apps/accounts/services/otp.py:162
|
||||||
|
msgid "Authentica request failed"
|
||||||
|
msgstr "فشل طلب Authentica"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:159
|
||||||
|
#, python-format
|
||||||
|
msgid "Authentica request failed: %(status)s %(body)s"
|
||||||
|
msgstr "فشل طلب Authentica: %(status)s %(body)s"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:168 apps/accounts/services/otp.py:276
|
||||||
|
msgid "Unsupported OTP channel"
|
||||||
|
msgstr "قناة رمز التحقق غير مدعومة"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:179
|
||||||
|
#, python-format
|
||||||
|
msgid "Authentica verify failed: %(response)s"
|
||||||
|
msgstr "فشل التحقق بـ Authentica: %(response)s"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:184
|
||||||
|
msgid "Authentica sender name is not configured"
|
||||||
|
msgstr "لم يتم تكوين اسم مُرسِل Authentica"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:195
|
||||||
|
msgid "Authentica WhatsApp messaging is not supported"
|
||||||
|
msgstr "مراسلة WhatsApp غير مدعومة لـ Authentica"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:209
|
||||||
|
#, python-format
|
||||||
|
msgid "Unknown OTP provider: %(provider)s"
|
||||||
|
msgstr "مزود رمز التحقق غير معروف: %(provider)s"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:256
|
||||||
|
#, python-format
|
||||||
|
msgid "Your verification code is %(code)s. It expires in %(minutes)s minutes."
|
||||||
|
msgstr "رمز التحقق الخاص بك هو %(code)s. ينتهي في %(minutes)s دقيقة."
|
||||||
|
|
||||||
|
#: apps/accounts/services/phone.py:8
|
||||||
|
msgid "Phone number is required"
|
||||||
|
msgstr "رقم الهاتف مطلوب"
|
||||||
|
|
||||||
|
#: apps/accounts/services/phone.py:17
|
||||||
|
msgid "Invalid phone number format"
|
||||||
|
msgstr "تنسيق رقم الهاتف غير صالح"
|
||||||
|
|
||||||
|
#: apps/accounts/services/phone.py:28
|
||||||
|
msgid "Phone number must be in E.164 format or a valid Saudi mobile"
|
||||||
|
msgstr "يجب أن يكون رقم الهاتف بصيغة E.164 أو رقم جوال سعودي صالح"
|
||||||
|
|
||||||
|
#: apps/accounts/views.py:75 apps/accounts/views.py:138
|
||||||
|
msgid "Invalid or expired code"
|
||||||
|
msgstr "الرمز غير صالح أو منتهي الصلاحية"
|
||||||
|
|
||||||
|
#: apps/accounts/views.py:82
|
||||||
|
msgid "Phone verified"
|
||||||
|
msgstr "تم التحقق من رقم الهاتف"
|
||||||
|
|
||||||
|
#: apps/accounts/views.py:99
|
||||||
|
msgid "Email already in use."
|
||||||
|
msgstr "البريد الإلكتروني مستخدم بالفعل."
|
||||||
|
|
||||||
|
#: apps/accounts/views.py:142
|
||||||
|
msgid "User not found"
|
||||||
|
msgstr "المستخدم غير موجود"
|
||||||
|
|
||||||
|
#: apps/accounts/views.py:164
|
||||||
|
msgid "Social login not configured yet. Add OAuth provider config."
|
||||||
|
msgstr "لم يتم تكوين تسجيل الدخول الاجتماعي بعد. أضف إعداد مزود OAuth."
|
||||||
|
|
||||||
|
#: apps/bookings/serializers.py:54
|
||||||
|
msgid "Only staff or managers can confirm bookings."
|
||||||
|
msgstr "يمكن للموظفين والمدراء فقط تأكيد الحجوزات."
|
||||||
|
|
||||||
|
#: apps/bookings/serializers.py:56
|
||||||
|
msgid "Only staff or managers can complete bookings."
|
||||||
|
msgstr "يمكن للموظفين والمدراء فقط إكمال الحجوزات."
|
||||||
|
|
||||||
|
#: apps/bookings/serializers.py:58
|
||||||
|
msgid "You are not allowed to cancel this booking."
|
||||||
|
msgstr "لا يُسمح لك بإلغاء هذا الحجز."
|
||||||
|
|
||||||
|
#: apps/bookings/serializers.py:101 apps/bookings/services.py:49
|
||||||
|
msgid "Booking overlaps an existing appointment"
|
||||||
|
msgstr "يتداخل الحجز مع موعد قائم"
|
||||||
|
|
||||||
|
#: apps/bookings/services.py:13
|
||||||
|
msgid "Staff is required for booking"
|
||||||
|
msgstr "يجب تحديد موظف للحجز"
|
||||||
|
|
||||||
|
#: apps/bookings/services.py:16
|
||||||
|
msgid "Selected staff does not belong to this salon"
|
||||||
|
msgstr "الموظف المختار لا ينتمي إلى هذا الصالون"
|
||||||
|
|
||||||
|
#: apps/bookings/services.py:19
|
||||||
|
msgid "End time must be after start time"
|
||||||
|
msgstr "يجب أن يكون وقت الانتهاء بعد وقت البدء"
|
||||||
|
|
||||||
|
#: apps/bookings/services.py:23
|
||||||
|
msgid "End time must match service duration"
|
||||||
|
msgstr "يجب أن يتطابق وقت الانتهاء مع مدة الخدمة"
|
||||||
|
|
||||||
|
#: apps/bookings/services.py:40
|
||||||
|
msgid "Booking is outside staff availability"
|
||||||
|
msgstr "الحجز خارج أوقات توفر الموظف"
|
||||||
|
|
||||||
|
#: apps/notifications/services.py:31
|
||||||
|
#, python-format
|
||||||
|
msgid "Unknown notification provider: %(provider)s"
|
||||||
|
msgstr "مزود الإشعارات غير معروف: %(provider)s"
|
||||||
|
|
||||||
|
#: apps/notifications/services.py:47
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"Your booking request is received for %(service)s at %(salon)s on %(start)s."
|
||||||
|
msgstr "تم استلام طلب حجزك لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
|
||||||
|
|
||||||
|
#: apps/notifications/services.py:55
|
||||||
|
#, python-format
|
||||||
|
msgid "Your booking is confirmed for %(service)s at %(salon)s on %(start)s."
|
||||||
|
msgstr "تم تأكيد حجزك لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
|
||||||
|
|
||||||
|
#: apps/notifications/services.py:63
|
||||||
|
#, python-format
|
||||||
|
msgid "Your booking was cancelled for %(service)s at %(salon)s on %(start)s."
|
||||||
|
msgstr "تم إلغاء حجزك لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
|
||||||
|
|
||||||
|
#: apps/notifications/services.py:70
|
||||||
|
#, python-format
|
||||||
|
msgid "Booking update for %(service)s at %(salon)s on %(start)s."
|
||||||
|
msgstr "تحديث الحجز لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
|
||||||
|
|
||||||
|
#: apps/notifications/services.py:85
|
||||||
|
msgid "Unsupported notification channel"
|
||||||
|
msgstr "قناة الإشعارات غير مدعومة"
|
||||||
|
|
||||||
|
#: apps/payments/serializers.py:49
|
||||||
|
msgid "Booking not found"
|
||||||
|
msgstr "الحجز غير موجود"
|
||||||
|
|
||||||
|
#: apps/payments/serializers.py:56 apps/payments/services/payments.py:79
|
||||||
|
msgid "Provider integration not implemented"
|
||||||
|
msgstr "تكامل المزود غير مُنفَّذ"
|
||||||
|
|
||||||
|
#: apps/payments/serializers.py:58
|
||||||
|
msgid "Payment source is required"
|
||||||
|
msgstr "مصدر الدفع مطلوب"
|
||||||
|
|
||||||
|
#: apps/payments/serializers.py:61
|
||||||
|
msgid "Payment source type is required"
|
||||||
|
msgstr "نوع مصدر الدفع مطلوب"
|
||||||
|
|
||||||
|
#: apps/payments/serializers.py:64
|
||||||
|
msgid "Card data must not be sent to the backend; use frontend tokenization"
|
||||||
|
msgstr "لا يجب إرسال بيانات البطاقة إلى الخادم؛ استخدم التحويل إلى رمز في الواجهة الأمامية"
|
||||||
|
|
||||||
|
#: apps/payments/serializers.py:67
|
||||||
|
msgid "Callback URL is required for token payments"
|
||||||
|
msgstr "رابط الاستجابة مطلوب لمدفوعات الرمز"
|
||||||
|
|
||||||
|
#: apps/payments/services/payments.py:84
|
||||||
|
msgid "Idempotency key already used"
|
||||||
|
msgstr "مفتاح الإيدمبوتنسي مستخدم بالفعل"
|
||||||
|
|
||||||
|
#: apps/payments/services/payments.py:89
|
||||||
|
msgid "Unsupported payment source type"
|
||||||
|
msgstr "نوع مصدر الدفع غير مدعوم"
|
||||||
|
|
||||||
|
#: apps/payments/services/payments.py:130
|
||||||
|
#: apps/payments/services/payments.py:141
|
||||||
|
msgid "Payment provider error"
|
||||||
|
msgstr "خطأ في مزود الدفع"
|
||||||
|
|
||||||
|
#: apps/payments/views.py:50
|
||||||
|
msgid "Not allowed"
|
||||||
|
msgstr "غير مسموح"
|
||||||
|
|
||||||
|
#: apps/payments/views.py:70
|
||||||
|
msgid "Webhook secret not configured"
|
||||||
|
msgstr "لم يتم تكوين رمز الـ webhook"
|
||||||
|
|
||||||
|
#: apps/payments/views.py:73
|
||||||
|
msgid "Invalid webhook signature"
|
||||||
|
msgstr "توقيع الـ webhook غير صالح"
|
||||||
|
|
||||||
|
#: apps/payments/views.py:79
|
||||||
|
msgid "Missing payment reference"
|
||||||
|
msgstr "مرجع الدفع مفقود"
|
||||||
|
|
||||||
|
#: apps/payments/views.py:84
|
||||||
|
msgid "Payment not found"
|
||||||
|
msgstr "لم يتم العثور على الدفعة"
|
||||||
|
|
||||||
|
#: apps/payments/views.py:88
|
||||||
|
msgid "Event ignored"
|
||||||
|
msgstr "تم تجاهل الحدث"
|
||||||
|
|
||||||
|
#: apps/payments/views.py:89
|
||||||
|
msgid "Webhook processed"
|
||||||
|
msgstr "تمت معالجة الـ webhook"
|
||||||
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
[pytest]
|
[pytest]
|
||||||
DJANGO_SETTINGS_MODULE = salon_api.settings
|
DJANGO_SETTINGS_MODULE = salon_api.settings
|
||||||
python_files = tests.py test_*.py *_tests.py
|
python_files = tests.py test_*.py *_tests.py
|
||||||
addopts = -q
|
addopts = -q -m "not external"
|
||||||
|
markers =
|
||||||
|
external: hits real third-party services (requires explicit env to run)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -78,11 +79,14 @@ def parse_database_url(database_url: str):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL")
|
running_tests = "PYTEST_CURRENT_TEST" in os.environ or any("pytest" in arg for arg in sys.argv)
|
||||||
if DATABASE_URL:
|
test_database_url = os.getenv("TEST_DATABASE_URL")
|
||||||
parsed_db = parse_database_url(DATABASE_URL)
|
database_url = os.getenv("DATABASE_URL")
|
||||||
|
|
||||||
|
if running_tests:
|
||||||
|
parsed_db = parse_database_url(test_database_url) if test_database_url else None
|
||||||
else:
|
else:
|
||||||
parsed_db = None
|
parsed_db = parse_database_url(database_url) if database_url else None
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": parsed_db
|
"default": parsed_db
|
||||||
@@ -136,6 +140,8 @@ CORS_ALLOWED_ORIGINS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
OTP_PROVIDER = os.getenv("OTP_PROVIDER", "console")
|
OTP_PROVIDER = os.getenv("OTP_PROVIDER", "console")
|
||||||
|
if running_tests:
|
||||||
|
OTP_PROVIDER = os.getenv("TEST_OTP_PROVIDER", "console")
|
||||||
OTP_EXPIRY_MINUTES = int(os.getenv("OTP_EXPIRY_MINUTES", "5"))
|
OTP_EXPIRY_MINUTES = int(os.getenv("OTP_EXPIRY_MINUTES", "5"))
|
||||||
OTP_MAX_PER_WINDOW = int(os.getenv("OTP_MAX_PER_WINDOW", "5"))
|
OTP_MAX_PER_WINDOW = int(os.getenv("OTP_MAX_PER_WINDOW", "5"))
|
||||||
OTP_WINDOW_MINUTES = int(os.getenv("OTP_WINDOW_MINUTES", "15"))
|
OTP_WINDOW_MINUTES = int(os.getenv("OTP_WINDOW_MINUTES", "15"))
|
||||||
|
|||||||
+34
-9
@@ -1,11 +1,36 @@
|
|||||||
# Docs Notes (MVP Alignment)
|
# Documentation Index
|
||||||
|
|
||||||
## High-Level Takeaways
|
This directory is the source of truth for product, engineering, and ops documentation. Keep it current as features change.
|
||||||
- The MVP roadmap aligns with Phase 1 goals but needs tighter documentation around provider readiness and async strategy.
|
|
||||||
- ExecPlan references drift between `AGENTS.md` and `PLANS.md` should be resolved to avoid conflicting guidance.
|
|
||||||
- Observability and operational visibility are thin; errors are stored but not surfaced through clear runbooks/dashboards.
|
|
||||||
|
|
||||||
## Near-Term Focus
|
## Start Here
|
||||||
- Make ExecPlan references consistent and keep active plans clearly labeled.
|
|
||||||
- Document whether MVP uses async jobs (and which system) or remains synchronous with strict timeouts.
|
- Project overview and setup: `README.md` (repo root)
|
||||||
- Keep `docs/risks.md` current as gaps are closed.
|
- Architecture overview: `docs/architecture.md`
|
||||||
|
- Active ExecPlan: `docs/execplans/booking-notifications.md`
|
||||||
|
- Known risks and gaps: `docs/risks.md`
|
||||||
|
|
||||||
|
## Documentation Standards
|
||||||
|
|
||||||
|
See `docs/documentation.md` for documentation goals, update triggers, and templates.
|
||||||
|
|
||||||
|
## Docs Map
|
||||||
|
|
||||||
|
- `docs/architecture.md`: System architecture, boundaries, and MVP async/observability decision.
|
||||||
|
- `docs/adr/`: Architecture Decision Records (ADRs). New cross-cutting decisions must land here.
|
||||||
|
- `docs/execplans/`: Execution plans for significant features or refactors.
|
||||||
|
- `docs/runbooks/`: Operational runbooks and production checklists.
|
||||||
|
- `docs/risks.md`: Tracked risks and gaps.
|
||||||
|
- `docs/templates/`: Reusable templates (ADR, runbook).
|
||||||
|
|
||||||
|
## Update Triggers (Quick Reference)
|
||||||
|
|
||||||
|
- New external dependency, provider, or major flow: add an ADR in `docs/adr/`.
|
||||||
|
- Change to booking/payment/auth logic: update `docs/architecture.md` and relevant runbook(s).
|
||||||
|
- New operational procedure: add a runbook in `docs/runbooks/`.
|
||||||
|
- Close or add a significant risk: update `docs/risks.md`.
|
||||||
|
|
||||||
|
## Ownership And Review
|
||||||
|
|
||||||
|
- Authors own freshness: if you touch an area, update the docs in the same PR.
|
||||||
|
- New production flows require at least one runbook.
|
||||||
|
- Avoid duplicating instructions; link to the single source of truth.
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# ADR 0001: Synchronous External Calls For MVP
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The MVP relies on OTP delivery, booking notifications, and payment gateway calls. Introducing a task queue (Celery/RQ) would add infrastructure (Redis, workers, retries) and operational complexity that is not required for the early launch.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
For the MVP, OTP sends, booking notifications, and payment gateway calls run synchronously in the request/response path with strict timeouts. A task queue will be revisited when traffic grows or operational needs change.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Faster initial delivery with fewer moving parts.
|
||||||
|
- Increased latency risk on endpoints that call external providers.
|
||||||
|
- Payment and OTP failures are surfaced to clients immediately (correct behaviour — clients need to know).
|
||||||
|
- Notification failures are absorbed: `notifications/services.py` catches provider errors, stores them as `FAILED` status, and never surfaces them to the client. A failed booking SMS does not cause the booking request to fail. This means notification failures require active monitoring rather than appearing in client-facing error rates.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- Celery + Redis for all external calls: rejected for MVP due to infra overhead.
|
||||||
|
- Hybrid async for notifications only: not wrong in principle, deferred for operational simplicity. The three call types have genuinely different semantics:
|
||||||
|
- **Payment creation**: synchronous by design — the client needs the Moyasar redirect URL before the response returns.
|
||||||
|
- **OTP sends**: synchronous by design — users expect immediate confirmation that the code was sent.
|
||||||
|
- **Booking notifications**: fire-and-forget by nature — the booking is already committed and the client does not wait for delivery confirmation.
|
||||||
|
When notification latency becomes a problem (e.g. under load or with slow SMS providers), only notifications need to move off the request path. Payments and OTP sends should remain synchronous regardless.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `docs/architecture.md`
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# ADR 0002: Moyasar As The Payment Gateway
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The platform needs a payment gateway that supports Saudi Arabia, SAR currency defaults, and local payment methods (e.g. STC Pay, Apple Pay, Samsung Pay). The backend already implements a `MoyasarGateway` integration and models `payments.Payment` with a `moyasar` provider option.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use Moyasar as the payment gateway for the MVP. Payment creation, capture, refund, and webhook reconciliation are implemented through `apps.payments.services.gateway.MoyasarGateway`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Supports KSA-focused payment methods and SAR by default.
|
||||||
|
- Operational dependency on Moyasar uptime and API stability.
|
||||||
|
- Payment flows and webhooks are tied to the Moyasar API surface until a gateway abstraction is expanded.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- Other regional gateways: deferred until the MVP is validated.
|
||||||
|
- Stripe or similar global providers: not selected for MVP due to KSA-specific coverage priorities.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `backend/apps/payments/services/gateway.py`
|
||||||
|
- `docs/runbooks/payments_sanity_check.md`
|
||||||
|
- `docs/architecture.md`
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# ADR 0003: Authentica As Primary OTP Provider
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The platform requires phone-first authentication with OTP delivery for KSA. The codebase includes multiple provider adapters (`console`, `twilio`, `unifonic`, `authentica`) but only Authentica is implemented for provider-managed OTP delivery (send/verify) and direct SMS messaging. Twilio and Unifonic adapters are partial or unimplemented; a console provider exists for local development.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use Authentica as the primary OTP provider for the MVP, with `OTP_PROVIDER=authentica` in production environments. Keep `console` for local development and tests, and retain Twilio/Unifonic adapters as scaffolds for future expansion.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- OTP verification relies on Authentica APIs and credentials in production.
|
||||||
|
- Local development remains simple with the console provider.
|
||||||
|
- Adding a second production provider will require completing adapters and updating operational runbooks.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- Twilio as primary provider: not selected due to KSA-focused delivery needs and current adapter gaps.
|
||||||
|
- Unifonic as primary provider: deferred until the adapter is fully implemented and validated.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `backend/apps/accounts/services/otp.py`
|
||||||
|
- `backend/salon_api/settings.py`
|
||||||
|
- `docs/architecture.md`
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Architecture Decision Records
|
||||||
|
|
||||||
|
ADRs capture cross-cutting or hard-to-reverse decisions. Add a new ADR when changing providers, async strategy, data model boundaries, or other architectural choices.
|
||||||
|
|
||||||
|
Use the template in `docs/templates/adr.md` and increment the numeric prefix (`0002`, `0003`, ...).
|
||||||
+22
-1
@@ -12,7 +12,17 @@ The Salon platform is a Django REST API backend with a React/Vite frontend, opti
|
|||||||
| **salons** | Salon catalog, services, staff, availability windows, reviews. Read-only public APIs. |
|
| **salons** | Salon catalog, services, staff, availability windows, reviews. Read-only public APIs. |
|
||||||
| **bookings** | Booking model, validation (availability, overlap prevention), status transitions. Triggers notifications on create and status change. |
|
| **bookings** | Booking model, validation (availability, overlap prevention), status transitions. Triggers notifications on create and status change. |
|
||||||
| **payments** | Payment model, Moyasar integration (create, capture, refund), webhook reconciliation, idempotency. |
|
| **payments** | Payment model, Moyasar integration (create, capture, refund), webhook reconciliation, idempotency. |
|
||||||
| **notifications** | Booking lifecycle notifications (SMS/WhatsApp). Reuses OTP providers; sends on booking created/confirmed/cancelled. |
|
| **notifications** | Booking lifecycle notifications (SMS/WhatsApp). Reuses OTP provider classes as an MVP shortcut; sends on booking created/confirmed/cancelled. See note below. |
|
||||||
|
|
||||||
|
## Data Model Overview
|
||||||
|
|
||||||
|
The core data model centers on users, salons, and time-bound bookings. A booking ties a customer to a service, a staff member, and a scheduled time. Payments are recorded per booking and reconcile to the external gateway. Notifications are stored for every booking lifecycle message for auditability.
|
||||||
|
|
||||||
|
- `accounts.User` owns phone, locale, and auth preferences.
|
||||||
|
- `salons.Salon`, `salons.Service`, and `salons.Staff` define the catalog and scheduling surface.
|
||||||
|
- `bookings.Booking` links customer, staff, service, and scheduled time, with status transitions.
|
||||||
|
- `payments.Payment` tracks gateway state and idempotency per booking.
|
||||||
|
- `notifications.Notification` records each SMS/WhatsApp send attempt tied to a booking event.
|
||||||
|
|
||||||
## Data Flow
|
## Data Flow
|
||||||
|
|
||||||
@@ -25,9 +35,20 @@ User → React Frontend → Django API
|
|||||||
payments ──→ Moyasar gateway
|
payments ──→ Moyasar gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Notification / OTP Provider Coupling (MVP Shortcut)
|
||||||
|
|
||||||
|
`notifications/services.py` imports `PROVIDERS` from `apps.accounts.services.otp` and uses OTP provider instances (e.g. `AuthenticaOtpProvider`) to send booking SMSes. This works today because Authentica handles both authentication OTPs and general SMS delivery.
|
||||||
|
|
||||||
|
Consequences of this coupling:
|
||||||
|
- Notifications and OTP delivery cannot independently use different providers (e.g. Twilio for OTP, Unifonic for notifications).
|
||||||
|
- The `notifications` app is conceptually coupled to the `accounts` app's auth infrastructure.
|
||||||
|
|
||||||
|
This is an acceptable MVP shortcut. Before Phase 2, introduce a dedicated `NotificationProvider` abstraction in `notifications/` (mirroring `OtpProvider`) so the two systems can be configured and tested independently.
|
||||||
|
|
||||||
## Async and Observability (MVP Decision)
|
## Async and Observability (MVP Decision)
|
||||||
|
|
||||||
**Decision (MVP):** All OTP sends, booking notifications, and payment gateway calls run **synchronously** in the request/response path. No Celery, RQ, or other task queue for the initial launch.
|
**Decision (MVP):** All OTP sends, booking notifications, and payment gateway calls run **synchronously** in the request/response path. No Celery, RQ, or other task queue for the initial launch.
|
||||||
|
This is captured in ADR 0001 (`docs/adr/0001-synchronous-external-calls-mvp.md`).
|
||||||
|
|
||||||
**Rationale:**
|
**Rationale:**
|
||||||
- Reduces deployment complexity (no Redis, no worker processes).
|
- Reduces deployment complexity (no Redis, no worker processes).
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Documentation Practices
|
||||||
|
|
||||||
|
These standards aim to keep documentation reliable as the codebase grows.
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
- Single source of truth: one canonical doc per topic; link instead of duplicating.
|
||||||
|
- Proximity: keep docs close to the code they describe when possible.
|
||||||
|
- Freshness: update docs in the same PR as the code change.
|
||||||
|
- Observable behavior: describe what someone can see or run to validate the behavior.
|
||||||
|
|
||||||
|
## Required Docs By Area
|
||||||
|
|
||||||
|
- Architecture and major decisions: `docs/architecture.md` and `docs/adr/`.
|
||||||
|
- Feature delivery plans: `docs/execplans/` (required by `PLANS.md`).
|
||||||
|
- Operational procedures: `docs/runbooks/`.
|
||||||
|
- Risks and gaps: `docs/risks.md`.
|
||||||
|
|
||||||
|
## When To Write An ADR
|
||||||
|
|
||||||
|
Use an ADR for any decision that is cross-cutting or hard to reverse, including:
|
||||||
|
|
||||||
|
- External providers or payment/auth strategy changes.
|
||||||
|
- Async vs synchronous execution decisions.
|
||||||
|
- Data model changes that affect multiple apps or services.
|
||||||
|
|
||||||
|
ADRs live in `docs/adr/` and use the template in `docs/templates/adr.md`.
|
||||||
|
|
||||||
|
## Runbook Expectations
|
||||||
|
|
||||||
|
Every production-impacting flow should have a runbook that covers:
|
||||||
|
|
||||||
|
- Symptoms and impact.
|
||||||
|
- Detection and quick checks.
|
||||||
|
- Safe remediation steps.
|
||||||
|
- Rollback or escalation path.
|
||||||
|
|
||||||
|
Use the template in `docs/templates/runbook.md`.
|
||||||
|
|
||||||
|
## Writing Style
|
||||||
|
|
||||||
|
- Be explicit: include exact commands, paths, and expected output where useful.
|
||||||
|
- Keep sections short and focused.
|
||||||
|
- Avoid unstated assumptions; if a step needs a specific directory, say so.
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
|
||||||
|
- Docs updated or explicitly confirmed unnecessary.
|
||||||
|
- New runbook added when operational behavior changes.
|
||||||
|
- ADR added for new cross-cutting decisions.
|
||||||
|
- `docs/risks.md` updated for meaningful gaps added or closed.
|
||||||
+5
-3
@@ -5,12 +5,13 @@ This file tracks known gaps and risks to address in future iterations.
|
|||||||
## Security And Auth
|
## Security And Auth
|
||||||
- Phone normalization is KSA-focused and minimal; broaden for multi-country use.
|
- Phone normalization is KSA-focused and minimal; broaden for multi-country use.
|
||||||
- OTP protections are basic; add device fingerprinting and IP throttling if needed.
|
- OTP protections are basic; add device fingerprinting and IP throttling if needed.
|
||||||
- Twilio OTP provider is implemented (SMS + WhatsApp); Unifonic remains a scaffold.
|
- Authentica OTP provider is implemented (SMS + WhatsApp via Authentica OTP); Unifonic remains a scaffold.
|
||||||
- Social login is a placeholder.
|
- Social login is a placeholder.
|
||||||
- USERNAME_FIELD is still `email` while email can be null; verify admin/login flows.
|
- `USERNAME_FIELD` is now `"phone_number"`; `REQUIRED_FIELDS = []`; `create_superuser` accepts `phone_number`. Admin and `createsuperuser` work correctly for phone-only users.
|
||||||
|
|
||||||
## Booking Integrity
|
## Booking Integrity
|
||||||
- Availability checks and overlap prevention are now enforced for staff bookings.
|
- Availability checks and overlap prevention are now enforced for staff bookings.
|
||||||
|
- **Race condition — fixed:** `BookingCreateSerializer.create()` now locks the staff row with `select_for_update()` inside `transaction.atomic()` and re-runs the overlap check before inserting. Concurrent requests for the same staff slot are serialized at the DB level. Requires PostgreSQL in production (SQLite ignores `FOR UPDATE` but still serializes writes).
|
||||||
- No timezone handling or business hours enforcement.
|
- No timezone handling or business hours enforcement.
|
||||||
- No cancellation rules or refund logic.
|
- No cancellation rules or refund logic.
|
||||||
|
|
||||||
@@ -21,10 +22,11 @@ This file tracks known gaps and risks to address in future iterations.
|
|||||||
## Data And UX
|
## Data And UX
|
||||||
- Ratings are not recalculated from reviews.
|
- Ratings are not recalculated from reviews.
|
||||||
- No image upload or storage strategy for photos.
|
- No image upload or storage strategy for photos.
|
||||||
- Booking lifecycle notifications are implemented; Twilio delivers SMS/WhatsApp when OTP_PROVIDER=twilio.
|
- Booking lifecycle notifications are implemented; Authentica can deliver SMS when NOTIFICATION_PROVIDER=authentica.
|
||||||
- Localization foundations are in progress; full Arabic translation coverage and RTL QA are still pending.
|
- Localization foundations are in progress; full Arabic translation coverage and RTL QA are still pending.
|
||||||
|
|
||||||
## Ops And Compliance
|
## Ops And Compliance
|
||||||
- No audit logs for admin actions.
|
- No audit logs for admin actions.
|
||||||
- No multi-tenant isolation or data export tooling.
|
- No multi-tenant isolation or data export tooling.
|
||||||
- No GDPR/PDPL data retention policies defined.
|
- No GDPR/PDPL data retention policies defined.
|
||||||
|
- CI baseline exists, but needs Gitea runner registration and required-check enforcement.
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Runbooks
|
||||||
|
|
||||||
|
Operational procedures live here. Each new production-impacting workflow should add or update a runbook.
|
||||||
|
|
||||||
|
Existing runbooks:
|
||||||
|
|
||||||
|
- `docs/runbooks/auth_otp_failures.md`
|
||||||
|
- `docs/runbooks/booking_failures.md`
|
||||||
|
- `docs/runbooks/payments_sanity_check.md`
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Runbook: Auth OTP Failures
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Guide for diagnosing and mitigating OTP send or verify failures in phone-first authentication.
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- Users report not receiving OTP codes.
|
||||||
|
- `/api/auth/otp/request/` or `/api/auth/phone/request/` returns HTTP 500 or rate-limit errors.
|
||||||
|
- `/api/auth/otp/verify/` or `/api/auth/phone/verify/` returns invalid or expired OTP errors unexpectedly.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Users cannot sign in or complete phone verification.
|
||||||
|
- Booking and payment flows are blocked when auth is required.
|
||||||
|
|
||||||
|
## Quick Checks
|
||||||
|
|
||||||
|
- Confirm the provider configured in `backend/salon_api/settings.py` via `OTP_PROVIDER`.
|
||||||
|
- Check recent application logs for OTP send errors.
|
||||||
|
- Verify provider credentials are present in `backend/.env` for the active provider.
|
||||||
|
|
||||||
|
## Mitigation Steps
|
||||||
|
|
||||||
|
- If provider credentials are missing or invalid, fix the environment variables and restart the API process.
|
||||||
|
- If the provider is down, temporarily switch to `OTP_PROVIDER=console` for non-production environments and notify support.
|
||||||
|
- If rate limits are triggered, validate `OTP_MAX_PER_WINDOW`, `OTP_WINDOW_MINUTES`, and `OTP_RESEND_COOLDOWN_SECONDS` values and confirm client behavior is not retrying aggressively.
|
||||||
|
- If verification is failing, confirm server time is correct and `OTP_EXPIRY_MINUTES` is appropriate.
|
||||||
|
|
||||||
|
## Rollback / Escalation
|
||||||
|
|
||||||
|
- Roll back recent auth/OTP changes if the failure coincides with a deployment.
|
||||||
|
- Escalate to the provider (Authentica) with request IDs and timestamps if external API errors persist.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Authentica is the primary OTP provider for MVP; console provider is for local development.
|
||||||
|
- OTP send/verify logic lives in `backend/apps/accounts/services/otp.py`.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Runbook: Booking Failures
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Guide for diagnosing booking creation or status update failures (availability, overlap prevention, or validation errors).
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- `POST /api/bookings/` returns HTTP 400 or 500.
|
||||||
|
- `PATCH /api/bookings/<id>/` fails when confirming or cancelling.
|
||||||
|
- Users report bookings not appearing or incorrect status.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Customers cannot place bookings.
|
||||||
|
- Staff schedules become inconsistent.
|
||||||
|
- Notification and payment flows may not trigger.
|
||||||
|
|
||||||
|
## Quick Checks
|
||||||
|
|
||||||
|
- Confirm the request payload includes a valid `service`, `staff`, and scheduled time.
|
||||||
|
- Check server logs for booking validation errors or integrity exceptions.
|
||||||
|
- Verify that staff availability and overlap prevention rules are behaving as expected.
|
||||||
|
|
||||||
|
## Mitigation Steps
|
||||||
|
|
||||||
|
- Reproduce with a known test user and staff member to isolate data issues.
|
||||||
|
- If overlap rules are too strict, review booking validation logic and confirm time zone assumptions.
|
||||||
|
- If status updates are blocked, verify role checks and serializer permissions in `backend/apps/bookings/`.
|
||||||
|
- If notifications are expected but missing, confirm `NOTIFICATION_PROVIDER` configuration and notification records.
|
||||||
|
|
||||||
|
## Rollback / Escalation
|
||||||
|
|
||||||
|
- Roll back recent booking-related changes if failures started after a deployment.
|
||||||
|
- Escalate to engineering with the booking ID, user ID, and timestamps.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Booking validation and status transitions live in `backend/apps/bookings/`.
|
||||||
|
- Notifications for booking lifecycle are handled in `backend/apps/notifications/`.
|
||||||
Vendored
+25
@@ -0,0 +1,25 @@
|
|||||||
|
# ADR <NNNN>: <Title>
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed | Accepted | Deprecated | Superseded
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Explain the problem and the forces at play. Include constraints, risks, or user needs.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
State the decision clearly and explicitly.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
List the expected positive and negative outcomes, including operational impact.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
Briefly document viable alternatives and why they were rejected.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
Link to relevant PRs, runbooks, or architecture sections.
|
||||||
Vendored
+29
@@ -0,0 +1,29 @@
|
|||||||
|
# Runbook: <Short Title>
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
One or two sentences describing the situation this runbook covers.
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
Describe what an operator or user will observe.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
Who or what is affected.
|
||||||
|
|
||||||
|
## Quick Checks
|
||||||
|
|
||||||
|
Exact commands or checks that confirm the issue.
|
||||||
|
|
||||||
|
## Mitigation Steps
|
||||||
|
|
||||||
|
Step-by-step actions to resolve or reduce impact.
|
||||||
|
|
||||||
|
## Rollback / Escalation
|
||||||
|
|
||||||
|
How to revert or who to contact if the issue persists.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Any caveats, dependencies, or follow-up actions.
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
import { Navigate, useLocation } from "react-router-dom";
|
import { Navigate, useLocation } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
|
||||||
export default function ProtectedRoute({ children }) {
|
export default function ProtectedRoute({ children }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { isAuthenticated, loading } = useAuth();
|
const { isAuthenticated, loading } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="auth-loading">
|
<div className="auth-loading">
|
||||||
<p>Loading...</p>
|
<p>{t("common.loading")}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,14 @@
|
|||||||
"phoneUnavailable": "الهاتف غير متوفر",
|
"phoneUnavailable": "الهاتف غير متوفر",
|
||||||
"viewDetails": "عرض التفاصيل والحجز"
|
"viewDetails": "عرض التفاصيل والحجز"
|
||||||
},
|
},
|
||||||
"nav":{"home":"الرئيسية","book":"احجز","pay":"ادفع","profile":"الحساب","bookings":"حجوزاتي","login":"تسجيل الدخول","logout":"تسجيل الخروج"},"book":{"title":"احجزي خدمة","placeholder":"تدفق الحجز قريباً.","cta":"احجزي الآن","selectSalon":"اختاري صالوناً من الصفحة الرئيسية للحجز.","service":"الخدمة","staff":"الفريق","date":"التاريخ","time":"الوقت","notes":"ملاحظات","notesPlaceholder":"ملاحظات اختيارية","selectService":"اختاري الخدمة","selectStaff":"اختاري الفني","submit":"تأكيد الحجز","submitting":"جاري الحجز...","errors":{"fillAll":"يرجى تعبئة جميع الحقول.","invalidTime":"تاريخ أو وقت غير صالح.","generic":"فشل الحجز."}},"salon":{"services":"الخدمات","staff":"الفريق"},"profile":{"title":"الحساب","placeholder":"الملف والحجوزات قريباً.","myBookings":"حجوزاتي","noContact":"لا توجد معلومات اتصال"},"bookings":{"title":"حجوزاتي","subtitle":"مواعيدك القادمة والسابقة.","empty":"لا توجد حجوزات بعد.","pay":"ادفع الآن"},"paymentReturn":{"title":"حالة الدفع","success":"تم الدفع بنجاح","successMessage":"تم إتمام الدفع. ستستلمين تأكيداً عبر SMS أو واتساب.","checkStatus":"تحققي من بريدك أو الصالون لحالة الدفع.","reference":"المرجع: {{id}}","viewBookings":"عرض حجوزاتي"},"auth":{"title":"تسجيل الدخول","subtitle":"أدخلي رقم جوالك لاستلام رمز التحقق.","phone":"رقم الجوال","channel":"الإرسال عبر","sms":"رسالة نصية","whatsapp":"واتساب","sendCode":"إرسال الرمز","sending":"جاري الإرسال...","verifyTitle":"أدخلي الرمز","verifySubtitle":"أرسلنا رمزاً إلى {{phone}}","code":"رمز التحقق","verify":"تحقق","verifying":"جاري التحقق...","back":"تغيير الرقم","errors":{"generic":"حدث خطأ.","retryAfter":"انتظري {{seconds}} ثانية قبل المحاولة مرة أخرى."}},"locale": {
|
"nav":{"home":"الرئيسية","book":"احجز","pay":"ادفع","profile":"الحساب","bookings":"حجوزاتي","login":"تسجيل الدخول","logout":"تسجيل الخروج"},"book":{"title":"احجزي خدمة","placeholder":"تدفق الحجز قريباً.","cta":"احجزي الآن","selectSalon":"اختاري صالوناً من الصفحة الرئيسية للحجز.","service":"الخدمة","staff":"الفريق","date":"التاريخ","time":"الوقت","notes":"ملاحظات","notesPlaceholder":"ملاحظات اختيارية","selectService":"اختاري الخدمة","selectStaff":"اختاري الفني","submit":"تأكيد الحجز","submitting":"جاري الحجز...","errors":{"fillAll":"يرجى تعبئة جميع الحقول.","invalidTime":"تاريخ أو وقت غير صالح.","generic":"فشل الحجز."}},"salon":{"services":"الخدمات","staff":"الفريق","unknownStaff":"موظف {{id}}"},"profile":{"title":"الحساب","placeholder":"الملف والحجوزات قريباً.","myBookings":"حجوزاتي","noContact":"لا توجد معلومات اتصال"},"bookings":{"title":"حجوزاتي","subtitle":"مواعيدك القادمة والسابقة.","empty":"لا توجد حجوزات بعد.","pay":"ادفع الآن"},"paymentReturn":{"title":"حالة الدفع","success":"تم الدفع بنجاح","successMessage":"تم إتمام الدفع. ستستلمين تأكيداً عبر SMS أو واتساب.","checkStatus":"تحققي من بريدك أو الصالون لحالة الدفع.","reference":"المرجع: {{id}}","viewBookings":"عرض حجوزاتي"},"auth":{"title":"تسجيل الدخول","subtitle":"أدخلي رقم جوالك لاستلام رمز التحقق.","phone":"رقم الجوال","channel":"الإرسال عبر","sms":"رسالة نصية","whatsapp":"واتساب","sendCode":"إرسال الرمز","sending":"جاري الإرسال...","verifyTitle":"أدخلي الرمز","verifySubtitle":"أرسلنا رمزاً إلى {{phone}}","code":"رمز التحقق","verify":"تحقق","verifying":"جاري التحقق...","back":"تغيير الرقم","errors":{"generic":"حدث خطأ.","retryAfter":"انتظري {{seconds}} ثانية قبل المحاولة مرة أخرى."}},"locale": {
|
||||||
"label": "اللغة",
|
"label": "اللغة",
|
||||||
"arabic": "العربية",
|
"arabic": "العربية",
|
||||||
"english": "الإنجليزية"
|
"english": "الإنجليزية"
|
||||||
},
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "جاري التحميل..."
|
||||||
|
},
|
||||||
"payment": {
|
"payment": {
|
||||||
"title": "المدفوعات (تجريبي)",
|
"title": "المدفوعات (تجريبي)",
|
||||||
"subtitle": "إرسال عملية دفع عبر Moyasar لحجز موجود.",
|
"subtitle": "إرسال عملية دفع عبر Moyasar لحجز موجود.",
|
||||||
|
|||||||
@@ -16,11 +16,14 @@
|
|||||||
"phoneUnavailable": "Phone unavailable",
|
"phoneUnavailable": "Phone unavailable",
|
||||||
"viewDetails": "View details & book"
|
"viewDetails": "View details & book"
|
||||||
},
|
},
|
||||||
"nav":{"home":"Home","book":"Book","pay":"Pay","profile":"Profile","bookings":"My bookings","login":"Sign in","logout":"Sign out"},"book":{"title":"Book a Service","placeholder":"Booking flow coming soon.","cta":"Book now","selectSalon":"Select a salon from the home page to book.","service":"Service","staff":"Staff","date":"Date","time":"Time","notes":"Notes","notesPlaceholder":"Optional notes","selectService":"Select service","selectStaff":"Select staff","submit":"Confirm booking","submitting":"Booking...","errors":{"fillAll":"Please fill all required fields.","invalidTime":"Invalid date or time.","generic":"Booking failed."}},"salon":{"services":"Services","staff":"Staff"},"profile":{"title":"Profile","placeholder":"Profile and bookings coming soon.","myBookings":"My bookings","noContact":"No contact info"},"bookings":{"title":"My bookings","subtitle":"Your upcoming and past appointments.","empty":"No bookings yet.","pay":"Pay now"},"paymentReturn":{"title":"Payment status","success":"Payment successful","successMessage":"Your payment was completed. You will receive a confirmation by SMS or WhatsApp.","checkStatus":"Check your email or the salon for payment status.","reference":"Reference: {{id}}","viewBookings":"View my bookings"},"auth":{"title":"Sign in","subtitle":"Enter your phone number to receive a verification code.","phone":"Phone number","channel":"Send via","sms":"SMS","whatsapp":"WhatsApp","sendCode":"Send code","sending":"Sending...","verifyTitle":"Enter code","verifySubtitle":"We sent a code to {{phone}}","code":"Verification code","verify":"Verify","verifying":"Verifying...","back":"Change number","errors":{"generic":"Something went wrong.","retryAfter":"Please wait {{seconds}} seconds before trying again."}},"locale": {
|
"nav":{"home":"Home","book":"Book","pay":"Pay","profile":"Profile","bookings":"My bookings","login":"Sign in","logout":"Sign out"},"book":{"title":"Book a Service","placeholder":"Booking flow coming soon.","cta":"Book now","selectSalon":"Select a salon from the home page to book.","service":"Service","staff":"Staff","date":"Date","time":"Time","notes":"Notes","notesPlaceholder":"Optional notes","selectService":"Select service","selectStaff":"Select staff","submit":"Confirm booking","submitting":"Booking...","errors":{"fillAll":"Please fill all required fields.","invalidTime":"Invalid date or time.","generic":"Booking failed."}},"salon":{"services":"Services","staff":"Staff","unknownStaff":"Staff {{id}}"},"profile":{"title":"Profile","placeholder":"Profile and bookings coming soon.","myBookings":"My bookings","noContact":"No contact info"},"bookings":{"title":"My bookings","subtitle":"Your upcoming and past appointments.","empty":"No bookings yet.","pay":"Pay now"},"paymentReturn":{"title":"Payment status","success":"Payment successful","successMessage":"Your payment was completed. You will receive a confirmation by SMS or WhatsApp.","checkStatus":"Check your email or the salon for payment status.","reference":"Reference: {{id}}","viewBookings":"View my bookings"},"auth":{"title":"Sign in","subtitle":"Enter your phone number to receive a verification code.","phone":"Phone number","channel":"Send via","sms":"SMS","whatsapp":"WhatsApp","sendCode":"Send code","sending":"Sending...","verifyTitle":"Enter code","verifySubtitle":"We sent a code to {{phone}}","code":"Verification code","verify":"Verify","verifying":"Verifying...","back":"Change number","errors":{"generic":"Something went wrong.","retryAfter":"Please wait {{seconds}} seconds before trying again."}},"locale": {
|
||||||
"label": "Language",
|
"label": "Language",
|
||||||
"arabic": "العربية",
|
"arabic": "العربية",
|
||||||
"english": "English"
|
"english": "English"
|
||||||
},
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "Loading..."
|
||||||
|
},
|
||||||
"payment": {
|
"payment": {
|
||||||
"title": "Payment (Beta)",
|
"title": "Payment (Beta)",
|
||||||
"subtitle": "Send a Moyasar payment for an existing booking.",
|
"subtitle": "Send a Moyasar payment for an existing booking.",
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export default function BookPage() {
|
|||||||
<option value="">{t("book.selectStaff")}</option>
|
<option value="">{t("book.selectStaff")}</option>
|
||||||
{salon.staff?.map((s) => (
|
{salon.staff?.map((s) => (
|
||||||
<option key={s.id} value={s.id}>
|
<option key={s.id} value={s.id}>
|
||||||
{s.name || s.title || `Staff ${s.id}`}
|
{s.name || s.title || t("salon.unknownStaff", { id: s.id })}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { apiGet } from "../api/client";
|
import { apiGet } from "../api/client";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import ProtectedRoute from "../components/ProtectedRoute";
|
import ProtectedRoute from "../components/ProtectedRoute";
|
||||||
|
import { getActiveLocale } from "../i18n/index";
|
||||||
|
|
||||||
function formatDateTime(iso) {
|
function formatDateTime(iso, locale) {
|
||||||
if (!iso) return "";
|
if (!iso) return "";
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
return d.toLocaleString(undefined, {
|
return d.toLocaleString(locale, {
|
||||||
dateStyle: "medium",
|
dateStyle: "medium",
|
||||||
timeStyle: "short",
|
timeStyle: "short",
|
||||||
});
|
});
|
||||||
@@ -51,7 +52,7 @@ export default function BookingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="booking-service">{b.service_name}</p>
|
<p className="booking-service">{b.service_name}</p>
|
||||||
<p className="booking-time">
|
<p className="booking-time">
|
||||||
{formatDateTime(b.start_time)} – {formatDateTime(b.end_time)}
|
{formatDateTime(b.start_time, getActiveLocale())} – {formatDateTime(b.end_time, getActiveLocale())}
|
||||||
</p>
|
</p>
|
||||||
<p className="booking-price">
|
<p className="booking-price">
|
||||||
{b.price_amount} {b.currency}
|
{b.price_amount} {b.currency}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function SalonDetailPage() {
|
|||||||
<h2>{t("salon.staff")}</h2>
|
<h2>{t("salon.staff")}</h2>
|
||||||
<ul className="staff-list">
|
<ul className="staff-list">
|
||||||
{salon.staff?.map((s) => (
|
{salon.staff?.map((s) => (
|
||||||
<li key={s.id}>{s.name || s.title || `Staff ${s.id}`}</li>
|
<li key={s.id}>{s.name || s.title || t("salon.unknownStaff", { id: s.id })}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user