v4.0.5
Component architecture - Reorganise src/lib/components into pages/, sections/, ui/ subdirectories and update all imports across routes and tests. Owner admin dashboard (+2k lines) - Scheduled welcome-pack emails: durable queue with cancel/reschedule and a background sender loop (SCHEDULED_CHECK_INTERVAL_SECONDS, default 60s). - Custom welcome-pack subject line, preview recipients, and client BCC. - Add-client, edit client profile, and reset-onboarding flows. - "View as client" onboarding preview (owner impersonation, dry-run submit). - Tabbed owner welcome route (/owner/welcome/[[tab]]). MYOB integration - New mail_api/myob.py: create new clients as MYOB AccountRight customer contacts. Disabled until all MYOB_* env vars are set (no-op otherwise). Onboarding - New "Does your dog resource guard?" Yes/No question in the Behaviour step. - Persist vetAddress, flea/tick, and pet-insurance fields server-side. Misc - vite config, new hooks.ts, responsive CSS tweaks, deploy/docker config, mail-api README and start-dev.ps1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -48,7 +48,7 @@ def setup_logging() -> logging.Logger:
|
||||
root.addHandler(rotating)
|
||||
|
||||
log = logging.getLogger("mail-api")
|
||||
log.info("Logging initialised → console=INFO, file=%s (DEBUG, rotating)", log_file)
|
||||
log.info("Logging initialised -> console=INFO, file=%s (DEBUG, rotating)", log_file)
|
||||
return log
|
||||
|
||||
|
||||
@@ -185,6 +185,10 @@ AUTH_IP_MAX_FAILURES = max(3, int(os.environ.get("AUTH_IP_MAX_FAILURES", "10")))
|
||||
AUTH_IP_FAILURE_WINDOW = max(60, int(os.environ.get("AUTH_IP_FAILURE_WINDOW", "600")))
|
||||
AUTH_IP_BLOCK_DURATION = max(60, int(os.environ.get("AUTH_IP_BLOCK_DURATION", "3600")))
|
||||
BIRTHDAY_CHECK_INTERVAL_SECONDS = max(3600, int(os.environ.get("BIRTHDAY_CHECK_INTERVAL_SECONDS", str(12 * 3600))))
|
||||
# How often the scheduled-email worker wakes to dispatch any sends that have
|
||||
# come due. Scheduled welcome emails need finer granularity than the birthday
|
||||
# sweep, so this defaults to one minute (floored at 15s).
|
||||
SCHEDULED_CHECK_INTERVAL_SECONDS = max(15, int(os.environ.get("SCHEDULED_CHECK_INTERVAL_SECONDS", "60")))
|
||||
|
||||
|
||||
def _split_csv_env(name: str, default: str) -> tuple[str, ...]:
|
||||
@@ -211,6 +215,10 @@ _DATA_DIR = Path(os.environ.get("DATA_DIR", "data"))
|
||||
ALLOWED_EMAILS_FILE = _DATA_DIR / "allowed_emails.json"
|
||||
CLIENT_PROFILES_FILE = _DATA_DIR / "client_profiles.json"
|
||||
DRAFTS_FILE = _DATA_DIR / "drafts.json"
|
||||
# Durable queue of owner-scheduled emails (welcome packs queued for later send).
|
||||
# Mirrors the client_profiles persistence model: postgres admin_kv is canonical
|
||||
# in production, with this JSON file as the dev/local fallback and seed source.
|
||||
SCHEDULED_EMAILS_FILE = _DATA_DIR / "scheduled_emails.json"
|
||||
# Legacy seed lives OUTSIDE the data volume — it's shipped in the image so a
|
||||
# fresh deploy always carries the same baked-in copy. On boot the mail-api
|
||||
# merges this dict into _client_profiles, adding any emails that aren't
|
||||
@@ -221,6 +229,27 @@ LEGACY_SEED_FILE = Path(
|
||||
|
||||
LOGO_URL = "https://www.goodwalk.co.nz/images/goodwalk-auckland-dog-walking-logo.png"
|
||||
|
||||
# ── MYOB integration ───────────────────────────────────────────────────────────
|
||||
# Optional: create new clients as customer contacts in MYOB AccountRight Live.
|
||||
# The integration stays disabled until every required value is present, so an
|
||||
# unconfigured environment behaves exactly as before.
|
||||
#
|
||||
# MYOB_API_KEY / MYOB_API_SECRET the developer app's OAuth client id/secret
|
||||
# MYOB_REFRESH_TOKEN long-lived token used to mint access tokens
|
||||
# MYOB_COMPANY_FILE_URI base API URI of the target company file,
|
||||
# e.g. https://api.myob.com/accountright/<guid>
|
||||
# MYOB_CF_USERNAME / MYOB_CF_PASSWORD company-file login (password may be blank)
|
||||
# MYOB_TOKEN_URL OAuth token endpoint (override only for tests)
|
||||
MYOB_API_KEY = (os.environ.get("MYOB_API_KEY") or "").strip()
|
||||
MYOB_API_SECRET = (os.environ.get("MYOB_API_SECRET") or "").strip()
|
||||
MYOB_REFRESH_TOKEN = (os.environ.get("MYOB_REFRESH_TOKEN") or "").strip()
|
||||
MYOB_COMPANY_FILE_URI = (os.environ.get("MYOB_COMPANY_FILE_URI") or "").strip().rstrip("/")
|
||||
MYOB_CF_USERNAME = (os.environ.get("MYOB_CF_USERNAME") or "Administrator").strip()
|
||||
MYOB_CF_PASSWORD = os.environ.get("MYOB_CF_PASSWORD", "")
|
||||
MYOB_TOKEN_URL = (os.environ.get("MYOB_TOKEN_URL") or "https://secure.myob.com/oauth2/v1/authorize").strip()
|
||||
MYOB_HTTP_TIMEOUT_SECONDS = max(5, int(os.environ.get("MYOB_HTTP_TIMEOUT_SECONDS", "20")))
|
||||
MYOB_ENABLED = bool(MYOB_API_KEY and MYOB_API_SECRET and MYOB_REFRESH_TOKEN and MYOB_COMPANY_FILE_URI)
|
||||
|
||||
# ── Legacy module constants (kept for compatibility with the existing main.py) ──
|
||||
|
||||
OWNER_EMAIL = settings.owner_email
|
||||
@@ -268,3 +297,4 @@ logger.info(
|
||||
RATE_LIMIT_MIN_INTERVAL_SECONDS,
|
||||
EMAIL_SEND_TIMEOUT_SECONDS,
|
||||
)
|
||||
logger.info("MYOB integration: %s", "enabled" if MYOB_ENABLED else "disabled (set MYOB_* env vars to enable)")
|
||||
|
||||
@@ -41,9 +41,13 @@ class OnboardingSubmission(BaseSubmission):
|
||||
medicalNotes: str = ""
|
||||
accessInstructions: str = ""
|
||||
vetName: str
|
||||
vetAddress: str
|
||||
vetPhone: str
|
||||
emergencyContactName: str
|
||||
emergencyContactPhone: str
|
||||
regularFleaTickTreatment: str = ""
|
||||
petInsurance: str = ""
|
||||
petInsuranceOwnerExpenseAccepted: bool = False
|
||||
councilRegistrationConfirmed: bool = False
|
||||
vaccinationsConfirmed: bool = False
|
||||
emergencyVetConsent: bool = False
|
||||
@@ -54,15 +58,33 @@ class OnboardingSubmission(BaseSubmission):
|
||||
|
||||
class WelcomePackEmailRequest(BaseModel):
|
||||
email: EmailStr
|
||||
# Owner-set subject line. Defaults to "Goodwalk Onboarding - <Dog Name>" when
|
||||
# blank (resolved server-side so scheduled sends keep a sensible subject too).
|
||||
subject: str = ""
|
||||
serviceType: str
|
||||
priceDetails: str
|
||||
startDate: str
|
||||
preview: bool = False
|
||||
previewRecipients: list[EmailStr] = []
|
||||
# ISO 8601 local datetime (e.g. "2026-06-20T14:30"). When set on a non-preview
|
||||
# request, the welcome email is queued for reliable delivery at that time
|
||||
# instead of being sent immediately.
|
||||
scheduledFor: str | None = None
|
||||
|
||||
|
||||
class ScheduledEmailCancelRequest(BaseModel):
|
||||
id: str
|
||||
|
||||
|
||||
class ScheduledEmailRescheduleRequest(BaseModel):
|
||||
id: str
|
||||
scheduledFor: str
|
||||
|
||||
|
||||
class BirthdayEmailRequest(BaseModel):
|
||||
email: EmailStr
|
||||
preview: bool = False
|
||||
previewRecipients: list[EmailStr] = []
|
||||
|
||||
|
||||
class BirthdayAutoSendRequest(BaseModel):
|
||||
@@ -81,6 +103,42 @@ class ClientStatusUpdate(BaseModel):
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class ClientProfileUpdate(BaseModel):
|
||||
email: EmailStr
|
||||
nextEmail: EmailStr
|
||||
fullName: str
|
||||
phone: str = ""
|
||||
address: str = ""
|
||||
dogName: str
|
||||
|
||||
|
||||
class ResetOnboardingRequest(BaseModel):
|
||||
"""Owner-initiated reset: keep the client's saved details but mark their
|
||||
onboarding incomplete so they can sign in and complete the new form
|
||||
(used for clients imported from the legacy Gravity Forms data)."""
|
||||
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class NewClientRequest(BaseModel):
|
||||
"""Owner-initiated client creation (e.g. leads from Instagram / Facebook
|
||||
that never came through the public enquiry form). Registers the email so
|
||||
the client can access the onboarding form, and seeds a profile."""
|
||||
|
||||
email: EmailStr
|
||||
fullName: str
|
||||
phone: str = ""
|
||||
address: str = ""
|
||||
dogName: str = ""
|
||||
dogBreed: 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
|
||||
# as a customer contact in MYOB. Non-fatal: a MYOB failure never blocks the
|
||||
# local client from being created.
|
||||
createInMyob: bool = False
|
||||
|
||||
|
||||
class ContractSubmission(BaseSubmission):
|
||||
address: str
|
||||
dogName: str
|
||||
@@ -126,3 +184,4 @@ class SendMessageRequest(BaseModel):
|
||||
fontId: str = "system"
|
||||
recipients: list[EmailStr] = []
|
||||
preview: bool = False
|
||||
previewRecipients: list[EmailStr] = []
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""MYOB AccountRight Live integration — create customer contacts.
|
||||
|
||||
Used when the owner ticks "Also create in MYOB" while adding a new client in the
|
||||
control panel. The integration is optional: when the required environment
|
||||
variables are not set, :func:`is_configured` returns ``False`` and the caller
|
||||
skips MYOB entirely (the local client is still created).
|
||||
|
||||
Auth model (MYOB OAuth 2.0):
|
||||
* A developer key/secret (``client_id`` / ``client_secret``) identifies this
|
||||
app to MYOB.
|
||||
* A long-lived refresh token mints short-lived bearer access tokens. We cache
|
||||
the access token in memory and refresh it shortly before it expires.
|
||||
* Each AccountRight company file lives at its own base URI and is gated by a
|
||||
company-file token: ``base64("username:password")`` sent as the
|
||||
``x-myobapi-cftoken`` header (password may be blank).
|
||||
|
||||
All HTTP calls are synchronous (``requests``); the async app invokes the public
|
||||
helpers via ``asyncio.to_thread`` so the event loop is never blocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import threading
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
from mail_api.config import (
|
||||
MYOB_API_KEY,
|
||||
MYOB_API_SECRET,
|
||||
MYOB_CF_PASSWORD,
|
||||
MYOB_CF_USERNAME,
|
||||
MYOB_COMPANY_FILE_URI,
|
||||
MYOB_ENABLED,
|
||||
MYOB_HTTP_TIMEOUT_SECONDS,
|
||||
MYOB_REFRESH_TOKEN,
|
||||
MYOB_TOKEN_URL,
|
||||
logger,
|
||||
)
|
||||
|
||||
|
||||
class MyobError(Exception):
|
||||
"""Raised when a MYOB API call cannot be completed."""
|
||||
|
||||
|
||||
# Access tokens are short-lived (MYOB returns ~20 min). Cache the current token
|
||||
# and its expiry so back-to-back client creations reuse a single refresh.
|
||||
_token_lock = threading.Lock()
|
||||
_cached_token: str = ""
|
||||
_cached_token_expires_at: float = 0.0
|
||||
# Refresh a little early so a token never expires mid-request.
|
||||
_TOKEN_EXPIRY_SKEW_SECONDS = 60
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
"""True when every credential needed to talk to MYOB is present."""
|
||||
return MYOB_ENABLED
|
||||
|
||||
|
||||
def _cf_token() -> str:
|
||||
raw = f"{MYOB_CF_USERNAME}:{MYOB_CF_PASSWORD}".encode("utf-8")
|
||||
return base64.b64encode(raw).decode("ascii")
|
||||
|
||||
|
||||
def _refresh_access_token() -> str:
|
||||
"""Exchange the stored refresh token for a fresh access token.
|
||||
|
||||
Caches the result in module state. Raises :class:`MyobError` on failure.
|
||||
"""
|
||||
global _cached_token, _cached_token_expires_at
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
MYOB_TOKEN_URL,
|
||||
data={
|
||||
"client_id": MYOB_API_KEY,
|
||||
"client_secret": MYOB_API_SECRET,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": MYOB_REFRESH_TOKEN,
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=MYOB_HTTP_TIMEOUT_SECONDS,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise MyobError(f"Could not reach the MYOB token service: {exc}") from exc
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise MyobError(
|
||||
f"MYOB token refresh failed (HTTP {resp.status_code}). "
|
||||
"Check MYOB_API_KEY / MYOB_API_SECRET / MYOB_REFRESH_TOKEN."
|
||||
)
|
||||
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError as exc:
|
||||
raise MyobError("MYOB token service returned an unreadable response.") from exc
|
||||
|
||||
token = str(payload.get("access_token") or "").strip()
|
||||
if not token:
|
||||
raise MyobError("MYOB token service did not return an access token.")
|
||||
|
||||
try:
|
||||
expires_in = int(payload.get("expires_in", 1200))
|
||||
except (TypeError, ValueError):
|
||||
expires_in = 1200
|
||||
|
||||
_cached_token = token
|
||||
_cached_token_expires_at = time.monotonic() + max(0, expires_in - _TOKEN_EXPIRY_SKEW_SECONDS)
|
||||
logger.info("MYOB: access token refreshed (valid ~%ds)", expires_in)
|
||||
return token
|
||||
|
||||
|
||||
def _access_token() -> str:
|
||||
with _token_lock:
|
||||
if _cached_token and time.monotonic() < _cached_token_expires_at:
|
||||
return _cached_token
|
||||
return _refresh_access_token()
|
||||
|
||||
|
||||
def _api_headers() -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {_access_token()}",
|
||||
"x-myobapi-key": MYOB_API_KEY,
|
||||
"x-myobapi-version": "v2",
|
||||
"x-myobapi-cftoken": _cf_token(),
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _split_name(full_name: str) -> tuple[str, str]:
|
||||
"""Best-effort split of a single owner name into first/last for MYOB."""
|
||||
parts = full_name.split()
|
||||
if not parts:
|
||||
return ("", "")
|
||||
if len(parts) == 1:
|
||||
return (parts[0], "")
|
||||
return (parts[0], " ".join(parts[1:]))
|
||||
|
||||
|
||||
def create_customer(
|
||||
*,
|
||||
email: str,
|
||||
full_name: str,
|
||||
phone: str = "",
|
||||
street: str = "",
|
||||
source: str = "",
|
||||
) -> dict[str, str]:
|
||||
"""Create an individual customer contact in MYOB.
|
||||
|
||||
Returns ``{"uid": ..., "uri": ...}`` parsed from the ``Location`` header MYOB
|
||||
returns on a successful create. Raises :class:`MyobError` on any failure so
|
||||
the caller can record it without aborting the local client creation.
|
||||
"""
|
||||
if not is_configured():
|
||||
raise MyobError("MYOB integration is not configured.")
|
||||
|
||||
first_name, last_name = _split_name(full_name)
|
||||
body = {
|
||||
"IsIndividual": True,
|
||||
"FirstName": first_name,
|
||||
"LastName": last_name,
|
||||
"IsActive": True,
|
||||
"Addresses": [
|
||||
{
|
||||
"Location": 1,
|
||||
"Email": email,
|
||||
"Phone1": phone,
|
||||
"Street": street,
|
||||
}
|
||||
],
|
||||
}
|
||||
if source:
|
||||
body["Notes"] = f"Added via Goodwalk control panel — source: {source}"
|
||||
|
||||
url = f"{MYOB_COMPANY_FILE_URI}/Contact/Customer"
|
||||
try:
|
||||
resp = requests.post(
|
||||
url,
|
||||
json=body,
|
||||
headers=_api_headers(),
|
||||
timeout=MYOB_HTTP_TIMEOUT_SECONDS,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise MyobError(f"Could not reach MYOB: {exc}") from exc
|
||||
|
||||
# 200/201 both indicate success depending on API version; MYOB returns the
|
||||
# new record's URI in the Location header.
|
||||
if resp.status_code not in (200, 201):
|
||||
detail = _error_detail(resp)
|
||||
raise MyobError(f"MYOB rejected the customer (HTTP {resp.status_code}): {detail}")
|
||||
|
||||
location = resp.headers.get("Location", "")
|
||||
uid = location.rstrip("/").rsplit("/", 1)[-1] if location else ""
|
||||
logger.info("MYOB: created customer uid=%s for %s", uid or "?", email)
|
||||
return {"uid": uid, "uri": location}
|
||||
|
||||
|
||||
def _error_detail(resp: requests.Response) -> str:
|
||||
"""Extract a human-readable error message from a MYOB error response."""
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
return (resp.text or "").strip()[:300] or "no detail provided"
|
||||
errors = data.get("Errors") if isinstance(data, dict) else None
|
||||
if isinstance(errors, list) and errors:
|
||||
messages = [str(e.get("Message", "")).strip() for e in errors if isinstance(e, dict)]
|
||||
joined = "; ".join(m for m in messages if m)
|
||||
if joined:
|
||||
return joined
|
||||
return str(data)[:300]
|
||||
Reference in New Issue
Block a user