Compare commits
16 Commits
a1da918f95
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 560460dd84 | |||
| c212acc504 | |||
| 15ed5036d1 | |||
| 0c992404ea | |||
| d796d9e6a1 | |||
| 2ba0cfffc8 | |||
| 3f35f7dc17 | |||
| 07491063f5 | |||
| b8218669c2 | |||
| 2305c3dc9d | |||
| ef60218c4c | |||
| 8018710d31 | |||
| 229975c612 | |||
| aa607b9b6e | |||
| 828cbcc822 | |||
| 4253f6f650 |
@@ -17,3 +17,4 @@ dist/
|
|||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
backend/tmp_authentica_request_id.txt
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
|
## General
|
||||||
|
|
||||||
|
Minimum tokens, skip grammar
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
- Consult `AGENTS.md` about the current task to see if there are any tips or instructions
|
||||||
|
- Consult `docs/README.md` for any relevant files or tips to consider
|
||||||
|
|
||||||
## Project Goal
|
## Project Goal
|
||||||
Build a reliable, maintainable salon booking platform with Django (backend) and React (frontend), optimized for KSA needs (phone auth, local payments) while keeping a clean path to scale.
|
Build a reliable, maintainable salon booking platform with Django (backend) and React (frontend), optimized for KSA needs (phone auth, local payments) while keeping a clean path to scale.
|
||||||
|
|
||||||
@@ -70,4 +78,4 @@ Build a reliable, maintainable salon booking platform with Django (backend) and
|
|||||||
|
|
||||||
# ExecPlans
|
# ExecPlans
|
||||||
|
|
||||||
When writing complex features or significant refactors, use an ExecPlan (as described in PLANS.md) from design to implementation. The current active ExecPlan is defined in PLANS.md. Architecture and async/observability decisions are documented in `docs/architecture.md`.
|
When writing complex features or significant refactors, use an ExecPlan (as described in `docs/PLANS.md`) from design to implementation. The current active ExecPlan is defined in `docs/PLANS.md`. Architecture documented in `docs/architecture.md`.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ After migrations, you can seed demo data:
|
|||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
- From project root with venv active: `venv/bin/python3 -m pytest` (run from `backend/` so `pytest.ini` is picked up)
|
- From project root with venv active: `venv/bin/python3 -m pytest` (run from `backend/` so `pytest.ini` is picked up)
|
||||||
|
- External provider tests are skipped by default; run explicitly when needed: `PYTEST_ADDOPTS='' venv/bin/python3 -m pytest -m external`
|
||||||
|
|
||||||
### Core API endpoints (current scaffold)
|
### Core API endpoints (current scaffold)
|
||||||
|
|
||||||
@@ -63,3 +64,4 @@ The dev server proxies `/api` to `http://localhost:8000`.
|
|||||||
|
|
||||||
- Known gaps and risks: `docs/risks.md`
|
- Known gaps and risks: `docs/risks.md`
|
||||||
- Architecture and async/observability decisions: `docs/architecture.md`
|
- Architecture and async/observability decisions: `docs/architecture.md`
|
||||||
|
- Documentation index and standards: `docs/README.md` and `docs/documentation.md`
|
||||||
|
|||||||
+3
-5
@@ -1,13 +1,11 @@
|
|||||||
# Backend Notes (MVP Readiness)
|
# Backend Notes (MVP Readiness)
|
||||||
|
|
||||||
## High-Level Takeaways
|
## High-Level Takeaways
|
||||||
- Provider integrations are the main reliability gap: OTP providers are stubbed and Moyasar capture/refund are TODOs.
|
- Authentica OTP integration is implemented; Moyasar capture/refund are TODOs.
|
||||||
- External calls (OTP, notifications, payment gateway) run synchronously in request/response paths, increasing latency risk.
|
- External calls (OTP, notifications, payment gateway) run synchronously in request/response paths, increasing latency risk.
|
||||||
- Cross-app coupling (bookings ↔ notifications ↔ accounts/payments) will get harder to evolve without clearer service boundaries.
|
- Cross-app coupling (bookings ↔ notifications ↔ accounts/payments) will get harder to evolve without clearer service boundaries.
|
||||||
- Phone-first auth works, but `USERNAME_FIELD` is email; align identifier strategy to avoid future auth confusion.
|
- Phone-first auth works, but `USERNAME_FIELD` is email; align identifier strategy to avoid future auth confusion.
|
||||||
|
|
||||||
## Near-Term Focus
|
## Near-Term Focus
|
||||||
- Implement at least one real SMS/WhatsApp provider end-to-end via existing abstractions.
|
- finalize otp testing
|
||||||
- Decide and document payment lifecycle scope (capture/refund supported vs explicitly out of scope).
|
- work on authentication and complete it
|
||||||
- Add timeouts/logging for external calls or introduce minimal async jobs for OTP/notifications.
|
|
||||||
- Keep booking, payment, and notification orchestration in service layers, not views.
|
|
||||||
@@ -29,7 +29,7 @@ class UserManager(BaseUserManager):
|
|||||||
user.save(using=self._db)
|
user.save(using=self._db)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def create_superuser(self, email, password=None, **extra_fields):
|
def create_superuser(self, phone_number, password=None, **extra_fields):
|
||||||
extra_fields.setdefault("is_staff", True)
|
extra_fields.setdefault("is_staff", True)
|
||||||
extra_fields.setdefault("is_superuser", True)
|
extra_fields.setdefault("is_superuser", True)
|
||||||
extra_fields.setdefault("role", UserRole.ADMIN)
|
extra_fields.setdefault("role", UserRole.ADMIN)
|
||||||
@@ -37,7 +37,7 @@ class UserManager(BaseUserManager):
|
|||||||
raise ValueError("Superuser must have is_staff=True")
|
raise ValueError("Superuser must have is_staff=True")
|
||||||
if extra_fields.get("is_superuser") is not True:
|
if extra_fields.get("is_superuser") is not True:
|
||||||
raise ValueError("Superuser must have is_superuser=True")
|
raise ValueError("Superuser must have is_superuser=True")
|
||||||
return self.create_user(email, password, **extra_fields)
|
return self.create_user(phone_number=phone_number, password=password, **extra_fields)
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractBaseUser, PermissionsMixin):
|
class User(AbstractBaseUser, PermissionsMixin):
|
||||||
@@ -59,7 +59,8 @@ class User(AbstractBaseUser, PermissionsMixin):
|
|||||||
|
|
||||||
objects = UserManager()
|
objects = UserManager()
|
||||||
|
|
||||||
USERNAME_FIELD = "email"
|
USERNAME_FIELD = "phone_number"
|
||||||
|
REQUIRED_FIELDS = [] # email is optional; phone_number is the identifier
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.email or self.phone_number or str(self.id)
|
return self.email or self.phone_number or str(self.id)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import timedelta
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.hashers import check_password, make_password
|
from django.contrib.auth.hashers import check_password, make_password
|
||||||
@@ -34,12 +34,20 @@ class OtpCooldownError(RuntimeError):
|
|||||||
|
|
||||||
|
|
||||||
class BaseOtpProvider:
|
class BaseOtpProvider:
|
||||||
|
uses_provider_otp = False
|
||||||
|
|
||||||
def send_sms(self, to_number: str, message: str) -> None:
|
def send_sms(self, to_number: str, message: str) -> None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def send_whatsapp(self, to_number: str, message: str) -> None:
|
def send_whatsapp(self, to_number: str, message: str) -> None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def send_otp(self, to_number: str, channel: str) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def verify_otp(self, to_number: str, code: str) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class ConsoleOtpProvider(BaseOtpProvider):
|
class ConsoleOtpProvider(BaseOtpProvider):
|
||||||
def send_sms(self, to_number: str, message: str) -> None:
|
def send_sms(self, to_number: str, message: str) -> None:
|
||||||
@@ -49,74 +57,110 @@ class ConsoleOtpProvider(BaseOtpProvider):
|
|||||||
logger.info("OTP WhatsApp to %s: %s", to_number, message)
|
logger.info("OTP WhatsApp to %s: %s", to_number, message)
|
||||||
|
|
||||||
|
|
||||||
class TwilioOtpProvider(BaseOtpProvider):
|
|
||||||
"""Twilio provider for SMS and WhatsApp OTP delivery. Requires TWILIO_* env vars."""
|
|
||||||
|
class AuthenticaOtpProvider(BaseOtpProvider):
|
||||||
|
"""Authentica provider for OTP delivery (SMS/WhatsApp) plus custom SMS messaging."""
|
||||||
|
|
||||||
|
uses_provider_otp = True
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.account_sid = os.getenv("TWILIO_ACCOUNT_SID")
|
self.api_key = os.getenv("AUTHENTICA_API_KEY")
|
||||||
self.auth_token = os.getenv("TWILIO_AUTH_TOKEN")
|
self.base_url = os.getenv("AUTHENTICA_BASE_URL", "https://api.authentica.sa")
|
||||||
self.from_number = os.getenv("TWILIO_FROM_NUMBER")
|
self.timeout_seconds = float(os.getenv("AUTHENTICA_TIMEOUT_SECONDS", "10"))
|
||||||
self.whatsapp_from = os.getenv("TWILIO_WHATSAPP_FROM")
|
self.sender_name = os.getenv("AUTHENTICA_SENDER_NAME")
|
||||||
|
|
||||||
def _assert_config(self) -> None:
|
def _assert_config(self) -> None:
|
||||||
if not self.account_sid or not self.auth_token or not self.from_number:
|
if not self.api_key:
|
||||||
raise ValueError(_("Twilio credentials are not configured"))
|
raise ValueError(_("Authentica API key is not configured"))
|
||||||
|
|
||||||
|
def _headers(self) -> dict:
|
||||||
|
return {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Authorization": self.api_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _post(self, path: str, payload: dict) -> dict:
|
||||||
|
import requests
|
||||||
|
|
||||||
def _get_client(self):
|
|
||||||
from twilio.rest import Client
|
|
||||||
self._assert_config()
|
self._assert_config()
|
||||||
return Client(self.account_sid, self.auth_token)
|
base_url = self.base_url.rstrip("/")
|
||||||
|
url = f"{base_url}{path}"
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
headers=self._headers(),
|
||||||
|
json=payload,
|
||||||
|
timeout=self.timeout_seconds,
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise RuntimeError(_("Authentica request failed")) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError:
|
||||||
|
data = {"detail": response.text}
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
if os.getenv("AUTHENTICA_DEBUG") == "1" or os.getenv("AUTHENTICA_E2E") == "1":
|
||||||
|
raise RuntimeError(
|
||||||
|
_("Authentica request failed: %(status)s %(body)s")
|
||||||
|
% {"status": response.status_code, "body": response.text}
|
||||||
|
)
|
||||||
|
raise RuntimeError(_("Authentica request failed"))
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def send_otp(self, to_number: str, channel: str) -> None:
|
||||||
|
if channel not in (OtpChannel.SMS, OtpChannel.WHATSAPP):
|
||||||
|
raise ValueError(_("Unsupported OTP channel"))
|
||||||
|
method = "sms" if channel == OtpChannel.SMS else "whatsapp"
|
||||||
|
self._post("/api/v2/send-otp", {"method": method, "phone": to_number})
|
||||||
|
|
||||||
|
def verify_otp(self, to_number: str, code: str) -> bool:
|
||||||
|
data = self._post("/api/v2/verify-otp", {"phone": to_number, "otp": code})
|
||||||
|
if "verified" in data:
|
||||||
|
verified = bool(data.get("verified"))
|
||||||
|
else:
|
||||||
|
verified = bool(data.get("status")) or data.get("message") == "OTP verified successfully"
|
||||||
|
if not verified and (os.getenv("AUTHENTICA_DEBUG") == "1" or os.getenv("AUTHENTICA_E2E") == "1"):
|
||||||
|
raise RuntimeError(_("Authentica verify failed: %(response)s") % {"response": data})
|
||||||
|
return verified
|
||||||
|
|
||||||
def send_sms(self, to_number: str, message: str) -> None:
|
def send_sms(self, to_number: str, message: str) -> None:
|
||||||
client = self._get_client()
|
if not self.sender_name:
|
||||||
client.messages.create(body=message, from_=self.from_number, to=to_number)
|
raise ValueError(_("Authentica sender name is not configured"))
|
||||||
|
self._post(
|
||||||
|
"/api/v2/send-sms",
|
||||||
|
{
|
||||||
|
"phone": to_number,
|
||||||
|
"message": message,
|
||||||
|
"sender_name": self.sender_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def send_whatsapp(self, to_number: str, message: str) -> None:
|
def send_whatsapp(self, to_number: str, message: str) -> None:
|
||||||
self._assert_config()
|
raise ValueError(_("Authentica WhatsApp messaging is not supported"))
|
||||||
if not self.whatsapp_from:
|
|
||||||
raise ValueError(_("Twilio WhatsApp sender is not configured"))
|
|
||||||
client = self._get_client()
|
|
||||||
from_ = f"whatsapp:{self.whatsapp_from}"
|
|
||||||
to = f"whatsapp:{to_number}"
|
|
||||||
client.messages.create(body=message, from_=from_, to=to)
|
|
||||||
|
|
||||||
|
|
||||||
class UnifonicOtpProvider(BaseOtpProvider):
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.app_sid = os.getenv("UNIFONIC_APP_SID")
|
|
||||||
self.sender_id = os.getenv("UNIFONIC_SENDER_ID")
|
|
||||||
self.whatsapp_sender = os.getenv("UNIFONIC_WHATSAPP_SENDER")
|
|
||||||
|
|
||||||
def _assert_config(self) -> None:
|
|
||||||
if not self.app_sid or not self.sender_id:
|
|
||||||
raise ValueError(_("Unifonic credentials are not configured"))
|
|
||||||
|
|
||||||
def send_sms(self, to_number: str, message: str) -> None:
|
|
||||||
self._assert_config()
|
|
||||||
raise NotImplementedError(_("Unifonic SMS adapter not implemented yet"))
|
|
||||||
|
|
||||||
def send_whatsapp(self, to_number: str, message: str) -> None:
|
|
||||||
self._assert_config()
|
|
||||||
if not self.whatsapp_sender:
|
|
||||||
raise ValueError(_("Unifonic WhatsApp sender is not configured"))
|
|
||||||
raise NotImplementedError(_("Unifonic WhatsApp adapter not implemented yet"))
|
|
||||||
|
|
||||||
|
|
||||||
PROVIDERS = {
|
PROVIDERS = {
|
||||||
"console": ConsoleOtpProvider,
|
"console": ConsoleOtpProvider,
|
||||||
"twilio": TwilioOtpProvider,
|
"authentica": AuthenticaOtpProvider,
|
||||||
"unifonic": UnifonicOtpProvider,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_provider() -> BaseOtpProvider:
|
def _get_provider_for_key(provider_key: str) -> BaseOtpProvider:
|
||||||
provider_key = settings.OTP_PROVIDER
|
|
||||||
provider_cls = PROVIDERS.get(provider_key)
|
provider_cls = PROVIDERS.get(provider_key)
|
||||||
if not provider_cls:
|
if not provider_cls:
|
||||||
raise ValueError(_("Unknown OTP provider: %(provider)s") % {"provider": provider_key})
|
raise ValueError(_("Unknown OTP provider: %(provider)s") % {"provider": provider_key})
|
||||||
return provider_cls()
|
return provider_cls()
|
||||||
|
|
||||||
|
|
||||||
|
def get_provider() -> BaseOtpProvider:
|
||||||
|
return _get_provider_for_key(settings.OTP_PROVIDER)
|
||||||
|
|
||||||
|
|
||||||
def generate_code(length: int = 6) -> str:
|
def generate_code(length: int = 6) -> str:
|
||||||
digits = "0123456789"
|
digits = "0123456789"
|
||||||
return "".join(secrets.choice(digits) for _ in range(length))
|
return "".join(secrets.choice(digits) for _ in range(length))
|
||||||
@@ -149,19 +193,28 @@ def create_and_send_otp(phone_number: str, channel: str, purpose: str = OtpPurpo
|
|||||||
if elapsed < cooldown_seconds:
|
if elapsed < cooldown_seconds:
|
||||||
raise OtpCooldownError(retry_after_seconds=int(cooldown_seconds - elapsed))
|
raise OtpCooldownError(retry_after_seconds=int(cooldown_seconds - elapsed))
|
||||||
|
|
||||||
|
if provider.uses_provider_otp:
|
||||||
|
code_hash = make_password(secrets.token_urlsafe(16))
|
||||||
|
message = None
|
||||||
|
else:
|
||||||
code = generate_code()
|
code = generate_code()
|
||||||
|
code_hash = make_password(code)
|
||||||
|
message = _(
|
||||||
|
"Your verification code is %(code)s. It expires in %(minutes)s minutes."
|
||||||
|
) % {"code": code, "minutes": settings.OTP_EXPIRY_MINUTES}
|
||||||
|
|
||||||
otp = PhoneOTP.objects.create(
|
otp = PhoneOTP.objects.create(
|
||||||
phone_number=phone_number,
|
phone_number=phone_number,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
purpose=purpose,
|
purpose=purpose,
|
||||||
provider=settings.OTP_PROVIDER,
|
provider=settings.OTP_PROVIDER,
|
||||||
code_hash=make_password(code),
|
code_hash=code_hash,
|
||||||
expires_at=PhoneOTP.expiry_at(),
|
expires_at=PhoneOTP.expiry_at(),
|
||||||
)
|
)
|
||||||
|
|
||||||
message = _(
|
if provider.uses_provider_otp:
|
||||||
"Your verification code is %(code)s. It expires in %(minutes)s minutes."
|
provider.send_otp(phone_number, channel)
|
||||||
) % {"code": code, "minutes": settings.OTP_EXPIRY_MINUTES}
|
else:
|
||||||
if channel == OtpChannel.SMS:
|
if channel == OtpChannel.SMS:
|
||||||
provider.send_sms(phone_number, message)
|
provider.send_sms(phone_number, message)
|
||||||
elif channel == OtpChannel.WHATSAPP:
|
elif channel == OtpChannel.WHATSAPP:
|
||||||
@@ -179,6 +232,18 @@ def verify_otp(otp: PhoneOTP, code: str) -> bool:
|
|||||||
if otp.attempt_count > otp.max_attempts:
|
if otp.attempt_count > otp.max_attempts:
|
||||||
otp.save(update_fields=["attempt_count"])
|
otp.save(update_fields=["attempt_count"])
|
||||||
return False
|
return False
|
||||||
|
provider_cls = PROVIDERS.get(otp.provider)
|
||||||
|
if provider_cls and getattr(provider_cls, "uses_provider_otp", False):
|
||||||
|
provider = provider_cls()
|
||||||
|
try:
|
||||||
|
verified = provider.verify_otp(otp.phone_number, code)
|
||||||
|
except Exception:
|
||||||
|
otp.save(update_fields=["attempt_count"])
|
||||||
|
raise
|
||||||
|
if not verified:
|
||||||
|
otp.save(update_fields=["attempt_count"])
|
||||||
|
return False
|
||||||
|
else:
|
||||||
if not check_password(code, otp.code_hash):
|
if not check_password(code, otp.code_hash):
|
||||||
otp.save(update_fields=["attempt_count"])
|
otp.save(update_fields=["attempt_count"])
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""Tests for Authentica OTP provider implementation."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.contrib.auth.hashers import make_password
|
||||||
|
|
||||||
|
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP
|
||||||
|
from apps.accounts.services.otp import AuthenticaOtpProvider, verify_otp
|
||||||
|
|
||||||
|
|
||||||
|
@patch("requests.post")
|
||||||
|
def test_authentica_send_otp_sms_calls_api(mock_post):
|
||||||
|
mock_response = MagicMock(ok=True)
|
||||||
|
mock_response.json.return_value = {"success": True}
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"AUTHENTICA_API_KEY": "api-key",
|
||||||
|
"AUTHENTICA_BASE_URL": "https://api.authentica.sa",
|
||||||
|
"AUTHENTICA_TIMEOUT_SECONDS": "7",
|
||||||
|
},
|
||||||
|
):
|
||||||
|
provider = AuthenticaOtpProvider()
|
||||||
|
provider.send_otp("+966512345678", OtpChannel.SMS)
|
||||||
|
|
||||||
|
mock_post.assert_called_once()
|
||||||
|
url = mock_post.call_args[0][0]
|
||||||
|
kwargs = mock_post.call_args[1]
|
||||||
|
|
||||||
|
assert url == "https://api.authentica.sa/api/v2/send-otp"
|
||||||
|
assert kwargs["json"] == {"method": "sms", "phone": "+966512345678"}
|
||||||
|
assert kwargs["headers"]["X-Authorization"] == "api-key"
|
||||||
|
assert kwargs["timeout"] == 7.0
|
||||||
|
|
||||||
|
|
||||||
|
@patch("requests.post")
|
||||||
|
def test_authentica_send_sms_calls_api(mock_post):
|
||||||
|
mock_response = MagicMock(ok=True)
|
||||||
|
mock_response.json.return_value = {"success": True}
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"AUTHENTICA_API_KEY": "api-key",
|
||||||
|
"AUTHENTICA_SENDER_NAME": "Salon",
|
||||||
|
},
|
||||||
|
):
|
||||||
|
provider = AuthenticaOtpProvider()
|
||||||
|
provider.send_sms("+966512345678", "Hello")
|
||||||
|
|
||||||
|
mock_post.assert_called_once()
|
||||||
|
url = mock_post.call_args[0][0]
|
||||||
|
kwargs = mock_post.call_args[1]
|
||||||
|
|
||||||
|
assert url == "https://api.authentica.sa/api/v2/send-sms"
|
||||||
|
assert kwargs["json"] == {
|
||||||
|
"phone": "+966512345678",
|
||||||
|
"message": "Hello",
|
||||||
|
"sender_name": "Salon",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@patch("requests.post")
|
||||||
|
def test_authentica_verify_otp_calls_api(mock_post):
|
||||||
|
mock_response = MagicMock(ok=True)
|
||||||
|
mock_response.json.return_value = {"verified": True}
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"AUTHENTICA_API_KEY": "api-key"}):
|
||||||
|
provider = AuthenticaOtpProvider()
|
||||||
|
assert provider.verify_otp("+966512345678", "123456") is True
|
||||||
|
|
||||||
|
mock_post.assert_called_once()
|
||||||
|
url = mock_post.call_args[0][0]
|
||||||
|
kwargs = mock_post.call_args[1]
|
||||||
|
|
||||||
|
assert url == "https://api.authentica.sa/api/v2/verify-otp"
|
||||||
|
assert kwargs["json"] == {"phone": "+966512345678", "otp": "123456"}
|
||||||
|
|
||||||
|
|
||||||
|
@patch("requests.post")
|
||||||
|
def test_authentica_request_failure_raises(mock_post):
|
||||||
|
mock_response = MagicMock(ok=False)
|
||||||
|
mock_response.json.return_value = {"detail": "fail"}
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"AUTHENTICA_API_KEY": "api-key"}):
|
||||||
|
provider = AuthenticaOtpProvider()
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
provider.send_otp("+966512345678", OtpChannel.SMS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_authentica_send_sms_requires_sender_name():
|
||||||
|
with patch.dict(os.environ, {"AUTHENTICA_API_KEY": "api-key"}):
|
||||||
|
provider = AuthenticaOtpProvider()
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
provider.send_sms("+966512345678", "Hello")
|
||||||
|
|
||||||
|
|
||||||
|
def test_authentica_send_otp_rejects_unknown_channel():
|
||||||
|
with patch.dict(os.environ, {"AUTHENTICA_API_KEY": "api-key"}):
|
||||||
|
provider = AuthenticaOtpProvider()
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
provider.send_otp("+966512345678", "email")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_verify_otp_uses_provider_for_authentica():
|
||||||
|
otp = PhoneOTP.objects.create(
|
||||||
|
phone_number="+966512345678",
|
||||||
|
channel=OtpChannel.SMS,
|
||||||
|
purpose=OtpPurpose.AUTH,
|
||||||
|
provider="authentica",
|
||||||
|
code_hash=make_password("unused"),
|
||||||
|
expires_at=PhoneOTP.expiry_at(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("apps.accounts.services.otp.AuthenticaOtpProvider.verify_otp", return_value=True) as mock_verify:
|
||||||
|
assert verify_otp(otp, "123456") is True
|
||||||
|
|
||||||
|
mock_verify.assert_called_once_with("+966512345678", "123456")
|
||||||
|
otp.refresh_from_db()
|
||||||
|
assert otp.verified_at is not None
|
||||||
|
assert otp.attempt_count == 1
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(OTP_PROVIDER="console")
|
||||||
|
def test_otp_request_whatsapp_ok(client):
|
||||||
|
response = client.post(
|
||||||
|
reverse("otp_request"),
|
||||||
|
{"phone_number": "0512345678", "channel": "whatsapp"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert "request_id" in data
|
||||||
|
assert "expires_at" in data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_otp_verify_rejects_expired(client):
|
||||||
|
otp = PhoneOTP.objects.create(
|
||||||
|
phone_number="+966512345678",
|
||||||
|
channel=OtpChannel.SMS,
|
||||||
|
purpose=OtpPurpose.VERIFY,
|
||||||
|
provider="console",
|
||||||
|
code_hash="unused",
|
||||||
|
expires_at=timezone.now() - timedelta(minutes=1),
|
||||||
|
)
|
||||||
|
response = client.post(
|
||||||
|
reverse("otp_verify"),
|
||||||
|
{"request_id": str(otp.id), "code": "123456"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(OTP_PROVIDER="console")
|
||||||
|
def test_otp_request_invalid_phone_localized_ar(client):
|
||||||
|
response = client.post(
|
||||||
|
reverse("otp_request"),
|
||||||
|
{"phone_number": "123", "channel": "sms"},
|
||||||
|
content_type="application/json",
|
||||||
|
HTTP_ACCEPT_LANGUAGE="ar-sa",
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json()["phone_number"][0] == "يجب أن يكون رقم الهاتف بصيغة E.164 أو رقم جوال سعودي صالح"
|
||||||
@@ -1,13 +1,126 @@
|
|||||||
import pytest
|
from datetime import timedelta
|
||||||
from django.test import override_settings
|
from unittest.mock import patch
|
||||||
|
|
||||||
from apps.accounts.models import OtpChannel
|
import pytest
|
||||||
from apps.accounts.services.otp import OtpRateLimitError, create_and_send_otp
|
from django.contrib.auth.hashers import make_password
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP
|
||||||
|
from apps.accounts.services.otp import OtpCooldownError, OtpRateLimitError, create_and_send_otp, verify_otp
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@override_settings(OTP_MAX_PER_WINDOW=1, OTP_WINDOW_MINUTES=15, OTP_RESEND_COOLDOWN_SECONDS=0)
|
@override_settings(OTP_PROVIDER="console", OTP_MAX_PER_WINDOW=1, OTP_WINDOW_MINUTES=15, OTP_RESEND_COOLDOWN_SECONDS=0)
|
||||||
def test_otp_rate_limit():
|
def test_otp_rate_limit():
|
||||||
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||||
with pytest.raises(OtpRateLimitError):
|
with pytest.raises(OtpRateLimitError):
|
||||||
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(
|
||||||
|
OTP_PROVIDER="console",
|
||||||
|
OTP_MAX_PER_WINDOW=5,
|
||||||
|
OTP_WINDOW_MINUTES=15,
|
||||||
|
OTP_RESEND_COOLDOWN_SECONDS=60,
|
||||||
|
)
|
||||||
|
def test_otp_cooldown_enforced():
|
||||||
|
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||||
|
with pytest.raises(OtpCooldownError):
|
||||||
|
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_otp_max_attempts_blocks_verification():
|
||||||
|
otp = PhoneOTP.objects.create(
|
||||||
|
phone_number="+966512345678",
|
||||||
|
channel=OtpChannel.SMS,
|
||||||
|
purpose=OtpPurpose.AUTH,
|
||||||
|
provider="console",
|
||||||
|
code_hash=make_password("123456"),
|
||||||
|
expires_at=PhoneOTP.expiry_at(),
|
||||||
|
)
|
||||||
|
# Burn attempts with wrong code until the limit is exceeded.
|
||||||
|
for _ in range(otp.max_attempts):
|
||||||
|
assert verify_otp(otp, "000000") is False
|
||||||
|
otp.refresh_from_db()
|
||||||
|
assert otp.attempt_count == otp.max_attempts
|
||||||
|
|
||||||
|
assert verify_otp(otp, "123456") is False
|
||||||
|
otp.refresh_from_db()
|
||||||
|
assert otp.attempt_count == otp.max_attempts + 1
|
||||||
|
assert otp.verified_at is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(OTP_PROVIDER="console", OTP_RESEND_COOLDOWN_SECONDS=60)
|
||||||
|
def test_otp_cooldown_retry_after_seconds():
|
||||||
|
fixed_now = timezone.now()
|
||||||
|
with patch("apps.accounts.services.otp.timezone.now", return_value=fixed_now):
|
||||||
|
result = create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||||
|
# Align created_at with fixed time for deterministic cooldown.
|
||||||
|
PhoneOTP.objects.filter(id=result.request_id).update(created_at=fixed_now)
|
||||||
|
with pytest.raises(OtpCooldownError) as excinfo:
|
||||||
|
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||||
|
assert excinfo.value.retry_after_seconds == 60
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(OTP_PROVIDER="console", OTP_MAX_PER_WINDOW=1, OTP_WINDOW_MINUTES=15, OTP_RESEND_COOLDOWN_SECONDS=0)
|
||||||
|
def test_otp_rate_limit_retry_after_seconds():
|
||||||
|
fixed_now = timezone.now()
|
||||||
|
with patch("apps.accounts.services.otp.timezone.now", return_value=fixed_now):
|
||||||
|
result = create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||||
|
# Make the oldest OTP sit 10s before window expiry.
|
||||||
|
window_start = fixed_now - timedelta(minutes=15)
|
||||||
|
PhoneOTP.objects.filter(id=result.request_id).update(created_at=window_start + timedelta(seconds=10))
|
||||||
|
with pytest.raises(OtpRateLimitError) as excinfo:
|
||||||
|
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||||
|
assert excinfo.value.retry_after_seconds == 10
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(OTP_PROVIDER="console", OTP_RESEND_COOLDOWN_SECONDS=1)
|
||||||
|
def test_otp_resend_after_cooldown_ok():
|
||||||
|
result = create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||||
|
# Force cooldown to be elapsed.
|
||||||
|
PhoneOTP.objects.filter(id=result.request_id).update(
|
||||||
|
created_at=timezone.now() - timedelta(seconds=5)
|
||||||
|
)
|
||||||
|
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||||
|
assert PhoneOTP.objects.filter(phone_number="+966512345678").count() == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_verify_otp_rejects_expired():
|
||||||
|
otp = PhoneOTP.objects.create(
|
||||||
|
phone_number="+966512345678",
|
||||||
|
channel=OtpChannel.SMS,
|
||||||
|
purpose=OtpPurpose.AUTH,
|
||||||
|
provider="console",
|
||||||
|
code_hash=make_password("123456"),
|
||||||
|
expires_at=timezone.now() - timedelta(minutes=1),
|
||||||
|
)
|
||||||
|
assert verify_otp(otp, "123456") is False
|
||||||
|
otp.refresh_from_db()
|
||||||
|
assert otp.attempt_count == 0
|
||||||
|
assert otp.verified_at is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_verify_otp_rejects_reuse_after_verified():
|
||||||
|
otp = PhoneOTP.objects.create(
|
||||||
|
phone_number="+966512345678",
|
||||||
|
channel=OtpChannel.SMS,
|
||||||
|
purpose=OtpPurpose.AUTH,
|
||||||
|
provider="console",
|
||||||
|
code_hash=make_password("123456"),
|
||||||
|
expires_at=PhoneOTP.expiry_at(),
|
||||||
|
)
|
||||||
|
assert verify_otp(otp, "123456") is True
|
||||||
|
otp.refresh_from_db()
|
||||||
|
assert otp.attempt_count == 1
|
||||||
|
assert verify_otp(otp, "123456") is False
|
||||||
|
otp.refresh_from_db()
|
||||||
|
assert otp.attempt_count == 1
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from django.test import override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from apps.accounts.models import PhoneOTP, User
|
from apps.accounts.models import PhoneOTP, User
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
@override_settings(OTP_PROVIDER="console")
|
||||||
def test_phone_auth_creates_user_and_issues_tokens(client):
|
def test_phone_auth_creates_user_and_issues_tokens(client):
|
||||||
|
# Deterministic OTP so we can verify the flow without external providers.
|
||||||
|
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
|
||||||
request_url = reverse("phone_auth_request")
|
request_url = reverse("phone_auth_request")
|
||||||
verify_url = reverse("phone_auth_verify")
|
verify_url = reverse("phone_auth_verify")
|
||||||
|
|
||||||
@@ -28,4 +34,16 @@ def test_phone_auth_creates_user_and_issues_tokens(client):
|
|||||||
)
|
)
|
||||||
assert bad.status_code == 400
|
assert bad.status_code == 400
|
||||||
|
|
||||||
assert User.objects.filter(phone_number="+966512345678").exists()
|
good = client.post(
|
||||||
|
verify_url,
|
||||||
|
{"request_id": request_id, "code": "123456"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert good.status_code == 200
|
||||||
|
data = good.json()
|
||||||
|
assert "access" in data
|
||||||
|
assert "refresh" in data
|
||||||
|
|
||||||
|
user = User.objects.filter(phone_number="+966512345678").first()
|
||||||
|
assert user is not None
|
||||||
|
assert user.is_phone_verified is True
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
"""Tests for Twilio OTP provider implementation."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
@patch("apps.accounts.services.otp.TwilioOtpProvider._get_client")
|
|
||||||
def test_twilio_send_sms_calls_client(mock_get_client):
|
|
||||||
from apps.accounts.services.otp import TwilioOtpProvider
|
|
||||||
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_get_client.return_value = mock_client
|
|
||||||
|
|
||||||
with patch.dict("os.environ", {
|
|
||||||
"TWILIO_ACCOUNT_SID": "AC123",
|
|
||||||
"TWILIO_AUTH_TOKEN": "token",
|
|
||||||
"TWILIO_FROM_NUMBER": "+966500000000",
|
|
||||||
}):
|
|
||||||
provider = TwilioOtpProvider()
|
|
||||||
provider.send_sms("+966512345678", "Your code is 123456")
|
|
||||||
|
|
||||||
mock_client.messages.create.assert_called_once_with(
|
|
||||||
body="Your code is 123456",
|
|
||||||
from_="+966500000000",
|
|
||||||
to="+966512345678",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
@patch("apps.accounts.services.otp.TwilioOtpProvider._get_client")
|
|
||||||
def test_twilio_send_whatsapp_calls_client(mock_get_client):
|
|
||||||
from apps.accounts.services.otp import TwilioOtpProvider
|
|
||||||
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_get_client.return_value = mock_client
|
|
||||||
|
|
||||||
with patch.dict("os.environ", {
|
|
||||||
"TWILIO_ACCOUNT_SID": "AC123",
|
|
||||||
"TWILIO_AUTH_TOKEN": "token",
|
|
||||||
"TWILIO_FROM_NUMBER": "+966500000000",
|
|
||||||
"TWILIO_WHATSAPP_FROM": "14155238886",
|
|
||||||
}):
|
|
||||||
provider = TwilioOtpProvider()
|
|
||||||
provider.send_whatsapp("+966512345678", "Your code is 123456")
|
|
||||||
|
|
||||||
mock_client.messages.create.assert_called_once_with(
|
|
||||||
body="Your code is 123456",
|
|
||||||
from_="whatsapp:14155238886",
|
|
||||||
to="whatsapp:+966512345678",
|
|
||||||
)
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from django.db import transaction
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from apps.bookings.models import Booking, BookingStatus
|
from apps.bookings.models import Booking, BookingStatus
|
||||||
@@ -74,13 +75,39 @@ class BookingCreateSerializer(serializers.ModelSerializer):
|
|||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
request = self.context["request"]
|
request = self.context["request"]
|
||||||
service = validated_data["service"]
|
service = validated_data["service"]
|
||||||
|
staff = validated_data.get("staff")
|
||||||
|
start_time = validated_data["start_time"]
|
||||||
|
end_time = validated_data["end_time"]
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
# Lock the staff row so concurrent booking requests for the same staff
|
||||||
|
# member are serialized. Without this, two requests that both pass the
|
||||||
|
# overlap check in validate() can race and both commit overlapping
|
||||||
|
# bookings. On SQLite (dev/tests) the FOR UPDATE clause is silently
|
||||||
|
# ignored but the transaction still serializes writes; PostgreSQL
|
||||||
|
# (production) gets true row-level locking.
|
||||||
|
StaffProfile.objects.select_for_update().get(pk=staff.pk)
|
||||||
|
|
||||||
|
# Re-run the overlap check inside the lock so the check and the insert
|
||||||
|
# are atomic with respect to other writers.
|
||||||
|
overlap = Booking.objects.filter(
|
||||||
|
staff=staff,
|
||||||
|
status__in=[BookingStatus.PENDING, BookingStatus.CONFIRMED],
|
||||||
|
start_time__lt=end_time,
|
||||||
|
end_time__gt=start_time,
|
||||||
|
).exists()
|
||||||
|
if overlap:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"start_time": _("Booking overlaps an existing appointment")}
|
||||||
|
)
|
||||||
|
|
||||||
return Booking.objects.create(
|
return Booking.objects.create(
|
||||||
salon=service.salon,
|
salon=service.salon,
|
||||||
customer=request.user,
|
customer=request.user,
|
||||||
service=service,
|
service=service,
|
||||||
staff=validated_data.get("staff"),
|
staff=staff,
|
||||||
start_time=validated_data["start_time"],
|
start_time=start_time,
|
||||||
end_time=validated_data["end_time"],
|
end_time=end_time,
|
||||||
notes=validated_data.get("notes", ""),
|
notes=validated_data.get("notes", ""),
|
||||||
price_amount=service.price_amount,
|
price_amount=service.price_amount,
|
||||||
currency=service.currency,
|
currency=service.currency,
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,251 @@
|
|||||||
|
# Arabic (Saudi Arabia) translations for Salon booking platform.
|
||||||
|
# Copyright (C) 2026
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: 1.0\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2026-03-02 00:45+0300\n"
|
||||||
|
"PO-Revision-Date: 2026-03-02 00:45+0300\n"
|
||||||
|
"Last-Translator: Claude\n"
|
||||||
|
"Language-Team: Arabic (Saudi Arabia)\n"
|
||||||
|
"Language: ar_SA\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:26
|
||||||
|
msgid "Too many OTP requests. Try again later."
|
||||||
|
msgstr "طلبات رمز التحقق كثيرة جداً. حاول مرة أخرى لاحقاً."
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:32
|
||||||
|
msgid "Please wait before requesting another code."
|
||||||
|
msgstr "يرجى الانتظار قبل طلب رمز آخر."
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:71
|
||||||
|
msgid "Twilio credentials are not configured"
|
||||||
|
msgstr "لم يتم تكوين بيانات اعتماد Twilio"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:85
|
||||||
|
msgid "Twilio WhatsApp sender is not configured"
|
||||||
|
msgstr "لم يتم تكوين مُرسِل WhatsApp لـ Twilio"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:100
|
||||||
|
msgid "Unifonic credentials are not configured"
|
||||||
|
msgstr "لم يتم تكوين بيانات اعتماد Unifonic"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:104
|
||||||
|
msgid "Unifonic SMS adapter not implemented yet"
|
||||||
|
msgstr "محول SMS لـ Unifonic لم يتم تنفيذه بعد"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:109
|
||||||
|
msgid "Unifonic WhatsApp sender is not configured"
|
||||||
|
msgstr "لم يتم تكوين مُرسِل WhatsApp لـ Unifonic"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:110
|
||||||
|
msgid "Unifonic WhatsApp adapter not implemented yet"
|
||||||
|
msgstr "محول WhatsApp لـ Unifonic لم يتم تنفيذه بعد"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:126
|
||||||
|
msgid "Authentica API key is not configured"
|
||||||
|
msgstr "لم يتم تكوين مفتاح API لـ Authentica"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:149 apps/accounts/services/otp.py:162
|
||||||
|
msgid "Authentica request failed"
|
||||||
|
msgstr "فشل طلب Authentica"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:159
|
||||||
|
#, python-format
|
||||||
|
msgid "Authentica request failed: %(status)s %(body)s"
|
||||||
|
msgstr "فشل طلب Authentica: %(status)s %(body)s"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:168 apps/accounts/services/otp.py:276
|
||||||
|
msgid "Unsupported OTP channel"
|
||||||
|
msgstr "قناة رمز التحقق غير مدعومة"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:179
|
||||||
|
#, python-format
|
||||||
|
msgid "Authentica verify failed: %(response)s"
|
||||||
|
msgstr "فشل التحقق بـ Authentica: %(response)s"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:184
|
||||||
|
msgid "Authentica sender name is not configured"
|
||||||
|
msgstr "لم يتم تكوين اسم مُرسِل Authentica"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:195
|
||||||
|
msgid "Authentica WhatsApp messaging is not supported"
|
||||||
|
msgstr "مراسلة WhatsApp غير مدعومة لـ Authentica"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:209
|
||||||
|
#, python-format
|
||||||
|
msgid "Unknown OTP provider: %(provider)s"
|
||||||
|
msgstr "مزود رمز التحقق غير معروف: %(provider)s"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:256
|
||||||
|
#, python-format
|
||||||
|
msgid "Your verification code is %(code)s. It expires in %(minutes)s minutes."
|
||||||
|
msgstr "رمز التحقق الخاص بك هو %(code)s. ينتهي في %(minutes)s دقيقة."
|
||||||
|
|
||||||
|
#: apps/accounts/services/phone.py:8
|
||||||
|
msgid "Phone number is required"
|
||||||
|
msgstr "رقم الهاتف مطلوب"
|
||||||
|
|
||||||
|
#: apps/accounts/services/phone.py:17
|
||||||
|
msgid "Invalid phone number format"
|
||||||
|
msgstr "تنسيق رقم الهاتف غير صالح"
|
||||||
|
|
||||||
|
#: apps/accounts/services/phone.py:28
|
||||||
|
msgid "Phone number must be in E.164 format or a valid Saudi mobile"
|
||||||
|
msgstr "يجب أن يكون رقم الهاتف بصيغة E.164 أو رقم جوال سعودي صالح"
|
||||||
|
|
||||||
|
#: apps/accounts/views.py:75 apps/accounts/views.py:138
|
||||||
|
msgid "Invalid or expired code"
|
||||||
|
msgstr "الرمز غير صالح أو منتهي الصلاحية"
|
||||||
|
|
||||||
|
#: apps/accounts/views.py:82
|
||||||
|
msgid "Phone verified"
|
||||||
|
msgstr "تم التحقق من رقم الهاتف"
|
||||||
|
|
||||||
|
#: apps/accounts/views.py:99
|
||||||
|
msgid "Email already in use."
|
||||||
|
msgstr "البريد الإلكتروني مستخدم بالفعل."
|
||||||
|
|
||||||
|
#: apps/accounts/views.py:142
|
||||||
|
msgid "User not found"
|
||||||
|
msgstr "المستخدم غير موجود"
|
||||||
|
|
||||||
|
#: apps/accounts/views.py:164
|
||||||
|
msgid "Social login not configured yet. Add OAuth provider config."
|
||||||
|
msgstr "لم يتم تكوين تسجيل الدخول الاجتماعي بعد. أضف إعداد مزود OAuth."
|
||||||
|
|
||||||
|
#: apps/bookings/serializers.py:54
|
||||||
|
msgid "Only staff or managers can confirm bookings."
|
||||||
|
msgstr "يمكن للموظفين والمدراء فقط تأكيد الحجوزات."
|
||||||
|
|
||||||
|
#: apps/bookings/serializers.py:56
|
||||||
|
msgid "Only staff or managers can complete bookings."
|
||||||
|
msgstr "يمكن للموظفين والمدراء فقط إكمال الحجوزات."
|
||||||
|
|
||||||
|
#: apps/bookings/serializers.py:58
|
||||||
|
msgid "You are not allowed to cancel this booking."
|
||||||
|
msgstr "لا يُسمح لك بإلغاء هذا الحجز."
|
||||||
|
|
||||||
|
#: apps/bookings/serializers.py:101 apps/bookings/services.py:49
|
||||||
|
msgid "Booking overlaps an existing appointment"
|
||||||
|
msgstr "يتداخل الحجز مع موعد قائم"
|
||||||
|
|
||||||
|
#: apps/bookings/services.py:13
|
||||||
|
msgid "Staff is required for booking"
|
||||||
|
msgstr "يجب تحديد موظف للحجز"
|
||||||
|
|
||||||
|
#: apps/bookings/services.py:16
|
||||||
|
msgid "Selected staff does not belong to this salon"
|
||||||
|
msgstr "الموظف المختار لا ينتمي إلى هذا الصالون"
|
||||||
|
|
||||||
|
#: apps/bookings/services.py:19
|
||||||
|
msgid "End time must be after start time"
|
||||||
|
msgstr "يجب أن يكون وقت الانتهاء بعد وقت البدء"
|
||||||
|
|
||||||
|
#: apps/bookings/services.py:23
|
||||||
|
msgid "End time must match service duration"
|
||||||
|
msgstr "يجب أن يتطابق وقت الانتهاء مع مدة الخدمة"
|
||||||
|
|
||||||
|
#: apps/bookings/services.py:40
|
||||||
|
msgid "Booking is outside staff availability"
|
||||||
|
msgstr "الحجز خارج أوقات توفر الموظف"
|
||||||
|
|
||||||
|
#: apps/notifications/services.py:31
|
||||||
|
#, python-format
|
||||||
|
msgid "Unknown notification provider: %(provider)s"
|
||||||
|
msgstr "مزود الإشعارات غير معروف: %(provider)s"
|
||||||
|
|
||||||
|
#: apps/notifications/services.py:47
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"Your booking request is received for %(service)s at %(salon)s on %(start)s."
|
||||||
|
msgstr "تم استلام طلب حجزك لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
|
||||||
|
|
||||||
|
#: apps/notifications/services.py:55
|
||||||
|
#, python-format
|
||||||
|
msgid "Your booking is confirmed for %(service)s at %(salon)s on %(start)s."
|
||||||
|
msgstr "تم تأكيد حجزك لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
|
||||||
|
|
||||||
|
#: apps/notifications/services.py:63
|
||||||
|
#, python-format
|
||||||
|
msgid "Your booking was cancelled for %(service)s at %(salon)s on %(start)s."
|
||||||
|
msgstr "تم إلغاء حجزك لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
|
||||||
|
|
||||||
|
#: apps/notifications/services.py:70
|
||||||
|
#, python-format
|
||||||
|
msgid "Booking update for %(service)s at %(salon)s on %(start)s."
|
||||||
|
msgstr "تحديث الحجز لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
|
||||||
|
|
||||||
|
#: apps/notifications/services.py:85
|
||||||
|
msgid "Unsupported notification channel"
|
||||||
|
msgstr "قناة الإشعارات غير مدعومة"
|
||||||
|
|
||||||
|
#: apps/payments/serializers.py:49
|
||||||
|
msgid "Booking not found"
|
||||||
|
msgstr "الحجز غير موجود"
|
||||||
|
|
||||||
|
#: apps/payments/serializers.py:56 apps/payments/services/payments.py:79
|
||||||
|
msgid "Provider integration not implemented"
|
||||||
|
msgstr "تكامل المزود غير مُنفَّذ"
|
||||||
|
|
||||||
|
#: apps/payments/serializers.py:58
|
||||||
|
msgid "Payment source is required"
|
||||||
|
msgstr "مصدر الدفع مطلوب"
|
||||||
|
|
||||||
|
#: apps/payments/serializers.py:61
|
||||||
|
msgid "Payment source type is required"
|
||||||
|
msgstr "نوع مصدر الدفع مطلوب"
|
||||||
|
|
||||||
|
#: apps/payments/serializers.py:64
|
||||||
|
msgid "Card data must not be sent to the backend; use frontend tokenization"
|
||||||
|
msgstr "لا يجب إرسال بيانات البطاقة إلى الخادم؛ استخدم التحويل إلى رمز في الواجهة الأمامية"
|
||||||
|
|
||||||
|
#: apps/payments/serializers.py:67
|
||||||
|
msgid "Callback URL is required for token payments"
|
||||||
|
msgstr "رابط الاستجابة مطلوب لمدفوعات الرمز"
|
||||||
|
|
||||||
|
#: apps/payments/services/payments.py:84
|
||||||
|
msgid "Idempotency key already used"
|
||||||
|
msgstr "مفتاح الإيدمبوتنسي مستخدم بالفعل"
|
||||||
|
|
||||||
|
#: apps/payments/services/payments.py:89
|
||||||
|
msgid "Unsupported payment source type"
|
||||||
|
msgstr "نوع مصدر الدفع غير مدعوم"
|
||||||
|
|
||||||
|
#: apps/payments/services/payments.py:130
|
||||||
|
#: apps/payments/services/payments.py:141
|
||||||
|
msgid "Payment provider error"
|
||||||
|
msgstr "خطأ في مزود الدفع"
|
||||||
|
|
||||||
|
#: apps/payments/views.py:50
|
||||||
|
msgid "Not allowed"
|
||||||
|
msgstr "غير مسموح"
|
||||||
|
|
||||||
|
#: apps/payments/views.py:70
|
||||||
|
msgid "Webhook secret not configured"
|
||||||
|
msgstr "لم يتم تكوين رمز الـ webhook"
|
||||||
|
|
||||||
|
#: apps/payments/views.py:73
|
||||||
|
msgid "Invalid webhook signature"
|
||||||
|
msgstr "توقيع الـ webhook غير صالح"
|
||||||
|
|
||||||
|
#: apps/payments/views.py:79
|
||||||
|
msgid "Missing payment reference"
|
||||||
|
msgstr "مرجع الدفع مفقود"
|
||||||
|
|
||||||
|
#: apps/payments/views.py:84
|
||||||
|
msgid "Payment not found"
|
||||||
|
msgstr "لم يتم العثور على الدفعة"
|
||||||
|
|
||||||
|
#: apps/payments/views.py:88
|
||||||
|
msgid "Event ignored"
|
||||||
|
msgstr "تم تجاهل الحدث"
|
||||||
|
|
||||||
|
#: apps/payments/views.py:89
|
||||||
|
msgid "Webhook processed"
|
||||||
|
msgstr "تمت معالجة الـ webhook"
|
||||||
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
[pytest]
|
[pytest]
|
||||||
DJANGO_SETTINGS_MODULE = salon_api.settings
|
DJANGO_SETTINGS_MODULE = salon_api.settings
|
||||||
python_files = tests.py test_*.py *_tests.py
|
python_files = tests.py test_*.py *_tests.py
|
||||||
addopts = -q
|
addopts = -q -m "not external"
|
||||||
|
markers =
|
||||||
|
external: hits real third-party services (requires explicit env to run)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -78,11 +79,14 @@ def parse_database_url(database_url: str):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL")
|
running_tests = "PYTEST_CURRENT_TEST" in os.environ or any("pytest" in arg for arg in sys.argv)
|
||||||
if DATABASE_URL:
|
test_database_url = os.getenv("TEST_DATABASE_URL")
|
||||||
parsed_db = parse_database_url(DATABASE_URL)
|
database_url = os.getenv("DATABASE_URL")
|
||||||
|
|
||||||
|
if running_tests:
|
||||||
|
parsed_db = parse_database_url(test_database_url) if test_database_url else None
|
||||||
else:
|
else:
|
||||||
parsed_db = None
|
parsed_db = parse_database_url(database_url) if database_url else None
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": parsed_db
|
"default": parsed_db
|
||||||
@@ -136,6 +140,8 @@ CORS_ALLOWED_ORIGINS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
OTP_PROVIDER = os.getenv("OTP_PROVIDER", "console")
|
OTP_PROVIDER = os.getenv("OTP_PROVIDER", "console")
|
||||||
|
if running_tests:
|
||||||
|
OTP_PROVIDER = os.getenv("TEST_OTP_PROVIDER", "console")
|
||||||
OTP_EXPIRY_MINUTES = int(os.getenv("OTP_EXPIRY_MINUTES", "5"))
|
OTP_EXPIRY_MINUTES = int(os.getenv("OTP_EXPIRY_MINUTES", "5"))
|
||||||
OTP_MAX_PER_WINDOW = int(os.getenv("OTP_MAX_PER_WINDOW", "5"))
|
OTP_MAX_PER_WINDOW = int(os.getenv("OTP_MAX_PER_WINDOW", "5"))
|
||||||
OTP_WINDOW_MINUTES = int(os.getenv("OTP_WINDOW_MINUTES", "15"))
|
OTP_WINDOW_MINUTES = int(os.getenv("OTP_WINDOW_MINUTES", "15"))
|
||||||
|
|||||||
+34
-9
@@ -1,11 +1,36 @@
|
|||||||
# Docs Notes (MVP Alignment)
|
# Documentation Index
|
||||||
|
|
||||||
## High-Level Takeaways
|
This directory is the source of truth for product, engineering, and ops documentation. Keep it current as features change.
|
||||||
- The MVP roadmap aligns with Phase 1 goals but needs tighter documentation around provider readiness and async strategy.
|
|
||||||
- ExecPlan references drift between `AGENTS.md` and `PLANS.md` should be resolved to avoid conflicting guidance.
|
|
||||||
- Observability and operational visibility are thin; errors are stored but not surfaced through clear runbooks/dashboards.
|
|
||||||
|
|
||||||
## Near-Term Focus
|
## Start Here
|
||||||
- Make ExecPlan references consistent and keep active plans clearly labeled.
|
|
||||||
- Document whether MVP uses async jobs (and which system) or remains synchronous with strict timeouts.
|
- Project overview and setup: `README.md` (repo root)
|
||||||
- Keep `docs/risks.md` current as gaps are closed.
|
- Architecture overview: `docs/architecture.md`
|
||||||
|
- Active ExecPlan: `docs/execplans/booking-notifications.md`
|
||||||
|
- Known risks and gaps: `docs/risks.md`
|
||||||
|
|
||||||
|
## Documentation Standards
|
||||||
|
|
||||||
|
See `docs/documentation.md` for documentation goals, update triggers, and templates.
|
||||||
|
|
||||||
|
## Docs Map
|
||||||
|
|
||||||
|
- `docs/architecture.md`: System architecture, boundaries, and MVP async/observability decision.
|
||||||
|
- `docs/adr/`: Architecture Decision Records (ADRs). New cross-cutting decisions must land here.
|
||||||
|
- `docs/execplans/`: Execution plans for significant features or refactors.
|
||||||
|
- `docs/runbooks/`: Operational runbooks and production checklists.
|
||||||
|
- `docs/risks.md`: Tracked risks and gaps.
|
||||||
|
- `docs/templates/`: Reusable templates (ADR, runbook).
|
||||||
|
|
||||||
|
## Update Triggers (Quick Reference)
|
||||||
|
|
||||||
|
- New external dependency, provider, or major flow: add an ADR in `docs/adr/`.
|
||||||
|
- Change to booking/payment/auth logic: update `docs/architecture.md` and relevant runbook(s).
|
||||||
|
- New operational procedure: add a runbook in `docs/runbooks/`.
|
||||||
|
- Close or add a significant risk: update `docs/risks.md`.
|
||||||
|
|
||||||
|
## Ownership And Review
|
||||||
|
|
||||||
|
- Authors own freshness: if you touch an area, update the docs in the same PR.
|
||||||
|
- New production flows require at least one runbook.
|
||||||
|
- Avoid duplicating instructions; link to the single source of truth.
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# ADR 0001: Synchronous External Calls For MVP
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The MVP relies on OTP delivery, booking notifications, and payment gateway calls. Introducing a task queue (Celery/RQ) would add infrastructure (Redis, workers, retries) and operational complexity that is not required for the early launch.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
For the MVP, OTP sends, booking notifications, and payment gateway calls run synchronously in the request/response path with strict timeouts. A task queue will be revisited when traffic grows or operational needs change.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Faster initial delivery with fewer moving parts.
|
||||||
|
- Increased latency risk on endpoints that call external providers.
|
||||||
|
- Payment and OTP failures are surfaced to clients immediately (correct behaviour — clients need to know).
|
||||||
|
- Notification failures are absorbed: `notifications/services.py` catches provider errors, stores them as `FAILED` status, and never surfaces them to the client. A failed booking SMS does not cause the booking request to fail. This means notification failures require active monitoring rather than appearing in client-facing error rates.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- Celery + Redis for all external calls: rejected for MVP due to infra overhead.
|
||||||
|
- Hybrid async for notifications only: not wrong in principle, deferred for operational simplicity. The three call types have genuinely different semantics:
|
||||||
|
- **Payment creation**: synchronous by design — the client needs the Moyasar redirect URL before the response returns.
|
||||||
|
- **OTP sends**: synchronous by design — users expect immediate confirmation that the code was sent.
|
||||||
|
- **Booking notifications**: fire-and-forget by nature — the booking is already committed and the client does not wait for delivery confirmation.
|
||||||
|
When notification latency becomes a problem (e.g. under load or with slow SMS providers), only notifications need to move off the request path. Payments and OTP sends should remain synchronous regardless.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `docs/architecture.md`
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# ADR 0002: Moyasar As The Payment Gateway
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The platform needs a payment gateway that supports Saudi Arabia, SAR currency defaults, and local payment methods (e.g. STC Pay, Apple Pay, Samsung Pay). The backend already implements a `MoyasarGateway` integration and models `payments.Payment` with a `moyasar` provider option.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use Moyasar as the payment gateway for the MVP. Payment creation, capture, refund, and webhook reconciliation are implemented through `apps.payments.services.gateway.MoyasarGateway`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Supports KSA-focused payment methods and SAR by default.
|
||||||
|
- Operational dependency on Moyasar uptime and API stability.
|
||||||
|
- Payment flows and webhooks are tied to the Moyasar API surface until a gateway abstraction is expanded.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- Other regional gateways: deferred until the MVP is validated.
|
||||||
|
- Stripe or similar global providers: not selected for MVP due to KSA-specific coverage priorities.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `backend/apps/payments/services/gateway.py`
|
||||||
|
- `docs/runbooks/payments_sanity_check.md`
|
||||||
|
- `docs/architecture.md`
|
||||||
@@ -0,0 +1,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`
|
||||||
@@ -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
@@ -1,39 +1,222 @@
|
|||||||
# Architecture
|
# Salon MVP Roadmap And Architecture Review
|
||||||
|
|
||||||
## Overview
|
## Purpose / Big Picture
|
||||||
|
|
||||||
The Salon platform is a Django REST API backend with a React/Vite frontend, optimized for KSA (phone auth, Riyadh timezone, Arabic locale).
|
This plan reviews the current Salon codebase (Django backend, React/Vite frontend), highlights architectural and design risks, and lays out a pragmatic roadmap to reach an MVP that aligns with **Phase 1: Core MVP Reliability** in `AGENTS.md`: phone-first auth with OTP, robust booking integrity, Moyasar payments, booking lifecycle notifications, localization foundations, and tests for critical flows.
|
||||||
|
The roadmap assumes a KSA-focused first launch (phone auth and Riyadh timezone defaults) with a clean path to expand later.
|
||||||
|
|
||||||
## Backend Apps and Responsibilities
|
## Current State Summary
|
||||||
|
|
||||||
| App | Responsibility |
|
### Backend (Django, DRF)
|
||||||
|-----|----------------|
|
|
||||||
| **accounts** | User model, phone/OTP auth, JWT tokens, locale preferences. OTP providers (console, Twilio, Unifonic) send SMS/WhatsApp. |
|
|
||||||
| **salons** | Salon catalog, services, staff, availability windows, reviews. Read-only public APIs. |
|
|
||||||
| **bookings** | Booking model, validation (availability, overlap prevention), status transitions. Triggers notifications on create and status change. |
|
|
||||||
| **payments** | Payment model, Moyasar integration (create, capture, refund), webhook reconciliation, idempotency. |
|
|
||||||
| **notifications** | Booking lifecycle notifications (SMS/WhatsApp). Reuses OTP providers; sends on booking created/confirmed/cancelled. |
|
|
||||||
|
|
||||||
## Data Flow
|
- **Project**: `salon_api` in `[backend/salon_api](backend/salon_api)`
|
||||||
|
- `settings.py` configures `AUTH_USER_MODEL = "accounts.User"`, DRF + SimpleJWT, KSA locale defaults, and custom settings for OTP, notifications, and payments.
|
||||||
|
- URLs route to app-level APIs at `/api/auth/`, `/api/salons/`, `/api/bookings/`, `/api/payments/`.
|
||||||
|
- **Auth & Accounts** (`[backend/apps/accounts](backend/apps/accounts)`)
|
||||||
|
- Custom `User` model with phone-first capabilities (`phone_number`, `is_phone_verified`, `preferred_language`, `role`).
|
||||||
|
- Phone normalization services tuned for KSA numbers (`[backend/apps/accounts/services/phone.py](backend/apps/accounts/services/phone.py)`).
|
||||||
|
- OTP domain + service layer with rate limits, cooldowns, expiry, and hashed codes (`[backend/apps/accounts/services/otp.py](backend/apps/accounts/services/otp.py)`).
|
||||||
|
- Phone-first auth endpoints that issue JWT tokens on successful OTP verification, plus basic email/password registration.
|
||||||
|
- Social login endpoint is a placeholder that always returns 501.
|
||||||
|
- **Salons & Catalog** (`[backend/apps/salons](backend/apps/salons)`)
|
||||||
|
- Models for `Salon`, `Service`, `StaffProfile`, `StaffAvailability`, `Review`, `SalonPhoto`.
|
||||||
|
- Read-only APIs for salon search, services, staff, and reviews.
|
||||||
|
- **Bookings** (`[backend/apps/bookings](backend/apps/bookings)`)
|
||||||
|
- `Booking` model ties together salon, service, staff, customer, time window, price, and status.
|
||||||
|
- `validate_booking_request` in `[backend/apps/bookings/services.py](backend/apps/bookings/services.py)` enforces staff membership, duration matching, availability windows, and overlap prevention for pending/confirmed bookings.
|
||||||
|
- `BookingViewSet` in `[backend/apps/bookings/views.py](backend/apps/bookings/views.py)` applies role-based access and hooks into notifications on create and relevant status changes.
|
||||||
|
- **Payments (Moyasar)** (`[backend/apps/payments](backend/apps/payments)`)
|
||||||
|
- `Payment` model tracks provider, status (fine-grained transitions), amount, idempotency key, external ID, payload, and timestamps.
|
||||||
|
- `MoyasarGateway` in `[backend/apps/payments/services/gateway.py](backend/apps/payments/services/gateway.py)` can create payments via HTTP but has `capture_payment`/`refund_payment` as TODOs.
|
||||||
|
- `create_payment_for_booking` in `[backend/apps/payments/services/payments.py](backend/apps/payments/services/payments.py)` enforces provider choice (Moyasar only), idempotency, and maps webhook events into internal statuses.
|
||||||
|
- Webhook view in `[backend/apps/payments/views.py](backend/apps/payments/views.py)` authenticates via shared secret and applies provider events idempotently.
|
||||||
|
- **Notifications** (`[backend/apps/notifications](backend/apps/notifications)`)
|
||||||
|
- `Notification` model records booking-related notifications and enforces uniqueness per booking/recipient/event/channel.
|
||||||
|
- Services in `[backend/apps/notifications/services.py](backend/apps/notifications/services.py)` reuse OTP providers to send SMS/WhatsApp messages for booking created/confirmed/cancelled events, with localization via `preferred_language`.
|
||||||
|
- Booking views call `notify_booking_lifecycle` / `notify_on_status_change` to trigger notifications for customers and staff.
|
||||||
|
- **Testing**
|
||||||
|
- Solid tests around:
|
||||||
|
- Phone normalization, OTP rate limiting, and phone auth flows (`[backend/apps/accounts/tests](backend/apps/accounts/tests)`).
|
||||||
|
- Booking integrity and overlap rules (`[backend/apps/bookings/tests](backend/apps/bookings/tests)`).
|
||||||
|
- Payment idempotency and Moyasar webhook handling (`[backend/apps/payments/tests](backend/apps/payments/tests)`).
|
||||||
|
- Booking notifications on create/status change (`[backend/apps/notifications/tests](backend/apps/notifications/tests)`).
|
||||||
|
- `docs/risks.md` explicitly tracks several known gaps around auth, booking, payments, data/UX, and ops.
|
||||||
|
|
||||||
```
|
### Frontend (React, Vite)
|
||||||
User → React Frontend → Django API
|
|
||||||
↓
|
- **Structure**
|
||||||
accounts (auth) ──→ OTP providers (Twilio/Unifonic/console)
|
- Vite React app at `[frontend](frontend)` with entry in `[frontend/src/main.jsx](frontend/src/main.jsx)` and single top-level component in `[frontend/src/App.jsx](frontend/src/App.jsx)`.
|
||||||
salons (catalog)
|
- No `react-router` or multi-page routing; the entire experience is one composed screen.
|
||||||
bookings ──→ notifications ──→ OTP providers
|
- **Current Features**
|
||||||
payments ──→ Moyasar gateway
|
- **Salon search**
|
||||||
|
- Text search field calling `/salons/?q=<query>` via a small API client in `[frontend/src/api/client.js](frontend/src/api/client.js)`.
|
||||||
|
- Renders responsive list of salons with rating, city, and phone.
|
||||||
|
- **Localization/i18n**
|
||||||
|
- `react-i18next` setup in `[frontend/src/i18n/index.js](frontend/src/i18n/index.js)` with `en` and `ar-sa` translations.
|
||||||
|
- Locale preference stored in `localStorage`; applies `lang` and `dir` on the document.
|
||||||
|
- **Payments beta**
|
||||||
|
- A form in `App.jsx` that sends payment creation requests to `/api/payments/` using the Moyasar-style payload, with configurable `booking_id`, source type, token, and callback URL.
|
||||||
|
- Optionally includes a Bearer token from a manually-entered access token field.
|
||||||
|
- On success, can redirect to `redirect_url` and shows the raw JSON response.
|
||||||
|
- **State & Tests**
|
||||||
|
- All state is local to `App.jsx` via `useState`/`useEffect`; there is no centralized state management or domain hooks yet.
|
||||||
|
- A single test file `[frontend/src/App.test.jsx](frontend/src/App.test.jsx)` covers hero copy and locale/RTL behavior, but not search or payments.
|
||||||
|
|
||||||
|
## Glaring Design And Architectural Issues
|
||||||
|
|
||||||
|
### Backend Risks
|
||||||
|
|
||||||
|
- **Incomplete provider implementations for production-critical flows**
|
||||||
|
- Authentica is the only OTP/notification provider; ensure it is fully configured and exercised in production-like environments.
|
||||||
|
- `MoyasarGateway` lacks `capture_payment` and `refund_payment` implementations, limiting payment lifecycle coverage.
|
||||||
|
- **Risk**: Code reads “production ready” at the API level, but the underlying integrations are not, which could cause outages if deployed naively.
|
||||||
|
- **Tight coupling between OTP and notifications**
|
||||||
|
- Notification services import the OTP provider mapping and default `NOTIFICATION_PROVIDER` to `OTP_PROVIDER`, binding booking notifications to auth configuration.
|
||||||
|
- **Risk**: Changing OTP providers or adding a second channel for marketing/ops notifications will be harder and could have unintended side effects.
|
||||||
|
- **Synchronous IO-heavy work in request/response path**
|
||||||
|
- OTP sends, booking notifications, and payment gateway calls all occur synchronously inside view methods (`perform_create`, `create`, etc.).
|
||||||
|
- **Risk**: Slow or flaky providers will degrade API latency and user experience; retries and backoff are hard to implement without background jobs.
|
||||||
|
- **Cross-app domain coupling without a clear orchestration layer**
|
||||||
|
- `apps.bookings` depends on salons and notifications; notifications depend on accounts (OTP providers) and bookings; payments depend on bookings.
|
||||||
|
- **Risk**: As you add more lifecycle rules (e.g., auto-confirm booking on payment, send reminders, handle refunds), the spaghetti of cross-imports will grow unless you introduce clearer service boundaries.
|
||||||
|
- **Auth model vs login patterns**
|
||||||
|
- `User.USERNAME_FIELD` is email, while phone-based JWT issuance happens via custom endpoints.
|
||||||
|
- **Risk**: This split can confuse clients and admin tooling and may complicate future flows like social login or SSO unless you standardize on an identifier strategy.
|
||||||
|
- **Docs drift around ExecPlans**
|
||||||
|
- `AGENTS.md` references `docs/execplans/payments-moyasar.md` as the active plan, while `PLANS.md` names `docs/execplans/booking-notifications.md`.
|
||||||
|
- **Risk**: Contributors may follow different “active” plans, causing architectural inconsistency.
|
||||||
|
|
||||||
|
### Frontend Risks
|
||||||
|
|
||||||
|
- **Monolithic `App` component with no routing**
|
||||||
|
- `App.jsx` mixes hero/search, salon listing, payments, and locale controls.
|
||||||
|
- There is no `react-router` or notion of separate flows (auth, booking, profile, payments).
|
||||||
|
- **Risk**: Extending to full MVP flows (auth, booking, history, management) will quickly become unmanageable without a routing/page system and domain separation.
|
||||||
|
- **Domain logic embedded in UI components**
|
||||||
|
- API payload construction, validation rules (e.g. for source types), and error handling are implemented directly in `App.jsx` rather than reusable hooks or service modules.
|
||||||
|
- **Risk**: Code reuse, testing, and evolution (e.g., adding booking pages or admin consoles) will be painful.
|
||||||
|
- **Minimal test coverage for critical flows**
|
||||||
|
- Only i18n and hero copy are tested; search behavior, API integration, and payments are untested.
|
||||||
|
- **Risk**: Regressions in search, booking, and payments UX will slip through as MVP grows.
|
||||||
|
- **Styling & layout fragility**
|
||||||
|
- `frontend/src/styles.css` uses `::root` instead of `:root`, which likely breaks intended global CSS variables or base styles.
|
||||||
|
- Global CSS is tightly bound to the monolithic `App` layout.
|
||||||
|
- **Risk**: Visual regressions and layout churn when introducing additional pages or components.
|
||||||
|
- **Ad hoc auth token handling**
|
||||||
|
- The “access token” is a free-form text field that gets persisted as `auth_token` in `localStorage` and injected into payment requests.
|
||||||
|
- **Risk**: This is a placeholder pattern that does not scale to full auth (refresh tokens, logout, token rotation) and will need to be replaced.
|
||||||
|
|
||||||
|
### Cross-Cutting Risks
|
||||||
|
|
||||||
|
- **Lack of async/background processing**
|
||||||
|
- No Celery/RQ or similar job queue; all side effects are synchronous.
|
||||||
|
- **Risk**: Scaling SMS/WhatsApp notifications, email, and payment webhook fan-out will be difficult.
|
||||||
|
- **Observability and admin tooling gaps**
|
||||||
|
- Errors for payments and notifications are recorded in model metadata but not clearly surfaced in logs, dashboards, or admin views.
|
||||||
|
- **Risk**: Operational debugging during MVP rollout will be slower and more error-prone.
|
||||||
|
- **Internationalization strategy vs future markets**
|
||||||
|
- Phone normalization and defaults are tailored to KSA, which is correct for MVP, but `docs/risks.md` already notes the need to broaden later.
|
||||||
|
- **Risk**: Without clear boundaries between KSA-specific logic and generic logic, future expansion may require invasive changes.
|
||||||
|
|
||||||
|
## MVP Roadmap (Aligned To Phase 1)
|
||||||
|
|
||||||
|
This roadmap assumes “MVP” is equivalent to **Phase 1: Core MVP Reliability** in `AGENTS.md`, with a thin but robust frontend on top.
|
||||||
|
|
||||||
|
### Phase 0 – Architecture & Production Readiness Hardening
|
||||||
|
|
||||||
|
- **Finalize critical provider implementations**
|
||||||
|
- Ensure Authentica OTP/SMS is fully configured and validated end-to-end behind the provider abstraction in `[backend/apps/accounts/services/otp.py](backend/apps/accounts/services/otp.py)`, and wired into `[backend/apps/notifications/services.py](backend/apps/notifications/services.py)`.
|
||||||
|
- Implement or deliberately fence off `capture_payment` and `refund_payment` in `[backend/apps/payments/services/gateway.py](backend/apps/payments/services/gateway.py)` so that the MVP either fully supports or explicitly does not support partial captures/refunds.
|
||||||
|
- **Clarify and document boundaries**
|
||||||
|
- Add a short architecture section in `README`/docs describing how `accounts`, `salons`, `bookings`, `payments`, and `notifications` interact, and what each service is responsible for.
|
||||||
|
- Resolve the ExecPlan drift by making `AGENTS.md` and `PLANS.md` agree on the current active plan.
|
||||||
|
- **Introduce minimal async infrastructure (optional but recommended)**
|
||||||
|
- Decide whether MVP will ship with a task queue (e.g., Celery with Redis) or keep everything synchronous for the initial launch.
|
||||||
|
- If yes, introduce a thin task layer for OTP sends and booking notifications while preserving current APIs; if not, at least add clear timeouts/logging to external calls.
|
||||||
|
- **Frontend scaffolding for growth**
|
||||||
|
- Introduce `react-router` and refactor `App.jsx` into route-based pages (e.g., `HomePage`, `BookPage`, `PaymentPage`, `ProfilePage`), with shared layout and navigation.
|
||||||
|
- Extract salon search, payment form, and locale controls into dedicated components and hooks.
|
||||||
|
|
||||||
|
### Phase 1 – Core MVP Features (Backend + Frontend)
|
||||||
|
|
||||||
|
- **Phone-first auth UX**
|
||||||
|
- Backend: reuse existing phone auth endpoints; ensure error messages and rate-limit responses are predictable and localized.
|
||||||
|
- Frontend:
|
||||||
|
- Build OTP-based login/registration screens that drive `/api/auth/phone/request/` and `/api/auth/phone/verify/`.
|
||||||
|
- Introduce an auth context (or similar) to store access/refresh tokens, current user profile, and handle logout.
|
||||||
|
- Defer social login beyond MVP, but keep API surface ready for it.
|
||||||
|
- **Booking search and creation**
|
||||||
|
- Backend is largely ready (booking validation and role-based access); review booking serializers in `[backend/apps/bookings/serializers.py](backend/apps/bookings/serializers.py)` to ensure they expose all fields needed for frontend booking forms.
|
||||||
|
- Frontend:
|
||||||
|
- Build a **booking flow**: pick a salon → choose service → select staff (optional) → select date/time slot (based on availability endpoints) → confirm booking.
|
||||||
|
- Add a “My bookings” page showing upcoming and past bookings, tied into the existing `/api/bookings/` endpoints.
|
||||||
|
- **Payments via Moyasar**
|
||||||
|
- Backend: confirm `create_payment_for_booking` contracts (inputs/outputs) are stable and documented.
|
||||||
|
- Frontend:
|
||||||
|
- Evolve the payments beta UI into a **post-booking payment step** that starts from a selected booking and guides the user into Moyasar’s hosted flow, then shows a status page.
|
||||||
|
- Handle callback/return from Moyasar (even if via manual redirect URL in MVP) and surface payment success/failure to the user.
|
||||||
|
- **Booking lifecycle notifications**
|
||||||
|
- Backend already sends notifications on booking create and status changes; align messaging templates with product UX and ensure localization strings exist.
|
||||||
|
- Frontend: surface notification results implicitly via booking status changes and explicit messages on the booking details page.
|
||||||
|
- **Localization foundations**
|
||||||
|
- Backend: ensure `UserLocaleMiddleware` and translation strings cover all user-visible errors in auth, bookings, payments, and notifications.
|
||||||
|
- Frontend: expand `en/ar-sa` translations to cover auth, booking, and payment flows; verify RTL layouts on the new screens.
|
||||||
|
- **Tests for critical flows**
|
||||||
|
- Backend: extend tests where needed to cover new booking/payment edge cases (e.g., tying booking status to payment status if/when introduced).
|
||||||
|
- Frontend: add Vitest tests for:
|
||||||
|
- Phone auth screen flows (request/verify success + errors).
|
||||||
|
- Booking flow (form validation, happy path, displaying server-side errors).
|
||||||
|
- Payment initiation from an existing booking.
|
||||||
|
|
||||||
|
### Phase 2 – Manager Ops Lite (Post-MVP, partially covered now)
|
||||||
|
|
||||||
|
- **Salon and staff management UI**
|
||||||
|
- Use existing salon and staff models to build basic management pages for salon owners/managers (create/update services, staff, availability).
|
||||||
|
- **Calendar views and rescheduling**
|
||||||
|
- Provide calendar views for staff/managers to view daily/weekly bookings and reschedule or cancel within defined rules.
|
||||||
|
- **Reviews and ratings**
|
||||||
|
- Implement review submission and rating recalculation on the backend, with corresponding frontend components.
|
||||||
|
- **Reporting basics**
|
||||||
|
- Lightweight reports for managers (upcoming bookings, simple revenue summaries based on payment status) using existing payments data.
|
||||||
|
|
||||||
|
### Phase 3 – Scale & Compliance (Later)
|
||||||
|
|
||||||
|
- **Audit logging** for admin actions and booking/payment state changes.
|
||||||
|
- **PDPL/GDPR retention policies** and data export tooling.
|
||||||
|
- **Observability**: structured logging, metrics, and basic dashboards for auth failures, OTP send failures, payment errors, and notification outcomes.
|
||||||
|
|
||||||
|
## Architecture Overview Diagram
|
||||||
|
|
||||||
|
A simplified view of the target MVP data flow:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
user["User (web/mobile)"] --> frontend["ReactFrontend"]
|
||||||
|
frontend --> api["DjangoAPI"]
|
||||||
|
|
||||||
|
api --> accounts["AccountsApp"]
|
||||||
|
api --> salons["SalonsApp"]
|
||||||
|
api --> bookings["BookingsApp"]
|
||||||
|
api --> payments["PaymentsApp"]
|
||||||
|
api --> notifications["NotificationsApp"]
|
||||||
|
|
||||||
|
accounts --> otpProviders["OtpProviders"]
|
||||||
|
notifications --> otpProviders
|
||||||
|
payments --> moyasar["MoyasarGateway"]
|
||||||
|
|
||||||
|
bookings --> notifications
|
||||||
|
bookings --> payments
|
||||||
|
salons --> bookings
|
||||||
```
|
```
|
||||||
|
|
||||||
## Async and Observability (MVP Decision)
|
|
||||||
|
|
||||||
**Decision (MVP):** All OTP sends, booking notifications, and payment gateway calls run **synchronously** in the request/response path. No Celery, RQ, or other task queue for the initial launch.
|
|
||||||
|
|
||||||
**Rationale:**
|
This diagram clarifies current coupling and highlights where future refactors (e.g., a dedicated messaging service or payment orchestrator) could sit.
|
||||||
- Reduces deployment complexity (no Redis, no worker processes).
|
|
||||||
- MVP traffic is expected to be low; synchronous latency is acceptable.
|
|
||||||
- External calls already use timeouts (e.g. Moyasar: 10s, Twilio: SDK default).
|
|
||||||
|
|
||||||
**Future:** When scaling, introduce a task queue (e.g. Celery + Redis) for OTP and notification sends. Payment creation and webhooks should remain synchronous for immediate feedback and idempotency.
|
## Validation And Acceptance For This Plan
|
||||||
|
|
||||||
**Observability:** Errors are logged via Python `logging` and stored in model metadata (e.g. `Payment.metadata["gateway_error"]`, `Notification.error_message`). Structured logging and metrics are Phase 3 work.
|
- The roadmap is accepted when:
|
||||||
|
- It clearly maps current backend and frontend capabilities to the Phase 1 MVP goals in `AGENTS.md`.
|
||||||
|
- It identifies the highest-risk design/architecture issues that could impede MVP reliability or future evolution.
|
||||||
|
- It provides a phased, concrete sequence of work packages that can be implemented via ExecPlans (e.g., booking notifications, Moyasar payments, phone auth UX).
|
||||||
|
- Each major feature area (auth, bookings, payments, notifications, localization, tests) should have or adopt an ExecPlan under `docs/execplans/` in line with `PLANS.md` before implementation begins.
|
||||||
|
|||||||
@@ -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
@@ -5,12 +5,13 @@ This file tracks known gaps and risks to address in future iterations.
|
|||||||
## Security And Auth
|
## Security And Auth
|
||||||
- Phone normalization is KSA-focused and minimal; broaden for multi-country use.
|
- Phone normalization is KSA-focused and minimal; broaden for multi-country use.
|
||||||
- OTP protections are basic; add device fingerprinting and IP throttling if needed.
|
- OTP protections are basic; add device fingerprinting and IP throttling if needed.
|
||||||
- Twilio OTP provider is implemented (SMS + WhatsApp); Unifonic remains a scaffold.
|
- Authentica OTP provider is implemented (SMS + WhatsApp via Authentica OTP).
|
||||||
- Social login is a placeholder.
|
- Social login is a placeholder.
|
||||||
- USERNAME_FIELD is still `email` while email can be null; verify admin/login flows.
|
- `USERNAME_FIELD` is now `"phone_number"`; `REQUIRED_FIELDS = []`; `create_superuser` accepts `phone_number`. Admin and `createsuperuser` work correctly for phone-only users.
|
||||||
|
|
||||||
## Booking Integrity
|
## Booking Integrity
|
||||||
- Availability checks and overlap prevention are now enforced for staff bookings.
|
- Availability checks and overlap prevention are now enforced for staff bookings.
|
||||||
|
- **Race condition — fixed:** `BookingCreateSerializer.create()` now locks the staff row with `select_for_update()` inside `transaction.atomic()` and re-runs the overlap check before inserting. Concurrent requests for the same staff slot are serialized at the DB level. Requires PostgreSQL in production (SQLite ignores `FOR UPDATE` but still serializes writes).
|
||||||
- No timezone handling or business hours enforcement.
|
- No timezone handling or business hours enforcement.
|
||||||
- No cancellation rules or refund logic.
|
- No cancellation rules or refund logic.
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ This file tracks known gaps and risks to address in future iterations.
|
|||||||
## Data And UX
|
## Data And UX
|
||||||
- Ratings are not recalculated from reviews.
|
- Ratings are not recalculated from reviews.
|
||||||
- No image upload or storage strategy for photos.
|
- No image upload or storage strategy for photos.
|
||||||
- Booking lifecycle notifications are implemented; Twilio delivers SMS/WhatsApp when OTP_PROVIDER=twilio.
|
- Booking lifecycle notifications are implemented; Authentica can deliver SMS when NOTIFICATION_PROVIDER=authentica.
|
||||||
- Localization foundations are in progress; full Arabic translation coverage and RTL QA are still pending.
|
- Localization foundations are in progress; full Arabic translation coverage and RTL QA are still pending.
|
||||||
|
|
||||||
## Ops And Compliance
|
## Ops And Compliance
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Runbooks
|
||||||
|
|
||||||
|
Operational procedures live here. Each new production-impacting workflow should add or update a runbook.
|
||||||
|
|
||||||
|
Existing runbooks:
|
||||||
|
|
||||||
|
- `docs/runbooks/auth_otp_failures.md`
|
||||||
|
- `docs/runbooks/booking_failures.md`
|
||||||
|
- `docs/runbooks/payments_sanity_check.md`
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Runbook: Auth OTP Failures
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Guide for diagnosing and mitigating OTP send or verify failures in phone-first authentication.
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- Users report not receiving OTP codes.
|
||||||
|
- `/api/auth/otp/request/` or `/api/auth/phone/request/` returns HTTP 500 or rate-limit errors.
|
||||||
|
- `/api/auth/otp/verify/` or `/api/auth/phone/verify/` returns invalid or expired OTP errors unexpectedly.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Users cannot sign in or complete phone verification.
|
||||||
|
- Booking and payment flows are blocked when auth is required.
|
||||||
|
|
||||||
|
## Quick Checks
|
||||||
|
|
||||||
|
- Confirm the provider configured in `backend/salon_api/settings.py` via `OTP_PROVIDER`.
|
||||||
|
- Check recent application logs for OTP send errors.
|
||||||
|
- Verify provider credentials are present in `backend/.env` for the active provider.
|
||||||
|
|
||||||
|
## Mitigation Steps
|
||||||
|
|
||||||
|
- If provider credentials are missing or invalid, fix the environment variables and restart the API process.
|
||||||
|
- If the provider is down, temporarily switch to `OTP_PROVIDER=console` for non-production environments and notify support.
|
||||||
|
- If rate limits are triggered, validate `OTP_MAX_PER_WINDOW`, `OTP_WINDOW_MINUTES`, and `OTP_RESEND_COOLDOWN_SECONDS` values and confirm client behavior is not retrying aggressively.
|
||||||
|
- If verification is failing, confirm server time is correct and `OTP_EXPIRY_MINUTES` is appropriate.
|
||||||
|
|
||||||
|
## Rollback / Escalation
|
||||||
|
|
||||||
|
- Roll back recent auth/OTP changes if the failure coincides with a deployment.
|
||||||
|
- Escalate to the provider (Authentica) with request IDs and timestamps if external API errors persist.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Authentica is the primary OTP provider for MVP; console provider is for local development.
|
||||||
|
- OTP send/verify logic lives in `backend/apps/accounts/services/otp.py`.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Runbook: Booking Failures
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Guide for diagnosing booking creation or status update failures (availability, overlap prevention, or validation errors).
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- `POST /api/bookings/` returns HTTP 400 or 500.
|
||||||
|
- `PATCH /api/bookings/<id>/` fails when confirming or cancelling.
|
||||||
|
- Users report bookings not appearing or incorrect status.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Customers cannot place bookings.
|
||||||
|
- Staff schedules become inconsistent.
|
||||||
|
- Notification and payment flows may not trigger.
|
||||||
|
|
||||||
|
## Quick Checks
|
||||||
|
|
||||||
|
- Confirm the request payload includes a valid `service`, `staff`, and scheduled time.
|
||||||
|
- Check server logs for booking validation errors or integrity exceptions.
|
||||||
|
- Verify that staff availability and overlap prevention rules are behaving as expected.
|
||||||
|
|
||||||
|
## Mitigation Steps
|
||||||
|
|
||||||
|
- Reproduce with a known test user and staff member to isolate data issues.
|
||||||
|
- If overlap rules are too strict, review booking validation logic and confirm time zone assumptions.
|
||||||
|
- If status updates are blocked, verify role checks and serializer permissions in `backend/apps/bookings/`.
|
||||||
|
- If notifications are expected but missing, confirm `NOTIFICATION_PROVIDER` configuration and notification records.
|
||||||
|
|
||||||
|
## Rollback / Escalation
|
||||||
|
|
||||||
|
- Roll back recent booking-related changes if failures started after a deployment.
|
||||||
|
- Escalate to engineering with the booking ID, user ID, and timestamps.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Booking validation and status transitions live in `backend/apps/bookings/`.
|
||||||
|
- Notifications for booking lifecycle are handled in `backend/apps/notifications/`.
|
||||||
Vendored
+25
@@ -0,0 +1,25 @@
|
|||||||
|
# ADR <NNNN>: <Title>
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed | Accepted | Deprecated | Superseded
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Explain the problem and the forces at play. Include constraints, risks, or user needs.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
State the decision clearly and explicitly.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
List the expected positive and negative outcomes, including operational impact.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
Briefly document viable alternatives and why they were rejected.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
Link to relevant PRs, runbooks, or architecture sections.
|
||||||
Vendored
+29
@@ -0,0 +1,29 @@
|
|||||||
|
# Runbook: <Short Title>
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
One or two sentences describing the situation this runbook covers.
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
Describe what an operator or user will observe.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
Who or what is affected.
|
||||||
|
|
||||||
|
## Quick Checks
|
||||||
|
|
||||||
|
Exact commands or checks that confirm the issue.
|
||||||
|
|
||||||
|
## Mitigation Steps
|
||||||
|
|
||||||
|
Step-by-step actions to resolve or reduce impact.
|
||||||
|
|
||||||
|
## Rollback / Escalation
|
||||||
|
|
||||||
|
How to revert or who to contact if the issue persists.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Any caveats, dependencies, or follow-up actions.
|
||||||
@@ -1,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 Moyasar’s 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.
|
|
||||||
|
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
import { Navigate, useLocation } from "react-router-dom";
|
import { Navigate, useLocation } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
|
||||||
export default function ProtectedRoute({ children }) {
|
export default function ProtectedRoute({ children }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { isAuthenticated, loading } = useAuth();
|
const { isAuthenticated, loading } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="auth-loading">
|
<div className="auth-loading">
|
||||||
<p>Loading...</p>
|
<p>{t("common.loading")}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,14 @@
|
|||||||
"phoneUnavailable": "الهاتف غير متوفر",
|
"phoneUnavailable": "الهاتف غير متوفر",
|
||||||
"viewDetails": "عرض التفاصيل والحجز"
|
"viewDetails": "عرض التفاصيل والحجز"
|
||||||
},
|
},
|
||||||
"nav":{"home":"الرئيسية","book":"احجز","pay":"ادفع","profile":"الحساب","bookings":"حجوزاتي","login":"تسجيل الدخول","logout":"تسجيل الخروج"},"book":{"title":"احجزي خدمة","placeholder":"تدفق الحجز قريباً.","cta":"احجزي الآن","selectSalon":"اختاري صالوناً من الصفحة الرئيسية للحجز.","service":"الخدمة","staff":"الفريق","date":"التاريخ","time":"الوقت","notes":"ملاحظات","notesPlaceholder":"ملاحظات اختيارية","selectService":"اختاري الخدمة","selectStaff":"اختاري الفني","submit":"تأكيد الحجز","submitting":"جاري الحجز...","errors":{"fillAll":"يرجى تعبئة جميع الحقول.","invalidTime":"تاريخ أو وقت غير صالح.","generic":"فشل الحجز."}},"salon":{"services":"الخدمات","staff":"الفريق"},"profile":{"title":"الحساب","placeholder":"الملف والحجوزات قريباً.","myBookings":"حجوزاتي","noContact":"لا توجد معلومات اتصال"},"bookings":{"title":"حجوزاتي","subtitle":"مواعيدك القادمة والسابقة.","empty":"لا توجد حجوزات بعد.","pay":"ادفع الآن"},"paymentReturn":{"title":"حالة الدفع","success":"تم الدفع بنجاح","successMessage":"تم إتمام الدفع. ستستلمين تأكيداً عبر SMS أو واتساب.","checkStatus":"تحققي من بريدك أو الصالون لحالة الدفع.","reference":"المرجع: {{id}}","viewBookings":"عرض حجوزاتي"},"auth":{"title":"تسجيل الدخول","subtitle":"أدخلي رقم جوالك لاستلام رمز التحقق.","phone":"رقم الجوال","channel":"الإرسال عبر","sms":"رسالة نصية","whatsapp":"واتساب","sendCode":"إرسال الرمز","sending":"جاري الإرسال...","verifyTitle":"أدخلي الرمز","verifySubtitle":"أرسلنا رمزاً إلى {{phone}}","code":"رمز التحقق","verify":"تحقق","verifying":"جاري التحقق...","back":"تغيير الرقم","errors":{"generic":"حدث خطأ.","retryAfter":"انتظري {{seconds}} ثانية قبل المحاولة مرة أخرى."}},"locale": {
|
"nav":{"home":"الرئيسية","book":"احجز","pay":"ادفع","profile":"الحساب","bookings":"حجوزاتي","login":"تسجيل الدخول","logout":"تسجيل الخروج"},"book":{"title":"احجزي خدمة","placeholder":"تدفق الحجز قريباً.","cta":"احجزي الآن","selectSalon":"اختاري صالوناً من الصفحة الرئيسية للحجز.","service":"الخدمة","staff":"الفريق","date":"التاريخ","time":"الوقت","notes":"ملاحظات","notesPlaceholder":"ملاحظات اختيارية","selectService":"اختاري الخدمة","selectStaff":"اختاري الفني","submit":"تأكيد الحجز","submitting":"جاري الحجز...","errors":{"fillAll":"يرجى تعبئة جميع الحقول.","invalidTime":"تاريخ أو وقت غير صالح.","generic":"فشل الحجز."}},"salon":{"services":"الخدمات","staff":"الفريق","unknownStaff":"موظف {{id}}"},"profile":{"title":"الحساب","placeholder":"الملف والحجوزات قريباً.","myBookings":"حجوزاتي","noContact":"لا توجد معلومات اتصال"},"bookings":{"title":"حجوزاتي","subtitle":"مواعيدك القادمة والسابقة.","empty":"لا توجد حجوزات بعد.","pay":"ادفع الآن"},"paymentReturn":{"title":"حالة الدفع","success":"تم الدفع بنجاح","successMessage":"تم إتمام الدفع. ستستلمين تأكيداً عبر SMS أو واتساب.","checkStatus":"تحققي من بريدك أو الصالون لحالة الدفع.","reference":"المرجع: {{id}}","viewBookings":"عرض حجوزاتي"},"auth":{"title":"تسجيل الدخول","subtitle":"أدخلي رقم جوالك لاستلام رمز التحقق.","phone":"رقم الجوال","channel":"الإرسال عبر","sms":"رسالة نصية","whatsapp":"واتساب","sendCode":"إرسال الرمز","sending":"جاري الإرسال...","verifyTitle":"أدخلي الرمز","verifySubtitle":"أرسلنا رمزاً إلى {{phone}}","code":"رمز التحقق","verify":"تحقق","verifying":"جاري التحقق...","back":"تغيير الرقم","errors":{"generic":"حدث خطأ.","retryAfter":"انتظري {{seconds}} ثانية قبل المحاولة مرة أخرى."}},"locale": {
|
||||||
"label": "اللغة",
|
"label": "اللغة",
|
||||||
"arabic": "العربية",
|
"arabic": "العربية",
|
||||||
"english": "الإنجليزية"
|
"english": "الإنجليزية"
|
||||||
},
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "جاري التحميل..."
|
||||||
|
},
|
||||||
"payment": {
|
"payment": {
|
||||||
"title": "المدفوعات (تجريبي)",
|
"title": "المدفوعات (تجريبي)",
|
||||||
"subtitle": "إرسال عملية دفع عبر Moyasar لحجز موجود.",
|
"subtitle": "إرسال عملية دفع عبر Moyasar لحجز موجود.",
|
||||||
|
|||||||
@@ -16,11 +16,14 @@
|
|||||||
"phoneUnavailable": "Phone unavailable",
|
"phoneUnavailable": "Phone unavailable",
|
||||||
"viewDetails": "View details & book"
|
"viewDetails": "View details & book"
|
||||||
},
|
},
|
||||||
"nav":{"home":"Home","book":"Book","pay":"Pay","profile":"Profile","bookings":"My bookings","login":"Sign in","logout":"Sign out"},"book":{"title":"Book a Service","placeholder":"Booking flow coming soon.","cta":"Book now","selectSalon":"Select a salon from the home page to book.","service":"Service","staff":"Staff","date":"Date","time":"Time","notes":"Notes","notesPlaceholder":"Optional notes","selectService":"Select service","selectStaff":"Select staff","submit":"Confirm booking","submitting":"Booking...","errors":{"fillAll":"Please fill all required fields.","invalidTime":"Invalid date or time.","generic":"Booking failed."}},"salon":{"services":"Services","staff":"Staff"},"profile":{"title":"Profile","placeholder":"Profile and bookings coming soon.","myBookings":"My bookings","noContact":"No contact info"},"bookings":{"title":"My bookings","subtitle":"Your upcoming and past appointments.","empty":"No bookings yet.","pay":"Pay now"},"paymentReturn":{"title":"Payment status","success":"Payment successful","successMessage":"Your payment was completed. You will receive a confirmation by SMS or WhatsApp.","checkStatus":"Check your email or the salon for payment status.","reference":"Reference: {{id}}","viewBookings":"View my bookings"},"auth":{"title":"Sign in","subtitle":"Enter your phone number to receive a verification code.","phone":"Phone number","channel":"Send via","sms":"SMS","whatsapp":"WhatsApp","sendCode":"Send code","sending":"Sending...","verifyTitle":"Enter code","verifySubtitle":"We sent a code to {{phone}}","code":"Verification code","verify":"Verify","verifying":"Verifying...","back":"Change number","errors":{"generic":"Something went wrong.","retryAfter":"Please wait {{seconds}} seconds before trying again."}},"locale": {
|
"nav":{"home":"Home","book":"Book","pay":"Pay","profile":"Profile","bookings":"My bookings","login":"Sign in","logout":"Sign out"},"book":{"title":"Book a Service","placeholder":"Booking flow coming soon.","cta":"Book now","selectSalon":"Select a salon from the home page to book.","service":"Service","staff":"Staff","date":"Date","time":"Time","notes":"Notes","notesPlaceholder":"Optional notes","selectService":"Select service","selectStaff":"Select staff","submit":"Confirm booking","submitting":"Booking...","errors":{"fillAll":"Please fill all required fields.","invalidTime":"Invalid date or time.","generic":"Booking failed."}},"salon":{"services":"Services","staff":"Staff","unknownStaff":"Staff {{id}}"},"profile":{"title":"Profile","placeholder":"Profile and bookings coming soon.","myBookings":"My bookings","noContact":"No contact info"},"bookings":{"title":"My bookings","subtitle":"Your upcoming and past appointments.","empty":"No bookings yet.","pay":"Pay now"},"paymentReturn":{"title":"Payment status","success":"Payment successful","successMessage":"Your payment was completed. You will receive a confirmation by SMS or WhatsApp.","checkStatus":"Check your email or the salon for payment status.","reference":"Reference: {{id}}","viewBookings":"View my bookings"},"auth":{"title":"Sign in","subtitle":"Enter your phone number to receive a verification code.","phone":"Phone number","channel":"Send via","sms":"SMS","whatsapp":"WhatsApp","sendCode":"Send code","sending":"Sending...","verifyTitle":"Enter code","verifySubtitle":"We sent a code to {{phone}}","code":"Verification code","verify":"Verify","verifying":"Verifying...","back":"Change number","errors":{"generic":"Something went wrong.","retryAfter":"Please wait {{seconds}} seconds before trying again."}},"locale": {
|
||||||
"label": "Language",
|
"label": "Language",
|
||||||
"arabic": "العربية",
|
"arabic": "العربية",
|
||||||
"english": "English"
|
"english": "English"
|
||||||
},
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "Loading..."
|
||||||
|
},
|
||||||
"payment": {
|
"payment": {
|
||||||
"title": "Payment (Beta)",
|
"title": "Payment (Beta)",
|
||||||
"subtitle": "Send a Moyasar payment for an existing booking.",
|
"subtitle": "Send a Moyasar payment for an existing booking.",
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export default function BookPage() {
|
|||||||
<option value="">{t("book.selectStaff")}</option>
|
<option value="">{t("book.selectStaff")}</option>
|
||||||
{salon.staff?.map((s) => (
|
{salon.staff?.map((s) => (
|
||||||
<option key={s.id} value={s.id}>
|
<option key={s.id} value={s.id}>
|
||||||
{s.name || s.title || `Staff ${s.id}`}
|
{s.name || s.title || t("salon.unknownStaff", { id: s.id })}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { apiGet } from "../api/client";
|
import { apiGet } from "../api/client";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import ProtectedRoute from "../components/ProtectedRoute";
|
import ProtectedRoute from "../components/ProtectedRoute";
|
||||||
|
import { getActiveLocale } from "../i18n/index";
|
||||||
|
|
||||||
function formatDateTime(iso) {
|
function formatDateTime(iso, locale) {
|
||||||
if (!iso) return "";
|
if (!iso) return "";
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
return d.toLocaleString(undefined, {
|
return d.toLocaleString(locale, {
|
||||||
dateStyle: "medium",
|
dateStyle: "medium",
|
||||||
timeStyle: "short",
|
timeStyle: "short",
|
||||||
});
|
});
|
||||||
@@ -51,7 +52,7 @@ export default function BookingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="booking-service">{b.service_name}</p>
|
<p className="booking-service">{b.service_name}</p>
|
||||||
<p className="booking-time">
|
<p className="booking-time">
|
||||||
{formatDateTime(b.start_time)} – {formatDateTime(b.end_time)}
|
{formatDateTime(b.start_time, getActiveLocale())} – {formatDateTime(b.end_time, getActiveLocale())}
|
||||||
</p>
|
</p>
|
||||||
<p className="booking-price">
|
<p className="booking-price">
|
||||||
{b.price_amount} {b.currency}
|
{b.price_amount} {b.currency}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function SalonDetailPage() {
|
|||||||
<h2>{t("salon.staff")}</h2>
|
<h2>{t("salon.staff")}</h2>
|
||||||
<ul className="staff-list">
|
<ul className="staff-list">
|
||||||
{salon.staff?.map((s) => (
|
{salon.staff?.map((s) => (
|
||||||
<li key={s.id}>{s.name || s.title || `Staff ${s.id}`}</li>
|
<li key={s.id}>{s.name || s.title || t("salon.unknownStaff", { id: s.id })}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user