feat(admin): move integration URLs and credentials to GUI settings

This commit is contained in:
Matthias Hochmeister
2026-04-20 16:29:12 +02:00
parent 65820805b0
commit c55ec55e1b
15 changed files with 860 additions and 93 deletions

View File

@@ -1,5 +1,6 @@
import pool from '../config/database';
import logger from '../utils/logger';
import settingsService from './settings.service';
import {
FahrzeugBuchung,
FahrzeugBuchungListItem,
@@ -362,7 +363,7 @@ class BookingService {
logger.info('Created new iCal token for user', { userId });
}
const baseUrl = (process.env.ICAL_BASE_URL || process.env.CORS_ORIGIN || 'http://localhost:3000').replace(/\/$/, '');
const baseUrl = (await settingsService.getSettingOrEnv('integration_ical_base_url', process.env.ICAL_BASE_URL || process.env.CORS_ORIGIN || 'http://localhost:3000')).replace(/\/$/, '');
const subscribeUrl = `${baseUrl}/api/bookings/calendar.ics?token=${token}`;
return { token, subscribeUrl };
}

View File

@@ -5,33 +5,102 @@ import logger from '../utils/logger';
// Helpers
// ---------------------------------------------------------------------------
function calculateNextDueDate(intervall: string | null, intervall_tage: number | null): Date | null {
interface PeriodBounds {
verfuegbar_ab: Date;
faellig_am: Date;
}
/** Returns the NEXT period boundaries (the period after today). */
function calculateNextPeriod(intervall: string | null, intervall_tage: number | null): PeriodBounds | null {
const now = new Date();
if (intervall_tage && intervall_tage > 0) {
now.setDate(now.getDate() + intervall_tage);
return now;
if (intervall === 'custom' && intervall_tage && intervall_tage > 0) {
const start = new Date(now);
start.setDate(start.getDate() + 1);
const end = new Date(start);
end.setDate(end.getDate() + intervall_tage - 1);
return { verfuegbar_ab: start, faellig_am: end };
}
switch (intervall) {
case 'weekly':
now.setDate(now.getDate() + 7);
return now;
case 'monthly':
now.setMonth(now.getMonth() + 1);
return now;
case 'quarterly':
now.setMonth(now.getMonth() + 3);
return now;
case 'halfyearly':
now.setMonth(now.getMonth() + 6);
return now;
case 'yearly':
now.setFullYear(now.getFullYear() + 1);
return now;
case 'weekly': {
// Next Monday → next Sunday
const day = now.getDay(); // 0=Sun
const daysUntilNextMon = day === 0 ? 1 : 8 - day;
const nextMon = new Date(now);
nextMon.setDate(now.getDate() + daysUntilNextMon);
const nextSun = new Date(nextMon);
nextSun.setDate(nextMon.getDate() + 6);
return { verfuegbar_ab: nextMon, faellig_am: nextSun };
}
case 'monthly': {
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const lastDay = new Date(nextMonth.getFullYear(), nextMonth.getMonth() + 1, 0);
return { verfuegbar_ab: nextMonth, faellig_am: lastDay };
}
case 'quarterly': {
const currentQ = Math.floor(now.getMonth() / 3);
const nextQStart = new Date(now.getFullYear(), (currentQ + 1) * 3, 1);
const nextQEnd = new Date(nextQStart.getFullYear(), nextQStart.getMonth() + 3, 0);
return { verfuegbar_ab: nextQStart, faellig_am: nextQEnd };
}
case 'halfyearly': {
const currentH = now.getMonth() < 6 ? 0 : 1;
const nextHStart = currentH === 0
? new Date(now.getFullYear(), 6, 1)
: new Date(now.getFullYear() + 1, 0, 1);
const nextHEnd = new Date(nextHStart.getFullYear(), nextHStart.getMonth() + 6, 0);
return { verfuegbar_ab: nextHStart, faellig_am: nextHEnd };
}
case 'yearly': {
const nextYear = new Date(now.getFullYear() + 1, 0, 1);
const yearEnd = new Date(now.getFullYear() + 1, 11, 31);
return { verfuegbar_ab: nextYear, faellig_am: yearEnd };
}
default:
return null;
}
}
/** Returns the CURRENT period boundaries containing today. */
function calculateCurrentPeriod(intervall: string | null, _intervall_tage: number | null): PeriodBounds | null {
const now = new Date();
switch (intervall) {
case 'weekly': {
const day = now.getDay(); // 0=Sun
const diffToMon = day === 0 ? -6 : 1 - day;
const mon = new Date(now);
mon.setDate(now.getDate() + diffToMon);
const sun = new Date(mon);
sun.setDate(mon.getDate() + 6);
return { verfuegbar_ab: mon, faellig_am: sun };
}
case 'monthly': {
const first = new Date(now.getFullYear(), now.getMonth(), 1);
const last = new Date(now.getFullYear(), now.getMonth() + 1, 0);
return { verfuegbar_ab: first, faellig_am: last };
}
case 'quarterly': {
const qStart = Math.floor(now.getMonth() / 3) * 3;
const first = new Date(now.getFullYear(), qStart, 1);
const last = new Date(now.getFullYear(), qStart + 3, 0);
return { verfuegbar_ab: first, faellig_am: last };
}
case 'halfyearly': {
const hStart = now.getMonth() < 6 ? 0 : 6;
const first = new Date(now.getFullYear(), hStart, 1);
const last = new Date(now.getFullYear(), hStart + 6, 0);
return { verfuegbar_ab: first, faellig_am: last };
}
case 'yearly': {
const first = new Date(now.getFullYear(), 0, 1);
const last = new Date(now.getFullYear(), 11, 31);
return { verfuegbar_ab: first, faellig_am: last };
}
default:
// custom intervals have no meaningful "current period"
return null;
}
}
// Subquery fragments for junction table arrays
const JUNCTION_SUBQUERIES = `
ARRAY(SELECT fahrzeug_typ_id FROM checklist_vorlage_fahrzeug_typen WHERE vorlage_id = v.id) AS fahrzeug_typ_ids,
@@ -611,17 +680,24 @@ async function getTemplatesForEquipment(ausruestungId: string) {
async function getOverviewItems() {
try {
// All vehicles with their assigned templates (direct, by type, or global)
const today = new Date();
today.setHours(0, 0, 0, 0);
// All vehicles with their assigned scheduled templates (direct, by type, or global)
const vehiclesResult = await pool.query(`
SELECT f.id, COALESCE(f.bezeichnung, f.kurzname) AS name,
json_agg(DISTINCT jsonb_build_object(
'vorlage_id', cv.id,
'vorlage_name', cv.name,
'intervall', cv.intervall,
'next_due', cf.naechste_faellig_am
'intervall_tage', cv.intervall_tage,
'next_due', cf.naechste_faellig_am,
'verfuegbar_ab', cf.verfuegbar_ab,
'letzte_ausfuehrung_am', ca.ausgefuehrt_am
)) AS checklists
FROM fahrzeuge f
JOIN checklist_vorlagen cv ON cv.aktiv = true
AND cv.intervall IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung_typen WHERE vorlage_id = cv.id)
AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung WHERE vorlage_id = cv.id)
AND (
@@ -639,22 +715,27 @@ async function getOverviewItems() {
)
)
LEFT JOIN checklist_faelligkeit cf ON cf.vorlage_id = cv.id AND cf.fahrzeug_id = f.id
LEFT JOIN checklist_ausfuehrungen ca ON ca.id = cf.letzte_ausfuehrung_id
WHERE f.deleted_at IS NULL
GROUP BY f.id, f.bezeichnung, f.kurzname
ORDER BY f.bezeichnung ASC, f.kurzname ASC
`);
// All equipment with their assigned templates
// Standalone equipment (not assigned to a vehicle)
const equipmentResult = await pool.query(`
SELECT a.id, a.bezeichnung AS name,
json_agg(DISTINCT jsonb_build_object(
'vorlage_id', cv.id,
'vorlage_name', cv.name,
'intervall', cv.intervall,
'next_due', cf.naechste_faellig_am
'intervall_tage', cv.intervall_tage,
'next_due', cf.naechste_faellig_am,
'verfuegbar_ab', cf.verfuegbar_ab,
'letzte_ausfuehrung_am', ca.ausgefuehrt_am
)) AS checklists
FROM ausruestung a
JOIN checklist_vorlagen cv ON cv.aktiv = true
AND cv.intervall IS NOT NULL
AND (
EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung cva WHERE cva.vorlage_id = cv.id AND cva.ausruestung_id = a.id)
OR EXISTS (
@@ -670,14 +751,128 @@ async function getOverviewItems() {
)
)
LEFT JOIN checklist_faelligkeit cf ON cf.vorlage_id = cv.id AND cf.ausruestung_id = a.id
WHERE a.deleted_at IS NULL
LEFT JOIN checklist_ausfuehrungen ca ON ca.id = cf.letzte_ausfuehrung_id
WHERE a.deleted_at IS NULL AND a.fahrzeug_id IS NULL
GROUP BY a.id, a.bezeichnung
ORDER BY a.bezeichnung ASC
`);
// Equipment assigned to vehicles — will be merged under their parent vehicle
const equipmentWithVehicleResult = await pool.query(`
SELECT a.id AS ausruestung_id, a.bezeichnung AS ausruestung_name, a.fahrzeug_id,
json_agg(DISTINCT jsonb_build_object(
'vorlage_id', cv.id,
'vorlage_name', cv.name,
'intervall', cv.intervall,
'intervall_tage', cv.intervall_tage,
'next_due', cf.naechste_faellig_am,
'verfuegbar_ab', cf.verfuegbar_ab,
'letzte_ausfuehrung_am', ca.ausgefuehrt_am,
'ausruestung_id', a.id,
'ausruestung_name', a.bezeichnung
)) AS checklists
FROM ausruestung a
JOIN checklist_vorlagen cv ON cv.aktiv = true
AND cv.intervall IS NOT NULL
AND (
EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung cva WHERE cva.vorlage_id = cv.id AND cva.ausruestung_id = a.id)
OR EXISTS (
SELECT 1 FROM checklist_vorlage_ausruestung_typen cvat
WHERE cvat.vorlage_id = cv.id
AND cvat.ausruestung_typ_id IN (SELECT ausruestung_typ_id FROM ausruestung_ausruestung_typen WHERE ausruestung_id = a.id)
)
OR (
NOT EXISTS (SELECT 1 FROM checklist_vorlage_fahrzeug_typen WHERE vorlage_id = cv.id)
AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_fahrzeuge WHERE vorlage_id = cv.id)
AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung_typen WHERE vorlage_id = cv.id)
AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung WHERE vorlage_id = cv.id)
)
)
LEFT JOIN checklist_faelligkeit cf ON cf.vorlage_id = cv.id AND cf.ausruestung_id = a.id
LEFT JOIN checklist_ausfuehrungen ca ON ca.id = cf.letzte_ausfuehrung_id
WHERE a.deleted_at IS NULL AND a.fahrzeug_id IS NOT NULL
GROUP BY a.id, a.bezeichnung, a.fahrzeug_id
ORDER BY a.bezeichnung ASC
`);
// Build vehicle map for merging equipment checklists
const vehicleMap = new Map<string, any>();
for (const v of vehiclesResult.rows) {
vehicleMap.set(v.id, v);
}
// Merge equipment-with-vehicle checklists into parent vehicles
for (const eq of equipmentWithVehicleResult.rows) {
let parent = vehicleMap.get(eq.fahrzeug_id);
if (!parent) {
// Vehicle has no own checklists but has equipment with checklists — create stub entry
const vehRes = await pool.query(
`SELECT id, COALESCE(bezeichnung, kurzname) AS name FROM fahrzeuge WHERE id = $1 AND deleted_at IS NULL`,
[eq.fahrzeug_id]
);
if (vehRes.rows.length > 0) {
parent = { id: vehRes.rows[0].id, name: vehRes.rows[0].name, checklists: [] };
vehicleMap.set(eq.fahrzeug_id, parent);
vehiclesResult.rows.push(parent);
}
}
if (parent) {
parent.checklists = parent.checklists.concat(eq.checklists);
}
}
// Post-process: compute verfuegbar_ab fallback and ist_faellig for each checklist
const processChecklists = (checklists: any[]) => {
return checklists.map((cl: any) => {
let verfuegbar = cl.verfuegbar_ab ? new Date(cl.verfuegbar_ab) : null;
let nextDue = cl.next_due ? new Date(cl.next_due) : null;
const letzteAusfuehrung = cl.letzte_ausfuehrung_am || null;
if (!verfuegbar && nextDue) {
// Old faelligkeit row without verfuegbar_ab — compute from current period
const period = calculateCurrentPeriod(cl.intervall, cl.intervall_tage ?? null);
if (period) {
verfuegbar = period.verfuegbar_ab;
}
}
if (!nextDue) {
// Never executed — use current period
const period = calculateCurrentPeriod(cl.intervall, cl.intervall_tage ?? null);
if (period) {
nextDue = period.faellig_am;
verfuegbar = period.verfuegbar_ab;
}
}
const istFaellig = verfuegbar ? verfuegbar <= today : false;
return {
vorlage_id: cl.vorlage_id,
vorlage_name: cl.vorlage_name,
intervall: cl.intervall,
next_due: nextDue ? nextDue.toISOString().split('T')[0] : null,
verfuegbar_ab: verfuegbar ? verfuegbar.toISOString().split('T')[0] : null,
letzte_ausfuehrung_am: letzteAusfuehrung,
ist_faellig: istFaellig,
...(cl.ausruestung_id ? { ausruestung_id: cl.ausruestung_id, ausruestung_name: cl.ausruestung_name } : {}),
};
});
};
const vehicles = vehiclesResult.rows.map((v: any) => ({
...v,
checklists: processChecklists(v.checklists),
}));
const equipment = equipmentResult.rows.map((e: any) => ({
...e,
checklists: processChecklists(e.checklists),
}));
return {
vehicles: vehiclesResult.rows,
equipment: equipmentResult.rows,
vehicles,
equipment,
};
} catch (error) {
logger.error('ChecklistService.getOverviewItems failed', { error });
@@ -812,6 +1007,12 @@ async function submitExecution(
notizen: string | null,
_userId: string,
) {
// Validate: NOK items must have a comment
const nokWithoutComment = items.filter(i => i.ergebnis === 'nok' && (!i.kommentar || !i.kommentar.trim()));
if (nokWithoutComment.length > 0) {
throw new Error('Kommentar ist erforderlich für nicht bestandene Prüfpunkte');
}
const client = await pool.connect();
try {
await client.query('BEGIN');
@@ -856,23 +1057,23 @@ async function submitExecution(
const { vorlage_id, fahrzeug_id, ausruestung_id } = exec.rows[0];
const vorlage = await client.query(`SELECT intervall, intervall_tage FROM checklist_vorlagen WHERE id = $1`, [vorlage_id]);
if (vorlage.rows.length > 0) {
const nextDue = calculateNextDueDate(vorlage.rows[0].intervall, vorlage.rows[0].intervall_tage);
if (nextDue) {
const nextPeriod = calculateNextPeriod(vorlage.rows[0].intervall, vorlage.rows[0].intervall_tage);
if (nextPeriod) {
if (ausruestung_id) {
await client.query(
`INSERT INTO checklist_faelligkeit (ausruestung_id, vorlage_id, naechste_faellig_am, letzte_ausfuehrung_id)
VALUES ($1, $2, $3, $4)
`INSERT INTO checklist_faelligkeit (ausruestung_id, vorlage_id, naechste_faellig_am, verfuegbar_ab, letzte_ausfuehrung_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (vorlage_id, ausruestung_id) WHERE ausruestung_id IS NOT NULL AND fahrzeug_id IS NULL
DO UPDATE SET naechste_faellig_am = $3, letzte_ausfuehrung_id = $4`,
[ausruestung_id, vorlage_id, nextDue, id]
DO UPDATE SET naechste_faellig_am = $3, verfuegbar_ab = $4, letzte_ausfuehrung_id = $5`,
[ausruestung_id, vorlage_id, nextPeriod.faellig_am, nextPeriod.verfuegbar_ab, id]
);
} else if (fahrzeug_id) {
await client.query(
`INSERT INTO checklist_faelligkeit (fahrzeug_id, vorlage_id, naechste_faellig_am, letzte_ausfuehrung_id)
VALUES ($1, $2, $3, $4)
`INSERT INTO checklist_faelligkeit (fahrzeug_id, vorlage_id, naechste_faellig_am, verfuegbar_ab, letzte_ausfuehrung_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (vorlage_id, fahrzeug_id) WHERE fahrzeug_id IS NOT NULL AND ausruestung_id IS NULL
DO UPDATE SET naechste_faellig_am = $3, letzte_ausfuehrung_id = $4`,
[fahrzeug_id, vorlage_id, nextDue, id]
DO UPDATE SET naechste_faellig_am = $3, verfuegbar_ab = $4, letzte_ausfuehrung_id = $5`,
[fahrzeug_id, vorlage_id, nextPeriod.faellig_am, nextPeriod.verfuegbar_ab, id]
);
}
}

View File

@@ -1,5 +1,6 @@
import pool from '../config/database';
import logger from '../utils/logger';
import settingsService from './settings.service';
import {
VeranstaltungKategorie,
Veranstaltung,
@@ -671,7 +672,7 @@ class EventsService {
token = inserted.rows[0].token;
}
const baseUrl = (process.env.ICAL_BASE_URL || process.env.CORS_ORIGIN || '').replace(/\/$/, '');
const baseUrl = (await settingsService.getSettingOrEnv('integration_ical_base_url', process.env.ICAL_BASE_URL || process.env.CORS_ORIGIN || '')).replace(/\/$/, '');
const subscribeUrl = `${baseUrl}/api/events/calendar.ics?token=${token}`;
return { token, subscribeUrl };

View File

@@ -3,22 +3,57 @@ import pool from '../config/database';
export interface AppSetting {
key: string;
value: any;
is_set?: boolean;
updated_at: string;
updated_by: string | null;
}
export const SENSITIVE_KEYS = new Set([
'integration_bookstack_token_id',
'integration_bookstack_token_secret',
'integration_vikunja_api_token',
'fdisk_username',
'fdisk_password',
]);
class SettingsService {
async getAll(): Promise<AppSetting[]> {
const result = await pool.query('SELECT * FROM app_settings ORDER BY key');
return result.rows;
return result.rows.map((row: AppSetting) => {
if (SENSITIVE_KEYS.has(row.key)) {
return { ...row, value: null, is_set: row.value !== '' && row.value !== null && row.value !== '""' };
}
return row;
});
}
async get(key: string): Promise<AppSetting | null> {
const result = await pool.query('SELECT * FROM app_settings WHERE key = $1', [key]);
return result.rows[0] ?? null;
const row: AppSetting | undefined = result.rows[0];
if (!row) return null;
if (SENSITIVE_KEYS.has(row.key)) {
return { ...row, value: null, is_set: row.value !== '' && row.value !== null && row.value !== '""' };
}
return row;
}
async getSecret(key: string): Promise<string | null> {
const result = await pool.query('SELECT value FROM app_settings WHERE key = $1', [key]);
if (!result.rows[0]) return null;
return result.rows[0].value as string;
}
async getSettingOrEnv(key: string, envFallback: string): Promise<string> {
const val = await this.getSecret(key);
if (val !== null && val !== '' && val !== '""') return val;
return envFallback;
}
async set(key: string, value: any, userId: string): Promise<AppSetting> {
if (value === '__UNCHANGED__') {
const existing = await this.get(key);
if (existing) return existing;
}
const result = await pool.query(
`INSERT INTO app_settings (key, value, updated_by, updated_at)
VALUES ($1, $2, $3, NOW())

