import asyncio import logging import re from copy import deepcopy from dataclasses import dataclass from datetime import date, datetime, timedelta, timezone from zoneinfo import ZoneInfo from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database import AsyncSessionLocal from app.models.member import AdminMessage, Booking, Member, MemberNotificationDispatch, Walk from app.models.settings import SiteSettings from app.services.sections import get_section from app.services.settings import get_feature_settings_snapshot, get_settings logger = logging.getLogger(__name__) NZ_TZ = ZoneInfo("Pacific/Auckland") AUTOMATION_INTERVAL_SECONDS = 3600 SERVICE_LABELS = { "pack_walk": "Pack Walk", "1_1_walk": "1-1 Walk", "puppy_visit": "Puppy Visit", } DEFAULT_SENT_BY = "Goodwalk" DEFAULT_INVOICE_DAY = 1 MATARIKI_DATES = { 2025: date(2025, 6, 20), 2026: date(2026, 7, 10), 2027: date(2027, 6, 25), 2028: date(2028, 7, 14), 2029: date(2029, 7, 6), 2030: date(2030, 6, 21), 2031: date(2031, 7, 11), 2032: date(2032, 7, 2), 2033: date(2033, 6, 24), 2034: date(2034, 7, 7), 2035: date(2035, 6, 29), } AUTOMATIC_NOTIFICATION_SECTION_KEY = "notifications.automaticMessages" PUBLIC_HOLIDAY_NOTIFICATION_SECTION_KEY = "notifications.publicHolidays" INVOICE_REMINDER_NOTIFICATION_SECTION_KEY = "notifications.invoiceReminders" TEMPLATE_TOKEN_PATTERN = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}") DEFAULT_AUTOMATIC_NOTIFICATION_TEMPLATES = { "member_activated": { "subject": "Your Goodwalk members account is now active", "body": "Your onboarding is complete and your full members area is now ready. You can log in to view bookings, messages, walks, and your contract.", }, "booking_confirmed": { "subject": "Your Goodwalk booking has been confirmed", "body": "Your {{service_label}} booking for {{requested_date_label}} has been confirmed. If anything changes, we will message you here.", }, "booking_rescheduled": { "subject": "Your Goodwalk booking has been rescheduled", "body": "Your {{service_label}} has been moved to {{requested_date_label}}. If you have any questions, please get in touch.", }, "booking_cancelled": { "subject": "Your Goodwalk booking has been cancelled", "body": "Your {{service_label}} booking for {{requested_date_label}} has been cancelled. Please contact us if you would like to arrange another time.", }, "walk_completed": { "subject": "Your Goodwalk walk is complete", "body": "{{member_first_name}}, your {{service_label}} on {{walked_on_label}} has been marked as complete.{{walk_notes_sentence}}", }, } DEFAULT_PUBLIC_HOLIDAY_NOTIFICATION_TEMPLATE = { "subject": "Goodwalk public holiday update: {{holiday_name}}", "body": "Today is {{holiday_name}} in New Zealand. If you were expecting service changes or slower replies today, this is why. We will confirm any booking adjustments directly in your messages.", } DEFAULT_INVOICE_REMINDER_NOTIFICATION_TEMPLATE = { "subject": "Invoice reminder from Goodwalk", "body": "A quick reminder that invoices are scheduled to go out on {{weekday_label}}. This week that falls on {{invoice_date_label}}.", } @dataclass class NotificationSettingsSnapshot: automatic_member_notifications_enabled: bool = True nz_public_holiday_notifications_enabled: bool = True invoice_reminder_notifications_enabled: bool = True invoice_day_of_week: int = DEFAULT_INVOICE_DAY admin_notifications_cleared_before: datetime | None = None @dataclass class NotificationRunSummary: automatic_member_notifications_enabled: bool public_holiday_messages_sent: int = 0 invoice_reminders_sent: int = 0 @dataclass class NotificationTemplateSnapshot: automatic_messages: dict[str, dict[str, str]] public_holidays: dict[str, str] invoice_reminders: dict[str, str] def _service_label(service_type: str | None) -> str: return SERVICE_LABELS.get(service_type or "", service_type or "service") def _weekday_label(weekday: int) -> str: labels = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] return labels[weekday] if 0 <= weekday < len(labels) else labels[DEFAULT_INVOICE_DAY] def _format_nz_date(day: datetime | date, *, include_year: bool = False) -> str: if isinstance(day, datetime): local_day = day.astimezone(NZ_TZ) base = local_day.date() else: base = day month = base.strftime("%B") weekday = base.strftime("%A") if include_year: return f"{weekday} {base.day} {month} {base.year}" return f"{weekday} {base.day} {month}" def _shift_single_holiday(day: date) -> date: if day.weekday() == 5: return day + timedelta(days=2) if day.weekday() == 6: return day + timedelta(days=1) return day def _observed_pair(day_one: date, day_two: date) -> list[date]: observed: list[date] = [] for actual in [day_one, day_two]: candidate = actual if candidate.weekday() >= 5: candidate += timedelta(days=7 - candidate.weekday()) while candidate in observed: candidate += timedelta(days=1) observed.append(candidate) return observed def _nth_weekday(year: int, month: int, weekday: int, occurrence: int) -> date: first = date(year, month, 1) offset = (weekday - first.weekday()) % 7 return first + timedelta(days=offset + (occurrence - 1) * 7) def _easter_sunday(year: int) -> date: a = year % 19 b = year // 100 c = year % 100 d = b // 4 e = b % 4 f = (b + 8) // 25 g = (b - f + 1) // 3 h = (19 * a + b - d - g + 15) % 30 i = c // 4 k = c % 4 l = (32 + 2 * e + 2 * i - h - k) % 7 m = (a + 11 * h + 22 * l) // 451 month = (h + l - 7 * m + 114) // 31 day = ((h + l - 7 * m + 114) % 31) + 1 return date(year, month, day) def nz_public_holidays_for_year(year: int) -> dict[date, str]: easter = _easter_sunday(year) new_year, day_after = _observed_pair(date(year, 1, 1), date(year, 1, 2)) christmas, boxing = _observed_pair(date(year, 12, 25), date(year, 12, 26)) holidays = { new_year: "New Year's Day", day_after: "Day after New Year's Day", _shift_single_holiday(date(year, 2, 6)): "Waitangi Day", easter - timedelta(days=2): "Good Friday", easter + timedelta(days=1): "Easter Monday", _shift_single_holiday(date(year, 4, 25)): "ANZAC Day", _nth_weekday(year, 6, 0, 1): "King's Birthday", _nth_weekday(year, 10, 0, 4): "Labour Day", christmas: "Christmas Day", boxing: "Boxing Day", } matariki = MATARIKI_DATES.get(year) if matariki is not None: holidays[matariki] = "Matariki" return holidays def nz_public_holiday_name(day: date) -> str | None: return nz_public_holidays_for_year(day.year).get(day) def _normalize_template_text(value: object, fallback: str) -> str: if isinstance(value, str): trimmed = value.strip() if trimmed: return trimmed return fallback def _normalize_subject_body_template(data: object, fallback: dict[str, str]) -> dict[str, str]: source = data if isinstance(data, dict) else {} return { "subject": _normalize_template_text(source.get("subject"), fallback["subject"]), "body": _normalize_template_text(source.get("body"), fallback["body"]), } async def get_notification_template_snapshot(db: AsyncSession) -> NotificationTemplateSnapshot: automatic_section = await get_section(db, AUTOMATIC_NOTIFICATION_SECTION_KEY) automatic_templates = deepcopy(DEFAULT_AUTOMATIC_NOTIFICATION_TEMPLATES) automatic_source = automatic_section.get("templates") if isinstance(automatic_section, dict) else {} if isinstance(automatic_source, dict): for template_key, fallback in DEFAULT_AUTOMATIC_NOTIFICATION_TEMPLATES.items(): automatic_templates[template_key] = _normalize_subject_body_template( automatic_source.get(template_key), fallback, ) public_holiday_section = await get_section(db, PUBLIC_HOLIDAY_NOTIFICATION_SECTION_KEY) invoice_reminder_section = await get_section(db, INVOICE_REMINDER_NOTIFICATION_SECTION_KEY) return NotificationTemplateSnapshot( automatic_messages=automatic_templates, public_holidays=_normalize_subject_body_template( public_holiday_section, DEFAULT_PUBLIC_HOLIDAY_NOTIFICATION_TEMPLATE, ), invoice_reminders=_normalize_subject_body_template( invoice_reminder_section, DEFAULT_INVOICE_REMINDER_NOTIFICATION_TEMPLATE, ), ) def _render_template_text(template: str, context: dict[str, object]) -> str: def replace(match: re.Match[str]) -> str: return str(context.get(match.group(1), "")) return TEMPLATE_TOKEN_PATTERN.sub(replace, template).strip() def _render_subject_body_template(template: dict[str, str], context: dict[str, object]) -> tuple[str, str]: return ( _render_template_text(template["subject"], context), _render_template_text(template["body"], context), ) async def get_notification_settings_snapshot(db: AsyncSession) -> NotificationSettingsSnapshot: row = await get_settings(db) if row is None: return NotificationSettingsSnapshot() return NotificationSettingsSnapshot( automatic_member_notifications_enabled=row.automatic_member_notifications_enabled, nz_public_holiday_notifications_enabled=row.nz_public_holiday_notifications_enabled, invoice_reminder_notifications_enabled=row.invoice_reminder_notifications_enabled, invoice_day_of_week=row.invoice_day_of_week, admin_notifications_cleared_before=row.admin_notifications_cleared_before, ) async def update_notification_settings_snapshot( db: AsyncSession, *, automatic_member_notifications_enabled: bool | None = None, nz_public_holiday_notifications_enabled: bool | None = None, invoice_reminder_notifications_enabled: bool | None = None, invoice_day_of_week: int | None = None, admin_notifications_cleared_before: datetime | None = None, ) -> NotificationSettingsSnapshot: row = await get_settings(db) if row is None: row = SiteSettings(site_name="") db.add(row) await db.flush() if automatic_member_notifications_enabled is not None: row.automatic_member_notifications_enabled = automatic_member_notifications_enabled if nz_public_holiday_notifications_enabled is not None: row.nz_public_holiday_notifications_enabled = nz_public_holiday_notifications_enabled if invoice_reminder_notifications_enabled is not None: row.invoice_reminder_notifications_enabled = invoice_reminder_notifications_enabled if invoice_day_of_week is not None: if invoice_day_of_week < 0 or invoice_day_of_week > 6: raise ValueError("invoice_day_of_week must be between 0 and 6") row.invoice_day_of_week = invoice_day_of_week if admin_notifications_cleared_before is not None: row.admin_notifications_cleared_before = admin_notifications_cleared_before await db.flush() await db.refresh(row) return await get_notification_settings_snapshot(db) async def create_member_message( db: AsyncSession, *, member: Member, subject: str, body: str, sent_by: str = DEFAULT_SENT_BY, automatic: bool = False, dispatch_key: str | None = None, notification_type: str | None = None, respect_preferences: bool = True, ) -> AdminMessage | None: feature_settings = await get_feature_settings_snapshot(db) if not feature_settings.messages_enabled: return None if respect_preferences and not member.notifications_enabled: return None if automatic: settings = await get_notification_settings_snapshot(db) if not settings.automatic_member_notifications_enabled: return None if dispatch_key is not None: existing = await db.execute( select(MemberNotificationDispatch).where( MemberNotificationDispatch.member_id == member.id, MemberNotificationDispatch.dispatch_key == dispatch_key, ) ) if existing.scalars().first() is not None: return None db.add( MemberNotificationDispatch( member_id=member.id, notification_type=notification_type or "notification", dispatch_key=dispatch_key, metadata_json={"automatic": automatic}, ) ) message = AdminMessage( member_id=member.id, subject=subject, body=body, sent_by=sent_by, ) db.add(message) await db.flush() return message async def send_account_activated_notification( db: AsyncSession, member: Member, *, templates: NotificationTemplateSnapshot | None = None, ) -> AdminMessage | None: templates = templates or await get_notification_template_snapshot(db) subject, body = _render_subject_body_template( templates.automatic_messages["member_activated"], { "member_first_name": member.first_name, "member_last_name": member.last_name, }, ) return await create_member_message( db, member=member, subject=subject, body=body, automatic=True, dispatch_key=f"member_activated:{member.id}:{member.activated_at.isoformat() if member.activated_at else 'pending'}", notification_type="member_activated", ) async def send_booking_status_notification( db: AsyncSession, member: Member, booking: Booking, *, templates: NotificationTemplateSnapshot | None = None, ) -> AdminMessage | None: if booking.status not in {"confirmed", "cancelled"}: return None templates = templates or await get_notification_template_snapshot(db) requested = _format_nz_date(booking.requested_date, include_year=True) if booking.requested_date else "the requested date" service_name = _service_label(booking.service_type) if booking.status == "confirmed": template = templates.automatic_messages["booking_confirmed"] else: template = templates.automatic_messages["booking_cancelled"] subject, body = _render_subject_body_template( template, { "member_first_name": member.first_name, "member_last_name": member.last_name, "service_label": service_name, "requested_date_label": requested, "booking_status": booking.status, }, ) return await create_member_message( db, member=member, subject=subject, body=body, automatic=True, dispatch_key=f"booking_status:{booking.id}:{booking.status}", notification_type="booking_status", ) async def send_booking_rescheduled_notification( db: AsyncSession, member: Member, booking: Booking, *, templates: NotificationTemplateSnapshot | None = None, ) -> AdminMessage | None: templates = templates or await get_notification_template_snapshot(db) requested = _format_nz_date(booking.requested_date, include_year=True) if booking.requested_date else "a new date" service_name = _service_label(booking.service_type) subject, body = _render_subject_body_template( templates.automatic_messages["booking_rescheduled"], { "member_first_name": member.first_name, "member_last_name": member.last_name, "service_label": service_name, "requested_date_label": requested, }, ) return await create_member_message( db, member=member, subject=subject, body=body, automatic=True, dispatch_key=f"booking_rescheduled:{booking.id}:{booking.requested_date.isoformat() if booking.requested_date else 'none'}", notification_type="booking_rescheduled", ) async def send_walk_completed_notification( db: AsyncSession, member: Member, walk: Walk, *, templates: NotificationTemplateSnapshot | None = None, ) -> AdminMessage | None: templates = templates or await get_notification_template_snapshot(db) walked_on = _format_nz_date(walk.walked_at) service_name = _service_label(walk.service_type) subject, body = _render_subject_body_template( templates.automatic_messages["walk_completed"], { "member_first_name": member.first_name, "member_last_name": member.last_name, "service_label": service_name, "walked_on_label": walked_on, "walk_notes": walk.notes or "", "walk_notes_sentence": f" Notes from the team: {walk.notes}" if walk.notes else "", }, ) return await create_member_message( db, member=member, subject=subject, body=body, automatic=True, dispatch_key=f"walk_completed:{walk.id}", notification_type="walk_completed", ) async def send_public_holiday_notification( db: AsyncSession, member: Member, holiday_date: date, holiday_name: str, *, templates: NotificationTemplateSnapshot | None = None, ) -> AdminMessage | None: templates = templates or await get_notification_template_snapshot(db) subject, body = _render_subject_body_template( templates.public_holidays, { "member_first_name": member.first_name, "member_last_name": member.last_name, "holiday_name": holiday_name, "holiday_date_label": _format_nz_date(holiday_date, include_year=True), }, ) return await create_member_message( db, member=member, subject=subject, body=body, automatic=True, dispatch_key=f"public_holiday:{holiday_date.isoformat()}", notification_type="public_holiday", ) async def send_invoice_day_notification( db: AsyncSession, member: Member, invoice_date: date, weekday_label: str, *, templates: NotificationTemplateSnapshot | None = None, ) -> AdminMessage | None: templates = templates or await get_notification_template_snapshot(db) subject, body = _render_subject_body_template( templates.invoice_reminders, { "member_first_name": member.first_name, "member_last_name": member.last_name, "weekday_label": weekday_label, "invoice_date_label": _format_nz_date(invoice_date), }, ) return await create_member_message( db, member=member, subject=subject, body=body, automatic=True, dispatch_key=f"invoice_reminder:{invoice_date.isoformat()}", notification_type="invoice_reminder", ) async def run_automatic_notifications( db: AsyncSession, *, now: datetime | None = None, ) -> NotificationRunSummary: settings = await get_notification_settings_snapshot(db) summary = NotificationRunSummary( automatic_member_notifications_enabled=settings.automatic_member_notifications_enabled, ) if not settings.automatic_member_notifications_enabled: return summary templates = await get_notification_template_snapshot(db) local_now = (now or datetime.now(timezone.utc)).astimezone(NZ_TZ) local_date = local_now.date() result = await db.execute( select(Member).where( Member.is_active == True, # noqa: E712 Member.member_status == "active", Member.notifications_enabled == True, # noqa: E712 ) ) members = result.scalars().all() if settings.nz_public_holiday_notifications_enabled: holiday_name = nz_public_holiday_name(local_date) if holiday_name: for member in members: created = await send_public_holiday_notification( db, member, local_date, holiday_name, templates=templates, ) if created is not None: summary.public_holiday_messages_sent += 1 if settings.invoice_reminder_notifications_enabled and local_date.weekday() == settings.invoice_day_of_week: weekday_label = _weekday_label(settings.invoice_day_of_week) for member in members: created = await send_invoice_day_notification( db, member, local_date, weekday_label, templates=templates, ) if created is not None: summary.invoice_reminders_sent += 1 return summary async def notification_automation_loop() -> None: while True: try: async with AsyncSessionLocal() as session: await run_automatic_notifications(session) await session.commit() except asyncio.CancelledError: raise except Exception: logger.exception("Automatic member notification loop failed.") await asyncio.sleep(AUTOMATION_INTERVAL_SECONDS)