v4.0.6 - Simplicity focus

This commit is contained in:
2026-07-04 09:59:57 +12:00
parent bfd96c677f
commit 8eb20813c8
21 changed files with 2410 additions and 5221 deletions
+72 -72
View File
@@ -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"
],
+14
View File
@@ -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
+408 -56
View File
@@ -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}