From ad189b8568561e5181af01f512f7720fcf2831c2 Mon Sep 17 00:00:00 2001 From: debu-2 Date: Wed, 14 Oct 2026 05:30:45 +0530 Subject: [PATCH] template --- backend/app/main.py | 2 + backend/app/models.py | 30 + backend/app/routes_whatsapp.py | 2 + backend/app/routes_whatsapp_templates.py | 98 +++ backend/app/whatsapp_template_service.py | 703 ++++++++++++++++++ frontend/src/App.tsx | 7 + frontend/src/components/AppShell.tsx | 3 +- .../features/whatsapp/WaTemplatePreview.tsx | 44 ++ .../whatsapp/WhatsAppBroadcastPage.tsx | 11 +- .../whatsapp/WhatsAppTemplateDetailPage.tsx | 111 +++ .../whatsapp/WhatsAppTemplateWizard.tsx | 497 +++++++++++++ .../whatsapp/WhatsAppTemplatesPage.tsx | 183 +++++ .../src/features/whatsapp/templateModel.ts | 157 ++++ frontend/src/index.css | 58 ++ frontend/src/lib/api.ts | 87 +++ 15 files changed, 1989 insertions(+), 4 deletions(-) create mode 100644 backend/app/routes_whatsapp_templates.py create mode 100644 backend/app/whatsapp_template_service.py create mode 100644 frontend/src/features/whatsapp/WaTemplatePreview.tsx create mode 100644 frontend/src/features/whatsapp/WhatsAppTemplateDetailPage.tsx create mode 100644 frontend/src/features/whatsapp/WhatsAppTemplateWizard.tsx create mode 100644 frontend/src/features/whatsapp/WhatsAppTemplatesPage.tsx create mode 100644 frontend/src/features/whatsapp/templateModel.ts diff --git a/backend/app/main.py b/backend/app/main.py index 977d57b..48dbd75 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -26,6 +26,7 @@ from .routes_numbers import router as numbers_router from .routes_urmiotp import router as urmiotp_router from .routes_voice import router as voice_router from .routes_whatsapp import router as whatsapp_router +from .routes_whatsapp_templates import router as whatsapp_templates_router from .routes_meta import router as meta_router from .routes_agent_templates import router as agent_templates_router from .routes_agent_messages import router as agent_messages_router @@ -109,6 +110,7 @@ app.include_router(numbers_router) app.include_router(integrations_router) app.include_router(voice_router) app.include_router(whatsapp_router) +app.include_router(whatsapp_templates_router) app.include_router(meta_router) app.include_router(agent_templates_router) app.include_router(agent_messages_router) diff --git a/backend/app/models.py b/backend/app/models.py index 3c19cbe..6a334fd 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -614,6 +614,36 @@ class WhatsAppPhoneAsset(Base): account: Mapped[WhatsAppBusinessAccount] = relationship(back_populates="phones") +class WhatsAppMessageTemplate(Base): + """A WhatsApp message template owned by one WABA. Meta remains the source of approval.""" + + __tablename__ = "whatsapp_message_templates" + __table_args__ = (UniqueConstraint("waba_id", "name", "language", name="uq_wa_template_name_lang"),) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True) + account_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("whatsapp_business_accounts.id", ondelete="CASCADE"), index=True + ) + waba_id: Mapped[str] = mapped_column(String(64), index=True) + phone_number_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + meta_template_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + name: Mapped[str] = mapped_column(String(512), index=True) + language: Mapped[str] = mapped_column(String(16), default="en_US") + category: Mapped[str] = mapped_column(String(32), default="MARKETING") + subtype: Mapped[str] = mapped_column(String(40), default="default") + status: Mapped[str] = mapped_column(String(32), default="PENDING", index=True) + quality: Mapped[str | None] = mapped_column(String(24), nullable=True) + rejected_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + parameter_format: Mapped[str] = mapped_column(String(16), default="POSITIONAL") + definition: Mapped[dict] = mapped_column(JSON, default=dict) + components: Mapped[list] = mapped_column(JSON, default=list) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + class WhatsAppConnectionAudit(Base): __tablename__ = "whatsapp_connection_audits" diff --git a/backend/app/routes_whatsapp.py b/backend/app/routes_whatsapp.py index fbbe22b..67ab57b 100644 --- a/backend/app/routes_whatsapp.py +++ b/backend/app/routes_whatsapp.py @@ -43,6 +43,7 @@ from .whatsapp_onboarding import ( from .whatsapp_security import verify_meta_signature from .whatsapp_webhook import persist_webhook_payload from .whatsapp_messages import record_inbound_messages +from .whatsapp_template_service import apply_template_status_webhook logger = logging.getLogger(__name__) @@ -131,6 +132,7 @@ async def receive_webhook( persist_webhook_payload(db, payload) record_inbound_messages(db, payload) + apply_template_status_webhook(db, payload) return {"status": "ok"} diff --git a/backend/app/routes_whatsapp_templates.py b/backend/app/routes_whatsapp_templates.py new file mode 100644 index 0000000..47f0f30 --- /dev/null +++ b/backend/app/routes_whatsapp_templates.py @@ -0,0 +1,98 @@ +from typing import Any + +from fastapi import APIRouter, Depends, File, Form, UploadFile +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from .auth import current_user +from .database import get_db +from .models import UserAccount +from .whatsapp_template_service import ( + create_managed_template, + delete_managed_template, + get_managed_template, + language_catalog, + list_managed_templates, + resubmit_managed_template, + upload_header_media, +) + +router = APIRouter(prefix="/api/v1/whatsapp/message-templates", tags=["whatsapp-templates"]) + + +class TemplateWriteRequest(BaseModel): + account_id: str | None = None + phone_number_id: str | None = None + name: str = Field(min_length=1, max_length=512) + language: str = "en_US" + category: str + subtype: str = "default" + parameter_format: str = "POSITIONAL" + header: dict[str, Any] = Field(default_factory=lambda: {"type": "NONE"}) + body: str = "" + body_examples: list[str] = Field(default_factory=list) + footer: str = "" + buttons: list[dict[str, Any]] = Field(default_factory=list) + auth: dict[str, Any] = Field(default_factory=dict) + + +@router.get("/languages") +def whatsapp_template_languages(_: UserAccount = Depends(current_user)) -> list[dict[str, str]]: + return language_catalog() + + +@router.get("") +async def whatsapp_template_list( + account_id: str | None = None, + db: Session = Depends(get_db), + user: UserAccount = Depends(current_user), +) -> dict: + return await list_managed_templates(db, user, account_id) + + +@router.post("/media") +async def whatsapp_template_media( + account_id: str = Form(), + file: UploadFile = File(), + db: Session = Depends(get_db), + user: UserAccount = Depends(current_user), +) -> dict: + data = await file.read() + return await upload_header_media(db, user, account_id, file.filename or "header", file.content_type or "", data) + + +@router.get("/{template_id}") +def whatsapp_template_detail( + template_id: str, + db: Session = Depends(get_db), + user: UserAccount = Depends(current_user), +) -> dict: + return get_managed_template(db, user, template_id) + + +@router.post("") +async def whatsapp_template_create( + payload: TemplateWriteRequest, + db: Session = Depends(get_db), + user: UserAccount = Depends(current_user), +) -> dict: + return await create_managed_template(db, user, payload.model_dump()) + + +@router.post("/{template_id}/resubmit") +async def whatsapp_template_resubmit( + template_id: str, + payload: TemplateWriteRequest, + db: Session = Depends(get_db), + user: UserAccount = Depends(current_user), +) -> dict: + return await resubmit_managed_template(db, user, template_id, payload.model_dump()) + + +@router.delete("/{template_id}") +async def whatsapp_template_delete( + template_id: str, + db: Session = Depends(get_db), + user: UserAccount = Depends(current_user), +) -> dict: + return await delete_managed_template(db, user, template_id) diff --git a/backend/app/whatsapp_template_service.py b/backend/app/whatsapp_template_service.py new file mode 100644 index 0000000..d4eb161 --- /dev/null +++ b/backend/app/whatsapp_template_service.py @@ -0,0 +1,703 @@ +"""WhatsApp message template lifecycle against the customer's WABA.""" + +from __future__ import annotations + +import re +from typing import Any + +import httpx +from fastapi import HTTPException, status +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session, selectinload + +from .config import settings +from .models import UserAccount, WhatsAppBusinessAccount, WhatsAppMessage, WhatsAppMessageTemplate +from .whatsapp_onboarding import _graph, _graph_error_message, _owned_account, _quality_label, _token_app_id + +_NAME = re.compile(r"^[a-z0-9_]{1,512}$") +_LANG = re.compile(r"^[a-z]{2}(_[A-Z]{2})?$") +_VAR = re.compile(r"\{\{([A-Za-z0-9_]+)\}\}") +_MEDIA = { + "image/jpeg": "IMAGE", + "image/png": "IMAGE", + "video/mp4": "VIDEO", + "application/pdf": "DOCUMENT", +} + +LANGUAGES = [ + ("en", "English"), + ("en_US", "English (US)"), + ("en_GB", "English (UK)"), + ("hi", "Hindi"), + ("bn", "Bengali"), + ("ta", "Tamil"), + ("te", "Telugu"), + ("mr", "Marathi"), + ("gu", "Gujarati"), + ("kn", "Kannada"), + ("ml", "Malayalam"), + ("pa", "Punjabi"), + ("ur", "Urdu"), + ("ar", "Arabic"), + ("es", "Spanish"), + ("es_MX", "Spanish (Mexico)"), + ("pt_BR", "Portuguese (Brazil)"), + ("fr", "French"), + ("de", "German"), + ("id", "Indonesian"), + ("it", "Italian"), + ("nl", "Dutch"), + ("ru", "Russian"), + ("tr", "Turkish"), + ("ja", "Japanese"), + ("ko", "Korean"), + ("zh_CN", "Chinese (China)"), + ("zh_HK", "Chinese (Hong Kong)"), +] + + +def language_catalog() -> list[dict[str, str]]: + return [{"code": code, "label": label} for code, label in LANGUAGES] + + +def _language_ok(code: str) -> bool: + return any(item[0] == code for item in LANGUAGES) + + +def _token(account: WhatsAppBusinessAccount) -> str: + connection = account.connection + token = connection.access_token if connection else None + if connection is None or not token or connection.status == "disconnected": + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Save the WhatsApp access token before managing templates.") + return token + + +def _variables(text: str) -> list[str]: + found: list[str] = [] + for match in _VAR.finditer(text or ""): + name = match.group(1) + if name not in found: + found.append(name) + return found + + +def _examples_for(text: str, examples: list[str], named: bool) -> list[str]: + names = _variables(text) + if len(examples) < len(names): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Every variable needs a sample value.") + if named and any(name.isdigit() for name in names): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Named variables cannot be only numbers. Use a name such as customer_name.") + if not named: + expected = [str(index) for index in range(1, len(names) + 1)] + if names != expected: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Numbered variables must be {{1}}, {{2}}, and so on, with no gaps.") + return examples[: len(names)] + + +def build_components(payload: dict[str, Any]) -> list[dict[str, Any]]: + category = str(payload.get("category") or "").upper() + named = str(payload.get("parameter_format") or "POSITIONAL").upper() == "NAMED" + if category == "AUTHENTICATION": + auth = payload.get("auth") if isinstance(payload.get("auth"), dict) else {} + minutes = int(auth.get("code_expiration_minutes") or 0) + components: list[dict[str, Any]] = [{ + "type": "BODY", + "add_security_recommendation": bool(auth.get("add_security_recommendation", True)), + }] + if minutes: + if minutes < 1 or minutes > 90: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Code expiry should be between 1 and 90 minutes.") + components.append({"type": "FOOTER", "code_expiration_minutes": minutes}) + components.append({ + "type": "BUTTONS", + "buttons": [{ + "type": "OTP", + "otp_type": "COPY_CODE", + "text": str(auth.get("copy_label") or "Copy code")[:25] or "Copy code", + }], + }) + return components + + header = payload.get("header") if isinstance(payload.get("header"), dict) else {"type": "NONE"} + body = str(payload.get("body") or "").strip() + footer = str(payload.get("footer") or "").strip() + buttons = payload.get("buttons") if isinstance(payload.get("buttons"), list) else [] + if not body: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Message body is required.") + if len(body) > 1024: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Message body can be at most 1,024 characters.") + if len(footer) > 60: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Footer can be at most 60 characters.") + if _variables(footer): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Footer cannot contain variables.") + + components = [] + header_type = str(header.get("type") or "NONE").upper() + if header_type == "TEXT": + text = str(header.get("text") or "").strip() + if not text: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Header text is required.") + if len(text) > 60: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Header text can be at most 60 characters.") + samples = _examples_for(text, list(header.get("examples") or []), named) + block: dict[str, Any] = {"type": "HEADER", "format": "TEXT", "text": text} + if samples: + names = _variables(text) + if named: + block["example"] = {"header_text_named_params": [ + {"param_name": name, "example": sample} for name, sample in zip(names, samples, strict=False) + ]} + else: + block["example"] = {"header_text": samples} + components.append(block) + elif header_type in {"IMAGE", "VIDEO", "DOCUMENT"}: + handle = str(header.get("handle") or "").strip() + if not handle: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Upload a header file before submitting.") + components.append({ + "type": "HEADER", + "format": header_type, + "example": {"header_handle": [handle]}, + }) + elif header_type == "LOCATION": + components.append({"type": "HEADER", "format": "LOCATION"}) + elif header_type != "NONE": + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Choose a supported header type.") + + body_samples = _examples_for(body, [str(item) for item in (payload.get("body_examples") or [])], named) + body_block: dict[str, Any] = {"type": "BODY", "text": body} + if body_samples: + names = _variables(body) + if named: + body_block["example"] = {"body_text_named_params": [ + {"param_name": name, "example": sample} for name, sample in zip(names, body_samples, strict=False) + ]} + else: + body_block["example"] = {"body_text": [body_samples]} + components.append(body_block) + if footer: + components.append({"type": "FOOTER", "text": footer}) + if buttons: + components.append({"type": "BUTTONS", "buttons": _buttons(buttons, category)}) + return components + + +def _buttons(buttons: list[Any], category: str) -> list[dict[str, Any]]: + if len(buttons) > 10: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="A template can have at most 10 buttons.") + built: list[dict[str, Any]] = [] + urls = phones = copies = 0 + for raw in buttons: + if not isinstance(raw, dict): + continue + kind = str(raw.get("type") or "").upper() + label = str(raw.get("text") or "").strip() + if kind != "COPY_CODE" and (not label or len(label) > 25): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Each button label should be 1 to 25 characters.") + if kind == "URL": + urls += 1 + url = str(raw.get("url") or "").strip() + if not url.startswith("https://"): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Website buttons need an https URL.") + button: dict[str, Any] = {"type": "URL", "text": label, "url": url} + names = _variables(url) + if len(names) > 1 or (names and not url.endswith("{{" + names[0] + "}}")): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="A website button can use one variable, and it has to be at the end of the URL.") + if names: + example = str(raw.get("example") or "").strip() + if not example.startswith("https://"): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Sample URL should be a full https address with the variable filled in.") + button["example"] = [example] + built.append(button) + elif kind == "PHONE_NUMBER": + phones += 1 + phone = re.sub(r"\D", "", str(raw.get("phone") or "")) + if len(phone) < 8: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Enter a phone number with the country code.") + built.append({"type": "PHONE_NUMBER", "text": label, "phone_number": phone}) + elif kind == "QUICK_REPLY": + built.append({"type": "QUICK_REPLY", "text": label}) + elif kind == "COPY_CODE": + if category != "MARKETING": + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Copy offer code is available on marketing templates.") + copies += 1 + example = str(raw.get("example") or "").strip() + if not example or len(example) > 15: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Offer code sample should be 1 to 15 characters.") + built.append({"type": "COPY_CODE", "example": example}) + elif kind == "CATALOG": + built.append({"type": "CATALOG", "text": label}) + elif kind == "FLOW": + flow_id = str(raw.get("flow_id") or "").strip() + if not flow_id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="A WhatsApp Flow button needs the Flow ID from Meta.") + built.append({ + "type": "FLOW", + "text": label, + "flow_id": flow_id, + "flow_action": "navigate", + "navigate_screen": str(raw.get("screen") or "MAIN"), + }) + else: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="That button type is not supported for this template.") + if urls > 2 or phones > 1 or copies > 1: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Use at most two website buttons, one phone button, and one offer code.", + ) + kinds = [str(item.get("type") or "") for item in built] + quick = [index for index, kind in enumerate(kinds) if kind == "QUICK_REPLY"] + if quick and (quick != list(range(quick[0], quick[0] + len(quick))) or quick[0] not in {0, len(kinds) - len(quick)}): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Keep quick reply buttons together, either all at the start or all at the end.", + ) + return built + + +def _definition_from_components(components: list[Any], category: str) -> dict[str, Any]: + definition: dict[str, Any] = { + "category": category, + "subtype": "otp" if category == "AUTHENTICATION" else "default", + "parameter_format": "POSITIONAL", + "header": {"type": "NONE"}, + "body": "", + "body_examples": [], + "footer": "", + "buttons": [], + "auth": { + "otp_type": "COPY_CODE", + "add_security_recommendation": True, + "code_expiration_minutes": 0, + "copy_label": "Copy code", + }, + } + for component in components: + if not isinstance(component, dict): + continue + kind = str(component.get("type") or "").upper() + if kind == "HEADER": + fmt = str(component.get("format") or "TEXT").upper() + example = component.get("example") if isinstance(component.get("example"), dict) else {} + if fmt == "TEXT": + samples = example.get("header_text") if isinstance(example.get("header_text"), list) else [] + definition["header"] = {"type": "TEXT", "text": component.get("text") or "", "examples": samples} + elif fmt == "LOCATION": + definition["header"] = {"type": "LOCATION"} + else: + handles = example.get("header_handle") if isinstance(example.get("header_handle"), list) else [] + definition["header"] = {"type": fmt, "handle": handles[0] if handles else "", "preview_name": fmt.title()} + elif kind == "BODY": + definition["body"] = component.get("text") or "" + example = component.get("example") if isinstance(component.get("example"), dict) else {} + if isinstance(example.get("body_text"), list) and example["body_text"] and isinstance(example["body_text"][0], list): + definition["body_examples"] = example["body_text"][0] + named = example.get("body_text_named_params") + if isinstance(named, list): + definition["parameter_format"] = "NAMED" + definition["body_examples"] = [item.get("example") for item in named if isinstance(item, dict)] + if "add_security_recommendation" in component: + definition["auth"]["add_security_recommendation"] = bool(component.get("add_security_recommendation")) + elif kind == "FOOTER": + definition["footer"] = component.get("text") or "" + if component.get("code_expiration_minutes"): + definition["auth"]["code_expiration_minutes"] = component.get("code_expiration_minutes") + elif kind == "BUTTONS": + for button in component.get("buttons") or []: + if not isinstance(button, dict): + continue + button_type = str(button.get("type") or "").upper() + if button_type == "OTP": + definition["auth"]["copy_label"] = button.get("text") or "Copy code" + continue + definition["buttons"].append({ + "type": button_type, + "text": button.get("text") or "", + "url": button.get("url") or "", + "phone": button.get("phone_number") or "", + "example": (button.get("example") or [""])[0] if isinstance(button.get("example"), list) else button.get("example") or "", + "flow_id": button.get("flow_id") or "", + "screen": button.get("navigate_screen") or "", + }) + return definition + + +def _sent_counts(db: Session, user: UserAccount) -> dict[str, int]: + rows = db.execute( + select(WhatsAppMessage.body, func.count()) + .where(WhatsAppMessage.user_id == user.id, WhatsAppMessage.message_type == "template") + .group_by(WhatsAppMessage.body) + ).all() + counts: dict[str, int] = {} + for body, count in rows: + text = str(body or "") + for prefix in ("Template: ", "Broadcast: "): + if text.startswith(prefix): + name = text[len(prefix):] + counts[name] = counts.get(name, 0) + int(count) + return counts + + +def _serialize(row: WhatsAppMessageTemplate, account: WhatsAppBusinessAccount | None, sent: int) -> dict[str, Any]: + phone = None + if account is not None: + phone = next((item for item in account.phones if item.phone_number_id == row.phone_number_id), None) + if phone is None and account.phones: + phone = account.phones[0] + return { + "id": str(row.id), + "account_id": str(row.account_id), + "account_name": account.name if account else "WhatsApp Business", + "waba_name": account.name if account else "", + "phone_number_id": row.phone_number_id, + "display_phone": phone.display_phone if phone else "", + "meta_template_id": row.meta_template_id, + "name": row.name, + "language": row.language, + "category": row.category, + "subtype": row.subtype, + "status": row.status, + "quality": row.quality, + "rejected_reason": row.rejected_reason, + "parameter_format": row.parameter_format, + "definition": row.definition or {}, + "components": row.components or [], + "messages_sent": sent, + "messages_sent_source": "urmi", + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _upsert_remote(db: Session, account: WhatsAppBusinessAccount, remote: dict[str, Any]) -> WhatsAppMessageTemplate: + name = str(remote.get("name") or "") + language = str(remote.get("language") or "en") + row = db.scalar( + select(WhatsAppMessageTemplate).where( + WhatsAppMessageTemplate.waba_id == account.waba_id, + WhatsAppMessageTemplate.name == name, + WhatsAppMessageTemplate.language == language, + ) + ) + components = remote.get("components") if isinstance(remote.get("components"), list) else [] + category = str(remote.get("category") or "MARKETING").upper() + quality = remote.get("quality_score") + score = quality.get("score") if isinstance(quality, dict) else None + reason = remote.get("rejected_reason") + if isinstance(reason, dict): + reason = reason.get("reason") or reason.get("message") + if row is None: + row = WhatsAppMessageTemplate( + user_id=account.user_id, + account_id=account.id, + waba_id=account.waba_id, + name=name, + language=language, + definition=_definition_from_components(components, category), + ) + db.add(row) + row.meta_template_id = str(remote.get("id") or row.meta_template_id or "") or None + row.category = category + if components: + definition = _definition_from_components(components, category) + if row.subtype and row.subtype != "default": + definition["subtype"] = row.subtype + row.definition = definition + row.parameter_format = str(definition.get("parameter_format") or row.parameter_format) + row.subtype = str(definition.get("subtype") or row.subtype or "default")[:40] + row.status = str(remote.get("status") or "PENDING").upper() + row.quality = _quality_label(str(score) if score else None) + row.rejected_reason = str(reason)[:2000] if reason and str(reason).upper() not in {"NONE", "NULL"} else None + row.components = components + if not row.definition: + row.definition = _definition_from_components(components, category) + return row + + +async def _template_page(token: str, waba_id: str, params: dict[str, Any]) -> dict[str, Any]: + last: HTTPException | None = None + for fields in ( + "id,name,language,status,category,components,quality_score,rejected_reason", + "id,name,language,status,category,components", + ): + try: + return await _graph("GET", f"{waba_id}/message_templates", token=token, params={**params, "fields": fields}) + except HTTPException as exc: + last = exc + detail = str(exc.detail).lower() + if "nonexisting field" in detail or "(#100)" in detail: + continue + raise + if last is not None: + raise last + return {} + + +async def sync_account_templates(db: Session, account: WhatsAppBusinessAccount) -> str | None: + token = account.connection.access_token if account.connection else None + if not token: + return "This account has no access token, so templates could not be refreshed." + seen: set[tuple[str, str]] = set() + after = None + try: + for _ in range(5): + params = {"limit": "100"} + if after: + params["after"] = after + data = await _template_page(token, account.waba_id, params) + rows = data.get("data") if isinstance(data.get("data"), list) else [] + for remote in rows: + if isinstance(remote, dict) and remote.get("name"): + _upsert_remote(db, account, remote) + seen.add((str(remote.get("name")), str(remote.get("language") or ""))) + paging = data.get("paging") if isinstance(data.get("paging"), dict) else {} + cursors = paging.get("cursors") if isinstance(paging.get("cursors"), dict) else {} + after = cursors.get("after") if paging.get("next") else None + if not after: + break + except HTTPException as exc: + db.rollback() + return str(exc.detail) + stored = db.scalars( + select(WhatsAppMessageTemplate).where(WhatsAppMessageTemplate.waba_id == account.waba_id) + ).all() + for row in stored: + if row.meta_template_id and (row.name, row.language) not in seen: + row.status = "DISABLED" + account.template_count = len(seen) + account.templates_status = "active" + db.commit() + return None + + +async def list_managed_templates(db: Session, user: UserAccount, account_id: str | None) -> dict[str, Any]: + query = ( + select(WhatsAppBusinessAccount) + .options(selectinload(WhatsAppBusinessAccount.phones), selectinload(WhatsAppBusinessAccount.connection)) + .where(WhatsAppBusinessAccount.user_id == user.id) + ) + accounts = db.scalars(query).all() + if account_id: + accounts = [account for account in accounts if str(account.id) == account_id] + warnings = [] + for account in accounts: + warning = await sync_account_templates(db, account) + if warning: + warnings.append(f"{account.name}: {warning}") + rows = db.scalars( + select(WhatsAppMessageTemplate) + .where( + WhatsAppMessageTemplate.user_id == user.id, + WhatsAppMessageTemplate.account_id.in_([account.id for account in accounts] or [user.id]), + ) + .order_by(WhatsAppMessageTemplate.updated_at.desc()) + ).all() if accounts else [] + by_id = {account.id: account for account in accounts} + sent = _sent_counts(db, user) + templates = [_serialize(row, by_id.get(row.account_id), sent.get(row.name, 0)) for row in rows] + summary = { + "total": len(templates), + "approved": sum(1 for item in templates if item["status"] == "APPROVED"), + "pending": sum(1 for item in templates if item["status"] in {"PENDING", "IN_APPEAL"}), + "rejected": sum(1 for item in templates if item["status"] == "REJECTED"), + "paused": sum(1 for item in templates if item["status"] == "PAUSED"), + } + return {"templates": templates, "summary": summary, "warnings": warnings} + + +def _owned_template(db: Session, user: UserAccount, template_id: Any) -> tuple[WhatsAppMessageTemplate, WhatsAppBusinessAccount]: + row = db.scalar( + select(WhatsAppMessageTemplate).where( + WhatsAppMessageTemplate.id == template_id, + WhatsAppMessageTemplate.user_id == user.id, + ) + ) + if row is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") + account = _owned_account(db, user, row.account_id) + return row, account + + +def get_managed_template(db: Session, user: UserAccount, template_id: Any) -> dict[str, Any]: + row, account = _owned_template(db, user, template_id) + sent = _sent_counts(db, user).get(row.name, 0) + payload = _serialize(row, account, sent) + bodies = [f"Template: {row.name}", f"Broadcast: {row.name}"] + status_rows = db.execute( + select(WhatsAppMessage.status, func.count()) + .where( + WhatsAppMessage.user_id == user.id, + WhatsAppMessage.message_type == "template", + or_(*[WhatsAppMessage.body == body for body in bodies]), + ) + .group_by(WhatsAppMessage.status) + ).all() + payload["analytics"] = { + "source": "urmi", + "note": "Counts are messages Urmi sent with this template name, plus delivery updates Urmi received. They are separate from Meta's template analytics.", + "sent": sent, + "by_status": {str(item or "unknown"): int(count) for item, count in status_rows}, + } + return payload + + +def _validate_identity(payload: dict[str, Any]) -> tuple[str, str, str]: + name = str(payload.get("name") or "").strip().lower() + language = str(payload.get("language") or "en_US").strip() + category = str(payload.get("category") or "").upper() + if not _NAME.match(name): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Template name should use lowercase letters, numbers, and underscores.") + if not _language_ok(language): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Choose a language from the list.") + if category not in {"MARKETING", "UTILITY", "AUTHENTICATION"}: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Choose Marketing, Utility, or Authentication.") + return name, language, category + + +async def create_managed_template(db: Session, user: UserAccount, payload: dict[str, Any]) -> dict[str, Any]: + account = _owned_account(db, user, payload.get("account_id")) + token = _token(account) + name, language, category = _validate_identity(payload) + existing = db.scalar( + select(WhatsAppMessageTemplate).where( + WhatsAppMessageTemplate.waba_id == account.waba_id, + WhatsAppMessageTemplate.name == name, + WhatsAppMessageTemplate.language == language, + ) + ) + if existing is not None and existing.status != "DISABLED": + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This template name and language already exist on the WhatsApp account.") + if existing is not None: + db.delete(existing) + db.flush() + components = build_components(payload) + body: dict[str, Any] = {"name": name, "language": language, "category": category, "components": components} + if category != "AUTHENTICATION": + body["parameter_format"] = "NAMED" if str(payload.get("parameter_format") or "").upper() == "NAMED" else "POSITIONAL" + data = await _graph("POST", f"{account.waba_id}/message_templates", token=token, json_body=body) + phone_id = str(payload.get("phone_number_id") or "").strip() or None + if phone_id and phone_id not in {phone.phone_number_id for phone in account.phones}: + phone_id = None + row = WhatsAppMessageTemplate( + user_id=user.id, + account_id=account.id, + waba_id=account.waba_id, + phone_number_id=phone_id, + meta_template_id=str(data.get("id") or "") or None, + name=name, + language=language, + category=str(data.get("category") or category).upper(), + subtype=str(payload.get("subtype") or "default")[:40], + status=str(data.get("status") or "PENDING").upper(), + parameter_format="NAMED" if str(payload.get("parameter_format") or "").upper() == "NAMED" else "POSITIONAL", + definition=payload, + components=components, + ) + db.add(row) + db.commit() + db.refresh(row) + return _serialize(row, account, 0) + + +async def resubmit_managed_template(db: Session, user: UserAccount, template_id: Any, payload: dict[str, Any]) -> dict[str, Any]: + row, account = _owned_template(db, user, template_id) + if row.status not in {"REJECTED", "PAUSED", "APPROVED"}: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This template can be edited after Meta rejects it, pauses it, or approves it.") + if not row.meta_template_id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Meta has no id for this template yet.") + _validate_identity({**payload, "name": row.name, "language": row.language, "category": payload.get("category") or row.category}) + components = build_components({**payload, "name": row.name, "language": row.language}) + data = await _graph("POST", row.meta_template_id, token=_token(account), json_body={"components": components, "category": row.category}) + row.components = components + row.definition = payload + row.status = str(data.get("status") or "PENDING").upper() + row.rejected_reason = None + row.category = str(data.get("category") or row.category).upper() + db.commit() + db.refresh(row) + return _serialize(row, account, _sent_counts(db, user).get(row.name, 0)) + + +async def delete_managed_template(db: Session, user: UserAccount, template_id: Any) -> dict[str, str]: + row, account = _owned_template(db, user, template_id) + params: dict[str, Any] = {"name": row.name} + if row.meta_template_id: + params["hsm_id"] = row.meta_template_id + await _graph("DELETE", f"{account.waba_id}/message_templates", token=_token(account), params=params) + db.delete(row) + db.commit() + return {"status": "deleted"} + + +async def upload_header_media(db: Session, user: UserAccount, account_id: Any, filename: str, content_type: str, data: bytes) -> dict[str, str]: + account = _owned_account(db, user, account_id) + token = _token(account) + kind = _MEDIA.get(content_type) + if kind is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Upload a JPEG, PNG, MP4, or PDF.") + if len(data) > 16 * 1024 * 1024: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Header files can be at most 16 MB.") + app_id = (account.connection.meta_app_id if account.connection else None) or settings.meta_app_id.strip() + if not app_id: + app_id = await _token_app_id(token) or "" + if not app_id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This connection has no Meta app id, so the header file cannot be uploaded.") + session = await _graph( + "POST", + f"{app_id}/uploads", + token=token, + params={"file_name": filename or "header", "file_length": str(len(data)), "file_type": content_type}, + ) + upload_id = str(session.get("id") or "") + if not upload_id: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Meta did not start the header upload.") + url = f"https://graph.facebook.com/{settings.whatsapp_api_version}/{upload_id}" + async with httpx.AsyncClient(timeout=120.0) as client: + response = await client.post( + url, + content=data, + headers={"Authorization": f"OAuth {token}", "file_offset": "0", "Content-Type": content_type}, + ) + try: + body = response.json() + except ValueError: + body = {} + if response.status_code >= 400: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=_graph_error_message(body, "Meta did not accept the header file.")) + handle = str(body.get("h") or "") + if not handle: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Meta did not return a header file handle.") + return {"handle": handle, "format": kind} + + +def apply_template_status_webhook(db: Session, payload: dict[str, Any]) -> None: + changed = False + for entry in payload.get("entry") or []: + if not isinstance(entry, dict): + continue + for change in entry.get("changes") or []: + if not isinstance(change, dict) or change.get("field") != "message_template_status_update": + continue + value = change.get("value") if isinstance(change.get("value"), dict) else {} + name = str(value.get("message_template_name") or "") + language = str(value.get("message_template_language") or "") + event = str(value.get("event") or "").upper() + if not name or not event: + continue + rows = db.scalars( + select(WhatsAppMessageTemplate).where( + WhatsAppMessageTemplate.name == name, + or_(WhatsAppMessageTemplate.language == language, language == ""), + ) + ).all() + reason = value.get("reason") + for row in rows: + row.status = event + if reason and str(reason).upper() not in {"NONE", "NULL"}: + row.rejected_reason = str(reason)[:2000] + meta_id = str(value.get("message_template_id") or "") + if meta_id: + row.meta_template_id = meta_id + changed = True + if changed: + db.commit() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c396c6b..6f6aa5c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,9 @@ import { LogsPage } from './features/logs/LogsPage' import { AnalyticsPage } from './features/analytics/AnalyticsPage' import { WhatsAppSetupPage } from './features/whatsapp/WhatsAppSetupPage' import { WhatsAppBroadcastPage } from './features/whatsapp/WhatsAppBroadcastPage' +import { WhatsAppTemplateDetailPage } from './features/whatsapp/WhatsAppTemplateDetailPage' +import { WhatsAppTemplatesPage } from './features/whatsapp/WhatsAppTemplatesPage' +import { WhatsAppTemplateWizard } from './features/whatsapp/WhatsAppTemplateWizard' import { WhatsAppTokenPage } from './features/whatsapp/WhatsAppTokenPage' import { MetaOverviewPage } from './features/meta/OverviewPage' import { MetaConnectionsPage } from './features/meta/ConnectionsPage' @@ -76,6 +79,10 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 92493fc..99d0aef 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { Activity, BarChart3, Bell, BookOpen, Bot, Boxes, ChevronDown, CircleDollarSign, - Code2, Cog, CreditCard, Cpu, FileCheck2, FileText, Gauge, Globe, Image, KeyRound, LayoutDashboard, + ClipboardList, Code2, Cog, CreditCard, Cpu, FileCheck2, FileText, Gauge, Globe, Image, KeyRound, LayoutDashboard, Link2, LogOut, Megaphone, Menu, MessageSquareText, Network, Phone, Search, Send, Settings, ShieldCheck, SlidersHorizontal, TestTube2, Users, Webhook, X, Zap, MessageCircle, @@ -31,6 +31,7 @@ const groups = [ { label: 'Communicate', links: [ { label: 'Messaging', path: '/console/messaging', icon: MessageSquareText }, { label: 'WhatsApp broadcast', path: '/console/whatsapp/broadcast', icon: Send }, + { label: 'WA Templates', path: '/console/whatsapp/templates', icon: ClipboardList }, { label: 'Campaigns', path: '/console/campaigns', icon: Zap }, { label: 'Templates', path: '/console/templates', icon: BookOpen }, ]}, diff --git a/frontend/src/features/whatsapp/WaTemplatePreview.tsx b/frontend/src/features/whatsapp/WaTemplatePreview.tsx new file mode 100644 index 0000000..0472daa --- /dev/null +++ b/frontend/src/features/whatsapp/WaTemplatePreview.tsx @@ -0,0 +1,44 @@ +import type { WaTemplateDraft } from '../../lib/api' +import { variableNames, withSamples } from './templateModel' + +export function WaTemplatePreview({ draft }: { draft: WaTemplateDraft }) { + const auth = draft.category === 'AUTHENTICATION' + const header = auth ? '' : draft.header.type === 'TEXT' ? withSamples(draft.header.text, draft.header.examples) : '' + const body = auth + ? `${draft.auth.add_security_recommendation ? 'Your verification code is 123456. For your security, do not share this code.' : 'Your verification code is 123456.'}` + : withSamples(draft.body, draft.body_examples) + const footer = auth + ? (draft.auth.code_expiration_minutes ? `This code expires in ${draft.auth.code_expiration_minutes} minutes.` : '') + : draft.footer + const buttons = auth + ? [draft.auth.copy_label || 'Copy code'] + : draft.buttons.map((button) => button.type === 'COPY_CODE' ? `Copy code ${button.example}` : button.text || button.type) + + return ( + + ) +} + +export function previewBody(draft: WaTemplateDraft): string { + if (draft.category === 'AUTHENTICATION') return 'Your verification code is 123456.' + return withSamples(draft.body, draft.body_examples) || variableNames(draft.body).join(', ') +} diff --git a/frontend/src/features/whatsapp/WhatsAppBroadcastPage.tsx b/frontend/src/features/whatsapp/WhatsAppBroadcastPage.tsx index 01389d6..8b134ee 100644 --- a/frontend/src/features/whatsapp/WhatsAppBroadcastPage.tsx +++ b/frontend/src/features/whatsapp/WhatsAppBroadcastPage.tsx @@ -1,6 +1,6 @@ import { Loader2 } from 'lucide-react' import { useEffect, useState, type FormEvent } from 'react' -import { Link } from 'react-router-dom' +import { Link, useSearchParams } from 'react-router-dom' import { whatsappApi, type WhatsAppAccount, type WhatsAppTemplateRow } from '../../lib/api' const TEAM_TEMPLATE_NAME = 'urmi_team_aircheck' @@ -19,6 +19,7 @@ type CreatedTemplate = { } export function WhatsAppBroadcastPage() { + const [params] = useSearchParams() const [accounts, setAccounts] = useState([]) const [accountId, setAccountId] = useState('') const [templates, setTemplates] = useState([]) @@ -57,8 +58,12 @@ export function WhatsAppBroadcastPage() { const marketing = payload.templates.filter((item) => (item.category || '').toUpperCase() === 'MARKETING') setTemplates(marketing) setTemplatesReady(payload.available) - const approved = marketing.find((item) => (item.status || '').toUpperCase() === 'APPROVED' && item.name) - if (approved?.name) { + const requested = params.get('template') + const requestedLanguage = params.get('language') + const approved = marketing.find((item) => item.name === requested) || marketing.find((item) => (item.status || '').toUpperCase() === 'APPROVED' && item.name) + if (requested) setTemplateName(requested) + if (requestedLanguage) setTemplateLanguage(requestedLanguage) + if (approved?.name && !requested) { setTemplateName(approved.name) setTemplateLanguage(approved.language || 'en') } diff --git a/frontend/src/features/whatsapp/WhatsAppTemplateDetailPage.tsx b/frontend/src/features/whatsapp/WhatsAppTemplateDetailPage.tsx new file mode 100644 index 0000000..d1b2a3a --- /dev/null +++ b/frontend/src/features/whatsapp/WhatsAppTemplateDetailPage.tsx @@ -0,0 +1,111 @@ +import { useEffect, useState } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import { waTemplateApi, type WaManagedTemplate, type WaTemplateDraft } from '../../lib/api' +import { WaTemplatePreview } from './WaTemplatePreview' +import { emptyDraft } from './templateModel' + +const STATUS_LABEL: Record = { + APPROVED: 'Approved', + PENDING: 'Pending', + REJECTED: 'Rejected', + PAUSED: 'Paused', + DISABLED: 'Disabled', + IN_APPEAL: 'In appeal', +} + +export function WhatsAppTemplateDetailPage() { + const { templateId = '' } = useParams() + const navigate = useNavigate() + const [row, setRow] = useState(null) + const [error, setError] = useState('') + const [tab, setTab] = useState<'details' | 'analytics'>('details') + + useEffect(() => { + waTemplateApi.get(templateId) + .then(setRow) + .catch((reason) => setError(reason instanceof Error ? reason.message : 'Template not found')) + }, [templateId]) + + async function remove() { + if (!row || !window.confirm(`Delete ${row.name}?`)) return + try { + await waTemplateApi.remove(row.id) + navigate('/console/whatsapp/templates') + } catch (reason) { + setError(reason instanceof Error ? reason.message : 'This template could not be deleted') + } + } + + if (error && !row) return

