Fixed Invite Accordion
This commit is contained in:
@@ -1049,7 +1049,36 @@ app.get('/api/servers/:guildId/invites', async (req, res) => {
|
|||||||
app.post('/api/servers/:guildId/invites', async (req, res) => {
|
app.post('/api/servers/:guildId/invites', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { guildId } = req.params;
|
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);
|
const guild = bot.client.guilds.cache.get(guildId);
|
||||||
if (!guild) return res.status(404).json({ success: false, message: 'Guild not found' });
|
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 });
|
res.json({ success: true, invite: item });
|
||||||
} catch (error) {
|
} 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' });
|
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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] 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 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] 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
|
## Database
|
||||||
- [x] Postgres support via `DATABASE_URL` (backend auto-creates `servers`, `invites`, `users`)
|
- [x] Postgres support via `DATABASE_URL` (backend auto-creates `servers`, `invites`, `users`)
|
||||||
|
|||||||
@@ -90,10 +90,24 @@ async function listInvites(guildId) {
|
|||||||
async function addInvite(guildId, invite) {
|
async function addInvite(guildId, invite) {
|
||||||
const path = `/api/servers/${guildId}/invites`;
|
const path = `/api/servers/${guildId}/invites`;
|
||||||
try {
|
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, {
|
const res = await tryFetch(path, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(invite),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
return res && res.ok;
|
return res && res.ok;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -208,4 +222,44 @@ async function getAutoroleSettings(guildId) {
|
|||||||
return json || { enabled: false, roleId: '' };
|
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 };
|
||||||
|
|||||||
49
discord-bot/events/inviteCreate.js
Normal file
49
discord-bot/events/inviteCreate.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
24
discord-bot/events/inviteDelete.js
Normal file
24
discord-bot/events/inviteDelete.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
const { ActivityType } = require('discord.js');
|
const { ActivityType } = require('discord.js');
|
||||||
const deployCommands = require('../deploy-commands');
|
const deployCommands = require('../deploy-commands');
|
||||||
|
const api = require('../api');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
name: 'clientReady',
|
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 = [
|
const activities = [
|
||||||
{ name: 'Watch EhChad Live!', type: ActivityType.Streaming, url: 'https://twitch.tv/ehchad' },
|
{ name: 'Watch EhChad Live!', type: ActivityType.Streaming, url: 'https://twitch.tv/ehchad' },
|
||||||
{ name: 'Follow EhChad!', type: ActivityType.Streaming, url: 'https://twitch.tv/ehchad' },
|
{ name: 'Follow EhChad!', type: ActivityType.Streaming, url: 'https://twitch.tv/ehchad' },
|
||||||
|
|||||||
@@ -269,6 +269,22 @@ const ServerSettings = () => {
|
|||||||
setAdminLogs([]);
|
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('twitchUsersUpdate', onTwitchUsers);
|
||||||
eventTarget.addEventListener('kickUsersUpdate', onKickUsers);
|
eventTarget.addEventListener('kickUsersUpdate', onKickUsers);
|
||||||
eventTarget.addEventListener('liveNotificationsUpdate', onLiveNotifications);
|
eventTarget.addEventListener('liveNotificationsUpdate', onLiveNotifications);
|
||||||
@@ -276,6 +292,8 @@ const ServerSettings = () => {
|
|||||||
eventTarget.addEventListener('adminLogAdded', onAdminLogAdded);
|
eventTarget.addEventListener('adminLogAdded', onAdminLogAdded);
|
||||||
eventTarget.addEventListener('adminLogDeleted', onAdminLogDeleted);
|
eventTarget.addEventListener('adminLogDeleted', onAdminLogDeleted);
|
||||||
eventTarget.addEventListener('adminLogsCleared', onAdminLogsCleared);
|
eventTarget.addEventListener('adminLogsCleared', onAdminLogsCleared);
|
||||||
|
eventTarget.addEventListener('inviteCreated', onInviteCreated);
|
||||||
|
eventTarget.addEventListener('inviteDeleted', onInviteDeleted);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
try {
|
try {
|
||||||
@@ -286,6 +304,8 @@ const ServerSettings = () => {
|
|||||||
eventTarget.removeEventListener('adminLogAdded', onAdminLogAdded);
|
eventTarget.removeEventListener('adminLogAdded', onAdminLogAdded);
|
||||||
eventTarget.removeEventListener('adminLogDeleted', onAdminLogDeleted);
|
eventTarget.removeEventListener('adminLogDeleted', onAdminLogDeleted);
|
||||||
eventTarget.removeEventListener('adminLogsCleared', onAdminLogsCleared);
|
eventTarget.removeEventListener('adminLogsCleared', onAdminLogsCleared);
|
||||||
|
eventTarget.removeEventListener('inviteCreated', onInviteCreated);
|
||||||
|
eventTarget.removeEventListener('inviteDeleted', onInviteDeleted);
|
||||||
} catch (err) {}
|
} catch (err) {}
|
||||||
};
|
};
|
||||||
}, [eventTarget, guildId]);
|
}, [eventTarget, guildId]);
|
||||||
@@ -669,7 +689,9 @@ const ServerSettings = () => {
|
|||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
<Select value={inviteForm.channelId} onChange={(e) => setInviteForm(f => ({ ...f, channelId: e.target.value }))} displayEmpty>
|
<Select value={inviteForm.channelId} onChange={(e) => setInviteForm(f => ({ ...f, channelId: e.target.value }))} displayEmpty>
|
||||||
<MenuItem value="">(Any channel)</MenuItem>
|
<MenuItem value="">(Any channel)</MenuItem>
|
||||||
{channels.map(ch => (<MenuItem key={ch.id} value={ch.id}>{ch.name}</MenuItem>))}
|
{channels.map((channel) => (
|
||||||
|
<MenuItem key={channel.id} value={channel.id}>{channel.name}</MenuItem>
|
||||||
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -778,7 +800,7 @@ const ServerSettings = () => {
|
|||||||
displayEmpty
|
displayEmpty
|
||||||
>
|
>
|
||||||
<MenuItem value="" disabled>Select a channel</MenuItem>
|
<MenuItem value="" disabled>Select a channel</MenuItem>
|
||||||
{channels.map(channel => (
|
{channels.map((channel) => (
|
||||||
<MenuItem key={channel.id} value={channel.id}>{channel.name}</MenuItem>
|
<MenuItem key={channel.id} value={channel.id}>{channel.name}</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
@@ -819,7 +841,7 @@ const ServerSettings = () => {
|
|||||||
displayEmpty
|
displayEmpty
|
||||||
>
|
>
|
||||||
<MenuItem value="" disabled>Select a channel</MenuItem>
|
<MenuItem value="" disabled>Select a channel</MenuItem>
|
||||||
{channels.map(channel => (
|
{channels.map((channel) => (
|
||||||
<MenuItem key={channel.id} value={channel.id}>{channel.name}</MenuItem>
|
<MenuItem key={channel.id} value={channel.id}>{channel.name}</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
Reference in New Issue
Block a user