9 Commits

Author SHA1 Message Date
mohd de8cbfec23 Add Gitea Actions CI baseline
CI / Backend tests (push) Has been cancelled
CI / Frontend tests (push) Has been cancelled
2026-03-02 21:10:22 +03:00
mohd b8218669c2 added claude.md 2026-03-02 00:58:00 +03:00
mohd 2305c3dc9d feat: add Arabic translations and fix frontend i18n gaps
- Add backend/locale/ar_SA/LC_MESSAGES/django.po with Arabic (ar-sa) translations
  for all 62 user-facing error/validation strings across accounts, bookings,
  payments, and notifications apps; compile to django.mo
- Add common.loading and salon.unknownStaff keys to both ar-sa.json and en.json
- ProtectedRoute: replace hardcoded "Loading..." with t("common.loading")
- BookPage, SalonDetailPage: replace `Staff ${s.id}` fallback with
  t("salon.unknownStaff", { id: s.id })
- BookingsPage: pass getActiveLocale() to toLocaleString so date/time
  format matches the active app language

All 35 backend tests and 7 frontend tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 00:53:24 +03:00
mohd ef60218c4c fix: make booking overlap check atomic with select_for_update
Wrap the overlap query and Booking.objects.create() in a single
transaction.atomic() block inside BookingCreateSerializer.create().
Lock the StaffProfile row with select_for_update() so concurrent
requests for the same staff slot are serialized at the DB level;
only one writer can hold the lock at a time, eliminating the race
window between validate() and save().

The early check in validate() is kept for fast user feedback in
the common non-concurrent case. The locked re-check in create()
is the correctness guarantee.

On SQLite (dev/tests) FOR UPDATE is silently ignored but writes
are still serialized. PostgreSQL (production) gets row-level locking.

Update docs/risks.md to mark the race condition as fixed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 00:27:04 +03:00
mohd 8018710d31 fix: use phone_number as USERNAME_FIELD on User model
- USERNAME_FIELD = "phone_number" (was "email") — email is optional on
  this platform; most customers will be phone-only
- Add REQUIRED_FIELDS = [] to make the intent explicit
- Update create_superuser to accept phone_number as the identifier and
  pass it through to create_user as a keyword argument
- All 35 backend tests pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 23:58:02 +03:00
mohd 229975c612 docs: revise ADR 0001, risks, and architecture for accuracy
- ADR 0001: distinguish payment/OTP (sync by design) from notifications
  (fire-and-forget); correct misleading claim that notification failures
  surface to clients — they are silently absorbed as FAILED status
- risks.md: upgrade USERNAME_FIELD entry with concrete breakage (admin,
  create_superuser, JWT lookup); add booking overlap race condition with
  root cause and fix (select_for_update)
- architecture.md: document notification/OTP provider coupling as an MVP
  shortcut and note the Phase 2 fix (dedicated NotificationProvider)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 23:30:04 +03:00
mohd aa607b9b6e Fleshed out documentation 2026-02-28 17:41:00 +03:00
mohd 828cbcc822 Authentica OTP tests 2026-02-28 17:31:03 +03:00
mohd 4253f6f650 Added Authentica OTP 2026-02-28 16:58:50 +03:00
36 changed files with 1341 additions and 80 deletions
+58
View File
@@ -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
+1
View File
@@ -17,3 +17,4 @@ dist/
# OS # OS
.DS_Store .DS_Store
backend/tmp_authentica_request_id.txt
+127
View File
@@ -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`)
+3
View File
@@ -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
View File
@@ -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.
+4 -3
View File
@@ -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)
+134 -16
View File
@@ -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
+39 -3
View File
@@ -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
+37 -19
View File
@@ -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
+38 -11
View File
@@ -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.
+251
View File
@@ -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
View File
@@ -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)
+10 -4
View File
@@ -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
View File
@@ -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`
+30
View File
@@ -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`
+30
View File
@@ -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`
+5
View File
@@ -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
View File
@@ -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).
+51
View File
@@ -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
View File
@@ -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.
+9
View File
@@ -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`
+39
View File
@@ -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`.
+40
View File
@@ -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/`.
+25
View File
@@ -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.
+29
View File
@@ -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.
+3 -1
View File
@@ -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>
); );
} }
+4 -1
View File
@@ -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 لحجز موجود.",
+4 -1
View File
@@ -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.",
+1 -1
View File
@@ -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 -3
View File
@@ -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}
+1 -1
View File
@@ -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>