Booking lifecycle notifications and status updates
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NotificationsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.notifications"
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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 []
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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/<id>` 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.
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user