mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
183fe0a93c
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
723 lines
22 KiB
TypeScript
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" } },
|
|
);
|
|
}
|
|
}); |