#!/usr/bin/env npx tsx
/**
* Migrate booking-rules.json to Supabase
*
* This script:
* 1. Reads the local config/booking-rules.json file
* 2. Transforms it to Supabase booking_rules format
* 3. Inserts the rules into Supabase for a specific administration
*
* Usage:
* npx tsx scripts/migrate-config-to-supabase.ts --administration-id <uuid>
* npx tsx scripts/migrate-config-to-supabase.ts --administration-id <uuid> --dry-run
*
* Environment variables required:
* SUPABASE_URL - Supabase project URL
* SUPABASE_SERVICE_ROLE_KEY - Service role key (for admin operations)
*
* Example:
* SUPABASE_URL=https://xxx.supabase.co SUPABASE_SERVICE_ROLE_KEY=xxx \
* npx tsx scripts/migrate-config-to-supabase.ts --administration-id 123e4567-e89b-12d3-a456-426614174000
*/
import { createClient } from '@supabase/supabase-js';
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
// Types
interface LeverancierData {
naam: string;
id?: string;
land: string;
btwNummer?: string;
kvkNummer?: string;
grootboek: {
nummer: number;
naam: string;
id: string;
};
btw: {
soort: 'Geen' | 'Hoog' | 'Laag' | 'Verlegd';
tarief?: number;
reden: string;
};
kostenplaats?: string;
facturenFolder?: string;
factuurnummerPrefix?: string;
_note?: string;
}
interface KostenplaatsData {
id: string;
nummer: number;
naam: string;
}
interface DagboekData {
nummer: number;
naam: string;
id: string;
}
interface GrootboekData {
naam: string;
id: string;
}
interface BookingRulesConfig {
_comment?: string;
_updated?: string;
settings?: {
requireKostenplaats?: boolean;
};
eigenBedrijf?: {
naam: string;
btwNummer: string;
kvkNummer: string;
};
kostenplaatsen: Record<string, KostenplaatsData>;
leveranciers: Record<string, LeverancierData>;
dagboeken: Record<string, DagboekData>;
grootboeken: Record<string, GrootboekData>;
}
interface BookingRuleInsert {
administration_id: string;
rule_type: 'leverancier' | 'kostenplaats' | 'dagboek' | 'grootboek';
key: string;
data: Record<string, unknown>;
snelstart_id: string | null;
is_active: boolean;
}
// Parse command line arguments
function parseArgs(): { administrationId: string; dryRun: boolean } {
const args = process.argv.slice(2);
let administrationId = '';
let dryRun = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--administration-id' && args[i + 1]) {
administrationId = args[i + 1];
i++;
} else if (args[i] === '--dry-run') {
dryRun = true;
}
}
if (!administrationId) {
console.error('Usage: npx tsx scripts/migrate-config-to-supabase.ts --administration-id <uuid> [--dry-run]');
console.error('\nRequired:');
console.error(' --administration-id <uuid> The Supabase administration ID to migrate to');
console.error('\nOptional:');
console.error(' --dry-run Show what would be inserted without making changes');
console.error('\nEnvironment variables:');
console.error(' SUPABASE_URL Supabase project URL');
console.error(' SUPABASE_SERVICE_ROLE_KEY Service role key for admin operations');
process.exit(1);
}
// Validate UUID format
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(administrationId)) {
console.error(`Error: Invalid administration ID format. Expected UUID, got: ${administrationId}`);
process.exit(1);
}
return { administrationId, dryRun };
}
// Load config file
function loadConfig(): BookingRulesConfig {
const possiblePaths = [
join(process.cwd(), 'config', 'booking-rules.json'),
join(process.cwd(), '..', 'config', 'booking-rules.json'),
];
for (const configPath of possiblePaths) {
if (existsSync(configPath)) {
console.log(`Loading config from: ${configPath}`);
const content = readFileSync(configPath, 'utf-8');
return JSON.parse(content) as BookingRulesConfig;
}
}
console.error('Error: Could not find config/booking-rules.json');
process.exit(1);
}
// Transform config to booking rules
function transformConfig(config: BookingRulesConfig, administrationId: string): BookingRuleInsert[] {
const rules: BookingRuleInsert[] = [];
// Transform leveranciers
for (const [key, leverancier] of Object.entries(config.leveranciers)) {
// Remove internal fields from data
const { id, _note, ...dataWithoutInternals } = leverancier;
rules.push({
administration_id: administrationId,
rule_type: 'leverancier',
key,
data: dataWithoutInternals,
snelstart_id: id || null,
is_active: true,
});
}
// Transform kostenplaatsen
for (const [key, kostenplaats] of Object.entries(config.kostenplaatsen)) {
const { id, ...dataWithoutId } = kostenplaats;
rules.push({
administration_id: administrationId,
rule_type: 'kostenplaats',
key,
data: dataWithoutId,
snelstart_id: id,
is_active: true,
});
}
// Transform dagboeken
for (const [key, dagboek] of Object.entries(config.dagboeken)) {
const { id, ...dataWithoutId } = dagboek;
rules.push({
administration_id: administrationId,
rule_type: 'dagboek',
key,
data: dataWithoutId,
snelstart_id: id,
is_active: true,
});
}
// Transform grootboeken
for (const [key, grootboek] of Object.entries(config.grootboeken)) {
const { id, ...dataWithoutId } = grootboek;
rules.push({
administration_id: administrationId,
rule_type: 'grootboek',
key,
data: dataWithoutId,
snelstart_id: id,
is_active: true,
});
}
return rules;
}
// Main function
async function main(): Promise<void> {
const { administrationId, dryRun } = parseArgs();
// Check environment variables
const supabaseUrl = process.env.SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseKey) {
console.error('Error: Missing environment variables');
console.error('Required: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY');
process.exit(1);
}
// Create Supabase client
const supabase = createClient(supabaseUrl, supabaseKey, {
auth: { persistSession: false },
});
// Load and transform config
const config = loadConfig();
const rules = transformConfig(config, administrationId);
console.log('\n=== Migration Summary ===');
console.log(`Administration ID: ${administrationId}`);
console.log(`Mode: ${dryRun ? 'DRY RUN (no changes)' : 'LIVE'}`);
console.log('\nRules to migrate:');
console.log(` - Leveranciers: ${Object.keys(config.leveranciers).length}`);
console.log(` - Kostenplaatsen: ${Object.keys(config.kostenplaatsen).length}`);
console.log(` - Dagboeken: ${Object.keys(config.dagboeken).length}`);
console.log(` - Grootboeken: ${Object.keys(config.grootboeken).length}`);
console.log(` - Total: ${rules.length}`);
if (dryRun) {
console.log('\n=== Dry Run - Rules to be inserted ===\n');
// Group by type for display
const grouped = rules.reduce(
(acc, rule) => {
if (!acc[rule.rule_type]) acc[rule.rule_type] = [];
acc[rule.rule_type].push(rule);
return acc;
},
{} as Record<string, BookingRuleInsert[]>
);
for (const [type, typeRules] of Object.entries(grouped)) {
console.log(`\n${type.toUpperCase()}:`);
for (const rule of typeRules) {
const data = rule.data as Record<string, unknown>;
const preview = (data.naam as string) || rule.key;
console.log(` ${rule.key}: ${preview} (snelstart_id: ${rule.snelstart_id || 'null'})`);
}
}
console.log('\n=== Dry run complete. No changes made. ===');
console.log('Run without --dry-run to apply changes.');
return;
}
// Verify administration exists
console.log('\nVerifying administration exists...');
const { data: admin, error: adminError } = await supabase
.from('administrations')
.select('id, name, organization_id')
.eq('id', administrationId)
.single();
if (adminError || !admin) {
console.error(`Error: Administration not found: ${administrationId}`);
console.error(adminError?.message || 'No administration found');
process.exit(1);
}
console.log(`Found administration: ${admin.name}`);
// Check for existing rules
console.log('Checking for existing rules...');
const { count: existingCount, error: countError } = await supabase
.from('booking_rules')
.select('*', { count: 'exact', head: true })
.eq('administration_id', administrationId);
if (countError) {
console.error(`Error checking existing rules: ${countError.message}`);
process.exit(1);
}
if (existingCount && existingCount > 0) {
console.log(`\nWarning: Found ${existingCount} existing rules for this administration.`);
console.log('This migration will use upsert to add/update rules.');
console.log('Existing rules with matching keys will be updated, new rules will be added.\n');
}
// Insert rules with upsert (update if key already exists)
console.log('Inserting rules...');
// Process in batches to avoid timeout
const batchSize = 50;
let inserted = 0;
let updated = 0;
let errors = 0;
for (let i = 0; i < rules.length; i += batchSize) {
const batch = rules.slice(i, i + batchSize);
const { data, error } = await supabase
.from('booking_rules')
.upsert(batch, {
onConflict: 'administration_id,rule_type,key',
ignoreDuplicates: false,
})
.select();
if (error) {
console.error(`Error in batch ${Math.floor(i / batchSize) + 1}: ${error.message}`);
errors += batch.length;
} else {
// Supabase upsert returns all affected rows, but doesn't distinguish insert vs update
// We count them all as inserted/updated based on whether we had existing rules
if (data) {
inserted += data.length;
}
}
// Progress indicator
const progress = Math.min(i + batchSize, rules.length);
process.stdout.write(`\rProgress: ${progress}/${rules.length} rules processed`);
}
console.log('\n');
// Summary
console.log('=== Migration Complete ===');
console.log(`Inserted/Updated: ${inserted} rules`);
if (errors > 0) {
console.log(`Errors: ${errors} rules failed`);
}
// Verify
console.log('\nVerifying migration...');
const { count: finalCount, error: verifyError } = await supabase
.from('booking_rules')
.select('*', { count: 'exact', head: true })
.eq('administration_id', administrationId);
if (verifyError) {
console.error(`Error verifying: ${verifyError.message}`);
} else {
console.log(`Total rules for this administration: ${finalCount}`);
}
// Show breakdown by type
const { data: breakdown } = await supabase
.from('booking_rules')
.select('rule_type')
.eq('administration_id', administrationId);
if (breakdown) {
const counts = breakdown.reduce(
(acc, row) => {
acc[row.rule_type] = (acc[row.rule_type] || 0) + 1;
return acc;
},
{} as Record<string, number>
);
console.log('\nBreakdown by type:');
for (const [type, count] of Object.entries(counts)) {
console.log(` - ${type}: ${count}`);
}
}
console.log('\nDone!');
}
// Run
main().catch((error) => {
console.error('Unhandled error:', error);
process.exit(1);
});