Mint campaign UTM links through the piha.li shortener (#10)
Deploy Beta / unit-tests (push) Successful in 21s
Deploy Beta / docker (push) Successful in 26s
Deploy Beta / deploy-beta (push) Successful in 5m20s

## Summary
Closes #9.

- Campaign composer auto-inserts a tracked homepage link (`utm_source=monica`, `utm_medium` = channel, `utm_campaign` = slug of the name) for email, SMS, and postcard QR — no manual UTM paste.
- When `SHORTENER_BASE_URL` + `SHORTENER_API_TOKEN` are set, the app mints that long HTTPS URL via `POST /api/links/` on the shortener **API host** and puts the returned `piha.li` / `beta.piha.li` short URL in SMS, email hrefs, and QR codes. Empty env (local) falls back to the long UTM URL.
- Live composer resolves shorts through a portal JSON endpoint (login + CSRF). Browser never calls the shortener.

## Secrets (control node, not git)

**monica_site** (`~/Documents/secrets/monica_site/`):
```
# prod
SHORTENER_BASE_URL=https://shortener.aimloperations.com
SHORTENER_API_TOKEN=monica:<secret>

# beta
SHORTENER_BASE_URL=https://shortener-beta.aimloperations.com
SHORTENER_API_TOKEN=monica:<beta-secret>
```

**url_shortening_service** (same secret, named token):
```
SHORTENER_API_TOKENS=monica:<secret>
SHORT_ALLOWED_HOSTS=mkdrealtor.com,aimloperations.com
```
Prod public short host: `piha.li`. Beta: `beta.piha.li`. Generate with `python -c "import secrets; print(secrets.token_urlsafe(32))"`.

Template port: westfarn/web_django_template#3

## Test plan
- [ ] `cd site && uv run python manage.py test messaging.tests.CampaignUtmLinkTests`
- [ ] Composer: type a campaign name — email gets an HTML link, SMS gets a URL, postcard shows a QR
- [ ] With shortener env set: SMS/QR show `piha.li` (or `beta.piha.li`); without it, long UTM URL still works
- [ ] Copy/download QR into postcard designer
- [ ] Secret files have `SHORTENER_*` on both caller and operator sides before beta/prod deploy

Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
2026-08-30 13:24:40 -07:00
parent dc0f3b2908
commit 2b3f10e784
17 changed files with 1082 additions and 4 deletions
+78
View File
@@ -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,