From ef0655e8d502ca1f763ffbf1073a6f1b2688c00a Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Sun, 30 Aug 2026 15:19:57 -0500 Subject: [PATCH] Mint campaign UTM links through the piha.li shortener. Closes #9. Auto-insert tracked homepage links in campaign compose, register them with url_shortening_service, and put short piha.li URLs in SMS, email hrefs, and postcard QR codes when SHORTENER_* is configured. --- .env.example | 8 + .env.prod.example | 9 + README.md | 11 + docker-compose.yml | 4 + scripts/validate-env.sh | 2 + site/messaging/README.md | 17 ++ site/messaging/services.py | 173 +++++++++++ site/messaging/shortener.py | 87 ++++++ .../templates/messaging/_utm_link_panel.html | 42 +++ .../templates/messaging/campaign_detail.html | 11 + .../templates/messaging/campaign_list.html | 52 +++- site/messaging/tests.py | 255 +++++++++++++++- site/messaging/urls.py | 5 + site/messaging/views.py | 78 +++++ site/monica_site/settings/base.py | 7 + site/monica_site/static/css/portal.css | 36 +++ site/monica_site/static/js/campaign-utm.js | 289 ++++++++++++++++++ 17 files changed, 1082 insertions(+), 4 deletions(-) create mode 100644 site/messaging/shortener.py create mode 100644 site/messaging/templates/messaging/_utm_link_panel.html create mode 100644 site/monica_site/static/js/campaign-utm.js diff --git a/.env.example b/.env.example index edfb813..62f1e86 100644 --- a/.env.example +++ b/.env.example @@ -79,6 +79,14 @@ SOCIAL_TOKEN_ENCRYPTION_KEY= OLLAMA_BASE_URL=http://10.0.0.128:11434 OLLAMA_MODEL=llama3.2 +# URL shortener (piha.li). Empty locally = long UTM URLs in campaigns. +# Call the API host, never https://piha.li (that host only 302s). +SHORTENER_BASE_URL= +SHORTENER_API_TOKEN= +# Local shortener compose: +# SHORTENER_BASE_URL=http://127.0.0.1:8005 +# SHORTENER_API_TOKEN=monica:dev-only-token + # Nominatim (LAN) — address autocomplete via Django /api/address-suggest/ # Nominatim has no built-in API keys; optional NOMINATIM_API_KEY only if you # put a gateway in front that checks X-API-Key. diff --git a/.env.prod.example b/.env.prod.example index b191a8e..8fcb041 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -87,6 +87,13 @@ OLLAMA_BASE_URL=http://10.0.0.128:11434 OLLAMA_MODEL=llama3.2 OLLAMA_TIMEOUT_SECONDS=120 +# URL shortener — mint short links for campaign SMS / email / postcard QR. +# POST /api/links/ on the API host. Recipients hit https://piha.li/. +# Token must match SHORTENER_API_TOKENS on url_shortening_service (name:secret). +# Generate: python -c "import secrets; print(secrets.token_urlsafe(32))" +SHORTENER_BASE_URL=https://shortener.aimloperations.com +SHORTENER_API_TOKEN=monica:replace-me + # Nominatim address suggest (server-side proxy only; not called from browser) NOMINATIM_BASE_URL=http://10.0.0.128:8089 NOMINATIM_TIMEOUT_SECONDS=8 @@ -123,3 +130,5 @@ GUNICORN_BIND=0.0.0.0:8000 # OLLAMA_BASE_URL=http://10.0.0.128:11434 # NOMINATIM_BASE_URL=http://10.0.0.128:8089 # NOMINATIM_COUNTRY_CODES=us +# SHORTENER_BASE_URL=https://shortener-beta.aimloperations.com +# SHORTENER_API_TOKEN=monica:replace-with-a-different-token diff --git a/README.md b/README.md index 67470e0..b1a2527 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,17 @@ Templates: `.env.prod.example` (full var list). Validate with: ./scripts/validate-env.sh ~/Documents/secrets/monica_site/monica_site_prod.env ``` +Campaign UTM short links (piha.li) need **both** sides: + +| File | Vars | +|------|------| +| `monica_site_prod.env` | `SHORTENER_BASE_URL=https://shortener.aimloperations.com` · `SHORTENER_API_TOKEN=monica:` | +| `monica_site_beta.env` | `SHORTENER_BASE_URL=https://shortener-beta.aimloperations.com` · distinct `SHORTENER_API_TOKEN` | +| `url_shortening_service_prod.env` | `SHORTENER_API_TOKENS=monica:` · `SHORT_ALLOWED_HOSTS` includes `mkdrealtor.com` · `PUBLIC_SHORT_URL=https://piha.li` | +| `url_shortening_service_beta.env` | matching beta token · `PUBLIC_SHORT_URL=https://beta.piha.li` | + +Generate the secret with `python -c "import secrets; print(secrets.token_urlsafe(32))"`. Do not put it in git. Call the API host, never `piha.li`, to mint links. + ## Deploy CI on merge to `main` → tests → `server-infra/scripts/deploy.sh --app monica_site --env prod --ref `. diff --git a/docker-compose.yml b/docker-compose.yml index 97b3dc9..d029c45 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,6 +61,8 @@ services: META_APP_ID: ${META_APP_ID:-} META_APP_SECRET: ${META_APP_SECRET:-} SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-} + SHORTENER_BASE_URL: ${SHORTENER_BASE_URL:-} + SHORTENER_API_TOKEN: ${SHORTENER_API_TOKEN:-} depends_on: db: condition: service_healthy @@ -102,6 +104,8 @@ services: META_APP_ID: ${META_APP_ID:-} META_APP_SECRET: ${META_APP_SECRET:-} SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-} + SHORTENER_BASE_URL: ${SHORTENER_BASE_URL:-} + SHORTENER_API_TOKEN: ${SHORTENER_API_TOKEN:-} depends_on: db: condition: service_healthy diff --git a/scripts/validate-env.sh b/scripts/validate-env.sh index 570ed23..05d5d3d 100755 --- a/scripts/validate-env.sh +++ b/scripts/validate-env.sh @@ -28,6 +28,8 @@ if [[ "$DJANGO_ENV" == "prod" || "$DJANGO_ENV" == "beta" ]]; then RECAPTCHA_PRIVATE_KEY EMAIL_HOST_USER EMAIL_HOST_PASSWORD + SHORTENER_BASE_URL + SHORTENER_API_TOKEN ) fi diff --git a/site/messaging/README.md b/site/messaging/README.md index bd34eac..1d8c657 100644 --- a/site/messaging/README.md +++ b/site/messaging/README.md @@ -35,6 +35,23 @@ webhook events match the correct recipient row. Invalid / missing header values no longer 500 the endpoint (SMTP2GO “Test this webhook” often sends a sample non-UUID). +## Campaign tracked links (UTM + piha.li) + +Composer auto-inserts a homepage link: + +| Param | Value | +|-------|--------| +| `utm_source` | `monica` | +| `utm_medium` | `email` / `sms` / `postcard` | +| `utm_campaign` | hyphenated campaign name | + +When `SHORTENER_BASE_URL` + `SHORTENER_API_TOKEN` are set, the app POSTs that +long HTTPS URL to `url_shortening_service` (`POST /api/links/`, Bearer +`monica:`) and puts `short_url` (`https://piha.li/`) in SMS, +email hrefs, and postcard QR codes. Empty env → long UTM URL (local default). + +Mint against the **API host**, not `piha.li`. See `.env.example` / `.env.prod.example`. + SMS correlation uses `message_id` (SMS id), then `destination_number` phone fallback. Do **not** treat webhook `id` as the SMS id. diff --git a/site/messaging/services.py b/site/messaging/services.py index 829d380..187729a 100644 --- a/site/messaging/services.py +++ b/site/messaging/services.py @@ -2,10 +2,13 @@ from __future__ import annotations +import html import re from datetime import datetime from typing import TYPE_CHECKING +from urllib.parse import urlencode +from django.conf import settings from django.core import signing from django.db.models import QuerySet from django.urls import reverse @@ -13,6 +16,7 @@ from django.utils import timezone from contacts.models import Channel, ConsentRecord, Contact, Suppression from messaging.models import Campaign, Message, MessageTemplate +from messaging.shortener import resolve_display_url if TYPE_CHECKING: from django.contrib.auth.models import AbstractBaseUser @@ -33,6 +37,21 @@ _MERGE_TAG_RE = re.compile( re.IGNORECASE, ) +# Campaign tracked links: utm_source is always the brand; medium = channel. +UTM_SOURCE = "monica" +UTM_CAMPAIGN_SLUG_MAX = 80 +_UTM_ANCHOR_RE = re.compile(r"(]*>)(.*?)()", re.IGNORECASE | re.DOTALL) +_UTM_TEXT_URL_RE = re.compile( + r"https?://[^\s<>\"]+utm_source=" + re.escape(UTM_SOURCE) + r"[^\s<>\"]*", + re.IGNORECASE, +) +# Minted short links in SMS (piha.li prod, beta.piha.li, local compose). +_SHORT_TEXT_URL_RE = re.compile( + r"https?://(?:(?:www\.)?(?:beta\.)?piha\.li|(?:127\.0\.0\.1|localhost):\d+)" + r"/[a-z0-9]{4,8}", + re.IGNORECASE, +) + REMOVABLE_MESSAGE_STATUSES = frozenset( { Message.Status.DRAFT, @@ -66,6 +85,149 @@ def render_merge_tags(text: str, contact: Contact | None) -> str: return _MERGE_TAG_RE.sub(_replace, text) +def public_site_base_url() -> str: + """Public homepage used as the UTM landing URL.""" + base = (getattr(settings, "PUBLIC_SITE_URL", None) or "").rstrip("/") + if not base: + base = "https://mkdrealtor.com" + return base + + +def public_site_link_label() -> str: + """Visible email-link text (host), with a stable label in local/dev.""" + host = ( + public_site_base_url() + .replace("https://", "") + .replace("http://", "") + .split("/")[0] + ) + if host.startswith("127.") or host.startswith("localhost") or host.startswith("0.0.0.0"): + return "MKDRealtor.com" + return host or "MKDRealtor.com" + + +def campaign_utm_slug(name: str) -> str: + """Lowercase hyphenated utm_campaign from the campaign name.""" + slug = re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower()) + slug = re.sub(r"-{2,}", "-", slug).strip("-") + return (slug or "campaign")[:UTM_CAMPAIGN_SLUG_MAX] + + +def build_campaign_utm_url( + *, + name: str, + medium: str, + base_url: str = "", +) -> str: + """Homepage URL with utm_source / utm_medium / utm_campaign.""" + base = (base_url or public_site_base_url()).rstrip("/") + channel = (medium or "").strip().lower() + if channel not in Channel.values: + channel = Channel.EMAIL + query = urlencode( + { + "utm_source": UTM_SOURCE, + "utm_medium": channel, + "utm_campaign": campaign_utm_slug(name), + } + ) + return f"{base}/?{query}" + + +def campaign_shortener_ref(*, campaign_id=None, user_id=None, medium: str = "", name: str = "") -> str: + """external_ref for the shortener (max 64). Prefer campaign UUID.""" + if campaign_id: + return str(campaign_id)[:64] + slug = campaign_utm_slug(name)[:40] + ch = (medium or "email").strip().lower()[:8] + uid = "" if user_id is None else str(user_id) + return f"p{uid}-{ch}-{slug}"[:64] + + +def resolve_campaign_tracked_url( + *, + name: str, + medium: str, + campaign_id=None, + user_id=None, + shorten: bool = True, +) -> tuple[str, str]: + """Return (long UTM URL, display URL). Display is short when mint works.""" + target = build_campaign_utm_url(name=name, medium=medium) + if not shorten: + return target, target + display = resolve_display_url( + target, + title=f"{(name or 'campaign').strip()} ({medium})"[:200], + external_ref=campaign_shortener_ref( + campaign_id=campaign_id, user_id=user_id, medium=medium, name=name + ), + ) + return target, display + + +def _is_our_utm_anchor(attrs: str) -> bool: + lower = attrs.lower() + return "data-monica-utm" in lower or f"utm_source={UTM_SOURCE}" in lower + + +def ensure_campaign_utm_in_html(body: str, url: str, label: str) -> str: + """Insert or refresh the tracked in an HTML (email) body.""" + href = html.escape(url, quote=True) + label_html = html.escape(label) + replaced = False + + def _repl(match: re.Match[str]) -> str: + nonlocal replaced + if replaced or not _is_our_utm_anchor(match.group(1)): + return match.group(0) + replaced = True + inner = match.group(2) if (match.group(2) or "").strip() else label_html + return f'{inner}' + + out = _UTM_ANCHOR_RE.sub(_repl, body or "") + if replaced: + return out + anchor = f'{label_html}' + text = (body or "").rstrip() + if text: + return f"{text}\n

