81654c0d33
Add normalized service and template catalogs, scoped administration, migration, validation, and regression coverage. Ref: IT-1601
285 lines
22 KiB
C#
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);
|
|
""";
|
|
}
|