From 730c03370d49e24713a97642055aff322151e530 Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Wed, 27 May 2026 17:24:09 -0500 Subject: [PATCH] delete if necc. --- backend/src/services/sync.ts | 78 ++++++++++++++++++++++++++++++------ 1 file changed, 66 insertions(+), 12 deletions(-) diff --git a/backend/src/services/sync.ts b/backend/src/services/sync.ts index a7db50b..493d6e4 100644 --- a/backend/src/services/sync.ts +++ b/backend/src/services/sync.ts @@ -5,12 +5,18 @@ import { DmsService } from './dms.js'; export class SyncService { constructor(private dms = new DmsService()) {} - async syncFromDms(): Promise<{ domains: number; mailboxes: number }> { + async syncFromDms(): Promise<{ + domains: number; + mailboxes: number; + removed_mailboxes: number; + removed_domains: number; + }> { const accounts = await this.dms.listAccounts(); const domains = [...new Set(accounts.map((a) => a.domain))].sort(); await pool.query('BEGIN'); try { + // 1. Upsert every domain that currently exists in DMS as 'active'. for (const domain of domains) { await pool.query( `INSERT INTO domains(domain, current_node, status, last_seen_at, last_synced_at) @@ -25,6 +31,7 @@ export class SyncService { ); } + // 2. Upsert every mailbox that currently exists in DMS as 'active'. const localEmails = accounts.map((a) => a.email); for (const account of accounts) { await pool.query( @@ -41,25 +48,72 @@ export class SyncService { ); } - await pool.query( - `UPDATE domains - SET status='missing_on_node', last_synced_at=now(), updated_at=now() - WHERE current_node=$1 AND NOT (domain = ANY($2::text[]))`, - [config.nodeName, domains], + // 3. Find mailboxes that this node previously owned but that are + // no longer in DMS (or never were). They get hard-deleted. + // We collect (email, domain) first so we can emit billing events + // for any of them that don't already have a 'deleted' event. + const toRemoveResult = await pool.query<{ email_address: string; domain: string; previous_status: string }>( + `SELECT email_address, domain, status AS previous_status + FROM mailboxes + WHERE node_name=$1 AND NOT (email_address = ANY($2::text[]))`, + [config.nodeName, localEmails], ); + const toRemove = toRemoveResult.rows; - await pool.query( - `UPDATE mailboxes - SET status='missing_on_node', updated_at=now() - WHERE node_name=$1 AND status='active' AND NOT (email_address = ANY($2::text[]))`, + // 3a. Emit a synthetic 'deleted' billing event for any mailbox + // that was previously active and doesn't already have one. + // This keeps billing history correct even though we hard-delete + // the mailbox row itself in the next step. + for (const row of toRemove) { + if (row.previous_status === 'active' || row.previous_status === 'missing_on_node') { + await pool.query( + `INSERT INTO mailbox_billing_events (domain, email, action, actor_email, notes) + SELECT $1, $2, 'deleted', NULL, 'removed via DMS resync (was ' || $3 || ')' + WHERE NOT EXISTS ( + SELECT 1 FROM mailbox_billing_events b + WHERE b.email = $2 + AND b.action = 'deleted' + AND b.occurred_at >= ( + SELECT COALESCE(MAX(occurred_at), '1970-01-01'::timestamptz) + FROM mailbox_billing_events + WHERE email = $2 AND action = 'created' + ) + )`, + [row.domain, row.email_address, row.previous_status], + ); + } + } + + // 3b. Hard-delete the mailbox rows themselves. + const removedMailboxes = await pool.query( + `DELETE FROM mailboxes + WHERE node_name=$1 AND NOT (email_address = ANY($2::text[]))`, [config.nodeName, localEmails], ); + // 4. Hard-delete domains that no longer have any mailbox on this node. + // We restrict to current_node so we don't touch domains owned by + // other nodes if that ever becomes a thing. + const removedDomains = await pool.query( + `DELETE FROM domains + WHERE current_node=$1 + AND NOT EXISTS ( + SELECT 1 FROM mailboxes m + WHERE m.domain = domains.domain AND m.node_name = $1 + )`, + [config.nodeName], + ); + await pool.query('COMMIT'); - return { domains: domains.length, mailboxes: accounts.length }; + return { + domains: domains.length, + mailboxes: accounts.length, + removed_mailboxes: removedMailboxes.rowCount ?? 0, + removed_domains: removedDomains.rowCount ?? 0, + }; } catch (err) { await pool.query('ROLLBACK'); throw err; } } -} +} \ No newline at end of file