15 Commits

Author SHA1 Message Date
mohd 6428459313 failed tests btw 2026-03-14 23:42:21 +03:00
mohd 2a8b6a7b62 feat: added initial implementation 2026-03-14 23:30:56 +03:00
mohd 8b626a940e chore: condense all docs and markdown files 2026-03-14 15:11:40 +03:00
mohd f3811b7520 fix: catch race conditions when creating users 2026-03-14 15:11:19 +03:00
mohd 9787fb699a feat: deprecate email, pre-verify users + documentation 2026-03-14 14:40:52 +03:00
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
66 changed files with 2356 additions and 1432 deletions
+1
View File
@@ -0,0 +1 @@
venv
+27 -71
View File
@@ -1,81 +1,37 @@
# AGENTS.md # AGENTS.md
## General ## General
- Minimum tokens, skip grammar.
- Start every task by reading this file and `docs/README.md`.
Minimum tokens, skip grammar ## Goal
Build a reliable salon booking platform (Django + React) for KSA first, with clear path to scale.
## Tasks ## Core Standards
- Consult `AGENTS.md` about the current task to see if there are any tips or instructions - Tests required for every feature/change.
- Consult `docs/README.md` for any relevant files or tips to consider - Booking/payment flows must be idempotent and auditable.
- Phone auth must be abuse-resistant (rate limits + safe verification).
- Keep business logic in services; avoid fat views.
- Use predictable API errors: HTTP status + `detail`.
## Project Goal ## Active Navigation Path
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. - Docs index: `docs/README.md`
- ExecPlan policy + active plan: `docs/PLANS.md`
- Active ExecPlan: `docs/execplans/auth-phone-first-hardening.md`
- Architecture snapshot: `docs/architecture.md`
- Known gaps: `docs/risks.md`
## Coding ## Roadmap (compact)
- Phase 1: phone OTP auth, booking integrity, Moyasar payments, lifecycle notifications, i18n/RTL base, critical tests.
- Comment concisely and often, especially where intent, edge cases, or business rules are not obvious. - Phase 2: manager tools (catalog/staff/calendar/reports/reviews).
- Phase 3: audit/compliance/observability.
## Current Plan (Roadmap)
### Phase 1: Core MVP Reliability
- Phone-first auth with OTP (SMS/WhatsApp), rate limits, and social login.
- Booking integrity (availability, staff schedules, overlap prevention).
- Payments via Moyasar (payment creation, webhooks, reconciliation).
- Notifications for booking lifecycle.
- Localization foundations (i18n plumbing, RTL readiness, locale preferences).
- Tests for critical flows.
### Phase 2: Manager Ops
- Salon/staff/service management.
- Calendar view + rescheduling/cancellation rules.
- Reviews/ratings with moderation and recompute.
- Reports (revenue, popular services, customer trends).
- Full translation coverage and Arabic UX polish.
### Phase 3: Scale & Compliance
- Audit logs and data export.
- PDPL/GDPR retention policy.
- Observability and performance baselines.
## Reliability Standards (NonNegotiable)
- Every new feature ships with tests.
- Avoid introducing regressions without covering a fix or guardrail.
- Payment and booking flows must be idempotent and auditable.
- Phone auth must be ratelimited and safe from abuse.
- Every change should be tested before completion; no exceptions.
## Testing Expectations ## Testing Expectations
### Backend - Backend: `pytest` + `pytest-django`, tests in `backend/apps/<app>/tests/`.
- Use `pytest` + `pytest-django`. - Frontend: `vitest` + Testing Library, cover search/booking/auth flows.
- Tests live beside apps (`apps/<app>/tests/`). - Backend tests run from `backend/` so `pytest.ini` resolves settings.
- Minimum coverage: auth, booking validation, payment state transitions.
### Frontend ## Collaboration Rules
- Use `vitest` + Testing Library. - Do not rewrite/delete unrelated work.
- Critical flows covered: search, booking form, auth state. - Do not run destructive git commands unless explicitly asked.
- Update docs when behavior changes (`docs/risks.md`, runbooks, ADR/ExecPlan as needed).
## Code Style & Practices
- Keep business logic in services (avoid fat views).
- Use explicit, readable model fields and serializers.
- Small, wellnamed functions > monolithic handlers.
- Prefer predictable error responses (HTTP status + `detail`).
- Prefer short, intent-focused comments over silent complexity.
## Known Gaps (Tracked)
- See `docs/risks.md` for current gaps/risks to address.
## Environment Notes
- Python is invoked as `python3`.
- A virtualenv is in use.
- DB: PostgreSQL in production, SQLite allowed for local dev.
- Backend tests must run with the venv active and `pytest-django` installed; run from `backend/` so `backend/pytest.ini` is picked up and `DJANGO_SETTINGS_MODULE` resolves.
## Collaboration Rules for Agents
- Dont delete or rewrite unrelated work.
- Avoid destructive git commands unless explicitly asked.
- Update `docs/risks.md` when adding or closing a significant gap.
- Keep README instructions current when tooling changes.
- Prefer feature branches for significant work; commit early with clear summary messages.
# ExecPlans
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`.
+24 -60
View File
@@ -1,67 +1,31 @@
# Salon Booking Platform # Salon
Scaffolded Django + React starter for a salon booking platform. KSA-first salon booking platform.
## Backend ## Quick start
Location: `backend/` ### Backend
1. `python3 -m venv venv && source venv/bin/activate`
2. `pip install -r backend/requirements.txt -r backend/requirements-dev.txt`
3. `cp backend/.env.example backend/.env`
4. `cd backend && python3 manage.py migrate && python3 manage.py runserver`
### Setup Optional demo data:
- `python3 manage.py seed_demo`
1. Create a virtualenv and install dependencies. Backend tests:
- `python3 -m venv venv && source venv/bin/activate` (or `venv\Scripts\activate` on Windows) - `cd backend && python3 -m pytest`
- `pip install -r backend/requirements.txt -r backend/requirements-dev.txt` - external integrations only: `PYTEST_ADDOPTS='' python3 -m pytest -m external`
2. Copy `backend/.env.example` to `backend/.env` and adjust values.
3. Run migrations and start the server.
### Demo data ### Frontend
1. `cd frontend && npm install`
2. `npm run dev`
3. tests: `npm run test`
After migrations, you can seed demo data: ## Docs map
- Agent rules: `AGENTS.md`
- `python manage.py seed_demo` - Docs index: `docs/README.md`
- Plans/ExecPlan policy: `docs/PLANS.md`
### Tests - Active ExecPlan: `docs/execplans/auth-phone-first-hardening.md`
- Architecture: `docs/architecture.md`
- From project root with venv active: `venv/bin/python3 -m pytest` (run from `backend/` so `pytest.ini` is picked up) - Risks: `docs/risks.md`
- External provider tests are skipped by default; run explicitly when needed: `PYTEST_ADDOPTS='' venv/bin/python3 -m pytest -m external`
### Core API endpoints (current scaffold)
- `POST /api/auth/register/`
- `POST /api/auth/token/`
- `POST /api/auth/token/refresh/`
- `GET/PATCH /api/auth/me/`
- `POST /api/auth/otp/request/`
- `POST /api/auth/otp/verify/`
- `POST /api/auth/phone/request/`
- `POST /api/auth/phone/verify/`
- `POST /api/auth/social/<provider>/` (placeholder)
- `GET /api/salons/`
- `GET /api/salons/<id>/`
- `GET /api/salons/<id>/services/`
- `GET /api/salons/<id>/staff/`
- `GET /api/salons/<id>/reviews/`
- `GET/POST /api/bookings/`
- `GET /api/bookings/<id>/`
- `GET/POST /api/payments/`
## Frontend
Location: `frontend/`
### Setup
1. Install dependencies via `npm install`.
2. Run `npm run dev`.
### Tests
- `npm run test`
The dev server proxies `/api` to `http://localhost:8000`.
## Project Notes
- Known gaps and risks: `docs/risks.md`
- Architecture and async/observability decisions: `docs/architecture.md`
- Documentation index and standards: `docs/README.md` and `docs/documentation.md`
+19 -9
View File
@@ -1,11 +1,21 @@
# Backend Notes (MVP Readiness) # Backend Notes
## High-Level Takeaways ## Current state
- Authentica OTP integration is implemented; Moyasar capture/refund are TODOs. - Phone-first auth is canonical (`/api/auth/phone/request`, `/api/auth/phone/verify`).
- External calls (OTP, notifications, payment gateway) run synchronously in request/response paths, increasing latency risk. - Password token endpoint `/api/auth/token/` is intentionally deprecated (`410`).
- Cross-app coupling (bookings ↔ notifications ↔ accounts/payments) will get harder to evolve without clearer service boundaries. - Moyasar payment create/webhook flow implemented.
- Phone-first auth works, but `USERNAME_FIELD` is email; align identifier strategy to avoid future auth confusion. - Booking integrity checks + lifecycle notifications implemented.
## Near-Term Focus ## Run
- finalize otp testing - `cd backend`
- work on authentication and complete it - `python3 manage.py migrate`
- `python3 manage.py runserver`
## Test
- `cd backend && python3 -m pytest`
- external-only: `PYTEST_ADDOPTS='' python3 -m pytest -m external`
## Pointers
- Architecture: `docs/architecture.md`
- Active plan: `docs/execplans/auth-phone-first-hardening.md`
- Risks: `docs/risks.md`
+4 -4
View File
@@ -7,12 +7,12 @@ from apps.accounts.models import PhoneOTP, User
@admin.register(User) @admin.register(User)
class UserAdmin(DjangoUserAdmin): class UserAdmin(DjangoUserAdmin):
model = User model = User
list_display = ("email", "phone_number", "role", "is_staff", "is_phone_verified") list_display = ("phone_number", "email", "role", "is_staff", "is_phone_verified")
list_filter = ("role", "is_staff", "is_phone_verified") list_filter = ("role", "is_staff", "is_phone_verified")
ordering = ("email",) ordering = ("phone_number",)
search_fields = ("email", "phone_number") search_fields = ("email", "phone_number")
fieldsets = ( fieldsets = (
(None, {"fields": ("email", "password")}), (None, {"fields": ("phone_number", "password")}),
("Personal", {"fields": ("first_name", "last_name", "phone_number")}), ("Personal", {"fields": ("first_name", "last_name", "phone_number")}),
("Roles", {"fields": ("role", "is_phone_verified")}), ("Roles", {"fields": ("role", "is_phone_verified")}),
("Permissions", {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}), ("Permissions", {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}),
@@ -21,7 +21,7 @@ class UserAdmin(DjangoUserAdmin):
add_fieldsets = ( add_fieldsets = (
(None, { (None, {
"classes": ("wide",), "classes": ("wide",),
"fields": ("email", "password1", "password2", "role"), "fields": ("phone_number", "password1", "password2", "role"),
}), }),
) )
@@ -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),
),
]
+26 -4
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,8 +63,26 @@ 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}$"),
),
]
@property
def display_name(self) -> str:
first = (self.first_name or "").strip()
last = (self.last_name or "").strip()
if first or last:
return f"{first} {last}".strip()
if self.email:
return self.email
return self.phone_number
def __str__(self): def __str__(self):
return self.email or self.phone_number or str(self.id) return self.display_name
class OtpChannel(models.TextChoices): class OtpChannel(models.TextChoices):
@@ -88,6 +107,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 -6
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
@@ -166,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)
@@ -177,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)
@@ -210,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:
@@ -47,9 +47,11 @@ 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
@@ -47,3 +47,54 @@ 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()
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console")
def test_phone_auth_verify_returns_404_when_user_removed(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"]
User.objects.filter(phone_number="+966512345678").delete()
verify_response = client.post(
reverse("phone_auth_verify"),
{"request_id": request_id, "code": "123456"},
content_type="application/json",
)
assert verify_response.status_code == 404
@@ -0,0 +1,293 @@
from unittest.mock import patch
import pytest
from django.db import IntegrityError
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_handles_duplicate_user_creation(client):
original_create_user = User.objects.create_user
otp_code = "123456"
def create_user_and_raise(*args, **kwargs):
original_create_user(*args, **kwargs)
raise IntegrityError("duplicate user")
with patch("apps.accounts.views.User.objects.create_user", side_effect=create_user_and_raise):
with patch("apps.accounts.services.otp.generate_code", return_value=otp_code):
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
assert User.objects.filter(phone_number="+966512345678").count() == 1
assert PhoneOTP.objects.filter(phone_number="+966512345678").count() == 1
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console")
def test_phone_auth_request_race_with_email_conflict(client):
original_create_user = User.objects.create_user
target_email = "race@example.com"
def create_conflict_user_then_raise(*args, **kwargs):
original_create_user(phone_number="+966500000002", email=target_email)
raise IntegrityError("email already claimed")
before_otp_count = PhoneOTP.objects.count()
with patch("apps.accounts.views.User.objects.create_user", side_effect=create_conflict_user_then_raise):
response = client.post(
reverse("phone_auth_request"),
{
"phone_number": "0512345678",
"channel": "sms",
"email": target_email,
},
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
@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()
@@ -0,0 +1,35 @@
import pytest
from apps.accounts.models import User
@pytest.mark.django_db
def test_display_name_prefers_full_name():
user = User.objects.create_user(
phone_number="+966500000001",
first_name="Sara",
last_name="Ali",
email="sara@example.com",
)
assert user.display_name == "Sara Ali"
assert str(user) == "Sara Ali"
@pytest.mark.django_db
def test_display_name_falls_back_to_email():
user = User.objects.create_user(
phone_number="+966500000002",
email="fallback@example.com",
)
assert user.display_name == "fallback@example.com"
@pytest.mark.django_db
def test_display_name_falls_back_to_phone_when_no_email():
user = User.objects.create_user(
phone_number="+966500000003",
)
assert user.display_name == "+966500000003"
+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"),
+65 -4
View File
@@ -1,5 +1,5 @@
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 django.db import IntegrityError
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 +17,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 +75,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 +99,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:
@@ -99,6 +126,7 @@ class PhoneAuthRequestView(APIView):
{"detail": _("Email already in use.")}, {"detail": _("Email already in use.")},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
try:
user = User.objects.create_user( user = User.objects.create_user(
email=email, email=email,
phone_number=phone_number, phone_number=phone_number,
@@ -106,9 +134,25 @@ class PhoneAuthRequestView(APIView):
last_name=data.get("last_name", ""), last_name=data.get("last_name", ""),
role="customer", role="customer",
) )
except IntegrityError:
user = User.objects.filter(phone_number=phone_number).first()
if not user:
# Another worker may have claimed this phone or email after our initial checks.
if email and User.objects.filter(email=email).exists():
return Response(
{"detail": _("Email already in use.")},
status=status.HTTP_400_BAD_REQUEST,
)
raise
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 +177,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 +211,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,
)
+1 -1
View File
@@ -29,4 +29,4 @@ class Booking(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return f"{self.customer.email} - {self.service.name}" return f"{self.customer.display_name} - {self.service.name}"
+1 -3
View File
@@ -34,9 +34,7 @@ class BookingSerializer(serializers.ModelSerializer):
def get_staff_name(self, obj): def get_staff_name(self, obj):
if not obj.staff: if not obj.staff:
return None return None
first = obj.staff.user.first_name or "" return obj.staff.user.display_name
last = obj.staff.user.last_name or ""
return (first + " " + last).strip() or obj.staff.user.email
def validate(self, attrs): def validate(self, attrs):
if not self.instance or "status" not in attrs: if not self.instance or "status" not in attrs:
@@ -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,
+6 -3
View File
@@ -56,7 +56,7 @@ class StaffProfile(models.Model):
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
def __str__(self): def __str__(self):
return f"{self.user.email} - {self.salon.name}" return f"{self.user.display_name} - {self.salon.name}"
class StaffAvailability(models.Model): class StaffAvailability(models.Model):
@@ -84,7 +84,10 @@ class StaffAvailability(models.Model):
ordering = ["staff_id", "day_of_week", "start_time"] ordering = ["staff_id", "day_of_week", "start_time"]
def __str__(self): def __str__(self):
return f"{self.staff.user.email} {self.get_day_of_week_display()} {self.start_time}-{self.end_time}" return (
f"{self.staff.user.display_name} {self.get_day_of_week_display()} "
f"{self.start_time}-{self.end_time}"
)
class Review(models.Model): class Review(models.Model):
@@ -95,4 +98,4 @@ class Review(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return f"Review {self.rating} for {self.salon.name}" return f"Review {self.rating} by {self.customer.display_name} for {self.salon.name}"
+2 -6
View File
@@ -26,9 +26,7 @@ class StaffSerializer(serializers.ModelSerializer):
fields = ["id", "name", "title", "bio", "is_active"] fields = ["id", "name", "title", "bio", "is_active"]
def get_name(self, obj): def get_name(self, obj):
first = obj.user.first_name or "" return obj.user.display_name
last = obj.user.last_name or ""
return (first + " " + last).strip() or obj.user.email
class ReviewSerializer(serializers.ModelSerializer): class ReviewSerializer(serializers.ModelSerializer):
@@ -39,9 +37,7 @@ class ReviewSerializer(serializers.ModelSerializer):
fields = ["id", "rating", "comment", "created_at", "customer_name"] fields = ["id", "rating", "comment", "created_at", "customer_name"]
def get_customer_name(self, obj): def get_customer_name(self, obj):
first = obj.customer.first_name or "" return obj.customer.display_name
last = obj.customer.last_name or ""
return (first + " " + last).strip() or obj.customer.email
class SalonSerializer(serializers.ModelSerializer): class SalonSerializer(serializers.ModelSerializer):
@@ -0,0 +1,103 @@
from datetime import timedelta, time
import pytest
from django.utils import timezone
from apps.accounts.models import User, UserRole
from apps.bookings.models import Booking
from apps.bookings.serializers import BookingSerializer
from apps.salons.models import (
Salon,
Service,
StaffAvailability,
StaffProfile,
Review,
)
from apps.salons.serializers import ReviewSerializer, StaffSerializer
@pytest.mark.django_db
class TestDisplayNameFallbacks:
def _create_customer(self, phone_number):
return User.objects.create_user(phone_number=phone_number)
def _create_staff_user(self, phone_number):
return User.objects.create_user(phone_number=phone_number, role=UserRole.STAFF)
def _create_salon(self, owner):
return Salon.objects.create(
owner=owner,
name="Test Salon",
address="123 Main",
city="Riyadh",
)
def _create_service(self, salon):
return Service.objects.create(
salon=salon,
name="Haircut",
description="",
duration_minutes=60,
price_amount=200,
currency="SAR",
)
def test_staff_serializer_falls_back_to_phone(self):
owner = User.objects.create_user(phone_number="+966500000001", email="owner@example.com")
salon = self._create_salon(owner)
staff_user = self._create_staff_user(phone_number="+966500000002")
staff_profile = StaffProfile.objects.create(user=staff_user, salon=salon)
serializer = StaffSerializer(staff_profile)
assert serializer.data["name"] == "+966500000002"
def test_review_serializer_customer_name_uses_phone(self):
owner = User.objects.create_user(phone_number="+966500000003", email="owner2@example.com")
salon = self._create_salon(owner)
customer = self._create_customer(phone_number="+966500000004")
review = Review.objects.create(salon=salon, customer=customer, rating=5, comment="Great")
serializer = ReviewSerializer(review)
assert serializer.data["customer_name"] == "+966500000004"
assert "+966500000004" in str(review)
def test_booking_serializer_staff_name_and_str_use_phone(self):
owner = User.objects.create_user(phone_number="+966500000005", email="owner3@example.com")
salon = self._create_salon(owner)
staff_user = self._create_staff_user(phone_number="+966500000006")
staff_profile = StaffProfile.objects.create(user=staff_user, salon=salon)
service = self._create_service(salon)
customer = self._create_customer(phone_number="+966500000007")
start = timezone.now()
booking = Booking.objects.create(
salon=salon,
customer=customer,
service=service,
staff=staff_profile,
start_time=start,
end_time=start + timedelta(hours=1),
price_amount=service.price_amount,
currency=service.currency,
)
serializer = BookingSerializer(booking)
assert serializer.data["staff_name"] == "+966500000006"
assert "+966500000007" in str(booking)
def test_staff_model_str_uses_phone(self):
owner = User.objects.create_user(phone_number="+966500000008", email="owner4@example.com")
salon = self._create_salon(owner)
staff_user = self._create_staff_user(phone_number="+966500000009")
staff_profile = StaffProfile.objects.create(user=staff_user, salon=salon)
availability = StaffAvailability.objects.create(
staff=staff_profile,
day_of_week=0,
start_time=time(9, 0),
end_time=time(10, 0),
)
assert "+966500000009" in str(staff_profile)
assert "+966500000009" in str(availability)
+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")
+37 -133
View File
@@ -1,154 +1,58 @@
# Codex Execution Plans (ExecPlans): # ExecPlan Guide
This document describes the requirements for an execution plan ("ExecPlan"), a design document that a coding agent can follow to deliver a working feature or system change. Treat the reader as a complete beginner to this repository: they have only the current working tree and the single ExecPlan file you provide. There is no memory of prior plans and no external context. ExecPlans are living implementation docs for complex features/refactors.
## Active ExecPlans ## Active ExecPlan
- `docs/execplans/auth-phone-first-hardening.md`
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. ## Other ExecPlans
- `docs/execplans/booking-notifications.md` (completed)
- `docs/execplans/booking-integrity.md` (completed)
- `docs/execplans/payments-moyasar.md` (completed)
- `docs/execplans/arabic-localization.md` (foundations completed)
## How to use ExecPlans and PLANS.md ## When To Use
Create/update an ExecPlan when work is cross-app, risky, or multi-step.
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. ## Required Sections (all ExecPlans)
- `Purpose / Big Picture`
- `Progress` (checkboxes with timestamps)
- `Surprises & Discoveries`
- `Decision Log`
- `Outcomes & Retrospective`
- `Context and Orientation`
- `Plan of Work`
- `Validation and Acceptance`
- `Idempotence and Recovery`
When implementing an executable specification (ExecPlan), do not prompt the user for "next steps"; simply proceed to the next milestone. Keep all sections up to date, add or split entries in the list at every stopping point to affirmatively state the progress made and next steps. Resolve ambiguities autonomously, and commit frequently. ## Operating Rules
- Keep plan self-contained and repo-path specific.
- Update the plan as you implement; do not treat it as static.
- Acceptance must be observable (API response/test/user-visible behavior).
- Include exact commands and working directory for validation.
When discussing an executable specification (ExecPlan), record decisions in a log in the spec for posterity; it should be unambiguously clear why any change to the specification was made. ExecPlans are living documents, and it should always be possible to restart from _only_ the ExecPlan and no other work. ## Minimal Skeleton
Use this structure for new plans:
When researching a design with challenging requirements or significant unknowns, use milestones to implement proof of concepts, "toy implementations", etc., that allow validating whether the user's proposal is feasible. Read the source code of libraries by finding or acquiring them, research deeply, and include prototypes to guide a fuller implementation. ```
# <Feature Name>
## Requirements
NON-NEGOTIABLE REQUIREMENTS:
* Every ExecPlan must be fully self-contained. Self-contained means that in its current form it contains all knowledge and instructions needed for a novice to succeed.
* Every ExecPlan is a living document. Contributors are required to revise it as progress is made, as discoveries occur, and as design decisions are finalized. Each revision must remain fully self-contained.
* Every ExecPlan must enable a complete novice to implement the feature end-to-end without prior knowledge of this repo.
* Every ExecPlan must produce a demonstrably working behavior, not merely code changes to "meet a definition".
* Every ExecPlan must define every term of art in plain language or do not use it.
Purpose and intent come first. Begin by explaining, in a few sentences, why the work matters from a user's perspective: what someone can do after this change that they could not do before, and how to see it working. Then guide the reader through the exact steps to achieve that outcome, including what to edit, what to run, and what they should observe.
The agent executing your plan can list files, read files, search, run the project, and run tests. It does not know any prior context and cannot infer what you meant from earlier milestones. Repeat any assumption you rely on. Do not point to external blogs or docs; if knowledge is required, embed it in the plan itself in your own words. If an ExecPlan builds upon a prior ExecPlan and that file is checked in, incorporate it by reference. If it is not, you must include all relevant context from that plan.
## Formatting
Format and envelope are simple and strict. Each ExecPlan must be one single fenced code block labeled as `md` that begins and ends with triple backticks. Do not nest additional triple-backtick code fences inside; when you need to show commands, transcripts, diffs, or code, present them as indented blocks within that single fence. Use indentation for clarity rather than code fences inside an ExecPlan to avoid prematurely closing the ExecPlan's code fence. Use two newlines after every heading, use # and ## and so on, and correct syntax for ordered and unordered lists.
When writing an ExecPlan to a Markdown (.md) file where the content of the file *is only* the single ExecPlan, you should omit the triple backticks.
Write in plain prose. Prefer sentences over lists. Avoid checklists, tables, and long enumerations unless brevity would obscure meaning. Checklists are permitted only in the `Progress` section, where they are mandatory. Narrative sections must remain prose-first.
## Guidelines
Self-containment and plain language are paramount. If you introduce a phrase that is not ordinary English ("daemon", "middleware", "RPC gateway", "filter graph"), define it immediately and remind the reader how it manifests in this repository (for example, by naming the files or commands where it appears). Do not say "as defined previously" or "according to the architecture doc." Include the needed explanation here, even if you repeat yourself.
Avoid common failure modes. Do not rely on undefined jargon. Do not describe "the letter of a feature" so narrowly that the resulting code compiles but does nothing meaningful. Do not outsource key decisions to the reader. When ambiguity exists, resolve it in the plan itself and explain why you chose that path. Err on the side of over-explaining user-visible effects and under-specifying incidental implementation details.
Anchor the plan with observable outcomes. State what the user can do after implementation, the commands to run, and the outputs they should see. Acceptance should be phrased as behavior a human can verify ("after starting the server, navigating to [http://localhost:8080/health](http://localhost:8080/health) returns HTTP 200 with body OK") rather than internal attributes ("added a HealthCheck struct"). If a change is internal, explain how its impact can still be demonstrated (for example, by running tests that fail before and pass after, and by showing a scenario that uses the new behavior).
Specify repository context explicitly. Name files with full repository-relative paths, name functions and modules precisely, and describe where new files should be created. If touching multiple areas, include a short orientation paragraph that explains how those parts fit together so a novice can navigate confidently. When running commands, show the working directory and exact command line. When outcomes depend on environment, state the assumptions and provide alternatives when reasonable.
Be idempotent and safe. Write the steps so they can be run multiple times without causing damage or drift. If a step can fail halfway, include how to retry or adapt. If a migration or destructive operation is necessary, spell out backups or safe fallbacks. Prefer additive, testable changes that can be validated as you go.
Validation is not optional. Include instructions to run tests, to start the system if applicable, and to observe it doing something useful. Describe comprehensive testing for any new features or capabilities. Include expected outputs and error messages so a novice can tell success from failure. Where possible, show how to prove that the change is effective beyond compilation (for example, through a small end-to-end scenario, a CLI invocation, or an HTTP request/response transcript). State the exact test commands appropriate to the projects toolchain and how to interpret their results.
Capture evidence. When your steps produce terminal output, short diffs, or logs, include them inside the single fenced block as indented examples. Keep them concise and focused on what proves success. If you need to include a patch, prefer file-scoped diffs or small excerpts that a reader can recreate by following your instructions rather than pasting large blobs.
## Milestones
Milestones are narrative, not bureaucracy. If you break the work into milestones, introduce each with a brief paragraph that describes the scope, what will exist at the end of the milestone that did not exist before, the commands to run, and the acceptance you expect to observe. Keep it readable as a story: goal, work, result, proof. Progress and milestones are distinct: milestones tell the story, progress tracks granular work. Both must exist. Never abbreviate a milestone merely for the sake of brevity, do not leave out details that could be crucial to a future implementation.
Each milestone must be independently verifiable and incrementally implement the overall goal of the execution plan.
## Living plans and design decisions
* ExecPlans are living documents. As you make key design decisions, update the plan to record both the decision and the thinking behind it. Record all decisions in the `Decision Log` section.
* ExecPlans must contain and maintain a `Progress` section, a `Surprises & Discoveries` section, a `Decision Log`, and an `Outcomes & Retrospective` section. These are not optional.
* When you discover optimizer behavior, performance tradeoffs, unexpected bugs, or inverse/unapply semantics that shaped your approach, capture those observations in the `Surprises & Discoveries` section with short evidence snippets (test output is ideal).
* If you change course mid-implementation, document why in the `Decision Log` and reflect the implications in `Progress`. Plans are guides for the next contributor as much as checklists for you.
* At completion of a major task or the full plan, write an `Outcomes & Retrospective` entry summarizing what was achieved, what remains, and lessons learned.
# Prototyping milestones and parallel implementations
It is acceptable—-and often encouraged—-to include explicit prototyping milestones when they de-risk a larger change. Examples: adding a low-level operator to a dependency to validate feasibility, or exploring two composition orders while measuring optimizer effects. Keep prototypes additive and testable. Clearly label the scope as “prototyping”; describe how to run and observe results; and state the criteria for promoting or discarding the prototype.
Prefer additive code changes followed by subtractions that keep tests passing. Parallel implementations (e.g., keeping an adapter alongside an older path during migration) are fine when they reduce risk or enable tests to continue passing during a large migration. Describe how to validate both paths and how to retire one safely with tests. When working with multiple new libraries or feature areas, consider creating spikes that evaluate the feasibility of these features _independently_ of one another, proving that the external library performs as expected and implements the features we need in isolation.
## Skeleton of a Good ExecPlan
# <Short, action-oriented description>
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.
If PLANS.md file is checked into the repo, reference the path to that file here from the repository root and note that this document must be maintained in accordance with PLANS.md.
## Purpose / Big Picture ## Purpose / Big Picture
Explain in a few sentences what someone gains after this change and how they can see it working. State the user-visible behavior you will enable.
## Progress ## Progress
- [ ] ...
Use a list with checkboxes to summarize granular steps. Every stopping point must be documented here, even if it requires splitting a partially completed task into two (“done” vs. “remaining”). This section must always reflect the actual current state of the work.
- [x] (2025-10-01 13:00Z) Example completed step.
- [ ] Example incomplete step.
- [ ] Example partially completed step (completed: X; remaining: Y).
Use timestamps to measure rates of progress.
## Surprises & Discoveries ## Surprises & Discoveries
- Observation: ...
Document unexpected behaviors, bugs, optimizations, or insights discovered during implementation. Provide concise evidence. Evidence: ...
- Observation: …
Evidence: …
## Decision Log ## Decision Log
- Decision: ...
Record every decision made while working on the plan in the format: Rationale: ...
Date/Author: ...
- Decision: …
Rationale: …
Date/Author: …
## Outcomes & Retrospective ## Outcomes & Retrospective
Summarize outcomes, gaps, and lessons learned at major milestones or at completion. Compare the result against the original purpose.
## Context and Orientation ## Context and Orientation
Describe the current state relevant to this task as if the reader knows nothing. Name the key files and modules by full path. Define any non-obvious term you will use. Do not refer to prior plans.
## Plan of Work ## Plan of Work
Describe, in prose, the sequence of edits and additions. For each edit, name the file and location (function, module) and what to insert or change. Keep it concrete and minimal.
## Concrete Steps
State the exact commands to run and where to run them (working directory). When a command generates output, show a short expected transcript so the reader can compare. This section must be updated as work proceeds.
## Validation and Acceptance ## Validation and Acceptance
Describe how to start or exercise the system and what to observe. Phrase acceptance as behavior, with specific inputs and outputs. If tests are involved, say "run <projects test command> and expect <N> passed; the new test <name> fails before the change and passes after>".
## Idempotence and Recovery ## Idempotence and Recovery
```
If steps can be repeated safely, say so. If a step is risky, provide a safe retry or rollback path. Keep the environment clean after completion.
## Artifacts and Notes
Include the most important transcripts, diffs, or snippets as indented examples. Keep them concise and focused on what proves success.
## Interfaces and Dependencies
Be prescriptive. Name the libraries, modules, and services to use and why. Specify the types, traits/interfaces, and function signatures that must exist at the end of the milestone. Prefer stable names and paths such as `crate::module::function` or `package.submodule.Interface`. E.g.:
In crates/foo/planner.rs, define:
pub trait Planner {
fn plan(&self, observed: &Observed) -> Vec<Action>;
}
If you follow the guidance above, a single, stateless agent -- or a human novice -- can read your ExecPlan from top to bottom and produce a working, observable result. That is the bar: SELF-CONTAINED, SELF-SUFFICIENT, NOVICE-GUIDING, OUTCOME-FOCUSED.
When you revise a plan, you must ensure your changes are comprehensively reflected across all sections, including the living document sections, and you must write a note at the bottom of the plan describing the change and the reason why. ExecPlans must describe not just the what but the why for almost everything.
+19 -31
View File
@@ -1,36 +1,24 @@
# Documentation Index # Docs Index
This directory is the source of truth for product, engineering, and ops documentation. Keep it current as features change. Use this file first.
## Start Here ## Start Here
- Repo setup: `README.md`
- Agent rules: `AGENTS.md`
- Plan rules + active plan: `docs/PLANS.md`
- Active ExecPlan: `docs/execplans/auth-phone-first-hardening.md`
- Architecture: `docs/architecture.md`
- Risks: `docs/risks.md`
- Project overview and setup: `README.md` (repo root) ## Map
- Architecture overview: `docs/architecture.md` - `docs/execplans/`: feature execution plans (living docs)
- Active ExecPlan: `docs/execplans/booking-notifications.md` - `docs/adr/`: architecture decisions
- Known risks and gaps: `docs/risks.md` - `docs/runbooks/`: operational response guides
- `docs/templates/`: ADR/runbook templates
- `docs/frontend-spec-requirements.md`: frontend technical specs + requirements (customer MVP)
## Documentation Standards ## Update Rules (short)
- Behavior/flow changes: update architecture + affected runbook.
See `docs/documentation.md` for documentation goals, update triggers, and templates. - Cross-cutting hard-to-reverse decision: add ADR.
- Significant risk added/closed: update `docs/risks.md`.
## Docs Map - Significant feature/refactor: update or add ExecPlan.
- `docs/architecture.md`: System architecture, boundaries, and MVP async/observability decision.
- `docs/adr/`: Architecture Decision Records (ADRs). New cross-cutting decisions must land here.
- `docs/execplans/`: Execution plans for significant features or refactors.
- `docs/runbooks/`: Operational runbooks and production checklists.
- `docs/risks.md`: Tracked risks and gaps.
- `docs/templates/`: Reusable templates (ADR, runbook).
## Update Triggers (Quick Reference)
- New external dependency, provider, or major flow: add an ADR in `docs/adr/`.
- Change to booking/payment/auth logic: update `docs/architecture.md` and relevant runbook(s).
- New operational procedure: add a runbook in `docs/runbooks/`.
- Close or add a significant risk: update `docs/risks.md`.
## Ownership And Review
- Authors own freshness: if you touch an area, update the docs in the same PR.
- New production flows require at least one runbook.
- Avoid duplicating instructions; link to the single source of truth.
+10 -18
View File
@@ -1,33 +1,25 @@
# ADR 0001: Synchronous External Calls For MVP # ADR 0001: Synchronous External Calls For MVP
## Status ## Status
Accepted Accepted
## Context ## Context
OTP sends, booking notifications, and payment provider calls were needed quickly for MVP reliability. Running queue infrastructure early would add operational overhead.
The MVP relies on OTP delivery, booking notifications, and payment gateway calls. Introducing a task queue (Celery/RQ) would add infrastructure (Redis, workers, retries) and operational complexity that is not required for the early launch.
## Decision ## Decision
Keep external calls synchronous in request/response paths for MVP, with explicit timeouts and failure handling.
For the MVP, OTP sends, booking notifications, and payment gateway calls run synchronously in the request/response path with strict timeouts. A task queue will be revisited when traffic grows or operational needs change.
## Consequences ## Consequences
- Faster delivery, fewer moving pieces.
- Faster initial delivery with fewer moving parts. - Higher latency risk when providers are slow.
- Increased latency risk on endpoints that call external providers. - Payment/OTP failures surface to clients immediately.
- Payment and OTP failures are surfaced to clients immediately (correct behaviour — clients need to know). - Notification failures are recorded (`FAILED`) and monitored, not returned to client requests.
- Notification failures are absorbed: `notifications/services.py` catches provider errors, stores them as `FAILED` status, and never surfaces them to the client. A failed booking SMS does not cause the booking request to fail. This means notification failures require active monitoring rather than appearing in client-facing error rates.
## Alternatives Considered ## Alternatives Considered
- Full queue (Celery/Redis): deferred.
- Celery + Redis for all external calls: rejected for MVP due to infra overhead. - Hybrid queue for notifications only: valid future step when latency/throughput needs it.
- Hybrid async for notifications only: not wrong in principle, deferred for operational simplicity. The three call types have genuinely different semantics:
- **Payment creation**: synchronous by design — the client needs the Moyasar redirect URL before the response returns.
- **OTP sends**: synchronous by design — users expect immediate confirmation that the code was sent.
- **Booking notifications**: fire-and-forget by nature — the booking is already committed and the client does not wait for delivery confirmation.
When notification latency becomes a problem (e.g. under load or with slow SMS providers), only notifications need to move off the request path. Payments and OTP sends should remain synchronous regardless.
## Related ## Related
- `docs/architecture.md` - `docs/architecture.md`
- `docs/runbooks/auth_otp_failures.md`
- `docs/runbooks/payments_sanity_check.md`
+8 -15
View File
@@ -1,30 +1,23 @@
# ADR 0002: Moyasar As The Payment Gateway # ADR 0002: Moyasar As Payment Gateway
## Status ## Status
Accepted Accepted
## Context ## Context
MVP requires KSA-focused payment support (SAR + local methods).
The platform needs a payment gateway that supports Saudi Arabia, SAR currency defaults, and local payment methods (e.g. STC Pay, Apple Pay, Samsung Pay). The backend already implements a `MoyasarGateway` integration and models `payments.Payment` with a `moyasar` provider option.
## Decision ## Decision
Use Moyasar as the primary gateway for payment creation and webhook reconciliation.
Use Moyasar as the payment gateway for the MVP. Payment creation, capture, refund, and webhook reconciliation are implemented through `apps.payments.services.gateway.MoyasarGateway`.
## Consequences ## Consequences
- Strong KSA fit for MVP.
- Supports KSA-focused payment methods and SAR by default. - External dependency on Moyasar uptime/API stability.
- Operational dependency on Moyasar uptime and API stability. - Gateway abstraction can be expanded later for multi-provider support.
- Payment flows and webhooks are tied to the Moyasar API surface until a gateway abstraction is expanded.
## Alternatives Considered ## Alternatives Considered
- Other regional gateways: deferred.
- Other regional gateways: deferred until the MVP is validated. - Global-first provider: not selected for MVP priorities.
- Stripe or similar global providers: not selected for MVP due to KSA-specific coverage priorities.
## Related ## Related
- `backend/apps/payments/services/gateway.py` - `backend/apps/payments/services/gateway.py`
- `docs/runbooks/payments_sanity_check.md` - `docs/runbooks/payments_sanity_check.md`
- `docs/architecture.md`
+8 -12
View File
@@ -1,28 +1,24 @@
# ADR 0003: Authentica As Primary OTP Provider # ADR 0003: Authentica As Primary OTP Provider
## Status ## Status
Accepted Accepted
## Context ## Context
MVP auth is phone-first; production OTP delivery needed for KSA.
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 production OTP provider. Keep `console` provider for local development/tests.
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
- Production auth depends on Authentica credentials + uptime.
- OTP verification relies on Authentica APIs and credentials in production. - Local development remains simple.
- Local development remains simple with the console provider. - Adding backup provider needs new adapter + runbook updates.
- Adding a second production provider will require completing adapters and updating operational runbooks.
## Alternatives Considered ## Alternatives Considered
- Console-only: not viable for production.
- Multi-provider from day one: deferred for scope control.
## Related ## Related
- `backend/apps/accounts/services/otp.py` - `backend/apps/accounts/services/otp.py`
- `backend/salon_api/settings.py` - `backend/salon_api/settings.py`
- `docs/architecture.md` - `docs/runbooks/auth_otp_failures.md`
+11 -3
View File
@@ -1,5 +1,13 @@
# Architecture Decision Records # ADR Index
ADRs capture cross-cutting or hard-to-reverse decisions. Add a new ADR when changing providers, async strategy, data model boundaries, or other architectural choices. Use ADRs for cross-cutting, hard-to-reverse decisions.
Use the template in `docs/templates/adr.md` and increment the numeric prefix (`0002`, `0003`, ...). ## Existing ADRs
- `0001-synchronous-external-calls-mvp.md`
- `0002-moyasar-payment-gateway.md`
- `0003-authentica-otp-provider.md`
## Add New ADR
- Copy `docs/templates/adr.md`
- Use next numeric prefix (`0004`, `0005`, ...)
- Keep status updated (`Proposed`, `Accepted`, `Deprecated`, `Superseded`)
+34 -214
View File
@@ -1,222 +1,42 @@
# Salon MVP Roadmap And Architecture Review # Architecture Snapshot
## Purpose / Big Picture Compact snapshot for current MVP reliability work.
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. ## System
The roadmap assumes a KSA-focused first launch (phone auth and Riyadh timezone defaults) with a clean path to expand later. - Backend: Django + DRF (`backend/`)
- Frontend: React + Vite (`frontend/`)
- Primary market defaults: KSA (locale + phone patterns)
## Current State Summary ## Backend Domains
- `apps/accounts`: phone-first users, OTP flows, auth APIs.
- `apps/salons`: salons/services/staff/availability/reviews.
- `apps/bookings`: booking creation + status transitions + overlap/availability validation.
- `apps/payments`: Moyasar payment creation + webhook reconciliation + idempotency.
- `apps/notifications`: booking lifecycle notifications (customer/staff).
### Backend (Django, DRF) ## Key Flows
1. Phone auth: `/api/auth/phone/request/` -> OTP issue, `/api/auth/phone/verify/` -> JWT.
2. Booking: `POST /api/bookings/` enforces staff, duration, availability, overlap.
3. Payment: `POST /api/payments/` creates Moyasar payment with idempotency key.
4. Webhook: `/api/payments/webhook/` maps provider events to internal payment status.
5. Notification: booking create/confirm/cancel dispatches SMS/WhatsApp records.
- **Project**: `salon_api` in `[backend/salon_api](backend/salon_api)` ## Current Design Decisions
- `settings.py` configures `AUTH_USER_MODEL = "accounts.User"`, DRF + SimpleJWT, KSA locale defaults, and custom settings for OTP, notifications, and payments. - External calls are synchronous for MVP (ADR 0001).
- URLs route to app-level APIs at `/api/auth/`, `/api/salons/`, `/api/bookings/`, `/api/payments/`. - Moyasar is payment provider (ADR 0002).
- **Auth & Accounts** (`[backend/apps/accounts](backend/apps/accounts)`) - Authentica is primary OTP provider (ADR 0003).
- 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) ## Reliability State
- Implemented: booking integrity, payment idempotency+webhooks, lifecycle notifications, locale foundations.
- Open: full OAuth linking policy, broader i18n coverage, stronger observability/audit/compliance.
- **Structure** ## Where To Extend Safely
- 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)`. - Add business rules in services modules, not views.
- No `react-router` or multi-page routing; the entire experience is one composed screen. - Preserve idempotency keys/state transitions for booking/payment changes.
- **Current Features** - Update runbooks + risks with every production-impacting behavior change.
- **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 ## Reference Docs
- Plan policy + active plan: `docs/PLANS.md`
### Backend Risks - Known gaps: `docs/risks.md`
- Runbooks: `docs/runbooks/README.md`
- **Incomplete provider implementations for production-critical flows** - ADRs: `docs/adr/README.md`
- Authentica is the only OTP/notification provider; ensure it is fully configured and exercised in production-like environments.
- `MoyasarGateway` lacks `capture_payment` and `refund_payment` implementations, limiting payment lifecycle coverage.
- **Risk**: Code reads “production ready” at the API level, but the underlying integrations are not, which could cause outages if deployed naively.
- **Tight coupling between OTP and notifications**
- Notification services import the OTP provider mapping and default `NOTIFICATION_PROVIDER` to `OTP_PROVIDER`, binding booking notifications to auth configuration.
- **Risk**: Changing OTP providers or adding a second channel for marketing/ops notifications will be harder and could have unintended side effects.
- **Synchronous IO-heavy work in request/response path**
- OTP sends, booking notifications, and payment gateway calls all occur synchronously inside view methods (`perform_create`, `create`, etc.).
- **Risk**: Slow or flaky providers will degrade API latency and user experience; retries and backoff are hard to implement without background jobs.
- **Cross-app domain coupling without a clear orchestration layer**
- `apps.bookings` depends on salons and notifications; notifications depend on accounts (OTP providers) and bookings; payments depend on bookings.
- **Risk**: As you add more lifecycle rules (e.g., auto-confirm booking on payment, send reminders, handle refunds), the spaghetti of cross-imports will grow unless you introduce clearer service boundaries.
- **Auth model vs login patterns**
- `User.USERNAME_FIELD` is email, while phone-based JWT issuance happens via custom endpoints.
- **Risk**: This split can confuse clients and admin tooling and may complicate future flows like social login or SSO unless you standardize on an identifier strategy.
- **Docs drift around ExecPlans**
- `AGENTS.md` references `docs/execplans/payments-moyasar.md` as the active plan, while `PLANS.md` names `docs/execplans/booking-notifications.md`.
- **Risk**: Contributors may follow different “active” plans, causing architectural inconsistency.
### Frontend Risks
- **Monolithic `App` component with no routing**
- `App.jsx` mixes hero/search, salon listing, payments, and locale controls.
- There is no `react-router` or notion of separate flows (auth, booking, profile, payments).
- **Risk**: Extending to full MVP flows (auth, booking, history, management) will quickly become unmanageable without a routing/page system and domain separation.
- **Domain logic embedded in UI components**
- API payload construction, validation rules (e.g. for source types), and error handling are implemented directly in `App.jsx` rather than reusable hooks or service modules.
- **Risk**: Code reuse, testing, and evolution (e.g., adding booking pages or admin consoles) will be painful.
- **Minimal test coverage for critical flows**
- Only i18n and hero copy are tested; search behavior, API integration, and payments are untested.
- **Risk**: Regressions in search, booking, and payments UX will slip through as MVP grows.
- **Styling & layout fragility**
- `frontend/src/styles.css` uses `::root` instead of `:root`, which likely breaks intended global CSS variables or base styles.
- Global CSS is tightly bound to the monolithic `App` layout.
- **Risk**: Visual regressions and layout churn when introducing additional pages or components.
- **Ad hoc auth token handling**
- The “access token” is a free-form text field that gets persisted as `auth_token` in `localStorage` and injected into payment requests.
- **Risk**: This is a placeholder pattern that does not scale to full auth (refresh tokens, logout, token rotation) and will need to be replaced.
### Cross-Cutting Risks
- **Lack of async/background processing**
- No Celery/RQ or similar job queue; all side effects are synchronous.
- **Risk**: Scaling SMS/WhatsApp notifications, email, and payment webhook fan-out will be difficult.
- **Observability and admin tooling gaps**
- Errors for payments and notifications are recorded in model metadata but not clearly surfaced in logs, dashboards, or admin views.
- **Risk**: Operational debugging during MVP rollout will be slower and more error-prone.
- **Internationalization strategy vs future markets**
- Phone normalization and defaults are tailored to KSA, which is correct for MVP, but `docs/risks.md` already notes the need to broaden later.
- **Risk**: Without clear boundaries between KSA-specific logic and generic logic, future expansion may require invasive changes.
## MVP Roadmap (Aligned To Phase 1)
This roadmap assumes “MVP” is equivalent to **Phase 1: Core MVP Reliability** in `AGENTS.md`, with a thin but robust frontend on top.
### Phase 0 Architecture & Production Readiness Hardening
- **Finalize critical provider implementations**
- Ensure Authentica OTP/SMS is fully configured and validated end-to-end behind the provider abstraction in `[backend/apps/accounts/services/otp.py](backend/apps/accounts/services/otp.py)`, and wired into `[backend/apps/notifications/services.py](backend/apps/notifications/services.py)`.
- Implement or deliberately fence off `capture_payment` and `refund_payment` in `[backend/apps/payments/services/gateway.py](backend/apps/payments/services/gateway.py)` so that the MVP either fully supports or explicitly does not support partial captures/refunds.
- **Clarify and document boundaries**
- Add a short architecture section in `README`/docs describing how `accounts`, `salons`, `bookings`, `payments`, and `notifications` interact, and what each service is responsible for.
- Resolve the ExecPlan drift by making `AGENTS.md` and `PLANS.md` agree on the current active plan.
- **Introduce minimal async infrastructure (optional but recommended)**
- Decide whether MVP will ship with a task queue (e.g., Celery with Redis) or keep everything synchronous for the initial launch.
- If yes, introduce a thin task layer for OTP sends and booking notifications while preserving current APIs; if not, at least add clear timeouts/logging to external calls.
- **Frontend scaffolding for growth**
- Introduce `react-router` and refactor `App.jsx` into route-based pages (e.g., `HomePage`, `BookPage`, `PaymentPage`, `ProfilePage`), with shared layout and navigation.
- Extract salon search, payment form, and locale controls into dedicated components and hooks.
### Phase 1 Core MVP Features (Backend + Frontend)
- **Phone-first auth UX**
- Backend: reuse existing phone auth endpoints; ensure error messages and rate-limit responses are predictable and localized.
- Frontend:
- Build OTP-based login/registration screens that drive `/api/auth/phone/request/` and `/api/auth/phone/verify/`.
- Introduce an auth context (or similar) to store access/refresh tokens, current user profile, and handle logout.
- Defer social login beyond MVP, but keep API surface ready for it.
- **Booking search and creation**
- Backend is largely ready (booking validation and role-based access); review booking serializers in `[backend/apps/bookings/serializers.py](backend/apps/bookings/serializers.py)` to ensure they expose all fields needed for frontend booking forms.
- Frontend:
- Build a **booking flow**: pick a salon → choose service → select staff (optional) → select date/time slot (based on availability endpoints) → confirm booking.
- Add a “My bookings” page showing upcoming and past bookings, tied into the existing `/api/bookings/` endpoints.
- **Payments via Moyasar**
- Backend: confirm `create_payment_for_booking` contracts (inputs/outputs) are stable and documented.
- Frontend:
- Evolve the payments beta UI into a **post-booking payment step** that starts from a selected booking and guides the user into Moyasars hosted flow, then shows a status page.
- Handle callback/return from Moyasar (even if via manual redirect URL in MVP) and surface payment success/failure to the user.
- **Booking lifecycle notifications**
- Backend already sends notifications on booking create and status changes; align messaging templates with product UX and ensure localization strings exist.
- Frontend: surface notification results implicitly via booking status changes and explicit messages on the booking details page.
- **Localization foundations**
- Backend: ensure `UserLocaleMiddleware` and translation strings cover all user-visible errors in auth, bookings, payments, and notifications.
- Frontend: expand `en/ar-sa` translations to cover auth, booking, and payment flows; verify RTL layouts on the new screens.
- **Tests for critical flows**
- Backend: extend tests where needed to cover new booking/payment edge cases (e.g., tying booking status to payment status if/when introduced).
- Frontend: add Vitest tests for:
- Phone auth screen flows (request/verify success + errors).
- Booking flow (form validation, happy path, displaying server-side errors).
- Payment initiation from an existing booking.
### Phase 2 Manager Ops Lite (Post-MVP, partially covered now)
- **Salon and staff management UI**
- Use existing salon and staff models to build basic management pages for salon owners/managers (create/update services, staff, availability).
- **Calendar views and rescheduling**
- Provide calendar views for staff/managers to view daily/weekly bookings and reschedule or cancel within defined rules.
- **Reviews and ratings**
- Implement review submission and rating recalculation on the backend, with corresponding frontend components.
- **Reporting basics**
- Lightweight reports for managers (upcoming bookings, simple revenue summaries based on payment status) using existing payments data.
### Phase 3 Scale & Compliance (Later)
- **Audit logging** for admin actions and booking/payment state changes.
- **PDPL/GDPR retention policies** and data export tooling.
- **Observability**: structured logging, metrics, and basic dashboards for auth failures, OTP send failures, payment errors, and notification outcomes.
## Architecture Overview Diagram
A simplified view of the target MVP data flow:
```mermaid
flowchart LR
user["User (web/mobile)"] --> frontend["ReactFrontend"]
frontend --> api["DjangoAPI"]
api --> accounts["AccountsApp"]
api --> salons["SalonsApp"]
api --> bookings["BookingsApp"]
api --> payments["PaymentsApp"]
api --> notifications["NotificationsApp"]
accounts --> otpProviders["OtpProviders"]
notifications --> otpProviders
payments --> moyasar["MoyasarGateway"]
bookings --> notifications
bookings --> payments
salons --> bookings
```
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.
+15 -47
View File
@@ -1,51 +1,19 @@
# Documentation Practices # Documentation Rules
These standards aim to keep documentation reliable as the codebase grows.
## Principles ## Principles
- Single source of truth per topic; link, do not duplicate.
- Update docs in same change set as code.
- Prefer observable behavior (what to run, what to see).
- Single source of truth: one canonical doc per topic; link instead of duplicating. ## Canonical Locations
- Proximity: keep docs close to the code they describe when possible. - Architecture/system boundaries: `docs/architecture.md`
- Freshness: update docs in the same PR as the code change. - Execution plans: `docs/execplans/` + policy in `docs/PLANS.md`
- Observable behavior: describe what someone can see or run to validate the behavior. - Operational procedures: `docs/runbooks/`
- Cross-cutting decisions: `docs/adr/`
- Known gaps: `docs/risks.md`
## Required Docs By Area ## Minimum Review Checklist
- Docs touched or explicitly unchanged.
- Architecture and major decisions: `docs/architecture.md` and `docs/adr/`. - Runbook updated for production-impacting flow changes.
- Feature delivery plans: `docs/execplans/` (required by `PLANS.md`). - ADR added when decision is hard to reverse.
- Operational procedures: `docs/runbooks/`. - Risks updated when a major gap opens/closes.
- Risks and gaps: `docs/risks.md`.
## When To Write An ADR
Use an ADR for any decision that is cross-cutting or hard to reverse, including:
- External providers or payment/auth strategy changes.
- Async vs synchronous execution decisions.
- Data model changes that affect multiple apps or services.
ADRs live in `docs/adr/` and use the template in `docs/templates/adr.md`.
## Runbook Expectations
Every production-impacting flow should have a runbook that covers:
- Symptoms and impact.
- Detection and quick checks.
- Safe remediation steps.
- Rollback or escalation path.
Use the template in `docs/templates/runbook.md`.
## Writing Style
- Be explicit: include exact commands, paths, and expected output where useful.
- Keep sections short and focused.
- Avoid unstated assumptions; if a step needs a specific directory, say so.
## Review Checklist
- Docs updated or explicitly confirmed unnecessary.
- New runbook added when operational behavior changes.
- ADR added for new cross-cutting decisions.
- `docs/risks.md` updated for meaningful gaps added or closed.
+26 -101
View File
@@ -1,120 +1,45 @@
# Arabic Localization Readiness (ar-sa First) # Arabic Localization Foundations
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. This ExecPlan follows `docs/PLANS.md`.
The requirements for ExecPlans live in `PLANS.md` at the repository root. This document must be maintained in accordance with that file.
## Purpose / Big Picture ## Purpose / Big Picture
Ship i18n plumbing for `ar-sa` first (locale selection, RTL behavior, user preference), with full translation breadth deferred.
After this change, the codebase has localization foundations in place: locale selection, right-to-left layout support, and a user language preference in the backend. Arabic is treated as a first-class locale for structure and behavior, but full translation coverage is intentionally deferred until core backend flows stabilize. You can see it working by starting the backend and frontend, switching the language to Arabic, and observing RTL layout, the page `dir="rtl"`, and the API responding with the correct `Content-Language` when sending `Accept-Language: ar-sa`.
## Progress ## Progress
- [x] (2026-02-27 00:00Z) Plan created.
- [x] (2026-02-27 00:00Z) Created initial ExecPlan for Arabic localization readiness. - [x] (2026-02-28 12:20Z) Backend locale middleware + user preference landed.
- [x] (2026-02-28 12:00Z) Added backend locale settings, LocaleMiddleware, user language preference, and user locale middleware. - [x] (2026-02-28 12:30Z) Frontend i18n + RTL switching landed.
- [x] (2026-02-28 12:10Z) Wrapped backend user-facing strings for future translation (no full catalog yet). - [x] (2026-02-28 12:40Z) Basic tests/docs updated.
- [x] (2026-02-28 12:20Z) Added frontend i18n, RTL support, language persistence, and minimal seed translations. - [ ] Complete translation coverage and broader RTL QA as features expand.
- [x] (2026-02-28 12:30Z) Added targeted backend and frontend tests for locale selection and RTL behavior.
- [x] (2026-02-28 12:40Z) Updated documentation and risks for staged localization.
## Surprises & Discoveries ## Surprises & Discoveries
- Observation: none critical after implementation.
- Observation: None yet. Evidence: targeted locale/RTL tests passed for initial scope.
Evidence: No implementation work has started.
## Decision Log ## Decision Log
- Decision: `ar-sa` default locale with `en` fallback.
- Decision: Use `ar-sa` as the default locale with English as a fallback. Rationale: KSA-first product target.
Rationale: The product is KSA-focused and Arabic should be primary while keeping English for mixed audiences. Date/Author: 2026-02-27/Codex
Date/Author: 2026-02-27, Codex - Decision: deliver foundations first, defer full string coverage.
- Decision: Implement localization foundations now and defer full translation coverage. Rationale: reduce churn while core product flows are still evolving.
Rationale: Early i18n plumbing avoids future refactors, while delaying full translation prevents churn as features evolve. Date/Author: 2026-02-28/Codex
Date/Author: 2026-02-28, Codex
- Decision: Use lower-case `ar-sa` for locale identifiers in code and storage.
Rationale: Django language codes are lower-case; standardizing avoids mismatches between backend and frontend.
Date/Author: 2026-02-28, Codex
- Decision: Persist user language preference on the `User` model and fall back to `Accept-Language` for anonymous requests.
Rationale: This provides consistent localized behavior for logged-in users while respecting browser preferences for guests.
Date/Author: 2026-02-27, Codex
- Decision: Localize both backend API messages and frontend UI strings.
Rationale: A partial localization would create mismatched language experiences and confuse users.
Date/Author: 2026-02-27, Codex
- Decision: Use `i18next` + `react-i18next` with a small custom locale selection helper rather than a detection plugin.
Rationale: The project is small and can avoid extra dependencies while still meeting locale selection requirements.
Date/Author: 2026-02-27, Codex
## Outcomes & Retrospective ## Outcomes & Retrospective
Foundations complete; translation completeness remains open.
Localization foundations are now in place across backend and frontend, with user preference support, RTL layout, minimal Arabic strings, and basic tests. Full translation coverage and broader RTL QA remain as future work once core flows stabilize.
## Context and Orientation ## Context and Orientation
- Backend locale settings/middleware: `backend/salon_api/settings.py`, `backend/apps/accounts/middleware.py`
The backend is a Django + DRF app in `backend/` with settings in `backend/salon_api/settings.py`. The frontend is a Vite + React app in `frontend/` with the entrypoint at `frontend/src/main.jsx` and global styles in `frontend/src/styles.css`. Localization foundations now exist: Django `LocaleMiddleware` is configured and `apps/accounts/middleware.py` applies user preferences, while the frontend initializes `i18next` in `frontend/src/i18n/index.js` and sets `lang`/`dir` on the root element. User-facing strings have begun to be wrapped for translation, but full Arabic translation coverage remains pending. - Frontend i18n/RTL: `frontend/src/i18n/`, `frontend/src/main.jsx`, `frontend/src/styles.css`
## Plan of Work ## Plan of Work
Next phase: expand string coverage and run end-to-end RTL checks on all added routes/components.
First, add Django locale support. Update `backend/salon_api/settings.py` to define `LANGUAGE_CODE="ar-sa"`, `LANGUAGES` with Arabic and English, `LOCALE_PATHS` pointing to `backend/locale`, and add `django.middleware.locale.LocaleMiddleware` to `MIDDLEWARE` after `SessionMiddleware`. Create `backend/apps/accounts/middleware.py` with `UserLocaleMiddleware` that activates `request.user.preferred_language` after `AuthenticationMiddleware` and sets the response `Content-Language` header. Add a `preferred_language` field to `backend/apps/accounts/models.py` and expose it via `backend/apps/accounts/serializers.py` so `/api/auth/me/` can read and update it.
Next, wrap all user-facing backend strings in translation wrappers. Use `from django.utils.translation import gettext_lazy as _` in serializers and models, and `gettext` in runtime view responses. Cover custom messages in `apps/accounts`, `apps/bookings`, `apps/payments`, and `apps/salons`. Do not translate the backend catalog yet; full Arabic API messages are a later milestone once core flows stabilize. Update or add tests that confirm language selection by user preference and `Accept-Language` headers.
Then, add frontend localization. Introduce an `frontend/src/i18n/` module that sets up `i18next` with `en` and `ar-sa` resource files. Update `frontend/src/main.jsx` to initialize i18n before rendering `App`, set `document.documentElement.lang` and `dir` whenever language changes, and persist the selected locale to local storage. Update `frontend/src/api/client.js` to include the `Accept-Language` header using the active locale. Replace hard-coded UI strings in `frontend/src/App.jsx` with `t(...)` keys and add minimal Arabic translations for the current UI.
Finally, make the UI RTL-safe. Update `frontend/src/styles.css` to use logical properties (`margin-inline`, `padding-inline`, `text-align: start`) where relevant, add `:dir(rtl)` overrides for layout if needed, and add an Arabic-capable font such as `Noto Sans Arabic` to the font stack. Validate end-to-end behavior by running the backend and frontend, switching language, and confirming the UI renders RTL and API responses match the selected locale. Full translation coverage remains a later milestone.
## Concrete Steps
Run these commands from the repository root (`/home/kopernikus/kshkool/Salon`).
1. Add backend locale middleware, settings, and `preferred_language` field, then create a migration.
- Update `backend/salon_api/settings.py`, `backend/apps/accounts/models.py`, and add `backend/apps/accounts/middleware.py`.
- Run:
python3 backend/manage.py makemigrations accounts
2. (Deferred) Generate and compile Arabic translations for the backend when full translation coverage is ready.
- Run:
python3 backend/manage.py makemessages -l ar --ignore frontend --ignore node_modules
python3 backend/manage.py compilemessages
- Edit `backend/locale/ar/LC_MESSAGES/django.po` to translate the newly wrapped strings.
3. Add frontend i18n resources and wire them into the app.
- Update `frontend/package.json`, `frontend/src/main.jsx`, `frontend/src/api/client.js`, `frontend/src/App.jsx`, and create `frontend/src/i18n/index.js` plus translation JSON files.
4. Run tests and verify behavior.
- Backend:
python3 -m pytest
- Frontend:
cd frontend
npm run test
## Validation and Acceptance ## Validation and Acceptance
- Backend: locale header behavior matches user preference/`Accept-Language`.
Backend acceptance is achieved when `Accept-Language` and user preference change the response language header. For example, an OTP error should carry `Content-Language: ar-sa` even if the message text remains English until translations are added: - Frontend: language toggle sets `lang` + `dir`, persists across refresh.
- Commands:
$ curl -s -H "Accept-Language: ar-sa" -X POST http://localhost:8000/api/auth/otp/request/ -H "Content-Type: application/json" -d '{"phone_number":"123","channel":"sms"}' - `cd backend && python3 -m pytest`
{"phone_number":["Phone number is required"]} - `cd frontend && npm run test`
Frontend acceptance is achieved when the page renders Arabic text, the root element uses `dir="rtl"`, and the UI remains readable. You should be able to toggle language, reload, and still see Arabic due to stored preference. Running `npm run dev` and visiting the page should show Arabic UI strings when the selected locale is `ar-sa`.
## Idempotence and Recovery ## Idempotence and Recovery
Locale config is additive and safe to reapply; translation catalogs can be regenerated.
The locale settings and middleware changes are safe to apply multiple times. Translation commands can be rerun; `makemessages` updates catalogs and `compilemessages` rebuilds `.mo` files. If a translation file is corrupted, re-run `makemessages` and re-apply translations. The migration adding `preferred_language` is additive and reversible via standard Django migration rollback.
## Artifacts and Notes
Expected header behavior after implementing locale selection:
Content-Language: ar-sa
Example local storage entry for the frontend:
localStorage["locale"] = "ar-sa"
## Interfaces and Dependencies
Add backend localization dependencies by using Djangos built-in translation system and middleware (`django.middleware.locale.LocaleMiddleware`) and a new `apps.accounts.middleware.UserLocaleMiddleware` to enforce user preference. The `User` model gains a language preference field:
preferred_language = models.CharField(max_length=10, choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE)
Frontend dependencies must include `i18next` and `react-i18next`. The i18n setup should live in `frontend/src/i18n/index.js`, exporting an initialized i18n instance. The API client in `frontend/src/api/client.js` must attach `Accept-Language` to every request based on the active locale.
Plan Maintenance Note: Initial plan created on 2026-02-27 to scope Arabic localization readiness across backend and frontend. Updated on 2026-02-28 to stage localization work (foundations now, full translations later).
@@ -0,0 +1,48 @@
# Phone-first Auth Hardening
This ExecPlan follows `docs/PLANS.md`.
## Purpose / Big Picture
Keep phone OTP as canonical login surface, preserve phone-first identity across serializers/admin/UI-facing strings, and lock regression tests around this contract.
## Progress
- [x] (2026-03-14 12:00 UTC) Plan created with test-first scope.
- [x] (2026-03-14 13:55 UTC) Added tests for display fallback + phone auth error contracts.
- [x] (2026-03-14 14:30 UTC) Implemented `User.display_name`, serializer/admin updates, and docs updates.
- [ ] Expand tests for OAuth linking policy and remaining phone-first invariants.
## Surprises & Discoveries
- Observation: JWT test key warning appears in suite.
Evidence: `InsecureKeyLengthWarning` during accounts/salons pytest runs.
## Decision Log
- Decision: Pre-create user on phone request; verify on phone verify.
Rationale: deterministic onboarding lifecycle.
Date/Author: 2026-03-14/Codex
- Decision: Add `User.display_name` and reuse everywhere.
Rationale: stable fallback for phone-only accounts.
Date/Author: 2026-03-14/Codex
## Outcomes & Retrospective
Core phone-first hardening landed and tests pass for implemented scope. Remaining work is mainly policy (OAuth linking/conflict) plus extra invariants coverage.
## Context and Orientation
- Auth endpoints: `backend/apps/accounts/views.py`
- User model/admin: `backend/apps/accounts/models.py`, `backend/apps/accounts/admin.py`
- Cross-app display paths: `backend/apps/salons/`, `backend/apps/bookings/`
## Plan of Work
1. Keep adding invariant tests first.
2. Finalize OAuth linking/conflict policy and enforce in auth services.
3. Update docs/runbooks/risks with final contract.
## Validation and Acceptance
From `backend/`:
- `python3 -m pytest backend/apps/accounts/tests backend/apps/salons/tests`
Acceptance:
- Phone auth endpoints remain canonical and stable.
- Display paths show phone-first labels when email absent.
- New invariant tests pass.
## Idempotence and Recovery
Auth hardening changes are additive and test-gated. Roll back by app-level revert if a contract regression is detected.
+26 -102
View File
@@ -1,121 +1,45 @@
# Booking Integrity (Availability, Schedules, Overlap Prevention) # Booking Integrity
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. This ExecPlan follows `docs/PLANS.md`.
The requirements for ExecPlans live in `PLANS.md` at the repository root. This document must be maintained in accordance with that file.
## Purpose / Big Picture ## Purpose / Big Picture
Reject invalid booking windows and staff double-booking; accept only schedule-valid requests.
After this change, bookings cannot be created in invalid time windows or in ways that double-book staff. A manager can rely on the system to prevent overlapping appointments and to enforce staff working hours. You can see it working by attempting to create a booking outside a staff members availability window or that overlaps an existing confirmed booking and receiving a clear validation error; creating a booking that fits availability and does not overlap should succeed.
## Progress ## Progress
- [x] (2026-02-28 13:05Z) Plan created.
- [x] (2026-02-28 13:05Z) Created ExecPlan for booking integrity (availability, schedules, overlap prevention). - [x] (2026-02-28 13:30Z) Validation service implemented (duration/availability/overlap).
- [x] (2026-02-28 13:25Z) Added staff availability model, admin registration, and manual migration. - [x] (2026-02-28 13:45Z) Tests added.
- [x] (2026-02-28 13:30Z) Introduced booking validation service for duration, schedule, and overlap checks. - [x] (2026-02-28 13:50Z) Risks updated.
- [x] (2026-02-28 13:32Z) Updated booking create serializer to require staff and enforce validation rules.
- [x] (2026-02-28 13:45Z) Added backend tests covering overlap prevention, availability windows, and duration validation.
- [x] (2026-02-28 13:50Z) Updated `docs/risks.md` to reflect closed booking-integrity gaps.
## Surprises & Discoveries ## Surprises & Discoveries
- Observation: `makemigrations` unavailable in one environment due missing Django.
- Observation: Django is not installed in the environment, so `makemigrations` could not run. Evidence: import error during initial migration step.
Evidence: `ImportError: Couldn't import Django` when running `python3 backend/manage.py makemigrations salons`.
## Decision Log ## Decision Log
- Decision: require `staff` on booking creation.
- Decision: Require `staff` on booking creation to enforce schedule and overlap rules deterministically. Rationale: no deterministic schedule validation without staff.
Rationale: Without an assigned staff member, the system cannot guarantee schedule integrity. Date/Author: 2026-02-28/Codex
Date/Author: 2026-02-28, Codex - Decision: no-availability-records => open schedule.
- Decision: Treat staff availability as open-ended if no availability records exist for that staff member. Rationale: backward compatibility while enabling stricter config when data exists.
Rationale: This avoids breaking existing workflows while enabling explicit schedule enforcement when configured. Date/Author: 2026-02-28/Codex
Date/Author: 2026-02-28, Codex
- Decision: Enforce that `end_time - start_time` matches the service duration in minutes.
Rationale: Prevents inconsistent bookings and ensures predictable slot lengths.
Date/Author: 2026-02-28, Codex
- Decision: Add the `StaffAvailability` migration manually instead of using `makemigrations`.
Rationale: Django was unavailable in the environment; a manual migration keeps schema changes explicit and reviewable.
Date/Author: 2026-02-28, Codex
## Outcomes & Retrospective ## Outcomes & Retrospective
Completed. Booking integrity checks are active with tests for core failure modes.
Booking integrity is now enforced via staff availability checks, duration validation, and overlap prevention, with test coverage for each rule. This closes the highest-risk booking integrity gap in `docs/risks.md`, while timezone and business-hours enforcement remain future work.
## Context and Orientation ## Context and Orientation
- Validation entrypoint: `backend/apps/bookings/services.py`
Booking creation is implemented in `backend/apps/bookings/serializers.py` (`BookingCreateSerializer`) and routed via `backend/apps/bookings/views.py` in a DRF `ModelViewSet`. The booking model lives in `backend/apps/bookings/models.py`, while staff information is in `backend/apps/salons/models.py` as `StaffProfile`. There is no current scheduling model and no overlap validation. This plan introduces a staff availability model and a dedicated booking validation service to keep business logic out of views, in line with project standards. - Create serializer: `backend/apps/bookings/serializers.py`
- Staff availability model: `backend/apps/salons/models.py`
## Plan of Work ## Plan of Work
Completed; remaining future policy work is timezone/business-hours specifics.
First, add a staff availability model in `backend/apps/salons/models.py`. Create a `StaffAvailability` model with a foreign key to `StaffProfile`, a day-of-week integer (0-6), and start/end times (as `TimeField`). Use an `is_active` boolean to allow disabling entries without deleting them. Register the model in `backend/apps/salons/admin.py` for basic management. Create and apply a migration in the salons app.
Next, add a booking validation service in `backend/apps/bookings/services.py`. The service should expose a function like `validate_booking_request(service, staff, start_time, end_time)` that raises `serializers.ValidationError` or a custom domain error translated into DRF validation errors. It should check:
- `staff` is required and belongs to the same salon as the service.
- `start_time < end_time` and duration matches `service.duration_minutes`.
- Staff availability: if availability records exist for the staff and day-of-week, ensure the booking window is fully inside one availability window with `is_active=True`.
- Overlap: prevent any booking for the same staff with status in `pending` or `confirmed` that overlaps the requested window; `cancelled` and `completed` bookings should not block.
Then, update `BookingCreateSerializer` in `backend/apps/bookings/serializers.py` to call the validation service and to require `staff`. Keep `create` unchanged beyond relying on validated data.
Finally, add tests in `backend/apps/bookings/tests/test_booking_integrity.py`. Cover these cases:
- Reject bookings with no staff assigned.
- Reject bookings where `end_time` precedes `start_time`.
- Reject bookings where duration does not match `service.duration_minutes`.
- Reject bookings outside staff availability when availability records exist.
- Allow bookings when no availability records exist.
- Reject overlapping bookings for the same staff with `pending` or `confirmed` status; allow overlaps with `cancelled` or `completed` bookings.
Update `docs/risks.md` to mark booking integrity gaps as addressed once tests pass.
## Concrete Steps
Run these commands from the repository root (`/home/m7md/kshkool/Salon`).
1. Add staff availability model and migration.
- Edit `backend/apps/salons/models.py` and `backend/apps/salons/admin.py`.
- Run:
python3 backend/manage.py makemigrations salons
2. Add booking validation service and update serializer.
- Create `backend/apps/bookings/services.py` and update `backend/apps/bookings/serializers.py`.
3. Add tests.
- Create `backend/apps/bookings/tests/test_booking_integrity.py`.
4. Run tests.
- Backend:
python3 -m pytest
## Validation and Acceptance ## Validation and Acceptance
From `backend/`:
- Attempting to create a booking without a staff member returns HTTP 400 with a clear validation error. - `python3 -m pytest backend/apps/bookings/tests`
- Creating a booking outside availability returns HTTP 400 with a clear validation error. Acceptance:
- Creating a booking overlapping an existing pending/confirmed booking for the same staff returns HTTP 400. - Invalid duration/availability/overlap cases return 400.
- Creating a booking within an availability window and without overlap returns HTTP 201. - Valid windows return 201.
- Running `python3 -m pytest` passes, and the new booking-integrity tests fail before the changes and pass after.
## Idempotence and Recovery ## Idempotence and Recovery
Validation is stateless; schema change is additive and reversible by standard migrations.
Model and serializer changes are additive and safe to reapply. If a migration needs to be re-run, it can be rolled back using standard Django migration rollback and re-applied. The validation service is pure and can be iterated without impacting data. If availability rules are too strict, disabling availability entries will effectively remove the constraint without deleting data.
## Artifacts and Notes
Example overlap query used in validation:
Booking.objects.filter(
staff=staff,
status__in=[BookingStatus.PENDING, BookingStatus.CONFIRMED],
start_time__lt=end_time,
end_time__gt=start_time,
)
## Interfaces and Dependencies
- `backend/apps/salons/models.py` must define a new `StaffAvailability` model with fields: `staff` (FK), `day_of_week` (0-6), `start_time`, `end_time`, `is_active`.
- `backend/apps/bookings/services.py` must define `validate_booking_request(service, staff, start_time, end_time)`.
- `backend/apps/bookings/serializers.py` must call the validation service and require `staff` on create.
Plan Maintenance Note: Created on 2026-02-28 to implement booking integrity (availability, schedules, overlap prevention) as the next Phase 1 reliability step.
Plan Maintenance Note (Update): Marked milestones complete and recorded the manual migration decision after implementing booking integrity and tests on 2026-02-28.
+24 -85
View File
@@ -1,104 +1,43 @@
# Booking Lifecycle Notifications (SMS/WhatsApp) # Booking Lifecycle Notifications
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. This ExecPlan follows `docs/PLANS.md`.
The requirements for ExecPlans live in `PLANS.md` at the repository root. This document must be maintained in accordance with that file.
## Purpose / Big Picture ## Purpose / Big Picture
Notify customer and staff on booking created/confirmed/cancelled with auditable notification rows.
After this change, a booking will automatically notify the customer and the assigned staff member when it is created, confirmed, or cancelled. You can see it working by creating a booking and observing two notification records (customer + staff), then changing the booking status to confirmed or cancelled and seeing two more notification records for that event. In the console provider, the messages are logged, giving an immediate, user-visible trace of the booking lifecycle.
## Progress ## Progress
- [x] (2026-02-28 17:05Z) Plan + scope finalized.
- [x] (2026-02-28 17:05Z) Created ExecPlan for booking lifecycle notifications and reviewed bookings + notifications gaps. - [x] (2026-02-28 17:40Z) Notification app + providers + booking wiring implemented.
- [x] (2026-02-28 17:30Z) Implemented notifications app with audit-friendly model, providers, and booking message templates. - [x] (2026-02-28 18:05Z) Tests added for create/status-change/no-duplicate behavior.
- [x] (2026-02-28 17:40Z) Connected booking create/update flows to notification dispatch with idempotent event handling. - [x] (2026-02-28 18:10Z) Risks/docs updated.
- [x] (2026-02-28 17:55Z) Allowed booking status updates with role checks to enable confirmation/cancellation.
- [x] (2026-02-28 18:05Z) Added tests for booking notifications (create, status change, no duplicate sends).
- [x] (2026-02-28 18:10Z) Updated `docs/risks.md` and validated tests (`python3 -m pytest`).
## Surprises & Discoveries ## Surprises & Discoveries
- Observation: status field had been read-only for update flow.
- Observation: Booking status updates were blocked because `status` was read-only on the default booking serializer. Evidence: `PATCH /api/bookings/<id>` validation failure before serializer update.
Evidence: `PATCH /api/bookings/<id>` returned HTTP 400 when attempting to confirm.
## Decision Log ## Decision Log
- Decision: persist notification records for every lifecycle send attempt.
- Decision: Store every booking notification in a dedicated `Notification` model for auditability, even when skipped. Rationale: auditability + support traceability.
Rationale: Lifecycle messages are user-facing and must be traceable for support and compliance. Date/Author: 2026-02-28/Codex
Date/Author: 2026-02-28, Codex - Decision: reuse OTP provider abstraction for notification channels.
- Decision: Reuse existing OTP provider adapters for SMS/WhatsApp delivery, with a new `NOTIFICATION_PROVIDER` setting. Rationale: avoid duplicated provider integration.
Rationale: Avoid duplicate integration code while still allowing independent provider configuration. Date/Author: 2026-02-28/Codex
Date/Author: 2026-02-28, Codex
- Decision: Default to SMS for booking notifications and use the recipients preferred language when formatting messages.
Rationale: SMS is the most reliable baseline in KSA, and language preference is already captured on the user.
Date/Author: 2026-02-28, Codex
- Decision: Allow booking status changes via `BookingSerializer` with role-based validation.
Rationale: Confirmation/cancellation must be reachable through the existing API, but should still respect basic role boundaries.
Date/Author: 2026-02-28, Codex
## Outcomes & Retrospective ## Outcomes & Retrospective
Completed. Booking lifecycle notifications are live with idempotent records and test coverage.
Booking lifecycle notifications are now implemented with audit-friendly records and idempotent sending. Booking creation and status changes (confirmed/cancelled) trigger SMS/WhatsApp notifications for both customer and staff, and role-based validation now governs status updates. Provider adapters remain scaffolds, so production delivery still requires real SMS/WhatsApp wiring.
## Context and Orientation ## Context and Orientation
- Notification domain: `backend/apps/notifications/`
Booking creation and updates are handled in `backend/apps/bookings/views.py` via a DRF `ModelViewSet`. The booking model is in `backend/apps/bookings/models.py`, with `status` indicating lifecycle state. There is currently no notification system beyond OTP scaffolding in `backend/apps/accounts/services/otp.py`. This plan adds a new Django app at `backend/apps/notifications/` to store notification records, format booking lifecycle messages, and dispatch them via SMS or WhatsApp providers. - Booking integration points: `backend/apps/bookings/views.py`
A “notification” in this repository means a user-facing message (SMS or WhatsApp) that is stored for auditability in a `Notification` database row. A “lifecycle event” is a booking change that should inform the customer and staff: booking created, confirmed, or cancelled.
## Plan of Work ## Plan of Work
Completed; future work is provider hardening/monitoring only.
First, create a `notifications` Django app with models and admin registration. Define `Notification`, `NotificationEvent`, `NotificationStatus`, and `NotificationChannel` in `backend/apps/notifications/models.py`. The model must capture booking, recipient, phone number, event, channel, status, provider, message, and send timestamps, and it must be idempotent by preventing duplicates for the same booking + recipient + event + channel. Register the model in `backend/apps/notifications/admin.py` and add `apps.notifications` to `INSTALLED_APPS` in `backend/salon_api/settings.py`.
Next, implement notification dispatch in `backend/apps/notifications/services.py`. Reuse OTP provider adapters from `apps.accounts.services.otp` with a new `NOTIFICATION_PROVIDER` setting (default to `OTP_PROVIDER`). Add a `NOTIFICATION_DEFAULT_CHANNEL` setting (default `sms`). Implement `send_booking_notification(booking, recipient, event)` to build localized message text using the recipients preferred language, send via the provider, and update the notification status. Implement `notify_booking_lifecycle(booking, event)` for initial sends and `notify_on_status_change(booking, previous_status)` to trigger only on status transitions. If the recipient lacks a phone number, record the notification as `skipped` with a reason.
Then, wire booking lifecycle events in `backend/apps/bookings/views.py`. On `perform_create`, call `notify_booking_lifecycle(..., booking_created)` so both customer and staff receive a message. On `perform_update`, compare the previous status to the new status and call `notify_on_status_change` for confirmed or cancelled transitions. Avoid sending notifications if the status does not change.
Finally, add tests in `backend/apps/notifications/tests/test_booking_notifications.py`. Cover booking creation (two notifications), status change to confirmed (two notifications), and a repeat status update that should not create duplicates. Ensure tests use phone numbers on users to avoid skipped notifications. Update `docs/risks.md` to mark “No notifications (email/SMS) beyond OTP scaffolding” as addressed once tests pass.
## Concrete Steps
Run these commands from the repository root (`/home/m7md/kshkool/Salon`).
1. Add notifications app code and migrations.
- Create `backend/apps/notifications/` with `apps.py`, `models.py`, `services.py`, `admin.py`, and a migration `0001_initial.py`.
- Update `backend/salon_api/settings.py` to include `apps.notifications` and notification settings.
2. Wire booking lifecycle events.
- Update `backend/apps/bookings/views.py` to call notification services on create and status changes.
3. Add tests.
- Create `backend/apps/notifications/tests/test_booking_notifications.py`.
4. Run backend tests.
- From `backend/` with the venv active:
python3 -m pytest
## Validation and Acceptance ## Validation and Acceptance
From `backend/`:
- Creating a booking returns HTTP 201 and creates two notification records (customer + staff) with event `booking_created`. - `python3 -m pytest backend/apps/notifications/tests`
- Updating a bookings status to `confirmed` creates two notification records with event `booking_confirmed`. Acceptance:
- Repeating the same status update does not create duplicate notifications (records remain at two for that event). - Create/confirm/cancel each emit customer+staff notification rows once per event/channel.
- `python3 -m pytest` passes, and the new tests fail before the change and pass after.
## Idempotence and Recovery ## Idempotence and Recovery
Uniqueness constraints prevent duplicate event/channel sends per recipient/booking; retries update existing rows safely.
Notification creation is idempotent by a uniqueness constraint on booking + recipient + event + channel. Re-running the send logic will update a pending or failed notification rather than creating duplicates. If a migration needs to be reverted, use standard Django migration rollback and re-apply. If a notification provider is misconfigured, notifications will be marked failed and can be retried after fixing settings.
## Artifacts and Notes
Expected console-provider log example when creating a booking:
INFO OTP SMS to 0500000002: Your booking request is received for Haircut at Main Salon on 2026-03-01 10:00.
INFO OTP SMS to 0500000003: Your booking request is received for Haircut at Main Salon on 2026-03-01 10:00.
## Interfaces and Dependencies
- `backend/apps/notifications/models.py` must define `Notification`, `NotificationEvent`, `NotificationStatus`, `NotificationChannel`.
- `backend/apps/notifications/services.py` must expose `send_booking_notification`, `notify_booking_lifecycle`, and `notify_on_status_change`.
- `backend/apps/bookings/views.py` must call notification services in `perform_create` and `perform_update`.
- `backend/salon_api/settings.py` must define `NOTIFICATION_PROVIDER` and `NOTIFICATION_DEFAULT_CHANNEL` settings.
Plan Maintenance Note: Created on 2026-02-28 to implement booking lifecycle notifications as the next Phase 1 reliability milestone.
Plan Maintenance Note (Update): Marked milestones complete, recorded the booking status update discovery, and documented role-based status validation after implementing notifications and tests on 2026-02-28.
+27 -119
View File
@@ -1,138 +1,46 @@
# Payments Integration (Moyasar, Webhooks, Idempotency) # Payments Integration (Moyasar)
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. This ExecPlan follows `docs/PLANS.md`.
The requirements for ExecPlans live in `PLANS.md` at the repository root. This document must be maintained in accordance with that file.
## Purpose / Big Picture ## Purpose / Big Picture
Create payments idempotently, reconcile webhook states safely, and keep payment history auditable.
After this change, the backend can create Moyasar payments, track their state transitions, and reconcile them via webhooks in an idempotent and auditable way. A user can create a booking payment and see it progress from initiated to paid or failed. You can see it working by creating a payment, receiving a webhook callback that marks it as paid, and observing the payment record transition with a recorded provider reference and idempotency key.
## Progress ## Progress
- [x] (2026-02-28 14:35Z) Plan created.
- [x] (2026-02-28 14:35Z) Created ExecPlan for payments integration (Moyasar + webhooks + idempotency). - [x] (2026-02-28 15:55Z) Payment creation + webhook processing implemented.
- [x] (2026-02-28 15:05Z) Inspected payments models/endpoints and aligned naming with Moyasar scaffolding. - [x] (2026-02-28 16:10Z) Tests for create/idempotency/webhooks implemented.
- [x] (2026-02-28 15:20Z) Defined payment state model extensions and idempotency tracking fields. - [x] (2026-02-28 16:20Z) Risks updated.
- [x] (2026-02-28 15:40Z) Implemented payment creation service and API endpoint with provider gateway.
- [x] (2026-02-28 15:55Z) Implemented webhook endpoint with secret verification and status mapping.
- [x] (2026-02-28 16:10Z) Added tests for creation, idempotency, and webhook reconciliation.
- [x] (2026-02-28 16:20Z) Updated `docs/risks.md` to close payment integration gaps once tested.
## Surprises & Discoveries ## Surprises & Discoveries
- Observation: missing `requests` dependency blocked gateway calls initially.
- Observation: The payments gateway needed an HTTP client dependency, so `requests` was added to backend requirements. Evidence: `ModuleNotFoundError: requests`.
Evidence: `ModuleNotFoundError: No module named 'requests'` when running migrations after adding gateway calls.
## Decision Log ## Decision Log
- Decision: enforce request-level idempotency key for payment create.
- Decision: Model payment state transitions as explicit status changes with audit-friendly timestamps. Rationale: prevent duplicate charges on retries.
Rationale: Payment flows must be auditable and deterministic under retries. Date/Author: 2026-02-28/Codex
Date/Author: 2026-02-28, Codex - Decision: persist provider + webhook payloads.
- Decision: Require idempotency keys on payment creation requests. Rationale: payment auditability/debuggability.
Rationale: Prevents duplicate charges when clients retry. Date/Author: 2026-02-28/Codex
Date/Author: 2026-02-28, Codex
- Decision: Use a dedicated webhook endpoint with signature verification.
Rationale: Ensures authenticity of provider callbacks and protects state integrity.
Date/Author: 2026-02-28, Codex
- Decision: Store provider payloads and webhook payloads on the payment record for auditability.
Rationale: Helps trace payment transitions without introducing a separate event table yet.
Date/Author: 2026-02-28, Codex
## Outcomes & Retrospective ## Outcomes & Retrospective
Completed for MVP core flow (create + webhook). Remaining work: richer operational monitoring and optional admin capture/refund APIs.
Payment creation, idempotency handling, and webhook reconciliation are implemented for Moyasar. Tests cover creation, idempotency, and webhook status transitions, reducing the largest Phase 1 reliability gap. Refund/capture operations remain future work if required.
## Context and Orientation ## Context and Orientation
- Payment model/services/views: `backend/apps/payments/`
Payments live in `backend/apps/payments/` with current models and API endpoints. The system currently stores payment records but does not integrate with Moyasar or reconcile webhooks. Booking flows live in `backend/apps/bookings/` and should link to payments. The project standards require business logic in services and predictable error responses. - Booking dependency: `backend/apps/bookings/`
## Plan of Work ## Plan of Work
Completed for current scope.
First, review existing payment models and endpoints to avoid breaking field names. Identify whether `Payment` includes a reference to `Booking`, a `provider_reference`, and a status field. If any are missing, add them along with timestamps for `initiated_at`, `paid_at`, and `failed_at`. Create a migration for the new fields. Ensure status choices include at least `initiated`, `pending`, `paid`, `failed`, and `refunded` if refunds are in scope.
Next, introduce idempotency tracking. Add a `idempotency_key` field to the payment model (unique, indexed) and validate that payment creation requests require it. If a request repeats with the same key, return the existing payment without creating a new provider charge.
Then, implement the Moyasar payment creation service in `backend/apps/payments/services.py`. The service should build the provider request using amount, currency, description, and return URLs, and persist the `provider_reference` (payment id returned by Moyasar). Store the full provider response in a JSON field for audit if available.
Add a dedicated API endpoint for payment creation in `backend/apps/payments/views.py` and `backend/apps/payments/urls.py`. It should:
- Require authentication.
- Validate booking ownership and amount.
- Require `idempotency_key`.
- Call the service to create the provider payment.
- Return the payment record plus any provider redirect URL if applicable.
Then, implement the webhook endpoint (`/api/payments/webhook/`) with signature verification using Moyasars secret. It should parse the event, locate the payment by `provider_reference`, apply an idempotent state transition, and record timestamps. Unknown events should be logged but return 200 to avoid retries if possible.
Finally, add tests in `backend/apps/payments/tests/`:
- Creating a payment succeeds and stores provider reference.
- Creating with the same idempotency key returns the original record.
- Webhook for `paid` updates status and timestamp.
- Webhook with invalid signature is rejected.
- Webhook is idempotent (replay does not change state or duplicate logs).
Update `docs/risks.md` to mark payment integration gaps as addressed.
## Concrete Steps
Run these commands from the repository root (`/home/m7md/kshkool/Salon`).
1. Inspect payments models and endpoints.
- Read `backend/apps/payments/models.py`, `backend/apps/payments/views.py`, and `backend/apps/payments/serializers.py`.
2. Add fields for provider reference, status timestamps, and idempotency.
- Update `backend/apps/payments/models.py` and create a migration.
- Run:
python3 backend/manage.py makemigrations payments
3. Implement services and endpoints.
- Add `backend/apps/payments/services.py`.
- Update serializers and views accordingly.
4. Add webhook endpoint and signature verification.
- Update `backend/apps/payments/urls.py` and `backend/apps/payments/views.py`.
5. Add tests.
- Create `backend/apps/payments/tests/test_payments_flow.py`.
6. Run tests.
- Backend:
source venv/bin/activate
cd backend
python3 -m pytest
## Validation and Acceptance ## Validation and Acceptance
From `backend/`:
- Creating a payment with a new idempotency key returns HTTP 201 and a provider reference. - `python3 -m pytest backend/apps/payments/tests`
- Creating the same payment with the same idempotency key returns HTTP 200/201 with the original payment (no new provider request). Acceptance:
- A valid webhook updates the payment status to `paid` and sets `paid_at`. - New key => new payment.
- An invalid webhook signature returns HTTP 400/401 and does not mutate data. - Same key => existing payment reused.
- `python3 -m pytest` passes with the new payments tests. - Valid webhook transitions state idempotently.
- Invalid webhook auth rejected.
## Idempotence and Recovery ## Idempotence and Recovery
Idempotency key guards create path; webhook replay is safe.
Payment creation is safe to retry with idempotency keys. Webhook processing is idempotent and can be replayed safely. If a payment status change is applied incorrectly, it can be corrected manually via admin and will be documented in audit fields.
## Artifacts and Notes
Example idempotency pattern:
existing = Payment.objects.filter(idempotency_key=key).first()
if existing:
return existing
Example overlap-safe webhook logic:
if payment.status == PaymentStatus.PAID:
return
payment.mark_paid()
## Interfaces and Dependencies
- `backend/apps/payments/models.py` must include fields: `provider_reference`, `idempotency_key`, `status`, `initiated_at`, `paid_at`, `failed_at`, and (optionally) `provider_payload` (JSON).
- `backend/apps/payments/services.py` must define `create_payment_for_booking(booking, idempotency_key, request_data)` and `verify_webhook_signature(request)`.
- `backend/apps/payments/views.py` must expose `PaymentCreateAPIView` and `payment_webhook` with signature verification.
Plan Maintenance Note: Created on 2026-02-28 to implement Moyasar payments with idempotency and webhook reconciliation as the next Phase 1 reliability milestone.
Plan Maintenance Note (Update): Marked steps complete and recorded dependency and audit decisions after implementing payments and tests on 2026-02-28.
+216
View File
@@ -0,0 +1,216 @@
# Frontend Technical Specs and Requirements (MVP)
## Purpose
Define the implementation contract for the React frontend against the current backend REST API so the customer flows (auth, search, booking, payment, profile) can ship reliably for KSA.
## Scope
In scope:
- Customer web app (React + Vite) for Phase 1 flows.
- API integration contracts for current backend endpoints.
- UX/error/loading behavior and test requirements.
- i18n/RTL and KSA timezone handling.
Out of scope (Phase 2+):
- Manager/staff admin dashboards.
- Advanced reporting/reviews moderation tools.
- Full observability product dashboards.
## Product and Platform Constraints
- Market default: KSA first.
- Default locale: `ar-sa`; fallback: `en`.
- Timezone baseline: `Asia/Riyadh` (`+03:00`).
- API error contract: HTTP status + `detail` where applicable.
- Booking/payment operations must avoid duplicate side effects.
## UX Priorities (Primary Contract)
- First screen for authenticated and guest users MUST be a feed of nearby/available salons.
- The feed is the default home experience and primary entry to booking.
- Main customer navigation MUST be bottom tabs (mobile-first), not header-first navigation.
- Bottom tabs MUST include at minimum:
- Home/Feed
- Bookings
- Profile
- Optional tabs (when implemented) may include Search/Explore and Payments, but must not displace Home/Feed as default.
## Frontend Architecture Requirements
## Runtime and Stack
- React 18 + Vite.
- React Router (`BrowserRouter`) for route navigation.
- `i18next` + `react-i18next` for translations and direction switching.
- `fetch` wrapper in `src/api/client.js` as single API boundary.
- Auth/session state in `AuthContext`.
## Route Map (Customer)
- `/` home feed (nearby/available salons) + search/filter.
- `/salon/:id` salon detail.
- `/login` phone OTP login.
- `/book?salon=<id>` booking creation.
- `/pay?booking=<id>` payment initiation.
- `/pay/return` payment callback/return surface.
- `/bookings` customer booking history.
- `/profile` customer profile summary.
## Module Boundaries
- `src/api/`: all HTTP logic, standardized errors.
- `src/contexts/`: auth/session lifecycle only.
- `src/hooks/`: domain-side UI logic (`useSalonSearch`, `usePaymentForm`).
- `src/pages/`: route-level composition.
- `src/components/`: reusable presentation and guarded wrappers.
- `src/i18n/`: locale dictionaries and locale/direction state.
## Functional Requirements
### FR-1 Phone-First Authentication
- Login MUST use:
- `POST /api/auth/phone/request/`
- `POST /api/auth/phone/verify/`
- Password auth endpoint (`/api/auth/token/`) MUST NOT be used (returns 410).
- Login request form MUST collect:
- `phone_number` (accept KSA local or E.164 input)
- `channel` (`sms` or `whatsapp`)
- optional: `device_id` (recommended for abuse controls)
- Verify step MUST submit `request_id` + 6-digit `code`.
- On success, frontend MUST persist `access` and `refresh` tokens and user payload.
### FR-2 Session Restore and Token Refresh
- On app boot, if `access` exists:
- call `GET /api/auth/me/`.
- If `401`/token invalid:
- call `POST /api/auth/token/refresh/` once.
- retry `GET /api/auth/me/` with new access token.
- If refresh fails, frontend MUST clear tokens and require re-login.
### FR-3 Salon Discovery
- Home MUST render a salon feed by default on first load.
- Feed data MUST come from `GET /api/salons/` and support query params for discovery.
- Home search MUST call `GET /api/salons/?q=<query>`.
- Search SHOULD support additional filters when UI is added:
- `city`
- `service`
- Nearby/available ranking can be client-side initially, but server response MUST remain source of truth.
- Result cards MUST link to `/salon/:id`.
### FR-4 Salon Detail
- Detail page MUST call `GET /api/salons/:id/`.
- UI MUST render:
- salon base info
- services (duration, amount, currency)
- staff list
- optional reviews/photos if present
- CTA MUST deep-link to booking flow with salon id.
### FR-5 Booking Creation
- Booking page MUST require authenticated user.
- Create booking with `POST /api/bookings/` using:
- `service`
- `staff` (required)
- `start_time`
- `end_time`
- optional `notes`
- `end_time` MUST match service duration exactly.
- Datetime submitted to backend MUST include explicit offset (`+03:00` for KSA baseline).
- On success (`201`), frontend MUST navigate to payment flow with booking id.
### FR-6 Booking History
- `/bookings` MUST call authenticated `GET /api/bookings/`.
- List MUST show booking id, status, salon/service labels, datetime, and price.
- Datetime rendering MUST use active locale formatting.
### FR-7 Payment Initiation (Idempotent)
- Payment submission MUST call `POST /api/payments/`.
- Payload requirements:
- `booking_id` (number)
- `provider` = `moyasar`
- `idempotency_key` (UUID)
- `source` object with supported type (`stcpay`, `token`, `applepay`, `samsungpay`)
- `callback_url` required for `source.type=token`
- Frontend MUST disable duplicate submits while request is in-flight.
- Same payment attempt retry MUST reuse the same `idempotency_key`.
- New attempt MUST generate a new key.
- If response includes `redirect_url`, frontend MUST redirect.
### FR-8 Payment Return Handling
- `/pay/return` MUST parse query params:
- `status`
- `id`
- Success statuses shown as success UX: `paid`, `captured`, `authorized`.
- Non-success statuses MUST show neutral/pending/failure guidance and link to profile/bookings.
### FR-9 Locale and Direction
- App MUST allow switching between `ar-sa` and `en`.
- Locale switch MUST:
- persist preference in local storage
- set `<html lang>`
- set `<html dir>` (`rtl` for `ar-sa`, `ltr` for `en`)
- API calls MUST include `Accept-Language` header with active locale.
## API Contract Requirements
| Endpoint | Auth | Request | Success | Error handling |
|---|---|---|---|---|
| `POST /api/auth/phone/request/` | No | `phone_number`, `channel`, optional profile fields | `201` with `request_id`, `expires_at` | `429` may include `retry_after_seconds`; show wait message |
| `POST /api/auth/phone/verify/` | No | `request_id`, `code` | `200` with `access`, `refresh`, `user` | `400` invalid/expired code |
| `POST /api/auth/token/refresh/` | No | `refresh` | `200` with new `access` | logout on failure |
| `GET /api/auth/me/` | Bearer | - | `200` user payload | `401` triggers refresh flow |
| `GET /api/salons/` | No | `q`, optional `city`, `service` | `200` list | show localized generic fetch error |
| `GET /api/salons/:id/` | No | - | `200` detail object | show detail/fallback |
| `POST /api/bookings/` | Bearer | booking payload | `201` booking | `400` field validation errors |
| `GET /api/bookings/` | Bearer | - | `200` list | auth + generic errors |
| `POST /api/payments/` | Bearer | payment payload | `201` created or `200` reused idempotent record | `400/403` with details; never auto-retry with new key |
## Error and State Handling Requirements
- API wrapper MUST throw structured errors with:
- HTTP status
- parsed response body
- best message (`detail` first, fallback to response text)
- For validation objects (`{field: [msg]}`), UI SHOULD render first field message near form and keep raw object in debug logs.
- For `429` with `retry_after_seconds`, UI MUST display server-provided cooldown.
- All mutating forms MUST expose:
- idle/loading/error/success states
- submit button disabled while loading
## Security and Abuse-Resistance Requirements
- Use Bearer access token for authenticated endpoints only.
- Include optional `device_id` during phone auth request to strengthen backend abuse controls.
- Never send raw card PAN/CVV data to backend; use tokenized sources only.
- On logout, clear user and both tokens from memory + storage.
## Accessibility and UX Requirements
- All interactive controls MUST have accessible labels.
- Auth/booking/payment forms MUST be keyboard usable.
- Error text MUST be visible and associated with active form context.
- Layout MUST remain usable on mobile widths (`>=320px`) and desktop.
## Non-Functional Requirements
- Reliability: no duplicate payment submission side effects for one attempt.
- Consistency: API errors surfaced predictably and localized where available.
- Maintainability: domain behavior in hooks/services, not route components.
- Extensibility: route/module structure must support manager/staff pages later without rewrite.
## Test Requirements (Frontend)
- Test stack: `vitest` + Testing Library.
- Required coverage for release:
- phone login request + verify success/failure + 429 cooldown message
- auth restore and refresh-token fallback
- protected route redirect behavior
- salon search loading/empty/results/error
- booking form validation + API error mapping + success redirect
- payment form source validation + idempotency key reuse on retry + redirect behavior
- locale switching persists and sets `lang`/`dir`
- bookings list rendering and localized datetime output
Run:
- `cd frontend && npm run test`
## Definition of Done (Frontend)
- All FR requirements implemented for in-scope routes.
- API integrations match endpoint/payload contract above.
- No use of deprecated password login API.
- All listed frontend tests pass.
- `ar-sa` and `en` UX verified on mobile + desktop.
## Known Dependencies and Open Decisions
- OAuth/social-linking policy is not finalized; keep social login UI hidden for now.
- Cancellation and refund policies are not finalized; do not ship irreversible customer actions until policy finalization.
- Detailed business-hours/timezone policy beyond current backend validation remains open; keep KSA-offset submission and avoid client-side assumptions that override server validation.
+18 -23
View File
@@ -1,31 +1,26 @@
# Risks And Gaps # Risks And Gaps
This file tracks known gaps and risks to address in future iterations. Open items only; remove resolved duplicates.
## Security And Auth ## Auth
- Phone normalization is KSA-focused and minimal; broaden for multi-country use. - KSA-focused phone normalization; multi-country strategy pending.
- OTP protections are basic; add device fingerprinting and IP throttling if needed. - Phone auth abuse controls need production tuning (IP/device thresholds).
- Authentica OTP provider is implemented (SMS + WhatsApp via Authentica OTP). - Social login/OAuth linking policy still undefined (collision/merge rules).
- Social login is a placeholder. - JWT test warning exists for short test signing key (`InsecureKeyLengthWarning`).
- `USERNAME_FIELD` is now `"phone_number"`; `REQUIRED_FIELDS = []`; `create_superuser` accepts `phone_number`. Admin and `createsuperuser` work correctly for phone-only users.
## Booking Integrity ## Booking
- Availability checks and overlap prevention are now enforced for staff bookings. - No explicit timezone/business-hours policy beyond current availability checks.
- **Race condition — fixed:** `BookingCreateSerializer.create()` now locks the staff row with `select_for_update()` inside `transaction.atomic()` and re-runs the overlap check before inserting. Concurrent requests for the same staff slot are serialized at the DB level. Requires PostgreSQL in production (SQLite ignores `FOR UPDATE` but still serializes writes). - Cancellation policy and refund policy not finalized.
- No timezone handling or business hours enforcement.
- No cancellation rules or refund logic.
## Payments ## Payments
- Moyasar payment creation, webhook reconciliation, and idempotency are implemented. - Core Moyasar flow works; admin capture/refund endpoints not exposed yet.
- Moyasar capture and refund are implemented in the gateway; API endpoints for admin-initiated capture/refund can be added when needed. - Monitoring/alerting for webhook failures is still basic.
## Data And UX ## Localization
- Ratings are not recalculated from reviews. - Foundations exist (`en`, `ar-sa`, RTL), but translation coverage is incomplete.
- No image upload or storage strategy for photos. - RTL QA across all future pages still pending.
- Booking lifecycle notifications are implemented; Authentica can deliver SMS when NOTIFICATION_PROVIDER=authentica.
- Localization foundations are in progress; full Arabic translation coverage and RTL QA are still pending.
## Ops And Compliance ## Ops/Compliance
- No audit logs for admin actions. - No full audit log strategy for privileged actions.
- No multi-tenant isolation or data export tooling. - No PDPL/GDPR retention policy or data export workflow.
- No GDPR/PDPL data retention policies defined. - No formal observability baseline (metrics/SLO dashboards).
+4 -5
View File
@@ -1,9 +1,8 @@
# Runbooks # Runbooks Index
Operational procedures live here. Each new production-impacting workflow should add or update a runbook.
Existing runbooks:
Runbooks for production-impacting flows:
- `docs/runbooks/auth_otp_failures.md` - `docs/runbooks/auth_otp_failures.md`
- `docs/runbooks/booking_failures.md` - `docs/runbooks/booking_failures.md`
- `docs/runbooks/payments_sanity_check.md` - `docs/runbooks/payments_sanity_check.md`
Rule: if a new production flow is added, add or update a runbook in same change.
+24 -29
View File
@@ -1,39 +1,34 @@
# Runbook: Auth OTP Failures # Runbook: Auth OTP Failures
## Summary
Guide for diagnosing and mitigating OTP send or verify failures in phone-first authentication.
## Symptoms ## Symptoms
- Users do not receive OTP.
- Users report not receiving OTP codes. - `/api/auth/otp/request` or `/api/auth/phone/request` fails.
- `/api/auth/otp/request/` or `/api/auth/phone/request/` returns HTTP 500 or rate-limit errors. - `/api/auth/otp/verify` or `/api/auth/phone/verify` shows invalid/expired unexpectedly.
- `/api/auth/otp/verify/` or `/api/auth/phone/verify/` returns invalid or expired OTP errors unexpectedly.
## Impact ## Impact
Users cannot sign in/verify phone; booking/payment flows may block.
- Users cannot sign in or complete phone verification.
- Booking and payment flows are blocked when auth is required.
## Quick Checks ## Quick Checks
- Confirm `OTP_PROVIDER` in `backend/salon_api/settings.py`.
- Check OTP provider credentials in `backend/.env`.
- Check app logs for provider/timeouts/rate-limit errors.
- Validate OTP rate-limit settings:
- `OTP_MAX_PER_WINDOW`
- `OTP_WINDOW_MINUTES`
- `OTP_RESEND_COOLDOWN_SECONDS`
- `PHONE_AUTH_IP_MAX_PER_WINDOW`
- `PHONE_AUTH_DEVICE_MAX_PER_WINDOW`
- Confirm the provider configured in `backend/salon_api/settings.py` via `OTP_PROVIDER`. ## Mitigation
- Check recent application logs for OTP send errors. 1. Fix env/config mismatch; restart API.
- Verify provider credentials are present in `backend/.env` for the active provider. 2. If provider outage, use `console` only in non-prod.
3. If abuse spike/false positives, tune IP/device thresholds.
4. Verify server clock and `OTP_EXPIRY_MINUTES`.
## Mitigation Steps ## Escalation
- Roll back recent auth changes if correlated with deployment.
- Escalate to Authentica with request IDs + timestamps.
- If provider credentials are missing or invalid, fix the environment variables and restart the API process. ## References
- If the provider is down, temporarily switch to `OTP_PROVIDER=console` for non-production environments and notify support. - OTP logic: `backend/apps/accounts/services/otp.py`
- 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. - Risks: `docs/risks.md`
- If verification is failing, confirm server time is correct and `OTP_EXPIRY_MINUTES` is appropriate.
## Rollback / Escalation
- Roll back recent auth/OTP changes if the failure coincides with a deployment.
- Escalate to the provider (Authentica) with request IDs and timestamps if external API errors persist.
## Notes
- Authentica is the primary OTP provider for MVP; console provider is for local development.
- OTP send/verify logic lives in `backend/apps/accounts/services/otp.py`.
+18 -30
View File
@@ -1,40 +1,28 @@
# Runbook: Booking Failures # Runbook: Booking Failures
## Summary
Guide for diagnosing booking creation or status update failures (availability, overlap prevention, or validation errors).
## Symptoms ## Symptoms
- `POST /api/bookings/` fails (400/500).
- `POST /api/bookings/` returns HTTP 400 or 500. - Booking status update fails.
- `PATCH /api/bookings/<id>/` fails when confirming or cancelling. - Booking missing/incorrect in listing.
- Users report bookings not appearing or incorrect status.
## Impact ## Impact
Customers cannot book; staff schedule integrity degrades; dependent flows break.
- Customers cannot place bookings.
- Staff schedules become inconsistent.
- Notification and payment flows may not trigger.
## Quick Checks ## Quick Checks
- Validate payload: `service`, `staff`, `start_time`, `end_time`.
- Check logs for validation/integrity errors.
- Confirm staff availability + overlap expectations.
- If notifications expected, confirm provider config + notification rows.
- Confirm the request payload includes a valid `service`, `staff`, and scheduled time. ## Mitigation
- Check server logs for booking validation errors or integrity exceptions. 1. Reproduce with known test data.
- Verify that staff availability and overlap prevention rules are behaving as expected. 2. Inspect booking validation service and serializer permissions.
3. Confirm timezone assumptions for failing case.
4. If regression after deploy, roll back booking-related change.
## Mitigation Steps ## Escalation
Share booking id, user id, timestamps, and failing payload/response with engineering.
- Reproduce with a known test user and staff member to isolate data issues. ## References
- If overlap rules are too strict, review booking validation logic and confirm time zone assumptions. - Booking logic: `backend/apps/bookings/`
- If status updates are blocked, verify role checks and serializer permissions in `backend/apps/bookings/`. - Notification logic: `backend/apps/notifications/`
- If notifications are expected but missing, confirm `NOTIFICATION_PROVIDER` configuration and notification records.
## Rollback / Escalation
- Roll back recent booking-related changes if failures started after a deployment.
- Escalate to engineering with the booking ID, user ID, and timestamps.
## Notes
- Booking validation and status transitions live in `backend/apps/bookings/`.
- Notifications for booking lifecycle are handled in `backend/apps/notifications/`.
+28 -127
View File
@@ -1,136 +1,37 @@
# Payments Sanity Check (Moyasar Mock + Demo Data) # Runbook: Payments Sanity Check (Local Mock)
This runbook documents the end-to-end sanity check for the Moyasar payments flow using demo data and a local mock provider. It is intended for developers and agents validating payment creation + webhook reconciliation before merging to `main`. Validate payment create + webhook reconciliation without hitting Moyasar.
## Purpose
Verify that the payment creation endpoint and webhook processing work end-to-end in a local environment without hitting Moyasar.
## Preconditions ## Preconditions
- Venv + backend deps installed.
- Backend dependencies installed in the Python venv. - DB migrated.
- Frontend is not required for this check. - Run from repo root unless noted.
- `backend/` database is migrated and uses SQLite for local dev.
## High-level Flow
1. Start a local mock Moyasar server (HTTP) that emulates `/v1/payments` responses.
2. Run migrations and seed demo data.
3. Start Django with a local payment configuration pointing to the mock server.
4. Obtain a JWT access token for the demo customer.
5. Create a payment for an existing booking.
6. Send a webhook payload to mark it as paid.
7. Verify the payment status updates.
## Steps ## Steps
1. Start local mock server on `127.0.0.1:8001` exposing `POST /v1/payments`.
2. Seed data:
- `source venv/bin/activate`
- `cd backend`
- `python3 manage.py migrate`
- `python3 manage.py seed_demo`
3. Run API with mock settings:
- `DJANGO_DEBUG=1 MOYASAR_SECRET_KEY=sk_test MOYASAR_PUBLISHABLE_KEY=pk_test MOYASAR_BASE_URL=http://127.0.0.1:8001 MOYASAR_WEBHOOK_SECRET=whsec python3 manage.py runserver 8000`
4. Generate JWT in shell (demo user) and store as `<ACCESS>`.
5. Create payment:
- `POST /api/payments/` with `booking_id`, `provider=moyasar`, `idempotency_key`, valid source.
6. Send paid webhook:
- `POST /api/payments/webhook/` with `{"type":"payment_paid","secret_token":"whsec","data":{"id":"<external_id>"}}`
7. Verify `GET /api/payments/` shows status `paid` and `paid_at` set.
### 1) Start the mock Moyasar server ## Expected Results
- Create payment returns `status=initiated` + provider `external_id` + `redirect_url`.
- Webhook returns `{"detail":"Webhook processed"}`.
- Payment transitions to `paid` idempotently.
The mock server responds to `POST /v1/payments` with a static `id` and `transaction_url`. ## Edge Checks
- Wrong/missing webhook secret -> `401`.
Create the mock server at `/tmp/moyasar_mock.py` and run it: - Reused idempotency key -> same payment reused, no duplicate charge.
- Unsupported sources rejected by validation.
python3 /tmp/moyasar_mock.py
Expected: the process stays running, listening on `http://127.0.0.1:8001`.
### 2) Run migrations and seed demo data
source venv/bin/activate
cd backend
python3 manage.py migrate
python3 manage.py seed_demo
Expected: `Demo data seeded.`
### 3) Start Django with the mock provider
Run the backend with environment variables pointing to the mock server:
DJANGO_DEBUG=1 \
MOYASAR_SECRET_KEY=sk_test \
MOYASAR_PUBLISHABLE_KEY=pk_test \
MOYASAR_BASE_URL=http://127.0.0.1:8001 \
MOYASAR_WEBHOOK_SECRET=whsec \
python3 manage.py runserver 8000
Expected: server starts at `http://127.0.0.1:8000/`.
### 4) Obtain a JWT access token
The demo customer is:
- `customer@example.com`
- `Customer123!`
Fetch the access token:
curl -s -X POST http://127.0.0.1:8000/api/auth/token/ \
-H "Content-Type: application/json" \
-d '{"email":"customer@example.com","password":"Customer123!"}'
Expected: JSON containing `access` and `refresh` tokens.
### 5) Create a payment
Pick a booking (demo data creates bookings; you can list them):
curl -s -H "Authorization: Bearer <ACCESS>" http://127.0.0.1:8000/api/bookings/
Then create a payment (example uses booking id `3`):
curl -s -X POST http://127.0.0.1:8000/api/payments/ \
-H "Authorization: Bearer <ACCESS>" \
-H "Content-Type: application/json" \
-d '{
"booking_id": 3,
"provider": "moyasar",
"idempotency_key": "<UUID>",
"source": {"type": "stcpay", "mobile": "0500000000"}
}'
Expected: response includes:
- `status: initiated`
- `external_id: pay_mock_123`
- `redirect_url: https://moyasar.example/tx/mock`
### 6) Send webhook for paid state
curl -s -X POST http://127.0.0.1:8000/api/payments/webhook/ \
-H "Content-Type: application/json" \
-d '{"type":"payment_paid","secret_token":"whsec","data":{"id":"pay_mock_123"}}'
Expected: `{ "detail": "Webhook processed" }`
### 7) Verify payment state
curl -s -H "Authorization: Bearer <ACCESS>" http://127.0.0.1:8000/api/payments/
Expected: payment record shows:
- `status: paid`
- `paid_at` set
- `metadata.last_webhook` populated
## Considerations and Edge Cases
- **Webhook secret**: `MOYASAR_WEBHOOK_SECRET` must be set. Requests missing or mismatching `secret_token` return `401`.
- **Idempotency**: reuse the same `idempotency_key` to verify the API returns the existing payment without creating another provider charge.
- **Unsupported sources**: `creditcard` is rejected by the backend. Use `stcpay`, `token`, or `applepay`.
- **Callback URL**: required for `token` payments; otherwise validation fails.
- **Demo data**: `seed_demo` creates a payment with `external_id=None` (not empty string) to avoid violating unique constraints.
- **Debug mode**: `DJANGO_DEBUG=1` is required for local `runserver` if `ALLOWED_HOSTS` is not set.
- **JWT warnings**: short JWT secret keys can trigger warnings in logs; this is acceptable for local sanity checks but should be hardened in production.
## What to Look For
- Payment creation returns `external_id` from the mock server.
- Webhook transitions the payment to `paid` and populates `paid_at`.
- `metadata.last_webhook` persists the payload for audit.
## Cleanup ## Cleanup
Stop Django + mock processes.
- Stop the Django server (`Ctrl+C`).
- Stop the mock server (`Ctrl+C`).
- Optionally delete `/tmp/moyasar_mock.py`.
+5 -11
View File
@@ -1,25 +1,19 @@
# ADR <NNNN>: <Title> # ADR <NNNN>: <Title>
## Status ## Status
Proposed | Accepted | Deprecated | Superseded Proposed | Accepted | Deprecated | Superseded
## Context ## Context
Problem, constraints, and forces.
Explain the problem and the forces at play. Include constraints, risks, or user needs.
## Decision ## Decision
Chosen option.
State the decision clearly and explicitly.
## Consequences ## Consequences
Positive/negative impact, including ops impact.
List the expected positive and negative outcomes, including operational impact.
## Alternatives Considered ## Alternatives Considered
Viable options and rejection reason.
Briefly document viable alternatives and why they were rejected.
## Related ## Related
Relevant docs/code/runbooks/PRs.
Link to relevant PRs, runbooks, or architecture sections.
+2 -23
View File
@@ -1,29 +1,8 @@
# Runbook: <Short Title> # Runbook: <Title>
## Summary
One or two sentences describing the situation this runbook covers.
## Symptoms ## Symptoms
Describe what an operator or user will observe.
## Impact ## Impact
Who or what is affected.
## Quick Checks ## Quick Checks
Exact commands or checks that confirm the issue.
## Mitigation Steps ## Mitigation Steps
Step-by-step actions to resolve or reduce impact.
## Rollback / Escalation ## Rollback / Escalation
## References
How to revert or who to contact if the issue persists.
## Notes
Any caveats, dependencies, or follow-up actions.
+14 -12
View File
@@ -1,14 +1,16 @@
# Frontend Notes (MVP Readiness) # Frontend Notes
## High-Level Takeaways ## Current state
- `App.jsx` is monolithic and mixes search, payments, and locale controls; no routing exists yet. - React/Vite app with i18n (`en`, `ar-sa`) and RTL switching.
- Domain logic (API payloads, validation, error handling) lives in UI components instead of hooks/services. - Still light on routing/domain separation; critical flow test coverage needs expansion.
- Tests only cover hero copy and RTL behavior; search and payment flows are untested.
- Global styles are fragile (likely `::root` typo instead of `:root`).
- Auth token handling is ad hoc and should be replaced with a proper auth flow/context.
## Near-Term Focus ## Run
- Introduce routing and split into pages (home/search, auth, booking, payment, profile). - `cd frontend && npm install`
- Extract API logic into hooks/services to make testing and reuse easier. - `npm run dev`
- Add Vitest coverage for search, booking, and payment flows.
- Fix global CSS root selector and stabilize base layout styles. ## Test
- `npm run test`
## Pointers
- Architecture: `docs/architecture.md`
- Risks: `docs/risks.md`
@@ -0,0 +1,20 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import LocaleSwitch from "./LocaleSwitch";
import i18n from "../i18n";
describe("LocaleSwitch", () => {
beforeEach(async () => {
localStorage.clear();
await i18n.changeLanguage("en");
});
it("persists locale and sets html lang/dir", async () => {
render(<LocaleSwitch />);
fireEvent.click(screen.getByRole("button", { name: "العربية" }));
await waitFor(() => {
expect(document.documentElement.lang).toBe("ar-sa");
});
expect(document.documentElement.dir).toBe("rtl");
expect(localStorage.getItem("locale")).toBe("ar-sa");
});
});
+1 -9
View File
@@ -33,15 +33,6 @@ export default function PaymentForm({ bookingId = "", token = "" }) {
required required
/> />
</label> </label>
<label className="field">
<span>{t("payment.accessToken")}</span>
<input
type="password"
value={form.tokenInput}
onChange={(e) => form.setTokenInput(e.target.value)}
placeholder={t("payment.accessTokenPlaceholder")}
/>
</label>
<label className="field"> <label className="field">
<span>{t("payment.sourceType")}</span> <span>{t("payment.sourceType")}</span>
<select <select
@@ -51,6 +42,7 @@ export default function PaymentForm({ bookingId = "", token = "" }) {
<option value="stcpay">{t("payment.sources.stcpay")}</option> <option value="stcpay">{t("payment.sources.stcpay")}</option>
<option value="token">{t("payment.sources.token")}</option> <option value="token">{t("payment.sources.token")}</option>
<option value="applepay">{t("payment.sources.applepay")}</option> <option value="applepay">{t("payment.sources.applepay")}</option>
<option value="samsungpay">{t("payment.sources.samsungpay")}</option>
</select> </select>
</label> </label>
<label className="field"> <label className="field">
@@ -0,0 +1,92 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { vi } from "vitest";
import PaymentForm from "./PaymentForm";
import i18n from "../i18n";
vi.mock("../api/client", () => ({
apiPost: vi.fn(),
}));
const { apiPost } = await import("../api/client");
describe("PaymentForm", () => {
beforeEach(async () => {
vi.clearAllMocks();
await i18n.changeLanguage("en");
Object.defineProperty(globalThis, "crypto", {
value: { randomUUID: () => "uuid-1" },
configurable: true,
});
});
it("validates source details for stc pay", async () => {
render(<PaymentForm bookingId="12" />);
fireEvent.click(screen.getByRole("button", { name: "Pay now" }));
expect(
await screen.findByText("Mobile number is required for stc pay.")
).toBeInTheDocument();
});
it("reuses idempotency key on retry", async () => {
apiPost.mockRejectedValueOnce(new Error("fail"));
apiPost.mockResolvedValueOnce({ id: "ok" });
render(<PaymentForm bookingId="12" />);
fireEvent.change(screen.getByLabelText("Source value"), {
target: { value: "+966500000000" },
});
fireEvent.click(screen.getByRole("button", { name: "Pay now" }));
await screen.findByText("fail");
fireEvent.click(screen.getByRole("button", { name: "Pay now" }));
await waitFor(() => {
expect(apiPost).toHaveBeenCalledTimes(2);
});
const firstPayload = apiPost.mock.calls[0][1];
const secondPayload = apiPost.mock.calls[1][1];
expect(firstPayload.idempotency_key).toBe("uuid-1");
expect(secondPayload.idempotency_key).toBe("uuid-1");
});
it("redirects when response includes redirect_url", async () => {
const assign = vi.fn();
Object.defineProperty(window.location, "assign", {
value: assign,
configurable: true,
});
apiPost.mockResolvedValueOnce({ redirect_url: "https://pay.test/redirect" });
render(<PaymentForm bookingId="12" />);
fireEvent.change(screen.getByLabelText("Source value"), {
target: { value: "+966500000000" },
});
fireEvent.click(screen.getByRole("button", { name: "Pay now" }));
await waitFor(() => {
expect(assign).toHaveBeenCalledWith("https://pay.test/redirect");
});
});
it("disables submit while loading", async () => {
let resolveRequest;
apiPost.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveRequest = resolve;
})
);
render(<PaymentForm bookingId="12" />);
fireEvent.change(screen.getByLabelText("Source value"), {
target: { value: "+966500000000" },
});
const button = screen.getByRole("button", { name: "Pay now" });
fireEvent.click(button);
expect(button).toBeDisabled();
resolveRequest({ id: "done" });
await waitFor(() => {
expect(button).not.toBeDisabled();
});
});
});
@@ -0,0 +1,33 @@
import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import ProtectedRoute from "./ProtectedRoute";
import { AuthProvider } from "../contexts/AuthContext";
function renderProtected(initialEntries = ["/protected"]) {
return render(
<AuthProvider>
<MemoryRouter initialEntries={initialEntries}>
<Routes>
<Route
path="/protected"
element={
<ProtectedRoute>
<div>Secret</div>
</ProtectedRoute>
}
/>
<Route path="/login" element={<div>Login</div>} />
</Routes>
</MemoryRouter>
</AuthProvider>
);
}
describe("ProtectedRoute", () => {
it("redirects unauthenticated users to login", async () => {
renderProtected();
await waitFor(() => {
expect(screen.getByText("Login")).toBeInTheDocument();
});
});
});
@@ -39,4 +39,12 @@ describe("SalonSearch", () => {
}); });
expect(screen.getByText("Riyadh")).toBeInTheDocument(); expect(screen.getByText("Riyadh")).toBeInTheDocument();
}); });
it("shows error state when api fails", async () => {
apiGet.mockRejectedValueOnce(new Error("boom"));
renderWithRouter(<SalonSearch query="fail" />);
await waitFor(() => {
expect(screen.getByText(/unable to load salons/i)).toBeInTheDocument();
});
});
}); });
+9 -2
View File
@@ -1,5 +1,5 @@
import { createContext, useCallback, useContext, useEffect, useState } from "react"; import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { apiGet, apiPost } from "../api/client"; import { apiGet, apiPost, ApiError } from "../api/client";
const STORAGE_ACCESS = "auth_access"; const STORAGE_ACCESS = "auth_access";
const STORAGE_REFRESH = "auth_refresh"; const STORAGE_REFRESH = "auth_refresh";
@@ -50,7 +50,14 @@ export function AuthProvider({ children }) {
setUser(data); setUser(data);
setLoading(false); setLoading(false);
}) })
.catch(() => { .catch((err) => {
const status = ApiError && err instanceof ApiError ? err.status : err?.status;
const message = typeof err?.message === "string" ? err.message : "";
const isUnauthorized = status === 401 || message.includes("401");
if (!isUnauthorized) {
setLoading(false);
return;
}
// Token invalid, try refresh // Token invalid, try refresh
if (!refreshToken) { if (!refreshToken) {
logout(); logout();
@@ -0,0 +1,70 @@
import { render, screen, waitFor } from "@testing-library/react";
import { vi } from "vitest";
import { AuthProvider, useAuth } from "./AuthContext";
vi.mock("../api/client", () => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
}));
const { apiGet, apiPost } = await import("../api/client");
function AuthProbe() {
const { user, isAuthenticated, loading } = useAuth();
if (loading) return <div>Loading</div>;
return (
<div>
<div>auth:{isAuthenticated ? "yes" : "no"}</div>
<div>user:{user ? user.name : "none"}</div>
</div>
);
}
describe("AuthContext", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
it("restores session with refresh fallback", async () => {
localStorage.setItem("auth_access", "expired");
localStorage.setItem("auth_refresh", "refresh-token");
apiGet.mockRejectedValueOnce(new Error("401"));
apiPost.mockResolvedValueOnce({ access: "new-access" });
apiGet.mockResolvedValueOnce({ name: "Sara" });
render(
<AuthProvider>
<AuthProbe />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByText("auth:yes")).toBeInTheDocument();
});
expect(screen.getByText("user:Sara")).toBeInTheDocument();
expect(apiPost).toHaveBeenCalledWith("/auth/token/refresh/", { refresh: "refresh-token" });
expect(apiGet).toHaveBeenCalledWith("/auth/me/", "new-access");
});
it("clears tokens on refresh failure", async () => {
localStorage.setItem("auth_access", "expired");
localStorage.setItem("auth_refresh", "refresh-token");
apiGet.mockRejectedValueOnce(new Error("401"));
apiPost.mockRejectedValueOnce(new Error("refresh failed"));
render(
<AuthProvider>
<AuthProbe />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByText("auth:no")).toBeInTheDocument();
});
expect(localStorage.getItem("auth_access")).toBeNull();
expect(localStorage.getItem("auth_refresh")).toBeNull();
});
});
+49 -29
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { apiPost } from "../api/client"; import { apiPost, ApiError } from "../api/client";
function generateIdempotencyKey() { function generateIdempotencyKey() {
if (typeof crypto !== "undefined" && crypto.randomUUID) { if (typeof crypto !== "undefined" && crypto.randomUUID) {
@@ -13,16 +13,8 @@ function generateIdempotencyKey() {
const AUTH_TOKEN_KEY = "auth_access"; const AUTH_TOKEN_KEY = "auth_access";
export function usePaymentForm(bookingId = "", token = "") { export function usePaymentForm(bookingId = "", token = "") {
// token: optional auth token from AuthContext; tokenInput: manual override from form
const { t } = useTranslation(); const { t } = useTranslation();
const [bookingIdInput, setBookingIdInput] = useState(bookingId); const [bookingIdInput, setBookingIdInput] = useState(bookingId);
const [tokenInput, setTokenInput] = useState(() => {
if (token) return token;
if (typeof window !== "undefined") {
return localStorage.getItem(AUTH_TOKEN_KEY) || "";
}
return "";
});
const [sourceType, setSourceType] = useState("stcpay"); const [sourceType, setSourceType] = useState("stcpay");
const [sourceValue, setSourceValue] = useState(""); const [sourceValue, setSourceValue] = useState("");
const [callbackUrl, setCallbackUrl] = useState(() => { const [callbackUrl, setCallbackUrl] = useState(() => {
@@ -34,22 +26,33 @@ export function usePaymentForm(bookingId = "", token = "") {
const [status, setStatus] = useState("idle"); const [status, setStatus] = useState("idle");
const [result, setResult] = useState(null); const [result, setResult] = useState(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const idempotencyRef = useRef(null);
const idempotencyKey = useMemo(generateIdempotencyKey, []); if (!idempotencyRef.current) {
idempotencyRef.current = generateIdempotencyKey();
// Persist token to localStorage when it changes
const setTokenInputAndPersist = (value) => {
setTokenInput(value);
if (typeof window !== "undefined") {
if (value) {
localStorage.setItem(AUTH_TOKEN_KEY, value);
} else {
localStorage.removeItem(AUTH_TOKEN_KEY);
} }
const [idempotencyKey, setIdempotencyKey] = useState(idempotencyRef.current);
const lastBookingId = useRef(bookingId);
useEffect(() => {
if (lastBookingId.current !== bookingIdInput) {
lastBookingId.current = bookingIdInput;
const nextKey = generateIdempotencyKey();
idempotencyRef.current = nextKey;
setIdempotencyKey(nextKey);
}
}, [bookingIdInput]);
function getFirstFieldError(body) {
if (!body || typeof body !== "object") return "";
for (const value of Object.values(body)) {
if (Array.isArray(value) && value.length) return value[0];
if (typeof value === "string") return value;
}
return "";
} }
};
async function submit() { async function submit() {
if (status === "loading") return;
setStatus("loading"); setStatus("loading");
setError(""); setError("");
setResult(null); setResult(null);
@@ -75,39 +78,56 @@ export function usePaymentForm(bookingId = "", token = "") {
setError(t("payment.errors.tokenRequired")); setError(t("payment.errors.tokenRequired"));
return; return;
} }
if (!callbackUrl) {
setStatus("error");
setError(t("payment.errors.callbackRequired"));
return;
}
source.token = sourceValue; source.token = sourceValue;
} }
const payload = { const payload = {
booking_id: Number(bookingIdInput), booking_id: Number(bookingIdInput),
provider: "moyasar", provider: "moyasar",
idempotency_key: idempotencyKey, idempotency_key: idempotencyRef.current,
source, source,
}; };
if (callbackUrl) { if (callbackUrl) {
payload.callback_url = callbackUrl; payload.callback_url = callbackUrl;
} }
// Use tokenInput (form state) so user edits are respected; token prop only initializes it
const authToken = tokenInput;
try { try {
const data = await apiPost("/payments/", payload, authToken || undefined); const fallbackToken =
token ||
(typeof window !== "undefined"
? localStorage.getItem(AUTH_TOKEN_KEY) || ""
: "");
const data = await apiPost("/payments/", payload, fallbackToken || undefined);
setResult(data); setResult(data);
setStatus("ready"); setStatus("ready");
if (data?.redirect_url) { if (data?.redirect_url) {
window.location.assign(data.redirect_url); window.location.assign(data.redirect_url);
} }
} catch (err) { } catch (err) {
if (ApiError && err instanceof ApiError && err.body) {
const fieldMessage = getFirstFieldError(err.body);
if (fieldMessage) {
console.warn("Payment validation error", err.body);
setStatus("error"); setStatus("error");
setError(err.message || t("payment.errors.generic")); setError(fieldMessage);
return;
}
}
const message =
(typeof err?.message === "string" && err.message) || String(err) || "";
setStatus("error");
setError(message || t("payment.errors.generic"));
} }
} }
return { return {
bookingIdInput, bookingIdInput,
setBookingIdInput, setBookingIdInput,
tokenInput,
setTokenInput: setTokenInputAndPersist,
sourceType, sourceType,
setSourceType, setSourceType,
sourceValue, sourceValue,
+6 -1
View File
@@ -11,7 +11,12 @@ export function useSalonSearch(query) {
async function load() { async function load() {
setStatus("loading"); setStatus("loading");
try { try {
const data = await apiGet(`/salons/?q=${encodeURIComponent(query)}`); const params = new URLSearchParams();
if (query) {
params.set("q", query);
}
const path = params.toString() ? `/salons/?${params.toString()}` : "/salons/";
const data = await apiGet(path);
if (!ignore) { if (!ignore) {
setSalons(data); setSalons(data);
setStatus("ready"); setStatus("ready");
+82 -5
View File
@@ -16,7 +16,84 @@
"phoneUnavailable": "الهاتف غير متوفر", "phoneUnavailable": "الهاتف غير متوفر",
"viewDetails": "عرض التفاصيل والحجز" "viewDetails": "عرض التفاصيل والحجز"
}, },
"nav":{"home":"الرئيسية","book":"احجز","pay":"ادفع","profile":"الحساب","bookings":"حجوزاتي","login":"تسجيل الدخول","logout":"تسجيل الخروج"},"book":{"title":"احجزي خدمة","placeholder":"تدفق الحجز قريباً.","cta":"احجزي الآن","selectSalon":"اختاري صالوناً من الصفحة الرئيسية للحجز.","service":"الخدمة","staff":"الفريق","date":"التاريخ","time":"الوقت","notes":"ملاحظات","notesPlaceholder":"ملاحظات اختيارية","selectService":"اختاري الخدمة","selectStaff":"اختاري الفني","submit":"تأكيد الحجز","submitting":"جاري الحجز...","errors":{"fillAll":"يرجى تعبئة جميع الحقول.","invalidTime":"تاريخ أو وقت غير صالح.","generic":"فشل الحجز."}},"salon":{"services":"الخدمات","staff":"الفريق","unknownStaff":"موظف {{id}}"},"profile":{"title":"الحساب","placeholder":"الملف والحجوزات قريباً.","myBookings":"حجوزاتي","noContact":"لا توجد معلومات اتصال"},"bookings":{"title":"حجوزاتي","subtitle":"مواعيدك القادمة والسابقة.","empty":"لا توجد حجوزات بعد.","pay":"ادفع الآن"},"paymentReturn":{"title":"حالة الدفع","success":"تم الدفع بنجاح","successMessage":"تم إتمام الدفع. ستستلمين تأكيداً عبر SMS أو واتساب.","checkStatus":"تحققي من بريدك أو الصالون لحالة الدفع.","reference":"المرجع: {{id}}","viewBookings":"عرض حجوزاتي"},"auth":{"title":"تسجيل الدخول","subtitle":"أدخلي رقم جوالك لاستلام رمز التحقق.","phone":"رقم الجوال","channel":"الإرسال عبر","sms":"رسالة نصية","whatsapp":"واتساب","sendCode":"إرسال الرمز","sending":"جاري الإرسال...","verifyTitle":"أدخلي الرمز","verifySubtitle":"أرسلنا رمزاً إلى {{phone}}","code":"رمز التحقق","verify":"تحقق","verifying":"جاري التحقق...","back":"تغيير الرقم","errors":{"generic":"حدث خطأ.","retryAfter":"انتظري {{seconds}} ثانية قبل المحاولة مرة أخرى."}},"locale": { "nav": {
"home": "الرئيسية",
"book": "احجز",
"pay": "ادفع",
"profile": "الحساب",
"bookings": "حجوزاتي",
"login": "تسجيل الدخول",
"logout": "تسجيل الخروج"
},
"book": {
"title": "احجزي خدمة",
"placeholder": "تدفق الحجز قريباً.",
"cta": "احجزي الآن",
"selectSalon": "اختاري صالوناً من الصفحة الرئيسية للحجز.",
"service": "الخدمة",
"staff": "الفريق",
"date": "التاريخ",
"time": "الوقت",
"notes": "ملاحظات",
"notesPlaceholder": "ملاحظات اختيارية",
"selectService": "اختاري الخدمة",
"selectStaff": "اختاري الفني",
"submit": "تأكيد الحجز",
"submitting": "جاري الحجز...",
"errors": {
"fillAll": "يرجى تعبئة جميع الحقول.",
"invalidTime": "تاريخ أو وقت غير صالح.",
"generic": "فشل الحجز."
}
},
"salon": {
"services": "الخدمات",
"staff": "الفريق",
"unknownStaff": "موظف {{id}}"
},
"profile": {
"title": "الحساب",
"placeholder": "الملف والحجوزات قريباً.",
"myBookings": "حجوزاتي",
"noContact": "لا توجد معلومات اتصال"
},
"bookings": {
"title": "حجوزاتي",
"subtitle": "مواعيدك القادمة والسابقة.",
"empty": "لا توجد حجوزات بعد.",
"pay": "ادفع الآن"
},
"paymentReturn": {
"title": "حالة الدفع",
"success": "تم الدفع بنجاح",
"successMessage": "تم إتمام الدفع. ستستلمين تأكيداً عبر SMS أو واتساب.",
"checkStatus": "تحققي من بريدك أو الصالون لحالة الدفع.",
"reference": "المرجع: {{id}}",
"viewBookings": "عرض حجوزاتي"
},
"auth": {
"title": "تسجيل الدخول",
"subtitle": "أدخلي رقم جوالك لاستلام رمز التحقق.",
"phone": "رقم الجوال",
"channel": "الإرسال عبر",
"sms": "رسالة نصية",
"whatsapp": "واتساب",
"sendCode": "إرسال الرمز",
"sending": "جاري الإرسال...",
"verifyTitle": "أدخلي الرمز",
"verifySubtitle": "أرسلنا رمزاً إلى {{phone}}",
"code": "رمز التحقق",
"verify": "تحقق",
"verifying": "جاري التحقق...",
"back": "تغيير الرقم",
"errors": {
"generic": "حدث خطأ.",
"retryAfter": "انتظري {{seconds}} ثانية قبل المحاولة مرة أخرى."
},
"deviceId": "معرّف الجهاز (اختياري)",
"deviceIdPlaceholder": "معرّف الجهاز"
},
"locale": {
"label": "اللغة", "label": "اللغة",
"arabic": "العربية", "arabic": "العربية",
"english": "الإنجليزية" "english": "الإنجليزية"
@@ -29,8 +106,6 @@
"subtitle": "إرسال عملية دفع عبر Moyasar لحجز موجود.", "subtitle": "إرسال عملية دفع عبر Moyasar لحجز موجود.",
"badge": "المدفوعات", "badge": "المدفوعات",
"bookingId": "رقم الحجز", "bookingId": "رقم الحجز",
"accessToken": "رمز الوصول",
"accessTokenPlaceholder": "الصقي رمز JWT",
"sourceType": "نوع المصدر", "sourceType": "نوع المصدر",
"sourceValue": "قيمة المصدر", "sourceValue": "قيمة المصدر",
"sourceValuePlaceholder": "رقم الجوال أو الرمز", "sourceValuePlaceholder": "رقم الجوال أو الرمز",
@@ -41,13 +116,15 @@
"sources": { "sources": {
"stcpay": "stc pay (جوال)", "stcpay": "stc pay (جوال)",
"token": "دفع عبر رمز", "token": "دفع عبر رمز",
"applepay": "Apple Pay" "applepay": "Apple Pay",
"samsungpay": "Samsung Pay"
}, },
"errors": { "errors": {
"bookingRequired": "رقم الحجز مطلوب.", "bookingRequired": "رقم الحجز مطلوب.",
"mobileRequired": "رقم الجوال مطلوب لـ stc pay.", "mobileRequired": "رقم الجوال مطلوب لـ stc pay.",
"tokenRequired": "الرمز مطلوب للدفع عبر الرمز.", "tokenRequired": "الرمز مطلوب للدفع عبر الرمز.",
"generic": "فشل طلب الدفع." "generic": "فشل طلب الدفع.",
"callbackRequired": "رابط العودة مطلوب لعمليات الدفع عبر الرمز."
} }
} }
} }
+82 -5
View File
@@ -16,7 +16,84 @@
"phoneUnavailable": "Phone unavailable", "phoneUnavailable": "Phone unavailable",
"viewDetails": "View details & book" "viewDetails": "View details & book"
}, },
"nav":{"home":"Home","book":"Book","pay":"Pay","profile":"Profile","bookings":"My bookings","login":"Sign in","logout":"Sign out"},"book":{"title":"Book a Service","placeholder":"Booking flow coming soon.","cta":"Book now","selectSalon":"Select a salon from the home page to book.","service":"Service","staff":"Staff","date":"Date","time":"Time","notes":"Notes","notesPlaceholder":"Optional notes","selectService":"Select service","selectStaff":"Select staff","submit":"Confirm booking","submitting":"Booking...","errors":{"fillAll":"Please fill all required fields.","invalidTime":"Invalid date or time.","generic":"Booking failed."}},"salon":{"services":"Services","staff":"Staff","unknownStaff":"Staff {{id}}"},"profile":{"title":"Profile","placeholder":"Profile and bookings coming soon.","myBookings":"My bookings","noContact":"No contact info"},"bookings":{"title":"My bookings","subtitle":"Your upcoming and past appointments.","empty":"No bookings yet.","pay":"Pay now"},"paymentReturn":{"title":"Payment status","success":"Payment successful","successMessage":"Your payment was completed. You will receive a confirmation by SMS or WhatsApp.","checkStatus":"Check your email or the salon for payment status.","reference":"Reference: {{id}}","viewBookings":"View my bookings"},"auth":{"title":"Sign in","subtitle":"Enter your phone number to receive a verification code.","phone":"Phone number","channel":"Send via","sms":"SMS","whatsapp":"WhatsApp","sendCode":"Send code","sending":"Sending...","verifyTitle":"Enter code","verifySubtitle":"We sent a code to {{phone}}","code":"Verification code","verify":"Verify","verifying":"Verifying...","back":"Change number","errors":{"generic":"Something went wrong.","retryAfter":"Please wait {{seconds}} seconds before trying again."}},"locale": { "nav": {
"home": "Home",
"book": "Book",
"pay": "Pay",
"profile": "Profile",
"bookings": "My bookings",
"login": "Sign in",
"logout": "Sign out"
},
"book": {
"title": "Book a Service",
"placeholder": "Booking flow coming soon.",
"cta": "Book now",
"selectSalon": "Select a salon from the home page to book.",
"service": "Service",
"staff": "Staff",
"date": "Date",
"time": "Time",
"notes": "Notes",
"notesPlaceholder": "Optional notes",
"selectService": "Select service",
"selectStaff": "Select staff",
"submit": "Confirm booking",
"submitting": "Booking...",
"errors": {
"fillAll": "Please fill all required fields.",
"invalidTime": "Invalid date or time.",
"generic": "Booking failed."
}
},
"salon": {
"services": "Services",
"staff": "Staff",
"unknownStaff": "Staff {{id}}"
},
"profile": {
"title": "Profile",
"placeholder": "Profile and bookings coming soon.",
"myBookings": "My bookings",
"noContact": "No contact info"
},
"bookings": {
"title": "My bookings",
"subtitle": "Your upcoming and past appointments.",
"empty": "No bookings yet.",
"pay": "Pay now"
},
"paymentReturn": {
"title": "Payment status",
"success": "Payment successful",
"successMessage": "Your payment was completed. You will receive a confirmation by SMS or WhatsApp.",
"checkStatus": "Check your email or the salon for payment status.",
"reference": "Reference: {{id}}",
"viewBookings": "View my bookings"
},
"auth": {
"title": "Sign in",
"subtitle": "Enter your phone number to receive a verification code.",
"phone": "Phone number",
"channel": "Send via",
"sms": "SMS",
"whatsapp": "WhatsApp",
"sendCode": "Send code",
"sending": "Sending...",
"verifyTitle": "Enter code",
"verifySubtitle": "We sent a code to {{phone}}",
"code": "Verification code",
"verify": "Verify",
"verifying": "Verifying...",
"back": "Change number",
"errors": {
"generic": "Something went wrong.",
"retryAfter": "Please wait {{seconds}} seconds before trying again."
},
"deviceId": "Device ID (optional)",
"deviceIdPlaceholder": "Device identifier"
},
"locale": {
"label": "Language", "label": "Language",
"arabic": "العربية", "arabic": "العربية",
"english": "English" "english": "English"
@@ -29,8 +106,6 @@
"subtitle": "Send a Moyasar payment for an existing booking.", "subtitle": "Send a Moyasar payment for an existing booking.",
"badge": "Payments", "badge": "Payments",
"bookingId": "Booking ID", "bookingId": "Booking ID",
"accessToken": "Access token",
"accessTokenPlaceholder": "Paste JWT access token",
"sourceType": "Source type", "sourceType": "Source type",
"sourceValue": "Source value", "sourceValue": "Source value",
"sourceValuePlaceholder": "Mobile number or token", "sourceValuePlaceholder": "Mobile number or token",
@@ -41,13 +116,15 @@
"sources": { "sources": {
"stcpay": "stc pay (mobile)", "stcpay": "stc pay (mobile)",
"token": "tokenized payment", "token": "tokenized payment",
"applepay": "Apple Pay" "applepay": "Apple Pay",
"samsungpay": "Samsung Pay"
}, },
"errors": { "errors": {
"bookingRequired": "Booking ID is required.", "bookingRequired": "Booking ID is required.",
"mobileRequired": "Mobile number is required for stc pay.", "mobileRequired": "Mobile number is required for stc pay.",
"tokenRequired": "Token is required for token payments.", "tokenRequired": "Token is required for token payments.",
"generic": "Payment request failed." "generic": "Payment request failed.",
"callbackRequired": "Callback URL is required for token payments."
} }
} }
} }
+16 -15
View File
@@ -1,4 +1,4 @@
import { Outlet, Link } from "react-router-dom"; import { Outlet, Link, NavLink } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import LocaleSwitch from "../components/LocaleSwitch"; import LocaleSwitch from "../components/LocaleSwitch";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
@@ -9,22 +9,11 @@ export default function MainLayout() {
return ( return (
<div className="page"> <div className="page">
<header className="main-header"> <header className="main-header">
<nav className="main-nav"> <div className="header-row">
<Link to="/" className="nav-brand"> <Link to="/" className="nav-brand">
{t("nav.home")} {t("nav.home")}
</Link> </Link>
<Link to="/book" className="nav-link"> <div className="header-actions">
{t("nav.book")}
</Link>
<Link to="/pay" className="nav-link">
{t("nav.pay")}
</Link>
<Link to="/profile" className="nav-link">
{t("nav.profile")}
</Link>
<Link to="/bookings" className="nav-link">
{t("nav.bookings")}
</Link>
{isAuthenticated ? ( {isAuthenticated ? (
<button type="button" className="nav-link nav-logout" onClick={logout}> <button type="button" className="nav-link nav-logout" onClick={logout}>
{t("nav.logout")} {t("nav.logout")}
@@ -34,12 +23,24 @@ export default function MainLayout() {
{t("nav.login")} {t("nav.login")}
</Link> </Link>
)} )}
</nav>
<LocaleSwitch /> <LocaleSwitch />
</div>
</div>
</header> </header>
<main> <main>
<Outlet /> <Outlet />
</main> </main>
<nav className="bottom-nav" aria-label={t("nav.home")}>
<NavLink to="/" end className="tab-link">
{t("nav.home")}
</NavLink>
<NavLink to="/bookings" className="tab-link">
{t("nav.bookings")}
</NavLink>
<NavLink to="/profile" className="tab-link">
{t("nav.profile")}
</NavLink>
</nav>
</div> </div>
); );
} }
+34 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSearchParams, useNavigate } from "react-router-dom"; import { useSearchParams, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { apiGet, apiPost } from "../api/client"; import { apiGet, apiPost, ApiError } from "../api/client";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import ProtectedRoute from "../components/ProtectedRoute"; import ProtectedRoute from "../components/ProtectedRoute";
@@ -40,11 +40,34 @@ export default function BookPage() {
const selectedService = salon?.services?.find((s) => String(s.id) === serviceId); const selectedService = salon?.services?.find((s) => String(s.id) === serviceId);
const duration = selectedService?.duration_minutes || 0; const duration = selectedService?.duration_minutes || 0;
function formatWithOffset(date, offsetMinutes) {
const utcMs = date.getTime() + date.getTimezoneOffset() * 60 * 1000;
const target = new Date(utcMs + offsetMinutes * 60 * 1000);
const pad = (value) => String(value).padStart(2, "0");
const year = target.getUTCFullYear();
const month = pad(target.getUTCMonth() + 1);
const day = pad(target.getUTCDate());
const hours = pad(target.getUTCHours());
const minutes = pad(target.getUTCMinutes());
const seconds = pad(target.getUTCSeconds());
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}+03:00`;
}
function computeEndTime(startISO) { function computeEndTime(startISO) {
if (!startISO || !duration) return null; if (!startISO || !duration) return null;
const start = new Date(startISO); const start = new Date(startISO);
if (Number.isNaN(start.getTime())) return null;
const end = new Date(start.getTime() + duration * 60 * 1000); const end = new Date(start.getTime() + duration * 60 * 1000);
return end.toISOString(); return formatWithOffset(end, 180);
}
function getFirstFieldError(body) {
if (!body || typeof body !== "object") return "";
for (const value of Object.values(body)) {
if (Array.isArray(value) && value.length) return value[0];
if (typeof value === "string") return value;
}
return "";
} }
async function handleSubmit(e) { async function handleSubmit(e) {
@@ -62,6 +85,7 @@ export default function BookPage() {
return; return;
} }
if (loading) return;
setLoading(true); setLoading(true);
try { try {
const booking = await apiPost( const booking = await apiPost(
@@ -77,6 +101,14 @@ export default function BookPage() {
); );
navigate(`/pay?booking=${booking.id}`); navigate(`/pay?booking=${booking.id}`);
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.body) {
const fieldMessage = getFirstFieldError(err.body);
if (fieldMessage) {
console.warn("Booking validation error", err.body);
setError(fieldMessage);
return;
}
}
setError(err.message || t("book.errors.generic")); setError(err.message || t("book.errors.generic"));
} finally { } finally {
setLoading(false); setLoading(false);
+100
View File
@@ -0,0 +1,100 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { vi } from "vitest";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import BookPage from "./BookPage";
import { AuthProvider } from "../contexts/AuthContext";
import i18n from "../i18n";
vi.mock("../components/ProtectedRoute", () => ({
default: ({ children }) => <>{children}</>,
}));
vi.mock("../api/client", () => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
}));
const { apiGet, apiPost } = await import("../api/client");
function renderBook(initialEntries = ["/book?salon=1"]) {
return render(
<AuthProvider>
<MemoryRouter initialEntries={initialEntries}>
<Routes>
<Route path="/book" element={<BookPage />} />
<Route path="/pay" element={<div>Pay Page</div>} />
</Routes>
</MemoryRouter>
</AuthProvider>
);
}
const salonFixture = {
id: 1,
name: "Riyadh Salon",
services: [
{ id: 10, name: "Cut", duration_minutes: 60, price_amount: 120, currency: "SAR" },
],
staff: [{ id: 99, name: "Mona" }],
};
describe("BookPage", () => {
beforeEach(async () => {
vi.clearAllMocks();
apiGet.mockResolvedValue(salonFixture);
await i18n.changeLanguage("en");
});
it("validates required fields", async () => {
renderBook();
await screen.findByText("Riyadh Salon");
fireEvent.click(screen.getByRole("button", { name: "Confirm booking" }));
expect(screen.getByText("Please fill all required fields.")).toBeInTheDocument();
});
it("submits booking and redirects to payment", async () => {
apiPost.mockResolvedValueOnce({ id: 55 });
renderBook();
await screen.findByText("Riyadh Salon");
fireEvent.change(screen.getByLabelText("Service"), { target: { value: "10" } });
fireEvent.change(screen.getByLabelText("Staff"), { target: { value: "99" } });
fireEvent.change(screen.getByLabelText("Date"), { target: { value: "2026-03-14" } });
fireEvent.change(screen.getByLabelText("Time"), { target: { value: "10:30" } });
fireEvent.click(screen.getByRole("button", { name: "Confirm booking" }));
await waitFor(() => {
expect(screen.getByText("Pay Page")).toBeInTheDocument();
});
expect(apiPost).toHaveBeenCalledWith(
"/bookings/",
expect.objectContaining({
service: 10,
staff: 99,
start_time: "2026-03-14T10:30:00+03:00",
end_time: expect.any(String),
}),
null
);
const payload = apiPost.mock.calls[0][1];
const startMs = new Date(payload.start_time).getTime();
const endMs = new Date(payload.end_time).getTime();
expect(endMs - startMs).toBe(60 * 60 * 1000);
});
it("shows API error message", async () => {
apiPost.mockRejectedValueOnce(new Error("Booking failed"));
renderBook();
await screen.findByText("Riyadh Salon");
fireEvent.change(screen.getByLabelText("Service"), { target: { value: "10" } });
fireEvent.change(screen.getByLabelText("Staff"), { target: { value: "99" } });
fireEvent.change(screen.getByLabelText("Date"), { target: { value: "2026-03-14" } });
fireEvent.change(screen.getByLabelText("Time"), { target: { value: "10:30" } });
fireEvent.click(screen.getByRole("button", { name: "Confirm booking" }));
await waitFor(() => {
expect(screen.getByText("Booking failed")).toBeInTheDocument();
});
});
});
+65
View File
@@ -0,0 +1,65 @@
import { render, screen, waitFor } from "@testing-library/react";
import { vi } from "vitest";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { AuthProvider } from "../contexts/AuthContext";
vi.mock("../components/ProtectedRoute", () => ({
default: ({ children }) => <>{children}</>,
}));
vi.mock("../api/client", () => ({
apiGet: vi.fn(),
}));
vi.mock("../i18n/index", async (importOriginal) => {
const actual = await importOriginal();
return { ...actual, getActiveLocale: () => "en" };
});
const { apiGet } = await import("../api/client");
const { default: BookingsPage } = await import("./BookingsPage");
function renderBookings() {
return render(
<AuthProvider>
<MemoryRouter initialEntries={["/bookings"]}>
<Routes>
<Route path="/bookings" element={<BookingsPage />} />
</Routes>
</MemoryRouter>
</AuthProvider>
);
}
describe("BookingsPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders bookings list with localized datetime", async () => {
apiGet.mockResolvedValueOnce([
{
id: 7,
status: "confirmed",
salon_name: "Glow",
service_name: "Cut",
start_time: "2026-03-14T10:00:00+03:00",
end_time: "2026-03-14T11:00:00+03:00",
price_amount: 120,
currency: "SAR",
},
]);
const spy = vi.spyOn(Date.prototype, "toLocaleString");
renderBookings();
await waitFor(() => {
expect(screen.getByText("Glow")).toBeInTheDocument();
});
expect(screen.getByText("confirmed")).toBeInTheDocument();
expect(screen.getByText("Cut")).toBeInTheDocument();
expect(screen.getByText("120 SAR")).toBeInTheDocument();
expect(spy).toHaveBeenCalledWith("en", expect.any(Object));
spy.mockRestore();
});
});
+11
View File
@@ -12,6 +12,7 @@ export default function LoginPage() {
const [step, setStep] = useState("phone"); const [step, setStep] = useState("phone");
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
const [channel, setChannel] = useState("sms"); const [channel, setChannel] = useState("sms");
const [deviceId, setDeviceId] = useState("");
const [requestId, setRequestId] = useState(""); const [requestId, setRequestId] = useState("");
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -27,6 +28,7 @@ export default function LoginPage() {
const res = await apiPost("/auth/phone/request/", { const res = await apiPost("/auth/phone/request/", {
phone_number: phone, phone_number: phone,
channel, channel,
...(deviceId ? { device_id: deviceId } : {}),
}); });
setRequestId(res.request_id); setRequestId(res.request_id);
setStep("verify"); setStep("verify");
@@ -88,6 +90,15 @@ export default function LoginPage() {
<option value="whatsapp">{t("auth.whatsapp")}</option> <option value="whatsapp">{t("auth.whatsapp")}</option>
</select> </select>
</label> </label>
<label className="field">
<span>{t("auth.deviceId")}</span>
<input
type="text"
value={deviceId}
onChange={(e) => setDeviceId(e.target.value)}
placeholder={t("auth.deviceIdPlaceholder")}
/>
</label>
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
<button type="submit" disabled={loading}> <button type="submit" disabled={loading}>
{loading ? t("auth.sending") : t("auth.sendCode")} {loading ? t("auth.sending") : t("auth.sendCode")}
+69 -1
View File
@@ -1,9 +1,10 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { vi } from "vitest"; import { vi } from "vitest";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter, MemoryRouter, Route, Routes } from "react-router-dom";
import LoginPage from "./LoginPage"; import LoginPage from "./LoginPage";
import { AuthProvider } from "../contexts/AuthContext"; import { AuthProvider } from "../contexts/AuthContext";
import i18n from "../i18n"; import i18n from "../i18n";
import { ApiError } from "../api/client";
vi.mock("../api/client", async (importOriginal) => { vi.mock("../api/client", async (importOriginal) => {
const actual = await importOriginal(); const actual = await importOriginal();
@@ -22,6 +23,19 @@ function renderLogin() {
); );
} }
function renderLoginWithRoutes(initialEntries = ["/login"]) {
return render(
<AuthProvider>
<MemoryRouter initialEntries={initialEntries}>
<Routes>
<Route path="/" element={<div>Home</div>} />
<Route path="/login" element={<LoginPage />} />
</Routes>
</MemoryRouter>
</AuthProvider>
);
}
describe("LoginPage", () => { describe("LoginPage", () => {
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -57,4 +71,58 @@ describe("LoginPage", () => {
expect(screen.getByText("Rate limited")).toBeInTheDocument(); expect(screen.getByText("Rate limited")).toBeInTheDocument();
}); });
}); });
it("shows cooldown message on 429 retry-after", async () => {
apiPost.mockRejectedValueOnce(
new ApiError("Too many", { status: 429, body: { retry_after_seconds: 30 } })
);
renderLogin();
fireEvent.change(screen.getByLabelText(/phone number/i), { target: { value: "+966512345678" } });
fireEvent.click(screen.getByRole("button", { name: "Send code" }));
await waitFor(() => {
expect(
screen.getByText("Please wait 30 seconds before trying again.")
).toBeInTheDocument();
});
});
it("verifies OTP and navigates home", async () => {
apiPost
.mockResolvedValueOnce({ request_id: "req-1", expires_at: "2025-01-01T12:00:00Z" })
.mockResolvedValueOnce({ access: "token", refresh: "refresh", user: { name: "Maha" } });
renderLoginWithRoutes();
fireEvent.change(screen.getByLabelText(/phone number/i), { target: { value: "+966512345678" } });
fireEvent.click(screen.getByRole("button", { name: "Send code" }));
await screen.findByLabelText(/verification code/i);
fireEvent.change(screen.getByLabelText(/verification code/i), { target: { value: "123456" } });
fireEvent.click(screen.getByRole("button", { name: "Verify" }));
await waitFor(() => {
expect(screen.getByText("Home")).toBeInTheDocument();
});
});
it("shows error when OTP verification fails", async () => {
apiPost
.mockResolvedValueOnce({ request_id: "req-1", expires_at: "2025-01-01T12:00:00Z" })
.mockRejectedValueOnce(new Error("Invalid code"));
renderLogin();
fireEvent.change(screen.getByLabelText(/phone number/i), { target: { value: "+966512345678" } });
fireEvent.click(screen.getByRole("button", { name: "Send code" }));
await screen.findByLabelText(/verification code/i);
fireEvent.change(screen.getByLabelText(/verification code/i), { target: { value: "123456" } });
fireEvent.click(screen.getByRole("button", { name: "Verify" }));
await waitFor(() => {
expect(screen.getByText("Invalid code")).toBeInTheDocument();
});
});
}); });