diff --git a/mail-api/legacy-clients-seed.json b/mail-api/legacy-clients-seed.json index 4df5be6..ef89108 100644 --- a/mail-api/legacy-clients-seed.json +++ b/mail-api/legacy-clients-seed.json @@ -207,12 +207,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "27" ], @@ -432,12 +432,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "45" ], @@ -657,12 +657,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "95" ], @@ -882,12 +882,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "103" ], @@ -1107,12 +1107,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "39" ], @@ -1332,12 +1332,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "41" ], @@ -1557,12 +1557,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "36" ], @@ -1782,12 +1782,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "83" ], @@ -2007,12 +2007,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "76", "49" @@ -2233,12 +2233,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "99" ], @@ -2458,12 +2458,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "84" ], @@ -2683,12 +2683,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "51" ], @@ -2908,12 +2908,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "87" ], @@ -3134,12 +3134,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "88" ], @@ -3359,12 +3359,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "34" ], @@ -3584,12 +3584,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "61", "47" @@ -3811,12 +3811,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "57" ], @@ -4036,12 +4036,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "32" ], @@ -4261,12 +4261,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "22" ], @@ -4486,12 +4486,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "64" ], @@ -4711,12 +4711,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "74" ], @@ -4936,12 +4936,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "56" ], @@ -5161,12 +5161,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "116", "93" @@ -5387,12 +5387,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "59" ], @@ -5612,12 +5612,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "30" ], @@ -5837,12 +5837,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "53" ], @@ -6062,12 +6062,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "107" ], @@ -6287,12 +6287,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "24" ], @@ -6512,12 +6512,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "43" ], @@ -6737,12 +6737,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "97" ], @@ -6962,12 +6962,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "90" ], @@ -7188,12 +7188,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "105" ], @@ -7413,12 +7413,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "101" ], @@ -7638,12 +7638,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "78" ], @@ -7664,12 +7664,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [], "contractEntryIds": [ "29" @@ -7887,12 +7887,12 @@ "lifecycle": { "status": "archived", "reason": "Imported from legacy Gravity Forms data", - "changedAt": "2026-06-15T21:24:42.302Z", + "changedAt": "2026-06-22T22:12:10.334Z", "changedBy": "legacy-import" }, "legacy": { "source": "gravity-forms-csv", - "importedAt": "2026-06-15T21:24:42.302Z", + "importedAt": "2026-06-22T22:12:10.334Z", "onboardingEntryIds": [ "80" ], diff --git a/mail-api/mail_api/models.py b/mail-api/mail_api/models.py index 0feecbe..6f5a78b 100644 --- a/mail-api/mail_api/models.py +++ b/mail-api/mail_api/models.py @@ -83,12 +83,14 @@ class ScheduledEmailRescheduleRequest(BaseModel): class BirthdayEmailRequest(BaseModel): email: EmailStr + dogId: str | None = None preview: bool = False previewRecipients: list[EmailStr] = [] class BirthdayAutoSendRequest(BaseModel): email: EmailStr + dogId: str | None = None enabled: bool @@ -110,6 +112,8 @@ class ClientProfileUpdate(BaseModel): phone: str = "" address: str = "" dogName: str + dogBreed: str = "" + dogAge: str = "" class ResetOnboardingRequest(BaseModel): @@ -131,6 +135,7 @@ class NewClientRequest(BaseModel): address: str = "" dogName: str = "" dogBreed: str = "" + dogAge: str = "" # Where the client came from, e.g. "Instagram", "Facebook", "Referral". source: str = "" # When true (and the MYOB integration is configured), also create the client @@ -139,6 +144,15 @@ class NewClientRequest(BaseModel): createInMyob: bool = False +class ClientDogUpsertRequest(BaseModel): + email: EmailStr + dogId: str | None = None + dogName: str + dogBreed: str = "" + dogAge: str = "" + birthdayAutoSend: bool = False + + class ContractSubmission(BaseSubmission): address: str dogName: str diff --git a/mail-api/main.py b/mail-api/main.py index 3e680fa..e649476 100644 --- a/mail-api/main.py +++ b/mail-api/main.py @@ -66,6 +66,7 @@ from mail_api.models import ( BaseSubmission, BirthdayAutoSendRequest, BirthdayEmailRequest, + ClientDogUpsertRequest, ClientProfileUpdate, BookingSubmission, ClientStatusUpdate, @@ -425,17 +426,133 @@ def _client_is_reachable(profile: dict) -> bool: return lifecycle.get("status") not in {"cancelled", "archived"} +def _dog_slug(value: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", (value or "").strip().lower()).strip("-") + return slug or f"dog-{uuid.uuid4().hex[:8]}" + + +def _normalize_profile_dog_entry(raw: Any) -> dict[str, Any] | None: + if not isinstance(raw, dict): + return None + name = str(raw.get("name") or raw.get("dogName") or "").strip() + breed = str(raw.get("breed") or raw.get("dogBreed") or "").strip() + birth_date = str(raw.get("birthDate") or raw.get("dogAge") or "").strip() + if not name and not breed and not birth_date: + return None + dog_id = str(raw.get("id") or raw.get("dogId") or "").strip() or _dog_slug(name or breed or birth_date) + return { + "id": dog_id, + "name": name, + "breed": breed, + "birthDate": birth_date, + "birthdayAutoSend": bool(raw.get("birthdayAutoSend")), + "birthdayEmailLastSentAt": str(raw.get("birthdayEmailLastSentAt") or "").strip(), + "birthdayEmailLastSentYear": str(raw.get("birthdayEmailLastSentYear") or "").strip(), + } + + +def _get_profile_dogs(profile: dict[str, Any]) -> list[dict[str, Any]]: + raw_dogs = profile.get("dogs") + dogs: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + if isinstance(raw_dogs, list): + for raw in raw_dogs: + dog = _normalize_profile_dog_entry(raw) + if not dog or dog["id"] in seen_ids: + continue + seen_ids.add(dog["id"]) + dogs.append(dog) + if dogs: + return dogs + + legacy = _normalize_profile_dog_entry( + { + "id": "primary", + "name": profile.get("dogName", ""), + "breed": profile.get("dogBreed", ""), + "birthDate": profile.get("dogAge", ""), + "birthdayAutoSend": profile.get("birthdayAutoSend", False), + "birthdayEmailLastSentAt": profile.get("birthdayEmailLastSentAt", ""), + "birthdayEmailLastSentYear": profile.get("birthdayEmailLastSentYear", ""), + } + ) + return [legacy] if legacy else [] + + +def _apply_profile_dogs(profile: dict[str, Any], dogs: list[dict[str, Any]]) -> dict[str, Any]: + next_profile = dict(profile) + if dogs: + normalized_dogs = [] + seen_ids: set[str] = set() + for raw in dogs: + dog = _normalize_profile_dog_entry(raw) + if not dog or dog["id"] in seen_ids: + continue + seen_ids.add(dog["id"]) + normalized_dogs.append(dog) + dogs = normalized_dogs + + if dogs: + primary = dogs[0] + next_profile["dogs"] = dogs + next_profile["dogName"] = primary.get("name", "") + next_profile["dogBreed"] = primary.get("breed", "") + next_profile["dogAge"] = primary.get("birthDate", "") + next_profile["birthdayAutoSend"] = bool(primary.get("birthdayAutoSend")) + next_profile["birthdayEmailLastSentAt"] = primary.get("birthdayEmailLastSentAt", "") + next_profile["birthdayEmailLastSentYear"] = primary.get("birthdayEmailLastSentYear", "") + else: + for key in ("dogs", "dogName", "dogBreed", "dogAge", "birthdayAutoSend", "birthdayEmailLastSentAt", "birthdayEmailLastSentYear"): + next_profile.pop(key, None) + + return next_profile + + +def _merge_client_profile(existing: dict[str, Any], updates: dict[str, Any]) -> dict[str, Any]: + merged = { + k: v + for k, v in {**existing, **updates}.items() + if v is not None and not (isinstance(v, str) and v == "") + } + + if "dogs" in updates: + return _apply_profile_dogs(merged, updates.get("dogs") if isinstance(updates.get("dogs"), list) else []) + + dog_related_keys = {"dogName", "dogBreed", "dogAge", "birthdayAutoSend", "birthdayEmailLastSentAt", "birthdayEmailLastSentYear"} + if dog_related_keys.intersection(updates.keys()): + dogs = _get_profile_dogs(existing) + primary = dict(dogs[0]) if dogs else {"id": "primary"} + primary["name"] = str(merged.get("dogName", "")).strip() + primary["breed"] = str(merged.get("dogBreed", "")).strip() + primary["birthDate"] = str(merged.get("dogAge", "")).strip() + primary["birthdayAutoSend"] = bool(merged.get("birthdayAutoSend")) + primary["birthdayEmailLastSentAt"] = str(merged.get("birthdayEmailLastSentAt", "")).strip() + primary["birthdayEmailLastSentYear"] = str(merged.get("birthdayEmailLastSentYear", "")).strip() + next_dogs = [primary] + dogs[1:] if dogs else [primary] + return _apply_profile_dogs(merged, next_dogs) + + return _apply_profile_dogs(merged, _get_profile_dogs(merged)) if _get_profile_dogs(merged) else merged + + +def _get_selected_dog(profile: dict[str, Any], dog_id: str | None) -> dict[str, Any] | None: + dogs = _get_profile_dogs(profile) + if not dogs: + return None + if not dog_id: + return dogs[0] + for dog in dogs: + if dog.get("id") == dog_id: + return dog + return None + + async def _store_client_profile(email: str, profile: dict) -> None: normalized = email.strip().lower() if not normalized: return async with _auth_lock: existing = _client_profiles.get(normalized, {}) - merged = { - k: v - for k, v in {**existing, **profile}.items() - if v is not None and not (isinstance(v, str) and v == "") - } + merged = _merge_client_profile(existing, profile) if merged != existing: _client_profiles[normalized] = merged snapshot = dict(_client_profiles) @@ -488,11 +605,7 @@ async def _update_client_profile( if next_normalized != normalized and isinstance(target_existing, dict): raise HTTPException(status_code=409, detail="Another client already uses that email address.") - merged = { - k: v - for k, v in {**existing, **profile_updates}.items() - if v is not None and not (isinstance(v, str) and v == "") - } + merged = _merge_client_profile(existing, profile_updates) if next_normalized != normalized: _client_profiles.pop(normalized, None) @@ -2518,12 +2631,16 @@ async def _send_birthday_email_for_profile( email: str, profile: dict, request_id: str, + dog_id: str | None = None, mark_auto_year: int | None = None, preview: bool = False, preview_recipients: list[str] | None = None, ) -> None: client_name = str(profile.get("fullName", "")).strip() - dog_name = str(profile.get("dogName", "")).strip() + dog = _get_selected_dog(profile, dog_id) + if not dog: + raise HTTPException(status_code=400, detail="This client does not have a dog on file.") + dog_name = str(dog.get("name", "")).strip() recipients = _resolve_preview_recipients(preview_recipients) if preview else [email] subject = f"Happy birthday {dog_name or 'from Goodwalk'}" if preview: @@ -2544,11 +2661,15 @@ async def _send_birthday_email_for_profile( if preview: return - profile_update = { - "birthdayEmailLastSentAt": datetime.now().isoformat(timespec="seconds"), - } - if mark_auto_year is not None: - profile_update["birthdayEmailLastSentYear"] = str(mark_auto_year) + sent_at = datetime.now().isoformat(timespec="seconds") + dogs = _get_profile_dogs(profile) + for item in dogs: + if item.get("id") == dog.get("id"): + item["birthdayEmailLastSentAt"] = sent_at + if mark_auto_year is not None: + item["birthdayEmailLastSentYear"] = str(mark_auto_year) + break + profile_update = {"dogs": dogs} await _store_client_profile(email, profile_update) @@ -2559,23 +2680,25 @@ async def _run_birthday_auto_sender_once() -> None: for email, profile in list(_client_profiles.items()): if not profile.get("onboardingCompleted"): continue - if not profile.get("birthdayAutoSend"): - continue - upcoming = _upcoming_birthday_date(str(profile.get("dogAge", ""))) - if not upcoming or (upcoming.month, upcoming.day) != today_month_day: - continue + for dog in _get_profile_dogs(profile): + if not dog.get("birthdayAutoSend"): + continue - last_sent_year = str(profile.get("birthdayEmailLastSentYear", "")).strip() - if last_sent_year == str(today.year): - continue + upcoming = _upcoming_birthday_date(str(dog.get("birthDate", ""))) + if not upcoming or (upcoming.month, upcoming.day) != today_month_day: + continue - request_id = f"birthday-auto-{uuid.uuid4().hex[:6]}" - try: - await _send_birthday_email_for_profile(email, profile, request_id, mark_auto_year=today.year) - logger.info("[%s] auto birthday email sent: email=%s", request_id, email) - except Exception as exc: - logger.error("[%s] auto birthday email failed: %s", request_id, exc, exc_info=True) + last_sent_year = str(dog.get("birthdayEmailLastSentYear", "")).strip() + if last_sent_year == str(today.year): + continue + + request_id = f"birthday-auto-{uuid.uuid4().hex[:6]}" + try: + await _send_birthday_email_for_profile(email, profile, request_id, dog_id=str(dog.get("id", "")), mark_auto_year=today.year) + logger.info("[%s] auto birthday email sent: email=%s dog=%s", request_id, email, dog.get("name", "")) + except Exception as exc: + logger.error("[%s] auto birthday email failed: %s", request_id, exc, exc_info=True) async def _birthday_auto_sender_loop() -> None: @@ -3313,7 +3436,10 @@ async def owner_render_birthday_email(data: BirthdayEmailRequest, request: Reque raise HTTPException(status_code=404, detail="Client profile not found.") owner_name = str(profile.get("fullName", "")).strip() - dog_name = str(profile.get("dogName", "")).strip() + dog = _get_selected_dog(profile, data.dogId) + if not dog: + raise HTTPException(status_code=404, detail="Dog not found.") + dog_name = str(dog.get("name", "")).strip() html = _birthday_email_html(owner_name, dog_name) return {"ok": True, "html": html} @@ -3543,6 +3669,69 @@ async def owner_activity(request: Request): return {"ok": True, "events": events} +@app.get("/owner/resend-overview") +async def owner_resend_overview(request: Request): + await _require_owner_email(request) + qp = request.query_params + try: + limit = int(qp.get("limit", "50")) + except ValueError: + limit = 50 + limit = max(1, min(100, limit)) + + try: + emails_result = await asyncio.to_thread(resend.Emails.list, {"limit": limit}) + logs_result = await asyncio.to_thread(resend.Logs.list, {"limit": limit}) + except Exception as exc: + logger.warning("owner_resend_overview failed: %s", exc, exc_info=True) + raise HTTPException( + status_code=502, + detail={ + "message": "Could not load Resend activity right now.", + "error_type": type(exc).__name__, + }, + ) + + email_rows = emails_result.get("data", []) if isinstance(emails_result, dict) else [] + log_rows = logs_result.get("data", []) if isinstance(logs_result, dict) else [] + + emails: list[dict[str, Any]] = [] + for item in email_rows: + if not isinstance(item, dict): + continue + emails.append({ + "id": item.get("id"), + "to": item.get("to") if isinstance(item.get("to"), list) else [], + "from": item.get("from"), + "createdAt": item.get("created_at"), + "subject": item.get("subject"), + "lastEvent": item.get("last_event"), + "scheduledAt": item.get("scheduled_at"), + }) + + logs: list[dict[str, Any]] = [] + for item in log_rows: + if not isinstance(item, dict): + continue + endpoint = str(item.get("endpoint") or "") + if "/emails" not in endpoint: + continue + logs.append({ + "id": item.get("id"), + "createdAt": item.get("created_at"), + "endpoint": endpoint, + "method": item.get("method"), + "responseStatus": item.get("response_status"), + "userAgent": item.get("user_agent"), + }) + + return { + "ok": True, + "emails": emails, + "logs": logs[:limit], + } + + @app.get("/owner/submissions") async def owner_submissions(request: Request): await _require_owner_email(request) @@ -3595,6 +3784,8 @@ async def owner_pending_onboarding(request: Request): "address": profile.get("address", ""), "dogName": profile.get("dogName", ""), "dogBreed": profile.get("dogBreed", ""), + "dogAge": profile.get("dogAge", ""), + "dogs": _get_profile_dogs(profile), "services": profile.get("services", []) if isinstance(profile.get("services"), list) else [], "lastEnquiryAt": profile.get("lastEnquiryAt", ""), "welcomePackSentAt": profile.get("welcomePackSentAt", ""), @@ -3650,8 +3841,9 @@ async def owner_completed_onboarding(request: Request): "dogName": profile.get("dogName", ""), "dogBreed": profile.get("dogBreed", ""), "dogAge": profile.get("dogAge", ""), + "dogs": _get_profile_dogs(profile), "onboardingSubmittedAt": profile.get("onboardingSubmittedAt", ""), - "hasBirthdayInvite": bool(_trimmed(str(profile.get("dogAge", "")))), + "hasBirthdayInvite": any(_trimmed(str(dog.get("birthDate", ""))) for dog in _get_profile_dogs(profile)), }) completed_clients.sort( @@ -3713,6 +3905,8 @@ async def owner_all_clients(request: Request): "address": profile.get("address", ""), "dogName": profile.get("dogName", ""), "dogBreed": profile.get("dogBreed", ""), + "dogAge": profile.get("dogAge", ""), + "dogs": _get_profile_dogs(profile), "status": "completed" if profile.get("onboardingCompleted") else "pending", "lifecycle": lifecycle or {"status": "active", "reason": "", "changedAt": "", "changedBy": ""}, "lastActivityAt": profile.get("onboardingSubmittedAt", "") or profile.get("lastEnquiryAt", "") or profile.get("welcomePackSentAt", ""), @@ -3745,6 +3939,32 @@ async def owner_all_clients(request: Request): } +@app.get("/owner/client-directory") +async def owner_client_directory(request: Request): + await _require_owner_email(request) + + clients: list[dict[str, Any]] = [] + for email, profile in _client_profiles.items(): + if email == OWNER_EMAIL.strip().lower(): + continue + lifecycle = profile.get("lifecycle") if isinstance(profile.get("lifecycle"), dict) else None + clients.append({ + "email": email, + "fullName": profile.get("fullName", ""), + "phone": profile.get("phone", ""), + "address": profile.get("address", ""), + "dogName": profile.get("dogName", ""), + "dogBreed": profile.get("dogBreed", ""), + "dogAge": profile.get("dogAge", ""), + "dogs": _get_profile_dogs(profile), + "status": "completed" if profile.get("onboardingCompleted") else "pending", + "lifecycle": lifecycle or {"status": "active", "reason": "", "changedAt": "", "changedBy": ""}, + }) + + clients.sort(key=lambda item: (item.get("fullName", "").lower(), item.get("email", "").lower())) + return {"ok": True, "clients": clients} + + @app.post("/owner/client-profile") async def owner_client_profile(data: ClientProfileUpdate, request: Request): request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8]) @@ -3752,6 +3972,8 @@ async def owner_client_profile(data: ClientProfileUpdate, request: Request): full_name = str(data.fullName).strip() dog_name = str(data.dogName).strip() + dog_breed = str(data.dogBreed).strip() + dog_age = str(data.dogAge).strip() phone = str(data.phone).strip() address = str(data.address).strip() current_email = str(data.email).strip().lower() @@ -3770,6 +3992,8 @@ async def owner_client_profile(data: ClientProfileUpdate, request: Request): "phone": phone, "address": address, "dogName": dog_name, + "dogBreed": dog_breed, + "dogAge": dog_age, }, ) @@ -3784,6 +4008,8 @@ async def owner_client_profile(data: ClientProfileUpdate, request: Request): "email": next_email, "fullName": full_name, "dogName": dog_name, + "dogBreed": dog_breed, + "dogAge": dog_age, }, ) @@ -3796,6 +4022,8 @@ async def owner_client_profile(data: ClientProfileUpdate, request: Request): "address": profile.get("address", ""), "dogName": profile.get("dogName", ""), "dogBreed": profile.get("dogBreed", ""), + "dogAge": profile.get("dogAge", ""), + "dogs": _get_profile_dogs(profile), "welcomePackSentAt": profile.get("welcomePackSentAt", ""), "welcomePackOffer": profile.get("welcomePackOffer", {}) if isinstance(profile.get("welcomePackOffer"), dict) else {}, }, @@ -3816,6 +4044,7 @@ async def owner_add_client(data: NewClientRequest, request: Request): address = str(data.address).strip() dog_name = str(data.dogName).strip() dog_breed = str(data.dogBreed).strip() + dog_age = str(data.dogAge).strip() source = str(data.source).strip() if not full_name: @@ -3836,6 +4065,7 @@ async def owner_add_client(data: NewClientRequest, request: Request): "address": address, "dogName": dog_name, "dogBreed": dog_breed, + "dogAge": dog_age, "source": source, # Treated like an enquiry timestamp so the client surfaces in the # pending-onboarding and recent-clients views straight away. @@ -3901,6 +4131,8 @@ async def owner_add_client(data: NewClientRequest, request: Request): "address": profile.get("address", ""), "dogName": profile.get("dogName", ""), "dogBreed": profile.get("dogBreed", ""), + "dogAge": profile.get("dogAge", ""), + "dogs": _get_profile_dogs(profile), "status": "completed" if profile.get("onboardingCompleted") else "pending", "lifecycle": lifecycle or {"status": "active", "reason": "", "changedAt": "", "changedBy": ""}, "lastActivityAt": profile.get("lastEnquiryAt", ""), @@ -3909,6 +4141,103 @@ async def owner_add_client(data: NewClientRequest, request: Request): } +@app.post("/owner/client-dog") +async def owner_client_dog(data: ClientDogUpsertRequest, request: Request): + request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8]) + owner_email = await _require_owner_email(request) + + email = str(data.email).strip().lower() + if not email: + raise HTTPException(status_code=400, detail="Client email is required.") + + dog_name = str(data.dogName).strip() + dog_breed = str(data.dogBreed).strip() + dog_age = str(data.dogAge).strip() + auto_send = bool(data.birthdayAutoSend) + if not dog_name: + raise HTTPException(status_code=400, detail="Please enter the dog name.") + if auto_send and not _upcoming_birthday_date(dog_age): + raise HTTPException(status_code=400, detail="Add a valid birthday before turning on automatic birthday emails.") + + profile = _client_profiles.get(email, {}) + if not profile: + raise HTTPException(status_code=404, detail="Client not found.") + + dogs = _get_profile_dogs(profile) + requested_dog_id = str(data.dogId or "").strip() + existing = next((dog for dog in dogs if dog.get("id") == requested_dog_id), None) if requested_dog_id else None + dog_id = requested_dog_id or _dog_slug(dog_name) + + duplicate_name = next( + ( + dog for dog in dogs + if dog.get("id") != requested_dog_id + and str(dog.get("name", "")).strip().lower() == dog_name.lower() + ), + None, + ) + if duplicate_name and not existing: + dog_id = str(duplicate_name.get("id") or dog_id) + + next_dog = { + "id": dog_id, + "name": dog_name, + "breed": dog_breed, + "birthDate": dog_age, + "birthdayAutoSend": auto_send, + "birthdayEmailLastSentAt": str(existing.get("birthdayEmailLastSentAt", "")) if existing and dog_age else "", + "birthdayEmailLastSentYear": str(existing.get("birthdayEmailLastSentYear", "")) if existing and dog_age else "", + } + + updated_existing = False + next_dogs: list[dict[str, Any]] = [] + for dog in dogs: + if dog.get("id") == dog_id: + next_dogs.append(next_dog) + updated_existing = True + else: + next_dogs.append(dog) + if not updated_existing: + next_dogs.append(next_dog) + + await _store_client_profile(email, {"dogs": next_dogs}) + updated_profile = _client_profiles.get(email, {}) + lifecycle = updated_profile.get("lifecycle") if isinstance(updated_profile.get("lifecycle"), dict) else None + + await admin_db.record_event( + event_type="owner_client_dog_updated" if updated_existing else "owner_client_dog_added", + request_id=request_id, + actor_email=owner_email, + ip=_get_ip(request), + status="ok", + detail={ + "email": email, + "dogId": dog_id, + "dogName": dog_name, + "dogBreed": dog_breed, + "dogAge": dog_age, + }, + ) + + return { + "ok": True, + "client": { + "email": email, + "fullName": updated_profile.get("fullName", ""), + "phone": updated_profile.get("phone", ""), + "address": updated_profile.get("address", ""), + "dogName": updated_profile.get("dogName", ""), + "dogBreed": updated_profile.get("dogBreed", ""), + "dogAge": updated_profile.get("dogAge", ""), + "dogs": _get_profile_dogs(updated_profile), + "status": "completed" if updated_profile.get("onboardingCompleted") else "pending", + "lifecycle": lifecycle or {"status": "active", "reason": "", "changedAt": "", "changedBy": ""}, + "lastActivityAt": updated_profile.get("lastEnquiryAt", ""), + "welcomePackSentAt": updated_profile.get("welcomePackSentAt", ""), + }, + } + + @app.get("/owner/birthdays") async def owner_birthdays(request: Request): await _require_owner_email(request) @@ -3932,21 +4261,28 @@ async def owner_birthdays(request: Request): if not _client_is_reachable(profile): continue - upcoming = _upcoming_birthday_date(str(profile.get("dogAge", "")), today) - if not upcoming: + dogs = _get_profile_dogs(profile) + if not dogs: continue - birthdays.append({ - "email": email, - "fullName": profile.get("fullName", ""), - "dogName": profile.get("dogName", ""), - "dogBreed": profile.get("dogBreed", ""), - "dogAge": profile.get("dogAge", ""), - "birthdayLabel": upcoming.isoformat(), - "daysUntil": (upcoming - today.date()).days, - "birthdayAutoSend": bool(profile.get("birthdayAutoSend")), - "birthdayEmailLastSentAt": profile.get("birthdayEmailLastSentAt", ""), - }) + for dog in dogs: + upcoming = _upcoming_birthday_date(str(dog.get("birthDate", "")), today) + if not upcoming: + continue + + birthdays.append({ + "email": email, + "fullName": profile.get("fullName", ""), + "dogId": dog.get("id", ""), + "dogName": dog.get("name", ""), + "dogBreed": dog.get("breed", ""), + "dogAge": dog.get("birthDate", ""), + "birthdayLabel": upcoming.isoformat(), + "daysUntil": (upcoming - today.date()).days, + "birthdayAutoSend": bool(dog.get("birthdayAutoSend")), + "birthdayEmailLastSentAt": dog.get("birthdayEmailLastSentAt", ""), + "dogs": dogs, + }) birthdays.sort( key=lambda item: ( @@ -3987,9 +4323,13 @@ async def owner_birthday_ics(request: Request): if not profile or not profile.get("onboardingCompleted"): raise HTTPException(status_code=404, detail="Completed client not found.") + dog = _get_selected_dog(profile, _trimmed(request.query_params.get("dogId", "")) or None) + if not dog: + raise HTTPException(status_code=404, detail="Dog not found.") + attachment = _birthday_ics_attachment( - str(profile.get("dogName", "")), - str(profile.get("dogAge", "")), + str(dog.get("name", "")), + str(dog.get("birthDate", "")), str(profile.get("fullName", "")), request_id, ) @@ -4373,14 +4713,18 @@ async def owner_send_birthday_email(data: BirthdayEmailRequest, request: Request if not profile or not profile.get("onboardingCompleted"): raise HTTPException(status_code=404, detail="Completed client not found.") - if not _upcoming_birthday_date(str(profile.get("dogAge", ""))): - raise HTTPException(status_code=400, detail="This client does not have a valid dog birthday on file.") + dog = _get_selected_dog(profile, data.dogId) + if not dog: + raise HTTPException(status_code=404, detail="Dog not found.") + if not _upcoming_birthday_date(str(dog.get("birthDate", ""))): + raise HTTPException(status_code=400, detail="This dog does not have a valid birthday on file.") try: await _send_birthday_email_for_profile( email, profile, request_id, + dog_id=data.dogId, preview=bool(data.preview), preview_recipients=[str(e) for e in (data.previewRecipients or [])], ) @@ -4398,7 +4742,7 @@ async def owner_send_birthday_email(data: BirthdayEmailRequest, request: Request await admin_db.record_event( event_type="owner_birthday_email_sent", request_id=request_id, actor_email=owner_email, ip=_get_ip(request), status="ok", - detail={"recipient": email, "preview": bool(data.preview)}, + detail={"recipient": email, "dogId": data.dogId or str(dog.get("id", "")), "dogName": str(dog.get("name", "")), "preview": bool(data.preview)}, ) return {"ok": True, "sentAt": datetime.now().isoformat(timespec="seconds"), "preview": bool(data.preview)} @@ -4487,6 +4831,8 @@ async def owner_reset_onboarding(data: ResetOnboardingRequest, request: Request) "address": updated.get("address", ""), "dogName": updated.get("dogName", ""), "dogBreed": updated.get("dogBreed", ""), + "dogAge": updated.get("dogAge", ""), + "dogs": _get_profile_dogs(updated), "status": "pending", "lifecycle": lifecycle or {"status": "active", "reason": "", "changedAt": "", "changedBy": ""}, "lastActivityAt": updated.get("lastEnquiryAt", "") or updated.get("welcomePackSentAt", ""), @@ -4504,17 +4850,23 @@ async def owner_birthday_auto_send(data: BirthdayAutoSendRequest, request: Reque if not profile or not profile.get("onboardingCompleted"): raise HTTPException(status_code=404, detail="Completed client not found.") - if not _upcoming_birthday_date(str(profile.get("dogAge", ""))): - raise HTTPException(status_code=400, detail="This client does not have a valid dog birthday on file.") + dog = _get_selected_dog(profile, data.dogId) + if not dog: + raise HTTPException(status_code=404, detail="Dog not found.") + if not _upcoming_birthday_date(str(dog.get("birthDate", ""))): + raise HTTPException(status_code=400, detail="This dog does not have a valid birthday on file.") - await _store_client_profile(email, { - "birthdayAutoSend": data.enabled, - }) + dogs = _get_profile_dogs(profile) + for item in dogs: + if item.get("id") == dog.get("id"): + item["birthdayAutoSend"] = data.enabled + break + await _store_client_profile(email, {"dogs": dogs}) await admin_db.record_event( event_type="owner_birthday_auto_toggled", actor_email=owner_email, ip=_get_ip(request), status="ok", - detail={"clientEmail": email, "enabled": bool(data.enabled)}, + detail={"clientEmail": email, "dogId": data.dogId or str(dog.get("id", "")), "dogName": str(dog.get("name", "")), "enabled": bool(data.enabled)}, ) return {"ok": True, "enabled": data.enabled} diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 543624f..f11eebd 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -9,6 +9,14 @@ export const handle: Handle = async ({ event, resolve }) => { const { surface } = resolveSurface(event.url, event.cookies); const path = event.url.pathname; + // Design variant previews are not for production. The whole /variants + // namespace returns 500 off-localhost so unfinished work never leaks to the + // public site. Localhost (the dev server, or a local prod build) is allowed. + const onLocalhost = ['localhost', '127.0.0.1', '[::1]'].includes(event.url.hostname); + if (!dev && !onLocalhost && (path === '/variants' || path.startsWith('/variants/'))) { + return new Response('Internal Server Error', { status: 500 }); + } + // Sticky A/B assignment, marketing surface only — no point polluting the // owner/clients hosts with marketing-experiment cookies, and it skews // exposure counts when staff hit the public site from the dashboard. diff --git a/src/lib/components/admin-dashboard/AdminDashboard.svelte b/src/lib/components/admin-dashboard/AdminDashboard.svelte index 509a0a2..87e4434 100644 --- a/src/lib/components/admin-dashboard/AdminDashboard.svelte +++ b/src/lib/components/admin-dashboard/AdminDashboard.svelte @@ -6,12 +6,23 @@ import OnboardingAuth from '$lib/components/sections/OnboardingAuth.svelte'; import OnboardingPage from '$lib/components/pages/OnboardingPage.svelte'; import logoDesktop from '$lib/images/goodwalk-auckland-dog-walking-logo.png?enhanced'; + import logoMobile from '$lib/images/goodwalk-auckland-dog-walking-logo-mobile.png?enhanced'; import type { Picture } from '@sveltejs/enhanced-img'; const desktop = logoDesktop as Picture; + const mobile = logoMobile as Picture; const allClientsPageSize = 12; const birthdaysPageSize = 12; + type DogSummary = { + id: string; + name: string; + breed: string; + birthDate: string; + birthdayAutoSend: boolean; + birthdayEmailLastSentAt: string; + }; + type PendingClient = { email: string; fullName: string; @@ -19,9 +30,11 @@ address: string; dogName: string; dogBreed: string; + dogAge: string; services: string[]; lastEnquiryAt: string; welcomePackSentAt: string; + dogs?: DogSummary[]; welcomePackOffer: { serviceType?: string; priceDetails?: string; @@ -46,10 +59,24 @@ address: string; dogName: string; dogBreed: string; + dogAge: string; status: 'pending' | 'completed'; lifecycle: Lifecycle; lastActivityAt: string; welcomePackSentAt: string; + dogs?: DogSummary[]; + }; + + type ClientDirectoryItem = { + email: string; + fullName: string; + phone: string; + address: string; + dogName: string; + dogBreed: string; + dogAge: string; + status: 'pending' | 'completed'; + dogs?: DogSummary[]; }; const lifecycleOptions: { value: LifecycleStatus; label: string; description: string }[] = [ @@ -66,6 +93,7 @@ type BirthdayClient = { email: string; fullName: string; + dogId: string; dogName: string; dogBreed: string; dogAge: string; @@ -73,6 +101,7 @@ daysUntil: number; birthdayAutoSend: boolean; birthdayEmailLastSentAt: string; + dogs?: DogSummary[]; }; let authChecking = true; @@ -84,7 +113,7 @@ let welcomePreviewDraft = false; let selectedPreviewEmails = new Set(); - type OwnerTab = 'home' | 'clients' | 'birthdays' | 'messaging' | 'scheduled' | 'activity'; + type OwnerTab = 'home' | 'clients' | 'birthdays' | 'messaging' | 'scheduled' | 'email' | 'activity'; // The dashboard lives at /owner/welcome internally, but on the cp.* host nginx // + the `reroute` hook serve the tabs at clean paths (/ , /clients, …). We work @@ -98,9 +127,16 @@ birthdays: 'birthdays', messaging: 'messaging', scheduled: 'scheduled', + email: 'email', activity: 'activity', }; const VALID_TABS = Object.keys(TAB_SEGMENTS) as OwnerTab[]; + const CLIENT_SECTION_TABS: Array<{ tab: OwnerTab; label: string; icon: string }> = [ + { tab: 'clients', label: 'All clients', icon: 'fas fa-users' }, + { tab: 'birthdays', label: 'Birthdays', icon: 'fas fa-cake-candles' }, + { tab: 'messaging', label: 'Messaging', icon: 'fas fa-envelope-open-text' }, + { tab: 'scheduled', label: 'Scheduled messages', icon: 'fas fa-clock' }, + ]; function tabFromPath(pathname: string): OwnerTab { let rest = pathname; @@ -120,6 +156,15 @@ return `${cpBase}/${segment}`; } + function isClientSection(tab: OwnerTab): boolean { + return CLIENT_SECTION_TABS.some((item) => item.tab === tab); + } + + function isPrimaryNavTabActive(tab: OwnerTab): boolean { + if (tab === 'clients') return isClientSection(activeTab); + return activeTab === tab; + } + // activeTab is derived from the URL so deep links and the browser back/forward // buttons just work; navigation happens by changing the URL (see setTab). $: activeTab = tabFromPath($page.url.pathname); @@ -138,6 +183,30 @@ let activityLoading = false; let activityError = ''; + type ResendEmailItem = { + id: string; + to: string[]; + from: string | null; + createdAt: string | null; + subject: string | null; + lastEvent: string | null; + scheduledAt: string | null; + }; + + type ResendLogItem = { + id: string; + createdAt: string | null; + endpoint: string | null; + method: string | null; + responseStatus: number | null; + userAgent: string | null; + }; + + let resendEmails: ResendEmailItem[] = []; + let resendLogs: ResendLogItem[] = []; + let resendLoading = false; + let resendError = ''; + let loadingHome = false; let homeError = ''; let pendingClients: PendingClient[] = []; @@ -204,9 +273,33 @@ phone: '', address: '', dogName: '', + dogBreed: '', + dogAge: '', }; - let birthdayBusyEmail = ''; + let birthdayBusyKey = ''; let birthdayActionError = ''; + let dogManagerOpen = false; + let dogManagerSaving = false; + let dogManagerError = ''; + let dogManagerClientEmail = ''; + let dogManagerClientLabel = ''; + let dogManagerDogs: DogSummary[] = []; + let dogForm = { + dogId: '', + dogName: '', + dogBreed: '', + dogAge: '', + birthdayAutoSend: false, + }; + let dogClientPickerOpen = false; + let dogClientPickerLoading = false; + let dogClientPickerError = ''; + let dogClientPickerQuery = ''; + let dogClientPickerClients: ClientDirectoryItem[] = []; + + function birthdayActionKey(email: string, dogId: string): string { + return `${email.toLowerCase()}::${dogId}`; + } // "Add client" modal — for leads from Instagram / Facebook / referrals that // never came through the public enquiry form. Registers the email so they can @@ -221,6 +314,7 @@ address: '', dogName: '', dogBreed: '', + dogAge: '', source: '', createInMyob: false, }; @@ -510,9 +604,307 @@ phone: '', address: '', dogName: '', + dogBreed: '', + dogAge: '', }; } + function normalizeDog(raw: Partial & { dogName?: string; dogBreed?: string; dogAge?: string; birthdayEmailLastSentAt?: string; birthdayAutoSend?: boolean; id?: string; dogId?: string }): DogSummary | null { + const name = String(raw.name ?? raw.dogName ?? '').trim(); + const breed = String(raw.breed ?? raw.dogBreed ?? '').trim(); + const birthDate = String(raw.birthDate ?? raw.dogAge ?? '').trim(); + if (!name && !breed && !birthDate) return null; + const fallbackId = `dog-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + return { + id: String(raw.id ?? raw.dogId ?? name.toLowerCase().replace(/[^a-z0-9]+/g, '-')).trim() || fallbackId, + name, + breed, + birthDate, + birthdayAutoSend: Boolean(raw.birthdayAutoSend), + birthdayEmailLastSentAt: String(raw.birthdayEmailLastSentAt ?? '').trim(), + }; + } + + function clientDogsFor(client: { + dogName?: string; + dogBreed?: string; + dogAge?: string; + dogs?: DogSummary[]; + }): DogSummary[] { + if (Array.isArray(client.dogs) && client.dogs.length) { + return client.dogs + .map((dog) => normalizeDog(dog)) + .filter((dog): dog is DogSummary => Boolean(dog)); + } + const fallback = normalizeDog({ + id: 'primary', + dogName: client.dogName, + dogBreed: client.dogBreed, + dogAge: client.dogAge, + }); + return fallback ? [fallback] : []; + } + + function clientDogHeadline(client: { + dogName?: string; + dogBreed?: string; + dogAge?: string; + dogs?: DogSummary[]; + }): string { + const dogs = clientDogsFor(client); + if (!dogs.length) return 'No dog details saved'; + if (dogs.length === 1) { + const [dog] = dogs; + return dog.breed ? `${dog.name || 'Unnamed dog'} · ${dog.breed}` : dog.name || 'Unnamed dog'; + } + const namedDogs = dogs.map((dog) => dog.name).filter(Boolean); + const preview = namedDogs.slice(0, 2).join(', '); + const remainder = dogs.length - Math.min(namedDogs.length, 2); + if (preview) { + return `${dogs.length} dogs · ${preview}${remainder > 0 ? ` +${remainder} more` : ''}`; + } + return `${dogs.length} dogs saved`; + } + + function clientBirthdaySummary(client: { + dogName?: string; + dogBreed?: string; + dogAge?: string; + dogs?: DogSummary[]; + }): string { + const withBirthdays = clientDogsFor(client).filter((dog) => dog.birthDate); + if (!withBirthdays.length) return ''; + if (withBirthdays.length === 1) { + return `Birthday ${formatBirthday(withBirthdays[0].birthDate)}`; + } + return `${withBirthdays.length} birthdays saved`; + } + + function openDogManager( + client: { + email: string; + fullName?: string; + dogName?: string; + dogBreed?: string; + dogAge?: string; + dogs?: DogSummary[]; + }, + dogId = '' + ) { + dogManagerOpen = true; + dogManagerSaving = false; + dogManagerError = ''; + dogManagerClientEmail = client.email; + dogManagerClientLabel = client.fullName || client.email; + dogManagerDogs = clientDogsFor(client); + const selected = dogManagerDogs.find((dog) => dog.id === dogId) ?? dogManagerDogs[0] ?? null; + dogForm = selected + ? { + dogId: selected.id, + dogName: selected.name, + dogBreed: selected.breed, + dogAge: selected.birthDate, + birthdayAutoSend: selected.birthdayAutoSend, + } + : { + dogId: '', + dogName: '', + dogBreed: '', + dogAge: '', + birthdayAutoSend: false, + }; + } + + function closeDogManager() { + if (dogManagerSaving) return; + dogManagerOpen = false; + dogManagerError = ''; + dogManagerClientEmail = ''; + dogManagerClientLabel = ''; + dogManagerDogs = []; + dogForm = { + dogId: '', + dogName: '', + dogBreed: '', + dogAge: '', + birthdayAutoSend: false, + }; + } + + async function openDogClientPicker() { + const token = getToken(); + if (!token) return; + + dogClientPickerOpen = true; + dogClientPickerLoading = true; + dogClientPickerError = ''; + dogClientPickerQuery = ''; + try { + const res = await fetch('/api/owner/client-directory', { + headers: { Authorization: `Bearer ${token}` }, + }); + const data = await res.json().catch(() => null); + if (!res.ok) throw new Error(data?.detail?.message ?? data?.detail ?? 'Could not load clients.'); + dogClientPickerClients = Array.isArray(data?.clients) ? data.clients : []; + } catch (error) { + dogClientPickerError = error instanceof Error ? error.message : 'Could not load clients.'; + dogClientPickerClients = []; + } finally { + dogClientPickerLoading = false; + } + } + + function closeDogClientPicker() { + if (dogClientPickerLoading) return; + dogClientPickerOpen = false; + dogClientPickerError = ''; + dogClientPickerQuery = ''; + dogClientPickerClients = []; + } + + function chooseClientForDog(client: ClientDirectoryItem) { + closeDogClientPicker(); + openDogManager(client); + startNewDog(); + } + + function selectDogForEdit(dogId: string) { + const selected = dogManagerDogs.find((dog) => dog.id === dogId); + if (!selected) return; + dogManagerError = ''; + dogForm = { + dogId: selected.id, + dogName: selected.name, + dogBreed: selected.breed, + dogAge: selected.birthDate, + birthdayAutoSend: selected.birthdayAutoSend, + }; + } + + function startNewDog() { + dogManagerError = ''; + dogForm = { + dogId: '', + dogName: '', + dogBreed: '', + dogAge: '', + birthdayAutoSend: false, + }; + } + + async function saveDogDetails() { + const token = getToken(); + if (!token || !dogManagerClientEmail) return; + + const dogName = dogForm.dogName.trim(); + const dogBirthday = dogForm.dogAge.trim(); + if (!dogName) { + dogManagerError = 'Please enter the dog name.'; + return; + } + if (dogForm.birthdayAutoSend && !dogBirthday) { + dogManagerError = 'Add a birthday before turning on automatic birthday emails.'; + return; + } + + dogManagerSaving = true; + dogManagerError = ''; + try { + const res = await fetch('/api/owner/client-dog', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + email: dogManagerClientEmail, + dogId: dogForm.dogId || null, + dogName, + dogBreed: dogForm.dogBreed.trim(), + dogAge: dogBirthday, + birthdayAutoSend: dogForm.birthdayAutoSend, + }), + }); + const data = await res.json().catch(() => null); + if (!res.ok) throw new Error(data?.detail?.message ?? data?.detail ?? 'Could not save dog details.'); + + dogManagerDogs = clientDogsFor(data?.client ?? {}); + const responseDogs = clientDogsFor(data?.client ?? {}); + const savedDog = responseDogs.find((dog) => dog.id === dogForm.dogId) + ?? dogManagerDogs.find((dog) => dog.name === dogName) + ?? dogManagerDogs[dogManagerDogs.length - 1]; + if (savedDog) { + dogForm = { + dogId: savedDog.id, + dogName: savedDog.name, + dogBreed: savedDog.breed, + dogAge: savedDog.birthDate, + birthdayAutoSend: savedDog.birthdayAutoSend, + }; + } + + syncClientDogsLocally(dogManagerClientEmail, dogManagerDogs); + await Promise.all([fetchHome(), fetchAllClients(allClientsPage), fetchBirthdays(birthdaysPage)]); + } catch (error) { + dogManagerError = error instanceof Error ? error.message : 'Could not save dog details.'; + } finally { + dogManagerSaving = false; + } + } + + function syncClientDogsLocally( + email: string, + dogs: DogSummary[] + ) { + const primary = dogs[0]; + const primaryName = primary?.name ?? ''; + const primaryBreed = primary?.breed ?? ''; + const primaryBirthday = primary?.birthDate ?? ''; + + pendingClients = pendingClients.map((client) => + client.email === email + ? { + ...client, + dogName: primaryName, + dogBreed: primaryBreed, + dogAge: primaryBirthday, + dogs, + } + : client + ); + + allClients = allClients.map((client) => + client.email === email + ? { + ...client, + dogName: primaryName, + dogBreed: primaryBreed, + dogAge: primaryBirthday, + dogs, + } + : client + ); + + birthdayClients = birthdayClients.map((client) => + client.email === email + ? { + ...client, + dogs, + } + : client + ); + } + + function matchesDogClientQuery(client: ClientDirectoryItem): boolean { + const query = dogClientPickerQuery.trim().toLowerCase(); + if (!query) return true; + return [ + client.fullName, + client.email, + clientDogHeadline(client), + ].some((value) => value.toLowerCase().includes(query)); + } + function formatDateLabel(value: string): string { if (!value) return ''; const parsed = new Date(value); @@ -602,7 +994,15 @@ confirmCtx = { kind: 'welcome', client }; } - function openProfileEditor(client: { email: string; fullName: string; phone: string; address?: string; dogName: string }) { + function openProfileEditor(client: { + email: string; + fullName: string; + phone: string; + address?: string; + dogName: string; + dogBreed?: string; + dogAge?: string; + }) { editingEmail = client.email; profileSaving = false; profileError = ''; @@ -614,12 +1014,14 @@ phone: client.phone || '', address: client.address || '', dogName: client.dogName || '', + dogBreed: client.dogBreed || '', + dogAge: client.dogAge || '', }; } function applyUpdatedClientLocally( originalEmail: string, - updated: { email: string; fullName: string; phone: string; address: string; dogName: string } + updated: { email: string; fullName: string; phone: string; address: string; dogName: string; dogBreed: string; dogAge: string } ) { pendingClients = pendingClients.map((client) => client.email === originalEmail @@ -630,6 +1032,8 @@ phone: updated.phone, address: updated.address, dogName: updated.dogName, + dogBreed: updated.dogBreed, + dogAge: updated.dogAge, } : client ); @@ -643,6 +1047,8 @@ phone: updated.phone, address: updated.address, dogName: updated.dogName, + dogBreed: updated.dogBreed, + dogAge: updated.dogAge, } : client ); @@ -654,6 +1060,8 @@ email: updated.email, fullName: updated.fullName, dogName: updated.dogName, + dogBreed: updated.dogBreed, + dogAge: updated.dogAge, } : client ); @@ -666,6 +1074,8 @@ const nextEmail = profileForm.email.trim().toLowerCase(); const fullName = profileForm.fullName.trim(); const dogName = profileForm.dogName.trim(); + const dogBreed = profileForm.dogBreed.trim(); + const dogAge = profileForm.dogAge.trim(); if (!fullName) { profileError = 'Please enter the owner name.'; return; @@ -698,6 +1108,8 @@ phone: profileForm.phone.trim(), address: profileForm.address.trim(), dogName, + dogBreed, + dogAge, }), }); const data = await res.json().catch(() => null); @@ -709,6 +1121,8 @@ phone: typeof data?.client?.phone === 'string' ? data.client.phone : profileForm.phone.trim(), address: typeof data?.client?.address === 'string' ? data.client.address : profileForm.address.trim(), dogName: typeof data?.client?.dogName === 'string' ? data.client.dogName : dogName, + dogBreed: typeof data?.client?.dogBreed === 'string' ? data.client.dogBreed : dogBreed, + dogAge: typeof data?.client?.dogAge === 'string' ? data.client.dogAge : dogAge, }; applyUpdatedClientLocally(originalEmail, updatedClient); @@ -727,7 +1141,7 @@ } function openAddClient() { - addClientForm = { email: '', fullName: '', phone: '', address: '', dogName: '', dogBreed: '', source: '', createInMyob: false }; + addClientForm = { email: '', fullName: '', phone: '', address: '', dogName: '', dogBreed: '', dogAge: '', source: '', createInMyob: false }; addClientError = ''; addClientSaving = false; addClientOpen = true; @@ -770,6 +1184,7 @@ address: addClientForm.address.trim(), dogName: addClientForm.dogName.trim(), dogBreed: addClientForm.dogBreed.trim(), + dogAge: addClientForm.dogAge.trim(), source: addClientForm.source.trim(), createInMyob: addClientForm.createInMyob, }), @@ -1047,7 +1462,7 @@ if (!res.ok) throw new Error(data?.detail?.message ?? data?.detail ?? 'Could not send message.'); } else { const { client } = confirmCtx; - birthdayBusyEmail = client.email; + birthdayBusyKey = birthdayActionKey(client.email, client.dogId); const res = await fetch('/api/owner/send-birthday-email', { method: 'POST', headers: { @@ -1056,6 +1471,7 @@ }, body: JSON.stringify({ email: client.email, + dogId: client.dogId, preview: confirmPreview, previewRecipients: confirmPreview ? previewRecipientList : [], }), @@ -1065,22 +1481,22 @@ if (!confirmPreview) { await fetchBirthdays(birthdaysPage); } - birthdayBusyEmail = ''; + birthdayBusyKey = ''; } confirmCtx = null; confirmPreview = false; } catch (error) { confirmError = error instanceof Error ? error.message : 'Could not send email.'; - birthdayBusyEmail = ''; + birthdayBusyKey = ''; } finally { confirmBusy = false; } } - function downloadBirthdayInvite(email: string) { + function downloadBirthdayInvite(email: string, dogId: string) { const token = getToken(); if (!token) return; - const url = `/api/owner/birthday-ics?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`; + const url = `/api/owner/birthday-ics?email=${encodeURIComponent(email)}&dogId=${encodeURIComponent(dogId)}&token=${encodeURIComponent(token)}`; void window.open(url, '_blank', 'noopener,noreferrer'); } @@ -1271,11 +1687,11 @@ openMenuEmail = ''; } - async function toggleBirthdayAutoSend(email: string, enabled: boolean) { + async function toggleBirthdayAutoSend(email: string, dogId: string, enabled: boolean) { const token = getToken(); if (!token) return; - birthdayBusyEmail = email; + birthdayBusyKey = birthdayActionKey(email, dogId); birthdayActionError = ''; try { const res = await fetch('/api/owner/birthday-auto-send', { @@ -1284,17 +1700,17 @@ 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, - body: JSON.stringify({ email, enabled }), + body: JSON.stringify({ email, dogId, enabled }), }); const data = await res.json().catch(() => null); if (!res.ok) throw new Error(data?.detail?.message ?? data?.detail ?? 'Could not update automatic birthday emails.'); birthdayClients = birthdayClients.map((item) => - item.email === email ? { ...item, birthdayAutoSend: enabled } : item + item.email === email && item.dogId === dogId ? { ...item, birthdayAutoSend: enabled } : item ); } catch (error) { birthdayActionError = error instanceof Error ? error.message : 'Could not update automatic birthday emails.'; } finally { - birthdayBusyEmail = ''; + birthdayBusyKey = ''; } } @@ -1313,6 +1729,8 @@ if (!messageTemplates.length) void fetchMessageTemplates(); } else if (activeTab === 'scheduled') { void fetchScheduled(); + } else if (activeTab === 'email') { + void fetchResendOverview(); } else if (activeTab === 'activity') { void fetchActivity(); } @@ -1422,6 +1840,28 @@ } } + async function fetchResendOverview() { + const token = getToken(); + if (!token) return; + resendLoading = true; + resendError = ''; + try { + const res = await fetch('/api/owner/resend-overview?limit=50', { + headers: { Authorization: `Bearer ${token}` }, + }); + const json = await res.json().catch(() => null); + if (!res.ok) { + throw new Error(json?.detail?.message ?? json?.detail ?? `Resend activity failed (${res.status}).`); + } + resendEmails = Array.isArray(json?.emails) ? json.emails : []; + resendLogs = Array.isArray(json?.logs) ? json.logs : []; + } catch (error) { + resendError = error instanceof Error ? error.message : 'Could not load Resend activity.'; + } finally { + resendLoading = false; + } + } + function formatEventTime(iso: string | null): string { if (!iso) return '—'; try { @@ -1439,6 +1879,35 @@ .join(' · '); } + function formatRecipientList(recipients: string[]): string { + if (!Array.isArray(recipients) || recipients.length === 0) return 'No recipients'; + if (recipients.length === 1) return recipients[0]; + return `${recipients[0]} +${recipients.length - 1}`; + } + + function formatResendEventLabel(value: string | null): string { + if (!value) return 'Queued'; + return value + .split('_') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); + } + + function resendEventTone(value: string | null): 'good' | 'warn' | 'neutral' { + const normalized = (value || '').toLowerCase(); + if (['delivered', 'opened', 'clicked', 'sent'].includes(normalized)) return 'good'; + if (['bounced', 'complained', 'failed', 'canceled'].includes(normalized)) return 'warn'; + return 'neutral'; + } + + function formatResponseStatus(value: number | null): string { + return typeof value === 'number' ? String(value) : '—'; + } + + $: deliveredCount = resendEmails.filter((item) => ['delivered', 'opened', 'clicked'].includes((item.lastEvent || '').toLowerCase())).length; + $: attentionCount = resendEmails.filter((item) => ['bounced', 'complained', 'failed', 'canceled'].includes((item.lastEvent || '').toLowerCase())).length; + $: queuedCount = resendEmails.filter((item) => !item.lastEvent || (item.lastEvent || '').toLowerCase() === 'queued').length; + type MessageTemplate = { id: string; name: string; @@ -1691,15 +2160,19 @@ }, birthdays: { title: 'Birthdays', - description: 'Manual sends, automatic toggles, and birthday invite downloads.', + description: 'Client birthdays, automatic send settings, and birthday invite downloads.', }, messaging: { title: 'Messaging', - description: 'Send themed bulk or individual emails to clients via BCC.', + description: 'Send bulk or individual client emails without leaving the Clients section.', }, scheduled: { - title: 'Scheduled', - description: 'Welcome emails queued to send automatically at a later time.', + title: 'Scheduled messages', + description: 'Queued client emails waiting to send automatically at a later time.', + }, + email: { + title: 'Email', + description: 'Live Resend delivery status and recent API activity for outbound email.', }, activity: { title: 'Activity', @@ -1708,6 +2181,20 @@ } as const; $: header = tabHeaders[activeTab]; + $: headerStats = + activeTab === 'email' + ? [ + { label: 'Delivered', value: String(deliveredCount), tone: 'good' }, + { label: 'Needs attention', value: String(attentionCount), tone: attentionCount > 0 ? 'warn' : 'neutral' }, + { label: 'Queued', value: String(queuedCount), tone: 'neutral' }, + ] + : activeTab === 'home' + ? [ + { label: 'Pending clients', value: String(pendingClients.length), tone: 'neutral' }, + { label: 'All clients', value: String(allClientsTotal || allClients.length), tone: 'neutral' }, + { label: 'Upcoming birthdays', value: String(birthdaysTotal || birthdayClients.length), tone: 'neutral' }, + ] + : []; onMount(async () => { await checkAuth(); @@ -1730,43 +2217,39 @@
{#if isAuthenticated && !accessDenied}
+ {#if isAuthenticated && !accessDenied && isClientSection(activeTab)} +
+
+ +
+
+ {/if}
{#if authChecking} @@ -1792,6 +2293,23 @@ {:else}
+
+
+ Goodwalk control panel +

{header.title}

+

{header.description}

+
+ {#if headerStats.length} +
+ {#each headerStats as stat} +
+ {stat.value} + {stat.label} +
+ {/each} +
+ {/if} +
{#if accessDenied}

Owner access only.

@@ -1874,11 +2392,17 @@ Client

{client.fullName || client.email}

-

{client.dogName || 'No dog name saved'}{#if client.dogBreed} · {client.dogBreed}{/if}

+

{clientDogHeadline(client)}

{client.welcomePackSentAt ? 'Welcome sent' : 'Needs welcome'} + {#if clientBirthdaySummary(client)} + {clientBirthdaySummary(client)} + {/if} + {#if clientDogsFor(client).length > 1} + {clientDogsFor(client).length} dogs + {/if} {#if client.lastEnquiryAt} Enquired {formatDateLabel(client.lastEnquiryAt)} {/if} @@ -1917,6 +2441,10 @@ Edit details +
@@ -2048,6 +2582,10 @@ Edit details + + +
Page {birthdaysPage} of {birthdaysTotalPages}
@@ -2126,7 +2678,7 @@ {:else if !birthdayClients.length}

No birthdays available.

-

Completed onboarding clients with valid dog birth dates will appear here.

+

Completed onboarding clients with valid dog birth dates will appear here. You can add one by editing a client or creating a client manually.

{:else}
@@ -2170,8 +2722,8 @@ toggleBirthdayAutoSend(client.email, (event.currentTarget as HTMLInputElement).checked)} + disabled={birthdayBusyKey === birthdayActionKey(client.email, client.dogId)} + on:change={(event) => toggleBirthdayAutoSend(client.email, client.dogId, (event.currentTarget as HTMLInputElement).checked)} /> {client.birthdayAutoSend ? 'On' : 'Off'} @@ -2180,13 +2732,17 @@
Actions - - +
@@ -2546,30 +3102,119 @@ {/if}
+ {:else if activeTab === 'email'} +
+
+
+

Resend overview

+ +
+

Live status comes from Resend itself, so this is the quickest way to check whether outbound messages were sent, delivered, opened, or need attention.

+ {#if resendError} +

{resendError}

+ {/if} +
+
+ {deliveredCount} + Delivered or opened +
+
0 ? 'warn' : 'neutral'}> + {attentionCount} + Needs attention +
+
+ {queuedCount} + Queued or waiting +
+
+
+ +
+
+
+

Sent emails

+

Latest Resend email records and delivery state.

+
+
+ {#if resendLoading && !resendEmails.length} +

Loading…

+ {:else if !resendEmails.length} +

No Resend email history available yet.

+ {:else} +
+ {#each resendEmails as item (item.id)} +
+
+
+

{item.subject || 'Untitled email'}

+

{formatRecipientList(item.to)}

+
+ + {formatResendEventLabel(item.lastEvent)} + +
+
+
Sent
{formatEventTime(item.createdAt)}
+
From
{item.from || '—'}
+
Message ID
{item.id}
+
+
+ {/each} +
+ {/if} +
+ +
+
+
+

API request logs

+

Recent Resend API requests related to email sending.

+
+
+ {#if resendLoading && !resendLogs.length} +

Loading…

+ {:else if !resendLogs.length} +

No Resend API logs available yet.

+ {:else} +
+ {#each resendLogs as item (item.id)} +
+
+ {item.method || 'GET'} + {item.endpoint || '—'} + = 400}> + {formatResponseStatus(item.responseStatus)} + +
+
+ {formatEventTime(item.createdAt)} + {item.userAgent || 'Unknown client'} +
+
+ {/each} +
+ {/if} +
+
{/if}
{/if} + {#if dogManagerOpen} + + {/if} + + {#if dogClientPickerOpen} + + {/if} + {#if enquiryOpen}
- + - + - - - - - - - - +{#if isMeetGreetVariant} + + + +{:else} + + + + + + + +{/if}