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
.DS_Store
backend/tmp_authentica_request_id.txt
+9 -1
View File
@@ -1,5 +1,13 @@
# 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
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
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
- From project root with venv active: `venv/bin/python3 -m pytest` (run from `backend/` so `pytest.ini` is picked up)
- External provider tests are skipped by default; run explicitly when needed: `PYTEST_ADDOPTS='' venv/bin/python3 -m pytest -m external`
### Core API endpoints (current scaffold)
@@ -63,3 +64,4 @@ The dev server proxies `/api` to `http://localhost:8000`.
- Known gaps and risks: `docs/risks.md`
- Architecture and async/observability decisions: `docs/architecture.md`
- Documentation index and standards: `docs/README.md` and `docs/documentation.md`
+3 -5
View File
@@ -1,13 +1,11 @@
# Backend Notes (MVP Readiness)
## High-Level Takeaways
- Provider integrations are the main reliability gap: OTP providers are stubbed and Moyasar capture/refund are TODOs.
- Authentica OTP integration is implemented; Moyasar capture/refund are TODOs.
- External calls (OTP, notifications, payment gateway) run synchronously in request/response paths, increasing latency risk.
- Cross-app coupling (bookings ↔ notifications ↔ accounts/payments) will get harder to evolve without clearer service boundaries.
- Phone-first auth works, but `USERNAME_FIELD` is email; align identifier strategy to avoid future auth confusion.
## Near-Term Focus
- Implement at least one real SMS/WhatsApp provider end-to-end via existing abstractions.
- Decide and document payment lifecycle scope (capture/refund supported vs explicitly out of scope).
- Add timeouts/logging for external calls or introduce minimal async jobs for OTP/notifications.
- Keep booking, payment, and notification orchestration in service layers, not views.
- finalize otp testing
- work on authentication and complete it
+4 -3
View File
@@ -29,7 +29,7 @@ class UserManager(BaseUserManager):
user.save(using=self._db)
return user
def create_superuser(self, email, password=None, **extra_fields):
def create_superuser(self, phone_number, password=None, **extra_fields):
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True)
extra_fields.setdefault("role", UserRole.ADMIN)
@@ -37,7 +37,7 @@ class UserManager(BaseUserManager):
raise ValueError("Superuser must have is_staff=True")
if extra_fields.get("is_superuser") is not True:
raise ValueError("Superuser must have is_superuser=True")
return self.create_user(email, password, **extra_fields)
return self.create_user(phone_number=phone_number, password=password, **extra_fields)
class User(AbstractBaseUser, PermissionsMixin):
@@ -59,7 +59,8 @@ class User(AbstractBaseUser, PermissionsMixin):
objects = UserManager()
USERNAME_FIELD = "email"
USERNAME_FIELD = "phone_number"
REQUIRED_FIELDS = [] # email is optional; phone_number is the identifier
def __str__(self):
return self.email or self.phone_number or str(self.id)
+124 -59
View File
@@ -1,8 +1,8 @@
import logging
import os
import secrets
from datetime import timedelta
from dataclasses import dataclass
from datetime import timedelta
from django.conf import settings
from django.contrib.auth.hashers import check_password, make_password
@@ -34,12 +34,20 @@ class OtpCooldownError(RuntimeError):
class BaseOtpProvider:
uses_provider_otp = False
def send_sms(self, to_number: str, message: str) -> None:
raise NotImplementedError
def send_whatsapp(self, to_number: str, message: str) -> None:
raise NotImplementedError
def send_otp(self, to_number: str, channel: str) -> None:
raise NotImplementedError
def verify_otp(self, to_number: str, code: str) -> bool:
raise NotImplementedError
class ConsoleOtpProvider(BaseOtpProvider):
def send_sms(self, to_number: str, message: str) -> None:
@@ -49,74 +57,110 @@ class ConsoleOtpProvider(BaseOtpProvider):
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:
self.account_sid = os.getenv("TWILIO_ACCOUNT_SID")
self.auth_token = os.getenv("TWILIO_AUTH_TOKEN")
self.from_number = os.getenv("TWILIO_FROM_NUMBER")
self.whatsapp_from = os.getenv("TWILIO_WHATSAPP_FROM")
self.api_key = os.getenv("AUTHENTICA_API_KEY")
self.base_url = os.getenv("AUTHENTICA_BASE_URL", "https://api.authentica.sa")
self.timeout_seconds = float(os.getenv("AUTHENTICA_TIMEOUT_SECONDS", "10"))
self.sender_name = os.getenv("AUTHENTICA_SENDER_NAME")
def _assert_config(self) -> None:
if not self.account_sid or not self.auth_token or not self.from_number:
raise ValueError(_("Twilio credentials are not configured"))
if not self.api_key:
raise ValueError(_("Authentica API key is not configured"))
def _headers(self) -> dict:
return {
"Accept": "application/json",
"Content-Type": "application/json",
"X-Authorization": self.api_key,
}
def _post(self, path: str, payload: dict) -> dict:
import requests
def _get_client(self):
from twilio.rest import Client
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:
client = self._get_client()
client.messages.create(body=message, from_=self.from_number, to=to_number)
if not self.sender_name:
raise ValueError(_("Authentica sender name is not configured"))
self._post(
"/api/v2/send-sms",
{
"phone": to_number,
"message": message,
"sender_name": self.sender_name,
},
)
def send_whatsapp(self, to_number: str, message: str) -> None:
self._assert_config()
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"))
raise ValueError(_("Authentica WhatsApp messaging is not supported"))
PROVIDERS = {
"console": ConsoleOtpProvider,
"twilio": TwilioOtpProvider,
"unifonic": UnifonicOtpProvider,
"authentica": AuthenticaOtpProvider,
}
def get_provider() -> BaseOtpProvider:
provider_key = settings.OTP_PROVIDER
def _get_provider_for_key(provider_key: str) -> BaseOtpProvider:
provider_cls = PROVIDERS.get(provider_key)
if not provider_cls:
raise ValueError(_("Unknown OTP provider: %(provider)s") % {"provider": provider_key})
return provider_cls()
def get_provider() -> BaseOtpProvider:
return _get_provider_for_key(settings.OTP_PROVIDER)
def generate_code(length: int = 6) -> str:
digits = "0123456789"
return "".join(secrets.choice(digits) for _ in range(length))
@@ -149,25 +193,34 @@ def create_and_send_otp(phone_number: str, channel: str, purpose: str = OtpPurpo
if elapsed < cooldown_seconds:
raise OtpCooldownError(retry_after_seconds=int(cooldown_seconds - elapsed))
code = generate_code()
if provider.uses_provider_otp:
code_hash = make_password(secrets.token_urlsafe(16))
message = None
else:
code = generate_code()
code_hash = make_password(code)
message = _(
"Your verification code is %(code)s. It expires in %(minutes)s minutes."
) % {"code": code, "minutes": settings.OTP_EXPIRY_MINUTES}
otp = PhoneOTP.objects.create(
phone_number=phone_number,
channel=channel,
purpose=purpose,
provider=settings.OTP_PROVIDER,
code_hash=make_password(code),
code_hash=code_hash,
expires_at=PhoneOTP.expiry_at(),
)
message = _(
"Your verification code is %(code)s. It expires in %(minutes)s minutes."
) % {"code": code, "minutes": settings.OTP_EXPIRY_MINUTES}
if channel == OtpChannel.SMS:
provider.send_sms(phone_number, message)
elif channel == OtpChannel.WHATSAPP:
provider.send_whatsapp(phone_number, message)
if provider.uses_provider_otp:
provider.send_otp(phone_number, channel)
else:
raise ValueError(_("Unsupported OTP channel"))
if channel == OtpChannel.SMS:
provider.send_sms(phone_number, message)
elif channel == OtpChannel.WHATSAPP:
provider.send_whatsapp(phone_number, message)
else:
raise ValueError(_("Unsupported OTP channel"))
return OtpSendResult(request_id=str(otp.id), expires_at=otp.expires_at.isoformat())
@@ -179,9 +232,21 @@ def verify_otp(otp: PhoneOTP, code: str) -> bool:
if otp.attempt_count > otp.max_attempts:
otp.save(update_fields=["attempt_count"])
return False
if not check_password(code, otp.code_hash):
otp.save(update_fields=["attempt_count"])
return False
provider_cls = PROVIDERS.get(otp.provider)
if provider_cls and getattr(provider_cls, "uses_provider_otp", False):
provider = provider_cls()
try:
verified = provider.verify_otp(otp.phone_number, code)
except Exception:
otp.save(update_fields=["attempt_count"])
raise
if not verified:
otp.save(update_fields=["attempt_count"])
return False
else:
if not check_password(code, otp.code_hash):
otp.save(update_fields=["attempt_count"])
return False
otp.verified_at = timezone.now()
otp.save(update_fields=["verified_at", "attempt_count"])
return True
@@ -0,0 +1,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 django.test import override_settings
from datetime import timedelta
from unittest.mock import patch
from apps.accounts.models import OtpChannel
from apps.accounts.services.otp import OtpRateLimitError, create_and_send_otp
import pytest
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
@override_settings(OTP_MAX_PER_WINDOW=1, OTP_WINDOW_MINUTES=15, OTP_RESEND_COOLDOWN_SECONDS=0)
@override_settings(OTP_PROVIDER="console", OTP_MAX_PER_WINDOW=1, OTP_WINDOW_MINUTES=15, OTP_RESEND_COOLDOWN_SECONDS=0)
def test_otp_rate_limit():
create_and_send_otp("+966512345678", OtpChannel.SMS)
with pytest.raises(OtpRateLimitError):
create_and_send_otp("+966512345678", OtpChannel.SMS)
@pytest.mark.django_db
@override_settings(
OTP_PROVIDER="console",
OTP_MAX_PER_WINDOW=5,
OTP_WINDOW_MINUTES=15,
OTP_RESEND_COOLDOWN_SECONDS=60,
)
def test_otp_cooldown_enforced():
create_and_send_otp("+966512345678", OtpChannel.SMS)
with pytest.raises(OtpCooldownError):
create_and_send_otp("+966512345678", OtpChannel.SMS)
@pytest.mark.django_db
def test_otp_max_attempts_blocks_verification():
otp = PhoneOTP.objects.create(
phone_number="+966512345678",
channel=OtpChannel.SMS,
purpose=OtpPurpose.AUTH,
provider="console",
code_hash=make_password("123456"),
expires_at=PhoneOTP.expiry_at(),
)
# Burn attempts with wrong code until the limit is exceeded.
for _ in range(otp.max_attempts):
assert verify_otp(otp, "000000") is False
otp.refresh_from_db()
assert otp.attempt_count == otp.max_attempts
assert verify_otp(otp, "123456") is False
otp.refresh_from_db()
assert otp.attempt_count == otp.max_attempts + 1
assert otp.verified_at is None
@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
+37 -19
View File
@@ -1,31 +1,49 @@
from unittest.mock import patch
import pytest
from django.test import override_settings
from django.urls import reverse
from apps.accounts.models import PhoneOTP, User
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console")
def test_phone_auth_creates_user_and_issues_tokens(client):
request_url = reverse("phone_auth_request")
verify_url = reverse("phone_auth_verify")
# Deterministic OTP so we can verify the flow without external providers.
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
request_url = reverse("phone_auth_request")
verify_url = reverse("phone_auth_verify")
response = client.post(
request_url,
{"phone_number": "0512345678", "channel": "sms", "first_name": "Sara"},
content_type="application/json",
)
assert response.status_code == 201
request_id = response.json()["request_id"]
response = client.post(
request_url,
{"phone_number": "0512345678", "channel": "sms", "first_name": "Sara"},
content_type="application/json",
)
assert response.status_code == 201
request_id = response.json()["request_id"]
otp = PhoneOTP.objects.filter(phone_number="+966512345678").order_by("-created_at").first()
assert otp is not None
assert str(otp.id) == request_id
otp = PhoneOTP.objects.filter(phone_number="+966512345678").order_by("-created_at").first()
assert otp is not None
assert str(otp.id) == request_id
bad = client.post(
verify_url,
{"request_id": request_id, "code": "000000"},
content_type="application/json",
)
assert bad.status_code == 400
bad = client.post(
verify_url,
{"request_id": request_id, "code": "000000"},
content_type="application/json",
)
assert bad.status_code == 400
assert User.objects.filter(phone_number="+966512345678").exists()
good = client.post(
verify_url,
{"request_id": request_id, "code": "123456"},
content_type="application/json",
)
assert good.status_code == 200
data = good.json()
assert "access" in data
assert "refresh" in data
user = User.objects.filter(phone_number="+966512345678").first()
assert user is not None
assert user.is_phone_verified is True
@@ -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",
)
+38 -11
View File
@@ -1,3 +1,4 @@
from django.db import transaction
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from apps.bookings.models import Booking, BookingStatus
@@ -74,14 +75,40 @@ class BookingCreateSerializer(serializers.ModelSerializer):
def create(self, validated_data):
request = self.context["request"]
service = validated_data["service"]
return Booking.objects.create(
salon=service.salon,
customer=request.user,
service=service,
staff=validated_data.get("staff"),
start_time=validated_data["start_time"],
end_time=validated_data["end_time"],
notes=validated_data.get("notes", ""),
price_amount=service.price_amount,
currency=service.currency,
)
staff = validated_data.get("staff")
start_time = validated_data["start_time"]
end_time = validated_data["end_time"]
with transaction.atomic():
# Lock the staff row so concurrent booking requests for the same staff
# member are serialized. Without this, two requests that both pass the
# overlap check in validate() can race and both commit overlapping
# bookings. On SQLite (dev/tests) the FOR UPDATE clause is silently
# ignored but the transaction still serializes writes; PostgreSQL
# (production) gets true row-level locking.
StaffProfile.objects.select_for_update().get(pk=staff.pk)
# Re-run the overlap check inside the lock so the check and the insert
# are atomic with respect to other writers.
overlap = Booking.objects.filter(
staff=staff,
status__in=[BookingStatus.PENDING, BookingStatus.CONFIRMED],
start_time__lt=end_time,
end_time__gt=start_time,
).exists()
if overlap:
raise serializers.ValidationError(
{"start_time": _("Booking overlaps an existing appointment")}
)
return Booking.objects.create(
salon=service.salon,
customer=request.user,
service=service,
staff=staff,
start_time=start_time,
end_time=end_time,
notes=validated_data.get("notes", ""),
price_amount=service.price_amount,
currency=service.currency,
)
Binary file not shown.
+251
View File
@@ -0,0 +1,251 @@
# Arabic (Saudi Arabia) translations for Salon booking platform.
# Copyright (C) 2026
#
msgid ""
msgstr ""
"Project-Id-Version: 1.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-02 00:45+0300\n"
"PO-Revision-Date: 2026-03-02 00:45+0300\n"
"Last-Translator: Claude\n"
"Language-Team: Arabic (Saudi Arabia)\n"
"Language: ar_SA\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
#: apps/accounts/services/otp.py:26
msgid "Too many OTP requests. Try again later."
msgstr "طلبات رمز التحقق كثيرة جداً. حاول مرة أخرى لاحقاً."
#: apps/accounts/services/otp.py:32
msgid "Please wait before requesting another code."
msgstr "يرجى الانتظار قبل طلب رمز آخر."
#: apps/accounts/services/otp.py:71
msgid "Twilio credentials are not configured"
msgstr "لم يتم تكوين بيانات اعتماد Twilio"
#: apps/accounts/services/otp.py:85
msgid "Twilio WhatsApp sender is not configured"
msgstr "لم يتم تكوين مُرسِل WhatsApp لـ Twilio"
#: apps/accounts/services/otp.py:100
msgid "Unifonic credentials are not configured"
msgstr "لم يتم تكوين بيانات اعتماد Unifonic"
#: apps/accounts/services/otp.py:104
msgid "Unifonic SMS adapter not implemented yet"
msgstr "محول SMS لـ Unifonic لم يتم تنفيذه بعد"
#: apps/accounts/services/otp.py:109
msgid "Unifonic WhatsApp sender is not configured"
msgstr "لم يتم تكوين مُرسِل WhatsApp لـ Unifonic"
#: apps/accounts/services/otp.py:110
msgid "Unifonic WhatsApp adapter not implemented yet"
msgstr "محول WhatsApp لـ Unifonic لم يتم تنفيذه بعد"
#: apps/accounts/services/otp.py:126
msgid "Authentica API key is not configured"
msgstr "لم يتم تكوين مفتاح API لـ Authentica"
#: apps/accounts/services/otp.py:149 apps/accounts/services/otp.py:162
msgid "Authentica request failed"
msgstr "فشل طلب Authentica"
#: apps/accounts/services/otp.py:159
#, python-format
msgid "Authentica request failed: %(status)s %(body)s"
msgstr "فشل طلب Authentica: %(status)s %(body)s"
#: apps/accounts/services/otp.py:168 apps/accounts/services/otp.py:276
msgid "Unsupported OTP channel"
msgstr "قناة رمز التحقق غير مدعومة"
#: apps/accounts/services/otp.py:179
#, python-format
msgid "Authentica verify failed: %(response)s"
msgstr "فشل التحقق بـ Authentica: %(response)s"
#: apps/accounts/services/otp.py:184
msgid "Authentica sender name is not configured"
msgstr "لم يتم تكوين اسم مُرسِل Authentica"
#: apps/accounts/services/otp.py:195
msgid "Authentica WhatsApp messaging is not supported"
msgstr "مراسلة WhatsApp غير مدعومة لـ Authentica"
#: apps/accounts/services/otp.py:209
#, python-format
msgid "Unknown OTP provider: %(provider)s"
msgstr "مزود رمز التحقق غير معروف: %(provider)s"
#: apps/accounts/services/otp.py:256
#, python-format
msgid "Your verification code is %(code)s. It expires in %(minutes)s minutes."
msgstr "رمز التحقق الخاص بك هو %(code)s. ينتهي في %(minutes)s دقيقة."
#: apps/accounts/services/phone.py:8
msgid "Phone number is required"
msgstr "رقم الهاتف مطلوب"
#: apps/accounts/services/phone.py:17
msgid "Invalid phone number format"
msgstr "تنسيق رقم الهاتف غير صالح"
#: apps/accounts/services/phone.py:28
msgid "Phone number must be in E.164 format or a valid Saudi mobile"
msgstr "يجب أن يكون رقم الهاتف بصيغة E.164 أو رقم جوال سعودي صالح"
#: apps/accounts/views.py:75 apps/accounts/views.py:138
msgid "Invalid or expired code"
msgstr "الرمز غير صالح أو منتهي الصلاحية"
#: apps/accounts/views.py:82
msgid "Phone verified"
msgstr "تم التحقق من رقم الهاتف"
#: apps/accounts/views.py:99
msgid "Email already in use."
msgstr "البريد الإلكتروني مستخدم بالفعل."
#: apps/accounts/views.py:142
msgid "User not found"
msgstr "المستخدم غير موجود"
#: apps/accounts/views.py:164
msgid "Social login not configured yet. Add OAuth provider config."
msgstr "لم يتم تكوين تسجيل الدخول الاجتماعي بعد. أضف إعداد مزود OAuth."
#: apps/bookings/serializers.py:54
msgid "Only staff or managers can confirm bookings."
msgstr "يمكن للموظفين والمدراء فقط تأكيد الحجوزات."
#: apps/bookings/serializers.py:56
msgid "Only staff or managers can complete bookings."
msgstr "يمكن للموظفين والمدراء فقط إكمال الحجوزات."
#: apps/bookings/serializers.py:58
msgid "You are not allowed to cancel this booking."
msgstr "لا يُسمح لك بإلغاء هذا الحجز."
#: apps/bookings/serializers.py:101 apps/bookings/services.py:49
msgid "Booking overlaps an existing appointment"
msgstr "يتداخل الحجز مع موعد قائم"
#: apps/bookings/services.py:13
msgid "Staff is required for booking"
msgstr "يجب تحديد موظف للحجز"
#: apps/bookings/services.py:16
msgid "Selected staff does not belong to this salon"
msgstr "الموظف المختار لا ينتمي إلى هذا الصالون"
#: apps/bookings/services.py:19
msgid "End time must be after start time"
msgstr "يجب أن يكون وقت الانتهاء بعد وقت البدء"
#: apps/bookings/services.py:23
msgid "End time must match service duration"
msgstr "يجب أن يتطابق وقت الانتهاء مع مدة الخدمة"
#: apps/bookings/services.py:40
msgid "Booking is outside staff availability"
msgstr "الحجز خارج أوقات توفر الموظف"
#: apps/notifications/services.py:31
#, python-format
msgid "Unknown notification provider: %(provider)s"
msgstr "مزود الإشعارات غير معروف: %(provider)s"
#: apps/notifications/services.py:47
#, python-format
msgid ""
"Your booking request is received for %(service)s at %(salon)s on %(start)s."
msgstr "تم استلام طلب حجزك لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
#: apps/notifications/services.py:55
#, python-format
msgid "Your booking is confirmed for %(service)s at %(salon)s on %(start)s."
msgstr "تم تأكيد حجزك لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
#: apps/notifications/services.py:63
#, python-format
msgid "Your booking was cancelled for %(service)s at %(salon)s on %(start)s."
msgstr "تم إلغاء حجزك لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
#: apps/notifications/services.py:70
#, python-format
msgid "Booking update for %(service)s at %(salon)s on %(start)s."
msgstr "تحديث الحجز لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
#: apps/notifications/services.py:85
msgid "Unsupported notification channel"
msgstr "قناة الإشعارات غير مدعومة"
#: apps/payments/serializers.py:49
msgid "Booking not found"
msgstr "الحجز غير موجود"
#: apps/payments/serializers.py:56 apps/payments/services/payments.py:79
msgid "Provider integration not implemented"
msgstr "تكامل المزود غير مُنفَّذ"
#: apps/payments/serializers.py:58
msgid "Payment source is required"
msgstr "مصدر الدفع مطلوب"
#: apps/payments/serializers.py:61
msgid "Payment source type is required"
msgstr "نوع مصدر الدفع مطلوب"
#: apps/payments/serializers.py:64
msgid "Card data must not be sent to the backend; use frontend tokenization"
msgstr "لا يجب إرسال بيانات البطاقة إلى الخادم؛ استخدم التحويل إلى رمز في الواجهة الأمامية"
#: apps/payments/serializers.py:67
msgid "Callback URL is required for token payments"
msgstr "رابط الاستجابة مطلوب لمدفوعات الرمز"
#: apps/payments/services/payments.py:84
msgid "Idempotency key already used"
msgstr "مفتاح الإيدمبوتنسي مستخدم بالفعل"
#: apps/payments/services/payments.py:89
msgid "Unsupported payment source type"
msgstr "نوع مصدر الدفع غير مدعوم"
#: apps/payments/services/payments.py:130
#: apps/payments/services/payments.py:141
msgid "Payment provider error"
msgstr "خطأ في مزود الدفع"
#: apps/payments/views.py:50
msgid "Not allowed"
msgstr "غير مسموح"
#: apps/payments/views.py:70
msgid "Webhook secret not configured"
msgstr "لم يتم تكوين رمز الـ webhook"
#: apps/payments/views.py:73
msgid "Invalid webhook signature"
msgstr "توقيع الـ webhook غير صالح"
#: apps/payments/views.py:79
msgid "Missing payment reference"
msgstr "مرجع الدفع مفقود"
#: apps/payments/views.py:84
msgid "Payment not found"
msgstr "لم يتم العثور على الدفعة"
#: apps/payments/views.py:88
msgid "Event ignored"
msgstr "تم تجاهل الحدث"
#: apps/payments/views.py:89
msgid "Webhook processed"
msgstr "تمت معالجة الـ webhook"
+3 -1
View File
@@ -1,4 +1,6 @@
[pytest]
DJANGO_SETTINGS_MODULE = salon_api.settings
python_files = tests.py test_*.py *_tests.py
addopts = -q
addopts = -q -m "not external"
markers =
external: hits real third-party services (requires explicit env to run)
+10 -4
View File
@@ -1,4 +1,5 @@
import os
import sys
from pathlib import Path
from datetime import timedelta
from urllib.parse import urlparse
@@ -78,11 +79,14 @@ def parse_database_url(database_url: str):
}
DATABASE_URL = os.getenv("DATABASE_URL")
if DATABASE_URL:
parsed_db = parse_database_url(DATABASE_URL)
running_tests = "PYTEST_CURRENT_TEST" in os.environ or any("pytest" in arg for arg in sys.argv)
test_database_url = os.getenv("TEST_DATABASE_URL")
database_url = os.getenv("DATABASE_URL")
if running_tests:
parsed_db = parse_database_url(test_database_url) if test_database_url else None
else:
parsed_db = None
parsed_db = parse_database_url(database_url) if database_url else None
DATABASES = {
"default": parsed_db
@@ -136,6 +140,8 @@ CORS_ALLOWED_ORIGINS = [
]
OTP_PROVIDER = os.getenv("OTP_PROVIDER", "console")
if running_tests:
OTP_PROVIDER = os.getenv("TEST_OTP_PROVIDER", "console")
OTP_EXPIRY_MINUTES = int(os.getenv("OTP_EXPIRY_MINUTES", "5"))
OTP_MAX_PER_WINDOW = int(os.getenv("OTP_MAX_PER_WINDOW", "5"))
OTP_WINDOW_MINUTES = int(os.getenv("OTP_WINDOW_MINUTES", "15"))
View File
+34 -9
View File
@@ -1,11 +1,36 @@
# Docs Notes (MVP Alignment)
# Documentation Index
## High-Level Takeaways
- The MVP roadmap aligns with Phase 1 goals but needs tighter documentation around provider readiness and async strategy.
- ExecPlan references drift between `AGENTS.md` and `PLANS.md` should be resolved to avoid conflicting guidance.
- Observability and operational visibility are thin; errors are stored but not surfaced through clear runbooks/dashboards.
This directory is the source of truth for product, engineering, and ops documentation. Keep it current as features change.
## Near-Term Focus
- Make ExecPlan references consistent and keep active plans clearly labeled.
- Document whether MVP uses async jobs (and which system) or remains synchronous with strict timeouts.
- Keep `docs/risks.md` current as gaps are closed.
## Start Here
- Project overview and setup: `README.md` (repo root)
- Architecture overview: `docs/architecture.md`
- Active ExecPlan: `docs/execplans/booking-notifications.md`
- Known risks and gaps: `docs/risks.md`
## Documentation Standards
See `docs/documentation.md` for documentation goals, update triggers, and templates.
## Docs Map
- `docs/architecture.md`: System architecture, boundaries, and MVP async/observability decision.
- `docs/adr/`: Architecture Decision Records (ADRs). New cross-cutting decisions must land here.
- `docs/execplans/`: Execution plans for significant features or refactors.
- `docs/runbooks/`: Operational runbooks and production checklists.
- `docs/risks.md`: Tracked risks and gaps.
- `docs/templates/`: Reusable templates (ADR, runbook).
## Update Triggers (Quick Reference)
- New external dependency, provider, or major flow: add an ADR in `docs/adr/`.
- Change to booking/payment/auth logic: update `docs/architecture.md` and relevant runbook(s).
- New operational procedure: add a runbook in `docs/runbooks/`.
- Close or add a significant risk: update `docs/risks.md`.
## Ownership And Review
- Authors own freshness: if you touch an area, update the docs in the same PR.
- New production flows require at least one runbook.
- Avoid duplicating instructions; link to the single source of truth.
@@ -0,0 +1,33 @@
# ADR 0001: Synchronous External Calls For MVP
## Status
Accepted
## Context
The MVP relies on OTP delivery, booking notifications, and payment gateway calls. Introducing a task queue (Celery/RQ) would add infrastructure (Redis, workers, retries) and operational complexity that is not required for the early launch.
## Decision
For the MVP, OTP sends, booking notifications, and payment gateway calls run synchronously in the request/response path with strict timeouts. A task queue will be revisited when traffic grows or operational needs change.
## Consequences
- Faster initial delivery with fewer moving parts.
- Increased latency risk on endpoints that call external providers.
- Payment and OTP failures are surfaced to clients immediately (correct behaviour — clients need to know).
- Notification failures are absorbed: `notifications/services.py` catches provider errors, stores them as `FAILED` status, and never surfaces them to the client. A failed booking SMS does not cause the booking request to fail. This means notification failures require active monitoring rather than appearing in client-facing error rates.
## Alternatives Considered
- Celery + Redis for all external calls: rejected for MVP due to infra overhead.
- Hybrid async for notifications only: not wrong in principle, deferred for operational simplicity. The three call types have genuinely different semantics:
- **Payment creation**: synchronous by design — the client needs the Moyasar redirect URL before the response returns.
- **OTP sends**: synchronous by design — users expect immediate confirmation that the code was sent.
- **Booking notifications**: fire-and-forget by nature — the booking is already committed and the client does not wait for delivery confirmation.
When notification latency becomes a problem (e.g. under load or with slow SMS providers), only notifications need to move off the request path. Payments and OTP sends should remain synchronous regardless.
## Related
- `docs/architecture.md`
+30
View File
@@ -0,0 +1,30 @@
# ADR 0002: Moyasar As The Payment Gateway
## Status
Accepted
## Context
The platform needs a payment gateway that supports Saudi Arabia, SAR currency defaults, and local payment methods (e.g. STC Pay, Apple Pay, Samsung Pay). The backend already implements a `MoyasarGateway` integration and models `payments.Payment` with a `moyasar` provider option.
## Decision
Use Moyasar as the payment gateway for the MVP. Payment creation, capture, refund, and webhook reconciliation are implemented through `apps.payments.services.gateway.MoyasarGateway`.
## Consequences
- Supports KSA-focused payment methods and SAR by default.
- Operational dependency on Moyasar uptime and API stability.
- Payment flows and webhooks are tied to the Moyasar API surface until a gateway abstraction is expanded.
## Alternatives Considered
- Other regional gateways: deferred until the MVP is validated.
- Stripe or similar global providers: not selected for MVP due to KSA-specific coverage priorities.
## Related
- `backend/apps/payments/services/gateway.py`
- `docs/runbooks/payments_sanity_check.md`
- `docs/architecture.md`
+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 |
|-----|----------------|
| **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. |
### Backend (Django, DRF)
## 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.
```
User → React Frontend → Django API
accounts (auth) ──→ OTP providers (Twilio/Unifonic/console)
salons (catalog)
bookings ──→ notifications ──→ OTP providers
payments ──→ Moyasar gateway
### 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**
- 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:**
- 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).
This diagram clarifies current coupling and highlights where future refactors (e.g., a dedicated messaging service or payment orchestrator) could sit.
**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
- Phone normalization is KSA-focused and minimal; broaden for multi-country use.
- OTP protections are basic; add device fingerprinting and IP throttling if needed.
- Twilio OTP provider is implemented (SMS + WhatsApp); Unifonic remains a scaffold.
- Authentica OTP provider is implemented (SMS + WhatsApp via Authentica OTP).
- Social login is a placeholder.
- USERNAME_FIELD is still `email` while email can be null; verify admin/login flows.
- `USERNAME_FIELD` is now `"phone_number"`; `REQUIRED_FIELDS = []`; `create_superuser` accepts `phone_number`. Admin and `createsuperuser` work correctly for phone-only users.
## Booking Integrity
- Availability checks and overlap prevention are now enforced for staff bookings.
- **Race condition — fixed:** `BookingCreateSerializer.create()` now locks the staff row with `select_for_update()` inside `transaction.atomic()` and re-runs the overlap check before inserting. Concurrent requests for the same staff slot are serialized at the DB level. Requires PostgreSQL in production (SQLite ignores `FOR UPDATE` but still serializes writes).
- No timezone handling or business hours enforcement.
- No cancellation rules or refund logic.
@@ -21,7 +22,7 @@ This file tracks known gaps and risks to address in future iterations.
## Data And UX
- Ratings are not recalculated from reviews.
- No image upload or storage strategy for photos.
- Booking lifecycle notifications are implemented; Twilio delivers SMS/WhatsApp when OTP_PROVIDER=twilio.
- Booking lifecycle notifications are implemented; Authentica can deliver SMS when NOTIFICATION_PROVIDER=authentica.
- Localization foundations are in progress; full Arabic translation coverage and RTL QA are still pending.
## Ops And Compliance
+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 { useTranslation } from "react-i18next";
import { useAuth } from "../contexts/AuthContext";
export default function ProtectedRoute({ children }) {
const { t } = useTranslation();
const { isAuthenticated, loading } = useAuth();
const location = useLocation();
if (loading) {
return (
<div className="auth-loading">
<p>Loading...</p>
<p>{t("common.loading")}</p>
</div>
);
}
+4 -1
View File
@@ -16,11 +16,14 @@
"phoneUnavailable": "الهاتف غير متوفر",
"viewDetails": "عرض التفاصيل والحجز"
},
"nav":{"home":"الرئيسية","book":"احجز","pay":"ادفع","profile":"الحساب","bookings":"حجوزاتي","login":"تسجيل الدخول","logout":"تسجيل الخروج"},"book":{"title":"احجزي خدمة","placeholder":"تدفق الحجز قريباً.","cta":"احجزي الآن","selectSalon":"اختاري صالوناً من الصفحة الرئيسية للحجز.","service":"الخدمة","staff":"الفريق","date":"التاريخ","time":"الوقت","notes":"ملاحظات","notesPlaceholder":"ملاحظات اختيارية","selectService":"اختاري الخدمة","selectStaff":"اختاري الفني","submit":"تأكيد الحجز","submitting":"جاري الحجز...","errors":{"fillAll":"يرجى تعبئة جميع الحقول.","invalidTime":"تاريخ أو وقت غير صالح.","generic":"فشل الحجز."}},"salon":{"services":"الخدمات","staff":"الفريق"},"profile":{"title":"الحساب","placeholder":"الملف والحجوزات قريباً.","myBookings":"حجوزاتي","noContact":"لا توجد معلومات اتصال"},"bookings":{"title":"حجوزاتي","subtitle":"مواعيدك القادمة والسابقة.","empty":"لا توجد حجوزات بعد.","pay":"ادفع الآن"},"paymentReturn":{"title":"حالة الدفع","success":"تم الدفع بنجاح","successMessage":"تم إتمام الدفع. ستستلمين تأكيداً عبر SMS أو واتساب.","checkStatus":"تحققي من بريدك أو الصالون لحالة الدفع.","reference":"المرجع: {{id}}","viewBookings":"عرض حجوزاتي"},"auth":{"title":"تسجيل الدخول","subtitle":"أدخلي رقم جوالك لاستلام رمز التحقق.","phone":"رقم الجوال","channel":"الإرسال عبر","sms":"رسالة نصية","whatsapp":"واتساب","sendCode":"إرسال الرمز","sending":"جاري الإرسال...","verifyTitle":"أدخلي الرمز","verifySubtitle":"أرسلنا رمزاً إلى {{phone}}","code":"رمز التحقق","verify":"تحقق","verifying":"جاري التحقق...","back":"تغيير الرقم","errors":{"generic":"حدث خطأ.","retryAfter":"انتظري {{seconds}} ثانية قبل المحاولة مرة أخرى."}},"locale": {
"nav":{"home":"الرئيسية","book":"احجز","pay":"ادفع","profile":"الحساب","bookings":"حجوزاتي","login":"تسجيل الدخول","logout":"تسجيل الخروج"},"book":{"title":"احجزي خدمة","placeholder":"تدفق الحجز قريباً.","cta":"احجزي الآن","selectSalon":"اختاري صالوناً من الصفحة الرئيسية للحجز.","service":"الخدمة","staff":"الفريق","date":"التاريخ","time":"الوقت","notes":"ملاحظات","notesPlaceholder":"ملاحظات اختيارية","selectService":"اختاري الخدمة","selectStaff":"اختاري الفني","submit":"تأكيد الحجز","submitting":"جاري الحجز...","errors":{"fillAll":"يرجى تعبئة جميع الحقول.","invalidTime":"تاريخ أو وقت غير صالح.","generic":"فشل الحجز."}},"salon":{"services":"الخدمات","staff":"الفريق","unknownStaff":"موظف {{id}}"},"profile":{"title":"الحساب","placeholder":"الملف والحجوزات قريباً.","myBookings":"حجوزاتي","noContact":"لا توجد معلومات اتصال"},"bookings":{"title":"حجوزاتي","subtitle":"مواعيدك القادمة والسابقة.","empty":"لا توجد حجوزات بعد.","pay":"ادفع الآن"},"paymentReturn":{"title":"حالة الدفع","success":"تم الدفع بنجاح","successMessage":"تم إتمام الدفع. ستستلمين تأكيداً عبر SMS أو واتساب.","checkStatus":"تحققي من بريدك أو الصالون لحالة الدفع.","reference":"المرجع: {{id}}","viewBookings":"عرض حجوزاتي"},"auth":{"title":"تسجيل الدخول","subtitle":"أدخلي رقم جوالك لاستلام رمز التحقق.","phone":"رقم الجوال","channel":"الإرسال عبر","sms":"رسالة نصية","whatsapp":"واتساب","sendCode":"إرسال الرمز","sending":"جاري الإرسال...","verifyTitle":"أدخلي الرمز","verifySubtitle":"أرسلنا رمزاً إلى {{phone}}","code":"رمز التحقق","verify":"تحقق","verifying":"جاري التحقق...","back":"تغيير الرقم","errors":{"generic":"حدث خطأ.","retryAfter":"انتظري {{seconds}} ثانية قبل المحاولة مرة أخرى."}},"locale": {
"label": "اللغة",
"arabic": "العربية",
"english": "الإنجليزية"
},
"common": {
"loading": "جاري التحميل..."
},
"payment": {
"title": "المدفوعات (تجريبي)",
"subtitle": "إرسال عملية دفع عبر Moyasar لحجز موجود.",
+4 -1
View File
@@ -16,11 +16,14 @@
"phoneUnavailable": "Phone unavailable",
"viewDetails": "View details & book"
},
"nav":{"home":"Home","book":"Book","pay":"Pay","profile":"Profile","bookings":"My bookings","login":"Sign in","logout":"Sign out"},"book":{"title":"Book a Service","placeholder":"Booking flow coming soon.","cta":"Book now","selectSalon":"Select a salon from the home page to book.","service":"Service","staff":"Staff","date":"Date","time":"Time","notes":"Notes","notesPlaceholder":"Optional notes","selectService":"Select service","selectStaff":"Select staff","submit":"Confirm booking","submitting":"Booking...","errors":{"fillAll":"Please fill all required fields.","invalidTime":"Invalid date or time.","generic":"Booking failed."}},"salon":{"services":"Services","staff":"Staff"},"profile":{"title":"Profile","placeholder":"Profile and bookings coming soon.","myBookings":"My bookings","noContact":"No contact info"},"bookings":{"title":"My bookings","subtitle":"Your upcoming and past appointments.","empty":"No bookings yet.","pay":"Pay now"},"paymentReturn":{"title":"Payment status","success":"Payment successful","successMessage":"Your payment was completed. You will receive a confirmation by SMS or WhatsApp.","checkStatus":"Check your email or the salon for payment status.","reference":"Reference: {{id}}","viewBookings":"View my bookings"},"auth":{"title":"Sign in","subtitle":"Enter your phone number to receive a verification code.","phone":"Phone number","channel":"Send via","sms":"SMS","whatsapp":"WhatsApp","sendCode":"Send code","sending":"Sending...","verifyTitle":"Enter code","verifySubtitle":"We sent a code to {{phone}}","code":"Verification code","verify":"Verify","verifying":"Verifying...","back":"Change number","errors":{"generic":"Something went wrong.","retryAfter":"Please wait {{seconds}} seconds before trying again."}},"locale": {
"nav":{"home":"Home","book":"Book","pay":"Pay","profile":"Profile","bookings":"My bookings","login":"Sign in","logout":"Sign out"},"book":{"title":"Book a Service","placeholder":"Booking flow coming soon.","cta":"Book now","selectSalon":"Select a salon from the home page to book.","service":"Service","staff":"Staff","date":"Date","time":"Time","notes":"Notes","notesPlaceholder":"Optional notes","selectService":"Select service","selectStaff":"Select staff","submit":"Confirm booking","submitting":"Booking...","errors":{"fillAll":"Please fill all required fields.","invalidTime":"Invalid date or time.","generic":"Booking failed."}},"salon":{"services":"Services","staff":"Staff","unknownStaff":"Staff {{id}}"},"profile":{"title":"Profile","placeholder":"Profile and bookings coming soon.","myBookings":"My bookings","noContact":"No contact info"},"bookings":{"title":"My bookings","subtitle":"Your upcoming and past appointments.","empty":"No bookings yet.","pay":"Pay now"},"paymentReturn":{"title":"Payment status","success":"Payment successful","successMessage":"Your payment was completed. You will receive a confirmation by SMS or WhatsApp.","checkStatus":"Check your email or the salon for payment status.","reference":"Reference: {{id}}","viewBookings":"View my bookings"},"auth":{"title":"Sign in","subtitle":"Enter your phone number to receive a verification code.","phone":"Phone number","channel":"Send via","sms":"SMS","whatsapp":"WhatsApp","sendCode":"Send code","sending":"Sending...","verifyTitle":"Enter code","verifySubtitle":"We sent a code to {{phone}}","code":"Verification code","verify":"Verify","verifying":"Verifying...","back":"Change number","errors":{"generic":"Something went wrong.","retryAfter":"Please wait {{seconds}} seconds before trying again."}},"locale": {
"label": "Language",
"arabic": "العربية",
"english": "English"
},
"common": {
"loading": "Loading..."
},
"payment": {
"title": "Payment (Beta)",
"subtitle": "Send a Moyasar payment for an existing booking.",
+1 -1
View File
@@ -118,7 +118,7 @@ export default function BookPage() {
<option value="">{t("book.selectStaff")}</option>
{salon.staff?.map((s) => (
<option key={s.id} value={s.id}>
{s.name || s.title || `Staff ${s.id}`}
{s.name || s.title || t("salon.unknownStaff", { id: s.id })}
</option>
))}
</select>
+4 -3
View File
@@ -4,11 +4,12 @@ import { useTranslation } from "react-i18next";
import { apiGet } from "../api/client";
import { useAuth } from "../contexts/AuthContext";
import ProtectedRoute from "../components/ProtectedRoute";
import { getActiveLocale } from "../i18n/index";
function formatDateTime(iso) {
function formatDateTime(iso, locale) {
if (!iso) return "";
const d = new Date(iso);
return d.toLocaleString(undefined, {
return d.toLocaleString(locale, {
dateStyle: "medium",
timeStyle: "short",
});
@@ -51,7 +52,7 @@ export default function BookingsPage() {
</div>
<p className="booking-service">{b.service_name}</p>
<p className="booking-time">
{formatDateTime(b.start_time)} {formatDateTime(b.end_time)}
{formatDateTime(b.start_time, getActiveLocale())} {formatDateTime(b.end_time, getActiveLocale())}
</p>
<p className="booking-price">
{b.price_amount} {b.currency}
+1 -1
View File
@@ -43,7 +43,7 @@ export default function SalonDetailPage() {
<h2>{t("salon.staff")}</h2>
<ul className="staff-list">
{salon.staff?.map((s) => (
<li key={s.id}>{s.name || s.title || `Staff ${s.id}`}</li>
<li key={s.id}>{s.name || s.title || t("salon.unknownStaff", { id: s.id })}</li>
))}
</ul>