{anchor}

" + return f"

{anchor}

" + + +def ensure_campaign_utm_in_text(body: str, url: str) -> str: + """Insert or refresh the tracked URL in a plain-text (SMS) body.""" + raw = body or "" + if _UTM_TEXT_URL_RE.search(raw): + return _UTM_TEXT_URL_RE.sub(url, raw, count=1) + if _SHORT_TEXT_URL_RE.search(raw): + return _SHORT_TEXT_URL_RE.sub(url, raw, count=1) + text = raw.rstrip() + return f"{text}\n\n{url}" if text else url + + +def ensure_campaign_utm_link( + body: str, + *, + name: str, + medium: str, + html: bool, + campaign_id=None, + shorten: bool | None = None, +) -> str: + """Keep a tracked site link in the body, matching campaign name + channel.""" + do_shorten = bool(campaign_id) if shorten is None else shorten + _target, url = resolve_campaign_tracked_url( + name=name, + medium=medium, + campaign_id=campaign_id, + shorten=do_shorten, + ) + if html: + return ensure_campaign_utm_in_html(body, url, public_site_link_label()) + return ensure_campaign_utm_in_text(body, url) + + def message_is_removable(message: Message) -> bool: return message.status in REMOVABLE_MESSAGE_STATUSES @@ -287,6 +449,17 @@ def create_campaign_draft( created_by=created_by, template=template, ) + if channel in (Channel.EMAIL, Channel.SMS): + body = ensure_campaign_utm_link( + body, + name=name, + medium=channel, + html=(channel == Channel.EMAIL), + campaign_id=campaign.pk, + ) + if body != campaign.body_override: + campaign.body_override = body + campaign.save(update_fields=["body_override", "updated_at"]) contacts = list(opted_in_contacts(channel)) Message.objects.bulk_create( [ diff --git a/site/messaging/shortener.py b/site/messaging/shortener.py new file mode 100644 index 0000000..023e626 --- /dev/null +++ b/site/messaging/shortener.py @@ -0,0 +1,87 @@ +"""Server-to-server client for url_shortening_service (piha.li). + +Call the API host (`SHORTENER_BASE_URL`), never the public short domain. +Auth: Authorization: Bearer (name:secret). +""" + +from __future__ import annotations + +import logging + +import requests +from django.conf import settings + +logger = logging.getLogger(__name__) + + +def is_configured() -> bool: + base = (getattr(settings, "SHORTENER_BASE_URL", None) or "").strip() + token = (getattr(settings, "SHORTENER_API_TOKEN", None) or "").strip() + return bool(base and token) + + +def mint_short_url( + target_url: str, + *, + title: str = "", + external_ref: str = "", +) -> str | None: + """POST /api/links/. Return short_url, or None if unconfigured / failed. + + Does not raise. Compose must still work when the shortener is down or unset. + """ + if not is_configured(): + return None + target = (target_url or "").strip() + if not target.lower().startswith("https://"): + logger.info("shortener skip: target is not https") + return None + + base = str(settings.SHORTENER_BASE_URL).rstrip("/") + token = str(settings.SHORTENER_API_TOKEN).strip() + timeout = int(getattr(settings, "SHORTENER_TIMEOUT_SECONDS", 10) or 10) + try: + response = requests.post( + f"{base}/api/links/", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + json={ + "target_url": target, + "title": (title or "")[:200], + "external_ref": (external_ref or "")[:64], + }, + timeout=timeout, + ) + except requests.RequestException: + logger.exception("shortener request failed") + return None + + if response.status_code not in (200, 201): + logger.warning( + "shortener mint failed status=%s", + response.status_code, + ) + return None + try: + data = response.json() + except ValueError: + logger.warning("shortener mint returned non-json") + return None + if not isinstance(data, dict): + return None + short = (data.get("short_url") or "").strip() + return short or None + + +def resolve_display_url( + target_url: str, + *, + title: str = "", + external_ref: str = "", +) -> str: + """Short URL when mint succeeds, otherwise the original target.""" + return mint_short_url( + target_url, title=title, external_ref=external_ref + ) or target_url diff --git a/site/messaging/templates/messaging/_utm_link_panel.html b/site/messaging/templates/messaging/_utm_link_panel.html new file mode 100644 index 0000000..e952737 --- /dev/null +++ b/site/messaging/templates/messaging/_utm_link_panel.html @@ -0,0 +1,42 @@ + diff --git a/site/messaging/templates/messaging/campaign_detail.html b/site/messaging/templates/messaging/campaign_detail.html index 8d5e004..21f5667 100644 --- a/site/messaging/templates/messaging/campaign_detail.html +++ b/site/messaging/templates/messaging/campaign_detail.html @@ -1,4 +1,5 @@ {% extends "portal_base.html" %} +{% load static %} {% block title %}{{ campaign.name }} · Campaign{% endblock %} {% block topbar_title %}{{ campaign.name }}{% endblock %} {% block portal_content %} @@ -51,6 +52,13 @@ {% endif %} +
+

Tracked site link

+
+ {% include "messaging/_utm_link_panel.html" with utm_live=False utm_medium=campaign.channel utm_campaign_name=campaign.name %} +
+
+
Messages
@@ -208,7 +216,10 @@
{% endblock %} {% block extra_js %} + + + + diff --git a/site/messaging/tests.py b/site/messaging/tests.py index 1cd18fd..72e6078 100644 --- a/site/messaging/tests.py +++ b/site/messaging/tests.py @@ -1,5 +1,5 @@ from django.contrib.auth import get_user_model -from django.test import Client, TestCase +from django.test import Client, TestCase, override_settings from django.urls import reverse from contacts.models import Channel, ConsentRecord, Contact, Suppression @@ -1359,6 +1359,7 @@ class CampaignRecipientTableTests(TestCase): self.assertNotContains(response, "Recent SMTP2GO events") +@override_settings(SHORTENER_BASE_URL="", SHORTENER_API_TOKEN="") class Smtp2goSmsSendTests(TestCase): def setUp(self): self.contact = Contact.objects.create( @@ -1416,7 +1417,9 @@ class Smtp2goSmsSendTests(TestCase): payload = post.call_args.kwargs["json"] self.assertEqual(payload["api_key"], "api-test-key") self.assertEqual(payload["destination"], ["+13304022675"]) - self.assertEqual(payload["content"], "Hello Rufus") + self.assertTrue(payload["content"].startswith("Hello Rufus")) + self.assertIn("utm_source=monica", payload["content"]) + self.assertIn("utm_medium=sms", payload["content"]) self.assertNotIn("to", payload) self.assertNotIn("text", payload) @@ -1450,3 +1453,251 @@ class Smtp2goSmsSendTests(TestCase): self.assertIn("Missing required field", str(ctx.exception)) self.assertIn("INVALID_REQUEST", str(ctx.exception)) + + +@override_settings(SHORTENER_BASE_URL="", SHORTENER_API_TOKEN="") +class CampaignUtmLinkTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user( + username="utm-composer", password="test-pass-123" + ) + self.client = Client() + self.client.login(username="utm-composer", password="test-pass-123") + self.contact = Contact.objects.create( + email="pat@example.com", + phone="5550100199", + first_name="Pat", + ) + set_channel_consent( + self.contact, Channel.EMAIL, opted_in=True, reason="test" + ) + set_channel_consent( + self.contact, Channel.SMS, opted_in=True, reason="test" + ) + + def test_slug_and_url_shape(self): + from messaging.services import ( + build_campaign_utm_url, + campaign_utm_slug, + ) + + self.assertEqual(campaign_utm_slug("Spring seller tips"), "spring-seller-tips") + with self.settings(PUBLIC_SITE_URL="https://mkdrealtor.com"): + url = build_campaign_utm_url( + name="Spring seller tips", medium="email" + ) + self.assertEqual( + url, + "https://mkdrealtor.com/?utm_source=monica" + "&utm_medium=email&utm_campaign=spring-seller-tips", + ) + + def test_email_draft_inserts_html_link(self): + with self.settings(PUBLIC_SITE_URL="https://mkdrealtor.com"): + campaign = create_campaign_draft( + name="Spring seller tips", + audience=Campaign.Audience.EMAIL_OPT_IN, + subject="Hello", + body="

Hi there

", + created_by=self.user, + ) + self.assertIn('data-monica-utm="1"', campaign.body_override) + self.assertIn("utm_medium=email", campaign.body_override) + self.assertIn("utm_campaign=spring-seller-tips", campaign.body_override) + self.assertIn("Hi there", campaign.body_override) + + def test_sms_draft_inserts_plain_url(self): + with self.settings(PUBLIC_SITE_URL="https://mkdrealtor.com"): + campaign = create_campaign_draft( + name="Open house", + audience=Campaign.Audience.SMS_OPT_IN, + body="See you Saturday", + created_by=self.user, + ) + self.assertIn("See you Saturday", campaign.body_override) + self.assertIn( + "https://mkdrealtor.com/?utm_source=monica&utm_medium=sms" + "&utm_campaign=open-house", + campaign.body_override, + ) + self.assertNotIn("/", views.campaign_detail, name="campaign_detail"), path( "campaigns//status.json", diff --git a/site/messaging/views.py b/site/messaging/views.py index 9848ed9..30b9d6b 100644 --- a/site/messaging/views.py +++ b/site/messaging/views.py @@ -1,6 +1,7 @@ import hashlib import hmac import io +import json import logging from django.conf import settings @@ -26,12 +27,17 @@ from messaging.providers.postcard.pcm import ( list_designs, ) from messaging.services import ( + channel_for_audience, create_campaign_draft, enqueue_campaign_send, + ensure_campaign_utm_link, message_is_removable, opted_in_contacts, parse_scheduled_for, + public_site_base_url, + public_site_link_label, record_sms_stop, + resolve_campaign_tracked_url, send_campaign_test_email, ) from messaging.webhooks import ( @@ -462,6 +468,16 @@ def campaign_list(request): if not body: body = "Postcard mailing" else: + if audience in Campaign.Audience.values: + channel = channel_for_audience(audience) + if channel in (Channel.EMAIL, Channel.SMS): + body = ensure_campaign_utm_link( + body, + name=name or "campaign", + medium=channel, + html=(channel == Channel.EMAIL), + ) + form_data["body"] = body if not body: form_errors.append("Body is required.") if ( @@ -506,10 +522,62 @@ def campaign_list(request): "form_data": form_data, "form_errors": form_errors, "image_upload_url": reverse("messaging:campaign_image_upload"), + "utm_base_url": public_site_base_url(), + "utm_link_label": public_site_link_label(), + "utm_shorten_url": reverse("messaging:campaign_short_link"), + "utm_url": "", }, ) +@login_required +@require_POST +def campaign_short_link(request): + """Mint (or reuse) a short URL for the campaign tracked landing link. + + Browser talks to this portal endpoint only. The shortener is server-to-server. + """ + try: + payload = json.loads(request.body.decode() or "{}") + except (json.JSONDecodeError, UnicodeDecodeError): + payload = {} + if not isinstance(payload, dict): + payload = {} + name = (payload.get("name") or request.POST.get("name") or "").strip() + medium = (payload.get("medium") or request.POST.get("medium") or "").strip() + campaign_id = ( + payload.get("campaign_id") or request.POST.get("campaign_id") or "" + ) + campaign_id = str(campaign_id).strip() + if campaign_id: + try: + campaign = Campaign.objects.filter(pk=campaign_id).first() + except (ValidationError, ValueError): + campaign = None + if campaign is None: + return JsonResponse({"detail": "Campaign not found."}, status=404) + name = name or campaign.name + medium = medium or campaign.channel + campaign_id = str(campaign.pk) + else: + campaign_id = None + target, display = resolve_campaign_tracked_url( + name=name or "campaign", + medium=medium, + campaign_id=campaign_id, + user_id=getattr(request.user, "pk", None), + shorten=True, + ) + return JsonResponse( + { + "target_url": target, + "short_url": display if display != target else "", + "display_url": display, + "shortened": display != target, + } + ) + + @login_required def campaign_detail(request, pk): campaign = get_object_or_404(Campaign, pk=pk) @@ -518,6 +586,12 @@ def campaign_detail(request, pk): except (TypeError, ValueError): page = 1 ctx = _campaign_report(campaign, page=page) + _target, display_url = resolve_campaign_tracked_url( + name=campaign.name, + medium=campaign.channel, + campaign_id=campaign.pk, + shorten=True, + ) return render( request, "messaging/campaign_detail.html", @@ -529,6 +603,10 @@ def campaign_detail(request, pk): "recent_events": ctx["recent_events"], "events_title": ctx["events_title"], "events_empty": ctx["events_empty"], + "utm_url": display_url, + "utm_base_url": public_site_base_url(), + "utm_link_label": public_site_link_label(), + "utm_shorten_url": reverse("messaging:campaign_short_link"), "can_send": campaign.status in { Campaign.Status.DRAFT, diff --git a/site/monica_site/settings/base.py b/site/monica_site/settings/base.py index 95605ab..55bba95 100644 --- a/site/monica_site/settings/base.py +++ b/site/monica_site/settings/base.py @@ -332,6 +332,13 @@ NOMINATIM_USER_AGENT = env( ) NOMINATIM_API_KEY = env("NOMINATIM_API_KEY", "") +# --- URL shortener (piha.li) --- +# Call the API host, never the public short domain. Empty = skip minting +# (composer falls back to the long UTM URL). Token format: name:secret. +SHORTENER_BASE_URL = env("SHORTENER_BASE_URL", "") +SHORTENER_API_TOKEN = env("SHORTENER_API_TOKEN", "") +SHORTENER_TIMEOUT_SECONDS = int(env("SHORTENER_TIMEOUT_SECONDS", "10") or "10") + # --- Tianji analytics (pageviews + events; notice banner, not opt-in) --- TIANJI_ENABLED = env_bool("TIANJI_ENABLED", not DEBUG) TIANJI_TRACKER_URL = env( diff --git a/site/monica_site/static/css/portal.css b/site/monica_site/static/css/portal.css index 10834da..d33f1db 100644 --- a/site/monica_site/static/css/portal.css +++ b/site/monica_site/static/css/portal.css @@ -690,3 +690,39 @@ body.portal { padding: 8px 10px; border: 1px solid var(--monica-border); font: inherit; font-size: 13px; background: #fff; } .empty-state { padding: 24px; color: var(--monica-muted); font-size: 14px; } + +.utm-panel { + margin-top: 4px; + padding-top: 4px; +} +.utm-url { + display: block; + font-size: 12px; + line-height: 1.45; + word-break: break-all; + background: #f8fafc; + border: 1px solid var(--monica-border); + padding: 8px 10px; + color: #0f172a; +} +.utm-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 8px; +} +.utm-qr { + display: flex; + gap: 16px; + align-items: flex-start; + flex-wrap: wrap; + margin-top: 12px; +} +.utm-qr canvas { + display: block; + border: 1px solid var(--monica-border); + background: #fff; +} +.utm-qr-actions { min-width: 160px; } +.utm-qr-actions .btn { margin: 0 8px 8px 0; } +#preview-body a { color: var(--monica-primary); } diff --git a/site/monica_site/static/js/campaign-utm.js b/site/monica_site/static/js/campaign-utm.js new file mode 100644 index 0000000..2576c3a --- /dev/null +++ b/site/monica_site/static/js/campaign-utm.js @@ -0,0 +1,289 @@ +/** + * Campaign UTM link helper (composer + report). + * utm_source=monica, utm_medium=email|sms|postcard, utm_campaign=slug(name). + * Short URLs come from the portal JSON endpoint (server mints via piha.li). + */ +(function (global) { + "use strict"; + + var SOURCE = "monica"; + var SLUG_MAX = 80; + var SHORTEN_WAIT_MS = 400; + var UTM_URL_RE = new RegExp( + "https?:\\/\\/[^\\s<>\"]+utm_source=" + SOURCE + "[^\\s<>\"]*", + "i" + ); + var SHORT_URL_RE = + /https?:\/\/(?:(?:www\.)?(?:beta\.)?piha\.li|(?:127\.0\.0\.1|localhost):\d+)\/[a-z0-9]{4,8}/i; + + function slug(name) { + var s = String(name || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return (s || "campaign").slice(0, SLUG_MAX); + } + + function buildUrl(base, medium, name) { + var root = String(base || "").replace(/\/+$/, "") || "https://mkdrealtor.com"; + var ch = String(medium || "email").toLowerCase(); + if (ch !== "email" && ch !== "sms" && ch !== "postcard") ch = "email"; + var q = + "utm_source=" + + encodeURIComponent(SOURCE) + + "&utm_medium=" + + encodeURIComponent(ch) + + "&utm_campaign=" + + encodeURIComponent(slug(name)); + return root + "/?" + q; + } + + function replaceTextUrl(text, url) { + var raw = String(text || ""); + if (UTM_URL_RE.test(raw)) { + return raw.replace(UTM_URL_RE, url); + } + if (SHORT_URL_RE.test(raw)) { + return raw.replace(SHORT_URL_RE, url); + } + var trimmed = raw.replace(/\s+$/, ""); + return trimmed ? trimmed + "\n\n" + url : url; + } + + function linkify(text) { + var esc = String(text || "").replace(/[&<>"']/g, function (c) { + return ( + { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ + c + ] || c + ); + }); + return esc.replace( + /(https?:\/\/[^\s]+)/g, + '$1' + ); + } + + function ensureQuillLink(quill, url, label) { + if (!quill || !quill.root) return; + var existing = quill.root.querySelectorAll( + 'a[data-monica-utm], a[href*="utm_source=' + SOURCE + '"], a[href*="piha.li/"]' + ); + if (existing.length) { + existing.forEach(function (a) { + a.setAttribute("href", url); + a.setAttribute("data-monica-utm", "1"); + }); + return; + } + var labelText = label || "MKDRealtor.com"; + var start = Math.max(0, quill.getLength() - 1); + quill.insertText(start, "\n" + labelText, "silent"); + quill.formatText(start + 1, labelText.length, "link", url, "silent"); + var links = quill.root.querySelectorAll("a"); + var last = links[links.length - 1]; + if (last) last.setAttribute("data-monica-utm", "1"); + } + + function renderQr(canvas, url) { + if (!canvas || !url || typeof QRCode === "undefined") return Promise.resolve(); + return QRCode.toCanvas(canvas, url, { + width: 192, + margin: 1, + color: { dark: "#00626c", light: "#ffffff" }, + }); + } + + function flash(el, okText, errText, ok) { + if (!el) return; + el.textContent = ok ? okText : errText; + el.hidden = false; + window.setTimeout(function () { + el.hidden = true; + }, 2200); + } + + function copyText(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text); + } + return Promise.reject(new Error("clipboard unavailable")); + } + + function copyCanvas(canvas) { + if (!canvas || !canvas.toBlob) { + return Promise.reject(new Error("canvas unavailable")); + } + return new Promise(function (resolve, reject) { + canvas.toBlob(function (blob) { + if (!blob) { + reject(new Error("qr blob failed")); + return; + } + if (!navigator.clipboard || !navigator.clipboard.write || typeof ClipboardItem === "undefined") { + reject(new Error("image clipboard unavailable")); + return; + } + navigator.clipboard + .write([new ClipboardItem({ "image/png": blob })]) + .then(resolve, reject); + }, "image/png"); + }); + } + + function downloadCanvas(canvas, filename) { + if (!canvas) return; + var a = document.createElement("a"); + a.href = canvas.toDataURL("image/png"); + a.download = filename || "campaign-qr.png"; + document.body.appendChild(a); + a.click(); + a.remove(); + } + + function bindPanel(panel, opts) { + if (!panel) return; + opts = opts || {}; + var urlEl = panel.querySelector("[data-utm-url]"); + var canvas = panel.querySelector("[data-utm-qr]"); + var qrWrap = panel.querySelector("[data-utm-qr-wrap]"); + var smsHint = panel.querySelector("[data-utm-sms-hint]"); + var emailHint = panel.querySelector("[data-utm-email-hint]"); + var postcardHint = panel.querySelector("[data-utm-postcard-hint]"); + var statusEl = panel.querySelector("[data-utm-status]"); + var base = panel.getAttribute("data-base-url") || ""; + var label = panel.getAttribute("data-link-label") || "MKDRealtor.com"; + var live = panel.getAttribute("data-live") === "1"; + var shortenUrl = panel.getAttribute("data-shorten-url") || ""; + var csrf = + (opts.csrfToken || panel.getAttribute("data-csrf") || "").trim(); + var shortenTimer = null; + var shortenSeq = 0; + + function medium() { + if (typeof opts.getMedium === "function") return opts.getMedium(); + return panel.getAttribute("data-medium") || "email"; + } + + function name() { + if (typeof opts.getName === "function") return opts.getName(); + return panel.getAttribute("data-campaign-name") || ""; + } + + function campaignId() { + return ( + (typeof opts.getCampaignId === "function" && opts.getCampaignId()) || + panel.getAttribute("data-campaign-id") || + "" + ); + } + + function paint(url, inject) { + var ch = medium(); + if (urlEl) urlEl.textContent = url; + if (qrWrap) qrWrap.hidden = ch !== "postcard"; + if (smsHint) smsHint.hidden = ch !== "sms"; + if (emailHint) emailHint.hidden = ch !== "email"; + if (postcardHint) postcardHint.hidden = ch !== "postcard"; + if (ch === "postcard" && canvas) { + renderQr(canvas, url).catch(function () {}); + } + if (inject && typeof opts.onUrlChange === "function") { + opts.onUrlChange(url, ch, label); + } + } + + function apply() { + var ch = medium(); + var longUrl = buildUrl(base, ch, name()); + if (!shortenUrl) { + paint(longUrl, true); + return longUrl; + } + paint(longUrl, false); + var seq = ++shortenSeq; + window.clearTimeout(shortenTimer); + shortenTimer = window.setTimeout(function () { + fetch(shortenUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "X-CSRFToken": csrf, + }, + credentials: "same-origin", + body: JSON.stringify({ + name: name(), + medium: ch, + campaign_id: campaignId(), + }), + }) + .then(function (r) { + return r.ok ? r.json() : Promise.reject(); + }) + .then(function (data) { + if (seq !== shortenSeq) return; + paint((data && data.display_url) || longUrl, true); + }) + .catch(function () { + if (seq !== shortenSeq) return; + paint(longUrl, true); + }); + }, SHORTEN_WAIT_MS); + return longUrl; + } + + panel.querySelectorAll("[data-utm-copy-url]").forEach(function (btn) { + btn.addEventListener("click", function () { + var url = (urlEl && urlEl.textContent) || ""; + copyText(url).then( + function () { + flash(statusEl, "Link copied.", "Could not copy.", true); + }, + function () { + flash(statusEl, "", "Could not copy link.", false); + } + ); + }); + }); + panel.querySelectorAll("[data-utm-copy-qr]").forEach(function (btn) { + btn.addEventListener("click", function () { + copyCanvas(canvas).then( + function () { + flash(statusEl, "QR image copied.", "Could not copy QR.", true); + }, + function () { + flash(statusEl, "", "Copy failed — use Download PNG.", false); + } + ); + }); + }); + panel.querySelectorAll("[data-utm-download-qr]").forEach(function (btn) { + btn.addEventListener("click", function () { + var slugName = slug(name()); + downloadCanvas(canvas, "qr-" + slugName + ".png"); + flash(statusEl, "QR downloaded.", "", true); + }); + }); + + if (live) { + var nameField = document.getElementById("id_name"); + if (nameField) nameField.addEventListener("input", apply); + } + + panel._utmApply = apply; + apply(); + return { apply: apply, buildUrl: buildUrl }; + } + + global.MonicaUtm = { + SOURCE: SOURCE, + slug: slug, + buildUrl: buildUrl, + replaceTextUrl: replaceTextUrl, + linkify: linkify, + ensureQuillLink: ensureQuillLink, + bindPanel: bindPanel, + }; +})(window); -- 2.54.0