learn-platform-deploy/Backend/cmd/api/relational_schema.go

566 lines
29 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
)
type relationalResourceSpec struct {
Name string
Table string
Fields []string
JSONFields map[string]struct{}
References map[string]relationalReference
ListRelations map[string]relationalListRelation
}
type relationalListRelation struct {
Table string
OwnerColumn string
TargetColumn string
TargetTable string
}
type relationalReference struct {
Table string
OnDelete string
}
var relationalResources = buildRelationalResourceSpecs()
func buildRelationalResourceSpecs() map[string]relationalResourceSpec {
specs := []relationalResourceSpec{
resource("tenants", []string{"name", "slug", "description", "status"}, nil, nil),
resource("users", []string{"name", "displayName", "email", "username", "keycloakUsername", "role", "status", "gdprErasedAt"}, nil,
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("pages", []string{"name", "title", "slug", "status", "visibility", "publishedAt", "layout", "blocks", "content", "seo"}, []string{"layout", "blocks", "content", "seo"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("datasources", []string{"name", "description", "type", "status", "config"}, []string{"config"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("scorm_courses", []string{"title", "description", "status", "version", "visibility", "launchUrl", "scos", "manifest"}, []string{"scos", "manifest"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("scorm_exports", []string{"courseId", "version", "storageKey"}, nil,
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "courseId": ref("scorm_courses", "CASCADE")}),
resource("enrollments", []string{"userId", "courseId", "courseType", "status", "score", "progress", "progressNote", "timeSpentMin", "completedAt", "archivedAt", "archivedReason", "erasedUserId", "gdprErasedAt"}, nil,
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE")}),
resource("authoring_runs", []string{"userId", "status", "brief", "proposalRevision", "source", "reviewUrl", "requiresCMSApproval", "proposal", "validation", "idempotencyKeys", "theme", "site", "page"}, []string{"proposal", "validation", "idempotencyKeys", "theme", "site", "page"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "SET NULL")}),
resource("consent_receipts", []string{"userId", "policyVersion", "policyUrl", "analytics", "marketing", "preferences", "necessary", "revokedAt", "revokedReason", "categories", "receipt"}, []string{"categories", "receipt"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE")}),
resource("data_subject_requests", []string{"userId", "type", "status", "requestedAt", "decidedAt", "mode", "reason", "archivedAt", "archivedReason", "payload", "recordTypes"}, []string{"payload", "recordTypes"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE")}),
resource("webhooks", []string{"name", "url", "secret", "status", "type", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("forms", []string{"name", "title", "slug", "description", "status", "schema", "fields"}, []string{"schema", "fields"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("form_submissions", []string{"formId", "userId", "submittedAt", "values", "data", "metadata"}, []string{"values", "data", "metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "formId": ref("forms", "CASCADE"), "userId": ref("users", "SET NULL")}),
resource("sitemap_menus", []string{"name", "description", "items"}, []string{"items"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("audit_events", []string{"actorUserId", "actor", "action", "resource", "resourceId", "result", "timestamp", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "actorUserId": ref("users", "SET NULL")}),
resource("assets", []string{"title", "name", "fileName", "description", "status", "visibility", "ownerUserId", "ownerName", "relatedResourceType", "relatedResourceId", "entitlementKey", "protectionLevel", "sourceUrl", "attributionText", "claimStatus", "licence", "license", "requiresAttribution", "allowDownload", "allowReuse", "allowModification", "allowCommercialUse", "encryption", "copyright", "metadata"}, []string{"encryption", "copyright", "metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "ownerUserId": ref("users", "SET NULL")}),
resource("asset_access_events", []string{"assetId", "userId", "eventType", "submittedAt", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "assetId": ref("assets", "CASCADE"), "userId": ref("users", "SET NULL")}),
resource("asset_claims", []string{"assetId", "userId", "status", "claimStatus", "submittedAt", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "assetId": ref("assets", "CASCADE"), "userId": ref("users", "SET NULL")}),
resource("notification_templates", []string{"name", "channel", "subject", "body", "enabled", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("notification_preferences", []string{"userId", "email", "inApp", "sms", "push", "enabled", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE")}),
resource("notification_deliveries", []string{"userId", "templateId", "channel", "to", "subject", "body", "status", "queuedAt", "sentAt", "deliveredAt", "failedAt", "deferredAt", "throttledAt", "provider", "providerMessageId", "throttleReason", "lastError", "idempotencyKey", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "SET NULL"), "templateId": ref("notification_templates", "SET NULL")}),
resource("notification_queue", []string{"deliveryId", "channel", "to", "subject", "body", "templateId", "status", "attemptCount", "nextAttemptAt", "providerHint", "fallbackProvider", "idempotencyKey", "lastError", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "deliveryId": ref("notification_deliveries", "CASCADE"), "templateId": ref("notification_templates", "SET NULL")}),
resource("notification_delivery_attempts", []string{"deliveryId", "queueId", "attempt", "attemptStatus", "errorMessage", "providerMessageId"}, nil,
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "deliveryId": ref("notification_deliveries", "CASCADE"), "queueId": ref("notification_queue", "SET NULL")}),
resource("notification_dead_letters", []string{"deliveryId", "queueId", "channel", "to", "subject", "body", "templateId", "idempotencyKey", "providerHint", "fallbackProvider", "attemptCount", "reason", "movedAt", "replayedAt", "replayId", "deadLetterId", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "deliveryId": ref("notification_deliveries", "SET NULL"), "templateId": ref("notification_templates", "SET NULL")}),
resource("notification_rate_limit_policies", []string{"channel", "throughputPerMinute", "burst", "maxRetries", "backoffSeconds", "maxBackoffSeconds", "deferredSeconds", "perTemplatePerHour", "perRecipientPerHour", "quietHoursStartUtc", "quietHoursEndUtc", "channelEnabled", "throttledAction", "fallbackProvider"}, nil,
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("notification_provider_configs", []string{"provider", "enabled", "apiKey", "fromEmail", "apiBaseURL", "credentialMode", "source", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("notifications", []string{"userId", "title", "message", "read", "channel", "status", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE")}),
resource("blog_categories", []string{"name", "slug", "description", "status"}, nil,
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("blog_tags", []string{"name", "slug", "description", "status"}, nil,
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("blog_authors", []string{"name", "slug", "email", "bio", "userId", "status", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "SET NULL")}),
resource("blog_posts", []string{"title", "slug", "status", "visibility", "excerpt", "content", "authorId", "categoryId", "publishedAt", "scheduledFor", "featured", "featuredImage", "seo", "metadata"}, []string{"content", "featuredImage", "seo", "metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "authorId": ref("blog_authors", "SET NULL"), "categoryId": ref("blog_categories", "SET NULL")}),
resource("entitlements", []string{"userId", "key", "value", "active", "status", "scope", "source", "reason", "startsAt", "endsAt", "revokedAt", "graceAt", "convertedFromTrialAt", "convertedFromEntitlementId", "initiatedByUserId", "lastUpdatedByUserId", "platformAccountId", "sourceEventId", "stripeEventId", "stripeEventType", "checkoutSessionId", "metadata"}, []string{"value", "metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE"), "convertedFromEntitlementId": ref("entitlements", "SET NULL")}),
resource("live_courses", []string{"title", "slug", "status", "visibility", "description", "deliveryMode", "level", "durationSummary", "communityId", "entitlementKey", "priceId", "organisationId", "publishedAt", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("live_course_cohorts", []string{"courseId", "liveCourseId", "name", "status", "startsAt", "endsAt", "timezone", "capacity", "enrolledCount", "waitlistEnabled", "priceId", "meetingProvider", "defaultMeetingUrl", "organisationId", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "courseId": ref("live_courses", "CASCADE"), "liveCourseId": ref("live_courses", "CASCADE")}),
resource("live_course_sessions", []string{"courseId", "liveCourseId", "cohortId", "title", "status", "startsAt", "endsAt", "meetingUrl", "recordingAssetId", "communityThreadId", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "cohortId": ref("live_course_cohorts", "CASCADE"), "recordingAssetId": ref("assets", "SET NULL")}),
resource("live_course_enrollments", []string{"courseId", "liveCourseId", "cohortId", "userId", "status", "source", "enrolledAt", "completedAt", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "cohortId": ref("live_course_cohorts", "CASCADE"), "userId": ref("users", "CASCADE")}),
resource("live_course_attendance", []string{"courseId", "liveCourseId", "cohortId", "sessionId", "enrollmentId", "userId", "status", "joinedAt", "leftAt", "durationSeconds", "notes", "markedBy", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "sessionId": ref("live_course_sessions", "CASCADE"), "enrollmentId": ref("live_course_enrollments", "CASCADE"), "userId": ref("users", "CASCADE")}),
resource("communities", []string{"name", "slug", "description", "status", "visibility", "accessPolicy", "requiredEntitlementKey", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("community_channels", []string{"communityId", "name", "slug", "topic", "isPrivate", "status", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "communityId": ref("communities", "CASCADE")}),
resource("community_memberships", []string{"communityId", "userId", "role", "status", "source", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "communityId": ref("communities", "CASCADE"), "userId": ref("users", "CASCADE")}),
resource("community_posts", []string{"communityId", "channelId", "authorId", "body", "status", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "communityId": ref("communities", "CASCADE"), "channelId": ref("community_channels", "CASCADE"), "authorId": ref("users", "SET NULL")}),
resource("community_replies", []string{"postId", "authorId", "body", "status", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "postId": ref("community_posts", "CASCADE"), "authorId": ref("users", "SET NULL")}),
resource("keycloak_themes", []string{"name", "slug", "version", "status", "scope", "type", "target", "organizationId", "realm", "client", "createdBy", "updatedBy", "sections", "tokens", "metadata"}, []string{"target", "sections", "tokens", "metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("keycloak_theme_policy", []string{"organizationId", "allowList", "policy", "metadata"}, []string{"allowList", "policy", "metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("keycloak_theme_exports", []string{"themeId", "themeSlug", "checksum", "format", "baseDir", "fileCount", "files", "previewOnly", "metadata"}, []string{"files", "metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "themeId": ref("keycloak_themes", "CASCADE")}),
resource("keycloak_theme_deployments_current", []string{"deploymentKey", "themeId", "themeSlug", "version", "status", "summary", "target", "realm", "client", "organizationId", "deployedBy", "deployedAt", "rollback", "rollbackThemeId", "rollbackVersion", "errorMessage"}, []string{"target", "rollback"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "themeId": ref("keycloak_themes", "CASCADE")}),
resource("keycloak_theme_deployments_audit", []string{"deploymentKey", "themeId", "themeSlug", "version", "status", "summary", "target", "realm", "client", "organizationId", "actor", "deployedBy", "deployedAt", "rollback", "rollbackThemeId", "rollbackVersion", "errorMessage"}, []string{"target", "rollback"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "themeId": ref("keycloak_themes", "SET NULL")}),
resource("platform_settings", []string{"platformIdentity", "seoDefaults", "consentLinks", "integrations", "features", "branding", "metadata"}, []string{"platformIdentity", "seoDefaults", "consentLinks", "integrations", "features", "branding", "metadata"}, nil),
resource("admin_settings", []string{"platformIdentity", "seoDefaults", "consentLinks", "integrations", "features", "branding", "metadata"}, []string{"platformIdentity", "seoDefaults", "consentLinks", "integrations", "features", "branding", "metadata"}, nil),
resource("billing_customers", []string{"userId", "stripeCustomerId", "email", "lastInitiatedByUserId", "scope", "status", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "SET NULL")}),
resource("billing_events", []string{"provider", "type", "status", "receivedAt", "stripeCreatedAt", "platformAccountId", "userId", "initiatedByUserId", "processedAt", "ignored", "reason", "scope", "billingCustomerId", "initiatedByTenantId", "isPlatformActor", "checkoutSessionId", "archivedAt", "archivedReason", "data", "metadata"}, []string{"data", "metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "SET NULL"), "billingCustomerId": ref("billing_customers", "SET NULL")}),
resource("billing_price_policies", []string{"priceId", "entitlementKey", "mode", "scope", "durationDays", "trialDays", "graceDays", "status", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
}
for i := range specs {
switch specs[i].Name {
case "blog_posts":
specs[i].ListRelations = map[string]relationalListRelation{
"tagIds": {Table: "blog_post_tags", OwnerColumn: "post_id", TargetColumn: "tag_id", TargetTable: "blog_tags"},
}
case "live_courses":
specs[i].ListRelations = map[string]relationalListRelation{
"instructorIds": {Table: "live_course_instructors", OwnerColumn: "live_course_id", TargetColumn: "user_id", TargetTable: "users"},
"assetIds": {Table: "live_course_assets", OwnerColumn: "live_course_id", TargetColumn: "asset_id", TargetTable: "assets"},
}
case "communities":
specs[i].ListRelations = map[string]relationalListRelation{
"allowedRoles": {Table: "community_allowed_roles", OwnerColumn: "community_id", TargetColumn: "role"},
}
}
}
out := make(map[string]relationalResourceSpec, len(specs))
for _, spec := range specs {
out[spec.Name] = spec
}
return out
}
func resource(name string, fields []string, jsonFields []string, references map[string]relationalReference) relationalResourceSpec {
jsonSet := map[string]struct{}{}
for _, field := range jsonFields {
jsonSet[field] = struct{}{}
}
return relationalResourceSpec{
Name: name,
Table: name,
Fields: append([]string{}, fields...),
JSONFields: jsonSet,
References: references,
ListRelations: nil,
}
}
func ref(table, onDelete string) relationalReference {
return relationalReference{Table: table, OnDelete: onDelete}
}
func (s relationalResourceSpec) columnFields() []string {
fields := []string{"id", "tenantId", "createdAt", "updatedAt"}
fields = append(fields, s.Fields...)
return uniqueStrings(fields)
}
func (s relationalResourceSpec) allFields() []string {
fields := s.columnFields()
for field := range s.ListRelations {
fields = append(fields, field)
}
return uniqueStrings(fields)
}
func (s relationalResourceSpec) hasField(field string) bool {
for _, candidate := range s.allFields() {
if candidate == field {
return true
}
}
return false
}
func (s relationalResourceSpec) hasColumn(field string) bool {
for _, candidate := range s.columnFields() {
if candidate == field {
return true
}
}
return false
}
func (s relationalResourceSpec) isJSON(field string) bool {
_, ok := s.JSONFields[field]
return ok
}
func relationalColumnName(field string) string {
if field == "tenantId" {
return "tenant_id"
}
var b strings.Builder
for i, r := range field {
if r >= 'A' && r <= 'Z' {
if i > 0 {
b.WriteByte('_')
}
b.WriteRune(r + ('a' - 'A'))
continue
}
b.WriteRune(r)
}
return b.String()
}
func relationalSQLType(spec relationalResourceSpec, field string) string {
if spec.isJSON(field) {
return "JSONB"
}
if isBooleanResourceField(spec.Name, field) {
return "BOOLEAN"
}
if isNumericField(field) {
if field == "score" || field == "progress" {
return "DOUBLE PRECISION"
}
return "BIGINT"
}
return "TEXT"
}
func isBooleanResourceField(resourceName, field string) bool {
switch resourceName {
case "consent_receipts":
return field == "analytics" || field == "marketing" || field == "preferences" || field == "necessary"
case "assets":
return field == "requiresAttribution" || field == "allowDownload" || field == "allowReuse" || field == "allowModification" || field == "allowCommercialUse"
case "notification_templates":
return field == "enabled"
case "notification_preferences":
return field == "email" || field == "inApp" || field == "sms" || field == "push" || field == "enabled"
case "notification_rate_limit_policies":
return field == "channelEnabled"
case "notification_provider_configs":
return field == "enabled"
case "notifications":
return field == "read"
case "entitlements":
return field == "active"
case "live_course_cohorts":
return field == "waitlistEnabled"
case "community_channels":
return field == "isPrivate"
case "authoring_runs":
return field == "requiresCMSApproval"
case "blog_posts":
return field == "featured"
case "keycloak_theme_exports":
return field == "previewOnly"
case "billing_events":
return field == "ignored" || field == "isPlatformActor"
default:
return false
}
}
func isNumericField(field string) bool {
if strings.HasSuffix(field, "At") {
return true
}
switch field {
case "createdAt", "updatedAt", "score", "progress", "timeSpentMin", "proposalRevision",
"fileCount", "capacity", "enrolledCount", "durationSeconds", "quantity", "attempt",
"attemptCount", "retryCount", "throughputPerMinute", "burst", "maxRetries",
"backoffSeconds", "maxBackoffSeconds", "deferredSeconds", "perTemplatePerHour",
"perRecipientPerHour", "quietHoursStartUtc", "quietHoursEndUtc", "durationDays",
"trialDays", "graceDays", "limit":
return true
default:
return false
}
}
func (d *pgDatabase) ensureRelationalSchema(ctx context.Context) error {
if _, err := d.pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
return err
}
names := make([]string, 0, len(relationalResources))
for name := range relationalResources {
names = append(names, name)
}
sort.SliceStable(names, func(i, j int) bool {
return relationalTableRank(names[i]) < relationalTableRank(names[j])
})
for _, name := range names {
spec := relationalResources[name]
if err := d.ensureRelationalTable(ctx, spec, false); err != nil {
return fmt.Errorf("create %s: %w", spec.Table, err)
}
}
for _, name := range names {
spec := relationalResources[name]
if err := d.ensureRelationalReferences(ctx, spec); err != nil {
return fmt.Errorf("reference %s: %w", spec.Table, err)
}
}
for _, name := range names {
if err := d.ensureListRelationTables(ctx, relationalResources[name]); err != nil {
return fmt.Errorf("list relations %s: %w", name, err)
}
}
if err := d.migrateLegacyDocumentTable(ctx); err != nil {
return fmt.Errorf("migrate legacy document store: %w", err)
}
_, err := d.pool.Exec(ctx, `INSERT INTO schema_migrations(version)
VALUES ('20260928_relational_v1')
ON CONFLICT (version) DO NOTHING`)
return err
}
func (d *pgDatabase) ensureListRelationTables(ctx context.Context, spec relationalResourceSpec) error {
fields := make([]string, 0, len(spec.ListRelations))
for field := range spec.ListRelations {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
relation := spec.ListRelations[field]
query := fmt.Sprintf(
"CREATE TABLE IF NOT EXISTS %s (%s TEXT NOT NULL REFERENCES %s(id) ON DELETE CASCADE, %s TEXT NOT NULL, PRIMARY KEY (%s, %s))",
relationalIdent(relation.Table),
relationalIdent(relation.OwnerColumn),
relationalIdent(spec.Table),
relationalIdent(relation.TargetColumn),
relationalIdent(relation.OwnerColumn),
relationalIdent(relation.TargetColumn),
)
if _, err := d.pool.Exec(ctx, query); err != nil {
return err
}
if relation.TargetTable != "" {
constraint := relation.Table + "_" + relation.TargetColumn + "_fk"
alter := fmt.Sprintf(`DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = '%s') THEN
ALTER TABLE %s ADD CONSTRAINT %s FOREIGN KEY (%s)
REFERENCES %s(id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
END IF;
END $$;`,
constraint,
relationalIdent(relation.Table),
relationalIdent(constraint),
relationalIdent(relation.TargetColumn),
relationalIdent(relation.TargetTable),
)
if _, err := d.pool.Exec(ctx, alter); err != nil {
return err
}
}
}
return nil
}
func (d *pgDatabase) migrateLegacyDocumentTable(ctx context.Context) error {
var legacyTable *string
if err := d.pool.QueryRow(ctx, "SELECT to_regclass('public.cms_documents')::text").Scan(&legacyTable); err != nil {
return err
}
if legacyTable == nil || strings.TrimSpace(*legacyTable) == "" {
return nil
}
var archiveTable *string
if err := d.pool.QueryRow(ctx, "SELECT to_regclass('public.cms_documents_legacy_20260928')::text").Scan(&archiveTable); err != nil {
return err
}
if archiveTable != nil && strings.TrimSpace(*archiveTable) != "" {
return fmt.Errorf("legacy archive table already exists")
}
rows, err := d.pool.Query(ctx, "SELECT collection, payload FROM cms_documents ORDER BY collection, id")
if err != nil {
return err
}
grouped := map[string][]Doc{}
for rows.Next() {
var collection string
var raw []byte
if err := rows.Scan(&collection, &raw); err != nil {
rows.Close()
return err
}
if _, ok := relationalResources[collection]; !ok {
rows.Close()
return fmt.Errorf("legacy collection %q has no relational mapping", collection)
}
var doc Doc
if err := json.Unmarshal(raw, &doc); err != nil {
rows.Close()
return fmt.Errorf("decode %s legacy row: %w", collection, err)
}
grouped[collection] = append(grouped[collection], doc)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
tx, err := d.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, "SET CONSTRAINTS ALL DEFERRED"); err != nil {
return err
}
names := make([]string, 0, len(grouped))
for name := range grouped {
names = append(names, name)
}
sort.SliceStable(names, func(i, j int) bool {
return relationalTableRank(names[i]) < relationalTableRank(names[j])
})
for _, name := range names {
collection := &pgCollection{db: d, spec: relationalResources[name]}
for _, doc := range grouped[name] {
if err := collection.writeDocument(ctx, tx, doc, true); err != nil {
return fmt.Errorf("%s row %v: %w", name, doc["id"], err)
}
}
}
if _, err := tx.Exec(ctx, `ALTER TABLE "cms_documents" RENAME TO "cms_documents_legacy_20260928"`); err != nil {
return err
}
return tx.Commit(ctx)
}
func (d *pgDatabase) ensureRelationalTable(ctx context.Context, spec relationalResourceSpec, _ bool) error {
columns := []string{relationalIdent("id") + " TEXT PRIMARY KEY"}
for _, field := range spec.columnFields() {
if field == "id" {
continue
}
column := relationalColumnName(field)
sqlType := relationalSQLType(spec, field)
definition := fmt.Sprintf("%s %s", relationalIdent(column), sqlType)
if field == "createdAt" || field == "updatedAt" {
definition += " NOT NULL DEFAULT 0"
}
columns = append(columns, definition)
}
query := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (%s)", relationalIdent(spec.Table), strings.Join(columns, ", "))
if _, err := d.pool.Exec(ctx, query); err != nil {
return err
}
for _, field := range []string{"tenantId", "updatedAt", "status", "slug", "userId", "courseId", "communityId", "cohortId"} {
if !spec.hasColumn(field) {
continue
}
column := relationalColumnName(field)
indexName := fmt.Sprintf("%s_%s_idx", spec.Table, column)
if _, err := d.pool.Exec(ctx, fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s ON %s (%s)", relationalIdent(indexName), relationalIdent(spec.Table), relationalIdent(column))); err != nil {
return err
}
}
return nil
}
func (d *pgDatabase) ensureRelationalReferences(ctx context.Context, spec relationalResourceSpec) error {
fields := make([]string, 0, len(spec.References))
for field := range spec.References {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
if !spec.hasField(field) {
continue
}
reference := spec.References[field]
column := relationalColumnName(field)
constraint := fmt.Sprintf("%s_%s_fk", spec.Table, column)
query := fmt.Sprintf(`DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = '%s'
) THEN
ALTER TABLE %s ADD CONSTRAINT %s FOREIGN KEY (%s)
REFERENCES %s(id) ON DELETE %s DEFERRABLE INITIALLY DEFERRED;
END IF;
END $$;`, constraint, relationalIdent(spec.Table), relationalIdent(constraint), relationalIdent(column), relationalIdent(reference.Table), reference.OnDelete)
if _, err := d.pool.Exec(ctx, query); err != nil {
return err
}
}
return nil
}
func relationalTableRank(name string) int {
switch name {
case "tenants":
return 0
case "users", "blog_categories", "blog_tags", "blog_authors", "forms", "scorm_courses", "assets", "communities", "live_courses", "notification_templates", "billing_customers", "keycloak_themes":
return 10
case "community_channels", "live_course_cohorts", "notification_deliveries":
return 20
case "live_course_sessions", "live_course_enrollments", "community_posts", "notification_queue":
return 30
case "live_course_attendance", "community_replies", "notification_delivery_attempts":
return 40
default:
return 50
}
}
func uniqueStrings(values []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(values))
for _, value := range values {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func relationalIdent(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `""`) + `"`
}