Ready to merge before import from excel button

This commit is contained in:
dvirlabs 2026-02-28 23:56:11 +02:00
parent 6ec3689b21
commit 43fccacc47
15 changed files with 408 additions and 253 deletions

View File

@ -36,7 +36,7 @@ This error occurred because:
"components": [{ // ✅ Correct structure "components": [{ // ✅ Correct structure
"type": "body", "type": "body",
"parameters": [ "parameters": [
{"type": "text", "text": "דוד"}, {"type": "text", "text": "דביר"},
{"type": "text", "text": "שרה"}, {"type": "text", "text": "שרה"},
... ...
] ]
@ -53,8 +53,8 @@ Your template has **7 variables** that MUST be sent in this EXACT order:
| Placeholder | Field | Example | Fallback | | Placeholder | Field | Example | Fallback |
|------------|-------|---------|----------| |------------|-------|---------|----------|
| `{{1}}` | Guest name | "דוד" | "חבר" | | `{{1}}` | Guest name | "דביר" | "חבר" |
| `{{2}}` | Groom name | "דוד" | "החתן" | | `{{2}}` | Groom name | "דביר" | "החתן" |
| `{{3}}` | Bride name | "שרה" | "הכלה" | | `{{3}}` | Bride name | "שרה" | "הכלה" |
| `{{4}}` | Hall/Venue | "אולם בן-גוריון" | "האולם" | | `{{4}}` | Hall/Venue | "אולם בן-גוריון" | "האולם" |
| `{{5}}` | Event date | "15/06" | "—" | | `{{5}}` | Event date | "15/06" | "—" |
@ -106,7 +106,7 @@ Before sending to Meta API, logs show:
``` ```
[WhatsApp] Sending template 'wedding_invitation' Language: he, [WhatsApp] Sending template 'wedding_invitation' Language: he,
To: +972541234567, To: +972541234567,
Params (7): ['דוד', 'דוד', 'שרה', 'אולם בן-גוריון', '15/06', '18:30', 'https://invy...guest'] Params (7): ['דביר', 'דביר', 'שרה', 'אולם בן-גוריון', '15/06', '18:30', 'https://invy...guest']
``` ```
On success: On success:

View File

@ -111,7 +111,7 @@ The approved Meta template body (in Hebrew):
**Auto-filled by system:** **Auto-filled by system:**
- `{{1}}` = Guest first name (or "חבר" if empty) - `{{1}}` = Guest first name (or "חבר" if empty)
- `{{2}}` = `event.partner1_name` (e.g., "דוד") - `{{2}}` = `event.partner1_name` (e.g., "דביר")
- `{{3}}` = `event.partner2_name` (e.g., "וורד") - `{{3}}` = `event.partner2_name` (e.g., "וורד")
- `{{4}}` = `event.venue` (e.g., "אולם כלות גן עדן") - `{{4}}` = `event.venue` (e.g., "אולם כלות גן עדן")
- `{{5}}` = `event.date` formatted as DD/MM (e.g., "25/02") - `{{5}}` = `event.date` formatted as DD/MM (e.g., "25/02")
@ -157,7 +157,7 @@ Content-Type: application/json
Response: Response:
{ {
"guest_id": "uuid", "guest_id": "uuid",
"guest_name": "דוד", "guest_name": "דביר",
"phone": "+972541234567", "phone": "+972541234567",
"status": "sent" | "failed", "status": "sent" | "failed",
"message_id": "wamid.xxx...", "message_id": "wamid.xxx...",

View File

@ -20,8 +20,8 @@
"שם החתן" "שם החתן"
], ],
"fallbacks": { "fallbacks": {
"contact_name": וד", "contact_name": ביר",
"groom_name": וד", "groom_name": ביר",
"bride_name": "ורד", "bride_name": "ורד",
"venue": "אולם הגן", "venue": "אולם הגן",
"event_date": "15/06", "event_date": "15/06",

View File

@ -1249,15 +1249,17 @@ def get_event_guest_by_phone(
).first() ).first()
if not guest: if not guest:
raise HTTPException( # Guest not in list — allow self-service registration instead of blocking
status_code=404, return {
detail="לא נמצאת ברשימת האורחים לאירוע זה. אנא בדוק את מספר הטלפון.", "found": False,
) "phone_number": normalized or phone,
}
return { return {
"found": True,
"guest_id": str(guest.id), "guest_id": str(guest.id),
"first_name": guest.first_name, # NOTE: first_name / last_name intentionally omitted so the guest
"last_name": guest.last_name, # never sees the host's contact nickname — they enter their own name.
"rsvp_status": guest.rsvp_status, "rsvp_status": guest.rsvp_status,
"meal_preference": guest.meal_preference, "meal_preference": guest.meal_preference,
"has_plus_one": guest.has_plus_one, "has_plus_one": guest.has_plus_one,
@ -1290,10 +1292,52 @@ def submit_event_rsvp(
).first() ).first()
if not guest: if not guest:
raise HTTPException( # Guest not pre-imported — create them as a self-service entry
status_code=404, event_obj = db.query(models.Event).filter(models.Event.id == event_id).first()
detail="לא נמצאת ברשימת האורחים לאירוע זה.", if not event_obj:
raise HTTPException(status_code=404, detail="האירוע לא נמצא.")
# Find the event admin to use as added_by_user_id
admin_member = (
db.query(models.EventMember)
.filter(
models.EventMember.event_id == event_id,
models.EventMember.role == models.RoleEnum.admin,
)
.first()
) )
if not admin_member:
admin_member = (
db.query(models.EventMember)
.filter(models.EventMember.event_id == event_id)
.first()
)
if not admin_member:
raise HTTPException(status_code=404, detail="האירוע לא נמצא.")
guest = models.Guest(
event_id=event_id,
added_by_user_id=admin_member.user_id,
first_name=data.first_name or "",
last_name=data.last_name or "",
phone_number=normalized,
phone=normalized,
rsvp_status=data.rsvp_status or models.GuestStatus.invited,
meal_preference=data.meal_preference,
has_plus_one=data.has_plus_one or False,
plus_one_name=data.plus_one_name,
source="self-service",
)
db.add(guest)
db.commit()
db.refresh(guest)
return {
"success": True,
"message": "תודה! אישור ההגעה שלך נשמר.",
"guest_id": str(guest.id),
"rsvp_status": guest.rsvp_status,
}
if data.rsvp_status is not None: if data.rsvp_status is not None:
guest.rsvp_status = data.rsvp_status guest.rsvp_status = data.rsvp_status

View File

@ -35,7 +35,7 @@ async def test_combinations():
"language": {"code": "he"}, "language": {"code": "he"},
"components": [{ "components": [{
"type": "header", "type": "header",
"parameters": [{"type": "text", "text": "דוד"}] "parameters": [{"type": "text", "text": "דביר"}]
}] }]
} }
}), }),
@ -47,7 +47,7 @@ async def test_combinations():
"name": "wedding_invitation", "name": "wedding_invitation",
"language": {"code": "he"}, "language": {"code": "he"},
"components": [ "components": [
{"type": "header", "parameters": [{"type": "text", "text": "דוד"}]}, {"type": "header", "parameters": [{"type": "text", "text": "דביר"}]},
{"type": "body", "parameters": [ {"type": "body", "parameters": [
{"type": "text", "text": "p1"}, {"type": "text", "text": "p1"},
{"type": "text", "text": "p2"}, {"type": "text", "text": "p2"},
@ -68,7 +68,7 @@ async def test_combinations():
"language": {"code": "he"}, "language": {"code": "he"},
"components": [{ "components": [{
"type": "body", "type": "body",
"parameters": [{"type": "text", "text": "דוד"}] "parameters": [{"type": "text", "text": "דביר"}]
}] }]
} }
}), }),

