using Microsoft.EntityFrameworkCore.Migrations; #nullable disable namespace HrynCo.NotificationService.DAL.EF.Migrations; public partial class EmailTemplateLocalizations : Migration { protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.Sql(SourceLayoutGuard()); migrationBuilder.Sql("DO $upgrade$ BEGIN IF (SELECT layout FROM it1601_migration_origin) = 'flat' THEN\n" + FlatUpgrade() + "\nELSE\n" + InterimUpgrade() + "\nEND IF; END $upgrade$;"); } protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.Sql(""" LOCK TABLE email_templates IN ACCESS EXCLUSIVE MODE; DO $$ BEGIN IF EXISTS (SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20261017070315_EmailTemplateLocalizations') THEN RAISE EXCEPTION 'Cannot downgrade an interim-origin database to the flat legacy model. Restore a verified backup instead.'; END IF; IF EXISTS (SELECT 1 FROM email_template_definitions d WHERE NOT EXISTS (SELECT 1 FROM email_templates t WHERE t.definition_id = d.id)) OR EXISTS (SELECT 1 FROM service_languages l WHERE NOT EXISTS (SELECT 1 FROM email_templates t WHERE t.service_language_id = l.id)) OR EXISTS (SELECT 1 FROM services s WHERE NOT EXISTS (SELECT 1 FROM email_templates t WHERE t.service_id = s.id)) THEN RAISE EXCEPTION 'Cannot downgrade catalog-only data or empty definitions to the legacy model. Restore a verified backup instead.'; END IF; END $$; ALTER TABLE email_templates ADD COLUMN service_name varchar(100), ADD COLUMN key varchar(100), ADD COLUMN language_code varchar(10), ADD COLUMN variables jsonb; UPDATE email_templates t SET service_name = s.name, key = d.key, variables = d.variables, language_code = l.code FROM email_template_definitions d JOIN services s ON s.id = d.service_id JOIN service_languages l ON l.service_id = s.id WHERE d.id = t.definition_id AND l.id = t.service_language_id; ALTER TABLE email_templates ALTER COLUMN service_name SET NOT NULL, ALTER COLUMN key SET NOT NULL, ALTER COLUMN language_code SET NOT NULL; ALTER TABLE email_templates DROP COLUMN definition_id, DROP COLUMN service_id, DROP COLUMN service_language_id; DROP TABLE email_template_definitions; DROP TABLE service_languages; DROP TABLE services; CREATE UNIQUE INDEX "IX_email_templates_service_name_key_language_code" ON email_templates(service_name, key, language_code); """); } private static string FlatUpgrade() => """ DO $audit$ DECLARE row_record record; BEGIN FOR row_record IN SELECT id, service_name, key, language_code, variables FROM email_templates LOOP IF btrim(row_record.service_name) = '' OR btrim(row_record.key) = '' OR btrim(row_record.language_code) = '' OR row_record.variables IS NULL OR jsonb_typeof(row_record.variables) <> 'array' THEN RAISE EXCEPTION 'Invalid template identity or variables array: service=%, key=%, language=%, id=%', row_record.service_name, row_record.key, row_record.language_code, row_record.id; END IF; IF EXISTS (SELECT 1 FROM jsonb_array_elements(row_record.variables) v WHERE jsonb_typeof(v) <> 'object' OR jsonb_typeof(v->'name') IS DISTINCT FROM 'string' OR btrim(v->>'name') = '' OR jsonb_typeof(v->'required') IS DISTINCT FROM 'boolean' OR (v - 'name' - 'required') <> '{}'::jsonb) THEN RAISE EXCEPTION 'Invalid variables shape: service=%, key=%, language=%, id=%', row_record.service_name, row_record.key, row_record.language_code, row_record.id; END IF; IF EXISTS (SELECT 1 FROM jsonb_array_elements(row_record.variables) v GROUP BY v->>'name' HAVING count(*) > 1) THEN RAISE EXCEPTION 'Duplicate variable names: service=%, key=%, language=%, id=%', row_record.service_name, row_record.key, row_record.language_code, row_record.id; END IF; END LOOP; END $audit$; CREATE TEMP TABLE template_contract_audit ON COMMIT DROP AS SELECT t.*, COALESCE((SELECT jsonb_agg(v ORDER BY v->>'name') FROM jsonb_array_elements(t.variables) v), '[]'::jsonb) AS contract FROM email_templates t; DO $conflicts$ DECLARE conflict_record record; BEGIN SELECT service_name, key, string_agg(language_code || ':' || id::text, ', ' ORDER BY language_code) AS records INTO conflict_record FROM template_contract_audit GROUP BY service_name, key HAVING count(DISTINCT contract) > 1 LIMIT 1; IF FOUND THEN RAISE EXCEPTION 'Conflicting shared variables: service=%, key=%, records=%', conflict_record.service_name, conflict_record.key, conflict_record.records; END IF; END $conflicts$; CREATE TABLE services ( id uuid CONSTRAINT "PK_services" PRIMARY KEY, name varchar(100) NOT NULL, revision uuid NOT NULL, created timestamptz NOT NULL, updated timestamptz NULL); CREATE UNIQUE INDEX "IX_services_name" ON services(name); CREATE TABLE service_languages ( id uuid CONSTRAINT "PK_service_languages" PRIMARY KEY, service_id uuid NOT NULL, code varchar(10) NOT NULL, created timestamptz NOT NULL, updated timestamptz NULL, CONSTRAINT "AK_service_languages_id_service_id" UNIQUE(id, service_id), CONSTRAINT "FK_service_languages_services_service_id" FOREIGN KEY(service_id) REFERENCES services(id) ON DELETE RESTRICT); CREATE UNIQUE INDEX "IX_service_languages_service_id_code" ON service_languages(service_id, code); CREATE TABLE email_template_definitions ( id uuid CONSTRAINT "PK_email_template_definitions" PRIMARY KEY, service_id uuid NOT NULL, key varchar(100) NOT NULL, revision uuid NOT NULL, variables jsonb NOT NULL, created timestamptz NOT NULL, updated timestamptz NULL, CONSTRAINT "AK_email_template_definitions_id_service_id" UNIQUE(id, service_id), CONSTRAINT "FK_email_template_definitions_services_service_id" FOREIGN KEY(service_id) REFERENCES services(id) ON DELETE RESTRICT); CREATE UNIQUE INDEX "IX_email_template_definitions_service_id_key" ON email_template_definitions(service_id, key); INSERT INTO services(id, name, revision, created) SELECT gen_random_uuid(), service_name, gen_random_uuid(), min(created) FROM email_templates GROUP BY service_name; INSERT INTO service_languages(id, service_id, code, created) SELECT gen_random_uuid(), s.id, t.language_code, min(t.created) FROM email_templates t JOIN services s ON s.name = t.service_name GROUP BY s.id, t.language_code; INSERT INTO email_template_definitions(id, service_id, key, revision, variables, created, updated) SELECT DISTINCT ON (t.service_name, t.key) t.id, s.id, t.key, gen_random_uuid(), t.contract, min(t.created) OVER (PARTITION BY t.service_name, t.key), max(t.updated) OVER (PARTITION BY t.service_name, t.key) FROM template_contract_audit t JOIN services s ON s.name = t.service_name ORDER BY t.service_name, t.key, t.id; ALTER TABLE email_templates ADD COLUMN definition_id uuid, ADD COLUMN service_id uuid, ADD COLUMN service_language_id uuid; UPDATE email_templates t SET definition_id = d.id, service_id = s.id, service_language_id = l.id FROM email_template_definitions d JOIN services s ON s.id = d.service_id JOIN service_languages l ON l.service_id = s.id WHERE t.service_name = s.name AND t.key = d.key AND t.language_code = l.code; ALTER TABLE email_templates ALTER COLUMN definition_id SET NOT NULL, ALTER COLUMN service_id SET NOT NULL, ALTER COLUMN service_language_id SET NOT NULL; ALTER TABLE email_templates ADD CONSTRAINT "FK_email_templates_email_template_definitions_definition_id_se~" FOREIGN KEY (definition_id, service_id) REFERENCES email_template_definitions(id, service_id) ON DELETE CASCADE; ALTER TABLE email_templates ADD CONSTRAINT "FK_email_templates_service_languages_service_language_id_servi~" FOREIGN KEY (service_language_id, service_id) REFERENCES service_languages(id, service_id) ON DELETE RESTRICT; DROP INDEX "IX_email_templates_service_name_key_language_code"; ALTER TABLE email_templates DROP COLUMN service_name, DROP COLUMN key, DROP COLUMN variables, DROP COLUMN language_code; CREATE UNIQUE INDEX "IX_email_templates_definition_id_service_language_id" ON email_templates(definition_id, service_language_id); CREATE INDEX "IX_email_templates_definition_id_service_id" ON email_templates(definition_id, service_id); CREATE INDEX "IX_email_templates_service_language_id_service_id" ON email_templates(service_language_id, service_id); """; private static string SourceLayoutGuard() => """ SET LOCAL search_path = public; DO $layout$ DECLARE template_columns text[]; service_columns text[]; definition_columns text[]; source_constraints text[]; source_indexes text[]; known_interim_history boolean; source_layout text; BEGIN SELECT array_agg(column_name || ':' || udt_name || ':' || coalesce(character_maximum_length::text, '') || ':' || is_nullable ORDER BY column_name) INTO template_columns FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'email_templates'; SELECT array_agg(column_name || ':' || udt_name || ':' || coalesce(character_maximum_length::text, '') || ':' || is_nullable ORDER BY column_name) INTO service_columns FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'services'; SELECT array_agg(column_name || ':' || udt_name || ':' || coalesce(character_maximum_length::text, '') || ':' || is_nullable ORDER BY column_name) INTO definition_columns FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'email_template_definitions'; SELECT EXISTS (SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20261017070315_EmailTemplateLocalizations') INTO known_interim_history; SELECT array_agg(c.relname || ':' || p.conname || ':' || pg_get_constraintdef(p.oid) ORDER BY c.relname, p.conname) INTO source_constraints FROM pg_constraint p JOIN pg_class c ON c.oid = p.conrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND c.relname IN ('services', 'email_template_definitions', 'email_templates'); SELECT array_agg(indexdef ORDER BY indexname) INTO source_indexes FROM pg_indexes WHERE schemaname = 'public' AND tablename IN ('services', 'email_template_definitions', 'email_templates'); IF to_regclass('public.service_languages') IS NOT NULL THEN RAISE EXCEPTION 'Unsupported email-template source layout: service language catalog already exists without the current migration history.'; END IF; IF template_columns = ARRAY['created:timestamptz::NO', 'html_body:text::NO', 'id:uuid::NO', 'key:varchar:100:NO', 'language_code:varchar:10:NO', 'service_name:varchar:100:NO', 'subject:text::NO', 'text_body:text::NO', 'updated:timestamptz::YES', 'variables:jsonb::YES'] AND service_columns IS NULL AND definition_columns IS NULL AND NOT known_interim_history AND source_constraints = ARRAY['email_templates:PK_email_templates:PRIMARY KEY (id)'] AND source_indexes = ARRAY[ 'CREATE UNIQUE INDEX "IX_email_templates_service_name_key_language_code" ON public.email_templates USING btree (service_name, key, language_code)', 'CREATE UNIQUE INDEX "PK_email_templates" ON public.email_templates USING btree (id)'] THEN source_layout := 'flat'; ELSIF template_columns = ARRAY['created:timestamptz::NO', 'definition_id:uuid::NO', 'html_body:text::NO', 'id:uuid::NO', 'language_code:varchar:10:NO', 'subject:text::NO', 'text_body:text::NO', 'updated:timestamptz::YES'] AND service_columns = ARRAY['created:timestamptz::NO', 'id:uuid::NO', 'name:varchar:100:NO', 'updated:timestamptz::YES'] AND definition_columns = ARRAY['created:timestamptz::NO', 'id:uuid::NO', 'key:varchar:100:NO', 'revision:uuid::NO', 'service_id:uuid::NO', 'updated:timestamptz::YES', 'variables:jsonb::NO'] AND known_interim_history AND source_constraints = ARRAY[ 'email_template_definitions:email_template_definitions_pkey:PRIMARY KEY (id)', 'email_template_definitions:email_template_definitions_service_id_fkey:FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE RESTRICT', 'email_templates:FK_email_templates_email_template_definitions_definition_id:FOREIGN KEY (definition_id) REFERENCES email_template_definitions(id) ON DELETE CASCADE', 'email_templates:PK_email_templates:PRIMARY KEY (id)', 'services:services_pkey:PRIMARY KEY (id)'] AND source_indexes = ARRAY[ 'CREATE UNIQUE INDEX "IX_email_template_definitions_service_id_key" ON public.email_template_definitions USING btree (service_id, key)', 'CREATE UNIQUE INDEX "IX_email_templates_definition_id_language_code" ON public.email_templates USING btree (definition_id, language_code)', 'CREATE UNIQUE INDEX "IX_services_name" ON public.services USING btree (name)', 'CREATE UNIQUE INDEX "PK_email_templates" ON public.email_templates USING btree (id)', 'CREATE UNIQUE INDEX email_template_definitions_pkey ON public.email_template_definitions USING btree (id)', 'CREATE UNIQUE INDEX services_pkey ON public.services USING btree (id)'] THEN source_layout := 'interim'; ELSE RAISE EXCEPTION 'Unsupported email-template source layout or history. Expected the flat legacy schema or the known 20261017070315 interim schema; no changes were made.'; END IF; LOCK TABLE email_templates IN ACCESS EXCLUSIVE MODE; IF source_layout = 'interim' THEN LOCK TABLE services, email_template_definitions IN ACCESS EXCLUSIVE MODE; END IF; CREATE TEMP TABLE it1601_migration_origin (layout text NOT NULL) ON COMMIT DROP; INSERT INTO it1601_migration_origin VALUES (source_layout); END $layout$; """; private static string InterimUpgrade() => """ DO $interim_audit$ DECLARE row_record record; BEGIN IF EXISTS (SELECT 1 FROM services WHERE btrim(name) = '') OR EXISTS (SELECT 1 FROM email_template_definitions WHERE btrim(key) = '') OR EXISTS (SELECT 1 FROM email_templates WHERE btrim(language_code) = '') THEN RAISE EXCEPTION 'Invalid blank identity in interim email-template data; no changes were made.'; END IF; IF EXISTS (SELECT 1 FROM email_template_definitions d LEFT JOIN services s ON s.id = d.service_id WHERE s.id IS NULL) OR EXISTS (SELECT 1 FROM email_templates t LEFT JOIN email_template_definitions d ON d.id = t.definition_id WHERE d.id IS NULL) THEN RAISE EXCEPTION 'Invalid orphaned reference in interim email-template data; no changes were made.'; END IF; FOR row_record IN SELECT d.id, s.name AS service_name, d.key, d.variables FROM email_template_definitions d JOIN services s ON s.id = d.service_id LOOP IF row_record.variables IS NULL OR jsonb_typeof(row_record.variables) <> 'array' THEN RAISE EXCEPTION 'Invalid interim shared variables array: service=%, key=%, definition=%', row_record.service_name, row_record.key, row_record.id; END IF; IF EXISTS (SELECT 1 FROM jsonb_array_elements(row_record.variables) v WHERE jsonb_typeof(v) <> 'object' OR jsonb_typeof(v->'name') IS DISTINCT FROM 'string' OR btrim(v->>'name') = '' OR jsonb_typeof(v->'required') IS DISTINCT FROM 'boolean' OR (v - 'name' - 'required') <> '{}'::jsonb) THEN RAISE EXCEPTION 'Invalid interim shared variables shape: service=%, key=%, definition=%', row_record.service_name, row_record.key, row_record.id; END IF; IF EXISTS (SELECT 1 FROM jsonb_array_elements(row_record.variables) v GROUP BY v->>'name' HAVING count(*) > 1) THEN RAISE EXCEPTION 'Duplicate interim shared variable names: service=%, key=%, definition=%', row_record.service_name, row_record.key, row_record.id; END IF; END LOOP; END $interim_audit$; ALTER TABLE services ADD COLUMN revision uuid; UPDATE services SET revision = gen_random_uuid(); ALTER TABLE services ALTER COLUMN revision SET NOT NULL; ALTER TABLE services RENAME CONSTRAINT services_pkey TO "PK_services"; ALTER TABLE email_template_definitions RENAME CONSTRAINT email_template_definitions_pkey TO "PK_email_template_definitions"; ALTER TABLE email_template_definitions RENAME CONSTRAINT email_template_definitions_service_id_fkey TO "FK_email_template_definitions_services_service_id"; ALTER TABLE email_template_definitions ADD CONSTRAINT "AK_email_template_definitions_id_service_id" UNIQUE(id, service_id); CREATE TABLE service_languages ( id uuid CONSTRAINT "PK_service_languages" PRIMARY KEY, service_id uuid NOT NULL, code varchar(10) NOT NULL, created timestamptz NOT NULL, updated timestamptz NULL, CONSTRAINT "AK_service_languages_id_service_id" UNIQUE(id, service_id), CONSTRAINT "FK_service_languages_services_service_id" FOREIGN KEY(service_id) REFERENCES services(id) ON DELETE RESTRICT); CREATE UNIQUE INDEX "IX_service_languages_service_id_code" ON service_languages(service_id, code); INSERT INTO service_languages(id, service_id, code, created, updated) SELECT gen_random_uuid(), d.service_id, t.language_code, min(t.created), max(t.updated) FROM email_templates t JOIN email_template_definitions d ON d.id = t.definition_id GROUP BY d.service_id, t.language_code; ALTER TABLE email_templates ADD COLUMN service_id uuid, ADD COLUMN service_language_id uuid; UPDATE email_templates t SET service_id = d.service_id, service_language_id = l.id FROM email_template_definitions d JOIN service_languages l ON l.service_id = d.service_id WHERE t.definition_id = d.id AND t.language_code = l.code; ALTER TABLE email_templates ALTER COLUMN service_id SET NOT NULL, ALTER COLUMN service_language_id SET NOT NULL; ALTER TABLE email_templates ADD CONSTRAINT "FK_email_templates_email_template_definitions_definition_id_se~" FOREIGN KEY (definition_id, service_id) REFERENCES email_template_definitions(id, service_id) ON DELETE CASCADE; ALTER TABLE email_templates ADD CONSTRAINT "FK_email_templates_service_languages_service_language_id_servi~" FOREIGN KEY (service_language_id, service_id) REFERENCES service_languages(id, service_id) ON DELETE RESTRICT; ALTER TABLE email_templates DROP CONSTRAINT "FK_email_templates_email_template_definitions_definition_id"; DROP INDEX "IX_email_templates_definition_id_language_code"; ALTER TABLE email_templates DROP COLUMN language_code; CREATE UNIQUE INDEX "IX_email_templates_definition_id_service_language_id" ON email_templates(definition_id, service_language_id); CREATE INDEX "IX_email_templates_definition_id_service_id" ON email_templates(definition_id, service_id); CREATE INDEX "IX_email_templates_service_language_id_service_id" ON email_templates(service_language_id, service_id); """; }