{error}

+ if (!row) return
Loading template…
+ + const draft: WaTemplateDraft = { + ...emptyDraft(), + ...row.definition, + category: ((row.definition.category || row.category) as WaTemplateDraft['category']) || '', + name: row.name, + language: row.language, + header: { ...emptyDraft().header, ...row.definition.header }, + auth: { ...emptyDraft().auth, ...row.definition.auth }, + buttons: row.definition.buttons || [], + body_examples: row.definition.body_examples || [], + } + const analytics = row.analytics + + return ( +
+
+
+ Message templates +

{row.name}

+

{STATUS_LABEL[row.status] || row.status}

+
+
+ Duplicate + {row.status === 'APPROVED' && Send} + {['REJECTED', 'PAUSED', 'APPROVED'].includes(row.status) && {row.status === 'APPROVED' ? 'Edit' : 'Edit and resubmit'}} + +
+
+ {error &&

{error}

} +
+ + +
+ {tab === 'details' ? ( +
+
+
+
Meta template ID
{row.meta_template_id || 'Not assigned yet'}
+
Account
{row.account_name}
+
Phone
{row.display_phone || 'Shared on the account'}
+
Category
{row.category.toLowerCase()}
+
Language
{row.language}
+
Quality
{row.quality || 'Not rated yet'}
+
Updated
{row.updated_at ? new Date(row.updated_at).toLocaleString() : '—'}
+
+ {row.rejected_reason && ( +
+ Why Meta paused or rejected it +

{row.rejected_reason}

+

Update the message so it matches Meta’s template rules, then submit it again. Meta reviews the new version.

+
+ )} +
+ +
+ ) : ( +
+

Messages recorded in Urmi

+

{analytics?.note}

+
+
Sent{analytics?.sent ?? row.messages_sent}
+ {Object.entries(analytics?.by_status || {}).map(([label, value]) => ( +
{label}{value}
+ ))} +
+
+ )} +
+ ) +} diff --git a/frontend/src/features/whatsapp/WhatsAppTemplateWizard.tsx b/frontend/src/features/whatsapp/WhatsAppTemplateWizard.tsx new file mode 100644 index 0000000..3adf690 --- /dev/null +++ b/frontend/src/features/whatsapp/WhatsAppTemplateWizard.tsx @@ -0,0 +1,497 @@ +import { Bell, KeyRound, Loader2, Megaphone } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom' +import { waTemplateApi, whatsappApi, type WaManagedTemplate, type WaTemplateDraft, type WhatsAppAccount } from '../../lib/api' +import { WaTemplatePreview } from './WaTemplatePreview' +import { CATEGORIES, DRAFT_KEY, buttonError, buttonTypes, contentError, emptyDraft, nameError, variableNames } from './templateModel' + +const STEPS = ['Number', 'Name', 'Category', 'Content', 'Buttons', 'Review'] + +function mergeDraft(base: WaTemplateDraft, incoming: Partial | undefined): WaTemplateDraft { + if (!incoming) return base + return { + ...base, + ...incoming, + header: { ...base.header, ...incoming.header }, + auth: { ...base.auth, ...incoming.auth }, + buttons: incoming.buttons || base.buttons, + body_examples: incoming.body_examples || base.body_examples, + } +} + +export function WhatsAppTemplateWizard() { + const navigate = useNavigate() + const { templateId } = useParams() + const [params] = useSearchParams() + const duplicateId = params.get('duplicate') + const editing = Boolean(templateId) + const [step, setStep] = useState(0) + const [draft, setDraft] = useState(emptyDraft) + const [accounts, setAccounts] = useState([]) + const [languages, setLanguages] = useState<{ code: string; label: string }[]>([{ code: 'en_US', label: 'English (US)' }, { code: 'en', label: 'English' }, { code: 'hi', label: 'Hindi' }]) + const [error, setError] = useState('') + const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState(false) + const [submitted, setSubmitted] = useState(null) + const bodyRef = useRef(null) + + useEffect(() => { + Promise.all([ + whatsappApi.accounts(), + waTemplateApi.languages().catch(() => languages), + templateId || duplicateId ? waTemplateApi.get(templateId || duplicateId || '') : Promise.resolve(null), + ]).then(([accountRows, languageRows, existing]) => { + setAccounts(accountRows) + if (languageRows.length) setLanguages(languageRows) + if (existing) { + const next = mergeDraft(emptyDraft(), existing.definition) + next.account_id = existing.account_id + next.phone_number_id = existing.phone_number_id || '' + next.name = duplicateId ? `${existing.name}_copy`.slice(0, 512) : existing.name + next.language = existing.language + next.category = existing.category as WaTemplateDraft['category'] + next.subtype = existing.subtype + next.parameter_format = existing.parameter_format === 'NAMED' ? 'NAMED' : 'POSITIONAL' + setDraft(next) + } else if (!editing) { + const saved = window.localStorage.getItem(DRAFT_KEY) + if (saved) { + try { setDraft(mergeDraft(emptyDraft(), JSON.parse(saved) as Partial)) } catch { /* keep empty */ } + } + } + }).catch((reason) => setError(reason instanceof Error ? reason.message : 'This template could not be opened')) + .finally(() => setLoading(false)) + // languages is only a fallback for the first request. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [templateId, duplicateId]) + + useEffect(() => { + if (!editing && !duplicateId) window.localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)) + }, [draft, editing, duplicateId]) + + const numbers = useMemo(() => accounts.flatMap((account) => ( + account.phones.length + ? account.phones.map((phone) => ({ account, phone: phone as WhatsAppAccount['phones'][number] | null })) + : [{ account, phone: null as WhatsAppAccount['phones'][number] | null }] + )), [accounts]) + const selected = numbers.find((item) => item.account.id === draft.account_id && (item.phone?.phone_number_id || '') === draft.phone_number_id) + const sharedCount = selected ? selected.account.phones.length : 0 + const category = CATEGORIES.find((item) => item.id === draft.category) + const stepError = [ + draft.account_id ? '' : 'Select a WhatsApp number.', + nameError(draft.name) || (draft.language ? '' : 'Select a language.'), + draft.category ? '' : 'Select a category.', + contentError(draft), + buttonError(draft), + contentError(draft) || buttonError(draft) || nameError(draft.name), + ][step] + + function patch(partial: Partial) { + setDraft((current) => ({ ...current, ...partial })) + setError('') + } + + function insertVariable() { + const names = variableNames(draft.body) + const token = draft.parameter_format === 'NAMED' + ? `{{${window.prompt('Variable name', 'customer_name')?.trim().replace(/\W/g, '_').toLowerCase() || 'name'}}}` + : `{{${names.length + 1}}}` + const area = bodyRef.current + if (!area) { + patch({ body: `${draft.body}${token}` }) + return + } + const start = area.selectionStart + const next = `${draft.body.slice(0, start)}${token}${draft.body.slice(area.selectionEnd)}` + patch({ body: next }) + } + + function wrap(marker: string) { + const area = bodyRef.current + if (!area) return + const start = area.selectionStart + const end = area.selectionEnd + const selectedText = draft.body.slice(start, end) || 'text' + const next = `${draft.body.slice(0, start)}${marker}${selectedText}${marker}${draft.body.slice(end)}` + patch({ body: next }) + } + + async function uploadHeader(file: File) { + if (!draft.account_id) return + setBusy(true) + setError('') + try { + const uploaded = await waTemplateApi.uploadHeader(draft.account_id, file) + patch({ + header: { + ...draft.header, + type: uploaded.format as WaTemplateDraft['header']['type'], + handle: uploaded.handle, + preview_name: file.name, + preview_url: file.type.startsWith('image/') ? URL.createObjectURL(file) : '', + }, + }) + } catch (reason) { + setError(reason instanceof Error ? reason.message : 'The header file was not accepted') + } finally { + setBusy(false) + } + } + + async function submit() { + setBusy(true) + setError('') + try { + const saved = editing && templateId + ? await waTemplateApi.resubmit(templateId, draft) + : await waTemplateApi.create(draft) + if (!editing) window.localStorage.removeItem(DRAFT_KEY) + setSubmitted(saved) + } catch (reason) { + setError(reason instanceof Error ? reason.message : 'Meta did not accept this template') + } finally { + setBusy(false) + } + } + + if (submitted) { + return ( +
+
+

