Booking lifecycle notifications and status updates

This commit is contained in:
2026-02-28 15:06:35 +03:00
parent db36551211
commit ca2a6b58b6
15 changed files with 613 additions and 5 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ This document describes the requirements for an execution plan ("ExecPlan"), a d
## Active ExecPlans ## 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 ## How to use ExecPlans and PLANS.md
+24 -2
View File
@@ -1,5 +1,6 @@
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers 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.bookings.services import validate_booking_request
from apps.salons.models import Service, StaffProfile from apps.salons.models import Service, StaffProfile
@@ -27,7 +28,7 @@ class BookingSerializer(serializers.ModelSerializer):
"notes", "notes",
"created_at", "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): def get_staff_name(self, obj):
if not obj.staff: if not obj.staff:
@@ -36,6 +37,27 @@ class BookingSerializer(serializers.ModelSerializer):
last = obj.staff.user.last_name or "" last = obj.staff.user.last_name or ""
return (first + " " + last).strip() or obj.staff.user.email 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 BookingCreateSerializer(serializers.ModelSerializer):
class Meta: class Meta:
+12 -1
View File
@@ -1,7 +1,9 @@
from rest_framework import permissions, viewsets 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.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): class BookingViewSet(viewsets.ModelViewSet):
@@ -21,3 +23,12 @@ class BookingViewSet(viewsets.ModelViewSet):
if self.action == "create": if self.action == "create":
return BookingCreateSerializer return BookingCreateSerializer
return BookingSerializer 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)
+21
View File
@@ -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")
+6
View File
@@ -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",
),
),
]
+64
View File
@@ -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}"
+171
View File
@@ -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
+3
View File
@@ -26,6 +26,7 @@ INSTALLED_APPS = [
"apps.salons", "apps.salons",
"apps.bookings", "apps.bookings",
"apps.payments", "apps.payments",
"apps.notifications",
] ]
MIDDLEWARE = [ 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_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"))
DEFAULT_CURRENCY = os.getenv("DEFAULT_CURRENCY", "SAR") DEFAULT_CURRENCY = os.getenv("DEFAULT_CURRENCY", "SAR")
NOTIFICATION_PROVIDER = os.getenv("NOTIFICATION_PROVIDER", OTP_PROVIDER)
NOTIFICATION_DEFAULT_CHANNEL = os.getenv("NOTIFICATION_DEFAULT_CHANNEL", "sms")
+104
View File
@@ -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 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
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 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
- Creating a booking returns HTTP 201 and creates two notification records (customer + staff) with event `booking_created`.
- Updating a bookings 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
View File
@@ -21,7 +21,7 @@ This file tracks known gaps and risks to address in future iterations.
## Data And UX ## Data And UX
- Ratings are not recalculated from reviews. - Ratings are not recalculated from reviews.
- No image upload or storage strategy for photos. - No image upload or storage strategy for photos.
- 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. - Localization foundations are in progress; full Arabic translation coverage and RTL QA are still pending.
## Ops And Compliance ## Ops And Compliance