View File

@ -39,8 +39,8 @@ async def test_whatsapp_send():
# Test data # Test data
phone = "0504370045" # Israeli format - should be converted to +972504370045 phone = "0504370045" # Israeli format - should be converted to +972504370045
guest_name = "דוד" guest_name = "דביר"
groom_name = "דוד" groom_name = "דביר"
bride_name = "שרה" bride_name = "שרה"
venue = "אולם בן-גוריון" venue = "אולם בן-גוריון"
event_date = "15/06" event_date = "15/06"

View File

@ -21,13 +21,13 @@ test_cases = [
{ {
"type": "header", "type": "header",
"parameters": [ "parameters": [
{"type": "text", "text": "דוד"} {"type": "text", "text": "דביר"}
] ]
}, },
{ {
"type": "body", "type": "body",
"parameters": [ "parameters": [
{"type": "text", "text": "דוד"}, {"type": "text", "text": "דביר"},
{"type": "text", "text": "שרה"}, {"type": "text", "text": "שרה"},
{"type": "text", "text": "אולם בן-גוריון"}, {"type": "text", "text": "אולם בן-גוריון"},
{"type": "text", "text": "15/06"}, {"type": "text", "text": "15/06"},
@ -42,12 +42,12 @@ test_cases = [
"components": [ "components": [
{ {
"type": "header", "type": "header",
"parameters": ["דוד"] "parameters": ["דביר"]
}, },
{ {
"type": "body", "type": "body",
"parameters": [ "parameters": [
{"type": "text", "text": "דוד"}, {"type": "text", "text": "דביר"},
{"type": "text", "text": "שרה"}, {"type": "text", "text": "שרה"},
{"type": "text", "text": "אולם בן-גוריון"}, {"type": "text", "text": "אולם בן-גוריון"},
{"type": "text", "text": "15/06"}, {"type": "text", "text": "15/06"},
@ -63,8 +63,8 @@ test_cases = [
{ {
"type": "body", "type": "body",
"parameters": [ "parameters": [
{"type": "text", "text": "דוד"}, {"type": "text", "text": "דביר"},
{"type": "text", "text": "דוד"}, {"type": "text", "text": "דביר"},
{"type": "text", "text": "שרה"}, {"type": "text", "text": "שרה"},
{"type": "text", "text": "אולם בן-גוריון"}, {"type": "text", "text": "אולם בן-גוריון"},
{"type": "text", "text": "15/06"}, {"type": "text", "text": "15/06"},
@ -80,7 +80,7 @@ test_cases = [
{ {
"type": "header", "type": "header",
"parameters": [ "parameters": [
{"type": "text", "text": "דוד"}, {"type": "text", "text": "דביר"},
{"type": "text", "text": "כללי"} {"type": "text", "text": "כללי"}
] ]
}, },

View File

@ -31,8 +31,8 @@ async def test_language_code():
template_name="wedding_invitation", template_name="wedding_invitation",
language_code="he_IL", # Try with locale language_code="he_IL", # Try with locale
parameters=[ parameters=[
"דוד", "דביר",
"דוד", "דביר",
"שרה", "שרה",
"אולם בן-גוריון", "אולם בן-גוריון",
"15/06", "15/06",

View File

@ -31,10 +31,10 @@ async def test_counts():
# Test different parameter counts # Test different parameter counts
test_params = [ test_params = [
(5, ["דוד", "דוד", "שרה", "אולם", "15/06"]), (5, ["דביר", "דביר", "שרה", "אולם", "15/06"]),
(6, ["דוד", "דוד", "שרה", "אולם", "15/06", "18:30"]), (6, ["דביר", "דביר", "שרה", "אולם", "15/06", "18:30"]),
(7, ["דוד", "דוד", "שרה", "אולם", "15/06", "18:30", "https://link"]), (7, ["דביר", "דביר", "שרה", "אולם", "15/06", "18:30", "https://link"]),
(8, ["דוד", "דוד", "שרה", "אולם", "15/06", "18:30", "https://link", "extra"]), (8, ["דביר", "דביר", "שרה", "אולם", "15/06", "18:30", "https://link", "extra"]),
] ]
print("Testing different parameter counts...") print("Testing different parameter counts...")

View File

@ -11,8 +11,8 @@ import json
# Sample template parameters (7 required) # Sample template parameters (7 required)
parameters = [ parameters = [
"דוד", # {{1}} contact_name "דביר", # {{1}} contact_name
"דוד", # {{2}} groom_name "דביר", # {{2}} groom_name
"שרה", # {{3}} bride_name "שרה", # {{3}} bride_name
"אולם בן-גוריון", # {{4}} hall_name "אולם בן-גוריון", # {{4}} hall_name
"15/06", # {{5}} event_date "15/06", # {{5}} event_date

View File

@ -43,8 +43,8 @@ async def test_payload():
{ {
"type": "body", "type": "body",
"parameters": [ "parameters": [
{"type": "text", "text": "דוד"}, {"type": "text", "text": "דביר"},
{"type": "text", "text": "דוד"}, {"type": "text", "text": "דביר"},
{"type": "text", "text": "שרה"}, {"type": "text", "text": "שרה"},
{"type": "text", "text": "אולם בן-גוריון"}, {"type": "text", "text": "אולם בן-גוריון"},
{"type": "text", "text": "15/06"}, {"type": "text", "text": "15/06"},
@ -72,8 +72,8 @@ async def test_payload():
{ {
"type": "body", "type": "body",
"parameters": [ "parameters": [
{"type": "text", "text": "דוד"}, {"type": "text", "text": "דביר"},
{"type": "text", "text": "דוד"}, {"type": "text", "text": "דביר"},
{"type": "text", "text": "שרה"}, {"type": "text", "text": "שרה"},
{"type": "text", "text": "אולם בן-גוריון"}, {"type": "text", "text": "אולם בן-גוריון"},
{"type": "text", "text": "15/06"}, {"type": "text", "text": "15/06"},
@ -97,8 +97,8 @@ async def test_payload():
{ {
"type": "body", "type": "body",
"parameters": [ "parameters": [
{"type": "text", "text": "דוד"}, {"type": "text", "text": "דביר"},
{"type": "text", "text": "דוד"}, {"type": "text", "text": "דביר"},
{"type": "text", "text": "שרה"}, {"type": "text", "text": "שרה"},
{"type": "text", "text": "אולם בן-גוריון"}, {"type": "text", "text": "אולם בן-גוריון"},
{"type": "text", "text": "15/06"}, {"type": "text", "text": "15/06"},

View File

@ -1,11 +1,6 @@
""" """
WhatsApp Cloud API Service WhatsApp Cloud API Service
Handles sending WhatsApp messages via Meta's Cloud API. Handles sending WhatsApp messages via Meta's API
Template system:
- All approved templates are declared in whatsapp_templates.py
- Use send_by_template_key() to send any registered template
- send_wedding_invitation() remains as a backward-compatible wrapper
""" """
import os import os
import httpx import httpx
@ -14,8 +9,6 @@ import logging
from typing import Optional from typing import Optional
from datetime import datetime from datetime import datetime
from whatsapp_templates import get_template, build_params_list, list_templates_for_frontend
# Setup logging # Setup logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -92,203 +85,251 @@ class WhatsAppService:
except Exception: except Exception:
return False return False
# ── Low-level: raw template sender ───────────────────────────────────── @staticmethod
def validate_template_params(params: list, expected_count: int = 8) -> bool:
async def _send_raw_template(
self,
to_e164: str,
meta_name: str,
language_code: str,
header_values: list,
body_values: list,
button_values: list = None,
) -> dict:
"""Build and POST a template message to Meta."""
components = []
if header_values:
components.append({
"type": "header",
"parameters": [{"type": "text", "text": v} for v in header_values],
})
if body_values:
components.append({
"type": "body",
"parameters": [{"type": "text", "text": v} for v in body_values],
})
# button_values is a list of (sub_type, index, payload) tuples
# e.g. [("url", 0, "abc123token")]
if button_values:
for sub_type, btn_index, payload in button_values:
components.append({
"type": "button",
"sub_type": sub_type,
"index": str(btn_index),
"parameters": [{"type": "text", "text": payload}],
})
payload = {
"messaging_product": "whatsapp",
"to": to_e164,
"type": "template",
"template": {
"name": meta_name,
"language": {"code": language_code},
"components": components,
},
}
def _mask(v: str) -> str:
return f"{v[:30]}...{v[-10:]}" if len(v) > 50 and v.startswith("http") else v
logger.info(
"[WhatsApp] template=%s lang=%s to=%s header_params=%d body_params=%d button_components=%d",
meta_name, language_code, to_e164, len(header_values), len(body_values),
len(button_values) if button_values else 0,
)
logger.debug(
"[WhatsApp] params header=%s body=%s buttons=%s",
[_mask(v) for v in header_values],
[_mask(v) for v in body_values],
button_values or [],
)
url = f"{self.base_url}/{self.phone_number_id}/messages"
try:
async with httpx.AsyncClient() as client:
response = await client.post(
url, json=payload, headers=self.headers, timeout=30.0
)
if response.status_code not in (200, 201):
error_data = response.json()
error_msg = error_data.get("error", {}).get("message", "Unknown error")
logger.error("[WhatsApp] API Error %d: %s", response.status_code, error_msg)
raise WhatsAppError(f"WhatsApp API error ({response.status_code}): {error_msg}")
result = response.json()
message_id = result.get("messages", [{}])[0].get("id")
logger.info("[WhatsApp] Sent OK message_id=%s", message_id)
return {
"message_id": message_id,
"status": "sent",
"to": to_e164,
"timestamp": datetime.utcnow().isoformat(),
"type": "template",
"template": meta_name,
}
except httpx.HTTPError as exc:
logger.error("[WhatsApp] HTTP error: %s", exc)
raise WhatsAppError(f"HTTP request failed: {exc}") from exc
except WhatsAppError:
raise
except Exception as exc:
logger.error("[WhatsApp] Unexpected error: %s", exc)
raise WhatsAppError(f"Failed to send WhatsApp template: {exc}") from exc
# ── Primary API: registry-driven ─────────────────────────────────────────
async def send_by_template_key(
self,
template_key: str,
to_phone: str,
params: dict,
) -> dict:
""" """
Send any registered template by its registry key. Validate template parameters
Args: Args:
template_key : key in TEMPLATES registry, e.g. "wedding_invitation" params: List of parameters to send
to_phone : recipient phone (normalized to E.164 automatically) expected_count: Expected number of parameters (default: 8)
params : dict {param_key: value}; missing keys use registry fallbacks Wedding template = 1 header param + 7 body params = 8 total
Returns: Returns:
dict with message_id, status, to, timestamp, template True if valid, otherwise raises WhatsAppError
""" """
to_e164 = self.normalize_phone_to_e164(to_phone) if not params:
if not self.validate_phone(to_e164): raise WhatsAppError(f"Parameters list is empty, expected {expected_count}")
raise WhatsAppError(f"Invalid phone number: {to_phone}")
if len(params) != expected_count:
tpl = get_template(template_key)
header_values, body_values = build_params_list(template_key, params)
expected = len(tpl["header_params"]) + len(tpl["body_params"])
actual = len(header_values) + len(body_values)
if actual != expected:
raise WhatsAppError( raise WhatsAppError(
f"Template '{template_key}': expected {expected} params, got {actual}" f"Parameter count mismatch: got {len(params)}, expected {expected_count}. "
f"Parameters: {params}"
) )
for i, v in enumerate(header_values + body_values, 1):
if not v.strip(): # Ensure all params are strings and non-empty
for i, param in enumerate(params, 1):
param_str = str(param).strip()
if not param_str:
raise WhatsAppError( raise WhatsAppError(
f"Template '{template_key}': param #{i} is empty after fallbacks" f"Parameter #{i} is empty or None. "
f"All {expected_count} parameters must have values. Parameters: {params}"
) )
# Build button components when the template has a url_button declaration return True
button_values = None
url_btn_cfg = tpl.get("url_button")
if url_btn_cfg and url_btn_cfg.get("enabled"):
param_key = url_btn_cfg.get("param_key", "rsvp_token")
btn_index = url_btn_cfg.get("index", 0)
btn_payload = params.get(param_key, "")
if btn_payload:
button_values = [("url", btn_index, str(btn_payload))]
return await self._send_raw_template(
to_e164=to_e164,
meta_name=tpl["meta_name"],
language_code=tpl["language_code"],
header_values=header_values,
body_values=body_values,
button_values=button_values,
)
# ── Plain text ────────────────────────────────────────────────────────────
async def send_text_message( async def send_text_message(
self, self,
to_phone: str, to_phone: str,
message_text: str, message_text: str,
context_message_id: Optional[str] = None, context_message_id: Optional[str] = None
) -> dict: ) -> dict:
"""Send a plain text message.""" """
Send a text message via WhatsApp Cloud API
Args:
to_phone: Recipient phone number (will be normalized to E.164)
message_text: Message body
context_message_id: Optional message ID to reply to
Returns:
dict with message_id and status
Raises:
WhatsAppError: If message fails to send
"""
# Normalize phone number
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):
raise WhatsAppError(f"Invalid phone number: {to_phone}") raise WhatsAppError(f"Invalid phone number: {to_phone}")
# Build payload
payload = { payload = {
"messaging_product": "whatsapp", "messaging_product": "whatsapp",
"to": to_e164, "to": to_e164,
"type": "text", "type": "text",
"text": {"body": message_text}, "text": {
"body": message_text
}
} }
# Add context if provided (for replies)
if context_message_id: if context_message_id:
payload["context"] = {"message_id": context_message_id} payload["context"] = {
"message_id": context_message_id
}
# Send to API
url = f"{self.base_url}/{self.phone_number_id}/messages" url = f"{self.base_url}/{self.phone_number_id}/messages"
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.post( response = await client.post(
url, json=payload, headers=self.headers, timeout=30.0 url,
json=payload,
headers=self.headers,
timeout=30.0
) )
if response.status_code not in (200, 201): if response.status_code not in (200, 201):
error_data = response.json() error_data = response.json()
error_msg = error_data.get("error", {}).get("message", "Unknown error") error_msg = error_data.get("error", {}).get("message", "Unknown error")
raise WhatsAppError(f"WhatsApp API error ({response.status_code}): {error_msg}") raise WhatsAppError(
f"WhatsApp API error ({response.status_code}): {error_msg}"
)
result = response.json() result = response.json()
return { return {
"message_id": result.get("messages", [{}])[0].get("id"), "message_id": result.get("messages", [{}])[0].get("id"),
"status": "sent", "status": "sent",
"to": to_e164, "to": to_e164,
"timestamp": datetime.utcnow().isoformat(), "timestamp": datetime.utcnow().isoformat(),
"type": "text", "type": "text"
} }
except httpx.HTTPError as exc:
raise WhatsAppError(f"HTTP request failed: {exc}") from exc except httpx.HTTPError as e:
except WhatsAppError: raise WhatsAppError(f"HTTP request failed: {str(e)}")
raise except Exception as e:
except Exception as exc: raise WhatsAppError(f"Failed to send WhatsApp message: {str(e)}")
raise WhatsAppError(f"Failed to send WhatsApp message: {exc}") from exc
async def send_template_message(
self,
to_phone: str,
template_name: str,
language_code: str = "en",
parameters: Optional[list] = None
) -> dict:
"""
Send a pre-approved template message via WhatsApp Cloud API
Args:
to_phone: Recipient phone number
template_name: Template name (must be approved by Meta)
language_code: Language code (default: en)
parameters: List of parameter values for template placeholders (must be 7 for wedding template)
Returns:
dict with message_id and status
"""
to_e164 = self.normalize_phone_to_e164(to_phone)
if not self.validate_phone(to_e164):
raise WhatsAppError(f"Invalid phone number: {to_phone}")
# Validate parameters
if not parameters:
raise WhatsAppError("Parameters list is required for template messages")
self.validate_template_params(parameters, expected_count=8)
# Convert all parameters to strings
param_list = [str(p).strip() for p in parameters]
# Build payload with correct Meta structure (includes "components" array)
# Template structure: Header (1 param) + Body (7 params)
# param_list[0] = guest_name (header)
# param_list[1] = guest_name (body {{1}} - repeated from header)
# param_list[2] = groom_name
# param_list[3] = bride_name
# param_list[4] = hall_name
# param_list[5] = event_date
# param_list[6] = event_time
# param_list[7] = guest_link
payload = {
"messaging_product": "whatsapp",
"to": to_e164,
"type": "template",
"template": {
"name": template_name,
"language": {
"code": language_code
},
"components": [
{
"type": "header",
"parameters": [
{"type": "text", "text": param_list[0]} # {{1}} - guest_name (header)
]
},
{
"type": "body",
"parameters": [
{"type": "text", "text": param_list[1]}, # {{1}} - guest_name (repeated)
{"type": "text", "text": param_list[2]}, # {{2}} - groom_name
{"type": "text", "text": param_list[3]}, # {{3}} - bride_name
{"type": "text", "text": param_list[4]}, # {{4}} - hall_name
{"type": "text", "text": param_list[5]}, # {{5}} - event_date
{"type": "text", "text": param_list[6]}, # {{6}} - event_time
{"type": "text", "text": param_list[7]} # {{7}} - guest_link
]
}
]
}
}
# DEBUG: Log what we're sending (mask long URLs)
masked_params = []
for p in param_list:
if len(p) > 50 and p.startswith("http"):
masked_params.append(f"{p[:30]}...{p[-10:]}")
else:
masked_params.append(p)
logger.info(
f"[WhatsApp] Sending template '{template_name}' "
f"Language: {language_code}, "
f"To: {to_e164}, "
f"Params ({len(param_list)}): {masked_params}"
)
url = f"{self.base_url}/{self.phone_number_id}/messages"
# DEBUG: Print the full payload
import json
print("\n" + "=" * 80)
print("[DEBUG] Full Payload Being Sent to Meta:")
print("=" * 80)
print(json.dumps(payload, indent=2, ensure_ascii=False))
print("=" * 80 + "\n")
try:
async with httpx.AsyncClient() as client:
response = await client.post(
url,
json=payload,
headers=self.headers,
timeout=30.0
)
if response.status_code not in (200, 201):
error_data = response.json()
error_msg = error_data.get("error", {}).get("message", "Unknown error")
logger.error(f"[WhatsApp] API Error ({response.status_code}): {error_msg}")
raise WhatsAppError(
f"WhatsApp API error ({response.status_code}): {error_msg}"
)
result = response.json()
message_id = result.get("messages", [{}])[0].get("id")
logger.info(f"[WhatsApp] Message sent successfully! ID: {message_id}")
return {
"message_id": message_id,
"status": "sent",
"to": to_e164,
"timestamp": datetime.utcnow().isoformat(),
"type": "template",
"template": template_name
}
except httpx.HTTPError as e:
logger.error(f"[WhatsApp] HTTP Error: {str(e)}")
raise WhatsAppError(f"HTTP request failed: {str(e)}")
except Exception as e:
logger.error(f"[WhatsApp] Unexpected error: {str(e)}")
raise WhatsAppError(f"Failed to send WhatsApp template: {str(e)}")
# ── Backward-compat wedding invitation ───────────────────────────────────
async def send_wedding_invitation( async def send_wedding_invitation(
self, self,
to_phone: str, to_phone: str,
@ -296,55 +337,127 @@ class WhatsAppService:
partner1_name: str, partner1_name: str,
partner2_name: str, partner2_name: str,
venue: str, venue: str,
event_date: str, event_date: str, # Should be formatted as DD/MM
event_time: str, event_time: str, # Should be formatted as HH:mm
guest_link: str, guest_link: str,
template_name: Optional[str] = None, template_name: Optional[str] = None,
language_code: Optional[str] = None, language_code: Optional[str] = None
template_key: Optional[str] = None,
) -> dict: ) -> dict:
""" """
Send a wedding invitation using the template registry. Send wedding invitation template message
template_key takes precedence; falls back to env WHATSAPP_TEMPLATE_KEY
or "wedding_invitation". IMPORTANT: Always sends exactly 7 parameters in this order:
{{1}} = contact_name (guest first name or fallback)
{{2}} = groom_name (partner1)
{{3}} = bride_name (partner2)
{{4}} = hall_name (venue)
{{5}} = event_date (DD/MM format)
{{6}} = event_time (HH:mm format)
{{7}} = guest_link (RSVP link)
Args:
to_phone: Recipient phone number
guest_name: Guest first name
partner1_name: First partner name (groom)
partner2_name: Second partner name (bride)
venue: Wedding venue/hall name
event_date: Event date in DD/MM format
event_time: Event time in HH:mm format
guest_link: RSVP/guest link
template_name: Meta template name (uses env var if not provided)
language_code: Language code (uses env var if not provided)
Returns:
dict with message_id and status
""" """
key = ( # Use environment defaults if not provided
template_key template_name = template_name or os.getenv("WHATSAPP_TEMPLATE_NAME", "wedding_invitation")
or os.getenv("WHATSAPP_TEMPLATE_KEY", "wedding_invitation") language_code = language_code or os.getenv("WHATSAPP_LANGUAGE_CODE", "he")
# Build 8 parameters with safe fallbacks
# The template requires: 1 header param + 7 body params
# Body {{1}} = guest_name (same as header, repeated)
param_1_contact_name = (guest_name or "").strip() or "חבר"
param_2_groom_name = (partner1_name or "").strip() or "החתן"
param_3_bride_name = (partner2_name or "").strip() or "הכלה"
param_4_hall_name = (venue or "").strip() or "האולם"
param_5_event_date = (event_date or "").strip() or ""
param_6_event_time = (event_time or "").strip() or ""
param_7_guest_link = (guest_link or "").strip() or f"{os.getenv('FRONTEND_URL', 'http://localhost:5174')}/guest?event_id=unknown"
parameters = [
param_1_contact_name, # header {{1}}
param_1_contact_name, # body {{1}} - guest name repeated
param_2_groom_name, # body {{2}}
param_3_bride_name, # body {{3}}
param_4_hall_name, # body {{4}}
param_5_event_date, # body {{5}}
param_6_event_time, # body {{6}}
param_7_guest_link # body {{7}}
]
logger.info(
f"[WhatsApp Invitation] Building params for {to_phone}: "
f"guest={param_1_contact_name}, groom={param_2_groom_name}, "
f"bride={param_3_bride_name}, venue={param_4_hall_name}, "
f"date={param_5_event_date}, time={param_6_event_time}"
) )
params = {
"contact_name": (guest_name or "").strip(), # Use standard template sending with validated parameters
"groom_name": (partner1_name or "").strip(), return await self.send_template_message(
"bride_name": (partner2_name or "").strip(), to_phone=to_phone,
"venue": (venue or "").strip(), template_name=template_name,
"event_date": (event_date or "").strip(), language_code=language_code,
"event_time": (event_time or "").strip(), parameters=parameters
"guest_link": (guest_link or "").strip(), )
}
return await self.send_by_template_key(key, to_phone, params)
# ── Webhook helpers ────────────────────────────────────────────────────
def handle_webhook_verification(self, challenge: str) -> str: def handle_webhook_verification(self, challenge: str) -> str:
"""
Handle webhook verification challenge from Meta
Args:
challenge: The challenge string from Meta
Returns:
The challenge string to echo back
"""
return challenge return challenge
def verify_webhook_signature(self, body: str, signature: str) -> bool: def verify_webhook_signature(self, body: str, signature: str) -> bool:
"""
Verify webhook signature from Meta
Args:
body: Raw request body
signature: x-hub-signature header value
Returns:
True if signature is valid
"""
import hmac import hmac
import hashlib import hashlib
if not self.verify_token: if not self.verify_token:
return False return False
# Extract signature from header (format: sha1=...)
try: try:
_, hash_value = signature.split("=") hash_algo, hash_value = signature.split("=")
except ValueError: except ValueError:
return False return False
expected = hmac.new(
self.verify_token.encode(), body.encode(), hashlib.sha1 # Compute expected signature
expected_signature = hmac.new(
self.verify_token.encode(),
body.encode(),
hashlib.sha1
).hexdigest() ).hexdigest()
return hmac.compare_digest(hash_value, expected)
# Constant-time comparison
return hmac.compare_digest(hash_value, expected_signature)
# ── Singleton ───────────────────────────────────────────────────────────────── # Singleton instance
_whatsapp_service: Optional[WhatsAppService] = None _whatsapp_service: Optional[WhatsAppService] = None
@ -354,9 +467,3 @@ def get_whatsapp_service() -> WhatsAppService:
if _whatsapp_service is None: if _whatsapp_service is None:
_whatsapp_service = WhatsAppService() _whatsapp_service = WhatsAppService()
return _whatsapp_service return _whatsapp_service
def reset_whatsapp_service() -> None:
"""Force recreation of the singleton (useful after env-var changes in tests)"""
global _whatsapp_service
_whatsapp_service = None

View File

@ -54,17 +54,21 @@ function GuestSelfService({ eventId }) {
setLoading(true) setLoading(true)
try { try {
const guestData = await getGuestForEvent(eventId, phoneNumber) const guestData = await getGuestForEvent(eventId, phoneNumber)
setGuest(guestData) // Always present the form regardless of whether the guest was pre-imported.
// Never pre-fill the name the host may have saved a nickname in their
// contacts that the guest should not see.
setGuest(guestData) // found:true or found:false both show the RSVP form
setFormData({ setFormData({
first_name: guestData.first_name || '', first_name: '', // guest enters their own name
last_name: guestData.last_name || '', last_name: '',
rsvp_status: guestData.rsvp_status || 'invited', rsvp_status: guestData.rsvp_status || 'invited',
meal_preference: guestData.meal_preference || '', meal_preference: guestData.meal_preference || '',
has_plus_one: guestData.has_plus_one || false, has_plus_one: guestData.has_plus_one || false,
plus_one_name: guestData.plus_one_name || '', plus_one_name: guestData.plus_one_name || '',
}) })
} catch { } catch {
setError('לא נמצאת ברשימת האורחים לאירוע זה. אנא בדוק את מספר הטלפון ונסה שוב.') // Only real network / server errors reach here
setError('אירעה שגיאה. אנא נסה שוב.')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -260,7 +264,7 @@ function GuestSelfService({ eventId }) {
/* ── Step 2: RSVP form ── */ /* ── Step 2: RSVP form ── */
<div className="update-form-container"> <div className="update-form-container">
<div className="guest-info"> <div className="guest-info">
<h2>שלום {guest.first_name || ''}! 👋</h2> <h2>שלום! 👋</h2>
<p className="guest-note">אנא אשר את הגעתך והעדפותיך</p> <p className="guest-note">אנא אשר את הגעתך והעדפותיך</p>
{!success && ( {!success && (
<button <button

View File

@ -4,8 +4,8 @@ import './TemplateEditor.css'
// Param catalogue // Param catalogue
const PARAM_OPTIONS = [ const PARAM_OPTIONS = [
{ key: 'contact_name', label: 'שם האורח', sample: וד' }, { key: 'contact_name', label: 'שם האורח', sample: ביר' },
{ key: 'groom_name', label: 'שם החתן', sample: וד' }, { key: 'groom_name', label: 'שם החתן', sample: ביר' },
{ key: 'bride_name', label: 'שם הכלה', sample: 'ורד' }, { key: 'bride_name', label: 'שם הכלה', sample: 'ורד' },
{ key: 'venue', label: 'שם האולם', sample: 'אולם הגן' }, { key: 'venue', label: 'שם האולם', sample: 'אולם הגן' },
{ key: 'event_date', label: 'תאריך האירוע', sample: '15/06' }, { key: 'event_date', label: 'תאריך האירוע', sample: '15/06' },

View File

@ -6,7 +6,7 @@ import './WhatsAppInviteModal.css'
// contact_name is always resolved per-guest on the backend; never shown as a field. // contact_name is always resolved per-guest on the backend; never shown as a field.
const SYSTEM_FIELDS = { const SYSTEM_FIELDS = {
contact_name: null, // skip auto-filled from guest record contact_name: null, // skip auto-filled from guest record
groom_name: { label: 'שם החתן', type: 'text', placeholder: וד', required: true }, groom_name: { label: 'שם החתן', type: 'text', placeholder: ביר', required: true },
bride_name: { label: 'שם הכלה', type: 'text', placeholder: 'ורד', required: true }, bride_name: { label: 'שם הכלה', type: 'text', placeholder: 'ורד', required: true },
venue: { label: 'שם האולם / מקום', type: 'text', placeholder: 'אולם הגן', required: true }, venue: { label: 'שם האולם / מקום', type: 'text', placeholder: 'אולם הגן', required: true },
event_date: { label: 'תאריך האירוע', type: 'date', required: true }, event_date: { label: 'תאריך האירוע', type: 'date', required: true },