Files
acmcc/supabase/functions/migrate-from-source/index.ts
2026-06-01 20:19:26 -04:00

723 lines
22 KiB
TypeScript

import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
};
// Tables in dependency order (parents first)
const MIGRATION_TABLES = [
// No FK dependencies
"associations",
"company_settings",
"email_templates",
"fee_schedule_subcategories",
"fee_schedules",
"notify_board_templates",
"owner_notification_templates",
"announcements",
// Depends on associations
"units",
"association_faqs",
"association_fee_rules",
"bank_accounts",
"bids_quotes",
"blocked_dates",
"board_members",
"board_votes",
"budgets",
"calendar_events",
"call_logs",
"chart_of_accounts",
"checklists",
"checks",
"client_requests",
"documents",
"email_routing_rules",
"email_senders",
"email_server_settings",
"estoppels",
"inspections",
"invoices",
"legal_matters",
"owner_update_tags",
"parking_records",
"payables",
"payment_plans",
"projects",
"shared_folders",
"status_updates",
"tasks",
"vendors",
"violations",
"violation_responses",
"work_orders",
// Depends on associations + owners/units
"owners",
"owner_updates",
"owner_ledger_entries",
"collections",
"admin_payments",
"arc_applications",
"arc_application_comments",
"arc_application_votes",
"bank_reconciliations",
"bank_transactions",
"bank_transfers",
"bill_approvals",
"bill_comments",
"billable_expenses",
"bills",
"client_invoices",
"client_invoice_items",
"deposit_batches",
"deposit_batch_items",
"document_validation_proofs",
"email_history",
"entity_comments",
"entity_votes",
"homeowner_requests",
"in_app_notifications",
"journal_entries",
"owner_notification_proofs",
// Auth-related
"profiles",
"user_roles",
"role_permissions",
] as const;
type MappingRow = {
mapping_type: "table" | "column" | "id_value";
source_table: string | null;
destination_table: string | null;
source_field: string | null;
destination_field: string | null;
source_value: string | null;
destination_value: string | null;
};
type ColumnMapping = {
sourceField: string;
destinationField: string;
};
type IdValueMapping = {
sourceField: string;
destinationField: string | null;
sourceValue: string;
destinationValue: string;
};
function isMissingValue(value: unknown) {
return value === null || value === undefined || value === "";
}
function normalizeName(value: unknown) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/\s+/g, " ");
}
function buildMappingIndexes(mappings: MappingRow[]) {
const sourceTableByTargetTable = new Map<string, string>();
const columnMappingsByTargetTable = new Map<string, ColumnMapping[]>();
const idMappingsByTargetTable = new Map<string, IdValueMapping[]>();
for (const mapping of mappings) {
if (mapping.mapping_type === "table" && mapping.source_table && mapping.destination_table) {
sourceTableByTargetTable.set(mapping.destination_table, mapping.source_table);
continue;
}
if (
mapping.mapping_type === "column" &&
mapping.source_field &&
mapping.destination_field
) {
const tableKey = mapping.destination_table || mapping.source_table;
if (!tableKey) continue;
const existing = columnMappingsByTargetTable.get(tableKey) || [];
existing.push({
sourceField: mapping.source_field,
destinationField: mapping.destination_field,
});
columnMappingsByTargetTable.set(tableKey, existing);
continue;
}
if (
mapping.mapping_type === "id_value" &&
mapping.source_field &&
mapping.source_value &&
mapping.destination_value
) {
const tableKey = mapping.destination_table || mapping.source_table;
if (!tableKey) continue;
const existing = idMappingsByTargetTable.get(tableKey) || [];
existing.push({
sourceField: mapping.source_field,
destinationField: mapping.destination_field,
sourceValue: mapping.source_value,
destinationValue: mapping.destination_value,
});
idMappingsByTargetTable.set(tableKey, existing);
}
}
return {
sourceTableByTargetTable,
columnMappingsByTargetTable,
idMappingsByTargetTable,
};
}
function applyIdValueMappings(
row: Record<string, unknown>,
mappings: IdValueMapping[],
): Record<string, unknown> {
const transformed = { ...row };
for (const mapping of mappings) {
const currentValue = transformed[mapping.sourceField];
if (String(currentValue ?? "") !== mapping.sourceValue) continue;
const targetField = mapping.destinationField || mapping.sourceField;
transformed[targetField] = mapping.destinationValue;
}
return transformed;
}
function applyColumnMappings(
row: Record<string, unknown>,
mappings: ColumnMapping[],
): Record<string, unknown> {
const transformed = { ...row };
for (const mapping of mappings) {
if (!(mapping.sourceField in transformed)) continue;
if (!(mapping.destinationField in transformed)) {
transformed[mapping.destinationField] = transformed[mapping.sourceField];
}
delete transformed[mapping.sourceField];
}
return transformed;
}
function applyAssociationReferenceMappings(
row: Record<string, unknown>,
associationIdMap: Map<string, string>,
): Record<string, unknown> {
if (associationIdMap.size === 0) return row;
const transformed = { ...row };
const mapValue = (value: unknown) => {
if (typeof value === "string" && associationIdMap.has(value)) {
return associationIdMap.get(value)!;
}
return value;
};
transformed.id = mapValue(transformed.id);
transformed.association_id = mapValue(transformed.association_id);
transformed.client_id = mapValue(transformed.client_id);
if (Array.isArray(transformed.assigned_client_ids)) {
transformed.assigned_client_ids = transformed.assigned_client_ids.map(mapValue);
}
return transformed;
}
function applyLegacyFallbacks(row: Record<string, unknown>): Record<string, unknown> {
const transformed = { ...row };
if (isMissingValue(transformed.association_id)) {
if (!isMissingValue(transformed.client_id)) {
transformed.association_id = transformed.client_id;
} else if (
Array.isArray(transformed.assigned_client_ids) &&
transformed.assigned_client_ids.length === 1
) {
transformed.association_id = transformed.assigned_client_ids[0];
}
}
return transformed;
}
function stripUnknownColumns(
row: Record<string, unknown>,
validColumns: Set<string>,
): Record<string, unknown> {
const cleaned: Record<string, unknown> = {};
for (const key of Object.keys(row)) {
if (validColumns.has(key)) {
cleaned[key] = row[key];
}
}
return cleaned;
}
async function getTargetColumns(targetClient: any, tableName: string) {
try {
const { data, error } = await targetClient.from(tableName).select("*").limit(1);
if (error) {
console.error(`[${tableName}] schema probe error:`, error.message);
return null;
}
if (data && data.length > 0) {
return new Set(Object.keys(data[0]));
}
return null;
} catch {
return null;
}
}
async function buildAssociationIdMap(
sourceClient: any,
targetClient: any,
sourceAssociationsTable: string,
) {
try {
let sourceRows: Array<{ id: string; name: string | null }> = [];
let offset = 0;
const limit = 1000;
let fetching = true;
while (fetching) {
const { data, error } = await sourceClient
.from(sourceAssociationsTable)
.select("id, name")
.range(offset, offset + limit - 1);
if (error) {
console.error("Failed to build association ID map from source:", error.message);
return new Map<string, string>();
}
if (!data || data.length === 0) {
fetching = false;
} else {
sourceRows = sourceRows.concat(data as Array<{ id: string; name: string | null }>);
if (data.length < limit) fetching = false;
offset += limit;
}
}
const { data: targetRows, error: targetError } = await targetClient
.from("associations")
.select("id, name");
if (targetError) {
console.error("Failed to build association ID map from target:", targetError.message);
return new Map<string, string>();
}
const targetIdByName = new Map<string, string>();
for (const row of targetRows || []) {
const key = normalizeName(row.name);
if (key && !targetIdByName.has(key)) {
targetIdByName.set(key, row.id);
}
}
const associationIdMap = new Map<string, string>();
for (const row of sourceRows) {
const key = normalizeName(row.name);
const matchingTargetId = targetIdByName.get(key);
if (row.id && matchingTargetId && row.id !== matchingTargetId) {
associationIdMap.set(row.id, matchingTargetId);
}
}
return associationIdMap;
} catch (error) {
console.error("Failed to create association ID map:", error);
return new Map<string, string>();
}
}
async function upsertWithUnknownColumnRecovery(
targetClient: any,
tableName: string,
rows: Record<string, unknown>[],
) {
let workingRows = rows.map((row) => ({ ...row }));
const strippedColumns = new Set<string>();
for (let attempt = 0; attempt < 20; attempt++) {
const { error } = await targetClient.from(tableName).upsert(workingRows, { onConflict: "id" });
if (!error) {
return {
success: true,
rows: workingRows,
strippedColumns: [...strippedColumns],
};
}
const unknownColumn = error.message?.match(/Could not find the '([^']+)' column/)?.[1];
if (!unknownColumn) {
return {
success: false,
rows: workingRows,
strippedColumns: [...strippedColumns],
error,
};
}
strippedColumns.add(unknownColumn);
workingRows = workingRows.map((row) => {
const cleaned = { ...row };
delete cleaned[unknownColumn];
return cleaned;
});
console.log(`[${tableName}] stripping unknown column during upsert: ${unknownColumn}`);
}
return {
success: false,
rows: workingRows,
strippedColumns: [...strippedColumns],
error: { message: "Exceeded unknown-column retry limit" },
};
}
Deno.serve(async (req: Request) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const authHeader = req.headers.get("Authorization");
if (!authHeader) throw new Error("No authorization header");
const targetUrl = Deno.env.get("SUPABASE_URL")!;
const targetServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const sourceUrl = Deno.env.get("SOURCE_SUPABASE_URL");
const sourceServiceKey = Deno.env.get("SOURCE_SUPABASE_SERVICE_ROLE_KEY");
if (!sourceUrl || !sourceServiceKey) {
throw new Error("Source Supabase credentials not configured");
}
const targetClient = createClient(targetUrl, targetServiceKey);
const token = authHeader.replace("Bearer ", "");
const payloadBase64 = token.split(".")[1];
if (!payloadBase64) throw new Error("Invalid token");
const payload = JSON.parse(atob(payloadBase64));
const userId = payload.sub;
if (!userId) throw new Error("Unauthorized");
const { data: roleData } = await targetClient
.from("user_roles")
.select("role")
.eq("user_id", userId)
.eq("role", "admin")
.single();
if (!roleData) throw new Error("Admin access required");
const body = await req.json().catch(() => ({}));
const action = body.action || "migrate_tables";
const selectedTables = body.tables || MIGRATION_TABLES;
const { data: mappingsData, error: mappingsError } = await targetClient
.from("migration_field_mappings")
.select(
"mapping_type, source_table, destination_table, source_field, destination_field, source_value, destination_value",
)
.eq("is_active", true);
if (mappingsError) {
console.error("Failed to load migration mappings:", mappingsError.message);
}
const {
sourceTableByTargetTable,
columnMappingsByTargetTable,
idMappingsByTargetTable,
} = buildMappingIndexes((mappingsData || []) as MappingRow[]);
const resolveSourceTable = (targetTable: string) => {
return sourceTableByTargetTable.get(targetTable) || targetTable;
};
const sourceClient = createClient(sourceUrl, sourceServiceKey);
const associationIdMap = await buildAssociationIdMap(
sourceClient,
targetClient,
resolveSourceTable("associations"),
);
if (action === "list_source_tables") {
const tableCounts: Record<string, number> = {};
for (const targetTable of MIGRATION_TABLES) {
const sourceTable = resolveSourceTable(targetTable);
try {
const { count } = await sourceClient
.from(sourceTable)
.select("*", { count: "exact", head: true });
tableCounts[targetTable] = count ?? 0;
} catch {
tableCounts[targetTable] = -1;
}
}
return new Response(JSON.stringify({ success: true, tables: tableCounts }), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
if (action === "import_users_with_passwords") {
const usersList = body.users || [];
if (!Array.isArray(usersList) || usersList.length === 0) {
throw new Error("No users provided. Send an array of {email, encrypted_password, ...} objects.");
}
const results: { created: number; skipped: number; errors: string[] } = {
created: 0,
skipped: 0,
errors: [],
};
for (const userData of usersList) {
try {
if (!userData.email) {
results.errors.push("Row missing email field");
continue;
}
const createPayload: Record<string, unknown> = {
email: userData.email,
email_confirm: true,
user_metadata: userData.user_metadata || {},
};
if (userData.encrypted_password) {
createPayload.password_hash = userData.encrypted_password;
} else if (userData.password) {
createPayload.password = userData.password;
} else {
createPayload.password = `Temp${crypto.randomUUID().slice(0, 8)}!`;
}
const { error: createErr } = await targetClient.auth.admin.createUser(createPayload);
if (createErr) {
if (createErr.message?.includes("already been registered")) {
results.skipped++;
} else {
results.errors.push(`${userData.email}: ${createErr.message}`);
}
continue;
}
results.created++;
} catch (e) {
results.errors.push(`${userData.email || "unknown"}: ${(e as Error).message}`);
}
}
return new Response(JSON.stringify({ success: true, users: results }), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
if (action === "migrate_users") {
const results: { created: number; skipped: number; errors: string[] } = {
created: 0,
skipped: 0,
errors: [],
};
let page = 1;
const perPage = 100;
let hasMore = true;
while (hasMore) {
const {
data: { users },
error,
} = await sourceClient.auth.admin.listUsers({ page, perPage });
if (error) {
results.errors.push(`Failed to list users page ${page}: ${(error as Error).message}`);
break;
}
if (!users || users.length === 0) {
hasMore = false;
break;
}
for (const srcUser of users) {
try {
const tempPassword = `Temp${crypto.randomUUID().slice(0, 8)}!`;
const { error: createErr } = await targetClient.auth.admin.createUser({
email: srcUser.email!,
password: tempPassword,
email_confirm: true,
user_metadata: srcUser.user_metadata || {},
});
if (createErr) {
if (createErr.message?.includes("already been registered")) {
results.skipped++;
} else {
results.errors.push(`${srcUser.email}: ${createErr.message}`);
}
continue;
}
results.created++;
} catch (e) {
results.errors.push(`${srcUser.email}: ${(e as Error).message}`);
}
}
if (users.length < perPage) hasMore = false;
page++;
}
return new Response(JSON.stringify({ success: true, users: results }), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
if (action === "migrate_tables") {
const results: Record<string, { inserted: number; skipped: number; error?: string }> = {};
for (const requestedTable of selectedTables) {
if (!MIGRATION_TABLES.includes(requestedTable)) continue;
const targetTable = requestedTable;
const sourceTable = resolveSourceTable(targetTable);
const columnMappings = columnMappingsByTargetTable.get(targetTable) || [];
const idMappings = idMappingsByTargetTable.get(targetTable) || [];
try {
let allRows: Record<string, unknown>[] = [];
let offset = 0;
const limit = 1000;
let fetching = true;
while (fetching) {
const { data, error } = await sourceClient
.from(sourceTable)
.select("*")
.range(offset, offset + limit - 1);
if (error) {
console.error(`[${targetTable}] fetch error from ${sourceTable} at offset ${offset}:`, error.message);
throw error;
}
if (!data || data.length === 0) {
fetching = false;
} else {
allRows = allRows.concat(data as Record<string, unknown>[]);
if (data.length < limit) fetching = false;
offset += limit;
}
}
console.log(`[${targetTable}] fetched ${allRows.length} rows from source table ${sourceTable}`);
if (allRows.length === 0) {
results[targetTable] = { inserted: 0, skipped: 0 };
continue;
}
let transformedRows = allRows.map((row) => {
let transformed = applyIdValueMappings(row, idMappings);
transformed = applyAssociationReferenceMappings(transformed, associationIdMap);
transformed = applyColumnMappings(transformed, columnMappings);
transformed = applyLegacyFallbacks(transformed);
transformed = applyAssociationReferenceMappings(transformed, associationIdMap);
return transformed;
});
const validColumns = await getTargetColumns(targetClient, targetTable);
if (validColumns) {
const strippedColumns = new Set<string>();
transformedRows = transformedRows.map((row) => {
for (const key of Object.keys(row)) {
if (!validColumns.has(key)) strippedColumns.add(key);
}
return stripUnknownColumns(row, validColumns);
});
if (strippedColumns.size > 0) {
console.log(`[${targetTable}] stripped columns not in target: ${[...strippedColumns].join(", ")}`);
}
}
let inserted = 0;
let skipped = 0;
const batchSize = 100;
for (let i = 0; i < transformedRows.length; i += batchSize) {
const batch = transformedRows.slice(i, i + batchSize);
const batchResult = await upsertWithUnknownColumnRecovery(targetClient, targetTable, batch);
if (!batchResult.success) {
console.error(
`[${targetTable}] batch upsert error at offset ${i}:`,
batchResult.error?.message,
batchResult.error?.details,
batchResult.error?.hint,
);
for (const row of batchResult.rows) {
const singleResult = await upsertWithUnknownColumnRecovery(targetClient, targetTable, [row]);
if (!singleResult.success) {
console.error(`[${targetTable}] single row error:`, singleResult.error?.message);
skipped++;
} else {
inserted++;
}
}
} else {
if (batchResult.strippedColumns.length > 0) {
console.log(`[${targetTable}] stripped during upsert: ${batchResult.strippedColumns.join(", ")}`);
}
inserted += batchResult.rows.length;
}
}
console.log(`[${targetTable}] result: ${inserted} inserted/updated, ${skipped} skipped`);
results[targetTable] = { inserted, skipped };
} catch (e) {
console.error(`[${targetTable}] fatal error:`, (e as Error).message);
results[targetTable] = { inserted: 0, skipped: 0, error: (e as Error).message };
}
}
return new Response(JSON.stringify({ success: true, results }), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
throw new Error(`Unknown action: ${action}`);
} catch (error) {
console.error("Migration error:", error);
return new Response(
JSON.stringify({ success: false, error: (error as Error).message }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
});