diff --git a/backend/index.js b/backend/index.js
index 3d3147e..2178d03 100644
--- a/backend/index.js
+++ b/backend/index.js
@@ -1049,7 +1049,36 @@ app.get('/api/servers/:guildId/invites', async (req, res) => {
app.post('/api/servers/:guildId/invites', async (req, res) => {
try {
const { guildId } = req.params;
- const { channelId, maxAge, maxUses, temporary } = req.body || {};
+ const { code, url, channelId, maxAge, maxUses, temporary, createdAt } = req.body || {};
+
+ // If code is provided, this is an existing invite to store (from Discord events)
+ if (code) {
+ const item = {
+ code,
+ url: url || `https://discord.gg/${code}`,
+ channelId: channelId || '',
+ createdAt: createdAt || new Date().toISOString(),
+ maxUses: maxUses || 0,
+ maxAge: maxAge || 0,
+ temporary: !!temporary,
+ };
+
+ await pgClient.addInvite({
+ code: item.code,
+ guildId,
+ url: item.url,
+ channelId: item.channelId,
+ createdAt: item.createdAt,
+ maxUses: item.maxUses,
+ maxAge: item.maxAge,
+ temporary: item.temporary
+ });
+
+ res.json({ success: true, invite: item });
+ return;
+ }
+
+ // Otherwise, create a new invite
const guild = bot.client.guilds.cache.get(guildId);
if (!guild) return res.status(404).json({ success: false, message: 'Guild not found' });
@@ -1089,7 +1118,7 @@ app.post('/api/servers/:guildId/invites', async (req, res) => {
res.json({ success: true, invite: item });
} catch (error) {
- console.error('Error creating invite:', error);
+ console.error('Error creating/storing invite:', error);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
diff --git a/checklist.md b/checklist.md
index 80b6e25..4ed1bad 100644
--- a/checklist.md
+++ b/checklist.md
@@ -94,6 +94,14 @@
- [x] Admin logs properly display the username who called the command and the user they called it on for both bot slash commands and frontend moderation actions
- [x] Bot command username logging fixed: uses correct Discord user properties (username/global_name instead of deprecated tag)
- [x] Bot event handlers: added guildCreate and guildDelete events to publish SSE notifications for live dashboard updates
+ - [x] Invite synchronization: real-time sync between Discord server events and frontend
+ - [x] Discord event handlers for inviteCreate and inviteDelete events
+ - [x] Only bot-created invites are tracked and synchronized
+ - [x] Frontend SSE event listeners for inviteCreated and inviteDeleted events
+ - [x] Backend API updated to store existing invites from Discord events
+ - [x] Invite deletions from Discord server are immediately reflected in frontend
+ - [x] Offline reconciliation: bot detects and removes invites deleted while offline on startup
+ - [x] Automatic cleanup of stale invites from database and frontend when bot comes back online
## Database
- [x] Postgres support via `DATABASE_URL` (backend auto-creates `servers`, `invites`, `users`)
diff --git a/discord-bot/api.js b/discord-bot/api.js
index 8ea2eab..ae68d3f 100644
--- a/discord-bot/api.js
+++ b/discord-bot/api.js
@@ -90,10 +90,24 @@ async function listInvites(guildId) {
async function addInvite(guildId, invite) {
const path = `/api/servers/${guildId}/invites`;
try {
+ // If invite is an object with code property, it's already created - send full data
+ // If it's just channelId/maxAge/etc, it's for creation
+ const isExistingInvite = invite && typeof invite === 'object' && invite.code;
+
+ const body = isExistingInvite ? {
+ code: invite.code,
+ url: invite.url,
+ channelId: invite.channelId,
+ maxUses: invite.maxUses,
+ maxAge: invite.maxAge,
+ temporary: invite.temporary,
+ createdAt: invite.createdAt
+ } : invite;
+
const res = await tryFetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(invite),
+ body: JSON.stringify(body),
});
return res && res.ok;
} catch (e) {
@@ -208,4 +222,44 @@ async function getAutoroleSettings(guildId) {
return json || { enabled: false, roleId: '' };
}
-module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite, getTwitchUsers, addTwitchUser, deleteTwitchUser, tryFetchTwitchStreams, _rawGetTwitchStreams, getKickUsers, addKickUser, deleteKickUser, getWelcomeLeaveSettings, getAutoroleSettings };
+async function reconcileInvites(guildId, currentDiscordInvites) {
+ try {
+ // Get invites from database
+ const dbInvites = await listInvites(guildId) || [];
+
+ // Find invites in database that no longer exist in Discord
+ const discordInviteCodes = new Set(currentDiscordInvites.map(inv => inv.code));
+ const deletedInvites = dbInvites.filter(dbInv => !discordInviteCodes.has(dbInv.code));
+
+ // Delete each invite that no longer exists
+ for (const invite of deletedInvites) {
+ console.log(`🗑️ Reconciling deleted invite ${invite.code} for guild ${guildId}`);
+ await deleteInvite(guildId, invite.code);
+
+ // Publish SSE event for frontend update
+ try {
+ await tryFetch('/api/events/publish', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ event: 'inviteDeleted',
+ data: { code: invite.code, guildId }
+ })
+ });
+ } catch (sseErr) {
+ console.error('Failed to publish SSE event for reconciled invite deletion:', sseErr);
+ }
+ }
+
+ if (deletedInvites.length > 0) {
+ console.log(`✅ Reconciled ${deletedInvites.length} deleted invites for guild ${guildId}`);
+ }
+
+ return deletedInvites.length;
+ } catch (e) {
+ console.error(`Failed to reconcile invites for guild ${guildId}:`, e && e.message ? e.message : e);
+ return 0;
+ }
+}
+
+module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite, getTwitchUsers, addTwitchUser, deleteTwitchUser, tryFetchTwitchStreams, _rawGetTwitchStreams, getKickUsers, addKickUser, deleteKickUser, getWelcomeLeaveSettings, getAutoroleSettings, reconcileInvites };
diff --git a/discord-bot/events/inviteCreate.js b/discord-bot/events/inviteCreate.js
new file mode 100644
index 0000000..dd767fc
--- /dev/null
+++ b/discord-bot/events/inviteCreate.js
@@ -0,0 +1,49 @@
+const api = require('../api');
+
+module.exports = {
+ name: 'inviteCreate',
+ async execute(invite) {
+ try {
+ // Only track invites created by the bot or in channels the bot can access
+ const guildId = invite.guild.id;
+
+ // Check if this invite was created by our bot
+ const isBotCreated = invite.inviter && invite.inviter.id === invite.client.user.id;
+
+ if (isBotCreated) {
+ // Add to database if created by bot
+ const inviteData = {
+ code: invite.code,
+ guildId: guildId,
+ url: invite.url,
+ channelId: invite.channel.id,
+ createdAt: invite.createdAt ? invite.createdAt.toISOString() : new Date().toISOString(),
+ maxUses: invite.maxUses || 0,
+ maxAge: invite.maxAge || 0,
+ temporary: invite.temporary || false
+ };
+
+ // Use the API to add the invite to database
+ await api.addInvite(inviteData);
+
+ // Publish SSE event for real-time frontend updates
+ const bot = require('..');
+ if (bot && bot.publishEvent) {
+ bot.publishEvent(guildId, 'inviteCreated', {
+ code: invite.code,
+ url: invite.url,
+ channelId: invite.channel.id,
+ maxUses: invite.maxUses || 0,
+ maxAge: invite.maxAge || 0,
+ temporary: invite.temporary || false,
+ createdAt: invite.createdAt ? invite.createdAt.toISOString() : new Date().toISOString()
+ });
+ }
+ }
+ // Note: We don't automatically add invites created by other users to avoid spam
+ // Only bot-created invites are tracked for the web interface
+ } catch (error) {
+ console.error('Error handling inviteCreate:', error);
+ }
+ }
+};
\ No newline at end of file
diff --git a/discord-bot/events/inviteDelete.js b/discord-bot/events/inviteDelete.js
new file mode 100644
index 0000000..8e3756f
--- /dev/null
+++ b/discord-bot/events/inviteDelete.js
@@ -0,0 +1,24 @@
+const api = require('../api');
+
+module.exports = {
+ name: 'inviteDelete',
+ async execute(invite) {
+ try {
+ const guildId = invite.guild.id;
+ const code = invite.code;
+
+ // Remove from database
+ await api.deleteInvite(guildId, code);
+
+ // Publish SSE event for real-time frontend updates
+ const bot = require('..');
+ if (bot && bot.publishEvent) {
+ bot.publishEvent(guildId, 'inviteDeleted', {
+ code: code
+ });
+ }
+ } catch (error) {
+ console.error('Error handling inviteDelete:', error);
+ }
+ }
+};
\ No newline at end of file
diff --git a/discord-bot/events/ready.js b/discord-bot/events/ready.js
index 79cb676..554e096 100644
--- a/discord-bot/events/ready.js
+++ b/discord-bot/events/ready.js
@@ -1,5 +1,6 @@
const { ActivityType } = require('discord.js');
const deployCommands = require('../deploy-commands');
+const api = require('../api');
module.exports = {
name: 'clientReady',
@@ -16,6 +17,31 @@ module.exports = {
}
}
+ // Reconcile invites for all guilds to detect invites deleted while bot was offline
+ console.log('🔄 Reconciling invites for offline changes...');
+ let totalReconciled = 0;
+ for (const guildId of guildIds) {
+ try {
+ const guild = client.guilds.cache.get(guildId);
+ if (!guild) continue;
+
+ // Fetch current invites from Discord
+ const discordInvites = await guild.invites.fetch();
+ const currentInvites = Array.from(discordInvites.values());
+
+ // Reconcile with database
+ const reconciled = await api.reconcileInvites(guildId, currentInvites);
+ totalReconciled += reconciled;
+ } catch (e) {
+ console.error(`Failed to reconcile invites for guild ${guildId}:`, e && e.message ? e.message : e);
+ }
+ }
+ if (totalReconciled > 0) {
+ console.log(`✅ Invite reconciliation complete: removed ${totalReconciled} stale invites`);
+ } else {
+ console.log('✅ Invite reconciliation complete: no stale invites found');
+ }
+
const activities = [
{ name: 'Watch EhChad Live!', type: ActivityType.Streaming, url: 'https://twitch.tv/ehchad' },
{ name: 'Follow EhChad!', type: ActivityType.Streaming, url: 'https://twitch.tv/ehchad' },
diff --git a/frontend/src/components/server/ServerSettings.js b/frontend/src/components/server/ServerSettings.js
index 3fe92f1..0cfe667 100644
--- a/frontend/src/components/server/ServerSettings.js
+++ b/frontend/src/components/server/ServerSettings.js
@@ -269,6 +269,22 @@ const ServerSettings = () => {
setAdminLogs([]);
};
+ const onInviteCreated = (e) => {
+ const data = e.detail || {};
+ if (!data) return;
+ if (data.guildId && data.guildId !== guildId) return;
+ // Add the new invite to the list
+ setInvites(prev => [...prev, data]);
+ };
+
+ const onInviteDeleted = (e) => {
+ const data = e.detail || {};
+ if (!data) return;
+ if (data.guildId && data.guildId !== guildId) return;
+ // Remove the deleted invite from the list
+ setInvites(prev => prev.filter(invite => invite.code !== data.code));
+ };
+
eventTarget.addEventListener('twitchUsersUpdate', onTwitchUsers);
eventTarget.addEventListener('kickUsersUpdate', onKickUsers);
eventTarget.addEventListener('liveNotificationsUpdate', onLiveNotifications);
@@ -276,6 +292,8 @@ const ServerSettings = () => {
eventTarget.addEventListener('adminLogAdded', onAdminLogAdded);
eventTarget.addEventListener('adminLogDeleted', onAdminLogDeleted);
eventTarget.addEventListener('adminLogsCleared', onAdminLogsCleared);
+ eventTarget.addEventListener('inviteCreated', onInviteCreated);
+ eventTarget.addEventListener('inviteDeleted', onInviteDeleted);
return () => {
try {
@@ -286,6 +304,8 @@ const ServerSettings = () => {
eventTarget.removeEventListener('adminLogAdded', onAdminLogAdded);
eventTarget.removeEventListener('adminLogDeleted', onAdminLogDeleted);
eventTarget.removeEventListener('adminLogsCleared', onAdminLogsCleared);
+ eventTarget.removeEventListener('inviteCreated', onInviteCreated);
+ eventTarget.removeEventListener('inviteDeleted', onInviteDeleted);
} catch (err) {}
};
}, [eventTarget, guildId]);
@@ -669,7 +689,9 @@ const ServerSettings = () => {
@@ -778,7 +800,7 @@ const ServerSettings = () => {
displayEmpty
>
- {channels.map(channel => (
+ {channels.map((channel) => (
))}
@@ -819,7 +841,7 @@ const ServerSettings = () => {
displayEmpty
>
- {channels.map(channel => (
+ {channels.map((channel) => (
))}