Commits
Ads import themselves: Advanced Ads heads to Payload
A short, productive Monday: the Advanced Ads importer brings zones, campaigns, banners and advertiser contacts over to Payload. And the whole of phase 7 lands in one evening: double opt-in subscriptions, newsletters and the first stone of production.
The short Monday
Three commits, all of them about ad migration (F6c): the Advanced Ads importer (the ads from the old WordPress) now brings campaigns over to Payload, including the advertiser's contact when it exists. The road here: the first sketch of the migrator and the connection with the production WordPress.
The contact that isn't always there
The advertiser contact field is optional: if the WordPress has none, the campaign is created anyway. If it were required, every campaign without a contact would have failed.
pymysql gives no text
The fix that was cutting off campaign creation: iso() expected text ('2027-01-29 23:01:00'), but pymysql (the library that talks to MySQL, the database) returns a datetime (the date-time type) when reading post_date. A replace on a datetime does not exist, and the import blew up. It now accepts both shapes, with asserts (a check that fails if not met) in our own tests:
assert iso(_dt(2027, 1, 29, 23, 1)) == "2027-01-29T23:01:00" # pymysql
assert iso("2027-01-29 23:01:00") == "2027-01-29T23:01:00" # text
Checking it against the live Payload
And to close, the post-import verification script: it reads the real campaigns and banners from Payload and confronts them with the WordPress origin. Migrating without checking is migrating with your fingers crossed.
What's left
F6c, closed. The old WordPress ads now have a path into Payload: zones, campaigns, banners and advertiser contacts.
The night: subscriptions, newsletters and the jump to production
Monday did not end with the ads. Between 7pm and 11pm the whole of phase 7 fell, plus the first stone of phase 8.
Double opt-in, non-negotiable
The subscription form is no longer a stub. There is now a subscribers collection with status (pending, confirmed, unsubscribed), a unique token only the admin can read and a GDPR-truncated IP: IPv4 to /24, IPv6 to /64. POST /api/subscriure keeps the layout stub's contract: always a 200 response, so addresses cannot be probed. And unsubscribing does not delete the record: it stays as an audit trail.
Newsletters: composer, sending and archive
The /admin/newsletter composer, sending via SendGrid and the public archive at /butlletins (F7b). With its migration batch:
DO $$ BEGIN
CREATE TYPE "public"."enum_butlletins_estat" AS ENUM('esborrany', 'programat', 'enviat');
EXCEPTION WHEN duplicate_object THEN null; END $$;
CREATE TYPE IF NOT EXISTS does not exist in Postgres: the repo's DO-block pattern is the way. And two classics: JS comments inside the SQL template sneaking out as literal text (now --), and a shared relation Set between the array and the root table in butlletins_rels, the same pattern we already saw with plans_laia_rels.
A frontend detail that took its hour: the newsletter archive with ISR prerender tried to talk to the database during the build. force-dynamic and done. And a lying test: the sitemap one gave a false negative because of SIGPIPE with pipefail.
The production server, in writing
F8a starts: docker-compose.prod.yml with Caddy on TLS (80/443), the app listening only on 127.0.0.1:3000, MinIO with no public ports and secrets via PANXING_SECRETS_DIR. deploy-prod.sh does pull → build (with a tag so rollback is possible) → up → migrate → smoke. And export-to-prod.sh automates runbook 09a: when there is a VPS, run it twice and watch the smoke.
The final touch, very much daily life: the MinIO user guard did mc admin user list | awk | grep, and the latest image no longer ships awk or grep. Now:
if mc admin user info local "$S3_ACCESS_KEY_ID" >/dev/null 2>&1; then
F7 closed. F8 underway.