View File

@@ -42,9 +42,9 @@ async function getBookstackConfig(): Promise<BookstackConfig> {
const db = await getDbConfig('tool_config_bookstack');
const config: BookstackConfig = {
url: db.url || environment.bookstack.url,
tokenId: db.tokenId || environment.bookstack.tokenId,
tokenSecret: db.tokenSecret || environment.bookstack.tokenSecret,
url: db.url || await settingsService.getSettingOrEnv('integration_bookstack_url', environment.bookstack.url),
tokenId: db.tokenId || await settingsService.getSettingOrEnv('integration_bookstack_token_id', environment.bookstack.tokenId),
tokenSecret: db.tokenSecret || await settingsService.getSettingOrEnv('integration_bookstack_token_secret', environment.bookstack.tokenSecret),
};
bookstackCache = { data: config, expiresAt: Date.now() + CACHE_TTL_MS };
@@ -58,8 +58,8 @@ async function getVikunjaConfig(): Promise<VikunjaConfig> {
const db = await getDbConfig('tool_config_vikunja');
const config: VikunjaConfig = {
url: db.url || environment.vikunja.url,
apiToken: db.apiToken || environment.vikunja.apiToken,
url: db.url || await settingsService.getSettingOrEnv('integration_vikunja_url', environment.vikunja.url),
apiToken: db.apiToken || await settingsService.getSettingOrEnv('integration_vikunja_api_token', environment.vikunja.apiToken),
};
vikunjaCache = { data: config, expiresAt: Date.now() + CACHE_TTL_MS };
@@ -73,7 +73,7 @@ async function getNextcloudConfig(): Promise<NextcloudConfig> {
const db = await getDbConfig('tool_config_nextcloud');
const config: NextcloudConfig = {
url: db.url || environment.nextcloudUrl,
url: db.url || await settingsService.getSettingOrEnv('integration_nextcloud_url', environment.nextcloudUrl),
bot_username: db.bot_username || undefined,
bot_app_password: db.bot_app_password || undefined,
};