From 74594b241149ca6858704256d1b7c111c204894d Mon Sep 17 00:00:00 2001 From: dvirlabs <114520947+dvirlabs@users.noreply.github.com> Date: Tue, 12 May 2026 11:40:29 +0300 Subject: [PATCH] Store WhatsApp templates in database for persistence across rebuilds --- backend/main.py | 20 ++++--- backend/migrations.sql | 28 +++++++++ backend/models.py | 38 ++++++++++++ backend/whatsapp.py | 16 +++-- backend/whatsapp_templates.py | 107 +++++++++++++++++++++++++--------- 5 files changed, 165 insertions(+), 44 deletions(-) diff --git a/backend/main.py b/backend/main.py index 3fd9d81..57960af 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 diff --git a/backend/migrations.sql b/backend/migrations.sql index 446d174..2a705b3 100644 --- a/backend/migrations.sql +++ b/backend/migrations.sql @@ -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); diff --git a/backend/models.py b/backend/models.py index f3dd598..2845fb8 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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()) diff --git a/backend/whatsapp.py b/backend/whatsapp.py index 2c34756..80ba8bf 100644 --- a/backend/whatsapp.py +++ b/backend/whatsapp.py @@ -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 diff --git a/backend/whatsapp_templates.py b/backend/whatsapp_templates.py index 4c77427..51901c4 100644 --- a/backend/whatsapp_templates.py +++ b/backend/whatsapp_templates.py @@ -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: