Files
hrynco-notification-service/HrynCo.NotificationService.DAL.EF/Migrations/20261017072410_EmailTemplateLocalizations.cs
T
agrynco 81654c0d33 feat(templates): add service language template management
Add normalized service and template catalogs, scoped administration, migration, validation, and regression coverage.

Ref: IT-1601
2026-10-17 22:02:43 +03:00

285 lines
22 KiB
C#

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);
""";
}