Store WhatsApp templates in database for persistence across rebuilds
This commit is contained in:
parent
ca9375a779
commit
74594b2411
@ -585,7 +585,7 @@ async def send_guest_message(
|
||||
phone = message_req.phone or guest.phone
|
||||
|
||||
try:
|
||||
service = get_whatsapp_service()
|
||||
service = get_whatsapp_service(db)
|
||||
result = await service.send_text_message(phone, message_req.message)
|
||||
return result
|
||||
except WhatsAppError as e:
|
||||
@ -635,7 +635,7 @@ async def broadcast_whatsapp_message(
|
||||
failed = []
|
||||
|
||||
try:
|
||||
service = get_whatsapp_service()
|
||||
service = get_whatsapp_service(db)
|
||||
for guest in guests:
|
||||
try:
|
||||
result = await service.send_text_message(guest.phone, message)
|
||||
@ -667,16 +667,17 @@ async def broadcast_whatsapp_message(
|
||||
# WhatsApp Template Registry Endpoints
|
||||
# ============================================
|
||||
@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 {"templates": list_templates_for_frontend()}
|
||||
return {"templates": list_templates_for_frontend(db)}
|
||||
|
||||
|
||||
@app.post("/whatsapp/templates")
|
||||
async def create_whatsapp_template(
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user_id = Depends(get_current_user_id)
|
||||
):
|
||||
"""
|
||||
@ -733,7 +734,7 @@ async def create_whatsapp_template(
|
||||
}
|
||||
|
||||
try:
|
||||
add_custom_template(key, template)
|
||||
add_custom_template(db, key, template)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
|
||||
@ -743,6 +744,7 @@ async def create_whatsapp_template(
|
||||
@app.delete("/whatsapp/templates/{key}")
|
||||
async def delete_whatsapp_template(
|
||||
key: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user_id = Depends(get_current_user_id)
|
||||
):
|
||||
"""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")
|
||||
|
||||
try:
|
||||
delete_custom_template(key)
|
||||
delete_custom_template(db, key)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=403, detail=str(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("/")
|
||||
guest_link = f"{_gl_base}/{event_id}"
|
||||
|
||||
service = get_whatsapp_service()
|
||||
service = get_whatsapp_service(db)
|
||||
result = await service.send_wedding_invitation(
|
||||
to_phone=to_phone,
|
||||
guest_name=guest_name,
|
||||
@ -882,7 +884,7 @@ async def send_wedding_invitation_bulk(
|
||||
results = []
|
||||
import asyncio
|
||||
|
||||
service = get_whatsapp_service()
|
||||
service = get_whatsapp_service(db)
|
||||
|
||||
for guest in guests:
|
||||
try:
|
||||
@ -949,7 +951,7 @@ async def send_wedding_invitation_bulk(
|
||||
# Auto-inject guest_name_key + event_id for url_button templates
|
||||
try:
|
||||
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", "")
|
||||
if _gnk:
|
||||
params[_gnk] = guest.first_name or guest_name
|
||||
|
||||
@ -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_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);
|
||||
|
||||
@ -136,3 +136,41 @@ class RsvpToken(Base):
|
||||
|
||||
event = relationship("Event")
|
||||
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())
|
||||
|
||||
@ -23,11 +23,12 @@ class WhatsAppError(Exception):
|
||||
class WhatsAppService:
|
||||
"""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.phone_number_id = os.getenv("WHATSAPP_PHONE_NUMBER_ID")
|
||||
self.api_version = os.getenv("WHATSAPP_API_VERSION", "v20.0")
|
||||
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:
|
||||
raise WhatsAppError(
|
||||
@ -438,7 +439,7 @@ class WhatsAppService:
|
||||
"""
|
||||
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"]
|
||||
language_code = tpl.get("language_code", "he")
|
||||
header_type = tpl.get("header_type", "TEXT")
|
||||
@ -446,7 +447,7 @@ class WhatsAppService:
|
||||
button_type = tpl.get("button_type", "")
|
||||
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)
|
||||
if not self.validate_phone(to_e164):
|
||||
@ -633,9 +634,12 @@ class WhatsAppService:
|
||||
_whatsapp_service: Optional[WhatsAppService] = None
|
||||
|
||||
|
||||
def get_whatsapp_service() -> WhatsAppService:
|
||||
"""Get or create WhatsApp service singleton"""
|
||||
def get_whatsapp_service(db=None) -> WhatsAppService:
|
||||
"""Get or create WhatsApp service singleton. Pass db if you need template lookups."""
|
||||
global _whatsapp_service
|
||||
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
|
||||
|
||||
@ -33,53 +33,102 @@ IMPORTANT: param order in header_params / body_params MUST match the
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, Any
|
||||
|
||||
# ── Custom templates file ─────────────────────────────────────────────────────
|
||||
|
||||
CUSTOM_TEMPLATES_FILE = os.path.join(os.path.dirname(__file__), "custom_templates.json")
|
||||
from typing import Dict, Any, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
def load_custom_templates() -> Dict[str, Dict[str, Any]]:
|
||||
"""Load user-created templates from the JSON store."""
|
||||
def load_custom_templates(db: Session) -> Dict[str, Dict[str, Any]]:
|
||||
"""Load user-created templates from the database."""
|
||||
from models import WhatsAppTemplate
|
||||
|
||||
try:
|
||||
with open(CUSTOM_TEMPLATES_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
templates = db.query(WhatsAppTemplate).all()
|
||||
result = {}
|
||||
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 {}
|
||||
|
||||
|
||||
def save_custom_templates(data: Dict[str, Dict[str, Any]]) -> None:
|
||||
"""Persist custom templates to the JSON store."""
|
||||
with open(CUSTOM_TEMPLATES_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
def save_custom_templates(db: Session, data: Dict[str, Dict[str, Any]]) -> None:
|
||||
"""Persist custom templates to the database."""
|
||||
from models import WhatsAppTemplate
|
||||
|
||||
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."""
|
||||
merged = dict(TEMPLATES)
|
||||
merged.update(load_custom_templates())
|
||||
merged.update(load_custom_templates(db))
|
||||
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)."""
|
||||
if key in TEMPLATES:
|
||||
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
|
||||
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."""
|
||||
if key in TEMPLATES:
|
||||
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:
|
||||
raise KeyError(f"Custom template '{key}' not found.")
|
||||
del data[key]
|
||||
save_custom_templates(data)
|
||||
save_custom_templates(db, data)
|
||||
|
||||
|
||||
# ── Template registry ─────────────────────────────────────────────────────────
|
||||
@ -187,12 +236,12 @@ TEMPLATES: Dict[str, Dict[str, Any]] = {
|
||||
|
||||
# ── 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).
|
||||
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:
|
||||
available = ", ".join(all_tpls.keys())
|
||||
raise KeyError(
|
||||
@ -202,13 +251,13 @@ def get_template(key: str) -> Dict[str, Any]:
|
||||
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).
|
||||
Each item: {key, friendly_name, meta_name, param_count, language_code, description, is_custom}
|
||||
"""
|
||||
all_tpls = get_all_templates()
|
||||
custom_keys = set(load_custom_templates().keys())
|
||||
all_tpls = get_all_templates(db)
|
||||
custom_keys = set(load_custom_templates(db).keys())
|
||||
return [
|
||||
{
|
||||
"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
|
||||
(header_params_list, body_params_list) after applying fallbacks.
|
||||
|
||||
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", {})
|
||||
|
||||
def resolve(param_key: str) -> str:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user