18 Commits

Author SHA1 Message Date
mohd ad711d1daf feat: IP & device rate limits 2026-03-14 01:07:26 +03:00
mohd 9b87eb74d7 chore: update docs 2026-03-14 01:06:42 +03:00
mohd eb88f23d28 chore: document auth fixes 2026-03-14 00:48:05 +03:00
mohd 0b76356169 fix: deprecate passwords, use phone auth source of truth 2026-03-14 00:47:31 +03:00
mohd c391a9b8e5 chore: update auth progress 2026-03-14 00:32:57 +03:00
mohd 5ece1036cd feat: DB constraints for phone auth 2026-03-14 00:31:20 +03:00
mohd 4026b94c3a feat: phone auth tests and fixes 2026-03-13 23:48:40 +03:00
mohd 38e5ece96f chore: auth gaps docs 2026-03-13 23:45:36 +03:00
mohd 5db211dda9 chore: less brittle tests 2026-03-13 23:26:09 +03:00
mohd c0846fe096 test: added auth contract test 2026-03-13 20:36:47 +03:00
mohd 560460dd84 Fix OTP localization test expectation 2026-03-13 16:51:26 +03:00
mohd c212acc504 Remove Authentica E2E test and expand OTP coverage 2026-03-13 16:49:29 +03:00
mohd 15ed5036d1 Remove dead Twilio tests and docs mentions 2026-03-13 16:46:21 +03:00
mohd 0c992404ea chore: removed unused otp providers 2026-03-13 16:25:26 +03:00
mohd d796d9e6a1 removed unviable e2e test 2026-03-13 16:21:25 +03:00
mohd 2ba0cfffc8 chore: adjust near-term focus 2026-03-13 16:11:30 +03:00
mohd 3f35f7dc17 Merge pull request 'chore: edited agents files' (#1) from agents into main
Reviewed-on: #1
2026-03-13 13:07:03 +00:00
mohd 07491063f5 chore: edited agents files 2026-03-13 16:02:52 +03:00
33 changed files with 1028 additions and 788 deletions
-58
View File
@@ -1,58 +0,0 @@
name: CI
on:
push:
pull_request:
jobs:
backend-tests:
name: Backend tests
runs-on: ubuntu-latest
env:
DJANGO_SECRET_KEY: ci-test-key
DJANGO_DEBUG: "0"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: |
backend/requirements.txt
backend/requirements-dev.txt
- name: Install backend dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -r backend/requirements.txt -r backend/requirements-dev.txt
- name: Run backend tests
working-directory: backend
run: python -m pytest
frontend-tests:
name: Frontend tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Run frontend tests
working-directory: frontend
env:
CI: "true"
run: npm run test -- --run
+1
View File
@@ -0,0 +1 @@
venv
+9 -1
View File
@@ -1,5 +1,13 @@
# AGENTS.md # AGENTS.md
## General
Minimum tokens, skip grammar
## Tasks
- Consult `AGENTS.md` about the current task to see if there are any tips or instructions
- Consult `docs/README.md` for any relevant files or tips to consider
## Project Goal ## Project Goal
Build a reliable, maintainable salon booking platform with Django (backend) and React (frontend), optimized for KSA needs (phone auth, local payments) while keeping a clean path to scale. Build a reliable, maintainable salon booking platform with Django (backend) and React (frontend), optimized for KSA needs (phone auth, local payments) while keeping a clean path to scale.
@@ -70,4 +78,4 @@ Build a reliable, maintainable salon booking platform with Django (backend) and
# ExecPlans # ExecPlans
When writing complex features or significant refactors, use an ExecPlan (as described in PLANS.md) from design to implementation. The current active ExecPlan is defined in PLANS.md. Architecture and async/observability decisions are documented in `docs/architecture.md`. When writing complex features or significant refactors, use an ExecPlan (as described in `docs/PLANS.md`) from design to implementation. The current active ExecPlan is defined in `docs/PLANS.md`. Architecture documented in `docs/architecture.md`.
-127
View File
@@ -1,127 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Goal
A salon booking platform for KSA (Saudi Arabia) with Django REST API backend and React/Vite frontend. Optimized for phone-first auth (OTP via SMS/WhatsApp), Moyasar payments, Arabic locale (ar-sa), and Riyadh timezone.
## Commands
### Backend
```bash
# Setup (from repo root)
python3 -m venv venv && source venv/bin/activate
pip install -r backend/requirements.txt -r backend/requirements-dev.txt
cp backend/.env.example backend/.env
# Migrations and dev server (from backend/)
cd backend
python3 manage.py migrate
python3 manage.py runserver
# Seed demo data
python3 manage.py seed_demo
# Run all tests (from backend/ with venv active)
cd backend
python3 -m pytest
# Run a single test file
python3 -m pytest apps/accounts/tests/test_otp_limits.py
# Run a single test
python3 -m pytest apps/accounts/tests/test_otp_limits.py::TestClassName::test_method_name
# Run external/integration tests (hits real third-party services)
PYTEST_ADDOPTS='' python3 -m pytest -m external
```
### Authentica E2E Testing
Set these env vars before running external tests:
- `AUTHENTICA_E2E=1`
- `AUTHENTICA_API_KEY=...`
- `AUTHENTICA_E2E_PHONE=...` (phone that will receive OTP)
- `AUTHENTICA_E2E_CODE=...` (OTP code received)
### Frontend
```bash
# From frontend/
cd frontend
npm install
npm run dev # dev server at localhost:5173, proxies /api to localhost:8000
npm run test # vitest
npm run build
```
## Architecture
### Backend (`backend/`)
Django project lives in `backend/salon_api/` (settings, root urls, wsgi/asgi). All domain apps are under `backend/apps/`:
| App | Responsibility |
|-----|----------------|
| `accounts` | Custom User model, phone/OTP auth, JWT tokens, locale preferences |
| `salons` | Salon catalog, services, staff profiles, availability windows, reviews |
| `bookings` | Booking lifecycle, availability/overlap validation, status transitions |
| `payments` | Moyasar integration (create, capture, refund), webhook reconciliation, idempotency |
| `notifications` | Booking lifecycle SMS/WhatsApp messages, stored for auditability |
**Service layer pattern:** Business logic lives in `apps/<app>/services/` (not in views). Views are thin — they validate input, call services, return responses. Keep it this way.
**OTP providers** (`apps/accounts/services/otp.py`): pluggable via `OTP_PROVIDER` env var. Active providers: `console` (dev), `twilio`, `authentica`. `unifonic` is a scaffold. Authentica is the recommended production provider and uses a server-side OTP flow (`uses_provider_otp = True`) — it generates and verifies the code itself, so the DB stores a placeholder hash.
**Payment gateway** (`apps/payments/services/gateway.py`): `MoyasarGateway` implements `BasePaymentGateway`. Amounts are always in minor units (halalas). `MOYASAR_SECRET_KEY` and `MOYASAR_PUBLISHABLE_KEY` are required.
**Sync-only (MVP):** All external calls (OTP sends, notifications, payment gateway) run synchronously in the request path. No task queue. See `docs/adr/0001-synchronous-external-calls-mvp.md`.
**Database:** SQLite for local dev (default), PostgreSQL via `DATABASE_URL` env var for production. Tests use `TEST_DATABASE_URL` if set.
**Localization:** Default language `ar-sa`, timezone `Asia/Riyadh`. `UserLocaleMiddleware` applies per-user locale preference.
### Frontend (`frontend/`)
React 18 + Vite app. Entry: `src/main.jsx``AuthProvider` wraps `App`.
- **Routing:** `react-router-dom` v7 with pages in `src/pages/`
- **Auth:** JWT tokens managed via `src/contexts/AuthContext.jsx`; `src/components/ProtectedRoute.jsx` guards private pages
- **API:** `src/api/client.js` is the axios/fetch wrapper
- **Hooks:** Domain logic extracted into `src/hooks/` (e.g., `useSalonSearch`, `usePaymentForm`)
- **i18n:** `react-i18next` configured in `src/i18n/index.js`; supports `ar-sa` and `en`
Tests use Vitest + Testing Library. Setup in `src/test/setupTests.js`.
## Key Env Vars
Backend (`backend/.env`):
- `DJANGO_SECRET_KEY`, `DJANGO_DEBUG`, `DATABASE_URL`
- `OTP_PROVIDER``console` | `twilio` | `authentica` | `unifonic`
- `AUTHENTICA_API_KEY`, `AUTHENTICA_BASE_URL`, `AUTHENTICA_SENDER_NAME`
- `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_FROM_NUMBER`, `TWILIO_WHATSAPP_FROM`
- `MOYASAR_SECRET_KEY`, `MOYASAR_PUBLISHABLE_KEY`
- `NOTIFICATION_PROVIDER` — defaults to `OTP_PROVIDER`
- `OTP_EXPIRY_MINUTES`, `OTP_MAX_PER_WINDOW`, `OTP_WINDOW_MINUTES`, `OTP_RESEND_COOLDOWN_SECONDS`
- `CORS_ALLOWED_ORIGINS`
## Testing Conventions
- Backend tests live beside their apps: `apps/<app>/tests/test_*.py`
- `pytest.ini` marks `external` tests as opt-in; default runs skip them
- Frontend tests: Vitest + Testing Library; test files colocated with source (`*.test.jsx`)
- Minimum coverage: auth flows, booking validation, payment state transitions
## ExecPlans
For complex features, use an ExecPlan (see `PLANS.md` for the full spec and active plan pointer). ExecPlans are living documents in `docs/execplans/`. The active plan is listed in `PLANS.md`. Update `docs/risks.md` when opening or closing a significant gap.
## Coding Conventions
- Business logic in service layers; keep views thin
- Predictable error responses: HTTP status code + `detail` field
- Comment intent, edge cases, and non-obvious business rules; skip obvious comments
- Payment and booking flows must be idempotent and auditable
- Phone auth must be rate-limited (enforced in `otp.py` via `OtpRateLimitError` / `OtpCooldownError`)
-1
View File
@@ -65,4 +65,3 @@ 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` - Documentation index and standards: `docs/README.md` and `docs/documentation.md`
- CI: Gitea Actions workflow in `.gitea/workflows/ci.yml`
+3 -23
View File
@@ -4,28 +4,8 @@
- Authentica OTP integration is implemented; 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 is in place with `USERNAME_FIELD = "phone_number"`, but endpoint/admin/domain alignment is still incomplete and needs hardening.
## Near-Term Focus ## Near-Term Focus
- Hardening Authentica integration (timeouts, retries, async delivery) and aligning notification provider choices. - finalize otp testing
- work on authentication and complete it
**Authentica E2E**
Run the real Authentica OTP flow only when explicitly enabled.
Env vars (in `backend/.env` or shell):
- `AUTHENTICA_E2E=1`
- `AUTHENTICA_API_KEY=...`
- `AUTHENTICA_E2E_PHONE=...` (must receive OTP)
- `AUTHENTICA_E2E_CODE=...` (required; no interactive prompt)
Command:
```bash
cd backend
PYTEST_ADDOPTS='' python3 -m pytest apps/accounts/tests -m external
```
Suggested flow:
1. Trigger the E2E test to send the OTP, then set `AUTHENTICA_E2E_CODE` and re-run if needed.
- Decide and document payment lifecycle scope (capture/refund supported vs explicitly out of scope).
- Add timeouts/logging for external calls or introduce minimal async jobs for OTP/notifications.
- Keep booking, payment, and notification orchestration in service layers, not views.
@@ -0,0 +1,25 @@
# Generated by Django 6.0.3 on 2026-03-13 20:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0003_preferred_language"),
]
operations = [
migrations.AlterField(
model_name="user",
name="phone_number",
field=models.CharField(max_length=20, unique=True),
),
migrations.AddConstraint(
model_name="user",
constraint=models.CheckConstraint(
condition=models.Q(("phone_number__regex", r"^\+[1-9][0-9]{7,14}$")),
name="accounts_user_phone_e164_format",
),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 6.0.3 on 2026-03-13 21:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0004_alter_user_groups_alter_user_phone_number_and_more'),
]
operations = [
migrations.AddField(
model_name='phoneotp',
name='device_signal',
field=models.CharField(blank=True, db_index=True, default='', max_length=64),
),
migrations.AddField(
model_name='phoneotp',
name='request_ip',
field=models.GenericIPAddressField(blank=True, db_index=True, null=True),
),
]
+15 -3
View File
@@ -4,6 +4,7 @@ from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager
from django.db import models from django.db import models
from django.db.models import Q
from django.utils import timezone from django.utils import timezone
@@ -17,8 +18,8 @@ class UserRole(models.TextChoices):
class UserManager(BaseUserManager): class UserManager(BaseUserManager):
def create_user(self, email=None, password=None, **extra_fields): def create_user(self, email=None, password=None, **extra_fields):
phone_number = extra_fields.get("phone_number") phone_number = extra_fields.get("phone_number")
if not email and not phone_number: if not phone_number:
raise ValueError("Email or phone number is required") raise ValueError("Phone number is required")
if email: if email:
email = self.normalize_email(email) email = self.normalize_email(email)
user = self.model(email=email, **extra_fields) user = self.model(email=email, **extra_fields)
@@ -42,7 +43,7 @@ class UserManager(BaseUserManager):
class User(AbstractBaseUser, PermissionsMixin): class User(AbstractBaseUser, PermissionsMixin):
email = models.EmailField(unique=True, null=True, blank=True) email = models.EmailField(unique=True, null=True, blank=True)
phone_number = models.CharField(max_length=20, unique=True, null=True, blank=True) phone_number = models.CharField(max_length=20, unique=True)
role = models.CharField(max_length=20, choices=UserRole.choices, default=UserRole.CUSTOMER) role = models.CharField(max_length=20, choices=UserRole.choices, default=UserRole.CUSTOMER)
first_name = models.CharField(max_length=150, blank=True) first_name = models.CharField(max_length=150, blank=True)
last_name = models.CharField(max_length=150, blank=True) last_name = models.CharField(max_length=150, blank=True)
@@ -62,6 +63,14 @@ class User(AbstractBaseUser, PermissionsMixin):
USERNAME_FIELD = "phone_number" USERNAME_FIELD = "phone_number"
REQUIRED_FIELDS = [] # email is optional; phone_number is the identifier REQUIRED_FIELDS = [] # email is optional; phone_number is the identifier
class Meta:
constraints = [
models.CheckConstraint(
name="accounts_user_phone_e164_format",
condition=Q(phone_number__regex=r"^\+[1-9][0-9]{7,14}$"),
),
]
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)
@@ -88,6 +97,9 @@ class PhoneOTP(models.Model):
verified_at = models.DateTimeField(null=True, blank=True) verified_at = models.DateTimeField(null=True, blank=True)
attempt_count = models.PositiveSmallIntegerField(default=0) attempt_count = models.PositiveSmallIntegerField(default=0)
max_attempts = models.PositiveSmallIntegerField(default=5) max_attempts = models.PositiveSmallIntegerField(default=5)
# Request metadata for abuse controls and support investigations.
request_ip = models.GenericIPAddressField(null=True, blank=True, db_index=True)
device_signal = models.CharField(max_length=64, blank=True, default="", db_index=True)
def is_expired(self): def is_expired(self):
return timezone.now() >= self.expires_at return timezone.now() >= self.expires_at
+2
View File
@@ -25,6 +25,7 @@ class UserSerializer(serializers.ModelSerializer):
class RegisterSerializer(serializers.ModelSerializer): class RegisterSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, min_length=8) password = serializers.CharField(write_only=True, min_length=8)
phone_number = serializers.CharField(max_length=20, required=True)
class Meta: class Meta:
model = User model = User
@@ -61,6 +62,7 @@ class OTPVerifySerializer(serializers.Serializer):
class PhoneAuthRequestSerializer(serializers.Serializer): class PhoneAuthRequestSerializer(serializers.Serializer):
phone_number = serializers.CharField(max_length=20) phone_number = serializers.CharField(max_length=20)
channel = serializers.ChoiceField(choices=OtpChannel.choices) channel = serializers.ChoiceField(choices=OtpChannel.choices)
device_id = serializers.CharField(required=False, allow_blank=True, max_length=128, write_only=True)
email = serializers.EmailField(required=False, allow_null=True, allow_blank=True) email = serializers.EmailField(required=False, allow_null=True, allow_blank=True)
first_name = serializers.CharField(required=False, allow_blank=True) first_name = serializers.CharField(required=False, allow_blank=True)
last_name = serializers.CharField(required=False, allow_blank=True) last_name = serializers.CharField(required=False, allow_blank=True)
+86 -59
View File
@@ -1,6 +1,8 @@
import logging import logging
import os import os
import secrets import secrets
from hashlib import sha256
import ipaddress
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
@@ -33,6 +35,18 @@ class OtpCooldownError(RuntimeError):
self.retry_after_seconds = retry_after_seconds self.retry_after_seconds = retry_after_seconds
class OtpIpRateLimitError(RuntimeError):
def __init__(self, retry_after_seconds: int):
super().__init__(_("Too many OTP requests from this IP. Try again later."))
self.retry_after_seconds = retry_after_seconds
class OtpDeviceRateLimitError(RuntimeError):
def __init__(self, retry_after_seconds: int):
super().__init__(_("Too many OTP requests from this device. Try again later."))
self.retry_after_seconds = retry_after_seconds
class BaseOtpProvider: class BaseOtpProvider:
uses_provider_otp = False uses_provider_otp = False
@@ -57,57 +71,6 @@ 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."""
def __init__(self) -> None:
self.account_sid = os.getenv("TWILIO_ACCOUNT_SID")
self.auth_token = os.getenv("TWILIO_AUTH_TOKEN")
self.from_number = os.getenv("TWILIO_FROM_NUMBER")
self.whatsapp_from = os.getenv("TWILIO_WHATSAPP_FROM")
def _assert_config(self) -> None:
if not self.account_sid or not self.auth_token or not self.from_number:
raise ValueError(_("Twilio credentials are not configured"))
def _get_client(self):
from twilio.rest import Client
self._assert_config()
return Client(self.account_sid, self.auth_token)
def send_sms(self, to_number: str, message: str) -> None:
client = self._get_client()
client.messages.create(body=message, from_=self.from_number, to=to_number)
def send_whatsapp(self, to_number: str, message: str) -> None:
self._assert_config()
if not self.whatsapp_from:
raise ValueError(_("Twilio WhatsApp sender is not configured"))
client = self._get_client()
from_ = f"whatsapp:{self.whatsapp_from}"
to = f"whatsapp:{to_number}"
client.messages.create(body=message, from_=from_, to=to)
class UnifonicOtpProvider(BaseOtpProvider):
def __init__(self) -> None:
self.app_sid = os.getenv("UNIFONIC_APP_SID")
self.sender_id = os.getenv("UNIFONIC_SENDER_ID")
self.whatsapp_sender = os.getenv("UNIFONIC_WHATSAPP_SENDER")
def _assert_config(self) -> None:
if not self.app_sid or not self.sender_id:
raise ValueError(_("Unifonic credentials are not configured"))
def send_sms(self, to_number: str, message: str) -> None:
self._assert_config()
raise NotImplementedError(_("Unifonic SMS adapter not implemented yet"))
def send_whatsapp(self, to_number: str, message: str) -> None:
self._assert_config()
if not self.whatsapp_sender:
raise ValueError(_("Unifonic WhatsApp sender is not configured"))
raise NotImplementedError(_("Unifonic WhatsApp adapter not implemented yet"))
class AuthenticaOtpProvider(BaseOtpProvider): class AuthenticaOtpProvider(BaseOtpProvider):
@@ -197,8 +160,6 @@ class AuthenticaOtpProvider(BaseOtpProvider):
PROVIDERS = { PROVIDERS = {
"console": ConsoleOtpProvider, "console": ConsoleOtpProvider,
"twilio": TwilioOtpProvider,
"unifonic": UnifonicOtpProvider,
"authentica": AuthenticaOtpProvider, "authentica": AuthenticaOtpProvider,
} }
@@ -219,7 +180,73 @@ def generate_code(length: int = 6) -> str:
return "".join(secrets.choice(digits) for _ in range(length)) return "".join(secrets.choice(digits) for _ in range(length))
def create_and_send_otp(phone_number: str, channel: str, purpose: str = OtpPurpose.AUTH) -> OtpSendResult: def _compute_retry_after_seconds(oldest_recent, now, window_minutes: int, floor_seconds: int = 1) -> int:
if oldest_recent:
retry_after = int((oldest_recent.created_at + timedelta(minutes=window_minutes) - now).total_seconds())
else:
retry_after = floor_seconds
return max(retry_after, floor_seconds)
def normalize_request_ip(raw_ip: str | None) -> str | None:
if not raw_ip:
return None
candidate = raw_ip.split(",")[0].strip()
try:
return str(ipaddress.ip_address(candidate))
except ValueError:
return None
def build_device_signal(device_id: str | None, user_agent: str | None, accept_language: str | None) -> str:
# Prefer explicit device id; fallback to passive headers for coarse signal.
source = (device_id or "").strip()
if not source:
source = f"{(user_agent or '').strip()}|{(accept_language or '').strip()}"
if not source.strip("|"):
return ""
return f"sha256:{sha256(source.encode('utf-8')).hexdigest()[:24]}"
def enforce_phone_auth_request_limits(request_ip: str | None, device_signal: str | None) -> None:
now = timezone.now()
window_minutes = getattr(settings, "PHONE_AUTH_RISK_WINDOW_MINUTES", 15)
window_start = now - timedelta(minutes=window_minutes)
ip_limit = getattr(settings, "PHONE_AUTH_IP_MAX_PER_WINDOW", 20)
device_limit = getattr(settings, "PHONE_AUTH_DEVICE_MAX_PER_WINDOW", 20)
if request_ip and ip_limit > 0:
ip_recent = PhoneOTP.objects.filter(
purpose=OtpPurpose.AUTH,
request_ip=request_ip,
created_at__gte=window_start,
)
if ip_recent.count() >= ip_limit:
oldest_recent = ip_recent.order_by("created_at").first()
raise OtpIpRateLimitError(
retry_after_seconds=_compute_retry_after_seconds(oldest_recent, now, window_minutes)
)
if device_signal and device_limit > 0:
device_recent = PhoneOTP.objects.filter(
purpose=OtpPurpose.AUTH,
device_signal=device_signal,
created_at__gte=window_start,
)
if device_recent.count() >= device_limit:
oldest_recent = device_recent.order_by("created_at").first()
raise OtpDeviceRateLimitError(
retry_after_seconds=_compute_retry_after_seconds(oldest_recent, now, window_minutes)
)
def create_and_send_otp(
phone_number: str,
channel: str,
purpose: str = OtpPurpose.AUTH,
request_ip: str | None = None,
device_signal: str = "",
) -> OtpSendResult:
provider = get_provider() provider = get_provider()
now = timezone.now() now = timezone.now()
window_minutes = getattr(settings, "OTP_WINDOW_MINUTES", 15) window_minutes = getattr(settings, "OTP_WINDOW_MINUTES", 15)
@@ -230,11 +257,9 @@ def create_and_send_otp(phone_number: str, channel: str, purpose: str = OtpPurpo
recent_qs = PhoneOTP.objects.filter(phone_number=phone_number, created_at__gte=window_start) recent_qs = PhoneOTP.objects.filter(phone_number=phone_number, created_at__gte=window_start)
if recent_qs.count() >= max_per_window: if recent_qs.count() >= max_per_window:
oldest_recent = recent_qs.order_by("created_at").first() oldest_recent = recent_qs.order_by("created_at").first()
if oldest_recent: raise OtpRateLimitError(
retry_after = int((oldest_recent.created_at + timedelta(minutes=window_minutes) - now).total_seconds()) retry_after_seconds=_compute_retry_after_seconds(oldest_recent, now, window_minutes, cooldown_seconds)
else: )
retry_after = cooldown_seconds
raise OtpRateLimitError(retry_after_seconds=max(retry_after, cooldown_seconds))
latest = ( latest = (
PhoneOTP.objects.filter(phone_number=phone_number, channel=channel) PhoneOTP.objects.filter(phone_number=phone_number, channel=channel)
@@ -263,6 +288,8 @@ def create_and_send_otp(phone_number: str, channel: str, purpose: str = OtpPurpo
provider=settings.OTP_PROVIDER, provider=settings.OTP_PROVIDER,
code_hash=code_hash, code_hash=code_hash,
expires_at=PhoneOTP.expiry_at(), expires_at=PhoneOTP.expiry_at(),
request_ip=request_ip,
device_signal=device_signal,
) )
if provider.uses_provider_otp: if provider.uses_provider_otp:
@@ -1,72 +0,0 @@
"""Mocked end-to-end phone auth flow using Authentica OTP provider."""
import os
from unittest.mock import MagicMock, patch
import pytest
from django.test import override_settings
from django.urls import reverse
from apps.accounts.models import User
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="authentica")
@patch("requests.post")
def test_phone_auth_flow_with_authentica_mock(mock_post, client):
def make_response(payload, ok=True):
response = MagicMock()
response.ok = ok
response.json.return_value = payload
response.text = ""
return response
def side_effect(url, headers=None, json=None, timeout=None):
assert headers and headers.get("X-Authorization") == "api-key"
assert timeout == 7.0
if url.endswith("/api/v2/send-otp"):
assert json == {"method": "sms", "phone": "+966512345678"}
return make_response({"success": True})
if url.endswith("/api/v2/verify-otp"):
if json == {"phone": "+966512345678", "otp": "123456"}:
return make_response({"verified": True})
return make_response({"verified": False})
raise AssertionError(f"Unexpected URL {url}")
with patch.dict(
os.environ,
{
"AUTHENTICA_API_KEY": "api-key",
"AUTHENTICA_TIMEOUT_SECONDS": "7",
},
):
mock_post.side_effect = side_effect
request_url = reverse("phone_auth_request")
verify_url = reverse("phone_auth_verify")
response = client.post(
request_url,
{"phone_number": "0512345678", "channel": "sms", "first_name": "Sara"},
content_type="application/json",
)
assert response.status_code == 201
request_id = response.json()["request_id"]
bad = client.post(
verify_url,
{"request_id": request_id, "code": "000000"},
content_type="application/json",
)
assert bad.status_code == 400
good = client.post(
verify_url,
{"request_id": request_id, "code": "123456"},
content_type="application/json",
)
assert good.status_code == 200
user = User.objects.filter(phone_number="+966512345678").first()
assert user is not None
assert user.is_phone_verified is True
@@ -1,72 +0,0 @@
"""Real Authentica E2E OTP flow. Requires live credentials and a phone receiving OTPs."""
import os
from datetime import timedelta
import pytest
from django.test import override_settings
from django.urls import reverse
from django.utils import timezone
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP, User
from apps.accounts.services.phone import normalize_phone_number
@pytest.mark.django_db
@pytest.mark.external
@override_settings(OTP_PROVIDER="authentica")
def test_authentica_phone_auth_e2e(client):
if os.getenv("AUTHENTICA_E2E") != "1":
pytest.skip("AUTHENTICA_E2E=1 not set")
api_key = os.getenv("AUTHENTICA_API_KEY")
phone_number = os.getenv("AUTHENTICA_E2E_PHONE")
if not api_key or not phone_number:
pytest.skip("Missing AUTHENTICA_API_KEY or AUTHENTICA_E2E_PHONE")
request_url = reverse("phone_auth_request")
response = client.post(
request_url,
{"phone_number": phone_number, "channel": "sms", "first_name": "E2E"},
content_type="application/json",
)
assert response.status_code == 201
request_id = response.json()["request_id"]
assert request_id
code = os.getenv("AUTHENTICA_E2E_CODE")
if not code:
pytest.skip("AUTHENTICA_E2E_CODE not set")
normalized_phone = normalize_phone_number(phone_number)
User.objects.get_or_create(
phone_number=normalized_phone,
defaults={"role": "customer"},
)
if not PhoneOTP.objects.filter(id=request_id).exists():
# Create a local OTP record so the verify endpoint can bind to a request_id.
PhoneOTP.objects.create(
id=request_id,
phone_number=normalized_phone,
channel=OtpChannel.SMS,
purpose=OtpPurpose.AUTH,
provider="authentica",
code_hash="placeholder",
expires_at=timezone.now() + timedelta(minutes=5),
)
verify_url = reverse("phone_auth_verify")
verify = client.post(
verify_url,
{"request_id": request_id, "code": code},
content_type="application/json",
)
assert verify.status_code == 200
data = verify.json()
assert "access" in data
assert "refresh" in data
user = User.objects.filter(phone_number=normalized_phone).first()
assert user is not None
assert user.is_phone_verified is True
@@ -83,6 +83,32 @@ def test_authentica_verify_otp_calls_api(mock_post):
assert kwargs["json"] == {"phone": "+966512345678", "otp": "123456"} 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 @pytest.mark.django_db
def test_verify_otp_uses_provider_for_authentica(): def test_verify_otp_uses_provider_for_authentica():
otp = PhoneOTP.objects.create( otp = PhoneOTP.objects.create(
@@ -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 أو رقم جوال سعودي صالح"
+80 -1
View File
@@ -1,6 +1,10 @@
from datetime import timedelta
from unittest.mock import patch
import pytest import pytest
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.test import override_settings from django.test import override_settings
from django.utils import timezone
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP
from apps.accounts.services.otp import OtpCooldownError, OtpRateLimitError, create_and_send_otp, verify_otp from apps.accounts.services.otp import OtpCooldownError, OtpRateLimitError, create_and_send_otp, verify_otp
@@ -43,7 +47,82 @@ def test_otp_max_attempts_blocks_verification():
otp.refresh_from_db() otp.refresh_from_db()
assert otp.attempt_count == otp.max_attempts assert otp.attempt_count == otp.max_attempts
# Once the max is reached, even a correct code must remain blocked.
assert verify_otp(otp, "123456") is False assert verify_otp(otp, "123456") is False
otp.refresh_from_db() otp.refresh_from_db()
assert otp.attempt_count == otp.max_attempts + 1 # Do not lock this test to a specific increment policy after lockout.
assert otp.attempt_count >= otp.max_attempts
assert otp.verified_at is None 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
@@ -47,3 +47,32 @@ def test_phone_auth_creates_user_and_issues_tokens(client):
user = User.objects.filter(phone_number="+966512345678").first() user = User.objects.filter(phone_number="+966512345678").first()
assert user is not None assert user is not None
assert user.is_phone_verified is True assert user.is_phone_verified is True
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console")
def test_phone_auth_refresh_endpoint_still_works(client):
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
request_response = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345678", "channel": "sms"},
content_type="application/json",
)
request_id = request_response.json()["request_id"]
verify_response = client.post(
reverse("phone_auth_verify"),
{"request_id": request_id, "code": "123456"},
content_type="application/json",
)
assert verify_response.status_code == 200
refresh = verify_response.json()["refresh"]
refresh_response = client.post(
reverse("token_refresh"),
{"refresh": refresh},
content_type="application/json",
)
assert refresh_response.status_code == 200
assert "access" in refresh_response.json()
@@ -0,0 +1,234 @@
from unittest.mock import patch
import pytest
from django.test import override_settings
from django.urls import reverse
from apps.accounts.models import OtpPurpose, PhoneOTP, User
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console")
def test_phone_auth_request_creates_customer_for_new_phone(client):
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
response = client.post(
reverse("phone_auth_request"),
{
"phone_number": "0512345678",
"channel": "sms",
"first_name": "Sara",
"last_name": "Ali",
"email": "sara@example.com",
},
content_type="application/json",
)
assert response.status_code == 201
data = response.json()
assert "request_id" in data
assert "expires_at" in data
user = User.objects.get(phone_number="+966512345678")
assert user.role == "customer"
assert user.is_phone_verified is False
otp = PhoneOTP.objects.get(id=data["request_id"])
assert otp.phone_number == "+966512345678"
assert otp.channel == "sms"
assert otp.purpose == OtpPurpose.AUTH
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console")
def test_phone_auth_request_existing_phone_no_duplicate_user(client):
User.objects.create_user(
phone_number="+966512345678",
email="existing@example.com",
first_name="Existing",
)
before_count = User.objects.filter(phone_number="+966512345678").count()
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
response = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345678", "channel": "sms"},
content_type="application/json",
)
assert response.status_code == 201
assert User.objects.filter(phone_number="+966512345678").count() == before_count
assert PhoneOTP.objects.filter(phone_number="+966512345678").count() == 1
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console")
def test_phone_auth_request_rejects_email_already_used(client):
User.objects.create_user(
phone_number="+966500000001",
email="taken@example.com",
)
before_otp_count = PhoneOTP.objects.count()
response = client.post(
reverse("phone_auth_request"),
{
"phone_number": "0512345678",
"channel": "sms",
"email": "taken@example.com",
},
content_type="application/json",
)
assert response.status_code == 400
assert "detail" in response.json()
assert User.objects.filter(phone_number="+966512345678").count() == 0
assert PhoneOTP.objects.count() == before_otp_count
@pytest.mark.django_db
def test_phone_auth_request_invalid_phone_localized_en(client):
response = client.post(
reverse("phone_auth_request"),
{"phone_number": "123", "channel": "sms"},
content_type="application/json",
HTTP_ACCEPT_LANGUAGE="en",
)
assert response.status_code == 400
assert response.json()["phone_number"][0] == "Phone number must be in E.164 format or a valid Saudi mobile"
@pytest.mark.django_db
def test_phone_auth_request_invalid_phone_localized_ar(client):
response = client.post(
reverse("phone_auth_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 أو رقم جوال سعودي صالح"
@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_phone_auth_request_cooldown_returns_retry_after(client):
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
first = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345678", "channel": "sms"},
content_type="application/json",
)
assert first.status_code == 201
second = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345678", "channel": "sms"},
content_type="application/json",
)
assert second.status_code == 429
data = second.json()
assert "detail" in data
assert data["retry_after_seconds"] > 0
@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_phone_auth_request_rate_limit_returns_retry_after(client):
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
first = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345678", "channel": "sms"},
content_type="application/json",
)
assert first.status_code == 201
second = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345678", "channel": "sms"},
content_type="application/json",
)
assert second.status_code == 429
data = second.json()
assert "detail" in data
assert data["retry_after_seconds"] > 0
@pytest.mark.django_db
@override_settings(
OTP_PROVIDER="console",
OTP_MAX_PER_WINDOW=20,
OTP_WINDOW_MINUTES=15,
OTP_RESEND_COOLDOWN_SECONDS=0,
PHONE_AUTH_RISK_WINDOW_MINUTES=15,
PHONE_AUTH_IP_MAX_PER_WINDOW=2,
PHONE_AUTH_DEVICE_MAX_PER_WINDOW=20,
)
def test_phone_auth_request_ip_throttle_returns_retry_after(client):
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
first = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345678", "channel": "sms"},
content_type="application/json",
REMOTE_ADDR="203.0.113.10",
)
assert first.status_code == 201
second = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345679", "channel": "sms"},
content_type="application/json",
REMOTE_ADDR="203.0.113.10",
)
assert second.status_code == 201
third = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345680", "channel": "sms"},
content_type="application/json",
REMOTE_ADDR="203.0.113.10",
)
assert third.status_code == 429
data = third.json()
assert "detail" in data
assert data["retry_after_seconds"] > 0
@pytest.mark.django_db
@override_settings(
OTP_PROVIDER="console",
OTP_RESEND_COOLDOWN_SECONDS=0,
OTP_MAX_PER_WINDOW=20,
PHONE_AUTH_IP_MAX_PER_WINDOW=20,
PHONE_AUTH_DEVICE_MAX_PER_WINDOW=20,
)
def test_phone_auth_request_persists_request_ip_and_device_signal(client):
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
response = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345678", "channel": "sms", "device_id": "device-abc"},
content_type="application/json",
REMOTE_ADDR="198.51.100.40",
HTTP_USER_AGENT="pytest-agent",
)
assert response.status_code == 201
otp = PhoneOTP.objects.get(id=response.json()["request_id"])
assert otp.request_ip == "198.51.100.40"
assert otp.device_signal
@@ -0,0 +1,107 @@
from unittest.mock import patch
import pytest
from django.db import IntegrityError
from django.urls import reverse
from apps.accounts.models import OtpPurpose, PhoneOTP, User
@pytest.mark.django_db
def test_create_user_rejects_email_only_identity():
# Phone-first invariant: do not allow creating users without phone_number.
with pytest.raises(ValueError):
User.objects.create_user(email="email-only@example.com")
@pytest.mark.django_db
def test_register_requires_phone_number(client):
# Public registration must keep phone as required identifier.
response = client.post(
reverse("register"),
{"email": "new@example.com", "password": "StrongPass123"},
content_type="application/json",
)
assert response.status_code == 400
assert "phone_number" in response.json()
@pytest.mark.django_db
def test_phone_auth_verify_rejects_verify_purpose_otp(client):
user = User.objects.create_user(phone_number="+966512345678")
otp = PhoneOTP.objects.create(
phone_number=user.phone_number,
channel="sms",
purpose=OtpPurpose.VERIFY,
provider="console",
code_hash="not-used",
expires_at=PhoneOTP.expiry_at(),
)
# Purpose boundary: /phone/verify must only accept auth OTP requests.
with patch("apps.accounts.views.verify_otp", return_value=True):
response = client.post(
reverse("phone_auth_verify"),
{"request_id": str(otp.id), "code": "123456"},
content_type="application/json",
)
assert response.status_code == 400
@pytest.mark.django_db
def test_otp_verify_rejects_auth_purpose_otp(client):
otp = PhoneOTP.objects.create(
phone_number="+966512345678",
channel="sms",
purpose=OtpPurpose.AUTH,
provider="console",
code_hash="not-used",
expires_at=PhoneOTP.expiry_at(),
)
# Purpose boundary: /otp/verify must only accept verify OTP requests.
with patch("apps.accounts.views.verify_otp", return_value=True):
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
def test_db_rejects_null_phone_number():
# DB invariant: phone_number is mandatory.
with pytest.raises(IntegrityError):
User.objects.create(email="null-phone@example.com")
@pytest.mark.django_db
def test_db_rejects_non_e164_phone_number():
# DB invariant: store normalized E.164 only.
with pytest.raises(IntegrityError):
User.objects.create_user(phone_number="0512345678")
@pytest.mark.django_db
def test_db_rejects_duplicate_phone_number():
User.objects.create_user(phone_number="+966512345678")
with pytest.raises(IntegrityError):
User.objects.create_user(phone_number="+966512345678")
@pytest.mark.django_db
def test_password_token_endpoint_is_disabled(client):
User.objects.create_user(phone_number="+966512345678", password="StrongPass123")
response = client.post(
reverse("token_obtain_pair"),
{"phone_number": "+966512345678", "password": "StrongPass123"},
content_type="application/json",
)
assert response.status_code == 410
assert "detail" in response.json()
@@ -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",
)
+3 -2
View File
@@ -1,5 +1,5 @@
from django.urls import path from django.urls import path
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView from rest_framework_simplejwt.views import TokenRefreshView
from apps.accounts.views import ( from apps.accounts.views import (
MeView, MeView,
@@ -7,6 +7,7 @@ from apps.accounts.views import (
OTPVerifyView, OTPVerifyView,
PhoneAuthRequestView, PhoneAuthRequestView,
PhoneAuthVerifyView, PhoneAuthVerifyView,
PasswordTokenObtainDeprecatedView,
RegisterView, RegisterView,
SocialLoginPlaceholderView, SocialLoginPlaceholderView,
) )
@@ -14,7 +15,7 @@ from apps.accounts.views import (
urlpatterns = [ urlpatterns = [
path("register/", RegisterView.as_view(), name="register"), path("register/", RegisterView.as_view(), name="register"),
path("me/", MeView.as_view(), name="me"), path("me/", MeView.as_view(), name="me"),
path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), path("token/", PasswordTokenObtainDeprecatedView.as_view(), name="token_obtain_pair"),
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
path("otp/request/", OTPRequestView.as_view(), name="otp_request"), path("otp/request/", OTPRequestView.as_view(), name="otp_request"),
path("otp/verify/", OTPVerifyView.as_view(), name="otp_verify"), path("otp/verify/", OTPVerifyView.as_view(), name="otp_verify"),
+53 -4
View File
@@ -1,5 +1,4 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.shortcuts import get_object_or_404
from rest_framework import generics, permissions, status from rest_framework import generics, permissions, status
from rest_framework.response import Response from rest_framework.response import Response
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@@ -17,8 +16,13 @@ from apps.accounts.serializers import (
) )
from apps.accounts.services.otp import ( from apps.accounts.services.otp import (
OtpCooldownError, OtpCooldownError,
OtpDeviceRateLimitError,
OtpIpRateLimitError,
OtpRateLimitError, OtpRateLimitError,
build_device_signal,
create_and_send_otp, create_and_send_otp,
enforce_phone_auth_request_limits,
normalize_request_ip,
verify_otp, verify_otp,
) )
@@ -70,7 +74,10 @@ class OTPVerifyView(APIView):
serializer = OTPVerifySerializer(data=request.data) serializer = OTPVerifySerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
data = serializer.validated_data data = serializer.validated_data
otp = get_object_or_404(PhoneOTP, id=data["request_id"]) # Purpose isolation: verification endpoint accepts only verify-purpose OTPs.
otp = PhoneOTP.objects.filter(id=data["request_id"], purpose=OtpPurpose.VERIFY).first()
if not otp:
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
if not verify_otp(otp, data["code"]): if not verify_otp(otp, data["code"]):
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
@@ -91,6 +98,25 @@ class PhoneAuthRequestView(APIView):
data = serializer.validated_data data = serializer.validated_data
phone_number = data["phone_number"] phone_number = data["phone_number"]
email = data.get("email") or None email = data.get("email") or None
request_ip = normalize_request_ip(request.META.get("HTTP_X_FORWARDED_FOR") or request.META.get("REMOTE_ADDR"))
device_signal = build_device_signal(
data.get("device_id"),
request.META.get("HTTP_USER_AGENT"),
request.META.get("HTTP_ACCEPT_LANGUAGE"),
)
try:
enforce_phone_auth_request_limits(request_ip=request_ip, device_signal=device_signal)
except OtpIpRateLimitError as exc:
return Response(
{"detail": str(exc), "retry_after_seconds": exc.retry_after_seconds},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
except OtpDeviceRateLimitError as exc:
return Response(
{"detail": str(exc), "retry_after_seconds": exc.retry_after_seconds},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
user = User.objects.filter(phone_number=phone_number).first() user = User.objects.filter(phone_number=phone_number).first()
if not user: if not user:
@@ -108,7 +134,13 @@ class PhoneAuthRequestView(APIView):
) )
try: try:
result = create_and_send_otp(phone_number, data["channel"], purpose=OtpPurpose.AUTH) result = create_and_send_otp(
phone_number,
data["channel"],
purpose=OtpPurpose.AUTH,
request_ip=request_ip,
device_signal=device_signal,
)
except OtpCooldownError as exc: except OtpCooldownError as exc:
return Response( return Response(
{"detail": str(exc), "retry_after_seconds": exc.retry_after_seconds}, {"detail": str(exc), "retry_after_seconds": exc.retry_after_seconds},
@@ -133,7 +165,10 @@ class PhoneAuthVerifyView(APIView):
serializer = PhoneAuthVerifySerializer(data=request.data) serializer = PhoneAuthVerifySerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
data = serializer.validated_data data = serializer.validated_data
otp = get_object_or_404(PhoneOTP, id=data["request_id"]) # Purpose isolation: login endpoint accepts only auth-purpose OTPs.
otp = PhoneOTP.objects.filter(id=data["request_id"], purpose=OtpPurpose.AUTH).first()
if not otp:
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
if not verify_otp(otp, data["code"]): if not verify_otp(otp, data["code"]):
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
@@ -164,3 +199,17 @@ class SocialLoginPlaceholderView(APIView):
{"detail": _("Social login not configured yet. Add OAuth provider config.")}, {"detail": _("Social login not configured yet. Add OAuth provider config.")},
status=status.HTTP_501_NOT_IMPLEMENTED, status=status.HTTP_501_NOT_IMPLEMENTED,
) )
class PasswordTokenObtainDeprecatedView(APIView):
permission_classes = [permissions.AllowAny]
def post(self, request):
return Response(
{
"detail": _(
"Password login is deprecated. Use /api/auth/phone/request/ then /api/auth/phone/verify/."
)
},
status=status.HTTP_410_GONE,
)
@@ -12,9 +12,23 @@ from apps.salons.models import Salon, Service, StaffAvailability, StaffProfile
@pytest.fixture @pytest.fixture
def base_entities(): def base_entities():
owner = User.objects.create_user(email="owner@example.com", password="pass", role=UserRole.MANAGER) owner = User.objects.create_user(
customer = User.objects.create_user(email="customer@example.com", password="pass") email="owner@example.com",
staff_user = User.objects.create_user(email="staff@example.com", password="pass", role=UserRole.STAFF) password="pass",
role=UserRole.MANAGER,
phone_number="+966500000001",
)
customer = User.objects.create_user(
email="customer@example.com",
password="pass",
phone_number="+966500000002",
)
staff_user = User.objects.create_user(
email="staff@example.com",
password="pass",
role=UserRole.STAFF,
phone_number="+966500000003",
)
salon = Salon.objects.create( salon = Salon.objects.create(
owner=owner, owner=owner,
@@ -17,18 +17,18 @@ def booking_payload():
email="owner@example.com", email="owner@example.com",
password="pass", password="pass",
role=UserRole.MANAGER, role=UserRole.MANAGER,
phone_number="0500000001", phone_number="+966500000001",
) )
customer = User.objects.create_user( customer = User.objects.create_user(
email="customer@example.com", email="customer@example.com",
password="pass", password="pass",
phone_number="0500000002", phone_number="+966500000002",
) )
staff_user = User.objects.create_user( staff_user = User.objects.create_user(
email="staff@example.com", email="staff@example.com",
password="pass", password="pass",
role=UserRole.STAFF, role=UserRole.STAFF,
phone_number="0500000003", phone_number="+966500000003",
) )
salon = Salon.objects.create( salon = Salon.objects.create(
@@ -15,9 +15,23 @@ from apps.salons.models import Salon, Service, StaffProfile
@pytest.fixture @pytest.fixture
def booking_entities(): def booking_entities():
owner = User.objects.create_user(email="owner@example.com", password="pass", role=UserRole.MANAGER) owner = User.objects.create_user(
customer = User.objects.create_user(email="customer@example.com", password="pass") email="owner@example.com",
staff_user = User.objects.create_user(email="staff@example.com", password="pass", role=UserRole.STAFF) password="pass",
role=UserRole.MANAGER,
phone_number="+966500000011",
)
customer = User.objects.create_user(
email="customer@example.com",
password="pass",
phone_number="+966500000012",
)
staff_user = User.objects.create_user(
email="staff@example.com",
password="pass",
role=UserRole.STAFF,
phone_number="+966500000013",
)
salon = Salon.objects.create( salon = Salon.objects.create(
owner=owner, owner=owner,
+3
View File
@@ -146,6 +146,9 @@ 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"))
OTP_RESEND_COOLDOWN_SECONDS = int(os.getenv("OTP_RESEND_COOLDOWN_SECONDS", "60")) OTP_RESEND_COOLDOWN_SECONDS = int(os.getenv("OTP_RESEND_COOLDOWN_SECONDS", "60"))
PHONE_AUTH_RISK_WINDOW_MINUTES = int(os.getenv("PHONE_AUTH_RISK_WINDOW_MINUTES", "15"))
PHONE_AUTH_IP_MAX_PER_WINDOW = int(os.getenv("PHONE_AUTH_IP_MAX_PER_WINDOW", "20"))
PHONE_AUTH_DEVICE_MAX_PER_WINDOW = int(os.getenv("PHONE_AUTH_DEVICE_MAX_PER_WINDOW", "20"))
DEFAULT_CURRENCY = os.getenv("DEFAULT_CURRENCY", "SAR") DEFAULT_CURRENCY = os.getenv("DEFAULT_CURRENCY", "SAR")
NOTIFICATION_PROVIDER = os.getenv("NOTIFICATION_PROVIDER", OTP_PROVIDER) NOTIFICATION_PROVIDER = os.getenv("NOTIFICATION_PROVIDER", OTP_PROVIDER)
NOTIFICATION_DEFAULT_CHANNEL = os.getenv("NOTIFICATION_DEFAULT_CHANNEL", "sms") NOTIFICATION_DEFAULT_CHANNEL = os.getenv("NOTIFICATION_DEFAULT_CHANNEL", "sms")
+11
View File
@@ -6,6 +6,17 @@ This document describes the requirements for an execution plan ("ExecPlan"), a d
The current execution plan is `docs/execplans/booking-notifications.md`. It focuses on booking lifecycle notifications (confirmation/cancellation) as the next Phase 1 reliability milestone. Keep it updated in line with the requirements below. The current execution plan is `docs/execplans/booking-notifications.md`. It focuses on booking lifecycle notifications (confirmation/cancellation) as the next Phase 1 reliability milestone. Keep it updated in line with the requirements below.
## Queued Next Review Focus
After the active booking notifications plan, the next reliability review track is phone-first authentication hardening. Keep these points visible when planning the next ExecPlan update:
- Enforce phone as first-class identifier at model/DB boundaries (normalized E.164, nullability/uniqueness policy).
- Consolidate auth contract (phone OTP vs password endpoints) and document intended public login surface.
- Enforce OTP purpose boundaries (`auth` vs `verify`) in verification flows.
- Align Django admin and cross-app display/audit behavior for phone-only users.
- Define OAuth linking policy and conflict handling (phone/email collisions, account merge rules).
- Add/expand tests for phone-first invariants and abuse controls (IP/device throttling strategy review).
## How to use ExecPlans and PLANS.md ## How to use ExecPlans and PLANS.md
When authoring an executable specification (ExecPlan), follow PLANS.md _to the letter_. If it is not in your context, refresh your memory by reading the entire PLANS.md file. Be thorough in reading (and re-reading) source material to produce an accurate specification. When creating a spec, start from the skeleton and flesh it out as you do your research. When authoring an executable specification (ExecPlan), follow PLANS.md _to the letter_. If it is not in your context, refresh your memory by reading the entire PLANS.md file. Be thorough in reading (and re-reading) source material to produce an accurate specification. When creating a spec, start from the skeleton and flesh it out as you do your research.
+2 -4
View File
@@ -6,11 +6,11 @@ Accepted
## Context ## Context
The platform requires phone-first authentication with OTP delivery for KSA. The codebase includes multiple provider adapters (`console`, `twilio`, `unifonic`, `authentica`) but only Authentica is implemented for provider-managed OTP delivery (send/verify) and direct SMS messaging. Twilio and Unifonic adapters are partial or unimplemented; a console provider exists for local development. 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 ## Decision
Use Authentica as the primary OTP provider for the MVP, with `OTP_PROVIDER=authentica` in production environments. Keep `console` for local development and tests, and retain Twilio/Unifonic adapters as scaffolds for future expansion. 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 ## Consequences
@@ -20,8 +20,6 @@ Use Authentica as the primary OTP provider for the MVP, with `OTP_PROVIDER=authe
## Alternatives Considered ## Alternatives Considered
- Twilio as primary provider: not selected due to KSA-focused delivery needs and current adapter gaps.
- Unifonic as primary provider: deferred until the adapter is fully implemented and validated.
## Related ## Related
+207 -45
View File
@@ -1,60 +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 provider classes as an MVP shortcut; sends on booking created/confirmed/cancelled. See note below. |
## Data Model Overview - **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.
The core data model centers on users, salons, and time-bound bookings. A booking ties a customer to a service, a staff member, and a scheduled time. Payments are recorded per booking and reconcile to the external gateway. Notifications are stored for every booking lifecycle message for auditability. ### Frontend (React, Vite)
- `accounts.User` owns phone, locale, and auth preferences. - **Structure**
- `salons.Salon`, `salons.Service`, and `salons.Staff` define the catalog and scheduling surface. - 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)`.
- `bookings.Booking` links customer, staff, service, and scheduled time, with status transitions. - No `react-router` or multi-page routing; the entire experience is one composed screen.
- `payments.Payment` tracks gateway state and idempotency per booking. - **Current Features**
- `notifications.Notification` records each SMS/WhatsApp send attempt tied to a booking event. - **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.
## Data Flow ## Glaring Design And Architectural Issues
``` ### Backend Risks
User → React Frontend → Django API
- **Incomplete provider implementations for production-critical flows**
accounts (auth) ──→ OTP providers (Twilio/Unifonic/console) - Authentica is the only OTP/notification provider; ensure it is fully configured and exercised in production-like environments.
salons (catalog) - `MoyasarGateway` lacks `capture_payment` and `refund_payment` implementations, limiting payment lifecycle coverage.
bookings ──→ notifications ──→ OTP providers - **Risk**: Code reads “production ready” at the API level, but the underlying integrations are not, which could cause outages if deployed naively.
payments ──→ Moyasar gateway - **Tight coupling between OTP and notifications**
- Notification services import the OTP provider mapping and default `NOTIFICATION_PROVIDER` to `OTP_PROVIDER`, binding booking notifications to auth configuration.
- **Risk**: Changing OTP providers or adding a second channel for marketing/ops notifications will be harder and could have unintended side effects.
- **Synchronous IO-heavy work in request/response path**
- OTP sends, booking notifications, and payment gateway calls all occur synchronously inside view methods (`perform_create`, `create`, etc.).
- **Risk**: Slow or flaky providers will degrade API latency and user experience; retries and backoff are hard to implement without background jobs.
- **Cross-app domain coupling without a clear orchestration layer**
- `apps.bookings` depends on salons and notifications; notifications depend on accounts (OTP providers) and bookings; payments depend on bookings.
- **Risk**: As you add more lifecycle rules (e.g., auto-confirm booking on payment, send reminders, handle refunds), the spaghetti of cross-imports will grow unless you introduce clearer service boundaries.
- **Auth model vs login patterns**
- `User.USERNAME_FIELD` is email, while phone-based JWT issuance happens via custom endpoints.
- **Risk**: This split can confuse clients and admin tooling and may complicate future flows like social login or SSO unless you standardize on an identifier strategy.
- **Docs drift around ExecPlans**
- `AGENTS.md` references `docs/execplans/payments-moyasar.md` as the active plan, while `PLANS.md` names `docs/execplans/booking-notifications.md`.
- **Risk**: Contributors may follow different “active” plans, causing architectural inconsistency.
### Frontend Risks
- **Monolithic `App` component with no routing**
- `App.jsx` mixes hero/search, salon listing, payments, and locale controls.
- There is no `react-router` or notion of separate flows (auth, booking, profile, payments).
- **Risk**: Extending to full MVP flows (auth, booking, history, management) will quickly become unmanageable without a routing/page system and domain separation.
- **Domain logic embedded in UI components**
- API payload construction, validation rules (e.g. for source types), and error handling are implemented directly in `App.jsx` rather than reusable hooks or service modules.
- **Risk**: Code reuse, testing, and evolution (e.g., adding booking pages or admin consoles) will be painful.
- **Minimal test coverage for critical flows**
- Only i18n and hero copy are tested; search behavior, API integration, and payments are untested.
- **Risk**: Regressions in search, booking, and payments UX will slip through as MVP grows.
- **Styling & layout fragility**
- `frontend/src/styles.css` uses `::root` instead of `:root`, which likely breaks intended global CSS variables or base styles.
- Global CSS is tightly bound to the monolithic `App` layout.
- **Risk**: Visual regressions and layout churn when introducing additional pages or components.
- **Ad hoc auth token handling**
- The “access token” is a free-form text field that gets persisted as `auth_token` in `localStorage` and injected into payment requests.
- **Risk**: This is a placeholder pattern that does not scale to full auth (refresh tokens, logout, token rotation) and will need to be replaced.
### Cross-Cutting Risks
- **Lack of async/background processing**
- No Celery/RQ or similar job queue; all side effects are synchronous.
- **Risk**: Scaling SMS/WhatsApp notifications, email, and payment webhook fan-out will be difficult.
- **Observability and admin tooling gaps**
- Errors for payments and notifications are recorded in model metadata but not clearly surfaced in logs, dashboards, or admin views.
- **Risk**: Operational debugging during MVP rollout will be slower and more error-prone.
- **Internationalization strategy vs future markets**
- Phone normalization and defaults are tailored to KSA, which is correct for MVP, but `docs/risks.md` already notes the need to broaden later.
- **Risk**: Without clear boundaries between KSA-specific logic and generic logic, future expansion may require invasive changes.
## MVP Roadmap (Aligned To Phase 1)
This roadmap assumes “MVP” is equivalent to **Phase 1: Core MVP Reliability** in `AGENTS.md`, with a thin but robust frontend on top.
### Phase 0 Architecture & Production Readiness Hardening
- **Finalize critical provider implementations**
- Ensure Authentica OTP/SMS is fully configured and validated end-to-end behind the provider abstraction in `[backend/apps/accounts/services/otp.py](backend/apps/accounts/services/otp.py)`, and wired into `[backend/apps/notifications/services.py](backend/apps/notifications/services.py)`.
- Implement or deliberately fence off `capture_payment` and `refund_payment` in `[backend/apps/payments/services/gateway.py](backend/apps/payments/services/gateway.py)` so that the MVP either fully supports or explicitly does not support partial captures/refunds.
- **Clarify and document boundaries**
- Add a short architecture section in `README`/docs describing how `accounts`, `salons`, `bookings`, `payments`, and `notifications` interact, and what each service is responsible for.
- Resolve the ExecPlan drift by making `AGENTS.md` and `PLANS.md` agree on the current active plan.
- **Introduce minimal async infrastructure (optional but recommended)**
- Decide whether MVP will ship with a task queue (e.g., Celery with Redis) or keep everything synchronous for the initial launch.
- If yes, introduce a thin task layer for OTP sends and booking notifications while preserving current APIs; if not, at least add clear timeouts/logging to external calls.
- **Frontend scaffolding for growth**
- Introduce `react-router` and refactor `App.jsx` into route-based pages (e.g., `HomePage`, `BookPage`, `PaymentPage`, `ProfilePage`), with shared layout and navigation.
- Extract salon search, payment form, and locale controls into dedicated components and hooks.
### Phase 1 Core MVP Features (Backend + Frontend)
- **Phone-first auth UX**
- Backend: reuse existing phone auth endpoints; ensure error messages and rate-limit responses are predictable and localized.
- Frontend:
- Build OTP-based login/registration screens that drive `/api/auth/phone/request/` and `/api/auth/phone/verify/`.
- Introduce an auth context (or similar) to store access/refresh tokens, current user profile, and handle logout.
- Defer social login beyond MVP, but keep API surface ready for it.
- **Booking search and creation**
- Backend is largely ready (booking validation and role-based access); review booking serializers in `[backend/apps/bookings/serializers.py](backend/apps/bookings/serializers.py)` to ensure they expose all fields needed for frontend booking forms.
- Frontend:
- Build a **booking flow**: pick a salon → choose service → select staff (optional) → select date/time slot (based on availability endpoints) → confirm booking.
- Add a “My bookings” page showing upcoming and past bookings, tied into the existing `/api/bookings/` endpoints.
- **Payments via Moyasar**
- Backend: confirm `create_payment_for_booking` contracts (inputs/outputs) are stable and documented.
- Frontend:
- Evolve the payments beta UI into a **post-booking payment step** that starts from a selected booking and guides the user into Moyasars hosted flow, then shows a status page.
- Handle callback/return from Moyasar (even if via manual redirect URL in MVP) and surface payment success/failure to the user.
- **Booking lifecycle notifications**
- Backend already sends notifications on booking create and status changes; align messaging templates with product UX and ensure localization strings exist.
- Frontend: surface notification results implicitly via booking status changes and explicit messages on the booking details page.
- **Localization foundations**
- Backend: ensure `UserLocaleMiddleware` and translation strings cover all user-visible errors in auth, bookings, payments, and notifications.
- Frontend: expand `en/ar-sa` translations to cover auth, booking, and payment flows; verify RTL layouts on the new screens.
- **Tests for critical flows**
- Backend: extend tests where needed to cover new booking/payment edge cases (e.g., tying booking status to payment status if/when introduced).
- Frontend: add Vitest tests for:
- Phone auth screen flows (request/verify success + errors).
- Booking flow (form validation, happy path, displaying server-side errors).
- Payment initiation from an existing booking.
### Phase 2 Manager Ops Lite (Post-MVP, partially covered now)
- **Salon and staff management UI**
- Use existing salon and staff models to build basic management pages for salon owners/managers (create/update services, staff, availability).
- **Calendar views and rescheduling**
- Provide calendar views for staff/managers to view daily/weekly bookings and reschedule or cancel within defined rules.
- **Reviews and ratings**
- Implement review submission and rating recalculation on the backend, with corresponding frontend components.
- **Reporting basics**
- Lightweight reports for managers (upcoming bookings, simple revenue summaries based on payment status) using existing payments data.
### Phase 3 Scale & Compliance (Later)
- **Audit logging** for admin actions and booking/payment state changes.
- **PDPL/GDPR retention policies** and data export tooling.
- **Observability**: structured logging, metrics, and basic dashboards for auth failures, OTP send failures, payment errors, and notification outcomes.
## Architecture Overview Diagram
A simplified view of the target MVP data flow:
```mermaid
flowchart LR
user["User (web/mobile)"] --> frontend["ReactFrontend"]
frontend --> api["DjangoAPI"]
api --> accounts["AccountsApp"]
api --> salons["SalonsApp"]
api --> bookings["BookingsApp"]
api --> payments["PaymentsApp"]
api --> notifications["NotificationsApp"]
accounts --> otpProviders["OtpProviders"]
notifications --> otpProviders
payments --> moyasar["MoyasarGateway"]
bookings --> notifications
bookings --> payments
salons --> bookings
``` ```
## Notification / OTP Provider Coupling (MVP Shortcut)
`notifications/services.py` imports `PROVIDERS` from `apps.accounts.services.otp` and uses OTP provider instances (e.g. `AuthenticaOtpProvider`) to send booking SMSes. This works today because Authentica handles both authentication OTPs and general SMS delivery.
Consequences of this coupling: This diagram clarifies current coupling and highlights where future refactors (e.g., a dedicated messaging service or payment orchestrator) could sit.
- Notifications and OTP delivery cannot independently use different providers (e.g. Twilio for OTP, Unifonic for notifications).
- The `notifications` app is conceptually coupled to the `accounts` app's auth infrastructure.
This is an acceptable MVP shortcut. Before Phase 2, introduce a dedicated `NotificationProvider` abstraction in `notifications/` (mirroring `OtpProvider`) so the two systems can be configured and tested independently. ## Validation And Acceptance For This Plan
## Async and Observability (MVP Decision) - The roadmap is accepted when:
- It clearly maps current backend and frontend capabilities to the Phase 1 MVP goals in `AGENTS.md`.
**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. - It identifies the highest-risk design/architecture issues that could impede MVP reliability or future evolution.
This is captured in ADR 0001 (`docs/adr/0001-synchronous-external-calls-mvp.md`). - 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.
**Rationale:**
- Reduces deployment complexity (no Redis, no worker processes).
- MVP traffic is expected to be low; synchronous latency is acceptable.
- External calls already use timeouts (e.g. Moyasar: 10s, Twilio: SDK default).
**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.
**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.
+13 -3
View File
@@ -4,10 +4,21 @@ 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 now include per-phone limits plus `/phone/request` IP/device window controls; thresholds still need production tuning.
- Authentica OTP provider is implemented (SMS + WhatsApp via Authentica OTP); 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 now `"phone_number"`; `REQUIRED_FIELDS = []`; `create_superuser` accepts `phone_number`. Admin and `createsuperuser` work correctly for phone-only users. - `USERNAME_FIELD` is now `"phone_number"`; `REQUIRED_FIELDS = []`; `create_superuser` accepts `phone_number`. Admin and `createsuperuser` work correctly for phone-only users.
- Password token obtain endpoint (`/api/auth/token/`) is deprecated (`410 Gone`); phone OTP flow is the login source of truth.
- OTP purpose isolation is enforced at verification endpoint boundaries (`/otp/verify` accepts only `verify`, `/phone/verify` accepts only `auth`).
- Django admin user configuration remains email-centric (ordering/add form defaults), increasing operational friction for phone-only accounts.
- Multiple serializers/model `__str__` paths in non-auth apps still fallback to `user.email`; phone-only users may get poor display/audit clarity.
## Next Auth Review Points
- DB-level guardrails for `accounts_user.phone_number` are now enforced (`NOT NULL`, `UNIQUE`, E.164 check constraint).
- Decide user lifecycle for phone auth (create user before OTP verify vs provisional/pre-user state).
- Abuse-control implementation for `/api/auth/phone/request/` is in place (IP throttling + persisted device signal); next step is monitor false positives and tune limits.
- Define OAuth account-linking policy (phone/email conflicts, merge rules, trust source).
- Add explicit tests for remaining phone-first invariants (verified-phone guards and any legacy-path regressions).
## 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.
@@ -29,4 +40,3 @@ This file tracks known gaps and risks to address in future iterations.
- No audit logs for admin actions. - No audit logs for admin actions.
- No multi-tenant isolation or data export tooling. - No multi-tenant isolation or data export tooling.
- No GDPR/PDPL data retention policies defined. - No GDPR/PDPL data retention policies defined.
- CI baseline exists, but needs Gitea runner registration and required-check enforcement.
+1
View File
@@ -26,6 +26,7 @@ Guide for diagnosing and mitigating OTP send or verify failures in phone-first a
- If provider credentials are missing or invalid, fix the environment variables and restart the API process. - 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 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 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.
- For phone-login abuse spikes, also validate `PHONE_AUTH_IP_MAX_PER_WINDOW`, `PHONE_AUTH_DEVICE_MAX_PER_WINDOW`, and `PHONE_AUTH_RISK_WINDOW_MINUTES`.
- If verification is failing, confirm server time is correct and `OTP_EXPIRY_MINUTES` is appropriate. - If verification is failing, confirm server time is correct and `OTP_EXPIRY_MINUTES` is appropriate.
## Rollback / Escalation ## Rollback / Escalation
+5 -5
View File
@@ -58,18 +58,18 @@ Expected: server starts at `http://127.0.0.1:8000/`.
### 4) Obtain a JWT access token ### 4) Obtain a JWT access token
Password token login at `/api/auth/token/` is deprecated for phone-first auth. For this runbook, mint a local JWT in Django shell.
The demo customer is: The demo customer is:
- `customer@example.com` - `customer@example.com`
- `Customer123!` - `Customer123!`
Fetch the access token: Generate an access token:
curl -s -X POST http://127.0.0.1:8000/api/auth/token/ \ python3 manage.py shell -c "from django.contrib.auth import get_user_model; from rest_framework_simplejwt.tokens import RefreshToken; u=get_user_model().objects.get(email='customer@example.com'); print(str(RefreshToken.for_user(u).access_token))"
-H "Content-Type: application/json" \
-d '{"email":"customer@example.com","password":"Customer123!"}'
Expected: JSON containing `access` and `refresh` tokens. Expected: a JWT string printed to stdout. Use it as `<ACCESS>`.
### 5) Create a payment ### 5) Create a payment
-248
View File
@@ -1,248 +0,0 @@
---
name: salon-mvp-roadmap
overview: High-level roadmap to bring the existing Salon Django/React codebase to a reliable MVP aligned with Phase 1 goals in AGENTS.md, plus a review of current architecture and major risks.
todos:
- id: backend-providers-readiness
content: "Harden backend providers: implement at least one real SMS/WhatsApp provider and clarify Moyasar capture/refund behavior for MVP."
status: pending
- id: async-and-observability
content: Decide on async task infrastructure and observability basics for OTP, notifications, and payments, and document the choice.
status: pending
- id: frontend-structure-and-routing
content: Refactor frontend into routed pages with separated components/hooks for search, auth, booking, and payments.
status: pending
- id: auth-and-booking-flows
content: Implement phone-first auth and end-to-end booking flows on the frontend using existing backend APIs.
status: pending
- id: payments-and-notifications-ux
content: Integrate payment initiation and booking lifecycle notifications into user-facing flows, including success/error handling.
status: pending
- id: tests-for-critical-flows
content: Expand backend and frontend tests to cover auth, booking, payment, and notification critical paths for MVP reliability.
status: pending
isProject: false
---
# Salon MVP Roadmap And Architecture Review
## Purpose / Big Picture
This plan reviews the current Salon codebase (Django backend, React/Vite frontend), highlights architectural and design risks, and lays out a pragmatic roadmap to reach an MVP that aligns with **Phase 1: Core MVP Reliability** in `AGENTS.md`: phone-first auth with OTP, robust booking integrity, Moyasar payments, booking lifecycle notifications, localization foundations, and tests for critical flows.
The roadmap assumes a KSA-focused first launch (phone auth and Riyadh timezone defaults) with a clean path to expand later.
## Current State Summary
### Backend (Django, DRF)
- **Project**: `salon_api` in `[backend/salon_api](backend/salon_api)`
- `settings.py` configures `AUTH_USER_MODEL = "accounts.User"`, DRF + SimpleJWT, KSA locale defaults, and custom settings for OTP, notifications, and payments.
- URLs route to app-level APIs at `/api/auth/`, `/api/salons/`, `/api/bookings/`, `/api/payments/`.
- **Auth & Accounts** (`[backend/apps/accounts](backend/apps/accounts)`)
- Custom `User` model with phone-first capabilities (`phone_number`, `is_phone_verified`, `preferred_language`, `role`).
- Phone normalization services tuned for KSA numbers (`[backend/apps/accounts/services/phone.py](backend/apps/accounts/services/phone.py)`).
- OTP domain + service layer with rate limits, cooldowns, expiry, and hashed codes (`[backend/apps/accounts/services/otp.py](backend/apps/accounts/services/otp.py)`).
- Phone-first auth endpoints that issue JWT tokens on successful OTP verification, plus basic email/password registration.
- Social login endpoint is a placeholder that always returns 501.
- **Salons & Catalog** (`[backend/apps/salons](backend/apps/salons)`)
- Models for `Salon`, `Service`, `StaffProfile`, `StaffAvailability`, `Review`, `SalonPhoto`.
- Read-only APIs for salon search, services, staff, and reviews.
- **Bookings** (`[backend/apps/bookings](backend/apps/bookings)`)
- `Booking` model ties together salon, service, staff, customer, time window, price, and status.
- `validate_booking_request` in `[backend/apps/bookings/services.py](backend/apps/bookings/services.py)` enforces staff membership, duration matching, availability windows, and overlap prevention for pending/confirmed bookings.
- `BookingViewSet` in `[backend/apps/bookings/views.py](backend/apps/bookings/views.py)` applies role-based access and hooks into notifications on create and relevant status changes.
- **Payments (Moyasar)** (`[backend/apps/payments](backend/apps/payments)`)
- `Payment` model tracks provider, status (fine-grained transitions), amount, idempotency key, external ID, payload, and timestamps.
- `MoyasarGateway` in `[backend/apps/payments/services/gateway.py](backend/apps/payments/services/gateway.py)` can create payments via HTTP but has `capture_payment`/`refund_payment` as TODOs.
- `create_payment_for_booking` in `[backend/apps/payments/services/payments.py](backend/apps/payments/services/payments.py)` enforces provider choice (Moyasar only), idempotency, and maps webhook events into internal statuses.
- Webhook view in `[backend/apps/payments/views.py](backend/apps/payments/views.py)` authenticates via shared secret and applies provider events idempotently.
- **Notifications** (`[backend/apps/notifications](backend/apps/notifications)`)
- `Notification` model records booking-related notifications and enforces uniqueness per booking/recipient/event/channel.
- Services in `[backend/apps/notifications/services.py](backend/apps/notifications/services.py)` reuse OTP providers to send SMS/WhatsApp messages for booking created/confirmed/cancelled events, with localization via `preferred_language`.
- Booking views call `notify_booking_lifecycle` / `notify_on_status_change` to trigger notifications for customers and staff.
- **Testing**
- Solid tests around:
- Phone normalization, OTP rate limiting, and phone auth flows (`[backend/apps/accounts/tests](backend/apps/accounts/tests)`).
- Booking integrity and overlap rules (`[backend/apps/bookings/tests](backend/apps/bookings/tests)`).
- Payment idempotency and Moyasar webhook handling (`[backend/apps/payments/tests](backend/apps/payments/tests)`).
- Booking notifications on create/status change (`[backend/apps/notifications/tests](backend/apps/notifications/tests)`).
- `docs/risks.md` explicitly tracks several known gaps around auth, booking, payments, data/UX, and ops.
### Frontend (React, Vite)
- **Structure**
- Vite React app at `[frontend](frontend)` with entry in `[frontend/src/main.jsx](frontend/src/main.jsx)` and single top-level component in `[frontend/src/App.jsx](frontend/src/App.jsx)`.
- No `react-router` or multi-page routing; the entire experience is one composed screen.
- **Current Features**
- **Salon search**
- Text search field calling `/salons/?q=<query>` via a small API client in `[frontend/src/api/client.js](frontend/src/api/client.js)`.
- Renders responsive list of salons with rating, city, and phone.
- **Localization/i18n**
- `react-i18next` setup in `[frontend/src/i18n/index.js](frontend/src/i18n/index.js)` with `en` and `ar-sa` translations.
- Locale preference stored in `localStorage`; applies `lang` and `dir` on the document.
- **Payments beta**
- A form in `App.jsx` that sends payment creation requests to `/api/payments/` using the Moyasar-style payload, with configurable `booking_id`, source type, token, and callback URL.
- Optionally includes a Bearer token from a manually-entered access token field.
- On success, can redirect to `redirect_url` and shows the raw JSON response.
- **State & Tests**
- All state is local to `App.jsx` via `useState`/`useEffect`; there is no centralized state management or domain hooks yet.
- A single test file `[frontend/src/App.test.jsx](frontend/src/App.test.jsx)` covers hero copy and locale/RTL behavior, but not search or payments.
## Glaring Design And Architectural Issues
### Backend Risks
- **Incomplete provider implementations for production-critical flows**
- Twilio/Unifonic providers in `[backend/apps/accounts/services/otp.py](backend/apps/accounts/services/otp.py)` are stubs with `NotImplementedError` for send methods, yet they are the backbone for both OTP and booking notifications.
- `MoyasarGateway` lacks `capture_payment` and `refund_payment` implementations, limiting payment lifecycle coverage.
- **Risk**: Code reads “production ready” at the API level, but the underlying integrations are not, which could cause outages if deployed naively.
- **Tight coupling between OTP and notifications**
- Notification services import the OTP provider mapping and default `NOTIFICATION_PROVIDER` to `OTP_PROVIDER`, binding booking notifications to auth configuration.
- **Risk**: Changing OTP providers or adding a second channel for marketing/ops notifications will be harder and could have unintended side effects.
- **Synchronous IO-heavy work in request/response path**
- OTP sends, booking notifications, and payment gateway calls all occur synchronously inside view methods (`perform_create`, `create`, etc.).
- **Risk**: Slow or flaky providers will degrade API latency and user experience; retries and backoff are hard to implement without background jobs.
- **Cross-app domain coupling without a clear orchestration layer**
- `apps.bookings` depends on salons and notifications; notifications depend on accounts (OTP providers) and bookings; payments depend on bookings.
- **Risk**: As you add more lifecycle rules (e.g., auto-confirm booking on payment, send reminders, handle refunds), the spaghetti of cross-imports will grow unless you introduce clearer service boundaries.
- **Auth model vs login patterns**
- `User.USERNAME_FIELD` is email, while phone-based JWT issuance happens via custom endpoints.
- **Risk**: This split can confuse clients and admin tooling and may complicate future flows like social login or SSO unless you standardize on an identifier strategy.
- **Docs drift around ExecPlans**
- `AGENTS.md` references `docs/execplans/payments-moyasar.md` as the active plan, while `PLANS.md` names `docs/execplans/booking-notifications.md`.
- **Risk**: Contributors may follow different “active” plans, causing architectural inconsistency.
### Frontend Risks
- **Monolithic `App` component with no routing**
- `App.jsx` mixes hero/search, salon listing, payments, and locale controls.
- There is no `react-router` or notion of separate flows (auth, booking, profile, payments).
- **Risk**: Extending to full MVP flows (auth, booking, history, management) will quickly become unmanageable without a routing/page system and domain separation.
- **Domain logic embedded in UI components**
- API payload construction, validation rules (e.g. for source types), and error handling are implemented directly in `App.jsx` rather than reusable hooks or service modules.
- **Risk**: Code reuse, testing, and evolution (e.g., adding booking pages or admin consoles) will be painful.
- **Minimal test coverage for critical flows**
- Only i18n and hero copy are tested; search behavior, API integration, and payments are untested.
- **Risk**: Regressions in search, booking, and payments UX will slip through as MVP grows.
- **Styling & layout fragility**
- `frontend/src/styles.css` uses `::root` instead of `:root`, which likely breaks intended global CSS variables or base styles.
- Global CSS is tightly bound to the monolithic `App` layout.
- **Risk**: Visual regressions and layout churn when introducing additional pages or components.
- **Ad hoc auth token handling**
- The “access token” is a free-form text field that gets persisted as `auth_token` in `localStorage` and injected into payment requests.
- **Risk**: This is a placeholder pattern that does not scale to full auth (refresh tokens, logout, token rotation) and will need to be replaced.
### Cross-Cutting Risks
- **Lack of async/background processing**
- No Celery/RQ or similar job queue; all side effects are synchronous.
- **Risk**: Scaling SMS/WhatsApp notifications, email, and payment webhook fan-out will be difficult.
- **Observability and admin tooling gaps**
- Errors for payments and notifications are recorded in model metadata but not clearly surfaced in logs, dashboards, or admin views.
- **Risk**: Operational debugging during MVP rollout will be slower and more error-prone.
- **Internationalization strategy vs future markets**
- Phone normalization and defaults are tailored to KSA, which is correct for MVP, but `docs/risks.md` already notes the need to broaden later.
- **Risk**: Without clear boundaries between KSA-specific logic and generic logic, future expansion may require invasive changes.
## MVP Roadmap (Aligned To Phase 1)
This roadmap assumes “MVP” is equivalent to **Phase 1: Core MVP Reliability** in `AGENTS.md`, with a thin but robust frontend on top.
### Phase 0 Architecture & Production Readiness Hardening
- **Finalize critical provider implementations**
- Implement at least one real SMS/WhatsApp provider (Twilio or Unifonic) end-to-end, behind the existing provider abstraction in `[backend/apps/accounts/services/otp.py](backend/apps/accounts/services/otp.py)`, and wire it into `[backend/apps/notifications/services.py](backend/apps/notifications/services.py)`.
- Implement or deliberately fence off `capture_payment` and `refund_payment` in `[backend/apps/payments/services/gateway.py](backend/apps/payments/services/gateway.py)` so that the MVP either fully supports or explicitly does not support partial captures/refunds.
- **Clarify and document boundaries**
- Add a short architecture section in `README`/docs describing how `accounts`, `salons`, `bookings`, `payments`, and `notifications` interact, and what each service is responsible for.
- Resolve the ExecPlan drift by making `AGENTS.md` and `PLANS.md` agree on the current active plan.
- **Introduce minimal async infrastructure (optional but recommended)**
- Decide whether MVP will ship with a task queue (e.g., Celery with Redis) or keep everything synchronous for the initial launch.
- If yes, introduce a thin task layer for OTP sends and booking notifications while preserving current APIs; if not, at least add clear timeouts/logging to external calls.
- **Frontend scaffolding for growth**
- Introduce `react-router` and refactor `App.jsx` into route-based pages (e.g., `HomePage`, `BookPage`, `PaymentPage`, `ProfilePage`), with shared layout and navigation.
- Extract salon search, payment form, and locale controls into dedicated components and hooks.
### Phase 1 Core MVP Features (Backend + Frontend)
- **Phone-first auth UX**
- Backend: reuse existing phone auth endpoints; ensure error messages and rate-limit responses are predictable and localized.
- Frontend:
- Build OTP-based login/registration screens that drive `/api/auth/phone/request/` and `/api/auth/phone/verify/`.
- Introduce an auth context (or similar) to store access/refresh tokens, current user profile, and handle logout.
- Defer social login beyond MVP, but keep API surface ready for it.
- **Booking search and creation**
- Backend is largely ready (booking validation and role-based access); review booking serializers in `[backend/apps/bookings/serializers.py](backend/apps/bookings/serializers.py)` to ensure they expose all fields needed for frontend booking forms.
- Frontend:
- Build a **booking flow**: pick a salon → choose service → select staff (optional) → select date/time slot (based on availability endpoints) → confirm booking.
- Add a “My bookings” page showing upcoming and past bookings, tied into the existing `/api/bookings/` endpoints.
- **Payments via Moyasar**
- Backend: confirm `create_payment_for_booking` contracts (inputs/outputs) are stable and documented.
- Frontend:
- Evolve the payments beta UI into a **post-booking payment step** that starts from a selected booking and guides the user into Moyasars hosted flow, then shows a status page.
- Handle callback/return from Moyasar (even if via manual redirect URL in MVP) and surface payment success/failure to the user.
- **Booking lifecycle notifications**
- Backend already sends notifications on booking create and status changes; align messaging templates with product UX and ensure localization strings exist.
- Frontend: surface notification results implicitly via booking status changes and explicit messages on the booking details page.
- **Localization foundations**
- Backend: ensure `UserLocaleMiddleware` and translation strings cover all user-visible errors in auth, bookings, payments, and notifications.
- Frontend: expand `en/ar-sa` translations to cover auth, booking, and payment flows; verify RTL layouts on the new screens.
- **Tests for critical flows**
- Backend: extend tests where needed to cover new booking/payment edge cases (e.g., tying booking status to payment status if/when introduced).
- Frontend: add Vitest tests for:
- Phone auth screen flows (request/verify success + errors).
- Booking flow (form validation, happy path, displaying server-side errors).
- Payment initiation from an existing booking.
### Phase 2 Manager Ops Lite (Post-MVP, partially covered now)
- **Salon and staff management UI**
- Use existing salon and staff models to build basic management pages for salon owners/managers (create/update services, staff, availability).
- **Calendar views and rescheduling**
- Provide calendar views for staff/managers to view daily/weekly bookings and reschedule or cancel within defined rules.
- **Reviews and ratings**
- Implement review submission and rating recalculation on the backend, with corresponding frontend components.
- **Reporting basics**
- Lightweight reports for managers (upcoming bookings, simple revenue summaries based on payment status) using existing payments data.
### Phase 3 Scale & Compliance (Later)
- **Audit logging** for admin actions and booking/payment state changes.
- **PDPL/GDPR retention policies** and data export tooling.
- **Observability**: structured logging, metrics, and basic dashboards for auth failures, OTP send failures, payment errors, and notification outcomes.
## Architecture Overview Diagram
A simplified view of the target MVP data flow:
```mermaid
flowchart LR
user["User (web/mobile)"] --> frontend["ReactFrontend"]
frontend --> api["DjangoAPI"]
api --> accounts["AccountsApp"]
api --> salons["SalonsApp"]
api --> bookings["BookingsApp"]
api --> payments["PaymentsApp"]
api --> notifications["NotificationsApp"]
accounts --> otpProviders["OtpProviders"]
notifications --> otpProviders
payments --> moyasar["MoyasarGateway"]
bookings --> notifications
bookings --> payments
salons --> bookings
```
This diagram clarifies current coupling and highlights where future refactors (e.g., a dedicated messaging service or payment orchestrator) could sit.
## Validation And Acceptance For This Plan
- The roadmap is accepted when:
- It clearly maps current backend and frontend capabilities to the Phase 1 MVP goals in `AGENTS.md`.
- It identifies the highest-risk design/architecture issues that could impede MVP reliability or future evolution.
- It provides a phased, concrete sequence of work packages that can be implemented via ExecPlans (e.g., booking notifications, Moyasar payments, phone auth UX).
- Each major feature area (auth, bookings, payments, notifications, localization, tests) should have or adopt an ExecPlan under `docs/execplans/` in line with `PLANS.md` before implementation begins.