bug fixes
This commit is contained in:
103
backend/index.js
103
backend/index.js
@@ -1216,6 +1216,75 @@ app.post('/api/servers/:guildId/admin-logs-settings', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// REACTION ROLES: CRUD
|
||||
app.get('/api/servers/:guildId/reaction-roles', async (req, res) => {
|
||||
try {
|
||||
const { guildId } = req.params;
|
||||
const rows = await pgClient.listReactionRoles(guildId);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error('Error listing reaction roles:', err);
|
||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/servers/:guildId/reaction-roles', async (req, res) => {
|
||||
try {
|
||||
const { guildId } = req.params;
|
||||
const { channelId, name, embed, buttons, messageId } = req.body || {};
|
||||
if (!channelId || !name || !embed || !Array.isArray(buttons) || buttons.length === 0) {
|
||||
return res.status(400).json({ success: false, message: 'channelId, name, embed, and at least one button are required' });
|
||||
}
|
||||
const created = await pgClient.createReactionRole({ guildId, channelId, name, embed, buttons, messageId });
|
||||
// publish SSE
|
||||
publishEvent(guildId, 'reactionRolesUpdate', { action: 'create', reactionRole: created });
|
||||
res.json({ success: true, reactionRole: created });
|
||||
} catch (err) {
|
||||
console.error('Error creating reaction role:', err && err.message ? err.message : err);
|
||||
// If the pg helper threw a validation error, return 400 with message
|
||||
if (err && err.message && err.message.startsWith('Invalid reaction role payload')) {
|
||||
return res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/servers/:guildId/reaction-roles/:id', async (req, res) => {
|
||||
try {
|
||||
const { guildId, id } = req.params;
|
||||
const updates = req.body || {};
|
||||
const existing = await pgClient.getReactionRole(id);
|
||||
if (!existing || existing.guild_id !== guildId) return res.status(404).json({ success: false, message: 'Not found' });
|
||||
const mapped = {
|
||||
channel_id: updates.channelId || existing.channel_id,
|
||||
message_id: typeof updates.messageId !== 'undefined' ? updates.messageId : existing.message_id,
|
||||
name: typeof updates.name !== 'undefined' ? updates.name : existing.name,
|
||||
embed: typeof updates.embed !== 'undefined' ? updates.embed : existing.embed,
|
||||
buttons: typeof updates.buttons !== 'undefined' ? updates.buttons : existing.buttons
|
||||
};
|
||||
const updated = await pgClient.updateReactionRole(id, mapped);
|
||||
publishEvent(guildId, 'reactionRolesUpdate', { action: 'update', reactionRole: updated });
|
||||
res.json({ success: true, reactionRole: updated });
|
||||
} catch (err) {
|
||||
console.error('Error updating reaction role:', err);
|
||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/servers/:guildId/reaction-roles/:id', async (req, res) => {
|
||||
try {
|
||||
const { guildId, id } = req.params;
|
||||
const existing = await pgClient.getReactionRole(id);
|
||||
if (!existing || existing.guild_id !== guildId) return res.status(404).json({ success: false, message: 'Not found' });
|
||||
await pgClient.deleteReactionRole(id);
|
||||
publishEvent(guildId, 'reactionRolesUpdate', { action: 'delete', id });
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Error deleting reaction role:', err);
|
||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/servers/:guildId/admin-logs', async (req, res) => {
|
||||
try {
|
||||
const { guildId } = req.params;
|
||||
@@ -1537,6 +1606,40 @@ app.post('/internal/test-live', express.json(), async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Internal: ask bot to publish a reaction role message for a reaction role ID
|
||||
app.post('/internal/publish-reaction-role', express.json(), async (req, res) => {
|
||||
try {
|
||||
// If BOT_SECRET is configured, require the request to include it in the header
|
||||
const requiredSecret = process.env.BOT_SECRET;
|
||||
if (requiredSecret) {
|
||||
const provided = (req.get('x-bot-secret') || req.get('X-Bot-Secret') || '').toString();
|
||||
if (!provided || provided !== requiredSecret) {
|
||||
console.warn('/internal/publish-reaction-role: missing or invalid x-bot-secret header');
|
||||
return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||
}
|
||||
}
|
||||
|
||||
const { guildId, id } = req.body || {};
|
||||
if (!guildId || !id) return res.status(400).json({ success: false, message: 'guildId and id required' });
|
||||
const rr = await pgClient.getReactionRole(id);
|
||||
if (!rr || rr.guild_id !== guildId) return res.status(404).json({ success: false, message: 'Not found' });
|
||||
const result = await bot.postReactionRoleMessage(guildId, rr);
|
||||
if (result && result.success) {
|
||||
// update db already attempted by bot; publish SSE update
|
||||
publishEvent(guildId, 'reactionRolesUpdate', { action: 'posted', id, messageId: result.messageId });
|
||||
} else {
|
||||
// If the channel or message cannot be created because it no longer exists, remove the DB entry
|
||||
if (result && result.message && result.message.toLowerCase && (result.message.includes('Channel not found') || result.message.includes('Guild not found'))) {
|
||||
try { await pgClient.deleteReactionRole(id); publishEvent(guildId, 'reactionRolesUpdate', { action: 'delete', id }); } catch(e){}
|
||||
}
|
||||
}
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
console.error('Error in /internal/publish-reaction-role:', e);
|
||||
res.status(500).json({ success: false, message: 'Internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(port, host, () => {
|
||||
console.log(`Server is running on ${host}:${port}`);
|
||||
});
|
||||
|
||||
4
backend/jest.config.js
Normal file
4
backend/jest.config.js
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
testTimeout: 20000,
|
||||
};
|
||||
3689
backend/package-lock.json
generated
3689
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"test": "jest --runInBand",
|
||||
"start": "node index.js",
|
||||
"dev": "nodemon index.js"
|
||||
},
|
||||
@@ -22,6 +22,8 @@
|
||||
"node-fetch": "^2.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.3"
|
||||
"nodemon": "^3.1.3",
|
||||
"jest": "^29.6.1",
|
||||
"supertest": "^6.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
126
backend/pg.js
126
backend/pg.js
@@ -57,6 +57,19 @@ async function ensureSchema() {
|
||||
timestamp TIMESTAMP WITH TIME ZONE DEFAULT now()
|
||||
);
|
||||
`);
|
||||
|
||||
await p.query(`
|
||||
CREATE TABLE IF NOT EXISTS reaction_roles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
guild_id TEXT NOT NULL,
|
||||
channel_id TEXT NOT NULL,
|
||||
message_id TEXT, -- message created in channel (optional until created)
|
||||
name TEXT NOT NULL,
|
||||
embed JSONB NOT NULL,
|
||||
buttons JSONB NOT NULL, -- array of { customId, label, roleId }
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
// Servers
|
||||
@@ -132,6 +145,116 @@ async function deleteAllAdminLogs(guildId) {
|
||||
await p.query('DELETE FROM admin_logs WHERE guild_id = $1', [guildId]);
|
||||
}
|
||||
|
||||
// Reaction Roles
|
||||
async function listReactionRoles(guildId) {
|
||||
const p = initPool();
|
||||
const res = await p.query('SELECT id, guild_id, channel_id, message_id, name, embed, buttons, created_at FROM reaction_roles WHERE guild_id = $1 ORDER BY created_at DESC', [guildId]);
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
async function getReactionRole(id) {
|
||||
const p = initPool();
|
||||
const res = await p.query('SELECT id, guild_id, channel_id, message_id, name, embed, buttons, created_at FROM reaction_roles WHERE id = $1', [id]);
|
||||
return res.rows[0] || null;
|
||||
}
|
||||
|
||||
async function createReactionRole(rr) {
|
||||
const p = initPool();
|
||||
const q = `INSERT INTO reaction_roles(guild_id, channel_id, message_id, name, embed, buttons) VALUES($1,$2,$3,$4,$5,$6) RETURNING *`;
|
||||
// Ensure embed/buttons are proper JSON objects/arrays (some clients may send them as JSON strings)
|
||||
let embed = rr.embed || {};
|
||||
let buttons = rr.buttons || [];
|
||||
// If the payload is double-encoded (string containing a JSON string), keep parsing until it's a non-string
|
||||
try {
|
||||
while (typeof embed === 'string') {
|
||||
embed = JSON.parse(embed);
|
||||
}
|
||||
} catch (e) {
|
||||
// fall through and let Postgres reject invalid JSON if it's still malformed
|
||||
}
|
||||
try {
|
||||
while (typeof buttons === 'string') {
|
||||
buttons = JSON.parse(buttons);
|
||||
}
|
||||
// If buttons is an array but elements are themselves JSON strings, parse each element
|
||||
if (Array.isArray(buttons)) {
|
||||
buttons = buttons.map(b => {
|
||||
if (typeof b === 'string') {
|
||||
try {
|
||||
let parsed = b;
|
||||
while (typeof parsed === 'string') {
|
||||
parsed = JSON.parse(parsed);
|
||||
}
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
return b; // leave as-is
|
||||
}
|
||||
}
|
||||
return b;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// leave as-is
|
||||
}
|
||||
|
||||
// Validate shapes before inserting to DB to avoid Postgres JSON errors
|
||||
if (!embed || typeof embed !== 'object' || Array.isArray(embed)) {
|
||||
throw new Error('Invalid reaction role payload: `embed` must be a JSON object');
|
||||
}
|
||||
if (!Array.isArray(buttons) || buttons.length === 0 || !buttons.every(b => b && typeof b === 'object')) {
|
||||
throw new Error('Invalid reaction role payload: `buttons` must be a non-empty array of objects');
|
||||
}
|
||||
|
||||
const res = await p.query(q, [rr.guildId, rr.channelId, rr.messageId || null, rr.name, embed, buttons]);
|
||||
return res.rows[0];
|
||||
}
|
||||
|
||||
async function updateReactionRole(id, updates) {
|
||||
const p = initPool();
|
||||
const parts = [];
|
||||
const vals = [];
|
||||
let idx = 1;
|
||||
for (const k of ['channel_id','message_id','name','embed','buttons']) {
|
||||
if (typeof updates[k] !== 'undefined') {
|
||||
parts.push(`${k} = $${idx}`);
|
||||
// coerce JSON strings to objects for JSONB columns
|
||||
if ((k === 'embed' || k === 'buttons') && typeof updates[k] === 'string') {
|
||||
try {
|
||||
vals.push(JSON.parse(updates[k]));
|
||||
} catch (e) {
|
||||
vals.push(updates[k]);
|
||||
}
|
||||
} else {
|
||||
vals.push(updates[k]);
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
if (parts.length === 0) return getReactionRole(id);
|
||||
const q = `UPDATE reaction_roles SET ${parts.join(', ')} WHERE id = $${idx} RETURNING *`;
|
||||
vals.push(id);
|
||||
// Validate embed/buttons if they are being updated
|
||||
if (typeof updates.embed !== 'undefined') {
|
||||
const embed = vals[parts.indexOf('embed = $' + (parts.findIndex(p => p.startsWith('embed')) + 1))];
|
||||
if (!embed || typeof embed !== 'object' || Array.isArray(embed)) {
|
||||
throw new Error('Invalid reaction role payload: `embed` must be a JSON object');
|
||||
}
|
||||
}
|
||||
if (typeof updates.buttons !== 'undefined') {
|
||||
const buttons = vals[parts.indexOf('buttons = $' + (parts.findIndex(p => p.startsWith('buttons')) + 1))];
|
||||
if (!Array.isArray(buttons) || buttons.length === 0 || !buttons.every(b => b && typeof b === 'object')) {
|
||||
throw new Error('Invalid reaction role payload: `buttons` must be a non-empty array of objects');
|
||||
}
|
||||
}
|
||||
const res = await p.query(q, vals);
|
||||
return res.rows[0] || null;
|
||||
}
|
||||
|
||||
async function deleteReactionRole(id) {
|
||||
const p = initPool();
|
||||
await p.query('DELETE FROM reaction_roles WHERE id = $1', [id]);
|
||||
}
|
||||
|
||||
// Users
|
||||
async function getUserData(discordId) {
|
||||
const p = initPool();
|
||||
@@ -145,4 +268,5 @@ async function upsertUserData(discordId, data) {
|
||||
await p.query(`INSERT INTO users(discord_id, data) VALUES($1, $2) ON CONFLICT (discord_id) DO UPDATE SET data = $2`, [discordId, data]);
|
||||
}
|
||||
|
||||
module.exports = { initPool, ensureSchema, getServerSettings, upsertServerSettings, listInvites, addInvite, deleteInvite, getUserData, upsertUserData, addAdminLog, getAdminLogs, getAdminLogsByAction, deleteAdminLog, deleteAllAdminLogs };
|
||||
module.exports = { initPool, ensureSchema, getServerSettings, upsertServerSettings, listInvites, addInvite, deleteInvite, getUserData, upsertUserData, addAdminLog, getAdminLogs, getAdminLogsByAction, deleteAdminLog, deleteAllAdminLogs, listReactionRoles, getReactionRole, createReactionRole, updateReactionRole, deleteReactionRole };
|
||||
|
||||
|
||||
62
backend/tests/pg.reactionroles.test.js
Normal file
62
backend/tests/pg.reactionroles.test.js
Normal file
@@ -0,0 +1,62 @@
|
||||
const pg = require('../pg');
|
||||
|
||||
// These tests are optional: they run only if TEST_DATABASE_URL is set in env.
|
||||
// They are intentionally lightweight and will skip when not configured.
|
||||
|
||||
const TEST_DB = process.env.TEST_DATABASE_URL;
|
||||
|
||||
describe('pg reaction_roles helpers (integration)', () => {
|
||||
if (!TEST_DB) {
|
||||
test('skipped - no TEST_DATABASE_URL', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.DATABASE_URL = TEST_DB;
|
||||
await pg.initPool();
|
||||
await pg.ensureSchema();
|
||||
});
|
||||
|
||||
let created;
|
||||
|
||||
test('createReactionRole -> returns created record', async () => {
|
||||
const rr = {
|
||||
guildId: 'test-guild',
|
||||
channelId: 'test-channel',
|
||||
name: 'Test RR',
|
||||
embed: { title: 'Hello' },
|
||||
buttons: [{ label: 'One', roleId: 'role1' }]
|
||||
};
|
||||
created = await pg.createReactionRole(rr);
|
||||
expect(created).toBeTruthy();
|
||||
expect(created.id).toBeGreaterThan(0);
|
||||
expect(created.guild_id).toBe('test-guild');
|
||||
});
|
||||
|
||||
test('listReactionRoles -> includes created', async () => {
|
||||
const list = await pg.listReactionRoles('test-guild');
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
const found = list.find(r => r.id === created.id);
|
||||
expect(found).toBeTruthy();
|
||||
});
|
||||
|
||||
test('getReactionRole -> returns record by id', async () => {
|
||||
const got = await pg.getReactionRole(created.id);
|
||||
expect(got).toBeTruthy();
|
||||
expect(got.id).toBe(created.id);
|
||||
});
|
||||
|
||||
test('updateReactionRole -> updates and returns', async () => {
|
||||
const updated = await pg.updateReactionRole(created.id, { name: 'Updated' });
|
||||
expect(updated).toBeTruthy();
|
||||
expect(updated.name).toBe('Updated');
|
||||
});
|
||||
|
||||
test('deleteReactionRole -> removes record', async () => {
|
||||
await pg.deleteReactionRole(created.id);
|
||||
const after = await pg.getReactionRole(created.id);
|
||||
expect(after).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user