v4.0.6 0 Simplicity focus
This commit is contained in:
+541
-42
@@ -2,6 +2,8 @@ import asyncio
|
||||
import base64
|
||||
from collections import deque
|
||||
from contextlib import asynccontextmanager
|
||||
import html
|
||||
from html.parser import HTMLParser
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
@@ -71,6 +73,7 @@ from mail_api.models import (
|
||||
BookingSubmission,
|
||||
ClientStatusUpdate,
|
||||
ContractSubmission,
|
||||
DeleteClientRequest,
|
||||
NewClientRequest,
|
||||
OnboardingSubmission,
|
||||
RenderMessageRequest,
|
||||
@@ -648,6 +651,53 @@ async def _update_client_profile(
|
||||
return merged
|
||||
|
||||
|
||||
async def _delete_client_profile(email: str) -> dict[str, Any]:
|
||||
"""Permanently remove a client: their profile, saved draft, any pending login
|
||||
codes/sessions, their allowed-email entry (so they can no longer sign in),
|
||||
and any welcome emails still queued to send to them. The business owner's own
|
||||
account can never be deleted."""
|
||||
normalized = email.strip().lower()
|
||||
if not normalized:
|
||||
raise HTTPException(status_code=400, detail="A valid email is required.")
|
||||
if normalized == OWNER_EMAIL.strip().lower():
|
||||
raise HTTPException(status_code=400, detail="The business owner account cannot be deleted.")
|
||||
|
||||
async with _auth_lock:
|
||||
if normalized not in _client_profiles:
|
||||
raise HTTPException(status_code=404, detail="Client not found.")
|
||||
|
||||
removed = _client_profiles.pop(normalized, {})
|
||||
_drafts.pop(normalized, None)
|
||||
_pending_codes.pop(normalized, None)
|
||||
_code_requests.pop(normalized, None)
|
||||
for token in [t for t, s in _active_sessions.items() if isinstance(s, dict) and s.get("email") == normalized]:
|
||||
_active_sessions.pop(token, None)
|
||||
_allowed_emails.discard(normalized)
|
||||
|
||||
profiles_snapshot = dict(_client_profiles)
|
||||
drafts_snapshot = dict(_drafts)
|
||||
allowed_emails_snapshot = sorted(_allowed_emails)
|
||||
|
||||
await asyncio.to_thread(_save_client_profiles_file, profiles_snapshot)
|
||||
await asyncio.to_thread(_save_drafts_file, drafts_snapshot)
|
||||
await asyncio.to_thread(_save_allowed_emails_file, set(allowed_emails_snapshot))
|
||||
await _persist_admin_state("client_profiles", profiles_snapshot)
|
||||
await _persist_admin_state("drafts", drafts_snapshot)
|
||||
await _persist_admin_state("allowed_emails", {"emails": allowed_emails_snapshot})
|
||||
await _save_active_sessions_async()
|
||||
|
||||
# Drop any scheduled welcome emails still queued for this address.
|
||||
async with _scheduled_lock:
|
||||
stale = [eid for eid, entry in _scheduled_emails.items()
|
||||
if isinstance(entry, dict) and str(entry.get("email", "")).strip().lower() == normalized]
|
||||
for eid in stale:
|
||||
_scheduled_emails.pop(eid, None)
|
||||
if stale:
|
||||
await _persist_scheduled_emails()
|
||||
|
||||
return removed if isinstance(removed, dict) else {}
|
||||
|
||||
|
||||
async def _reset_client_onboarding(email: str) -> dict[str, Any]:
|
||||
"""Mark a client's onboarding as incomplete while keeping every saved detail.
|
||||
|
||||
@@ -2384,11 +2434,353 @@ def _format_date_label(value: str) -> str:
|
||||
return raw
|
||||
|
||||
|
||||
def _welcome_pack_email_html(client_name: str, dog_name: str, service_type: str, price_details: str, start_date: str) -> str:
|
||||
WELCOME_FONT_STACK = "-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif"
|
||||
WELCOME_CTA_DEFAULT_LABEL = "Complete onboarding"
|
||||
WELCOME_CTA_DEFAULT_URL = "https://clients.goodwalk.co.nz/"
|
||||
|
||||
# Canonical business contact details, kept in step with the public site footer
|
||||
# (src/lib/content/homepage.ts). These power the copyright + contact block at the
|
||||
# bottom of every email the owner sends.
|
||||
BUSINESS_EMAIL = "info@goodwalk.co.nz"
|
||||
BUSINESS_PHONE = "(022) 642 1011"
|
||||
BUSINESS_PHONE_TEL = "0226421011"
|
||||
|
||||
|
||||
def _email_footer_html(
|
||||
*,
|
||||
text_color: str = "#ffffff",
|
||||
link_color: str = "#ffffff",
|
||||
font_stack: str = WELCOME_FONT_STACK,
|
||||
) -> str:
|
||||
"""Shared email footer: a simple copyright notice plus the business contact
|
||||
details. White text by default so it reads cleanly on the dark-green band."""
|
||||
year = datetime.now().year
|
||||
return (
|
||||
f'<div style="font-family:{font_stack};font-size:12px;line-height:1.7;color:{text_color};">'
|
||||
f'© {year} Goodwalk<br>'
|
||||
f'<a href="mailto:{BUSINESS_EMAIL}" style="color:{link_color};text-decoration:none;">{BUSINESS_EMAIL}</a>'
|
||||
f' · '
|
||||
f'<a href="tel:{BUSINESS_PHONE_TEL}" style="color:{link_color};text-decoration:none;">{BUSINESS_PHONE}</a>'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
|
||||
def _apply_welcome_tokens(text: str, first_name: str, dog_name: str) -> str:
|
||||
"""Substitute the personalisation tokens the owner can use in custom copy."""
|
||||
dog = dog_name or "your dog"
|
||||
return (
|
||||
text.replace("{first_name}", first_name)
|
||||
.replace("{firstName}", first_name)
|
||||
.replace("{dog_name}", dog)
|
||||
.replace("{dogName}", dog)
|
||||
.replace("{dog}", dog)
|
||||
)
|
||||
|
||||
|
||||
def _welcome_paragraphs(text: str, color: str) -> str:
|
||||
"""Render owner body text as one or more email-safe paragraphs."""
|
||||
parts = [p.strip() for p in (text or "").split("\n\n") if p.strip()]
|
||||
return "".join(
|
||||
f'<p style="margin:0 0 18px;font-family:{WELCOME_FONT_STACK};font-size:15px;'
|
||||
f'line-height:1.7;color:{color};">{_html_breaks(para)}</p>'
|
||||
for para in parts
|
||||
)
|
||||
|
||||
|
||||
def _html_breaks(text: str) -> str:
|
||||
"""Convert single newlines within a paragraph to <br> (text is already trusted owner copy)."""
|
||||
return text.replace("\n", "<br>")
|
||||
|
||||
|
||||
# Inline styles applied to the whitelisted tags the WYSIWYG editor can produce,
|
||||
# so the owner's message renders consistently across email clients.
|
||||
_WELCOME_BODY_STYLES = {
|
||||
"p": f"margin:0 0 16px;font-family:{WELCOME_FONT_STACK};font-size:15px;line-height:1.7;color:#4b584b;",
|
||||
"h1": f"margin:0 0 12px;font-family:{WELCOME_FONT_STACK};font-size:26px;line-height:1.2;letter-spacing:-0.02em;font-weight:700;color:#171b20;",
|
||||
"h2": f"margin:20px 0 10px;font-family:{WELCOME_FONT_STACK};font-size:20px;line-height:1.25;letter-spacing:-0.01em;font-weight:700;color:#171b20;",
|
||||
"h3": f"margin:18px 0 8px;font-family:{WELCOME_FONT_STACK};font-size:16px;line-height:1.3;font-weight:700;color:#213021;",
|
||||
"ul": "margin:0 0 16px;padding-left:22px;",
|
||||
"ol": "margin:0 0 16px;padding-left:22px;",
|
||||
"li": f"margin:0 0 6px;font-family:{WELCOME_FONT_STACK};font-size:15px;line-height:1.6;color:#4b584b;",
|
||||
"strong": "font-weight:700;color:#171b20;",
|
||||
"em": "font-style:italic;",
|
||||
"u": "text-decoration:underline;",
|
||||
"a": "color:#213021;text-decoration:underline;",
|
||||
}
|
||||
# Tags browsers emit that we fold onto our canonical equivalents.
|
||||
_WELCOME_TAG_ALIASES = {"b": "strong", "i": "em", "div": "p"}
|
||||
# Block tags that establish a paragraph context (so stray text gets wrapped).
|
||||
_WELCOME_BLOCK_TAGS = {"p", "li", "h1", "h2", "h3"}
|
||||
|
||||
|
||||
class _WelcomeBodySanitizer(HTMLParser):
|
||||
"""Reduce the contenteditable HTML the owner produces to a small, email-safe
|
||||
subset (paragraphs, bold/italic/underline, lists and http(s)/mailto links),
|
||||
re-emitting every tag with our inline styles and dropping everything else."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.parts: list[str] = []
|
||||
self.open_tags: list[str] = []
|
||||
|
||||
def _in_block(self) -> bool:
|
||||
return any(t in _WELCOME_BLOCK_TAGS for t in self.open_tags)
|
||||
|
||||
def _in_list(self) -> bool:
|
||||
return any(t in ("ul", "ol") for t in self.open_tags)
|
||||
|
||||
def _emit_open(self, tag: str) -> None:
|
||||
style = _WELCOME_BODY_STYLES.get(tag, "")
|
||||
self.parts.append(f'<{tag} style="{style}">' if style else f"<{tag}>")
|
||||
self.open_tags.append(tag)
|
||||
|
||||
def _emit_close(self, tag: str) -> None:
|
||||
if tag not in self.open_tags:
|
||||
return
|
||||
while self.open_tags:
|
||||
current = self.open_tags.pop()
|
||||
self.parts.append(f"</{current}>")
|
||||
if current == tag:
|
||||
break
|
||||
|
||||
def _ensure_paragraph(self) -> None:
|
||||
if not self._in_block() and not self._in_list():
|
||||
self._emit_open("p")
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
tag = _WELCOME_TAG_ALIASES.get(tag, tag)
|
||||
if tag == "br":
|
||||
self._ensure_paragraph()
|
||||
self.parts.append("<br>")
|
||||
return
|
||||
if tag in ("p", "ul", "ol", "h1", "h2", "h3"):
|
||||
# A new block ends any paragraph/heading we were implicitly inside.
|
||||
if self._in_block():
|
||||
for candidate in ("p", "li", "h1", "h2", "h3"):
|
||||
if candidate in self.open_tags:
|
||||
self._emit_close(candidate)
|
||||
break
|
||||
self._emit_open(tag)
|
||||
return
|
||||
if tag == "li":
|
||||
self._emit_open("li")
|
||||
return
|
||||
if tag in ("strong", "em", "u"):
|
||||
self._ensure_paragraph()
|
||||
self._emit_open(tag)
|
||||
return
|
||||
if tag == "a":
|
||||
href = ""
|
||||
for name, value in attrs:
|
||||
if name == "href" and value:
|
||||
href = value.strip()
|
||||
if not re.match(r"^(https?:|mailto:)", href, re.IGNORECASE):
|
||||
return # drop unsafe/relative links, keep their text
|
||||
self._ensure_paragraph()
|
||||
self.parts.append(f'<a href="{_escape_attr(href)}" style="{_WELCOME_BODY_STYLES["a"]}">')
|
||||
self.open_tags.append("a")
|
||||
return
|
||||
# Any other tag (span, font, h1…): ignored, but its text is kept.
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
tag = _WELCOME_TAG_ALIASES.get(tag, tag)
|
||||
if tag in ("p", "ul", "ol", "li", "strong", "em", "u", "a", "h1", "h2", "h3"):
|
||||
self._emit_close(tag)
|
||||
|
||||
def handle_data(self, data):
|
||||
if not data.strip() and not self._in_block():
|
||||
return # ignore whitespace between block tags
|
||||
self._ensure_paragraph()
|
||||
self.parts.append(
|
||||
data.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
)
|
||||
|
||||
def result(self) -> str:
|
||||
while self.open_tags:
|
||||
self.parts.append(f"</{self.open_tags.pop()}>")
|
||||
html = "".join(self.parts)
|
||||
# Drop empty paragraphs the editor leaves behind (e.g. trailing blank line).
|
||||
html = re.sub(r"<p[^>]*>(?:\s|<br>| )*</p>", "", html)
|
||||
return html.strip()
|
||||
|
||||
|
||||
def _sanitize_welcome_body_html(raw: str) -> str:
|
||||
"""Public entry point: parse and re-emit owner rich-text as email-safe HTML."""
|
||||
if not (raw or "").strip():
|
||||
return ""
|
||||
parser = _WelcomeBodySanitizer()
|
||||
parser.feed(raw)
|
||||
parser.close()
|
||||
return parser.result()
|
||||
|
||||
|
||||
def _strip_legacy_welcome_tag_html(raw: str) -> str:
|
||||
"""Remove the old frontend-injected yellow tag block if it was saved into
|
||||
bodyHtml before tagLabel became a first-class server-rendered field."""
|
||||
if not (raw or "").strip():
|
||||
return ""
|
||||
return re.sub(
|
||||
r'^\s*<div[^>]*>\s*<span[^>]*background:\s*#f4e7a8;[^>]*>.*?</span>\s*</div>\s*',
|
||||
'',
|
||||
raw,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
).strip()
|
||||
|
||||
|
||||
def _welcome_cta_html(label: str, url: str) -> str:
|
||||
"""The dark-green pill button used at the foot of the welcome email."""
|
||||
label = (label or "").strip()
|
||||
url = (url or "").strip()
|
||||
if not (label and url):
|
||||
return ""
|
||||
safe_label = label.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
return (
|
||||
f'<a href="{_escape_attr(url)}" style="display:inline-block;background:#213021;color:#ffffff;'
|
||||
"text-decoration:none;border-radius:999px;padding:14px 20px;font-family:"
|
||||
f'{WELCOME_FONT_STACK};font-size:15px;font-weight:700;">{safe_label}</a>'
|
||||
)
|
||||
|
||||
|
||||
def _welcome_pack_email_html(
|
||||
client_name: str,
|
||||
dog_name: str,
|
||||
service_type: str,
|
||||
price_details: str,
|
||||
start_date: str,
|
||||
*,
|
||||
heading: str = "",
|
||||
intro: str = "",
|
||||
outro: str = "",
|
||||
tag_label: str = "",
|
||||
cta_label: str = WELCOME_CTA_DEFAULT_LABEL,
|
||||
cta_url: str = WELCOME_CTA_DEFAULT_URL,
|
||||
show_details: bool = True,
|
||||
body_html: str = "",
|
||||
include_button: bool = True,
|
||||
) -> str:
|
||||
first_name = client_name.split()[0] if client_name.strip() else "there"
|
||||
dog_line = f" for {dog_name}" if dog_name.strip() else ""
|
||||
formatted_start_date = _format_date_label(start_date)
|
||||
|
||||
# Preferred path: the owner wrote a free-form message in the WYSIWYG editor.
|
||||
# We sanitise it, substitute the personalisation tokens, and (optionally) add
|
||||
# the onboarding button beneath it — no forced heading or details table.
|
||||
clean_body = _sanitize_welcome_body_html(_strip_legacy_welcome_tag_html(body_html))
|
||||
if clean_body:
|
||||
clean_body = _apply_welcome_tokens(clean_body, first_name, dog_name)
|
||||
# The "Badge" field drives the single header badge in the shell — it is not a
|
||||
# second in-body pill, so the email never shows the badge text twice.
|
||||
badge_label = _apply_welcome_tokens((tag_label or "").strip(), first_name, dog_name)
|
||||
cta_html = _welcome_cta_html(WELCOME_CTA_DEFAULT_LABEL, WELCOME_CTA_DEFAULT_URL) if include_button else ""
|
||||
# Only add a top margin when there's a badge to sit beneath; otherwise the
|
||||
# cell's own top padding is enough and a top margin would leave an empty
|
||||
# gap where the badge would have been.
|
||||
body_top_margin = "16px" if badge_label else "0"
|
||||
content_inner = f'<div style="margin:{body_top_margin} 0 22px;">{clean_body}</div>{cta_html}'
|
||||
return _welcome_pack_shell(content_inner, tag_label=badge_label)
|
||||
|
||||
# No message written and no legacy structured copy supplied: render just the
|
||||
# email chrome (logo + badge) so the live preview stays genuinely empty until
|
||||
# the owner writes something, rather than falling back to canned default copy.
|
||||
if not any(((heading or "").strip(), (intro or "").strip(), (outro or "").strip())):
|
||||
badge_label = _apply_welcome_tokens((tag_label or "").strip(), first_name, dog_name)
|
||||
return _welcome_pack_shell("", tag_label=badge_label)
|
||||
|
||||
# Legacy structured path: each block falls back to the original default copy
|
||||
# when blank, so older callers that pass heading/intro/outro look unchanged.
|
||||
heading_text = _apply_welcome_tokens(
|
||||
(heading or "").strip()
|
||||
or f"Hi {first_name}, we’d love to get {dog_name or 'your dog'} started with Goodwalk.",
|
||||
first_name,
|
||||
dog_name,
|
||||
)
|
||||
intro_text = _apply_welcome_tokens(
|
||||
(intro or "").strip()
|
||||
or (
|
||||
f"We’ve set aside the details below{dog_line}. When you’re ready, "
|
||||
"complete your onboarding form and we’ll take it from there."
|
||||
),
|
||||
first_name,
|
||||
dog_name,
|
||||
)
|
||||
outro_text = _apply_welcome_tokens(
|
||||
(outro or "").strip()
|
||||
or (
|
||||
"Use the same email address you originally used with Goodwalk. "
|
||||
"We’ll send you a one-time code when you sign in."
|
||||
),
|
||||
first_name,
|
||||
dog_name,
|
||||
)
|
||||
|
||||
intro_html = _welcome_paragraphs(intro_text, "#4b584b")
|
||||
outro_html = ""
|
||||
if outro_text:
|
||||
outro_html = (
|
||||
f'<p style="margin:18px 0 0;font-family:{WELCOME_FONT_STACK};font-size:14px;'
|
||||
f'line-height:1.7;color:#657365;">{_html_breaks(outro_text)}</p>'
|
||||
)
|
||||
|
||||
# The details table is optional — when hidden, or when every row is blank, it
|
||||
# is dropped entirely so a free-text email reads cleanly.
|
||||
details_rows = ""
|
||||
if show_details:
|
||||
details_rows = (
|
||||
_detail_row("Service", service_type)
|
||||
+ _detail_row("Price", price_details)
|
||||
+ _detail_row("Start date", formatted_start_date if start_date.strip() else "")
|
||||
)
|
||||
details_html = ""
|
||||
if details_rows:
|
||||
details_html = f"""
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;border-radius:18px;border:1px solid rgba(33,48,33,0.08);margin-bottom:22px;">
|
||||
<tr><td style="padding:22px 20px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
{details_rows}
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>"""
|
||||
|
||||
# The button block keeps the welcome email's dark-green pill styling but lets
|
||||
# the owner relabel it and point it anywhere. Clearing either field hides it.
|
||||
# The standard default is materialised by callers (see _welcome_pack_payload),
|
||||
# so an explicit blank here genuinely means "no button".
|
||||
label = (cta_label or "").strip()
|
||||
url = (cta_url or "").strip()
|
||||
cta_html = ""
|
||||
if label and url:
|
||||
safe_url = _escape_attr(url)
|
||||
safe_label = label.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
cta_html = (
|
||||
f'<a href="{safe_url}" style="display:inline-block;background:#213021;color:#ffffff;'
|
||||
"text-decoration:none;border-radius:999px;padding:14px 20px;font-family:"
|
||||
f'{WELCOME_FONT_STACK};font-size:15px;font-weight:700;">{safe_label}</a>'
|
||||
)
|
||||
|
||||
# The legacy structured path never renders a header badge, so the heading
|
||||
# has no top margin — the cell's top padding alone sets the spacing.
|
||||
heading_html = (
|
||||
f'<h1 style="margin:0 0 12px;font-family:{WELCOME_FONT_STACK};font-size:32px;'
|
||||
f'line-height:1.05;letter-spacing:-0.03em;color:#171b20;">{heading_text}</h1>'
|
||||
)
|
||||
content_inner = f"{heading_html}{intro_html}{details_html}{cta_html}{outro_html}"
|
||||
return _welcome_pack_shell(content_inner)
|
||||
|
||||
|
||||
def _welcome_pack_shell(content_inner: str, *, tag_label: str = "") -> str:
|
||||
"""The shared welcome-email chrome (logo header, optional header badge, rounded
|
||||
card) wrapped around whatever message content is supplied. The badge text is
|
||||
owner-editable via the "Badge" field; when it is left blank, no badge is shown.
|
||||
This is the email's only tag — there is no second in-body pill."""
|
||||
badge_label = (tag_label or "").strip()
|
||||
badge_html = (
|
||||
'<div style="display:inline-block;background:#ffd100;border-radius:999px;'
|
||||
f'padding:8px 14px;margin-bottom:4px;font-family:{WELCOME_FONT_STACK};font-size:12px;'
|
||||
'font-weight:700;color:#213021;letter-spacing:0.06em;text-transform:uppercase;">'
|
||||
f'{html.escape(badge_label)}</div>'
|
||||
if badge_label
|
||||
else ""
|
||||
)
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -2407,33 +2799,13 @@ def _welcome_pack_email_html(client_name: str, dog_name: str, service_type: str,
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background:#fbfaf7;padding:34px 24px 30px;">
|
||||
<div style="display:inline-block;background:#ffd100;border-radius:999px;padding:8px 14px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:12px;font-weight:700;color:#213021;letter-spacing:0.06em;text-transform:uppercase;">
|
||||
Welcome to the pack
|
||||
</div>
|
||||
<h1 style="margin:18px 0 12px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:32px;line-height:1.05;letter-spacing:-0.03em;color:#171b20;">
|
||||
Hi {first_name}, we’d love to get {dog_name or 'your dog'} started with Goodwalk.
|
||||
</h1>
|
||||
<p style="margin:0 0 20px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:15px;line-height:1.7;color:#4b584b;">
|
||||
We’ve set aside the details below{dog_line}. When you’re ready, complete your onboarding form and we’ll take it from there.
|
||||
</p>
|
||||
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;border-radius:18px;border:1px solid rgba(33,48,33,0.08);margin-bottom:22px;">
|
||||
<tr><td style="padding:22px 20px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
{_detail_row("Service", service_type)}
|
||||
{_detail_row("Price", price_details)}
|
||||
{_detail_row("Start date", formatted_start_date)}
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
<a href="https://clients.goodwalk.co.nz/" style="display:inline-block;background:#213021;color:#ffffff;text-decoration:none;border-radius:999px;padding:14px 20px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:15px;font-weight:700;">
|
||||
Complete onboarding
|
||||
</a>
|
||||
|
||||
<p style="margin:18px 0 0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;line-height:1.7;color:#657365;">
|
||||
Use the same email address you originally used with Goodwalk. We’ll send you a one-time code when you sign in.
|
||||
</p>
|
||||
{badge_html}
|
||||
{content_inner}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background:#213021;padding:22px 24px;text-align:center;">
|
||||
{_email_footer_html()}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -2575,6 +2947,11 @@ def _birthday_email_html(client_name: str, dog_name: str) -> str:
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background:#213021;padding:22px 24px;text-align:center;">
|
||||
{_email_footer_html()}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
@@ -2635,6 +3012,7 @@ async def _send_birthday_email_for_profile(
|
||||
mark_auto_year: int | None = None,
|
||||
preview: bool = False,
|
||||
preview_recipients: list[str] | None = None,
|
||||
subject: str = "",
|
||||
) -> None:
|
||||
client_name = str(profile.get("fullName", "")).strip()
|
||||
dog = _get_selected_dog(profile, dog_id)
|
||||
@@ -2642,7 +3020,7 @@ async def _send_birthday_email_for_profile(
|
||||
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'}"
|
||||
subject = (subject or "").strip() or f"Happy birthday {dog_name or 'from Goodwalk'}"
|
||||
if preview:
|
||||
subject = f"[PREVIEW for {client_name or email}] {subject}"
|
||||
payload = {
|
||||
@@ -3346,7 +3724,8 @@ def _render_message_html(
|
||||
<tr>
|
||||
<td align="center" style="background:{footer_bg};padding:22px 24px 18px;font-family:{font_stack};font-size:12px;line-height:1.6;color:{footer_text_color};">
|
||||
<div style="font-size:14px;letter-spacing:0.3em;margin-bottom:8px;color:{footer_text_color};">{ornament_bottom or '🐾 · 🐾 · 🐾'}</div>
|
||||
{('<div style="font-weight:600;">' + fn + '</div>') if fn else ''}
|
||||
{('<div style="font-weight:600;margin-bottom:10px;">' + fn + '</div>') if fn else ''}
|
||||
{_email_footer_html(text_color=footer_text_color, link_color=footer_text_color, font_stack=font_stack)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -3415,12 +3794,22 @@ async def owner_render_welcome_pack(data: WelcomePackEmailRequest, request: Requ
|
||||
owner_name = str(profile.get("fullName", "")).strip()
|
||||
dog_name = str(profile.get("dogName", "")).strip()
|
||||
|
||||
custom = _welcome_custom_fields(data)
|
||||
html = _welcome_pack_email_html(
|
||||
owner_name,
|
||||
dog_name,
|
||||
_trimmed(data.serviceType),
|
||||
_trimmed(data.priceDetails),
|
||||
_trimmed(data.startDate),
|
||||
heading=str(custom["heading"]),
|
||||
intro=str(custom["intro"]),
|
||||
outro=str(custom["outro"]),
|
||||
tag_label=str(custom["tagLabel"]),
|
||||
cta_label=str(custom["ctaLabel"]),
|
||||
cta_url=str(custom["ctaUrl"]),
|
||||
show_details=bool(custom["showDetails"]),
|
||||
body_html=str(custom["bodyHtml"]),
|
||||
include_button=bool(custom["includeButton"]),
|
||||
)
|
||||
return {"ok": True, "html": html}
|
||||
|
||||
@@ -4030,6 +4419,31 @@ async def owner_client_profile(data: ClientProfileUpdate, request: Request):
|
||||
}
|
||||
|
||||
|
||||
@app.post("/owner/delete-client")
|
||||
async def owner_delete_client(data: DeleteClientRequest, request: Request):
|
||||
"""Permanently delete a client and revoke their access to the onboarding form."""
|
||||
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
|
||||
owner_email = await _require_owner_email(request)
|
||||
|
||||
email = str(data.email).strip().lower()
|
||||
removed = await _delete_client_profile(email)
|
||||
|
||||
await admin_db.record_event(
|
||||
event_type="owner_client_deleted",
|
||||
request_id=request_id,
|
||||
actor_email=owner_email,
|
||||
ip=_get_ip(request),
|
||||
status="ok",
|
||||
detail={
|
||||
"email": email,
|
||||
"fullName": removed.get("fullName", "") if isinstance(removed, dict) else "",
|
||||
"dogName": removed.get("dogName", "") if isinstance(removed, dict) else "",
|
||||
},
|
||||
)
|
||||
|
||||
return {"ok": True, "email": email}
|
||||
|
||||
|
||||
@app.post("/owner/add-client")
|
||||
async def owner_add_client(data: NewClientRequest, request: Request):
|
||||
"""Create a client by hand (leads from Instagram / Facebook / referrals that
|
||||
@@ -4046,6 +4460,7 @@ async def owner_add_client(data: NewClientRequest, request: Request):
|
||||
dog_breed = str(data.dogBreed).strip()
|
||||
dog_age = str(data.dogAge).strip()
|
||||
source = str(data.source).strip()
|
||||
joining_date = str(data.joiningDate).strip()
|
||||
|
||||
if not full_name:
|
||||
raise HTTPException(status_code=400, detail="Please enter the owner name.")
|
||||
@@ -4067,6 +4482,9 @@ async def owner_add_client(data: NewClientRequest, request: Request):
|
||||
"dogBreed": dog_breed,
|
||||
"dogAge": dog_age,
|
||||
"source": source,
|
||||
# Owner-supplied date the client joined Goodwalk (falls back to the
|
||||
# add timestamp). Drives the joining-anniversaries view.
|
||||
"joiningDate": joining_date or added_at[:10],
|
||||
# Treated like an enquiry timestamp so the client surfaces in the
|
||||
# pending-onboarding and recent-clients views straight away.
|
||||
"lastEnquiryAt": added_at,
|
||||
@@ -4352,6 +4770,22 @@ def _default_welcome_subject(dog_name: str) -> str:
|
||||
return f"Goodwalk Onboarding - {name}" if name else "Goodwalk Onboarding"
|
||||
|
||||
|
||||
def _welcome_custom_fields(data: WelcomePackEmailRequest) -> dict:
|
||||
"""Pull the owner's customisation fields off the request into a plain dict
|
||||
that travels with the offer (live send, preview, and scheduled queue)."""
|
||||
return {
|
||||
"tagLabel": _trimmed(data.tagLabel),
|
||||
"heading": _trimmed(data.heading),
|
||||
"intro": _trimmed(data.intro),
|
||||
"outro": _trimmed(data.outro),
|
||||
"ctaLabel": _trimmed(data.ctaLabel),
|
||||
"ctaUrl": _trimmed(data.ctaUrl),
|
||||
"showDetails": bool(data.showDetails),
|
||||
"bodyHtml": _trimmed(data.bodyHtml),
|
||||
"includeButton": bool(data.includeButton),
|
||||
}
|
||||
|
||||
|
||||
def _welcome_pack_payload(
|
||||
email: str,
|
||||
owner_name: str,
|
||||
@@ -4363,22 +4797,45 @@ def _welcome_pack_payload(
|
||||
subject: str,
|
||||
is_preview: bool,
|
||||
recipients: list[str],
|
||||
custom: dict | None = None,
|
||||
cc: list[str] | None = None,
|
||||
bcc_extra: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Assemble the Resend payload for a welcome-pack email. Shared by the
|
||||
immediate send, the preview path, and the scheduled-email worker."""
|
||||
subject = (subject or "").strip() or _default_welcome_subject(dog_name)
|
||||
if is_preview:
|
||||
subject = f"[PREVIEW for {owner_name or email}] {subject}"
|
||||
custom = custom or {}
|
||||
payload = {
|
||||
"from": FROM_EMAIL,
|
||||
"to": recipients,
|
||||
"reply_to": REPLY_TO,
|
||||
"subject": subject,
|
||||
"html": _welcome_pack_email_html(owner_name, dog_name, service, price, start),
|
||||
"html": _welcome_pack_email_html(
|
||||
owner_name, dog_name, service, price, start,
|
||||
heading=str(custom.get("heading", "")),
|
||||
intro=str(custom.get("intro", "")),
|
||||
outro=str(custom.get("outro", "")),
|
||||
tag_label=str(custom.get("tagLabel", "")),
|
||||
# Missing keys (legacy scheduled entries) fall back to the standard
|
||||
# button; a present-but-empty value is an explicit "hide the button".
|
||||
cta_label=str(custom.get("ctaLabel", WELCOME_CTA_DEFAULT_LABEL)),
|
||||
cta_url=str(custom.get("ctaUrl", WELCOME_CTA_DEFAULT_URL)),
|
||||
show_details=bool(custom.get("showDetails", True)),
|
||||
body_html=str(custom.get("bodyHtml", "")),
|
||||
include_button=bool(custom.get("includeButton", True)),
|
||||
),
|
||||
}
|
||||
bcc = _client_bcc_list()
|
||||
bcc = list(_client_bcc_list())
|
||||
if bcc_extra and not is_preview:
|
||||
for addr in bcc_extra:
|
||||
if addr and addr not in bcc:
|
||||
bcc.append(addr)
|
||||
if bcc and not is_preview:
|
||||
payload["bcc"] = bcc
|
||||
if cc and not is_preview:
|
||||
payload["cc"] = cc
|
||||
return payload
|
||||
|
||||
|
||||
@@ -4390,6 +4847,9 @@ async def _deliver_welcome_pack(
|
||||
start: str,
|
||||
subject: str,
|
||||
request_id: str,
|
||||
custom: dict | None = None,
|
||||
cc: list[str] | None = None,
|
||||
bcc_extra: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Send the welcome pack to the client now and record it on the profile.
|
||||
Returns the sentAt timestamp. Raises on send failure (caller decides how to
|
||||
@@ -4397,10 +4857,11 @@ async def _deliver_welcome_pack(
|
||||
two paths can never drift apart."""
|
||||
owner_name = str(profile.get("fullName", "")).strip()
|
||||
dog_name = str(profile.get("dogName", "")).strip()
|
||||
custom = custom or {}
|
||||
sent_at = datetime.now().isoformat(timespec="seconds")
|
||||
payload = _welcome_pack_payload(
|
||||
email, owner_name, dog_name, service, price, start,
|
||||
subject=subject, is_preview=False, recipients=[email],
|
||||
subject=subject, is_preview=False, recipients=[email], custom=custom, cc=cc, bcc_extra=bcc_extra,
|
||||
)
|
||||
await _send_email(payload, label="welcome_pack_email", request_id=request_id)
|
||||
await _store_client_profile(email, {
|
||||
@@ -4409,6 +4870,16 @@ async def _deliver_welcome_pack(
|
||||
"serviceType": service,
|
||||
"priceDetails": price,
|
||||
"startDate": start,
|
||||
"subject": subject,
|
||||
"tagLabel": str(custom.get("tagLabel", "")),
|
||||
"heading": str(custom.get("heading", "")),
|
||||
"intro": str(custom.get("intro", "")),
|
||||
"outro": str(custom.get("outro", "")),
|
||||
"ctaLabel": str(custom.get("ctaLabel", "")),
|
||||
"ctaUrl": str(custom.get("ctaUrl", "")),
|
||||
"showDetails": bool(custom.get("showDetails", True)),
|
||||
"bodyHtml": str(custom.get("bodyHtml", "")),
|
||||
"includeButton": bool(custom.get("includeButton", True)),
|
||||
"sentAt": sent_at,
|
||||
},
|
||||
})
|
||||
@@ -4425,8 +4896,10 @@ async def _enqueue_scheduled_welcome(
|
||||
subject: str,
|
||||
scheduled_for: datetime,
|
||||
owner_email: str,
|
||||
custom: dict | None = None,
|
||||
) -> dict:
|
||||
"""Add a welcome email to the durable queue for later delivery."""
|
||||
custom = custom or {}
|
||||
entry_id = uuid.uuid4().hex
|
||||
entry = {
|
||||
"id": entry_id,
|
||||
@@ -4436,6 +4909,12 @@ async def _enqueue_scheduled_welcome(
|
||||
"serviceType": service,
|
||||
"priceDetails": price,
|
||||
"startDate": start,
|
||||
"heading": str(custom.get("heading", "")),
|
||||
"intro": str(custom.get("intro", "")),
|
||||
"outro": str(custom.get("outro", "")),
|
||||
"ctaLabel": str(custom.get("ctaLabel", "")),
|
||||
"ctaUrl": str(custom.get("ctaUrl", "")),
|
||||
"showDetails": bool(custom.get("showDetails", True)),
|
||||
"clientName": str(profile.get("fullName", "")).strip(),
|
||||
"dogName": str(profile.get("dogName", "")).strip(),
|
||||
"scheduledFor": scheduled_for.isoformat(timespec="minutes"),
|
||||
@@ -4495,6 +4974,16 @@ async def _run_scheduled_sender_once() -> None:
|
||||
str(entry.get("startDate", "")),
|
||||
str(entry.get("subject", "")),
|
||||
request_id,
|
||||
custom={
|
||||
"heading": str(entry.get("heading", "")),
|
||||
"intro": str(entry.get("intro", "")),
|
||||
"outro": str(entry.get("outro", "")),
|
||||
# Pre-customisation entries have no cta keys → standard
|
||||
# button; newer entries store explicit values (incl. "").
|
||||
"ctaLabel": str(entry.get("ctaLabel", WELCOME_CTA_DEFAULT_LABEL)),
|
||||
"ctaUrl": str(entry.get("ctaUrl", WELCOME_CTA_DEFAULT_URL)),
|
||||
"showDetails": bool(entry.get("showDetails", True)),
|
||||
},
|
||||
)
|
||||
entry["status"] = "sent"
|
||||
entry["sentAt"] = sent_at
|
||||
@@ -4545,19 +5034,26 @@ async def owner_send_welcome_pack(data: WelcomePackEmailRequest, request: Reques
|
||||
raise HTTPException(status_code=404, detail="Client profile not found.")
|
||||
if profile.get("onboardingCompleted"):
|
||||
raise HTTPException(status_code=400, detail="This client has already completed onboarding.")
|
||||
if not _trimmed(data.serviceType):
|
||||
raise HTTPException(status_code=400, detail="Please enter a service.")
|
||||
if not _trimmed(data.priceDetails):
|
||||
raise HTTPException(status_code=400, detail="Please enter the price details.")
|
||||
if not _trimmed(data.startDate):
|
||||
raise HTTPException(status_code=400, detail="Please enter a start date.")
|
||||
|
||||
# The email is now fully customisable, so the structured offer fields are
|
||||
# optional. We only require that the email isn't completely empty.
|
||||
service = _trimmed(data.serviceType)
|
||||
price = _trimmed(data.priceDetails)
|
||||
start = _trimmed(data.startDate)
|
||||
custom = _welcome_custom_fields(data)
|
||||
subject = _trimmed(data.subject) or _default_welcome_subject(str(profile.get("dogName", "")))
|
||||
is_preview = bool(data.preview)
|
||||
|
||||
has_body = bool(
|
||||
_sanitize_welcome_body_html(str(custom["bodyHtml"]))
|
||||
or custom["heading"]
|
||||
or custom["intro"]
|
||||
or custom["outro"]
|
||||
)
|
||||
has_details = bool(custom["showDetails"] and (service or price or start))
|
||||
if not has_body and not has_details:
|
||||
raise HTTPException(status_code=400, detail="Please add some content or offer details to the email.")
|
||||
|
||||
# Scheduled send: queue it for the worker rather than sending now. Previews
|
||||
# are always immediate — there's nothing to schedule about a test email.
|
||||
if data.scheduledFor and not is_preview:
|
||||
@@ -4566,7 +5062,7 @@ async def owner_send_welcome_pack(data: WelcomePackEmailRequest, request: Reques
|
||||
raise HTTPException(status_code=400, detail="Please choose a time in the future to schedule the email.")
|
||||
entry = await _enqueue_scheduled_welcome(
|
||||
email=email, profile=profile, service=service, price=price, start=start,
|
||||
subject=subject, scheduled_for=when, owner_email=owner_email,
|
||||
subject=subject, scheduled_for=when, owner_email=owner_email, custom=custom,
|
||||
)
|
||||
logger.info("[%s] welcome pack scheduled: email=%s for=%s id=%s", request_id, email, entry["scheduledFor"], entry["id"])
|
||||
await admin_db.record_event(
|
||||
@@ -4582,7 +5078,7 @@ async def owner_send_welcome_pack(data: WelcomePackEmailRequest, request: Reques
|
||||
recipients = _resolve_preview_recipients([str(e) for e in (data.previewRecipients or [])])
|
||||
payload = _welcome_pack_payload(
|
||||
email, owner_name, dog_name, service, price, start,
|
||||
subject=subject, is_preview=True, recipients=recipients,
|
||||
subject=subject, is_preview=True, recipients=recipients, custom=custom,
|
||||
)
|
||||
try:
|
||||
await _send_email(payload, label="welcome_pack_email_preview", request_id=request_id)
|
||||
@@ -4599,8 +5095,10 @@ async def owner_send_welcome_pack(data: WelcomePackEmailRequest, request: Reques
|
||||
logger.info("[%s] welcome pack PREVIEW sent: original_recipient=%s -> owner", request_id, email)
|
||||
return {"ok": True, "sentAt": datetime.now().isoformat(timespec="seconds"), "preview": True}
|
||||
|
||||
cc_list = [owner_email] if (bool(data.ccOwner) and owner_email) else None
|
||||
bcc_list = [owner_email] if (bool(data.bccOwner) and owner_email) else None
|
||||
try:
|
||||
sent_at = await _deliver_welcome_pack(email, profile, service, price, start, subject, request_id)
|
||||
sent_at = await _deliver_welcome_pack(email, profile, service, price, start, subject, request_id, custom=custom, cc=cc_list, bcc_extra=bcc_list)
|
||||
except Exception as exc:
|
||||
logger.error("[%s] welcome pack email failed: %s", request_id, exc, exc_info=True)
|
||||
raise HTTPException(
|
||||
@@ -4727,6 +5225,7 @@ async def owner_send_birthday_email(data: BirthdayEmailRequest, request: Request
|
||||
dog_id=data.dogId,
|
||||
preview=bool(data.preview),
|
||||
preview_recipients=[str(e) for e in (data.previewRecipients or [])],
|
||||
subject=str(data.subject or ""),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("[%s] birthday email failed: %s", request_id, exc, exc_info=True)
|
||||
|
||||
Reference in New Issue
Block a user