import fs from "node:fs" import path from "node:path" import { and, eq } from "drizzle-orm" import { db, pool } from "../db" import { customers, entitybankaccounts } from "../db/schema" import { decrypt, encrypt } from "../src/utils/crypt" import { loadSecrets, secrets } from "../src/utils/secrets" type CsvMemberRow = { number: string lastname: string firstname: string street: string zip: string city: string birthdate: string mobile: string email: string bankInstitute: string iban: string bic: string date: string memberStatus: string } const TENANT_ID = 38 const DEFAULT_CSV_PATH = "/Users/florianfederspiel/Downloads/Mitglieder Übersicht 2026_1.csv" const args = process.argv.slice(2) const dryRun = args.includes("--dry-run") const csvArg = args.find((arg) => !arg.startsWith("--")) const csvPath = csvArg || DEFAULT_CSV_PATH function normalizeIban(value: string) { return String(value || "").replace(/\s+/g, "").toUpperCase() } function parseGermanDate(value: string): string | null { const v = String(value || "").trim() if (!v) return null const m = v.match(/^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})$/) if (!m) return null const day = m[1].padStart(2, "0") const month = m[2].padStart(2, "0") const yy = m[3] const year = yy.length === 4 ? yy : (Number(yy) >= 70 ? `19${yy}` : `20${yy}`) return `${year}-${month}-${day}` } function parseBoolFromStatus(value: string) { const normalized = String(value || "").trim().toLowerCase() return normalized !== "inaktiv" } function parseCsv(content: string): CsvMemberRow[] { const lines = content .split(/\r?\n/) .map((l) => l.trim()) .filter((l) => l.length > 0) if (!lines.length) return [] // Header: // Nr;Name;Vorname;Straße, Hausnr.;PLZ;Wohnort;Geburtsdatum;Mobilfunknummer;Private Mail-Adresse;Kreditinstitut;IBAN;BIC;Datum;Mitgliedsstatus const rows: CsvMemberRow[] = [] for (let i = 1; i < lines.length; i++) { const cols = lines[i].split(";").map((v) => v.trim()) if (cols.length < 14) continue const number = cols[0] const lastname = cols[1] const firstname = cols[2] if (!number || !lastname || !firstname) continue rows.push({ number, lastname, firstname, street: cols[3] || "", zip: cols[4] || "", city: cols[5] || "", birthdate: cols[6] || "", mobile: cols[7] || "", email: cols[8] || "", bankInstitute: cols[9] || "", iban: cols[10] || "", bic: cols[11] || "", date: cols[12] || "", memberStatus: cols[13] || "", }) } return rows } async function loadBankAccountByIban(tenantId: number) { const rows = await db .select({ id: entitybankaccounts.id, ibanEncrypted: entitybankaccounts.ibanEncrypted, }) .from(entitybankaccounts) .where(eq(entitybankaccounts.tenant, tenantId)) const map = new Map() for (const row of rows) { try { const iban = normalizeIban(decrypt(row.ibanEncrypted as any)) if (iban) map.set(iban, Number(row.id)) } catch { // skip broken ciphertext rows } } return map } async function main() { if (!secrets.ENCRYPTION_KEY && process.env.ENCRYPTION_KEY) { secrets.ENCRYPTION_KEY = process.env.ENCRYPTION_KEY } if (!secrets.ENCRYPTION_KEY && process.env.INFISICAL_CLIENT_ID && process.env.INFISICAL_CLIENT_SECRET) { await loadSecrets() } if (!secrets.ENCRYPTION_KEY) { throw new Error("ENCRYPTION_KEY fehlt. Bitte ENCRYPTION_KEY setzen oder Infisical-Zugang (INFISICAL_CLIENT_ID/INFISICAL_CLIENT_SECRET) bereitstellen.") } const absoluteCsvPath = path.resolve(csvPath) if (!fs.existsSync(absoluteCsvPath)) { throw new Error(`CSV nicht gefunden: ${absoluteCsvPath}`) } const raw = fs.readFileSync(absoluteCsvPath, "utf8") const csvRows = parseCsv(raw) if (!csvRows.length) { throw new Error("Keine importierbaren Zeilen gefunden.") } const existingMembers = await db .select() .from(customers) .where(and(eq(customers.tenant, TENANT_ID), eq(customers.type, "Mitglied"))) const memberByNumber = new Map(existingMembers.map((m) => [String(m.customerNumber), m])) const bankAccountByIban = await loadBankAccountByIban(TENANT_ID) let createdMembers = 0 let updatedMembers = 0 let createdBankAccounts = 0 let skippedNoIban = 0 for (const row of csvRows) { const iban = normalizeIban(row.iban) if (!iban) { skippedNoIban += 1 continue } const fullName = `${row.firstname} ${row.lastname}`.trim() const birthdate = parseGermanDate(row.birthdate) const sepaSignedAt = parseGermanDate(row.date) const active = parseBoolFromStatus(row.memberStatus) let bankAccountId = bankAccountByIban.get(iban) || null if (!bankAccountId) { if (!dryRun) { const [created] = await db .insert(entitybankaccounts) .values({ tenant: TENANT_ID, ibanEncrypted: encrypt(iban), bicEncrypted: encrypt(row.bic || "UNBEKANNT"), bankNameEncrypted: encrypt(row.bankInstitute || "Unbekannt"), description: "Import Mitglieder Uebersicht 2026_1", }) .returning({ id: entitybankaccounts.id }) bankAccountId = created?.id || null } else { bankAccountId = -1 } if (bankAccountId) { bankAccountByIban.set(iban, bankAccountId) createdBankAccounts += 1 } } const existing = memberByNumber.get(String(row.number)) const existingInfo = (existing?.infoData && typeof existing.infoData === "object") ? { ...(existing.infoData as Record) } : {} const existingIds = Array.isArray(existingInfo.bankAccountIds) ? existingInfo.bankAccountIds : [] const mergedBankAccountIds = bankAccountId && !existingIds.includes(bankAccountId) ? [...existingIds, bankAccountId] : existingIds const infoData = { ...existingInfo, street: row.street || existingInfo.street || "", zip: row.zip || existingInfo.zip || "", city: row.city || existingInfo.city || "", phone: row.mobile || existingInfo.phone || "", email: row.email || existingInfo.email || "", birthdate: birthdate || existingInfo.birthdate || null, hasSEPA: Boolean(sepaSignedAt || existingInfo.sepaSignedAt || existingInfo.hasSEPA), sepaSignedAt: sepaSignedAt || existingInfo.sepaSignedAt || null, bankAccountIds: mergedBankAccountIds, } const payload = { tenant: TENANT_ID, customerNumber: String(row.number), type: "Mitglied", isCompany: false, firstname: row.firstname, lastname: row.lastname, name: fullName, active, infoData, archived: false, } if (!existing) { if (!dryRun) { const [created] = await db.insert(customers).values(payload).returning() if (created) memberByNumber.set(String(row.number), created) } createdMembers += 1 } else { if (!dryRun) { await db .update(customers) .set({ ...payload, updatedAt: new Date(), }) .where(and(eq(customers.id, existing.id), eq(customers.tenant, TENANT_ID))) } updatedMembers += 1 } } console.log("") console.log(`[IMPORT MEMBERS] Tenant: ${TENANT_ID}`) console.log(`[IMPORT MEMBERS] CSV: ${absoluteCsvPath}`) console.log(`[IMPORT MEMBERS] Dry-Run: ${dryRun ? "JA" : "NEIN"}`) console.log(`[IMPORT MEMBERS] Zeilen: ${csvRows.length}`) console.log(`[IMPORT MEMBERS] Mitglieder erstellt: ${createdMembers}`) console.log(`[IMPORT MEMBERS] Mitglieder aktualisiert: ${updatedMembers}`) console.log(`[IMPORT MEMBERS] Bankkonten erstellt: ${createdBankAccounts}`) console.log(`[IMPORT MEMBERS] Ohne IBAN übersprungen: ${skippedNoIban}`) console.log("") } main() .catch((err) => { console.error("[IMPORT MEMBERS] Fehler:", err) process.exitCode = 1 }) .finally(async () => { await pool.end() })