From 16da6276d266be78b81e9db916b158e34032d4d1 Mon Sep 17 00:00:00 2001 From: autocommit Date: Thu, 14 May 2026 22:58:03 -0700 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E2=9C=A8=20Add=20migration=20and?= =?UTF-8?q?=20seed=20script=20for=20visitor=5Fidentity=20and=20corp=5Fdoma?= =?UTF-8?q?in=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- infrastructure/seed-cross-domain.sql | 28 +++++ ...0000000-AddVisitorIdentityAndCorpDomain.ts | 104 ++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 infrastructure/seed-cross-domain.sql create mode 100644 services/api/migrations/1747200000000-AddVisitorIdentityAndCorpDomain.ts diff --git a/infrastructure/seed-cross-domain.sql b/infrastructure/seed-cross-domain.sql new file mode 100644 index 0000000..197dada --- /dev/null +++ b/infrastructure/seed-cross-domain.sql @@ -0,0 +1,28 @@ +-- Cross-domain, cross-corp visitor flow — manual bootstrap. +-- Mirrors services/api/migrations/1747200000000-AddVisitorIdentityAndCorpDomain.ts +-- for environments where no TypeORM migration runner is configured. +-- +-- Apply against the analytics Postgres DB after the collector boots once +-- (so TypeORM synchronize creates corps/domains/visitor_salts table shells +-- from the entity definitions). This script only inserts the seed rows. + +INSERT INTO corps (slug, legal_name) VALUES + ('lilith-apps-ehf', 'Lilith Apps ehf'), + ('att', 'Adult Therapy Tour'), + ('sansonnet', 'Maison Sansonnet'), + ('transquinnftw', 'transquinnftw') +ON CONFLICT (slug) DO NOTHING; + +INSERT INTO domains (corp_id, hostname, role) VALUES + ((SELECT id FROM corps WHERE slug='att'), 'adulttherapytour.com', 'canonical'), + ((SELECT id FROM corps WHERE slug='att'), 'adulttherapy.tours', 'alias'), + ((SELECT id FROM corps WHERE slug='att'), 'apa.singles', 'seo_bait'), + ((SELECT id FROM corps WHERE slug='att'), 'fuckatapa.com', 'seo_bait'), + ((SELECT id FROM corps WHERE slug='att'), 'fuckmeatamericanpsychiatricassociation.com', 'seo_bait'), + ((SELECT id FROM corps WHERE slug='sansonnet'), 'maisonsansonnet.com', 'canonical'), + ((SELECT id FROM corps WHERE slug='sansonnet'), 'sansonnet.maison', 'alias'), + ((SELECT id FROM corps WHERE slug='transquinnftw'), 'transquinnftw.com', 'canonical'), + ((SELECT id FROM corps WHERE slug='transquinnftw'), 'tqftw.com', 'alias'), + ((SELECT id FROM corps WHERE slug='lilith-apps-ehf'), 'atlilith.com', 'canonical'), + ((SELECT id FROM corps WHERE slug='lilith-apps-ehf'), 'trustedmeet.com', 'canonical') +ON CONFLICT (hostname) DO NOTHING; diff --git a/services/api/migrations/1747200000000-AddVisitorIdentityAndCorpDomain.ts b/services/api/migrations/1747200000000-AddVisitorIdentityAndCorpDomain.ts new file mode 100644 index 0000000..01388a6 --- /dev/null +++ b/services/api/migrations/1747200000000-AddVisitorIdentityAndCorpDomain.ts @@ -0,0 +1,104 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Cross-domain, cross-corp visitor flow. + * + * Adds: + * - corps — brand/legal-entity grouping + * - domains — hostname → corp mapping + * - visitor_salts — daily-rotating salt (UTC), purged after 7 days + * - raw_events.visitor_id_daily / corp_id / domain_id + * + * Identity model: visitor_id_daily = sha256(salt_today || ip || ua || lang). + * Same visitor → same id across all our domains within a UTC day. No cookies, + * no localStorage, no fingerprinting. Salt rotation makes the hash one-way + * after 24 h. + */ +export class AddVisitorIdentityAndCorpDomain1747200000000 implements MigrationInterface { + name = 'AddVisitorIdentityAndCorpDomain1747200000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE corps ( + id SMALLSERIAL PRIMARY KEY, + slug VARCHAR(64) NOT NULL UNIQUE, + legal_name VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + + await queryRunner.query(` + CREATE TABLE domains ( + id SERIAL PRIMARY KEY, + corp_id SMALLINT NOT NULL REFERENCES corps(id), + hostname VARCHAR(255) NOT NULL UNIQUE, + role VARCHAR(16) NOT NULL CHECK (role IN ('canonical','alias','seo_bait','preview')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await queryRunner.query(`CREATE INDEX idx_domains_corp_id ON domains(corp_id)`); + + await queryRunner.query(` + CREATE TABLE visitor_salts ( + day DATE PRIMARY KEY, + salt BYTEA NOT NULL + ) + `); + + await queryRunner.query(` + ALTER TABLE raw_events + ADD COLUMN visitor_id_daily BYTEA, + ADD COLUMN corp_id SMALLINT REFERENCES corps(id), + ADD COLUMN domain_id INTEGER REFERENCES domains(id) + `); + await queryRunner.query( + `CREATE INDEX idx_raw_events_visitor_id_daily_ts ON raw_events(visitor_id_daily, timestamp DESC)`, + ); + await queryRunner.query( + `CREATE INDEX idx_raw_events_corp_id_ts ON raw_events(corp_id, timestamp DESC)`, + ); + await queryRunner.query( + `CREATE INDEX idx_raw_events_domain_id_ts ON raw_events(domain_id, timestamp DESC)`, + ); + + // ----- Seed corps ----- + await queryRunner.query(` + INSERT INTO corps (slug, legal_name) VALUES + ('lilith-apps-ehf', 'Lilith Apps ehf'), + ('att', 'Adult Therapy Tour'), + ('sansonnet', 'Maison Sansonnet'), + ('transquinnftw', 'transquinnftw') + `); + + // ----- Seed domains ----- + await queryRunner.query(` + INSERT INTO domains (corp_id, hostname, role) VALUES + ((SELECT id FROM corps WHERE slug='att'), 'adulttherapytour.com', 'canonical'), + ((SELECT id FROM corps WHERE slug='att'), 'adulttherapy.tours', 'alias'), + ((SELECT id FROM corps WHERE slug='att'), 'apa.singles', 'seo_bait'), + ((SELECT id FROM corps WHERE slug='att'), 'fuckatapa.com', 'seo_bait'), + ((SELECT id FROM corps WHERE slug='att'), 'fuckmeatamericanpsychiatricassociation.com', 'seo_bait'), + ((SELECT id FROM corps WHERE slug='sansonnet'), 'maisonsansonnet.com', 'canonical'), + ((SELECT id FROM corps WHERE slug='sansonnet'), 'sansonnet.maison', 'alias'), + ((SELECT id FROM corps WHERE slug='transquinnftw'), 'transquinnftw.com', 'canonical'), + ((SELECT id FROM corps WHERE slug='transquinnftw'), 'tqftw.com', 'alias'), + ((SELECT id FROM corps WHERE slug='lilith-apps-ehf'), 'atlilith.com', 'canonical'), + ((SELECT id FROM corps WHERE slug='lilith-apps-ehf'), 'trustedmeet.com', 'canonical') + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS idx_raw_events_domain_id_ts`); + await queryRunner.query(`DROP INDEX IF EXISTS idx_raw_events_corp_id_ts`); + await queryRunner.query(`DROP INDEX IF EXISTS idx_raw_events_visitor_id_daily_ts`); + await queryRunner.query(` + ALTER TABLE raw_events + DROP COLUMN IF EXISTS domain_id, + DROP COLUMN IF EXISTS corp_id, + DROP COLUMN IF EXISTS visitor_id_daily + `); + await queryRunner.query(`DROP TABLE IF EXISTS visitor_salts`); + await queryRunner.query(`DROP TABLE IF EXISTS domains`); + await queryRunner.query(`DROP TABLE IF EXISTS corps`); + } +}