v4.0.6 - Simplicity focus
This commit is contained in:
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user