- Added `panelChannelId` and `panelMessageId` fields to the `TicketConfig` model for managing ticket panel settings. - Introduced `syncTicketPanel` function to handle posting and updating the ticket panel in Discord channels via BullMQ. - Enhanced error handling for ticket panel synchronization, including specific error messages for missing categories and posting failures. - Updated WebUI components to support panel channel selection and improved error messaging for synchronization issues. - Added localization entries for new error messages related to ticket panel synchronization in both English and German. - Refactored ticket category management to trigger panel synchronization upon category creation, update, or deletion.
725 lines
20 KiB
TypeScript
725 lines
20 KiB
TypeScript
import { Worker } from 'bullmq';
|
|
import { PermissionFlagsBits } from 'discord.js';
|
|
import { mkdir, readdir, rm, stat } from 'node:fs/promises';
|
|
import { basename, join } from 'node:path';
|
|
import { spawn } from 'node:child_process';
|
|
import { redis } from './redis.js';
|
|
import { logger } from './logger.js';
|
|
import type { BotContext } from './types.js';
|
|
import { env } from './env.js';
|
|
import { refreshPhishingDomains } from './modules/automod/filters.js';
|
|
import { activateLockdown, deactivateLockdown } from './modules/automod/actions.js';
|
|
import {
|
|
completeVerification,
|
|
runVerificationPanelSyncJob
|
|
} from './modules/verification/handlers.js';
|
|
import { closePoll } from './modules/utility/poll.js';
|
|
import { sendReminder } from './modules/utility/reminders.js';
|
|
import {
|
|
expireSelfRole,
|
|
runSelfRolePanelCreateJob,
|
|
runSelfRolePanelUpdateJob,
|
|
runSelfRolePanelDeleteJob
|
|
} from './modules/selfroles/index.js';
|
|
import type { SelfRoleBehavior, SelfRoleEntry, SelfRoleMode } from '@nexumi/shared';
|
|
import {
|
|
endGiveaway,
|
|
runGiveawayCreateJob,
|
|
runGiveawayPauseJob,
|
|
runGiveawayDeleteJob,
|
|
GiveawayError
|
|
} from './modules/giveaways/index.js';
|
|
import { closeInactiveTickets, runTicketPanelSyncJob } from './modules/tickets/index.js';
|
|
import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js';
|
|
import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js';
|
|
import { pollAllFeeds } from './modules/feeds/index.js';
|
|
import { sendScheduledMessage } from './modules/scheduler/index.js';
|
|
import { sendDashboardMessage, type DashboardMessageSendJob } from './modules/components-v2/send.js';
|
|
import { runGuildBackupRestoreJob } from './modules/guildbackup/index.js';
|
|
import { runSuggestionStatusUpdateJob } from './modules/suggestions/index.js';
|
|
import { runPresenceRefreshJob } from './presence.js';
|
|
import {
|
|
automodQueue,
|
|
automodQueueName,
|
|
backupQueue,
|
|
backupQueueName,
|
|
birthdayQueue,
|
|
birthdayQueueName,
|
|
feedsQueue,
|
|
feedsQueueName,
|
|
giveawayQueueName,
|
|
guildBackupQueueName,
|
|
messagesQueueName,
|
|
moderationQueueName,
|
|
presenceQueue,
|
|
presenceQueueName,
|
|
reminderQueueName,
|
|
retentionQueue,
|
|
retentionQueueName,
|
|
scheduleQueueName,
|
|
selfrolesQueueName,
|
|
suggestionsQueueName,
|
|
ticketQueue,
|
|
ticketQueueName,
|
|
verificationQueueName
|
|
} from './queues.js';
|
|
|
|
export {
|
|
automodQueue,
|
|
backupQueue,
|
|
moderationQueue,
|
|
moderationQueueName,
|
|
reminderQueue,
|
|
verificationQueue
|
|
} from './queues.js';
|
|
|
|
type TempBanJob = {
|
|
guildId: string;
|
|
userId: string;
|
|
reason: string | null;
|
|
};
|
|
|
|
type VerificationCompleteJob = {
|
|
guildId: string;
|
|
userId: string;
|
|
ipHash?: string | null;
|
|
userAgentHash?: string | null;
|
|
captchaProvider?: string | null;
|
|
};
|
|
|
|
type VerificationPanelSyncJob = {
|
|
guildId: string;
|
|
};
|
|
|
|
type ReminderSendJob = {
|
|
reminderId: string;
|
|
};
|
|
|
|
type PollCloseJob = {
|
|
pollId: string;
|
|
};
|
|
|
|
type SelfRoleExpireJob = {
|
|
guildId: string;
|
|
userId: string;
|
|
roleId: string;
|
|
};
|
|
|
|
type GiveawayEndJob = {
|
|
giveawayId: string;
|
|
/** When true, end even if the giveaway is paused (dashboard/slash paths). */
|
|
force?: boolean;
|
|
};
|
|
|
|
type GiveawayCreateJob = {
|
|
guildId: string;
|
|
channelId: string;
|
|
hostId: string;
|
|
prize: string;
|
|
winnerCount: number;
|
|
durationMs: number;
|
|
requiredRoleId?: string | null;
|
|
requiredLevel?: number | null;
|
|
requiredMemberDays?: number | null;
|
|
};
|
|
|
|
type GiveawayPauseJob = {
|
|
giveawayId: string;
|
|
paused: boolean;
|
|
};
|
|
|
|
type GiveawayDeleteJob = {
|
|
giveawayId: string;
|
|
};
|
|
|
|
|
|
type BirthdayRoleExpireJob = {
|
|
guildId: string;
|
|
userId: string;
|
|
roleId: string;
|
|
};
|
|
|
|
type SuggestionStatusUpdateJob = {
|
|
suggestionId: string;
|
|
guildId: string;
|
|
status: 'OPEN' | 'APPROVED' | 'DENIED' | 'CONSIDERED' | 'IMPLEMENTED';
|
|
reason: string;
|
|
};
|
|
|
|
type SelfRolePanelCreateJob = {
|
|
guildId: string;
|
|
channelId: string;
|
|
title: string;
|
|
description?: string | null;
|
|
mode: SelfRoleMode;
|
|
behavior: SelfRoleBehavior;
|
|
roles: SelfRoleEntry[];
|
|
temporaryDurationMs?: number;
|
|
expiresAt?: string | null;
|
|
};
|
|
|
|
type SelfRolePanelUpdateJob = {
|
|
panelId: string;
|
|
guildId: string;
|
|
channelId?: string;
|
|
title?: string;
|
|
description?: string | null;
|
|
mode?: SelfRoleMode;
|
|
behavior?: SelfRoleBehavior;
|
|
roles?: SelfRoleEntry[];
|
|
temporaryDurationMs?: number;
|
|
expiresAt?: string | null;
|
|
};
|
|
|
|
type SelfRolePanelDeleteJob = {
|
|
panelId: string;
|
|
guildId: string;
|
|
};
|
|
|
|
export function startWorkers(context: BotContext): Worker[] {
|
|
const moderationWorker = new Worker<TempBanJob>(
|
|
moderationQueueName,
|
|
async (job) => {
|
|
if (job.name !== 'tempBanExpire') {
|
|
return;
|
|
}
|
|
|
|
const { guildId, userId, reason } = job.data;
|
|
const guild = context.client.guilds.cache.get(guildId);
|
|
if (!guild) {
|
|
if (!context.client.shard) {
|
|
throw new Error(`Guild ${guildId} not available for temp ban expiration`);
|
|
}
|
|
const results = await context.client.shard.broadcastEval(
|
|
async (c, payload) => {
|
|
const g = c.guilds.cache.get(payload.guildId);
|
|
if (!g) {
|
|
return false;
|
|
}
|
|
const me = await g.members.fetchMe();
|
|
if (!me.permissions.has(PermissionFlagsBits.BanMembers)) {
|
|
throw new Error('Missing BanMembers permission for temp ban expiration');
|
|
}
|
|
try {
|
|
await g.members.unban(payload.userId, payload.reason ?? 'Temporary ban expired');
|
|
} catch (error) {
|
|
const code =
|
|
typeof error === 'object' && error && 'code' in error
|
|
? Number((error as { code: unknown }).code)
|
|
: 0;
|
|
// Unknown ban (10026) — already unbanned
|
|
if (code === 10026) {
|
|
return true;
|
|
}
|
|
throw error;
|
|
}
|
|
return true;
|
|
},
|
|
{ context: { guildId, userId, reason } }
|
|
);
|
|
if (!results.some(Boolean)) {
|
|
throw new Error(`Guild ${guildId} not found on any shard for temp ban expiration`);
|
|
}
|
|
} else {
|
|
const me = await guild.members.fetchMe();
|
|
if (!me.permissions.has(PermissionFlagsBits.BanMembers)) {
|
|
throw new Error('Missing BanMembers permission for temp ban expiration');
|
|
}
|
|
try {
|
|
await guild.members.unban(userId, reason ?? 'Temporary ban expired');
|
|
} catch (error) {
|
|
const code =
|
|
typeof error === 'object' && error && 'code' in error
|
|
? Number((error as { code: unknown }).code)
|
|
: 0;
|
|
if (code !== 10026) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
const { createCase } = await import('./modules/moderation/service.js');
|
|
await createCase(context, {
|
|
guildId,
|
|
targetUserId: userId,
|
|
moderatorId: context.client.user?.id ?? 'system',
|
|
action: 'UNBAN',
|
|
reason: reason ?? 'Temporary ban expired',
|
|
source: 'job',
|
|
metadata: { tempBanExpire: true }
|
|
});
|
|
},
|
|
{ connection: redis }
|
|
);
|
|
|
|
moderationWorker.on('failed', (job, error) => {
|
|
logger.error({ jobId: job?.id, error }, 'Moderation job failed');
|
|
});
|
|
|
|
const backupWorker = new Worker(
|
|
backupQueueName,
|
|
async (job) => {
|
|
if (job.name !== 'dailyPgDump') {
|
|
return;
|
|
}
|
|
await runPgDumpBackup();
|
|
},
|
|
{ connection: redis }
|
|
);
|
|
|
|
backupWorker.on('failed', (job, error) => {
|
|
logger.error({ jobId: job?.id, error }, 'Backup job failed');
|
|
});
|
|
|
|
const automodWorker = new Worker(
|
|
automodQueueName,
|
|
async (job) => {
|
|
if (job.name === 'refreshPhishingList') {
|
|
const count = await refreshPhishingDomains(redis);
|
|
logger.info({ domainCount: count }, 'Phishing domain list refreshed');
|
|
return;
|
|
}
|
|
if (job.name === 'deactivateLockdown' || job.name === 'activateLockdown') {
|
|
const guildId = (job.data as { guildId?: string }).guildId;
|
|
if (!guildId) {
|
|
return;
|
|
}
|
|
const guild = await context.client.guilds.fetch(guildId).catch(() => null);
|
|
if (!guild) {
|
|
return;
|
|
}
|
|
if (job.name === 'deactivateLockdown') {
|
|
await deactivateLockdown(guild);
|
|
logger.info({ guildId }, 'Lockdown deactivated via dashboard');
|
|
return;
|
|
}
|
|
await activateLockdown(guild);
|
|
logger.info({ guildId }, 'Lockdown activated via dashboard');
|
|
}
|
|
},
|
|
{ connection: redis }
|
|
);
|
|
|
|
automodWorker.on('failed', (job, error) => {
|
|
logger.error({ jobId: job?.id, error }, 'AutoMod job failed');
|
|
});
|
|
|
|
const verificationWorker = new Worker<VerificationCompleteJob | VerificationPanelSyncJob>(
|
|
verificationQueueName,
|
|
async (job) => {
|
|
if (job.name === 'verificationPanelSync') {
|
|
return runVerificationPanelSyncJob(context, job.data as VerificationPanelSyncJob);
|
|
}
|
|
if (job.name !== 'verificationComplete') {
|
|
return;
|
|
}
|
|
const data = job.data as VerificationCompleteJob;
|
|
await completeVerification(context, data.guildId, data.userId, {
|
|
ipHash: data.ipHash,
|
|
userAgentHash: data.userAgentHash,
|
|
captchaProvider: data.captchaProvider
|
|
});
|
|
},
|
|
{ connection: redis }
|
|
);
|
|
|
|
verificationWorker.on('failed', (job, error) => {
|
|
logger.error({ jobId: job?.id, error }, 'Verification job failed');
|
|
});
|
|
|
|
const reminderWorker = new Worker<ReminderSendJob | PollCloseJob>(
|
|
reminderQueueName,
|
|
async (job) => {
|
|
if (job.name === 'reminderSend') {
|
|
await sendReminder(context, (job.data as ReminderSendJob).reminderId);
|
|
return;
|
|
}
|
|
if (job.name === 'pollClose') {
|
|
await closePoll(context, (job.data as PollCloseJob).pollId);
|
|
}
|
|
},
|
|
{ connection: redis }
|
|
);
|
|
|
|
reminderWorker.on('failed', (job, error) => {
|
|
logger.error({ jobId: job?.id, error }, 'Reminder job failed');
|
|
});
|
|
|
|
const ticketWorker = new Worker(
|
|
ticketQueueName,
|
|
async (job) => {
|
|
if (job.name === 'ticketPanelSync') {
|
|
return runTicketPanelSyncJob(context, job.data as { guildId: string });
|
|
}
|
|
if (job.name === 'selfRoleExpire') {
|
|
const data = job.data as SelfRoleExpireJob;
|
|
await expireSelfRole(context, data.guildId, data.userId, data.roleId);
|
|
return;
|
|
}
|
|
if (job.name === 'ticketAutoClose') {
|
|
await closeInactiveTickets(context);
|
|
}
|
|
},
|
|
{ connection: redis }
|
|
);
|
|
|
|
ticketWorker.on('failed', (job, error) => {
|
|
logger.error({ jobId: job?.id, error }, 'Ticket queue job failed');
|
|
});
|
|
|
|
const selfrolesWorker = new Worker<
|
|
SelfRolePanelCreateJob | SelfRolePanelUpdateJob | SelfRolePanelDeleteJob
|
|
>(
|
|
selfrolesQueueName,
|
|
async (job) => {
|
|
if (job.name === 'selfRolePanelCreate') {
|
|
return runSelfRolePanelCreateJob(context, job.data as SelfRolePanelCreateJob);
|
|
}
|
|
if (job.name === 'selfRolePanelUpdate') {
|
|
return runSelfRolePanelUpdateJob(context, job.data as SelfRolePanelUpdateJob);
|
|
}
|
|
if (job.name === 'selfRolePanelDelete') {
|
|
const data = job.data as SelfRolePanelDeleteJob;
|
|
await runSelfRolePanelDeleteJob(context, data.panelId, data.guildId);
|
|
}
|
|
},
|
|
{ connection: redis }
|
|
);
|
|
|
|
selfrolesWorker.on('failed', (job, error) => {
|
|
logger.error({ jobId: job?.id, error }, 'Self-roles queue job failed');
|
|
});
|
|
|
|
const giveawayWorker = new Worker<
|
|
GiveawayEndJob | GiveawayCreateJob | GiveawayPauseJob | GiveawayDeleteJob
|
|
>(
|
|
giveawayQueueName,
|
|
async (job) => {
|
|
if (job.name === 'giveawayEnd') {
|
|
try {
|
|
const data = job.data as GiveawayEndJob;
|
|
if (!data.force) {
|
|
const current = await context.prisma.giveaway.findUnique({
|
|
where: { id: data.giveawayId }
|
|
});
|
|
// Timer/recover must not end a paused giveaway.
|
|
if (current?.paused) {
|
|
return;
|
|
}
|
|
}
|
|
await endGiveaway(context, data.giveawayId);
|
|
} catch (error) {
|
|
if (
|
|
error instanceof GiveawayError &&
|
|
(error.code === 'already_ended' || error.code === 'not_found')
|
|
) {
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
return;
|
|
}
|
|
if (job.name === 'giveawayCreate') {
|
|
return runGiveawayCreateJob(context, job.data as GiveawayCreateJob);
|
|
}
|
|
if (job.name === 'giveawayPause') {
|
|
const data = job.data as GiveawayPauseJob;
|
|
return runGiveawayPauseJob(context, data.giveawayId, data.paused);
|
|
}
|
|
if (job.name === 'giveawayDelete') {
|
|
await runGiveawayDeleteJob(context, (job.data as GiveawayDeleteJob).giveawayId);
|
|
}
|
|
},
|
|
{ connection: redis }
|
|
);
|
|
|
|
giveawayWorker.on('failed', (job, error) => {
|
|
logger.error({ jobId: job?.id, error }, 'Giveaway job failed');
|
|
});
|
|
|
|
const birthdayWorker = new Worker(
|
|
birthdayQueueName,
|
|
async (job) => {
|
|
if (job.name === 'birthdayDailyCheck') {
|
|
await runBirthdayCheck(context);
|
|
return;
|
|
}
|
|
if (job.name === 'birthdayRoleExpire') {
|
|
const data = job.data as BirthdayRoleExpireJob;
|
|
await expireBirthdayRole(context, data.guildId, data.userId, data.roleId);
|
|
}
|
|
},
|
|
{ connection: redis }
|
|
);
|
|
|
|
birthdayWorker.on('failed', (job, error) => {
|
|
logger.error({ jobId: job?.id, error }, 'Birthday job failed');
|
|
});
|
|
|
|
const statsWorker = startStatsWorker(context);
|
|
|
|
const feedsWorker = new Worker(
|
|
feedsQueueName,
|
|
async (job) => {
|
|
if (job.name !== 'pollFeeds') {
|
|
return;
|
|
}
|
|
await pollAllFeeds(context);
|
|
},
|
|
{ connection: redis }
|
|
);
|
|
feedsWorker.on('failed', (job, error) => {
|
|
logger.error({ jobId: job?.id, error }, 'Feeds job failed');
|
|
});
|
|
|
|
const scheduleWorker = new Worker<{ scheduleId: string }>(
|
|
scheduleQueueName,
|
|
async (job) => {
|
|
if (job.name !== 'scheduleSend') {
|
|
return;
|
|
}
|
|
await sendScheduledMessage(context, job.data.scheduleId);
|
|
},
|
|
{ connection: redis }
|
|
);
|
|
scheduleWorker.on('failed', (job, error) => {
|
|
logger.error({ jobId: job?.id, error }, 'Schedule job failed');
|
|
});
|
|
|
|
const messagesWorker = new Worker<DashboardMessageSendJob>(
|
|
messagesQueueName,
|
|
async (job) => {
|
|
if (job.name !== 'dashboardMessageSend') {
|
|
return;
|
|
}
|
|
return sendDashboardMessage(context, job.data);
|
|
},
|
|
{ connection: redis }
|
|
);
|
|
messagesWorker.on('failed', (job, error) => {
|
|
logger.error({ jobId: job?.id, error }, 'Dashboard message send job failed');
|
|
});
|
|
|
|
const guildBackupWorker = new Worker<{ backupId: string; guildId: string }>(
|
|
guildBackupQueueName,
|
|
async (job) => {
|
|
if (job.name !== 'restoreGuildBackup') {
|
|
return;
|
|
}
|
|
await runGuildBackupRestoreJob(context, job.data.backupId, job.data.guildId);
|
|
},
|
|
{ connection: redis }
|
|
);
|
|
guildBackupWorker.on('failed', (job, error) => {
|
|
logger.error({ jobId: job?.id, error }, 'Guild backup restore job failed');
|
|
});
|
|
|
|
const suggestionsWorker = new Worker<SuggestionStatusUpdateJob>(
|
|
suggestionsQueueName,
|
|
async (job) => {
|
|
if (job.name !== 'suggestionStatusUpdate') {
|
|
return;
|
|
}
|
|
return runSuggestionStatusUpdateJob(context, job.data);
|
|
},
|
|
{ connection: redis }
|
|
);
|
|
suggestionsWorker.on('failed', (job, error) => {
|
|
logger.error({ jobId: job?.id, error }, 'Suggestion status update job failed');
|
|
});
|
|
|
|
const presenceWorker = new Worker(
|
|
presenceQueueName,
|
|
async (job) => {
|
|
if (job.name !== 'presenceRefresh') {
|
|
return;
|
|
}
|
|
await runPresenceRefreshJob(context);
|
|
},
|
|
{ connection: redis }
|
|
);
|
|
presenceWorker.on('failed', (job, error) => {
|
|
logger.error({ jobId: job?.id, error }, 'Presence refresh job failed');
|
|
});
|
|
|
|
const retentionWorker = new Worker(
|
|
retentionQueueName,
|
|
async (job) => {
|
|
if (job.name !== 'purgeExpiredLogs') {
|
|
return;
|
|
}
|
|
await runLogRetentionJob(context);
|
|
},
|
|
{ connection: redis }
|
|
);
|
|
retentionWorker.on('failed', (job, error) => {
|
|
logger.error({ jobId: job?.id, error }, 'Log retention job failed');
|
|
});
|
|
|
|
return [
|
|
moderationWorker,
|
|
backupWorker,
|
|
automodWorker,
|
|
verificationWorker,
|
|
reminderWorker,
|
|
ticketWorker,
|
|
selfrolesWorker,
|
|
giveawayWorker,
|
|
birthdayWorker,
|
|
statsWorker,
|
|
feedsWorker,
|
|
scheduleWorker,
|
|
messagesWorker,
|
|
guildBackupWorker,
|
|
suggestionsWorker,
|
|
presenceWorker,
|
|
retentionWorker
|
|
];
|
|
}
|
|
|
|
export async function ensureRecurringJobs(): Promise<void> {
|
|
await backupQueue.add(
|
|
'dailyPgDump',
|
|
{},
|
|
{
|
|
repeat: { pattern: env.BACKUP_CRON },
|
|
removeOnComplete: 100,
|
|
removeOnFail: 100
|
|
}
|
|
);
|
|
|
|
await automodQueue.add(
|
|
'refreshPhishingList',
|
|
{},
|
|
{
|
|
repeat: { pattern: '0 */6 * * *' },
|
|
removeOnComplete: 20,
|
|
removeOnFail: 20
|
|
}
|
|
);
|
|
|
|
await automodQueue.add('refreshPhishingList', {}, { removeOnComplete: 20, removeOnFail: 20 });
|
|
|
|
await ticketQueue.add(
|
|
'ticketAutoClose',
|
|
{},
|
|
{
|
|
repeat: { pattern: '0 * * * *' },
|
|
removeOnComplete: 20,
|
|
removeOnFail: 20
|
|
}
|
|
);
|
|
|
|
await birthdayQueue.add(
|
|
'birthdayDailyCheck',
|
|
{},
|
|
{
|
|
repeat: { pattern: '0 * * * *' },
|
|
removeOnComplete: 20,
|
|
removeOnFail: 20
|
|
}
|
|
);
|
|
|
|
await ensureStatsRecurringJobs();
|
|
|
|
await feedsQueue.add(
|
|
'pollFeeds',
|
|
{},
|
|
{
|
|
repeat: { pattern: '*/5 * * * *' },
|
|
removeOnComplete: 20,
|
|
removeOnFail: 20
|
|
}
|
|
);
|
|
|
|
await presenceQueue.add(
|
|
'presenceRefresh',
|
|
{},
|
|
{
|
|
repeat: { every: 60_000 },
|
|
removeOnComplete: 5,
|
|
removeOnFail: 5
|
|
}
|
|
);
|
|
|
|
await retentionQueue.add(
|
|
'purgeExpiredLogs',
|
|
{},
|
|
{
|
|
repeat: { pattern: '15 4 * * *' },
|
|
removeOnComplete: 20,
|
|
removeOnFail: 20
|
|
}
|
|
);
|
|
}
|
|
|
|
async function runLogRetentionJob(context: BotContext): Promise<void> {
|
|
const configs = await context.prisma.loggingConfig.findMany({
|
|
where: { retentionDays: { gt: 0 } },
|
|
select: { guildId: true, retentionDays: true }
|
|
});
|
|
|
|
for (const config of configs) {
|
|
const cutoff = new Date(Date.now() - config.retentionDays * 24 * 60 * 60 * 1000);
|
|
const [cases, audits] = await Promise.all([
|
|
context.prisma.case.deleteMany({
|
|
where: { guildId: config.guildId, createdAt: { lt: cutoff } }
|
|
}),
|
|
context.prisma.dashboardAuditLog.deleteMany({
|
|
where: { guildId: config.guildId, createdAt: { lt: cutoff } }
|
|
})
|
|
]);
|
|
if (cases.count > 0 || audits.count > 0) {
|
|
logger.info(
|
|
{ guildId: config.guildId, cases: cases.count, audits: audits.count, retentionDays: config.retentionDays },
|
|
'Log retention purge completed'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runPgDumpBackup(): Promise<void> {
|
|
await mkdir(env.BACKUP_DIR, { recursive: true });
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const filename = `nexumi-${timestamp}.sql`;
|
|
const outputPath = join(env.BACKUP_DIR, filename);
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const dump = spawn('pg_dump', ['--dbname', env.DATABASE_URL, '--file', outputPath], {
|
|
stdio: 'pipe'
|
|
});
|
|
let stderr = '';
|
|
dump.stderr.on('data', (chunk: Buffer) => {
|
|
stderr += chunk.toString('utf8');
|
|
});
|
|
dump.on('close', (code) => {
|
|
if (code === 0) {
|
|
resolve();
|
|
return;
|
|
}
|
|
reject(new Error(stderr || `pg_dump exited with code ${code ?? 'unknown'}`));
|
|
});
|
|
});
|
|
|
|
await cleanupOldBackups();
|
|
logger.info({ backupFile: basename(outputPath) }, 'Database backup finished');
|
|
}
|
|
|
|
async function cleanupOldBackups(): Promise<void> {
|
|
const files = await readdir(env.BACKUP_DIR, { withFileTypes: true });
|
|
const cutoff = Date.now() - env.BACKUP_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
|
await Promise.all(
|
|
files
|
|
.filter((entry) => entry.isFile() && entry.name.endsWith('.sql'))
|
|
.map(async (entry) => {
|
|
const fullPath = join(env.BACKUP_DIR, entry.name);
|
|
const fileStat = await stat(fullPath);
|
|
const created = fileStat.mtime.getTime();
|
|
if (Number.isNaN(created) || created >= cutoff) {
|
|
return;
|
|
}
|
|
await rm(fullPath, { force: true });
|
|
})
|
|
);
|
|
}
|