Template submitted for review

+

Meta has the template. It is not approved until Meta says so.

+
+
Template
{submitted.name}
+
Account
{submitted.account_name}
+
Category
{submitted.category.toLowerCase()}
+
Language
{submitted.language}
+
Status
{submitted.status === 'APPROVED' ? 'Approved' : 'Pending review'}
+
Meta template ID
{submitted.meta_template_id || 'Waiting for Meta'}
+
+
+ View template + +
+
+
+ ) + } + + return ( +
+
+ Message templates +

{editing ? 'Edit template' : 'Create WhatsApp template'}

+
+
    + {STEPS.map((label, index) =>
  1. {index + 1}. {label}
  2. )} +
+ {error &&

{error}

} + {loading ?
Loading…
: ( +
= 2 ? 'wa-tpl-split' : ''}> +
+ {step === 0 && ( + <> +

Select WhatsApp number

+

The template is created on the WhatsApp Business account that owns this number. Other numbers on the same account can use it after Meta approves it.

+
+ {numbers.map(({ account, phone }) => { + const phoneId = phone?.phone_number_id || '' + const checked = draft.account_id === account.id && draft.phone_number_id === phoneId + return ( + + ) + })} +
+ {numbers.length === 0 &&

Connect a WhatsApp number first. Open WhatsApp

} + {sharedCount > 1 &&

This account has {sharedCount} numbers. The template is shared across them.

} + + )} + {step === 1 && ( + <> +

Template details

+ + +

Selected account: {selected ? `${selected.phone?.verified_name || selected.account.name} · ${selected.phone?.display_phone || selected.account.name}` : 'None selected'}

+ + )} + {step === 2 && ( + <> +

Choose template category

+
+ {CATEGORIES.map((item) => { + const Icon = item.id === 'MARKETING' ? Megaphone : item.id === 'UTILITY' ? Bell : KeyRound + return ( + + ) + })} +
+ {category && ( + + )} + + )} + {step === 3 && draft.category === 'AUTHENTICATION' && ( + <> +

One-time passcode

+

Meta writes the code message. You choose the security line, expiry, and copy button.

+ + + + + )} + {step === 3 && draft.category !== 'AUTHENTICATION' && ( + <> +

Design your message

+ + + {draft.header.type === 'TEXT' && ( + + )} + {['IMAGE', 'VIDEO', 'DOCUMENT'].includes(draft.header.type) && ( + + )} +