From dc68ecfe4c2b65a3ff19bf8eb46544e939b30de9 Mon Sep 17 00:00:00 2001 From: mohammad Date: Fri, 27 Feb 2026 15:10:30 +0300 Subject: [PATCH] Summary of what I changed and added: Added initial migrations for accounts, salons, bookings, payments. 0001_initial.py 0001_initial.py 0001_initial.py 0001_initial.py Added demo seeding command. seed_demo.py OTP provider abstraction now includes Twilio + Unifonic adapter scaffolds (not implemented yet, they raise). otp.py Moyasar gateway scaffold added (not implemented yet, raises). gateway.py Added .env loading in settings and expanded .env.example for OTP and Moyasar. settings.py .env.example Captured current gaps/risks in a tracked doc as requested. risks.md Updated README with seeding guidance and risk doc pointer. README.md --- README.md | 10 ++ backend/.env.example | 10 ++ .../apps/accounts/migrations/0001_initial.py | 93 ++++++++++++ backend/apps/accounts/services/otp.py | 46 ++++++ .../apps/bookings/migrations/0001_initial.py | 56 +++++++ .../apps/payments/migrations/0001_initial.py | 58 ++++++++ backend/apps/payments/services/__init__.py | 0 backend/apps/payments/services/gateway.py | 43 ++++++ backend/apps/salons/management/__init__.py | 0 .../salons/management/commands/__init__.py | 0 .../salons/management/commands/seed_demo.py | 137 ++++++++++++++++++ .../apps/salons/migrations/0001_initial.py | 99 +++++++++++++ backend/salon_api/settings.py | 4 + docs/risks.md | 29 ++++ 14 files changed, 585 insertions(+) create mode 100644 backend/apps/accounts/migrations/0001_initial.py create mode 100644 backend/apps/bookings/migrations/0001_initial.py create mode 100644 backend/apps/payments/migrations/0001_initial.py create mode 100644 backend/apps/payments/services/__init__.py create mode 100644 backend/apps/payments/services/gateway.py create mode 100644 backend/apps/salons/management/__init__.py create mode 100644 backend/apps/salons/management/commands/__init__.py create mode 100644 backend/apps/salons/management/commands/seed_demo.py create mode 100644 backend/apps/salons/migrations/0001_initial.py create mode 100644 docs/risks.md diff --git a/README.md b/README.md index 1500280..c3e47a4 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,12 @@ Location: `backend/` 2. Copy `backend/.env.example` to `backend/.env` and adjust values. 3. Run migrations and start the server. +### Demo data + +After migrations, you can seed demo data: + +- `python manage.py seed_demo` + ### Core API endpoints (current scaffold) - `POST /api/auth/register/` @@ -40,3 +46,7 @@ Location: `frontend/` 2. Run `npm run dev`. The dev server proxies `/api` to `http://localhost:8000`. + +## Project Notes + +- Known gaps and risks: `docs/risks.md` diff --git a/backend/.env.example b/backend/.env.example index 9060fb9..4ea9289 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -6,3 +6,13 @@ CORS_ALLOWED_ORIGINS=http://localhost:5173 OTP_PROVIDER=console OTP_EXPIRY_MINUTES=5 DEFAULT_CURRENCY=SAR +TWILIO_ACCOUNT_SID= +TWILIO_AUTH_TOKEN= +TWILIO_FROM_NUMBER= +TWILIO_WHATSAPP_FROM= +UNIFONIC_APP_SID= +UNIFONIC_SENDER_ID= +UNIFONIC_WHATSAPP_SENDER= +MOYASAR_SECRET_KEY= +MOYASAR_PUBLISHABLE_KEY= +MOYASAR_BASE_URL= diff --git a/backend/apps/accounts/migrations/0001_initial.py b/backend/apps/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..0e040f5 --- /dev/null +++ b/backend/apps/accounts/migrations/0001_initial.py @@ -0,0 +1,93 @@ +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("password", models.CharField(max_length=128, verbose_name="password")), + ("last_login", models.DateTimeField(blank=True, null=True, verbose_name="last login")), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ("email", models.EmailField(max_length=254, unique=True)), + ("phone_number", models.CharField(blank=True, max_length=20, null=True, unique=True)), + ( + "role", + models.CharField( + choices=[ + ("admin", "Admin"), + ("manager", "Salon Manager"), + ("staff", "Staff"), + ("customer", "Customer"), + ], + default="customer", + max_length=20, + ), + ), + ("first_name", models.CharField(blank=True, max_length=150)), + ("last_name", models.CharField(blank=True, max_length=150)), + ("is_phone_verified", models.BooleanField(default=False)), + ("is_active", models.BooleanField(default=True)), + ("is_staff", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + ], + options={"abstract": False}, + ), + migrations.CreateModel( + name="PhoneOTP", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("phone_number", models.CharField(max_length=20)), + ( + "channel", + models.CharField( + choices=[("sms", "SMS"), ("whatsapp", "WhatsApp")], + max_length=20, + ), + ), + ("provider", models.CharField(max_length=50)), + ("code_hash", models.CharField(max_length=128)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("expires_at", models.DateTimeField()), + ("verified_at", models.DateTimeField(blank=True, null=True)), + ], + ), + ] diff --git a/backend/apps/accounts/services/otp.py b/backend/apps/accounts/services/otp.py index 098bbaa..206fb7d 100644 --- a/backend/apps/accounts/services/otp.py +++ b/backend/apps/accounts/services/otp.py @@ -1,4 +1,5 @@ import logging +import os import secrets from dataclasses import dataclass @@ -33,8 +34,53 @@ class ConsoleOtpProvider(BaseOtpProvider): logger.info("OTP WhatsApp to %s: %s", to_number, message) +class TwilioOtpProvider(BaseOtpProvider): + def __init__(self) -> None: + self.account_sid = os.getenv("TWILIO_ACCOUNT_SID") + self.auth_token = os.getenv("TWILIO_AUTH_TOKEN") + self.from_number = os.getenv("TWILIO_FROM_NUMBER") + self.whatsapp_from = os.getenv("TWILIO_WHATSAPP_FROM") + + def _assert_config(self) -> None: + if not self.account_sid or not self.auth_token or not self.from_number: + raise ValueError("Twilio credentials are not configured") + + def send_sms(self, to_number: str, message: str) -> None: + self._assert_config() + raise NotImplementedError("Twilio SMS adapter not implemented yet") + + def send_whatsapp(self, to_number: str, message: str) -> None: + self._assert_config() + if not self.whatsapp_from: + raise ValueError("Twilio WhatsApp sender is not configured") + raise NotImplementedError("Twilio WhatsApp adapter not implemented yet") + + +class UnifonicOtpProvider(BaseOtpProvider): + def __init__(self) -> None: + self.app_sid = os.getenv("UNIFONIC_APP_SID") + self.sender_id = os.getenv("UNIFONIC_SENDER_ID") + self.whatsapp_sender = os.getenv("UNIFONIC_WHATSAPP_SENDER") + + def _assert_config(self) -> None: + if not self.app_sid or not self.sender_id: + raise ValueError("Unifonic credentials are not configured") + + def send_sms(self, to_number: str, message: str) -> None: + self._assert_config() + raise NotImplementedError("Unifonic SMS adapter not implemented yet") + + def send_whatsapp(self, to_number: str, message: str) -> None: + self._assert_config() + if not self.whatsapp_sender: + raise ValueError("Unifonic WhatsApp sender is not configured") + raise NotImplementedError("Unifonic WhatsApp adapter not implemented yet") + + PROVIDERS = { "console": ConsoleOtpProvider, + "twilio": TwilioOtpProvider, + "unifonic": UnifonicOtpProvider, } diff --git a/backend/apps/bookings/migrations/0001_initial.py b/backend/apps/bookings/migrations/0001_initial.py new file mode 100644 index 0000000..045a3aa --- /dev/null +++ b/backend/apps/bookings/migrations/0001_initial.py @@ -0,0 +1,56 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("salons", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="Booking", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("start_time", models.DateTimeField()), + ("end_time", models.DateTimeField()), + ( + "status", + models.CharField( + choices=[ + ("pending", "Pending"), + ("confirmed", "Confirmed"), + ("cancelled", "Cancelled"), + ("completed", "Completed"), + ], + default="pending", + max_length=20, + ), + ), + ("price_amount", models.DecimalField(decimal_places=2, max_digits=10)), + ("currency", models.CharField(default="SAR", max_length=10)), + ("notes", models.TextField(blank=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "customer", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="bookings", to=settings.AUTH_USER_MODEL), + ), + ( + "salon", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="bookings", to="salons.salon"), + ), + ( + "service", + models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="salons.service"), + ), + ( + "staff", + models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="salons.staffprofile"), + ), + ], + ), + ] diff --git a/backend/apps/payments/migrations/0001_initial.py b/backend/apps/payments/migrations/0001_initial.py new file mode 100644 index 0000000..b717a34 --- /dev/null +++ b/backend/apps/payments/migrations/0001_initial.py @@ -0,0 +1,58 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("bookings", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="Payment", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "provider", + models.CharField( + choices=[ + ("hyperpay", "HyperPay"), + ("paytabs", "PayTabs"), + ("moyasar", "Moyasar"), + ("tap", "Tap"), + ("amazon_payment_services", "Amazon Payment Services"), + ("checkout", "Checkout.com"), + ("other", "Other"), + ], + max_length=50, + ), + ), + ( + "status", + models.CharField( + choices=[ + ("created", "Created"), + ("authorized", "Authorized"), + ("captured", "Captured"), + ("failed", "Failed"), + ("refunded", "Refunded"), + ], + default="created", + max_length=20, + ), + ), + ("amount", models.DecimalField(decimal_places=2, max_digits=10)), + ("currency", models.CharField(default="SAR", max_length=10)), + ("external_id", models.CharField(blank=True, max_length=200)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "booking", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="payments", to="bookings.booking"), + ), + ], + ), + ] diff --git a/backend/apps/payments/services/__init__.py b/backend/apps/payments/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/payments/services/gateway.py b/backend/apps/payments/services/gateway.py new file mode 100644 index 0000000..9683a68 --- /dev/null +++ b/backend/apps/payments/services/gateway.py @@ -0,0 +1,43 @@ +import os +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class PaymentInitResult: + external_id: str + redirect_url: Optional[str] + + +class BasePaymentGateway: + def create_payment(self, amount: str, currency: str, description: str) -> PaymentInitResult: + raise NotImplementedError + + def capture_payment(self, external_id: str) -> None: + raise NotImplementedError + + def refund_payment(self, external_id: str, amount: Optional[str] = None) -> None: + raise NotImplementedError + + +class MoyasarGateway(BasePaymentGateway): + def __init__(self) -> None: + self.secret_key = os.getenv("MOYASAR_SECRET_KEY") + self.publishable_key = os.getenv("MOYASAR_PUBLISHABLE_KEY") + self.base_url = os.getenv("MOYASAR_BASE_URL", "https://api.moyasar.com") + + def _assert_config(self) -> None: + if not self.secret_key or not self.publishable_key: + raise ValueError("Moyasar credentials are not configured") + + def create_payment(self, amount: str, currency: str, description: str) -> PaymentInitResult: + self._assert_config() + raise NotImplementedError("Moyasar gateway integration not implemented yet") + + def capture_payment(self, external_id: str) -> None: + self._assert_config() + raise NotImplementedError("Moyasar capture not implemented yet") + + def refund_payment(self, external_id: str, amount: Optional[str] = None) -> None: + self._assert_config() + raise NotImplementedError("Moyasar refund not implemented yet") diff --git a/backend/apps/salons/management/__init__.py b/backend/apps/salons/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/salons/management/commands/__init__.py b/backend/apps/salons/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/salons/management/commands/seed_demo.py b/backend/apps/salons/management/commands/seed_demo.py new file mode 100644 index 0000000..bd70522 --- /dev/null +++ b/backend/apps/salons/management/commands/seed_demo.py @@ -0,0 +1,137 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand +from django.utils import timezone + +from apps.bookings.models import Booking, BookingStatus +from apps.payments.models import Payment, PaymentProvider, PaymentStatus +from apps.salons.models import Review, Salon, SalonPhoto, Service, StaffProfile + + +class Command(BaseCommand): + help = "Seed demo data for local development" + + def handle(self, *args, **options): + User = get_user_model() + + admin, _ = User.objects.get_or_create( + email="admin@example.com", + defaults={"role": "admin", "is_staff": True, "is_superuser": True}, + ) + if not admin.has_usable_password(): + admin.set_password("Admin123!") + admin.save(update_fields=["password"]) + + manager, _ = User.objects.get_or_create( + email="manager@example.com", + defaults={"role": "manager", "first_name": "Rania", "last_name": "Mansour"}, + ) + if not manager.has_usable_password(): + manager.set_password("Manager123!") + manager.save(update_fields=["password"]) + + staff_user, _ = User.objects.get_or_create( + email="stylist@example.com", + defaults={"role": "staff", "first_name": "Lina", "last_name": "Hassan"}, + ) + if not staff_user.has_usable_password(): + staff_user.set_password("Staff123!") + staff_user.save(update_fields=["password"]) + + customer, _ = User.objects.get_or_create( + email="customer@example.com", + defaults={"role": "customer", "first_name": "Yara", "last_name": "Saleh"}, + ) + if not customer.has_usable_password(): + customer.set_password("Customer123!") + customer.save(update_fields=["password"]) + + salon, _ = Salon.objects.get_or_create( + owner=manager, + name="Luxe Riyadh Studio", + defaults={ + "description": "Premium styling and beauty services in the heart of Riyadh.", + "address": "Olaya Street", + "city": "Riyadh", + "phone_number": "+966500000000", + "email": "hello@luxeriayadh.example", + "website": "https://luxeriayadh.example", + }, + ) + + SalonPhoto.objects.get_or_create( + salon=salon, + image_url="https://images.unsplash.com/photo-1522335789203-aabd1fc54bc9", + defaults={"alt_text": "Salon interior", "sort_order": 1}, + ) + + service_blowout, _ = Service.objects.get_or_create( + salon=salon, + name="Signature Blowout", + defaults={ + "description": "45-minute styling session with wash and blowdry.", + "duration_minutes": 45, + "price_amount": 180, + "currency": "SAR", + }, + ) + + service_color, _ = Service.objects.get_or_create( + salon=salon, + name="Color Refresh", + defaults={ + "description": "Full color refresh with toning and gloss.", + "duration_minutes": 90, + "price_amount": 420, + "currency": "SAR", + }, + ) + + staff_profile, _ = StaffProfile.objects.get_or_create( + user=staff_user, + salon=salon, + defaults={"title": "Senior Stylist", "bio": "Specialist in modern cuts and color."}, + ) + + Review.objects.get_or_create( + salon=salon, + customer=customer, + defaults={"rating": 5, "comment": "Loved the service and attention to detail."}, + ) + + salon.rating_avg = 5 + salon.rating_count = 1 + salon.save(update_fields=["rating_avg", "rating_count"]) + + start_time = timezone.now() + timedelta(days=1) + end_time = start_time + timedelta(minutes=service_blowout.duration_minutes) + + booking, _ = Booking.objects.get_or_create( + salon=salon, + customer=customer, + service=service_blowout, + staff=staff_profile, + start_time=start_time, + end_time=end_time, + defaults={ + "status": BookingStatus.CONFIRMED, + "price_amount": service_blowout.price_amount, + "currency": service_blowout.currency, + "notes": "Prefers natural volume.", + }, + ) + + Payment.objects.get_or_create( + booking=booking, + provider=PaymentProvider.MOYASAR, + defaults={ + "status": PaymentStatus.CREATED, + "amount": booking.price_amount, + "currency": booking.currency, + "external_id": "", + "metadata": {"note": "Demo payment record"}, + }, + ) + + self.stdout.write(self.style.SUCCESS("Demo data seeded.")) diff --git a/backend/apps/salons/migrations/0001_initial.py b/backend/apps/salons/migrations/0001_initial.py new file mode 100644 index 0000000..5e752ad --- /dev/null +++ b/backend/apps/salons/migrations/0001_initial.py @@ -0,0 +1,99 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Salon", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=200)), + ("description", models.TextField(blank=True)), + ("address", models.CharField(max_length=255)), + ("city", models.CharField(max_length=100)), + ("latitude", models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), + ("longitude", models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), + ("phone_number", models.CharField(blank=True, max_length=20)), + ("email", models.EmailField(blank=True, max_length=254)), + ("website", models.URLField(blank=True)), + ("rating_avg", models.DecimalField(decimal_places=2, default=0, max_digits=3)), + ("rating_count", models.PositiveIntegerField(default=0)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "owner", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="owned_salons", to=settings.AUTH_USER_MODEL), + ), + ], + ), + migrations.CreateModel( + name="SalonPhoto", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("image_url", models.URLField()), + ("alt_text", models.CharField(blank=True, max_length=200)), + ("sort_order", models.PositiveIntegerField(default=0)), + ( + "salon", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="photos", to="salons.salon"), + ), + ], + ), + migrations.CreateModel( + name="Service", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=200)), + ("description", models.TextField(blank=True)), + ("duration_minutes", models.PositiveIntegerField()), + ("price_amount", models.DecimalField(decimal_places=2, max_digits=10)), + ("currency", models.CharField(default="SAR", max_length=10)), + ("is_active", models.BooleanField(default=True)), + ( + "salon", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="services", to="salons.salon"), + ), + ], + ), + migrations.CreateModel( + name="StaffProfile", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("title", models.CharField(blank=True, max_length=200)), + ("bio", models.TextField(blank=True)), + ("is_active", models.BooleanField(default=True)), + ( + "salon", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="staff", to="salons.salon"), + ), + ( + "user", + models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ], + ), + migrations.CreateModel( + name="Review", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("rating", models.PositiveSmallIntegerField()), + ("comment", models.TextField(blank=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "customer", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="reviews", to=settings.AUTH_USER_MODEL), + ), + ( + "salon", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="reviews", to="salons.salon"), + ), + ], + ), + ] diff --git a/backend/salon_api/settings.py b/backend/salon_api/settings.py index c56d093..b4d7c4e 100644 --- a/backend/salon_api/settings.py +++ b/backend/salon_api/settings.py @@ -3,8 +3,12 @@ from pathlib import Path from datetime import timedelta from urllib.parse import urlparse +from dotenv import load_dotenv + BASE_DIR = Path(__file__).resolve().parent.parent +load_dotenv(BASE_DIR / ".env") + SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "unsafe-dev-key") DEBUG = os.getenv("DJANGO_DEBUG", "0") == "1" ALLOWED_HOSTS = [h.strip() for h in os.getenv("DJANGO_ALLOWED_HOSTS", "").split(",") if h.strip()] diff --git a/docs/risks.md b/docs/risks.md new file mode 100644 index 0000000..818bfc1 --- /dev/null +++ b/docs/risks.md @@ -0,0 +1,29 @@ +# Risks And Gaps + +This file tracks known gaps and risks to address in future iterations. + +## Security And Auth +- Phone auth only verifies existing users. Add phone-first sign-up flow. +- OTP rate limiting, resend cooldown, and abuse protections are missing. +- Phone normalization/validation (E.164) not implemented. +- Social login is a placeholder. + +## Booking Integrity +- No availability checks or overlap prevention for staff/salon schedules. +- No timezone handling or business hours enforcement. +- No cancellation rules or refund logic. + +## Payments +- Payment integration is not implemented. Current API only stores records. +- Webhook handling and payment status reconciliation missing. +- Idempotency handling for payment creation missing. + +## Data And UX +- Ratings are not recalculated from reviews. +- No image upload or storage strategy for photos. +- No notifications (email/SMS) beyond OTP scaffolding. + +## Ops And Compliance +- No audit logs for admin actions. +- No multi-tenant isolation or data export tooling. +- No GDPR/PDPL data retention policies defined.