- FastAPI skeleton, SQLAlchemy models (§13), Alembic initial migration - SchedulingProvider interface with google_calendar (free/busy read-only), partner_api (Appendix B client) and mock implementations - Proposal engine: create → provider-routed delivery → owner actions (resolve/confirm+SMS/reject) → expiry + reminders (§9) - Signed single-use action links, .ics METHOD:REQUEST attachment - Partner outcome webhook with HMAC verification + polling fallback - SmsProvider (console) with Bosnian templates (§5.5), EmailProvider (console/SMTP) - Fake partner API server in tests/ — Appendix B reference implementation - 43 tests: slot math, proposal lifecycle, action links, partner contract Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
218 lines
7.5 KiB
Python
218 lines
7.5 KiB
Python
"""Proposal email to the owner: subject, body, action links, .ics (§9.1)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from gogo.config import get_settings
|
|
from gogo.domain import Slot
|
|
from gogo.email import EmailMessage, send_email
|
|
from gogo.i18n import fmt_slot
|
|
from gogo.models import BookingRequest, EmailLog, Service, Tenant
|
|
from gogo.proposals.ics import build_ics
|
|
from gogo.proposals.tokens import action_url, create_action_token
|
|
|
|
|
|
def _slots_from_request(req: BookingRequest) -> list[Slot]:
|
|
return [Slot.model_validate(s) for s in (req.slots or [])]
|
|
|
|
|
|
async def send_proposal_email(
|
|
session: AsyncSession, tenant: Tenant, req: BookingRequest, service: Service | None
|
|
) -> None:
|
|
recipients = list(tenant.notify_emails or [])
|
|
if not recipients:
|
|
return
|
|
|
|
slots = _slots_from_request(req)
|
|
service_name = service.name if service else (req.service_name_raw or "termin")
|
|
days = ", ".join(sorted({fmt_slot(s.start, tenant.timezone).split(",")[0] for s in slots}))
|
|
subject = f"Novi zahtjev za termin — {service_name} — {req.client_name}"
|
|
if days:
|
|
subject += f" ({days})"
|
|
|
|
resolve_url = action_url(await create_action_token(session, req.id, "resolve"))
|
|
reject_url = action_url(await create_action_token(session, req.id, "reject"))
|
|
confirm_urls = [
|
|
(
|
|
fmt_slot(s.start, tenant.timezone),
|
|
action_url(await create_action_token(session, req.id, "confirm", slot_index=i)),
|
|
)
|
|
for i, s in enumerate(slots)
|
|
]
|
|
|
|
transcript_url = f"{get_settings().base_url}/t/{req.id}"
|
|
|
|
lines = [
|
|
f"Novi zahtjev za termin — {tenant.name}",
|
|
"",
|
|
f"Klijent: {req.client_name}",
|
|
f"Telefon: {req.client_phone}",
|
|
f"Usluga: {service_name}",
|
|
]
|
|
if req.home_visit:
|
|
lines.append(f"Dolazak na adresu: DA — {req.address or 'adresa nije navedena'}")
|
|
if slots:
|
|
lines.append("Traženi termini (po redoslijedu želje):")
|
|
lines += [f" {i + 1}. {fmt_slot(s.start, tenant.timezone)}" for i, s in enumerate(slots)]
|
|
if req.time_preference_text and not slots:
|
|
lines.append(f"Željeno vrijeme: {req.time_preference_text}")
|
|
if req.summary:
|
|
lines += ["", f"Sažetak razgovora: {req.summary}"]
|
|
lines += [
|
|
"",
|
|
f"Cijeli razgovor: {transcript_url}",
|
|
"",
|
|
"─" * 40,
|
|
f"RIJEŠENO — kontaktirao/la sam klijenta:\n {resolve_url}",
|
|
]
|
|
for label, url in confirm_urls:
|
|
lines.append(f"POTVRDI {label} + pošalji SMS klijentu:\n {url}")
|
|
lines.append(f"ODBIJ zahtjev:\n {reject_url}")
|
|
|
|
html = _render_html(
|
|
tenant, req, service_name, slots, resolve_url, confirm_urls, reject_url, transcript_url
|
|
)
|
|
|
|
attachments = []
|
|
if slots:
|
|
first = slots[0]
|
|
ics = build_ics(
|
|
uid=str(req.id),
|
|
start=first.start,
|
|
end=first.end,
|
|
summary=f"{service_name} — {req.client_name} (zahtjev)",
|
|
description=(
|
|
f"Zahtjev putem Gogo Telefona.\nKlijent: {req.client_name}, {req.client_phone}\n"
|
|
f"{req.summary}"
|
|
),
|
|
)
|
|
attachments.append(("termin.ics", "text/calendar", ics))
|
|
|
|
await send_email(EmailMessage(recipients, subject, "\n".join(lines), html, attachments))
|
|
session.add(
|
|
EmailLog(
|
|
tenant_id=tenant.id,
|
|
to_addr=", ".join(recipients),
|
|
subject=subject,
|
|
kind="proposal",
|
|
status="sent",
|
|
)
|
|
)
|
|
|
|
|
|
def _render_html(
|
|
tenant: Tenant,
|
|
req: BookingRequest,
|
|
service_name: str,
|
|
slots: list[Slot],
|
|
resolve_url: str,
|
|
confirm_urls: list[tuple[str, str]],
|
|
reject_url: str,
|
|
transcript_url: str,
|
|
) -> str:
|
|
def esc(s: str) -> str:
|
|
return (
|
|
str(s).replace("&", "&").replace("<", "<").replace(">", ">")
|
|
)
|
|
|
|
slot_rows = "".join(
|
|
f"<li>{esc(fmt_slot(s.start, tenant.timezone))}</li>" for s in slots
|
|
)
|
|
confirm_buttons = "".join(
|
|
f'<p><a href="{url}" style="background:#2563eb;color:#fff;padding:10px 16px;'
|
|
f'border-radius:6px;text-decoration:none;display:inline-block">'
|
|
f"Potvrdi {esc(label)} + SMS</a></p>"
|
|
for label, url in confirm_urls
|
|
)
|
|
home = (
|
|
f"<p><b>Dolazak na adresu:</b> DA — {esc(req.address or 'adresa nije navedena')}</p>"
|
|
if req.home_visit
|
|
else ""
|
|
)
|
|
pref = (
|
|
f"<p><b>Željeno vrijeme:</b> {esc(req.time_preference_text)}</p>"
|
|
if req.time_preference_text and not slots
|
|
else ""
|
|
)
|
|
summary = f"<p><b>Sažetak:</b> {esc(req.summary)}</p>" if req.summary else ""
|
|
return f"""
|
|
<div style="font-family:sans-serif;max-width:560px">
|
|
<h2 style="margin-bottom:4px">Novi zahtjev za termin</h2>
|
|
<p style="color:#666;margin-top:0">{esc(tenant.name)}</p>
|
|
<p><b>Klijent:</b> {esc(req.client_name)}<br>
|
|
<b>Telefon:</b> {esc(req.client_phone)}<br>
|
|
<b>Usluga:</b> {esc(service_name)}</p>
|
|
{home}
|
|
{"<p><b>Traženi termini:</b></p><ol>" + slot_rows + "</ol>" if slots else ""}
|
|
{pref}
|
|
{summary}
|
|
<p><a href="{transcript_url}">Cijeli razgovor →</a></p>
|
|
<hr>
|
|
<p><a href="{resolve_url}" style="background:#16a34a;color:#fff;padding:12px 20px;
|
|
border-radius:6px;text-decoration:none;display:inline-block;font-weight:bold">
|
|
✓ Riješeno — kontaktirao/la sam klijenta</a></p>
|
|
{confirm_buttons}
|
|
<p><a href="{reject_url}" style="color:#dc2626">Odbij zahtjev</a></p>
|
|
<p style="color:#999;font-size:12px">Gogo Telefon — virtuelni asistent salona.
|
|
U prilogu je .ics za prvi traženi termin (možete ga pomjeriti prije spremanja u kalendar).</p>
|
|
</div>
|
|
"""
|
|
|
|
|
|
async def send_reminder_email(session: AsyncSession, tenant: Tenant, req: BookingRequest) -> None:
|
|
"""Reminder at 50% of proposal TTL (§9.2)."""
|
|
recipients = list(tenant.notify_emails or [])
|
|
if not recipients:
|
|
return
|
|
subject = f"Podsjetnik: neodgovoren zahtjev — {req.client_name}"
|
|
ttl = tenant.proposal_ttl_hours or 24
|
|
body = (
|
|
f"Zahtjev klijenta {req.client_name} ({req.client_phone}) čeka odgovor.\n"
|
|
f"Ako ne odgovorite u roku od {ttl // 2}h, zahtjev ističe i klijent dobija "
|
|
f"SMS s molbom da nazove ponovo.\n\n"
|
|
f"Pregled: {get_settings().base_url}/t/{req.id}"
|
|
)
|
|
await send_email(EmailMessage(recipients, subject, body))
|
|
session.add(
|
|
EmailLog(
|
|
tenant_id=tenant.id,
|
|
to_addr=", ".join(recipients),
|
|
subject=subject,
|
|
kind="proposal_reminder",
|
|
status="sent",
|
|
)
|
|
)
|
|
|
|
|
|
async def send_usage_warning_email(
|
|
session: AsyncSession, tenant: Tenant, used_minutes: int, pct: int
|
|
) -> None:
|
|
recipients = list(tenant.notify_emails or [])
|
|
if not recipients:
|
|
return
|
|
subject = f"Gogo Telefon: iskorišteno {pct}% minuta ovaj mjesec"
|
|
body = (
|
|
f"Salon {tenant.name} je iskoristio {used_minutes} od {tenant.included_minutes} "
|
|
f"uključenih minuta virtualnog asistenta ovaj mjesec.\n"
|
|
"Asistent nastavlja odgovarati na pozive. Za veći paket javite se Gogo podršci."
|
|
)
|
|
await send_email(EmailMessage(recipients, subject, body))
|
|
session.add(
|
|
EmailLog(
|
|
tenant_id=tenant.id,
|
|
to_addr=", ".join(recipients),
|
|
subject=subject,
|
|
kind="usage_warning",
|
|
status="sent",
|
|
)
|
|
)
|
|
|
|
|
|
def now_utc() -> datetime:
|
|
from datetime import UTC
|
|
|
|
return datetime.now(UTC)
|