diff --git a/PLANS.md b/PLANS.md index 4e4b8e6..9ece5ea 100644 --- a/PLANS.md +++ b/PLANS.md @@ -4,7 +4,7 @@ This document describes the requirements for an execution plan ("ExecPlan"), a d ## Active ExecPlans -The current execution plan is `docs/execplans/payments-moyasar.md`. It focuses on Moyasar payments integration with webhooks and idempotency as the next Phase 1 reliability milestone. Keep it updated in line with the requirements below. +The current execution plan is `docs/execplans/booking-notifications.md`. It focuses on booking lifecycle notifications (confirmation/cancellation) as the next Phase 1 reliability milestone. Keep it updated in line with the requirements below. ## How to use ExecPlans and PLANS.md diff --git a/backend/apps/bookings/serializers.py b/backend/apps/bookings/serializers.py index 4c9291d..4ad920f 100644 --- a/backend/apps/bookings/serializers.py +++ b/backend/apps/bookings/serializers.py @@ -1,5 +1,6 @@ +from django.utils.translation import gettext_lazy as _ from rest_framework import serializers -from apps.bookings.models import Booking +from apps.bookings.models import Booking, BookingStatus from apps.bookings.services import validate_booking_request from apps.salons.models import Service, StaffProfile @@ -27,7 +28,7 @@ class BookingSerializer(serializers.ModelSerializer): "notes", "created_at", ] - read_only_fields = ["id", "salon", "status", "price_amount", "currency", "created_at"] + read_only_fields = ["id", "salon", "price_amount", "currency", "created_at"] def get_staff_name(self, obj): if not obj.staff: @@ -36,6 +37,27 @@ class BookingSerializer(serializers.ModelSerializer): last = obj.staff.user.last_name or "" return (first + " " + last).strip() or obj.staff.user.email + def validate(self, attrs): + if not self.instance or "status" not in attrs: + return attrs + + new_status = attrs["status"] + old_status = self.instance.status + if new_status == old_status: + return attrs + + user = self.context["request"].user + role = getattr(user, "role", None) + + if new_status == BookingStatus.CONFIRMED and role not in {"manager", "staff", "admin"}: + raise serializers.ValidationError({"status": _("Only staff or managers can confirm bookings.")}) + if new_status == BookingStatus.COMPLETED and role not in {"manager", "staff", "admin"}: + raise serializers.ValidationError({"status": _("Only staff or managers can complete bookings.")}) + if new_status == BookingStatus.CANCELLED and role not in {"manager", "staff", "admin", "customer"}: + raise serializers.ValidationError({"status": _("You are not allowed to cancel this booking.")}) + + return attrs + class BookingCreateSerializer(serializers.ModelSerializer): class Meta: diff --git a/backend/apps/bookings/views.py b/backend/apps/bookings/views.py index 3b9c1ba..2b60ea8 100644 --- a/backend/apps/bookings/views.py +++ b/backend/apps/bookings/views.py @@ -1,7 +1,9 @@ from rest_framework import permissions, viewsets -from apps.bookings.models import Booking +from apps.bookings.models import Booking, BookingStatus from apps.bookings.serializers import BookingCreateSerializer, BookingSerializer +from apps.notifications.models import NotificationEvent +from apps.notifications.services import notify_booking_lifecycle, notify_on_status_change class BookingViewSet(viewsets.ModelViewSet): @@ -21,3 +23,12 @@ class BookingViewSet(viewsets.ModelViewSet): if self.action == "create": return BookingCreateSerializer return BookingSerializer + + def perform_create(self, serializer): + booking = serializer.save() + notify_booking_lifecycle(booking, NotificationEvent.BOOKING_CREATED) + + def perform_update(self, serializer): + previous_status = self.get_object().status + booking = serializer.save() + notify_on_status_change(booking, previous_status) diff --git a/backend/apps/notifications/__init__.py b/backend/apps/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/notifications/admin.py b/backend/apps/notifications/admin.py new file mode 100644 index 0000000..1844e1d --- /dev/null +++ b/backend/apps/notifications/admin.py @@ -0,0 +1,21 @@ +from django.contrib import admin + +from apps.notifications.models import Notification + + +@admin.register(Notification) +class NotificationAdmin(admin.ModelAdmin): + list_display = ( + "id", + "event", + "channel", + "status", + "booking", + "recipient", + "phone_number", + "provider", + "sent_at", + "created_at", + ) + list_filter = ("event", "channel", "status", "provider") + search_fields = ("phone_number", "message") diff --git a/backend/apps/notifications/apps.py b/backend/apps/notifications/apps.py new file mode 100644 index 0000000..e04ade9 --- /dev/null +++ b/backend/apps/notifications/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.notifications" diff --git a/backend/apps/notifications/migrations/0001_initial.py b/backend/apps/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..0c722a8 --- /dev/null +++ b/backend/apps/notifications/migrations/0001_initial.py @@ -0,0 +1,85 @@ +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("bookings", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Notification", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("phone_number", models.CharField(blank=True, max_length=20)), + ( + "channel", + models.CharField( + choices=[("sms", "SMS"), ("whatsapp", "WhatsApp")], + max_length=20, + ), + ), + ( + "event", + models.CharField( + choices=[ + ("booking_created", "Booking Created"), + ("booking_confirmed", "Booking Confirmed"), + ("booking_cancelled", "Booking Cancelled"), + ], + max_length=50, + ), + ), + ( + "status", + models.CharField( + choices=[ + ("pending", "Pending"), + ("sent", "Sent"), + ("failed", "Failed"), + ("skipped", "Skipped"), + ], + default="pending", + max_length=20, + ), + ), + ("provider", models.CharField(blank=True, max_length=50)), + ("message", models.TextField(blank=True)), + ("provider_payload", models.JSONField(blank=True, default=dict)), + ("error_message", models.TextField(blank=True)), + ("sent_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "booking", + models.ForeignKey( + blank=True, + null=True, + on_delete=models.deletion.CASCADE, + related_name="notifications", + to="bookings.booking", + ), + ), + ( + "recipient", + models.ForeignKey( + blank=True, + null=True, + on_delete=models.deletion.SET_NULL, + related_name="notifications", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.AddConstraint( + model_name="notification", + constraint=models.UniqueConstraint( + fields=("booking", "recipient", "event", "channel"), + name="uniq_notification_booking_event", + ), + ), + ] diff --git a/backend/apps/notifications/migrations/__init__.py b/backend/apps/notifications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/notifications/models.py b/backend/apps/notifications/models.py new file mode 100644 index 0000000..f68513c --- /dev/null +++ b/backend/apps/notifications/models.py @@ -0,0 +1,64 @@ +from django.conf import settings +from django.db import models + +from apps.bookings.models import Booking + + +class NotificationChannel(models.TextChoices): + SMS = "sms", "SMS" + WHATSAPP = "whatsapp", "WhatsApp" + + +class NotificationEvent(models.TextChoices): + BOOKING_CREATED = "booking_created", "Booking Created" + BOOKING_CONFIRMED = "booking_confirmed", "Booking Confirmed" + BOOKING_CANCELLED = "booking_cancelled", "Booking Cancelled" + + +class NotificationStatus(models.TextChoices): + PENDING = "pending", "Pending" + SENT = "sent", "Sent" + FAILED = "failed", "Failed" + SKIPPED = "skipped", "Skipped" + + +class Notification(models.Model): + booking = models.ForeignKey( + Booking, + on_delete=models.CASCADE, + related_name="notifications", + null=True, + blank=True, + ) + recipient = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + related_name="notifications", + null=True, + blank=True, + ) + phone_number = models.CharField(max_length=20, blank=True) + channel = models.CharField(max_length=20, choices=NotificationChannel.choices) + event = models.CharField(max_length=50, choices=NotificationEvent.choices) + status = models.CharField( + max_length=20, + choices=NotificationStatus.choices, + default=NotificationStatus.PENDING, + ) + provider = models.CharField(max_length=50, blank=True) + message = models.TextField(blank=True) + provider_payload = models.JSONField(default=dict, blank=True) + error_message = models.TextField(blank=True) + sent_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["booking", "recipient", "event", "channel"], + name="uniq_notification_booking_event", + ) + ] + + def __str__(self) -> str: + return f"{self.event} to {self.phone_number or self.recipient_id}" diff --git a/backend/apps/notifications/services.py b/backend/apps/notifications/services.py new file mode 100644 index 0000000..a0819a0 --- /dev/null +++ b/backend/apps/notifications/services.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from django.conf import settings +from django.db import transaction +from django.utils import timezone, translation +from django.utils.translation import gettext_lazy as _ + +from apps.accounts.services.otp import PROVIDERS as OTP_PROVIDERS +from apps.bookings.models import Booking, BookingStatus +from apps.notifications.models import ( + Notification, + NotificationChannel, + NotificationEvent, + NotificationStatus, +) + + +@dataclass +class NotificationSendResult: + status: str + payload: dict + error_message: str = "" + + +def _get_provider(): + provider_key = getattr(settings, "NOTIFICATION_PROVIDER", settings.OTP_PROVIDER) + provider_cls = OTP_PROVIDERS.get(provider_key) + if not provider_cls: + raise ValueError(_("Unknown notification provider: %(provider)s") % {"provider": provider_key}) + return provider_cls(), provider_key + + +def _format_start_time(booking: Booking) -> str: + start_local = timezone.localtime(booking.start_time) + return start_local.strftime("%Y-%m-%d %H:%M") + + +def _build_message(booking: Booking, event: str) -> str: + start_text = _format_start_time(booking) + service_name = booking.service.name + salon_name = booking.salon.name + + if event == NotificationEvent.BOOKING_CREATED: + return _( + "Your booking request is received for %(service)s at %(salon)s on %(start)s." + ) % { + "service": service_name, + "salon": salon_name, + "start": start_text, + } + if event == NotificationEvent.BOOKING_CONFIRMED: + return _( + "Your booking is confirmed for %(service)s at %(salon)s on %(start)s." + ) % { + "service": service_name, + "salon": salon_name, + "start": start_text, + } + if event == NotificationEvent.BOOKING_CANCELLED: + return _( + "Your booking was cancelled for %(service)s at %(salon)s on %(start)s." + ) % { + "service": service_name, + "salon": salon_name, + "start": start_text, + } + + return _("Booking update for %(service)s at %(salon)s on %(start)s.") % { + "service": service_name, + "salon": salon_name, + "start": start_text, + } + + +def _send_message(phone_number: str, channel: str, message: str) -> NotificationSendResult: + provider, _ = _get_provider() + try: + if channel == NotificationChannel.SMS: + provider.send_sms(phone_number, message) + elif channel == NotificationChannel.WHATSAPP: + provider.send_whatsapp(phone_number, message) + else: + raise ValueError(_("Unsupported notification channel")) + except Exception as exc: # pragma: no cover - provider failures are environment specific + return NotificationSendResult(status=NotificationStatus.FAILED, payload={}, error_message=str(exc)) + + return NotificationSendResult(status=NotificationStatus.SENT, payload={}, error_message="") + + +def _notification_channel() -> str: + return getattr(settings, "NOTIFICATION_DEFAULT_CHANNEL", NotificationChannel.SMS) + + +def send_booking_notification(booking: Booking, recipient, event: str) -> Notification: + channel = _notification_channel() + phone_number = getattr(recipient, "phone_number", None) or "" + + # Render the message in the recipient's preferred language. + with translation.override(getattr(recipient, "preferred_language", None)): + message = _build_message(booking, event) + + with transaction.atomic(): + notification, created = Notification.objects.get_or_create( + booking=booking, + recipient=recipient, + event=event, + channel=channel, + defaults={ + "phone_number": phone_number, + "message": message, + }, + ) + + if not created and notification.status == NotificationStatus.SENT: + return notification + + if not phone_number: + # Record the skip for auditability when we cannot deliver. + notification.status = NotificationStatus.SKIPPED + notification.error_message = "Recipient has no phone number" + notification.save(update_fields=["status", "error_message"]) + return notification + + notification.phone_number = phone_number + notification.message = message + send_result = _send_message(phone_number, channel, message) + notification.status = send_result.status + notification.provider = getattr(settings, "NOTIFICATION_PROVIDER", settings.OTP_PROVIDER) + notification.provider_payload = send_result.payload + notification.error_message = send_result.error_message + notification.sent_at = timezone.now() if send_result.status == NotificationStatus.SENT else None + notification.save( + update_fields=[ + "phone_number", + "message", + "status", + "provider", + "provider_payload", + "error_message", + "sent_at", + ] + ) + + return notification + + +def notify_booking_lifecycle(booking: Booking, event: str) -> list[Notification]: + recipients = [booking.customer] + if booking.staff and booking.staff.user: + recipients.append(booking.staff.user) + + notifications = [] + for recipient in recipients: + notifications.append(send_booking_notification(booking, recipient, event)) + + return notifications + + +def notify_on_status_change(booking: Booking, previous_status: str) -> list[Notification]: + if booking.status == previous_status: + return [] + + # Only notify for lifecycle transitions we explicitly support today. + if booking.status == BookingStatus.CONFIRMED: + return notify_booking_lifecycle(booking, NotificationEvent.BOOKING_CONFIRMED) + if booking.status == BookingStatus.CANCELLED: + return notify_booking_lifecycle(booking, NotificationEvent.BOOKING_CANCELLED) + + return [] diff --git a/backend/apps/notifications/tests/__init__.py b/backend/apps/notifications/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/notifications/tests/test_booking_notifications.py b/backend/apps/notifications/tests/test_booking_notifications.py new file mode 100644 index 0000000..ab9a206 --- /dev/null +++ b/backend/apps/notifications/tests/test_booking_notifications.py @@ -0,0 +1,121 @@ +from datetime import timedelta + +import pytest +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient + +from apps.accounts.models import User, UserRole +from apps.bookings.models import Booking, BookingStatus +from apps.notifications.models import Notification, NotificationEvent, NotificationStatus +from apps.salons.models import Salon, Service, StaffProfile + + +@pytest.fixture +def booking_payload(): + owner = User.objects.create_user( + email="owner@example.com", + password="pass", + role=UserRole.MANAGER, + phone_number="0500000001", + ) + customer = User.objects.create_user( + email="customer@example.com", + password="pass", + phone_number="0500000002", + ) + staff_user = User.objects.create_user( + email="staff@example.com", + password="pass", + role=UserRole.STAFF, + phone_number="0500000003", + ) + + salon = Salon.objects.create( + owner=owner, + name="Main Salon", + description="", + address="123 King Rd", + city="Riyadh", + phone_number="0512345678", + ) + service = Service.objects.create( + salon=salon, + name="Haircut", + description="", + duration_minutes=60, + price_amount=120, + currency="SAR", + ) + staff = StaffProfile.objects.create(user=staff_user, salon=salon) + + start_time = timezone.now() + timedelta(days=1) + end_time = start_time + timedelta(minutes=60) + + return { + "customer": customer, + "staff_user": staff_user, + "service": service, + "staff": staff, + "payload": { + "service": service.id, + "staff": staff.id, + "start_time": start_time.isoformat(), + "end_time": end_time.isoformat(), + "notes": "", + }, + } + + +@pytest.mark.django_db +def test_booking_create_sends_notifications(booking_payload): + client = APIClient() + client.force_authenticate(user=booking_payload["customer"]) + + response = client.post( + reverse("booking-list"), + booking_payload["payload"], + content_type="application/json", + ) + assert response.status_code == 201 + + notifications = Notification.objects.filter(event=NotificationEvent.BOOKING_CREATED) + assert notifications.count() == 2 + assert all(notification.status == NotificationStatus.SENT for notification in notifications) + + +@pytest.mark.django_db +def test_booking_status_change_sends_notifications_once(booking_payload): + client = APIClient() + client.force_authenticate(user=booking_payload["customer"]) + + response = client.post( + reverse("booking-list"), + booking_payload["payload"], + content_type="application/json", + ) + assert response.status_code == 201 + + booking_id = Booking.objects.get(customer=booking_payload["customer"]).id + update_payload = {"status": BookingStatus.CONFIRMED} + + client.force_authenticate(user=booking_payload["staff_user"]) + response_update = client.patch( + reverse("booking-detail", args=[booking_id]), + update_payload, + content_type="application/json", + ) + assert response_update.status_code == 200 + + notifications = Notification.objects.filter(event=NotificationEvent.BOOKING_CONFIRMED) + assert notifications.count() == 2 + + response_repeat = client.patch( + reverse("booking-detail", args=[booking_id]), + update_payload, + content_type="application/json", + ) + assert response_repeat.status_code == 200 + + notifications_repeat = Notification.objects.filter(event=NotificationEvent.BOOKING_CONFIRMED) + assert notifications_repeat.count() == 2 diff --git a/backend/salon_api/settings.py b/backend/salon_api/settings.py index eea1364..6ca6513 100644 --- a/backend/salon_api/settings.py +++ b/backend/salon_api/settings.py @@ -26,6 +26,7 @@ INSTALLED_APPS = [ "apps.salons", "apps.bookings", "apps.payments", + "apps.notifications", ] MIDDLEWARE = [ @@ -140,3 +141,5 @@ OTP_MAX_PER_WINDOW = int(os.getenv("OTP_MAX_PER_WINDOW", "5")) OTP_WINDOW_MINUTES = int(os.getenv("OTP_WINDOW_MINUTES", "15")) OTP_RESEND_COOLDOWN_SECONDS = int(os.getenv("OTP_RESEND_COOLDOWN_SECONDS", "60")) DEFAULT_CURRENCY = os.getenv("DEFAULT_CURRENCY", "SAR") +NOTIFICATION_PROVIDER = os.getenv("NOTIFICATION_PROVIDER", OTP_PROVIDER) +NOTIFICATION_DEFAULT_CHANNEL = os.getenv("NOTIFICATION_DEFAULT_CHANNEL", "sms") diff --git a/docs/execplans/booking-notifications.md b/docs/execplans/booking-notifications.md new file mode 100644 index 0000000..3a911fa --- /dev/null +++ b/docs/execplans/booking-notifications.md @@ -0,0 +1,104 @@ +# Booking Lifecycle Notifications (SMS/WhatsApp) + +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. + +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 + +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 + +- [x] (2026-02-28 17:05Z) Created ExecPlan for booking lifecycle notifications and reviewed bookings + notifications gaps. +- [x] (2026-02-28 17:30Z) Implemented notifications app with audit-friendly model, providers, and booking message templates. +- [x] (2026-02-28 17:40Z) Connected booking create/update flows to notification dispatch with idempotent event handling. +- [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 + +- Observation: Booking status updates were blocked because `status` was read-only on the default booking serializer. + Evidence: `PATCH /api/bookings/` returned HTTP 400 when attempting to confirm. + +## Decision Log + +- Decision: Store every booking notification in a dedicated `Notification` model for auditability, even when skipped. + Rationale: Lifecycle messages are user-facing and must be traceable for support and compliance. + Date/Author: 2026-02-28, Codex +- Decision: Reuse existing OTP provider adapters for SMS/WhatsApp delivery, with a new `NOTIFICATION_PROVIDER` setting. + Rationale: Avoid duplicate integration code while still allowing independent provider configuration. + Date/Author: 2026-02-28, Codex +- Decision: Default to SMS for booking notifications and use the recipient’s 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 + +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 + +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. + +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 + +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 recipient’s 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 + +- Creating a booking returns HTTP 201 and creates two notification records (customer + staff) with event `booking_created`. +- Updating a booking’s status to `confirmed` creates two notification records with event `booking_confirmed`. +- Repeating the same status update does not create duplicate notifications (records remain at two for that event). +- `python3 -m pytest` passes, and the new tests fail before the change and pass after. + +## Idempotence and Recovery + +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. diff --git a/docs/risks.md b/docs/risks.md index c31ea36..63ee63d 100644 --- a/docs/risks.md +++ b/docs/risks.md @@ -21,7 +21,7 @@ This file tracks known gaps and risks to address in future iterations. ## Data And UX - Ratings are not recalculated from reviews. - No image upload or storage strategy for photos. -- No notifications (email/SMS) beyond OTP scaffolding. +- Booking lifecycle notifications are implemented (SMS/WhatsApp via provider scaffolds); production delivery still needs real provider adapters. - Localization foundations are in progress; full Arabic translation coverage and RTL QA are still pending. ## Ops And Compliance