analytics/services/api/migrations/1747200000000-AddVisitorIdentityAndCorpDomain.ts
2026-05-15 22:59:30 -07:00

107 lines
5 KiB
TypeScript

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<void> {
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'),
('cocotte', 'Maison Cocotte')
`);
// ----- 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'),
((SELECT id FROM corps WHERE slug='cocotte'), 'cocotte.maison', 'canonical'),
((SELECT id FROM corps WHERE slug='cocotte'), 'data.cocotte.maison', 'canonical')
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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`);
}
}