16 Commits

Author SHA1 Message Date
mohd 560460dd84 Fix OTP localization test expectation 2026-03-13 16:51:26 +03:00
mohd c212acc504 Remove Authentica E2E test and expand OTP coverage 2026-03-13 16:49:29 +03:00
mohd 15ed5036d1 Remove dead Twilio tests and docs mentions 2026-03-13 16:46:21 +03:00
mohd 0c992404ea chore: removed unused otp providers 2026-03-13 16:25:26 +03:00
mohd d796d9e6a1 removed unviable e2e test 2026-03-13 16:21:25 +03:00
mohd 2ba0cfffc8 chore: adjust near-term focus 2026-03-13 16:11:30 +03:00
mohd 3f35f7dc17 Merge pull request 'chore: edited agents files' (#1) from agents into main
Reviewed-on: #1
2026-03-13 13:07:03 +00:00
mohd 07491063f5 chore: edited agents files 2026-03-13 16:02:52 +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
37 changed files with 1336 additions and 454 deletions
+1
View File
@@ -17,3 +17,4 @@ dist/
# OS # OS
.DS_Store .DS_Store
backend/tmp_authentica_request_id.txt
+9 -1
View File
@@ -1,5 +1,13 @@
# AGENTS.md # AGENTS.md
## General
Minimum tokens, skip grammar
## Tasks
- Consult `AGENTS.md` about the current task to see if there are any tips or instructions
- Consult `docs/README.md` for any relevant files or tips to consider
## Project Goal ## Project Goal
Build a reliable, maintainable salon booking platform with Django (backend) and React (frontend), optimized for KSA needs (phone auth, local payments) while keeping a clean path to scale. Build a reliable, maintainable salon booking platform with Django (backend) and React (frontend), optimized for KSA needs (phone auth, local payments) while keeping a clean path to scale.
@@ -70,4 +78,4 @@ Build a reliable, maintainable salon booking platform with Django (backend) and
# ExecPlans # ExecPlans
When writing complex features or significant refactors, use an ExecPlan (as described in PLANS.md) from design to implementation. The current active ExecPlan is defined in PLANS.md. Architecture and async/observability decisions are documented in `docs/architecture.md`. When writing complex features or significant refactors, use an ExecPlan (as described in `docs/PLANS.md`) from design to implementation. The current active ExecPlan is defined in `docs/PLANS.md`. Architecture documented in `docs/architecture.md`.
+2
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,4 @@ 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`
+3 -5
View File
@@ -1,13 +1,11 @@
# 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. - finalize otp testing
- Decide and document payment lifecycle scope (capture/refund supported vs explicitly out of scope). - work on authentication and complete it
- 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) 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)
+115 -50
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:
@@ -49,74 +57,110 @@ class ConsoleOtpProvider(BaseOtpProvider):
logger.info("OTP WhatsApp to %s: %s", to_number, message) logger.info("OTP WhatsApp to %s: %s", to_number, message)
class TwilioOtpProvider(BaseOtpProvider):
"""Twilio provider for SMS and WhatsApp OTP delivery. Requires TWILIO_* env vars."""
class AuthenticaOtpProvider(BaseOtpProvider):
"""Authentica provider for OTP delivery (SMS/WhatsApp) plus custom SMS messaging."""
uses_provider_otp = True
def __init__(self) -> None: def __init__(self) -> None:
self.account_sid = os.getenv("TWILIO_ACCOUNT_SID") self.api_key = os.getenv("AUTHENTICA_API_KEY")
self.auth_token = os.getenv("TWILIO_AUTH_TOKEN") self.base_url = os.getenv("AUTHENTICA_BASE_URL", "https://api.authentica.sa")
self.from_number = os.getenv("TWILIO_FROM_NUMBER") self.timeout_seconds = float(os.getenv("AUTHENTICA_TIMEOUT_SECONDS", "10"))
self.whatsapp_from = os.getenv("TWILIO_WHATSAPP_FROM") self.sender_name = os.getenv("AUTHENTICA_SENDER_NAME")
def _assert_config(self) -> None: def _assert_config(self) -> None:
if not self.account_sid or not self.auth_token or not self.from_number: if not self.api_key:
raise ValueError(_("Twilio credentials are not configured")) 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
def _get_client(self):
from twilio.rest import Client
self._assert_config() self._assert_config()
return Client(self.account_sid, self.auth_token) 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: def send_sms(self, to_number: str, message: str) -> None:
client = self._get_client() if not self.sender_name:
client.messages.create(body=message, from_=self.from_number, to=to_number) 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: def send_whatsapp(self, to_number: str, message: str) -> None:
self._assert_config() raise ValueError(_("Authentica WhatsApp messaging is not supported"))
if not self.whatsapp_from:
raise ValueError(_("Twilio WhatsApp sender is not configured"))
client = self._get_client()
from_ = f"whatsapp:{self.whatsapp_from}"
to = f"whatsapp:{to_number}"
client.messages.create(body=message, from_=from_, to=to)
class UnifonicOtpProvider(BaseOtpProvider):
def __init__(self) -> None:
self.app_sid = os.getenv("UNIFONIC_APP_SID")
self.sender_id = os.getenv("UNIFONIC_SENDER_ID")
self.whatsapp_sender = os.getenv("UNIFONIC_WHATSAPP_SENDER")
def _assert_config(self) -> None:
if not self.app_sid or not self.sender_id:
raise ValueError(_("Unifonic credentials are not configured"))
def send_sms(self, to_number: str, message: str) -> None:
self._assert_config()
raise NotImplementedError(_("Unifonic SMS adapter not implemented yet"))
def send_whatsapp(self, to_number: str, message: str) -> None:
self._assert_config()
if not self.whatsapp_sender:
raise ValueError(_("Unifonic WhatsApp sender is not configured"))
raise NotImplementedError(_("Unifonic WhatsApp adapter not implemented yet"))
PROVIDERS = { PROVIDERS = {
"console": ConsoleOtpProvider, "console": ConsoleOtpProvider,
"twilio": TwilioOtpProvider, "authentica": AuthenticaOtpProvider,
"unifonic": UnifonicOtpProvider,
} }
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,19 +193,28 @@ 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))
if provider.uses_provider_otp:
code_hash = make_password(secrets.token_urlsafe(16))
message = None
else:
code = generate_code() 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} else:
if channel == OtpChannel.SMS: if channel == OtpChannel.SMS:
provider.send_sms(phone_number, message) provider.send_sms(phone_number, message)
elif channel == OtpChannel.WHATSAPP: elif channel == OtpChannel.WHATSAPP:
@@ -179,6 +232,18 @@ 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
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): if not check_password(code, otp.code_hash):
otp.save(update_fields=["attempt_count"]) otp.save(update_fields=["attempt_count"])
return False return False
@@ -0,0 +1,129 @@
"""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"}
@patch("requests.post")
def test_authentica_request_failure_raises(mock_post):
mock_response = MagicMock(ok=False)
mock_response.json.return_value = {"detail": "fail"}
mock_post.return_value = mock_response
with patch.dict(os.environ, {"AUTHENTICA_API_KEY": "api-key"}):
provider = AuthenticaOtpProvider()
with pytest.raises(RuntimeError):
provider.send_otp("+966512345678", OtpChannel.SMS)
def test_authentica_send_sms_requires_sender_name():
with patch.dict(os.environ, {"AUTHENTICA_API_KEY": "api-key"}):
provider = AuthenticaOtpProvider()
with pytest.raises(ValueError):
provider.send_sms("+966512345678", "Hello")
def test_authentica_send_otp_rejects_unknown_channel():
with patch.dict(os.environ, {"AUTHENTICA_API_KEY": "api-key"}):
provider = AuthenticaOtpProvider()
with pytest.raises(ValueError):
provider.send_otp("+966512345678", "email")
@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
@@ -0,0 +1,53 @@
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
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console")
def test_otp_request_whatsapp_ok(client):
response = client.post(
reverse("otp_request"),
{"phone_number": "0512345678", "channel": "whatsapp"},
content_type="application/json",
)
assert response.status_code == 201
data = response.json()
assert "request_id" in data
assert "expires_at" in data
@pytest.mark.django_db
def test_otp_verify_rejects_expired(client):
otp = PhoneOTP.objects.create(
phone_number="+966512345678",
channel=OtpChannel.SMS,
purpose=OtpPurpose.VERIFY,
provider="console",
code_hash="unused",
expires_at=timezone.now() - timedelta(minutes=1),
)
response = client.post(
reverse("otp_verify"),
{"request_id": str(otp.id), "code": "123456"},
content_type="application/json",
)
assert response.status_code == 400
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console")
def test_otp_request_invalid_phone_localized_ar(client):
response = client.post(
reverse("otp_request"),
{"phone_number": "123", "channel": "sms"},
content_type="application/json",
HTTP_ACCEPT_LANGUAGE="ar-sa",
)
assert response.status_code == 400
assert response.json()["phone_number"][0] == "يجب أن يكون رقم الهاتف بصيغة E.164 أو رقم جوال سعودي صالح"
+118 -5
View File
@@ -1,13 +1,126 @@
import pytest from datetime import timedelta
from django.test import override_settings from unittest.mock import patch
from apps.accounts.models import OtpChannel import pytest
from apps.accounts.services.otp import OtpRateLimitError, create_and_send_otp from django.contrib.auth.hashers import make_password
from django.test import override_settings
from django.utils import timezone
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 @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
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console", OTP_RESEND_COOLDOWN_SECONDS=60)
def test_otp_cooldown_retry_after_seconds():
fixed_now = timezone.now()
with patch("apps.accounts.services.otp.timezone.now", return_value=fixed_now):
result = create_and_send_otp("+966512345678", OtpChannel.SMS)
# Align created_at with fixed time for deterministic cooldown.
PhoneOTP.objects.filter(id=result.request_id).update(created_at=fixed_now)
with pytest.raises(OtpCooldownError) as excinfo:
create_and_send_otp("+966512345678", OtpChannel.SMS)
assert excinfo.value.retry_after_seconds == 60
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console", OTP_MAX_PER_WINDOW=1, OTP_WINDOW_MINUTES=15, OTP_RESEND_COOLDOWN_SECONDS=0)
def test_otp_rate_limit_retry_after_seconds():
fixed_now = timezone.now()
with patch("apps.accounts.services.otp.timezone.now", return_value=fixed_now):
result = create_and_send_otp("+966512345678", OtpChannel.SMS)
# Make the oldest OTP sit 10s before window expiry.
window_start = fixed_now - timedelta(minutes=15)
PhoneOTP.objects.filter(id=result.request_id).update(created_at=window_start + timedelta(seconds=10))
with pytest.raises(OtpRateLimitError) as excinfo:
create_and_send_otp("+966512345678", OtpChannel.SMS)
assert excinfo.value.retry_after_seconds == 10
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console", OTP_RESEND_COOLDOWN_SECONDS=1)
def test_otp_resend_after_cooldown_ok():
result = create_and_send_otp("+966512345678", OtpChannel.SMS)
# Force cooldown to be elapsed.
PhoneOTP.objects.filter(id=result.request_id).update(
created_at=timezone.now() - timedelta(seconds=5)
)
create_and_send_otp("+966512345678", OtpChannel.SMS)
assert PhoneOTP.objects.filter(phone_number="+966512345678").count() == 2
@pytest.mark.django_db
def test_verify_otp_rejects_expired():
otp = PhoneOTP.objects.create(
phone_number="+966512345678",
channel=OtpChannel.SMS,
purpose=OtpPurpose.AUTH,
provider="console",
code_hash=make_password("123456"),
expires_at=timezone.now() - timedelta(minutes=1),
)
assert verify_otp(otp, "123456") is False
otp.refresh_from_db()
assert otp.attempt_count == 0
assert otp.verified_at is None
@pytest.mark.django_db
def test_verify_otp_rejects_reuse_after_verified():
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(),
)
assert verify_otp(otp, "123456") is True
otp.refresh_from_db()
assert otp.attempt_count == 1
assert verify_otp(otp, "123456") is False
otp.refresh_from_db()
assert otp.attempt_count == 1
+19 -1
View File
@@ -1,11 +1,17 @@
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):
# 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") request_url = reverse("phone_auth_request")
verify_url = reverse("phone_auth_verify") verify_url = reverse("phone_auth_verify")
@@ -28,4 +34,16 @@ def test_phone_auth_creates_user_and_issues_tokens(client):
) )
assert bad.status_code == 400 assert bad.status_code == 400
assert User.objects.filter(phone_number="+966512345678").exists() good = client.post(
verify_url,
{"request_id": request_id, "code": "123456"},
content_type="application/json",
)
assert good.status_code == 200
data = good.json()
assert "access" in data
assert "refresh" in data
user = User.objects.filter(phone_number="+966512345678").first()
assert user is not None
assert user.is_phone_verified is True
@@ -1,51 +0,0 @@
"""Tests for Twilio OTP provider implementation."""
import pytest
from unittest.mock import MagicMock, patch
@pytest.mark.django_db
@patch("apps.accounts.services.otp.TwilioOtpProvider._get_client")
def test_twilio_send_sms_calls_client(mock_get_client):
from apps.accounts.services.otp import TwilioOtpProvider
mock_client = MagicMock()
mock_get_client.return_value = mock_client
with patch.dict("os.environ", {
"TWILIO_ACCOUNT_SID": "AC123",
"TWILIO_AUTH_TOKEN": "token",
"TWILIO_FROM_NUMBER": "+966500000000",
}):
provider = TwilioOtpProvider()
provider.send_sms("+966512345678", "Your code is 123456")
mock_client.messages.create.assert_called_once_with(
body="Your code is 123456",
from_="+966500000000",
to="+966512345678",
)
@pytest.mark.django_db
@patch("apps.accounts.services.otp.TwilioOtpProvider._get_client")
def test_twilio_send_whatsapp_calls_client(mock_get_client):
from apps.accounts.services.otp import TwilioOtpProvider
mock_client = MagicMock()
mock_get_client.return_value = mock_client
with patch.dict("os.environ", {
"TWILIO_ACCOUNT_SID": "AC123",
"TWILIO_AUTH_TOKEN": "token",
"TWILIO_FROM_NUMBER": "+966500000000",
"TWILIO_WHATSAPP_FROM": "14155238886",
}):
provider = TwilioOtpProvider()
provider.send_whatsapp("+966512345678", "Your code is 123456")
mock_client.messages.create.assert_called_once_with(
body="Your code is 123456",
from_="whatsapp:14155238886",
to="whatsapp:+966512345678",
)
+30 -3
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,13 +75,39 @@ 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"]
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( return Booking.objects.create(
salon=service.salon, salon=service.salon,
customer=request.user, customer=request.user,
service=service, service=service,
staff=validated_data.get("staff"), staff=staff,
start_time=validated_data["start_time"], start_time=start_time,
end_time=validated_data["end_time"], end_time=end_time,
notes=validated_data.get("notes", ""), notes=validated_data.get("notes", ""),
price_amount=service.price_amount, price_amount=service.price_amount,
currency=service.currency, 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"))
View File
+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`
+28
View File
@@ -0,0 +1,28 @@
# ADR 0003: Authentica As Primary OTP Provider
## Status
Accepted
## Context
The platform requires phone-first authentication with OTP delivery for KSA. The codebase includes provider adapters (`console`, `authentica`) and Authentica is implemented for provider-managed OTP delivery (send/verify) and direct SMS messaging. 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.
## 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
## 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`, ...).
+210 -27
View File
@@ -1,39 +1,222 @@
# Architecture # Salon MVP Roadmap And Architecture Review
## Overview ## Purpose / Big Picture
The Salon platform is a Django REST API backend with a React/Vite frontend, optimized for KSA (phone auth, Riyadh timezone, Arabic locale). This plan reviews the current Salon codebase (Django backend, React/Vite frontend), highlights architectural and design risks, and lays out a pragmatic roadmap to reach an MVP that aligns with **Phase 1: Core MVP Reliability** in `AGENTS.md`: phone-first auth with OTP, robust booking integrity, Moyasar payments, booking lifecycle notifications, localization foundations, and tests for critical flows.
The roadmap assumes a KSA-focused first launch (phone auth and Riyadh timezone defaults) with a clean path to expand later.
## Backend Apps and Responsibilities ## Current State Summary
| App | Responsibility | ### Backend (Django, DRF)
|-----|----------------|
| **accounts** | User model, phone/OTP auth, JWT tokens, locale preferences. OTP providers (console, Twilio, Unifonic) send SMS/WhatsApp. |
| **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. |
## Data Flow - **Project**: `salon_api` in `[backend/salon_api](backend/salon_api)`
- `settings.py` configures `AUTH_USER_MODEL = "accounts.User"`, DRF + SimpleJWT, KSA locale defaults, and custom settings for OTP, notifications, and payments.
- URLs route to app-level APIs at `/api/auth/`, `/api/salons/`, `/api/bookings/`, `/api/payments/`.
- **Auth & Accounts** (`[backend/apps/accounts](backend/apps/accounts)`)
- Custom `User` model with phone-first capabilities (`phone_number`, `is_phone_verified`, `preferred_language`, `role`).
- Phone normalization services tuned for KSA numbers (`[backend/apps/accounts/services/phone.py](backend/apps/accounts/services/phone.py)`).
- OTP domain + service layer with rate limits, cooldowns, expiry, and hashed codes (`[backend/apps/accounts/services/otp.py](backend/apps/accounts/services/otp.py)`).
- Phone-first auth endpoints that issue JWT tokens on successful OTP verification, plus basic email/password registration.
- Social login endpoint is a placeholder that always returns 501.
- **Salons & Catalog** (`[backend/apps/salons](backend/apps/salons)`)
- Models for `Salon`, `Service`, `StaffProfile`, `StaffAvailability`, `Review`, `SalonPhoto`.
- Read-only APIs for salon search, services, staff, and reviews.
- **Bookings** (`[backend/apps/bookings](backend/apps/bookings)`)
- `Booking` model ties together salon, service, staff, customer, time window, price, and status.
- `validate_booking_request` in `[backend/apps/bookings/services.py](backend/apps/bookings/services.py)` enforces staff membership, duration matching, availability windows, and overlap prevention for pending/confirmed bookings.
- `BookingViewSet` in `[backend/apps/bookings/views.py](backend/apps/bookings/views.py)` applies role-based access and hooks into notifications on create and relevant status changes.
- **Payments (Moyasar)** (`[backend/apps/payments](backend/apps/payments)`)
- `Payment` model tracks provider, status (fine-grained transitions), amount, idempotency key, external ID, payload, and timestamps.
- `MoyasarGateway` in `[backend/apps/payments/services/gateway.py](backend/apps/payments/services/gateway.py)` can create payments via HTTP but has `capture_payment`/`refund_payment` as TODOs.
- `create_payment_for_booking` in `[backend/apps/payments/services/payments.py](backend/apps/payments/services/payments.py)` enforces provider choice (Moyasar only), idempotency, and maps webhook events into internal statuses.
- Webhook view in `[backend/apps/payments/views.py](backend/apps/payments/views.py)` authenticates via shared secret and applies provider events idempotently.
- **Notifications** (`[backend/apps/notifications](backend/apps/notifications)`)
- `Notification` model records booking-related notifications and enforces uniqueness per booking/recipient/event/channel.
- Services in `[backend/apps/notifications/services.py](backend/apps/notifications/services.py)` reuse OTP providers to send SMS/WhatsApp messages for booking created/confirmed/cancelled events, with localization via `preferred_language`.
- Booking views call `notify_booking_lifecycle` / `notify_on_status_change` to trigger notifications for customers and staff.
- **Testing**
- Solid tests around:
- Phone normalization, OTP rate limiting, and phone auth flows (`[backend/apps/accounts/tests](backend/apps/accounts/tests)`).
- Booking integrity and overlap rules (`[backend/apps/bookings/tests](backend/apps/bookings/tests)`).
- Payment idempotency and Moyasar webhook handling (`[backend/apps/payments/tests](backend/apps/payments/tests)`).
- Booking notifications on create/status change (`[backend/apps/notifications/tests](backend/apps/notifications/tests)`).
- `docs/risks.md` explicitly tracks several known gaps around auth, booking, payments, data/UX, and ops.
``` ### Frontend (React, Vite)
User → React Frontend → Django API
- **Structure**
accounts (auth) ──→ OTP providers (Twilio/Unifonic/console) - Vite React app at `[frontend](frontend)` with entry in `[frontend/src/main.jsx](frontend/src/main.jsx)` and single top-level component in `[frontend/src/App.jsx](frontend/src/App.jsx)`.
salons (catalog) - No `react-router` or multi-page routing; the entire experience is one composed screen.
bookings ──→ notifications ──→ OTP providers - **Current Features**
payments ──→ Moyasar gateway - **Salon search**
- Text search field calling `/salons/?q=<query>` via a small API client in `[frontend/src/api/client.js](frontend/src/api/client.js)`.
- Renders responsive list of salons with rating, city, and phone.
- **Localization/i18n**
- `react-i18next` setup in `[frontend/src/i18n/index.js](frontend/src/i18n/index.js)` with `en` and `ar-sa` translations.
- Locale preference stored in `localStorage`; applies `lang` and `dir` on the document.
- **Payments beta**
- A form in `App.jsx` that sends payment creation requests to `/api/payments/` using the Moyasar-style payload, with configurable `booking_id`, source type, token, and callback URL.
- Optionally includes a Bearer token from a manually-entered access token field.
- On success, can redirect to `redirect_url` and shows the raw JSON response.
- **State & Tests**
- All state is local to `App.jsx` via `useState`/`useEffect`; there is no centralized state management or domain hooks yet.
- A single test file `[frontend/src/App.test.jsx](frontend/src/App.test.jsx)` covers hero copy and locale/RTL behavior, but not search or payments.
## Glaring Design And Architectural Issues
### Backend Risks
- **Incomplete provider implementations for production-critical flows**
- Authentica is the only OTP/notification provider; ensure it is fully configured and exercised in production-like environments.
- `MoyasarGateway` lacks `capture_payment` and `refund_payment` implementations, limiting payment lifecycle coverage.
- **Risk**: Code reads “production ready” at the API level, but the underlying integrations are not, which could cause outages if deployed naively.
- **Tight coupling between OTP and notifications**
- Notification services import the OTP provider mapping and default `NOTIFICATION_PROVIDER` to `OTP_PROVIDER`, binding booking notifications to auth configuration.
- **Risk**: Changing OTP providers or adding a second channel for marketing/ops notifications will be harder and could have unintended side effects.
- **Synchronous IO-heavy work in request/response path**
- OTP sends, booking notifications, and payment gateway calls all occur synchronously inside view methods (`perform_create`, `create`, etc.).
- **Risk**: Slow or flaky providers will degrade API latency and user experience; retries and backoff are hard to implement without background jobs.
- **Cross-app domain coupling without a clear orchestration layer**
- `apps.bookings` depends on salons and notifications; notifications depend on accounts (OTP providers) and bookings; payments depend on bookings.
- **Risk**: As you add more lifecycle rules (e.g., auto-confirm booking on payment, send reminders, handle refunds), the spaghetti of cross-imports will grow unless you introduce clearer service boundaries.
- **Auth model vs login patterns**
- `User.USERNAME_FIELD` is email, while phone-based JWT issuance happens via custom endpoints.
- **Risk**: This split can confuse clients and admin tooling and may complicate future flows like social login or SSO unless you standardize on an identifier strategy.
- **Docs drift around ExecPlans**
- `AGENTS.md` references `docs/execplans/payments-moyasar.md` as the active plan, while `PLANS.md` names `docs/execplans/booking-notifications.md`.
- **Risk**: Contributors may follow different “active” plans, causing architectural inconsistency.
### Frontend Risks
- **Monolithic `App` component with no routing**
- `App.jsx` mixes hero/search, salon listing, payments, and locale controls.
- There is no `react-router` or notion of separate flows (auth, booking, profile, payments).
- **Risk**: Extending to full MVP flows (auth, booking, history, management) will quickly become unmanageable without a routing/page system and domain separation.
- **Domain logic embedded in UI components**
- API payload construction, validation rules (e.g. for source types), and error handling are implemented directly in `App.jsx` rather than reusable hooks or service modules.
- **Risk**: Code reuse, testing, and evolution (e.g., adding booking pages or admin consoles) will be painful.
- **Minimal test coverage for critical flows**
- Only i18n and hero copy are tested; search behavior, API integration, and payments are untested.
- **Risk**: Regressions in search, booking, and payments UX will slip through as MVP grows.
- **Styling & layout fragility**
- `frontend/src/styles.css` uses `::root` instead of `:root`, which likely breaks intended global CSS variables or base styles.
- Global CSS is tightly bound to the monolithic `App` layout.
- **Risk**: Visual regressions and layout churn when introducing additional pages or components.
- **Ad hoc auth token handling**
- The “access token” is a free-form text field that gets persisted as `auth_token` in `localStorage` and injected into payment requests.
- **Risk**: This is a placeholder pattern that does not scale to full auth (refresh tokens, logout, token rotation) and will need to be replaced.
### Cross-Cutting Risks
- **Lack of async/background processing**
- No Celery/RQ or similar job queue; all side effects are synchronous.
- **Risk**: Scaling SMS/WhatsApp notifications, email, and payment webhook fan-out will be difficult.
- **Observability and admin tooling gaps**
- Errors for payments and notifications are recorded in model metadata but not clearly surfaced in logs, dashboards, or admin views.
- **Risk**: Operational debugging during MVP rollout will be slower and more error-prone.
- **Internationalization strategy vs future markets**
- Phone normalization and defaults are tailored to KSA, which is correct for MVP, but `docs/risks.md` already notes the need to broaden later.
- **Risk**: Without clear boundaries between KSA-specific logic and generic logic, future expansion may require invasive changes.
## MVP Roadmap (Aligned To Phase 1)
This roadmap assumes “MVP” is equivalent to **Phase 1: Core MVP Reliability** in `AGENTS.md`, with a thin but robust frontend on top.
### Phase 0 Architecture & Production Readiness Hardening
- **Finalize critical provider implementations**
- Ensure Authentica OTP/SMS is fully configured and validated end-to-end behind the provider abstraction in `[backend/apps/accounts/services/otp.py](backend/apps/accounts/services/otp.py)`, and wired into `[backend/apps/notifications/services.py](backend/apps/notifications/services.py)`.
- Implement or deliberately fence off `capture_payment` and `refund_payment` in `[backend/apps/payments/services/gateway.py](backend/apps/payments/services/gateway.py)` so that the MVP either fully supports or explicitly does not support partial captures/refunds.
- **Clarify and document boundaries**
- Add a short architecture section in `README`/docs describing how `accounts`, `salons`, `bookings`, `payments`, and `notifications` interact, and what each service is responsible for.
- Resolve the ExecPlan drift by making `AGENTS.md` and `PLANS.md` agree on the current active plan.
- **Introduce minimal async infrastructure (optional but recommended)**
- Decide whether MVP will ship with a task queue (e.g., Celery with Redis) or keep everything synchronous for the initial launch.
- If yes, introduce a thin task layer for OTP sends and booking notifications while preserving current APIs; if not, at least add clear timeouts/logging to external calls.
- **Frontend scaffolding for growth**
- Introduce `react-router` and refactor `App.jsx` into route-based pages (e.g., `HomePage`, `BookPage`, `PaymentPage`, `ProfilePage`), with shared layout and navigation.
- Extract salon search, payment form, and locale controls into dedicated components and hooks.
### Phase 1 Core MVP Features (Backend + Frontend)
- **Phone-first auth UX**
- Backend: reuse existing phone auth endpoints; ensure error messages and rate-limit responses are predictable and localized.
- Frontend:
- Build OTP-based login/registration screens that drive `/api/auth/phone/request/` and `/api/auth/phone/verify/`.
- Introduce an auth context (or similar) to store access/refresh tokens, current user profile, and handle logout.
- Defer social login beyond MVP, but keep API surface ready for it.
- **Booking search and creation**
- Backend is largely ready (booking validation and role-based access); review booking serializers in `[backend/apps/bookings/serializers.py](backend/apps/bookings/serializers.py)` to ensure they expose all fields needed for frontend booking forms.
- Frontend:
- Build a **booking flow**: pick a salon → choose service → select staff (optional) → select date/time slot (based on availability endpoints) → confirm booking.
- Add a “My bookings” page showing upcoming and past bookings, tied into the existing `/api/bookings/` endpoints.
- **Payments via Moyasar**
- Backend: confirm `create_payment_for_booking` contracts (inputs/outputs) are stable and documented.
- Frontend:
- Evolve the payments beta UI into a **post-booking payment step** that starts from a selected booking and guides the user into Moyasars hosted flow, then shows a status page.
- Handle callback/return from Moyasar (even if via manual redirect URL in MVP) and surface payment success/failure to the user.
- **Booking lifecycle notifications**
- Backend already sends notifications on booking create and status changes; align messaging templates with product UX and ensure localization strings exist.
- Frontend: surface notification results implicitly via booking status changes and explicit messages on the booking details page.
- **Localization foundations**
- Backend: ensure `UserLocaleMiddleware` and translation strings cover all user-visible errors in auth, bookings, payments, and notifications.
- Frontend: expand `en/ar-sa` translations to cover auth, booking, and payment flows; verify RTL layouts on the new screens.
- **Tests for critical flows**
- Backend: extend tests where needed to cover new booking/payment edge cases (e.g., tying booking status to payment status if/when introduced).
- Frontend: add Vitest tests for:
- Phone auth screen flows (request/verify success + errors).
- Booking flow (form validation, happy path, displaying server-side errors).
- Payment initiation from an existing booking.
### Phase 2 Manager Ops Lite (Post-MVP, partially covered now)
- **Salon and staff management UI**
- Use existing salon and staff models to build basic management pages for salon owners/managers (create/update services, staff, availability).
- **Calendar views and rescheduling**
- Provide calendar views for staff/managers to view daily/weekly bookings and reschedule or cancel within defined rules.
- **Reviews and ratings**
- Implement review submission and rating recalculation on the backend, with corresponding frontend components.
- **Reporting basics**
- Lightweight reports for managers (upcoming bookings, simple revenue summaries based on payment status) using existing payments data.
### Phase 3 Scale & Compliance (Later)
- **Audit logging** for admin actions and booking/payment state changes.
- **PDPL/GDPR retention policies** and data export tooling.
- **Observability**: structured logging, metrics, and basic dashboards for auth failures, OTP send failures, payment errors, and notification outcomes.
## Architecture Overview Diagram
A simplified view of the target MVP data flow:
```mermaid
flowchart LR
user["User (web/mobile)"] --> frontend["ReactFrontend"]
frontend --> api["DjangoAPI"]
api --> accounts["AccountsApp"]
api --> salons["SalonsApp"]
api --> bookings["BookingsApp"]
api --> payments["PaymentsApp"]
api --> notifications["NotificationsApp"]
accounts --> otpProviders["OtpProviders"]
notifications --> otpProviders
payments --> moyasar["MoyasarGateway"]
bookings --> notifications
bookings --> payments
salons --> bookings
``` ```
## 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.
**Rationale:** This diagram clarifies current coupling and highlights where future refactors (e.g., a dedicated messaging service or payment orchestrator) could sit.
- Reduces deployment complexity (no Redis, no worker processes).
- MVP traffic is expected to be low; synchronous latency is acceptable.
- External calls already use timeouts (e.g. Moyasar: 10s, Twilio: SDK default).
**Future:** When scaling, introduce a task queue (e.g. Celery + Redis) for OTP and notification sends. Payment creation and webhooks should remain synchronous for immediate feedback and idempotency. ## Validation And Acceptance For This Plan
**Observability:** Errors are logged via Python `logging` and stored in model metadata (e.g. `Payment.metadata["gateway_error"]`, `Notification.error_message`). Structured logging and metrics are Phase 3 work. - The roadmap is accepted when:
- It clearly maps current backend and frontend capabilities to the Phase 1 MVP goals in `AGENTS.md`.
- It identifies the highest-risk design/architecture issues that could impede MVP reliability or future evolution.
- It provides a phased, concrete sequence of work packages that can be implemented via ExecPlans (e.g., booking notifications, Moyasar payments, phone auth UX).
- Each major feature area (auth, bookings, payments, notifications, localization, tests) should have or adopt an ExecPlan under `docs/execplans/` in line with `PLANS.md` before implementation begins.
+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.
+4 -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).
- 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,7 +22,7 @@ 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
+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.
-248
View File
@@ -1,248 +0,0 @@
---
name: salon-mvp-roadmap
overview: High-level roadmap to bring the existing Salon Django/React codebase to a reliable MVP aligned with Phase 1 goals in AGENTS.md, plus a review of current architecture and major risks.
todos:
- id: backend-providers-readiness
content: "Harden backend providers: implement at least one real SMS/WhatsApp provider and clarify Moyasar capture/refund behavior for MVP."
status: pending
- id: async-and-observability
content: Decide on async task infrastructure and observability basics for OTP, notifications, and payments, and document the choice.
status: pending
- id: frontend-structure-and-routing
content: Refactor frontend into routed pages with separated components/hooks for search, auth, booking, and payments.
status: pending
- id: auth-and-booking-flows
content: Implement phone-first auth and end-to-end booking flows on the frontend using existing backend APIs.
status: pending
- id: payments-and-notifications-ux
content: Integrate payment initiation and booking lifecycle notifications into user-facing flows, including success/error handling.
status: pending
- id: tests-for-critical-flows
content: Expand backend and frontend tests to cover auth, booking, payment, and notification critical paths for MVP reliability.
status: pending
isProject: false
---
# Salon MVP Roadmap And Architecture Review
## Purpose / Big Picture
This plan reviews the current Salon codebase (Django backend, React/Vite frontend), highlights architectural and design risks, and lays out a pragmatic roadmap to reach an MVP that aligns with **Phase 1: Core MVP Reliability** in `AGENTS.md`: phone-first auth with OTP, robust booking integrity, Moyasar payments, booking lifecycle notifications, localization foundations, and tests for critical flows.
The roadmap assumes a KSA-focused first launch (phone auth and Riyadh timezone defaults) with a clean path to expand later.
## Current State Summary
### Backend (Django, DRF)
- **Project**: `salon_api` in `[backend/salon_api](backend/salon_api)`
- `settings.py` configures `AUTH_USER_MODEL = "accounts.User"`, DRF + SimpleJWT, KSA locale defaults, and custom settings for OTP, notifications, and payments.
- URLs route to app-level APIs at `/api/auth/`, `/api/salons/`, `/api/bookings/`, `/api/payments/`.
- **Auth & Accounts** (`[backend/apps/accounts](backend/apps/accounts)`)
- Custom `User` model with phone-first capabilities (`phone_number`, `is_phone_verified`, `preferred_language`, `role`).
- Phone normalization services tuned for KSA numbers (`[backend/apps/accounts/services/phone.py](backend/apps/accounts/services/phone.py)`).
- OTP domain + service layer with rate limits, cooldowns, expiry, and hashed codes (`[backend/apps/accounts/services/otp.py](backend/apps/accounts/services/otp.py)`).
- Phone-first auth endpoints that issue JWT tokens on successful OTP verification, plus basic email/password registration.
- Social login endpoint is a placeholder that always returns 501.
- **Salons & Catalog** (`[backend/apps/salons](backend/apps/salons)`)
- Models for `Salon`, `Service`, `StaffProfile`, `StaffAvailability`, `Review`, `SalonPhoto`.
- Read-only APIs for salon search, services, staff, and reviews.
- **Bookings** (`[backend/apps/bookings](backend/apps/bookings)`)
- `Booking` model ties together salon, service, staff, customer, time window, price, and status.
- `validate_booking_request` in `[backend/apps/bookings/services.py](backend/apps/bookings/services.py)` enforces staff membership, duration matching, availability windows, and overlap prevention for pending/confirmed bookings.
- `BookingViewSet` in `[backend/apps/bookings/views.py](backend/apps/bookings/views.py)` applies role-based access and hooks into notifications on create and relevant status changes.
- **Payments (Moyasar)** (`[backend/apps/payments](backend/apps/payments)`)
- `Payment` model tracks provider, status (fine-grained transitions), amount, idempotency key, external ID, payload, and timestamps.
- `MoyasarGateway` in `[backend/apps/payments/services/gateway.py](backend/apps/payments/services/gateway.py)` can create payments via HTTP but has `capture_payment`/`refund_payment` as TODOs.
- `create_payment_for_booking` in `[backend/apps/payments/services/payments.py](backend/apps/payments/services/payments.py)` enforces provider choice (Moyasar only), idempotency, and maps webhook events into internal statuses.
- Webhook view in `[backend/apps/payments/views.py](backend/apps/payments/views.py)` authenticates via shared secret and applies provider events idempotently.
- **Notifications** (`[backend/apps/notifications](backend/apps/notifications)`)
- `Notification` model records booking-related notifications and enforces uniqueness per booking/recipient/event/channel.
- Services in `[backend/apps/notifications/services.py](backend/apps/notifications/services.py)` reuse OTP providers to send SMS/WhatsApp messages for booking created/confirmed/cancelled events, with localization via `preferred_language`.
- Booking views call `notify_booking_lifecycle` / `notify_on_status_change` to trigger notifications for customers and staff.
- **Testing**
- Solid tests around:
- Phone normalization, OTP rate limiting, and phone auth flows (`[backend/apps/accounts/tests](backend/apps/accounts/tests)`).
- Booking integrity and overlap rules (`[backend/apps/bookings/tests](backend/apps/bookings/tests)`).
- Payment idempotency and Moyasar webhook handling (`[backend/apps/payments/tests](backend/apps/payments/tests)`).
- Booking notifications on create/status change (`[backend/apps/notifications/tests](backend/apps/notifications/tests)`).
- `docs/risks.md` explicitly tracks several known gaps around auth, booking, payments, data/UX, and ops.
### Frontend (React, Vite)
- **Structure**
- Vite React app at `[frontend](frontend)` with entry in `[frontend/src/main.jsx](frontend/src/main.jsx)` and single top-level component in `[frontend/src/App.jsx](frontend/src/App.jsx)`.
- No `react-router` or multi-page routing; the entire experience is one composed screen.
- **Current Features**
- **Salon search**
- Text search field calling `/salons/?q=<query>` via a small API client in `[frontend/src/api/client.js](frontend/src/api/client.js)`.
- Renders responsive list of salons with rating, city, and phone.
- **Localization/i18n**
- `react-i18next` setup in `[frontend/src/i18n/index.js](frontend/src/i18n/index.js)` with `en` and `ar-sa` translations.
- Locale preference stored in `localStorage`; applies `lang` and `dir` on the document.
- **Payments beta**
- A form in `App.jsx` that sends payment creation requests to `/api/payments/` using the Moyasar-style payload, with configurable `booking_id`, source type, token, and callback URL.
- Optionally includes a Bearer token from a manually-entered access token field.
- On success, can redirect to `redirect_url` and shows the raw JSON response.
- **State & Tests**
- All state is local to `App.jsx` via `useState`/`useEffect`; there is no centralized state management or domain hooks yet.
- A single test file `[frontend/src/App.test.jsx](frontend/src/App.test.jsx)` covers hero copy and locale/RTL behavior, but not search or payments.
## Glaring Design And Architectural Issues
### Backend Risks
- **Incomplete provider implementations for production-critical flows**
- Twilio/Unifonic providers in `[backend/apps/accounts/services/otp.py](backend/apps/accounts/services/otp.py)` are stubs with `NotImplementedError` for send methods, yet they are the backbone for both OTP and booking notifications.
- `MoyasarGateway` lacks `capture_payment` and `refund_payment` implementations, limiting payment lifecycle coverage.
- **Risk**: Code reads “production ready” at the API level, but the underlying integrations are not, which could cause outages if deployed naively.
- **Tight coupling between OTP and notifications**
- Notification services import the OTP provider mapping and default `NOTIFICATION_PROVIDER` to `OTP_PROVIDER`, binding booking notifications to auth configuration.
- **Risk**: Changing OTP providers or adding a second channel for marketing/ops notifications will be harder and could have unintended side effects.
- **Synchronous IO-heavy work in request/response path**
- OTP sends, booking notifications, and payment gateway calls all occur synchronously inside view methods (`perform_create`, `create`, etc.).
- **Risk**: Slow or flaky providers will degrade API latency and user experience; retries and backoff are hard to implement without background jobs.
- **Cross-app domain coupling without a clear orchestration layer**
- `apps.bookings` depends on salons and notifications; notifications depend on accounts (OTP providers) and bookings; payments depend on bookings.
- **Risk**: As you add more lifecycle rules (e.g., auto-confirm booking on payment, send reminders, handle refunds), the spaghetti of cross-imports will grow unless you introduce clearer service boundaries.
- **Auth model vs login patterns**
- `User.USERNAME_FIELD` is email, while phone-based JWT issuance happens via custom endpoints.
- **Risk**: This split can confuse clients and admin tooling and may complicate future flows like social login or SSO unless you standardize on an identifier strategy.
- **Docs drift around ExecPlans**
- `AGENTS.md` references `docs/execplans/payments-moyasar.md` as the active plan, while `PLANS.md` names `docs/execplans/booking-notifications.md`.
- **Risk**: Contributors may follow different “active” plans, causing architectural inconsistency.
### Frontend Risks
- **Monolithic `App` component with no routing**
- `App.jsx` mixes hero/search, salon listing, payments, and locale controls.
- There is no `react-router` or notion of separate flows (auth, booking, profile, payments).
- **Risk**: Extending to full MVP flows (auth, booking, history, management) will quickly become unmanageable without a routing/page system and domain separation.
- **Domain logic embedded in UI components**
- API payload construction, validation rules (e.g. for source types), and error handling are implemented directly in `App.jsx` rather than reusable hooks or service modules.
- **Risk**: Code reuse, testing, and evolution (e.g., adding booking pages or admin consoles) will be painful.
- **Minimal test coverage for critical flows**
- Only i18n and hero copy are tested; search behavior, API integration, and payments are untested.
- **Risk**: Regressions in search, booking, and payments UX will slip through as MVP grows.
- **Styling & layout fragility**
- `frontend/src/styles.css` uses `::root` instead of `:root`, which likely breaks intended global CSS variables or base styles.
- Global CSS is tightly bound to the monolithic `App` layout.
- **Risk**: Visual regressions and layout churn when introducing additional pages or components.
- **Ad hoc auth token handling**
- The “access token” is a free-form text field that gets persisted as `auth_token` in `localStorage` and injected into payment requests.
- **Risk**: This is a placeholder pattern that does not scale to full auth (refresh tokens, logout, token rotation) and will need to be replaced.
### Cross-Cutting Risks
- **Lack of async/background processing**
- No Celery/RQ or similar job queue; all side effects are synchronous.
- **Risk**: Scaling SMS/WhatsApp notifications, email, and payment webhook fan-out will be difficult.
- **Observability and admin tooling gaps**
- Errors for payments and notifications are recorded in model metadata but not clearly surfaced in logs, dashboards, or admin views.
- **Risk**: Operational debugging during MVP rollout will be slower and more error-prone.
- **Internationalization strategy vs future markets**
- Phone normalization and defaults are tailored to KSA, which is correct for MVP, but `docs/risks.md` already notes the need to broaden later.
- **Risk**: Without clear boundaries between KSA-specific logic and generic logic, future expansion may require invasive changes.
## MVP Roadmap (Aligned To Phase 1)
This roadmap assumes “MVP” is equivalent to **Phase 1: Core MVP Reliability** in `AGENTS.md`, with a thin but robust frontend on top.
### Phase 0 Architecture & Production Readiness Hardening
- **Finalize critical provider implementations**
- Implement at least one real SMS/WhatsApp provider (Twilio or Unifonic) end-to-end, behind the existing provider abstraction in `[backend/apps/accounts/services/otp.py](backend/apps/accounts/services/otp.py)`, and wire it into `[backend/apps/notifications/services.py](backend/apps/notifications/services.py)`.
- Implement or deliberately fence off `capture_payment` and `refund_payment` in `[backend/apps/payments/services/gateway.py](backend/apps/payments/services/gateway.py)` so that the MVP either fully supports or explicitly does not support partial captures/refunds.
- **Clarify and document boundaries**
- Add a short architecture section in `README`/docs describing how `accounts`, `salons`, `bookings`, `payments`, and `notifications` interact, and what each service is responsible for.
- Resolve the ExecPlan drift by making `AGENTS.md` and `PLANS.md` agree on the current active plan.
- **Introduce minimal async infrastructure (optional but recommended)**
- Decide whether MVP will ship with a task queue (e.g., Celery with Redis) or keep everything synchronous for the initial launch.
- If yes, introduce a thin task layer for OTP sends and booking notifications while preserving current APIs; if not, at least add clear timeouts/logging to external calls.
- **Frontend scaffolding for growth**
- Introduce `react-router` and refactor `App.jsx` into route-based pages (e.g., `HomePage`, `BookPage`, `PaymentPage`, `ProfilePage`), with shared layout and navigation.
- Extract salon search, payment form, and locale controls into dedicated components and hooks.
### Phase 1 Core MVP Features (Backend + Frontend)
- **Phone-first auth UX**
- Backend: reuse existing phone auth endpoints; ensure error messages and rate-limit responses are predictable and localized.
- Frontend:
- Build OTP-based login/registration screens that drive `/api/auth/phone/request/` and `/api/auth/phone/verify/`.
- Introduce an auth context (or similar) to store access/refresh tokens, current user profile, and handle logout.
- Defer social login beyond MVP, but keep API surface ready for it.
- **Booking search and creation**
- Backend is largely ready (booking validation and role-based access); review booking serializers in `[backend/apps/bookings/serializers.py](backend/apps/bookings/serializers.py)` to ensure they expose all fields needed for frontend booking forms.
- Frontend:
- Build a **booking flow**: pick a salon → choose service → select staff (optional) → select date/time slot (based on availability endpoints) → confirm booking.
- Add a “My bookings” page showing upcoming and past bookings, tied into the existing `/api/bookings/` endpoints.
- **Payments via Moyasar**
- Backend: confirm `create_payment_for_booking` contracts (inputs/outputs) are stable and documented.
- Frontend:
- Evolve the payments beta UI into a **post-booking payment step** that starts from a selected booking and guides the user into Moyasars hosted flow, then shows a status page.
- Handle callback/return from Moyasar (even if via manual redirect URL in MVP) and surface payment success/failure to the user.
- **Booking lifecycle notifications**
- Backend already sends notifications on booking create and status changes; align messaging templates with product UX and ensure localization strings exist.
- Frontend: surface notification results implicitly via booking status changes and explicit messages on the booking details page.
- **Localization foundations**
- Backend: ensure `UserLocaleMiddleware` and translation strings cover all user-visible errors in auth, bookings, payments, and notifications.
- Frontend: expand `en/ar-sa` translations to cover auth, booking, and payment flows; verify RTL layouts on the new screens.
- **Tests for critical flows**
- Backend: extend tests where needed to cover new booking/payment edge cases (e.g., tying booking status to payment status if/when introduced).
- Frontend: add Vitest tests for:
- Phone auth screen flows (request/verify success + errors).
- Booking flow (form validation, happy path, displaying server-side errors).
- Payment initiation from an existing booking.
### Phase 2 Manager Ops Lite (Post-MVP, partially covered now)
- **Salon and staff management UI**
- Use existing salon and staff models to build basic management pages for salon owners/managers (create/update services, staff, availability).
- **Calendar views and rescheduling**
- Provide calendar views for staff/managers to view daily/weekly bookings and reschedule or cancel within defined rules.
- **Reviews and ratings**
- Implement review submission and rating recalculation on the backend, with corresponding frontend components.
- **Reporting basics**
- Lightweight reports for managers (upcoming bookings, simple revenue summaries based on payment status) using existing payments data.
### Phase 3 Scale & Compliance (Later)
- **Audit logging** for admin actions and booking/payment state changes.
- **PDPL/GDPR retention policies** and data export tooling.
- **Observability**: structured logging, metrics, and basic dashboards for auth failures, OTP send failures, payment errors, and notification outcomes.
## Architecture Overview Diagram
A simplified view of the target MVP data flow:
```mermaid
flowchart LR
user["User (web/mobile)"] --> frontend["ReactFrontend"]
frontend --> api["DjangoAPI"]
api --> accounts["AccountsApp"]
api --> salons["SalonsApp"]
api --> bookings["BookingsApp"]
api --> payments["PaymentsApp"]
api --> notifications["NotificationsApp"]
accounts --> otpProviders["OtpProviders"]
notifications --> otpProviders
payments --> moyasar["MoyasarGateway"]
bookings --> notifications
bookings --> payments
salons --> bookings
```
This diagram clarifies current coupling and highlights where future refactors (e.g., a dedicated messaging service or payment orchestrator) could sit.
## Validation And Acceptance For This Plan
- The roadmap is accepted when:
- It clearly maps current backend and frontend capabilities to the Phase 1 MVP goals in `AGENTS.md`.
- It identifies the highest-risk design/architecture issues that could impede MVP reliability or future evolution.
- It provides a phased, concrete sequence of work packages that can be implemented via ExecPlans (e.g., booking notifications, Moyasar payments, phone auth UX).
- Each major feature area (auth, bookings, payments, notifications, localization, tests) should have or adopt an ExecPlan under `docs/execplans/` in line with `PLANS.md` before implementation begins.
+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>