kundencenter/apps/api/src/modules/domains/index.ts

133 lines
15 KiB
TypeScript
Raw Normal View History

import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { one, query, run, tx } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { requireAuth, requirePermission, clientIp } from '../../core/auth.js';
import { badRequest, notFound } from '../../core/errors.js';
import { rl } from '../../core/config.js';
import type { KcModule } from '../../core/module.js';
import { randomUUID } from 'node:crypto';
import { loadInstance } from '@kc/connectors';
import { breakdown, checkAvailability, normalizeDomain, parsePriceList, sellPrice, splitDomain, type CostBasis, type Margin } from './logic.js';
const taxBp = async () => Number((await one("SELECT rate_bp FROM tax_rates WHERE name LIKE 'Regelsteuersatz%' LIMIT 1"))?.rate_bp ?? 1900);
/** Preis-Schnappschuss für eine Domain: Einkauf brutto/netto und errechneter Verkauf brutto/netto. Null-Preise, wenn Endung fehlt oder kein Aufschlag festgelegt ist. */
async function snapshot(tld: string) {
const s = await settings(); const r = await one('SELECT * FROM domain_tlds WHERE tld = ?', [tld]); const tax = await taxBp();
if (!r) return { termMonths: null, costNet: null, costGross: null, setup: 0, net: null, gross: null, tax };
const o = offer(r, s.tier, s.margin, s.rounding, s.basis, tax);
return { termMonths: o.termMonths, costNet: o.costNetCents, costGross: o.costGrossCents, setup: o.setupGrossCents, net: o.priceNetCents, gross: o.priceGrossCents, tax };
}
const recView = (r: any) => ({ id: r.id, domain: r.domain, tld: r.tld, orgId: r.org_id, customer: r.org_name ?? null, customerNumber: r.customer_number ?? null, resourceId: r.resource_id, source: r.source, termMonths: r.term_months,
costNetCents: r.cost_net_cents, costGrossCents: r.cost_gross_cents, setupCostCents: r.setup_cost_cents, sellNetCents: r.sell_net_cents, taxBp: r.tax_bp, sellGrossCents: r.sell_gross_cents, profitNetCents: r.sell_net_cents === null || r.cost_net_cents === null ? null : r.sell_net_cents - r.cost_net_cents,
procurement: r.procurement, orderedAt: r.ordered_at, orderedRef: r.ordered_ref, note: r.note, pricedAt: r.priced_at, createdAt: r.created_at });
const REC_SELECT = 'SELECT d.*, o.name AS org_name, o.customer_number FROM domain_records d LEFT JOIN organizations o ON o.id = d.org_id';
const SUGGEST = ['de', 'com', 'net', 'org', 'eu', 'info'];
const marginIn = z.object({ type: z.enum(['percent', 'fixed']).nullable(), value: z.number().int().min(0).max(100_000_00).nullable() }).refine((m) => (m.type === null) === (m.value === null), 'Art und Wert gehören zusammen');
async function settings() { const s = await one('SELECT tier, margin_type, margin_value, rounding, cost_basis FROM domain_settings WHERE id = 1'); return { tier: Number(s?.tier ?? 1), rounding: s ? !!s.rounding : true, basis: (s?.cost_basis ?? 'gross') as CostBasis, margin: { type: s?.margin_type ?? null, value: s?.margin_value ?? null } as Margin }; }
const offer = (r: any, tier: number, g: Margin, round: boolean, basis: CostBasis, tax: number) => {
const list = Number(r[`cost${tier}_cents`]); const own: Margin = { type: r.margin_type, value: r.margin_value }; const b = breakdown(list, own, g, round, basis, tax);
const setupGross = basis === 'gross' ? r.setup_cents : r.setup_cents + Math.round((r.setup_cents * tax) / 10000);
return { tld: r.tld, termMonths: r.term_months, ...b, setupGrossCents: setupGross as number, setupNetCents: Math.round((setupGross * 10000) / (10000 + tax)), taxBp: tax, margin: own, active: !!r.active };
};
export const domainsModule: KcModule = {
name: 'domains',
permissions: { staff: { support: ['domains.read'], accounting: ['domains.read'], admin: ['domains.read', 'domains.write'], superadmin: ['domains.read', 'domains.write'] } },
register(app: FastifyInstance) {
// Domain prüfen (für alle angemeldeten Benutzer). Ohne Endung werden gängige Endungen geprüft. Einkaufspreise erscheinen hier nie.
app.get('/domains/check', { config: rl(30, '1 minute') }, async (req) => {
requireAuth(req);
const { name } = z.object({ name: z.string().min(1).max(300) }).parse(req.query);
const s = await settings(); const tax = await taxBp(); const rows = await query('SELECT * FROM domain_tlds WHERE active = 1'); const by = new Map(rows.map((r) => [r.tld as string, r]));
const norm = normalizeDomain(name.includes('.') ? name : `${name}.de`); if (!norm) throw badRequest('Das ist kein gültiger Domainname. Erlaubt sind Buchstaben, Ziffern und Bindestriche.');
const names = name.includes('.') ? [norm] : SUGGEST.filter((t) => by.has(t)).map((t) => `${norm.split('.')[0]}.${t}`);
const results = await Promise.all(names.map(async (n) => {
const sp = splitDomain(n, new Set(by.keys()))!; const o = by.get(sp.tld); const price = o ? offer(o, s.tier, s.margin, s.rounding, s.basis, tax) : null; const a = await checkAvailability(n);
return { domain: n, tld: sp.tld, ...a, offer: price && price.priceGrossCents !== null ? { termMonths: price.termMonths, priceGrossCents: price.priceGrossCents, priceNetCents: price.priceNetCents!, taxBp: tax, setupGrossCents: price.setupGrossCents } : null, offered: !!o };
}));
return { results };
});
app.get('/admin/domain-tlds', async (req) => {
requirePermission(req, 'domains.read'); const s = await settings(); const tax = await taxBp();
return { settings: { tier: s.tier, margin: s.margin, rounding: s.rounding, basis: s.basis, taxBp: tax }, tlds: (await query('SELECT * FROM domain_tlds ORDER BY tld')).map((r) => offer(r, s.tier, s.margin, s.rounding, s.basis, tax)) };
});
// ---- Domain-Aufstellung (Einkauf beim Registrar KCS vs. errechneter Verkauf) ----
app.get('/admin/domain-records', async (req) => {
requirePermission(req, 'domains.read'); const q = z.object({ status: z.enum(['open', 'ordered', 'external']).optional(), q: z.string().max(100).optional(), orgId: z.string().uuid().optional() }).parse(req.query);
const rows = await query(`${REC_SELECT} WHERE (? IS NULL OR d.org_id = ?) AND (? IS NULL OR d.procurement = ?) AND (? IS NULL OR d.domain LIKE ?) ORDER BY d.created_at DESC LIMIT 2000`, [q.orgId ?? null, q.orgId ?? null, q.status ?? null, q.status ?? null, q.q ?? null, `%${q.q ?? ''}%`]);
return rows.map(recView);
});
app.post('/admin/domain-records', async (req) => {
const a = requirePermission(req, 'domains.write'); const b = z.object({ domain: z.string().max(300), orgId: z.string().uuid().nullable().optional(), procurement: z.enum(['open', 'ordered', 'external']).default('open'), note: z.string().max(300).optional() }).parse(req.body);
const name = normalizeDomain(b.domain); if (!name || !name.includes('.')) throw badRequest('Das ist kein gültiger Domainname.');
const known = new Set((await query('SELECT tld FROM domain_tlds')).map((r) => r.tld as string)); const sp = splitDomain(name, known)!;
if (await one('SELECT 1 AS x FROM domain_records WHERE domain = ?', [name])) throw badRequest('Diese Domain ist bereits erfasst.');
const p = await snapshot(sp.tld); const id = randomUUID();
await run('INSERT INTO domain_records (id, domain, tld, org_id, source, term_months, cost_net_cents, cost_gross_cents, setup_cost_cents, sell_net_cents, tax_bp, sell_gross_cents, procurement, note, priced_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,UTC_TIMESTAMP(3))', [id, name, sp.tld, b.orgId ?? null, 'manual', p.termMonths, p.costNet, p.costGross, p.setup, p.net, p.tax, p.gross, b.procurement, b.note ?? null]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.record.create', resourceType: 'domain_record', resourceId: id, after: { domain: name }, ip: clientIp(req) });
return { id };
});
// Domains eines Hosting-Kontos aus dem Panel übernehmen (Subdomains und System-Domain werden ausgelassen)
app.post('/admin/domain-records/from-resource/:id', async (req) => {
const a = requirePermission(req, 'domains.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await one('SELECT r.id, r.org_id, r.external_ref, r.instance_id, i.capabilities_json FROM resources r JOIN connector_instances i ON i.id = r.instance_id WHERE r.id = ?', [id]); if (!r) throw notFound();
const { connector, ctx } = await loadInstance(r.instance_id, req.correlationId); if (!connector.children) throw badRequest('Diese Verbindung liefert keine Domains.');
const list = await connector.children.list(ctx, r.external_ref, 'domain' as never) as { name: string; details?: { subdomain?: boolean; system?: boolean } }[];
const known = new Set((await query('SELECT tld FROM domain_tlds')).map((x) => x.tld as string)); let added = 0, skipped = 0, unpriced = 0;
for (const d of list) {
const name = normalizeDomain(d.name); if (!name || d.details?.subdomain || d.details?.system) { skipped++; continue; }
if (await one('SELECT 1 AS x FROM domain_records WHERE domain = ?', [name])) { skipped++; continue; }
const sp = splitDomain(name, known); if (!sp) { skipped++; continue; } const p = await snapshot(sp.tld); if (p.net === null) unpriced++;
await run('INSERT INTO domain_records (id, domain, tld, org_id, resource_id, source, term_months, cost_net_cents, cost_gross_cents, setup_cost_cents, sell_net_cents, tax_bp, sell_gross_cents, priced_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,UTC_TIMESTAMP(3))', [randomUUID(), name, sp.tld, r.org_id, id, 'keyhelp', p.termMonths, p.costNet, p.costGross, p.setup, p.net, p.tax, p.gross]); added++;
}
await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.record.import', resourceType: 'resource', resourceId: id, after: { added, skipped }, ip: clientIp(req) });
return { added, skipped, unpriced };
});
app.patch('/admin/domain-records/:id', async (req) => {
const a = requirePermission(req, 'domains.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ procurement: z.enum(['open', 'ordered', 'external']).optional(), orderedRef: z.string().max(100).nullable().optional(), note: z.string().max(300).nullable().optional(), orgId: z.string().uuid().nullable().optional(), reprice: z.boolean().optional() }).parse(req.body);
const rec = await one('SELECT * FROM domain_records WHERE id = ?', [id]); if (!rec) throw notFound();
if (b.procurement) await run('UPDATE domain_records SET procurement = ?, ordered_at = ? WHERE id = ?', [b.procurement, b.procurement === 'ordered' ? (rec.ordered_at ?? new Date()) : null, id]);
if (b.orderedRef !== undefined) await run('UPDATE domain_records SET ordered_ref = ? WHERE id = ?', [b.orderedRef, id]);
if (b.note !== undefined) await run('UPDATE domain_records SET note = ? WHERE id = ?', [b.note, id]);
if (b.orgId !== undefined) await run('UPDATE domain_records SET org_id = ? WHERE id = ?', [b.orgId, id]);
if (b.reprice) { const p = await snapshot(rec.tld); await run('UPDATE domain_records SET term_months=?, cost_net_cents=?, cost_gross_cents=?, setup_cost_cents=?, sell_net_cents=?, tax_bp=?, sell_gross_cents=?, priced_at=UTC_TIMESTAMP(3) WHERE id = ?', [p.termMonths, p.costNet, p.costGross, p.setup, p.net, p.tax, p.gross, id]); }
await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.record.update', resourceType: 'domain_record', resourceId: id, after: b, ip: clientIp(req) });
return { ok: true };
});
app.delete('/admin/domain-records/:id', async (req) => {
const a = requirePermission(req, 'domains.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
await run('DELETE FROM domain_records WHERE id = ?', [id]); await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.record.delete', resourceType: 'domain_record', resourceId: id, ip: clientIp(req) }); return { ok: true };
});
app.put('/admin/domain-settings', async (req) => {
const a = requirePermission(req, 'domains.write');
const b = z.object({ tier: z.number().int().min(1).max(4), margin: marginIn, rounding: z.boolean(), basis: z.enum(['gross', 'net']) }).parse(req.body);
await run('UPDATE domain_settings SET tier = ?, margin_type = ?, margin_value = ?, rounding = ?, cost_basis = ? WHERE id = 1', [b.tier, b.margin.type, b.margin.value, b.rounding ? 1 : 0, b.basis]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.settings', resourceType: 'domain_settings', resourceId: '1', after: b, ip: clientIp(req) });
return { ok: true };
});
app.patch('/admin/domain-tlds/:tld', async (req) => {
const a = requirePermission(req, 'domains.write'); const { tld } = z.object({ tld: z.string().max(63) }).parse(req.params);
const b = z.object({ margin: marginIn.optional(), active: z.boolean().optional() }).parse(req.body);
if (!(await one('SELECT 1 AS x FROM domain_tlds WHERE tld = ?', [tld]))) throw notFound();
if (b.margin) await run('UPDATE domain_tlds SET margin_type = ?, margin_value = ? WHERE tld = ?', [b.margin.type, b.margin.value, tld]);
if (b.active !== undefined) await run('UPDATE domain_tlds SET active = ? WHERE tld = ?', [b.active ? 1 : 0, tld]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.tld.update', resourceType: 'domain_tld', resourceId: tld, after: b, ip: clientIp(req) });
return { ok: true };
});
// Preisliste einlesen (Einkaufspreise); Aufschläge und Aktiv-Status bestehender Endungen bleiben erhalten.
app.post('/admin/domain-tlds/import', async (req) => {
const a = requirePermission(req, 'domains.write'); const { text, dryRun } = z.object({ text: z.string().min(1).max(500_000), dryRun: z.boolean().default(false) }).parse(req.body);
const { rows, skipped } = parsePriceList(text); if (rows.length === 0) throw badRequest('Es wurden keine Preiszeilen erkannt. Erwartet: Endung, Laufzeit, vier Staffelpreise, optional Setup.');
const existing = new Set((await query('SELECT tld FROM domain_tlds')).map((r) => r.tld as string)); const created = rows.filter((r) => !existing.has(r.tld)).length;
if (!dryRun) {
await tx(async (c) => { for (const r of rows) await run('INSERT INTO domain_tlds (tld, term_months, cost1_cents, cost2_cents, cost3_cents, cost4_cents, setup_cents) VALUES (?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE term_months=VALUES(term_months), cost1_cents=VALUES(cost1_cents), cost2_cents=VALUES(cost2_cents), cost3_cents=VALUES(cost3_cents), cost4_cents=VALUES(cost4_cents), setup_cents=VALUES(setup_cents)', [r.tld, r.termMonths, ...r.costs, r.setupCents], c); });
await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.import', resourceType: 'domain_tld', resourceId: 'bulk', after: { rows: rows.length, created }, ip: clientIp(req) });
}
return { rows: rows.length, created, updated: rows.length - created, skipped, dryRun };
});
},
};