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
.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
- 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)
@@ -63,3 +64,5 @@ The dev server proxies `/api` to `http://localhost:8000`.
- Known gaps and risks: `docs/risks.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)
## 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.
- 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.
## 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).
- 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.
+4 -3
View File
@@ -29,7 +29,7 @@ class UserManager(BaseUserManager):
user.save(using=self._db)
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_superuser", True)
extra_fields.setdefault("role", UserRole.ADMIN)
@@ -37,7 +37,7 @@ class UserManager(BaseUserManager):
raise ValueError("Superuser must have is_staff=True")
if extra_fields.get("is_superuser") is not 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):
@@ -59,7 +59,8 @@ class User(AbstractBaseUser, PermissionsMixin):
objects = UserManager()
USERNAME_FIELD = "email"
USERNAME_FIELD = "phone_number"
REQUIRED_FIELDS = [] # email is optional; phone_number is the identifier
def __str__(self):
return self.email or self.phone_number or str(self.id)
+134 -16
View File
@@ -1,8 +1,8 @@
import logging
import os
import secrets
from datetime import timedelta
from dataclasses import dataclass
from datetime import timedelta
from django.conf import settings
from django.contrib.auth.hashers import check_password, make_password
@@ -34,12 +34,20 @@ class OtpCooldownError(RuntimeError):
class BaseOtpProvider:
uses_provider_otp = False
def send_sms(self, to_number: str, message: str) -> None:
raise NotImplementedError
def send_whatsapp(self, to_number: str, message: str) -> None:
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):
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"))
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 = {
"console": ConsoleOtpProvider,
"twilio": TwilioOtpProvider,
"unifonic": UnifonicOtpProvider,
"authentica": AuthenticaOtpProvider,
}
def get_provider() -> BaseOtpProvider:
provider_key = settings.OTP_PROVIDER
def _get_provider_for_key(provider_key: str) -> BaseOtpProvider:
provider_cls = PROVIDERS.get(provider_key)
if not provider_cls:
raise ValueError(_("Unknown OTP provider: %(provider)s") % {"provider": provider_key})
return provider_cls()
def get_provider() -> BaseOtpProvider:
return _get_provider_for_key(settings.OTP_PROVIDER)
def generate_code(length: int = 6) -> str:
digits = "0123456789"
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:
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(
phone_number=phone_number,
channel=channel,
purpose=purpose,
provider=settings.OTP_PROVIDER,
code_hash=make_password(code),
code_hash=code_hash,
expires_at=PhoneOTP.expiry_at(),
)
message = _(
"Your verification code is %(code)s. It expires in %(minutes)s minutes."
) % {"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)
if provider.uses_provider_otp:
provider.send_otp(phone_number, channel)
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())
@@ -179,9 +285,21 @@ def verify_otp(otp: PhoneOTP, code: str) -> bool:
if otp.attempt_count > otp.max_attempts:
otp.save(update_fields=["attempt_count"])
return False
if not check_password(code, otp.code_hash):
otp.save(update_fields=["attempt_count"])
return False
provider_cls = PROVIDERS.get(otp.provider)
if provider_cls and getattr(provider_cls, "uses_provider_otp", 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.save(update_fields=["verified_at", "attempt_count"])
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
from django.contrib.auth.hashers import make_password
from django.test import override_settings
from apps.accounts.models import OtpChannel
from apps.accounts.services.otp import OtpRateLimitError, create_and_send_otp
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP
from apps.accounts.services.otp import OtpCooldownError, OtpRateLimitError, create_and_send_otp, verify_otp
@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():
create_and_send_otp("+966512345678", OtpChannel.SMS)
with pytest.raises(OtpRateLimitError):
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
from django.test import override_settings
from django.urls import reverse
from apps.accounts.models import PhoneOTP, User
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console")
def test_phone_auth_creates_user_and_issues_tokens(client):
request_url = reverse("phone_auth_request")
verify_url = reverse("phone_auth_verify")
# Deterministic OTP so we can verify the flow without external providers.
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(
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"]
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"]
otp = PhoneOTP.objects.filter(phone_number="+966512345678").order_by("-created_at").first()
assert otp is not None
assert str(otp.id) == request_id
otp = PhoneOTP.objects.filter(phone_number="+966512345678").order_by("-created_at").first()
assert otp is not None
assert str(otp.id) == request_id
bad = client.post(
verify_url,
{"request_id": request_id, "code": "000000"},
content_type="application/json",
)
assert bad.status_code == 400
bad = client.post(
verify_url,
{"request_id": request_id, "code": "000000"},
content_type="application/json",
)
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 rest_framework import serializers
from apps.bookings.models import Booking, BookingStatus
@@ -74,14 +75,40 @@ class BookingCreateSerializer(serializers.ModelSerializer):
def create(self, validated_data):
request = self.context["request"]
service = validated_data["service"]
return Booking.objects.create(
salon=service.salon,
customer=request.user,
service=service,
staff=validated_data.get("staff"),
start_time=validated_data["start_time"],
end_time=validated_data["end_time"],
notes=validated_data.get("notes", ""),
price_amount=service.price_amount,
currency=service.currency,
)
staff = validated_data.get("staff")
start_time = validated_data["start_time"]
end_time = validated_data["end_time"]
with transaction.atomic():
# Lock the staff row so concurrent booking requests for the same staff
# member are serialized. Without this, two requests that both pass the
# overlap check in validate() can race and both commit overlapping
# bookings. On SQLite (dev/tests) the FOR UPDATE clause is silently
# 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]
DJANGO_SETTINGS_MODULE = salon_api.settings
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 sys
from pathlib import Path
from datetime import timedelta
from urllib.parse import urlparse
@@ -78,11 +79,14 @@ def parse_database_url(database_url: str):
}
DATABASE_URL = os.getenv("DATABASE_URL")
if DATABASE_URL:
parsed_db = parse_database_url(DATABASE_URL)
running_tests = "PYTEST_CURRENT_TEST" in os.environ or any("pytest" in arg for arg in sys.argv)
test_database_url = os.getenv("TEST_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:
parsed_db = None
parsed_db = parse_database_url(database_url) if database_url else None
DATABASES = {
"default": parsed_db
@@ -136,6 +140,8 @@ CORS_ALLOWED_ORIGINS = [
]
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_MAX_PER_WINDOW = int(os.getenv("OTP_MAX_PER_WINDOW", "5"))
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
- 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.
This directory is the source of truth for product, engineering, and ops documentation. Keep it current as features change.
## Near-Term Focus
- 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.
- Keep `docs/risks.md` current as gaps are closed.
## Start Here
- Project overview and setup: `README.md` (repo root)
- 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. |
| **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. |
| **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
@@ -25,9 +35,20 @@ User → React Frontend → Django API
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)
**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:**
- 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
- Phone normalization is KSA-focused and minimal; broaden for multi-country use.
- 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.
- 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
- 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 cancellation rules or refund logic.
@@ -21,10 +22,11 @@ This file tracks known gaps and risks to address in future iterations.
## Data And UX
- Ratings are not recalculated from reviews.
- 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.
## Ops And Compliance
- No audit logs for admin actions.
- No multi-tenant isolation or data export tooling.
- 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 { useTranslation } from "react-i18next";
import { useAuth } from "../contexts/AuthContext";
export default function ProtectedRoute({ children }) {
const { t } = useTranslation();
const { isAuthenticated, loading } = useAuth();
const location = useLocation();
if (loading) {
return (
<div className="auth-loading">
<p>Loading...</p>
<p>{t("common.loading")}</p>
</div>
);
}
+4 -1
View File
@@ -16,11 +16,14 @@
"phoneUnavailable": "الهاتف غير متوفر",
"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": "اللغة",
"arabic": "العربية",
"english": "الإنجليزية"
},
"common": {
"loading": "جاري التحميل..."
},
"payment": {
"title": "المدفوعات (تجريبي)",
"subtitle": "إرسال عملية دفع عبر Moyasar لحجز موجود.",
+4 -1
View File
@@ -16,11 +16,14 @@
"phoneUnavailable": "Phone unavailable",
"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",
"arabic": "العربية",
"english": "English"
},
"common": {
"loading": "Loading..."
},
"payment": {
"title": "Payment (Beta)",
"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>
{salon.staff?.map((s) => (
<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>
))}
</select>
+4 -3
View File
@@ -4,11 +4,12 @@ import { useTranslation } from "react-i18next";
import { apiGet } from "../api/client";
import { useAuth } from "../contexts/AuthContext";
import ProtectedRoute from "../components/ProtectedRoute";
import { getActiveLocale } from "../i18n/index";
function formatDateTime(iso) {
function formatDateTime(iso, locale) {
if (!iso) return "";
const d = new Date(iso);
return d.toLocaleString(undefined, {
return d.toLocaleString(locale, {
dateStyle: "medium",
timeStyle: "short",
});
@@ -51,7 +52,7 @@ export default function BookingsPage() {
</div>
<p className="booking-service">{b.service_name}</p>
<p className="booking-time">
{formatDateTime(b.start_time)} {formatDateTime(b.end_time)}
{formatDateTime(b.start_time, getActiveLocale())} {formatDateTime(b.end_time, getActiveLocale())}
</p>
<p className="booking-price">
{b.price_amount} {b.currency}
+1 -1
View File
@@ -43,7 +43,7 @@ export default function SalonDetailPage() {
<h2>{t("salon.staff")}</h2>
<ul className="staff-list">
{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>