Store WhatsApp templates in database for persistence across rebuilds

This commit is contained in:
dvirlabs 2026-05-12 11:40:29 +03:00
parent ca9375a779
commit 74594b2411
5 changed files with 165 additions and 44 deletions

View File

@ -585,7 +585,7 @@ async def send_guest_message(
phone = message_req.phone or guest.phone phone = message_req.phone or guest.phone
try: try:
service = get_whatsapp_service() service = get_whatsapp_service(db)
result = await service.send_text_message(phone, message_req.message) result = await service.send_text_message(phone, message_req.message)
return result return result
except WhatsAppError as e: except WhatsAppError as e:
@ -635,7 +635,7 @@ async def broadcast_whatsapp_message(
failed = [] failed = []
try: try:
service = get_whatsapp_service() service = get_whatsapp_service(db)
for guest in guests: for guest in guests:
try: try:
result = await service.send_text_message(guest.phone, message) result = await service.send_text_message(guest.phone, message)
@ -667,16 +667,17 @@ async def broadcast_whatsapp_message(
# WhatsApp Template Registry Endpoints # WhatsApp Template Registry Endpoints
# ============================================ # ============================================
@app.get("/whatsapp/templates") @app.get("/whatsapp/templates")
async def get_whatsapp_templates(): async def get_whatsapp_templates(db: Session = Depends(get_db)):
""" """
Return all registered WhatsApp templates (built-in + custom) for the frontend dropdown. Return all registered WhatsApp templates (built-in + custom) for the frontend dropdown.
""" """
return {"templates": list_templates_for_frontend()} return {"templates": list_templates_for_frontend(db)}
@app.post("/whatsapp/templates") @app.post("/whatsapp/templates")
async def create_whatsapp_template( async def create_whatsapp_template(
body: dict, body: dict,
db: Session = Depends(get_db),
current_user_id = Depends(get_current_user_id) current_user_id = Depends(get_current_user_id)
): ):
""" """
@ -733,7 +734,7 @@ async def create_whatsapp_template(
} }
try: try:
add_custom_template(key, template) add_custom_template(db, key, template)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=409, detail=str(e)) raise HTTPException(status_code=409, detail=str(e))
@ -743,6 +744,7 @@ async def create_whatsapp_template(
@app.delete("/whatsapp/templates/{key}") @app.delete("/whatsapp/templates/{key}")
async def delete_whatsapp_template( async def delete_whatsapp_template(
key: str, key: str,
db: Session = Depends(get_db),
current_user_id = Depends(get_current_user_id) current_user_id = Depends(get_current_user_id)
): ):
"""Delete a custom template by key (built-in templates cannot be deleted).""" """Delete a custom template by key (built-in templates cannot be deleted)."""
@ -750,7 +752,7 @@ async def delete_whatsapp_template(
raise HTTPException(status_code=403, detail="Not authenticated") raise HTTPException(status_code=403, detail="Not authenticated")
try: try:
delete_custom_template(key) delete_custom_template(db, key)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=403, detail=str(e)) raise HTTPException(status_code=403, detail=str(e))
except KeyError as e: except KeyError as e:
@ -814,7 +816,7 @@ async def send_wedding_invitation_single(
_gl_base = (event.guest_link or "https://invy.dvirlabs.com/guest").split("?")[0].rstrip("/") _gl_base = (event.guest_link or "https://invy.dvirlabs.com/guest").split("?")[0].rstrip("/")
guest_link = f"{_gl_base}/{event_id}" guest_link = f"{_gl_base}/{event_id}"
service = get_whatsapp_service() service = get_whatsapp_service(db)
result = await service.send_wedding_invitation( result = await service.send_wedding_invitation(
to_phone=to_phone, to_phone=to_phone,
guest_name=guest_name, guest_name=guest_name,
@ -882,7 +884,7 @@ async def send_wedding_invitation_bulk(
results = [] results = []
import asyncio import asyncio
service = get_whatsapp_service() service = get_whatsapp_service(db)
for guest in guests: for guest in guests:
try: try:
@ -949,7 +951,7 @@ async def send_wedding_invitation_bulk(
# Auto-inject guest_name_key + event_id for url_button templates # Auto-inject guest_name_key + event_id for url_button templates
try: try:
from whatsapp_templates import get_template as _get_tpl from whatsapp_templates import get_template as _get_tpl
_tpl_def = _get_tpl(request_body.template_key or "wedding_invitation") _tpl_def = _get_tpl(db, request_body.template_key or "wedding_invitation")
_gnk = _tpl_def.get("guest_name_key", "") _gnk = _tpl_def.get("guest_name_key", "")
if _gnk: if _gnk:
params[_gnk] = guest.first_name or guest_name params[_gnk] = guest.first_name or guest_name

View File

@ -353,3 +353,31 @@ CREATE TABLE IF NOT EXISTS rsvp_tokens (
); );
CREATE INDEX IF NOT EXISTS idx_rsvp_tokens_event_id ON rsvp_tokens(event_id); CREATE INDEX IF NOT EXISTS idx_rsvp_tokens_event_id ON rsvp_tokens(event_id);
CREATE INDEX IF NOT EXISTS idx_rsvp_tokens_guest_id ON rsvp_tokens(guest_id); CREATE INDEX IF NOT EXISTS idx_rsvp_tokens_guest_id ON rsvp_tokens(guest_id);
-- ============================================
-- STEP 16: WhatsApp Templates Table
-- Store custom WhatsApp message templates created by users
-- ============================================
CREATE TABLE IF NOT EXISTS whatsapp_templates (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
template_key TEXT NOT NULL UNIQUE,
meta_name TEXT NOT NULL,
friendly_name TEXT NOT NULL,
language_code TEXT DEFAULT 'he' NOT NULL,
description TEXT,
header_text TEXT,
body_text TEXT,
header_params TEXT DEFAULT '[]' NOT NULL,
body_params TEXT DEFAULT '[]' NOT NULL,
fallbacks TEXT DEFAULT '{}' NOT NULL,
button_type TEXT,
button_text TEXT,
button_url TEXT,
header_type TEXT DEFAULT 'TEXT' NOT NULL,
header_handle TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_whatsapp_templates_key ON whatsapp_templates(template_key);
CREATE INDEX IF NOT EXISTS idx_whatsapp_templates_created ON whatsapp_templates(created_at);

View File

@ -136,3 +136,41 @@ class RsvpToken(Base):
event = relationship("Event") event = relationship("Event")
guest = relationship("Guest") guest = relationship("Guest")
# ── WhatsApp Custom Templates ──────────────────────────────────────────────────
class WhatsAppTemplate(Base):
"""
Stores custom WhatsApp message templates created by users.
Built-in templates are defined in code, but custom templates are persisted here.
"""
__tablename__ = "whatsapp_templates"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
template_key = Column(String, unique=True, nullable=False, index=True) # e.g., "wedding_invitation_custom_1"
meta_name = Column(String, nullable=False) # Name as it appears in Meta Business Manager
friendly_name = Column(String, nullable=False) # Display name in frontend
language_code = Column(String, default="he", nullable=False) # e.g., "he", "en"
description = Column(Text, nullable=True)
# Template structure
header_text = Column(Text, nullable=True) # Header content
body_text = Column(Text, nullable=True) # Body content with {{1}}, {{2}} placeholders
# Parameters (stored as JSON string)
header_params = Column(Text, nullable=False, default="[]") # JSON array
body_params = Column(Text, nullable=False, default="[]") # JSON array
fallbacks = Column(Text, nullable=False, default="{}") # JSON object with default values
# Optional: Button configuration
button_type = Column(String, nullable=True) # "URL", "PHONE_NUMBER", "QUICK_REPLY"
button_text = Column(String, nullable=True)
button_url = Column(String, nullable=True)
# Media support
header_type = Column(String, default="TEXT", nullable=False) # "TEXT", "IMAGE", "VIDEO", "DOCUMENT"
header_handle = Column(String, nullable=True) # Media handle for IMAGE/VIDEO/DOCUMENT
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@ -23,11 +23,12 @@ class WhatsAppError(Exception):
class WhatsAppService: class WhatsAppService:
"""Service for sending WhatsApp messages via Meta API""" """Service for sending WhatsApp messages via Meta API"""
def __init__(self): def __init__(self, db=None):
self.access_token = os.getenv("WHATSAPP_ACCESS_TOKEN") self.access_token = os.getenv("WHATSAPP_ACCESS_TOKEN")
self.phone_number_id = os.getenv("WHATSAPP_PHONE_NUMBER_ID") self.phone_number_id = os.getenv("WHATSAPP_PHONE_NUMBER_ID")
self.api_version = os.getenv("WHATSAPP_API_VERSION", "v20.0") self.api_version = os.getenv("WHATSAPP_API_VERSION", "v20.0")
self.verify_token = os.getenv("WHATSAPP_VERIFY_TOKEN", "") self.verify_token = os.getenv("WHATSAPP_VERIFY_TOKEN", "")
self.db = db # Database session for template lookups
if not self.access_token or not self.phone_number_id: if not self.access_token or not self.phone_number_id:
raise WhatsAppError( raise WhatsAppError(
@ -438,7 +439,7 @@ class WhatsAppService:
""" """
from whatsapp_templates import get_template, build_params_list from whatsapp_templates import get_template, build_params_list
tpl = get_template(template_key) tpl = get_template(self.db, template_key)
meta_name = tpl["meta_name"] meta_name = tpl["meta_name"]
language_code = tpl.get("language_code", "he") language_code = tpl.get("language_code", "he")
header_type = tpl.get("header_type", "TEXT") header_type = tpl.get("header_type", "TEXT")
@ -446,7 +447,7 @@ class WhatsAppService:
button_type = tpl.get("button_type", "") button_type = tpl.get("button_type", "")
button_url = tpl.get("button_url", "") button_url = tpl.get("button_url", "")
header_values, body_values = build_params_list(template_key, params) header_values, body_values = build_params_list(self.db, template_key, params)
to_e164 = self.normalize_phone_to_e164(to_phone) to_e164 = self.normalize_phone_to_e164(to_phone)
if not self.validate_phone(to_e164): if not self.validate_phone(to_e164):
@ -633,9 +634,12 @@ class WhatsAppService:
_whatsapp_service: Optional[WhatsAppService] = None _whatsapp_service: Optional[WhatsAppService] = None
def get_whatsapp_service() -> WhatsAppService: def get_whatsapp_service(db=None) -> WhatsAppService:
"""Get or create WhatsApp service singleton""" """Get or create WhatsApp service singleton. Pass db if you need template lookups."""
global _whatsapp_service global _whatsapp_service
if _whatsapp_service is None: if _whatsapp_service is None:
_whatsapp_service = WhatsAppService() _whatsapp_service = WhatsAppService(db=db)
# Update db if provided (for template lookups)
if db is not None:
_whatsapp_service.db = db
return _whatsapp_service return _whatsapp_service

View File

@ -33,53 +33,102 @@ IMPORTANT: param order in header_params / body_params MUST match the
import json import json
import os import os
from typing import Dict, Any from typing import Dict, Any, Optional
from sqlalchemy.orm import Session
# ── Custom templates file ─────────────────────────────────────────────────────
CUSTOM_TEMPLATES_FILE = os.path.join(os.path.dirname(__file__), "custom_templates.json")
def load_custom_templates() -> Dict[str, Dict[str, Any]]: def load_custom_templates(db: Session) -> Dict[str, Dict[str, Any]]:
"""Load user-created templates from the JSON store.""" """Load user-created templates from the database."""
from models import WhatsAppTemplate
try: try:
with open(CUSTOM_TEMPLATES_FILE, "r", encoding="utf-8") as f: templates = db.query(WhatsAppTemplate).all()
return json.load(f) result = {}
except (FileNotFoundError, json.JSONDecodeError): for t in templates:
result[t.template_key] = {
"meta_name": t.meta_name,
"language_code": t.language_code,
"friendly_name": t.friendly_name,
"description": t.description,
"header_type": t.header_type,
"header_text": t.header_text,
"header_handle": t.header_handle,
"body_text": t.body_text,
"header_params": json.loads(t.header_params),
"body_params": json.loads(t.body_params),
"button_type": t.button_type,
"button_text": t.button_text,
"button_url": t.button_url,
"button_param_key": t.button_url, # backward compat
"fallbacks": json.loads(t.fallbacks),
"guest_name_key": "",
}
return result
except Exception as e:
print(f"[WARNING] Failed to load custom templates from database: {e}")
return {} return {}
def save_custom_templates(data: Dict[str, Dict[str, Any]]) -> None: def save_custom_templates(db: Session, data: Dict[str, Dict[str, Any]]) -> None:
"""Persist custom templates to the JSON store.""" """Persist custom templates to the database."""
with open(CUSTOM_TEMPLATES_FILE, "w", encoding="utf-8") as f: from models import WhatsAppTemplate
json.dump(data, f, ensure_ascii=False, indent=2)
try:
# Clear old templates
db.query(WhatsAppTemplate).delete()
# Add new ones
for key, tpl in data.items():
template_record = WhatsAppTemplate(
template_key=key,
meta_name=tpl.get("meta_name", key),
friendly_name=tpl.get("friendly_name", key),
language_code=tpl.get("language_code", "he"),
description=tpl.get("description", ""),
header_type=tpl.get("header_type", "TEXT"),
header_text=tpl.get("header_text", ""),
header_handle=tpl.get("header_handle", ""),
body_text=tpl.get("body_text", ""),
header_params=json.dumps(tpl.get("header_params", [])),
body_params=json.dumps(tpl.get("body_params", [])),
button_type=tpl.get("button_type", ""),
button_text=tpl.get("button_text", ""),
button_url=tpl.get("button_url", ""),
fallbacks=json.dumps(tpl.get("fallbacks", {})),
)
db.add(template_record)
db.commit()
except Exception as e:
print(f"[WARNING] Failed to save custom templates to database: {e}")
db.rollback()
def get_all_templates() -> Dict[str, Dict[str, Any]]: def get_all_templates(db: Session) -> Dict[str, Dict[str, Any]]:
"""Return merged dict: built-in TEMPLATES + user custom templates.""" """Return merged dict: built-in TEMPLATES + user custom templates."""
merged = dict(TEMPLATES) merged = dict(TEMPLATES)
merged.update(load_custom_templates()) merged.update(load_custom_templates(db))
return merged return merged
def add_custom_template(key: str, template: Dict[str, Any]) -> None: def add_custom_template(db: Session, key: str, template: Dict[str, Any]) -> None:
"""Add or overwrite a custom template (cannot replace built-ins).""" """Add or overwrite a custom template (cannot replace built-ins)."""
if key in TEMPLATES: if key in TEMPLATES:
raise ValueError(f"Template key '{key}' is a built-in and cannot be overwritten.") raise ValueError(f"Template key '{key}' is a built-in and cannot be overwritten.")
data = load_custom_templates() data = load_custom_templates(db)
data[key] = template data[key] = template
save_custom_templates(data) save_custom_templates(db, data)
def delete_custom_template(key: str) -> None: def delete_custom_template(db: Session, key: str) -> None:
"""Delete a custom template by key. Raises KeyError if not found.""" """Delete a custom template by key. Raises KeyError if not found."""
if key in TEMPLATES: if key in TEMPLATES:
raise ValueError(f"Template key '{key}' is a built-in and cannot be deleted.") raise ValueError(f"Template key '{key}' is a built-in and cannot be deleted.")
data = load_custom_templates() data = load_custom_templates(db)
if key not in data: if key not in data:
raise KeyError(f"Custom template '{key}' not found.") raise KeyError(f"Custom template '{key}' not found.")
del data[key] del data[key]
save_custom_templates(data) save_custom_templates(db, data)
# ── Template registry ───────────────────────────────────────────────────────── # ── Template registry ─────────────────────────────────────────────────────────
@ -187,12 +236,12 @@ TEMPLATES: Dict[str, Dict[str, Any]] = {
# ── Helper functions ────────────────────────────────────────────────────────── # ── Helper functions ──────────────────────────────────────────────────────────
def get_template(key: str) -> Dict[str, Any]: def get_template(db: Session, key: str) -> Dict[str, Any]:
""" """
Return the template definition for *key* (checks both built-in + custom). Return the template definition for *key* (checks both built-in + custom).
Raises KeyError with a helpful message if not found. Raises KeyError with a helpful message if not found.
""" """
all_tpls = get_all_templates() all_tpls = get_all_templates(db)
if key not in all_tpls: if key not in all_tpls:
available = ", ".join(all_tpls.keys()) available = ", ".join(all_tpls.keys())
raise KeyError( raise KeyError(
@ -202,13 +251,13 @@ def get_template(key: str) -> Dict[str, Any]:
return all_tpls[key] return all_tpls[key]
def list_templates_for_frontend() -> list: def list_templates_for_frontend(db: Session) -> list:
""" """
Return a list suitable for the frontend dropdown (built-in + custom). Return a list suitable for the frontend dropdown (built-in + custom).
Each item: {key, friendly_name, meta_name, param_count, language_code, description, is_custom} Each item: {key, friendly_name, meta_name, param_count, language_code, description, is_custom}
""" """
all_tpls = get_all_templates() all_tpls = get_all_templates(db)
custom_keys = set(load_custom_templates().keys()) custom_keys = set(load_custom_templates(db).keys())
return [ return [
{ {
"key": key, "key": key,
@ -237,14 +286,14 @@ def list_templates_for_frontend() -> list:
] ]
def build_params_list(key: str, values: dict) -> tuple: def build_params_list(db: Session, key: str, values: dict) -> tuple:
""" """
Given a template key and a dict of {param_key: value}, return Given a template key and a dict of {param_key: value}, return
(header_params_list, body_params_list) after applying fallbacks. (header_params_list, body_params_list) after applying fallbacks.
Both lists contain plain string values in correct order. Both lists contain plain string values in correct order.
""" """
tpl = get_template(key) # checks built-in + custom tpl = get_template(db, key) # checks built-in + custom
fallbacks = tpl.get("fallbacks", {}) fallbacks = tpl.get("fallbacks", {})
def resolve(param_key: str) -> str: def resolve(param_key: str) -> str: