Skip to content

Commit a28325f

Browse files
committed
feat: flagged queue
1 parent 5b11ee8 commit a28325f

3 files changed

Lines changed: 357 additions & 0 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { getContext } from '@chatsift/backend-core';
2+
import type { AmaQuestions, AmaSessions } from '@chatsift/db';
3+
import type { APIMessageComponentInteraction } from '@discordjs/core';
4+
import { ButtonStyle, ComponentType, MessageFlags } from '@discordjs/core';
5+
import { client } from '../lib/client.js';
6+
import type { ComponentHandler } from '../lib/components.js';
7+
import { CurrentlyInQueue, getNextQueue, postToAnswersChannel, postToGuestQueue } from '../lib/queues.js';
8+
9+
export default class FlaggedApproveComponent implements ComponentHandler<string> {
10+
public readonly name = 'flagged-approve';
11+
12+
public readonly stateStore = null;
13+
14+
public async handle(interaction: APIMessageComponentInteraction, questionIdStr: string) {
15+
const questionId = Number.parseInt(questionIdStr, 10);
16+
17+
// Ack within Discord's 3s window before doing any DB/REST work below; everything past this point
18+
// finishes via editReply/followUp instead of reply/updateMessage.
19+
await client.api.interactions.deferMessageUpdate(interaction.id, interaction.token);
20+
21+
try {
22+
const [question] = await getContext().db<AmaQuestions[]>`
23+
SELECT * FROM ama_questions WHERE id = ${questionId}
24+
`;
25+
26+
if (!question) {
27+
await client.api.interactions.followUp(interaction.application_id, interaction.token, {
28+
content: 'Question not found. It may have been deleted.',
29+
flags: MessageFlags.Ephemeral,
30+
});
31+
return;
32+
}
33+
34+
const [session] = await getContext().db<AmaSessions[]>`
35+
SELECT * FROM ama_sessions WHERE id = ${question.amaId}
36+
`;
37+
38+
if (!session) {
39+
throw new Error(`No AMA session found for id ${question.amaId}`);
40+
}
41+
42+
if (session.ended) {
43+
await client.api.interactions.followUp(interaction.application_id, interaction.token, {
44+
content: 'This AMA session has ended.',
45+
flags: MessageFlags.Ephemeral,
46+
});
47+
return;
48+
}
49+
50+
// Get user details from the interaction
51+
const user = await client.api.users.get(question.authorId);
52+
const member = interaction.guild_id
53+
? await client.api.guilds.getMember(interaction.guild_id, question.authorId).catch(() => undefined)
54+
: undefined;
55+
56+
// Attachments aren't persisted on the row, so we carry them forward off the source message; the
57+
// question text itself comes straight from the DB (the source message's text has a footer baked in).
58+
const attachments = interaction.message.attachments ?? [];
59+
60+
// A flag is a side-branch off the mod queue, so clearing it resumes the normal pipeline at the
61+
// stage that would've followed mod approval: guest queue if configured, otherwise straight to answers.
62+
const nextQueue = getNextQueue(CurrentlyInQueue.mod, session);
63+
64+
// Post first, claim second: if the post throws, the row is never touched and stays FLAGGED, so
65+
// the button remains retryable. If we lose a claim race after posting (another moderator got
66+
// there first), we clean up the message we just created instead of leaving a stray duplicate.
67+
const reportLostRace = async (channelId: string, messageId: string) => {
68+
// eslint-disable-next-line promise/prefer-await-to-then
69+
void client.api.channels.deleteMessage(channelId, messageId).catch(() => null);
70+
await client.api.interactions.followUp(interaction.application_id, interaction.token, {
71+
content: 'This question was already handled by another moderator.',
72+
flags: MessageFlags.Ephemeral,
73+
});
74+
};
75+
76+
if (nextQueue?.kind === CurrentlyInQueue.guest) {
77+
const msg = await postToGuestQueue({
78+
attachments,
79+
content: question.content,
80+
member,
81+
question,
82+
session,
83+
user,
84+
});
85+
86+
const [claimed] = await getContext().db<AmaQuestions[]>`
87+
UPDATE ama_questions
88+
SET state = 'PENDING_GUEST_REVIEW', guest_queue_message_id = ${msg.id}, updated_at = now()
89+
WHERE id = ${question.id} AND state = 'FLAGGED'
90+
RETURNING *
91+
`;
92+
93+
if (!claimed) {
94+
await reportLostRace(session.guestQueueId!, msg.id);
95+
return;
96+
}
97+
} else {
98+
const msg = await postToAnswersChannel({
99+
attachments,
100+
content: question.content,
101+
member,
102+
question,
103+
session,
104+
user,
105+
});
106+
107+
const [claimed] = await getContext().db<AmaQuestions[]>`
108+
UPDATE ama_questions
109+
SET state = 'APPROVED', answers_message_id = ${msg.id}, updated_at = now()
110+
WHERE id = ${question.id} AND state = 'FLAGGED'
111+
RETURNING *
112+
`;
113+
114+
if (!claimed) {
115+
await reportLostRace(session.answersChannelId, msg.id);
116+
return;
117+
}
118+
}
119+
120+
// Update the message to show it was approved
121+
await client.api.interactions.editReply(interaction.application_id, interaction.token, {
122+
components: [
123+
{
124+
type: ComponentType.ActionRow,
125+
components: [
126+
{
127+
type: ComponentType.Button,
128+
style: ButtonStyle.Success,
129+
label: '✅ Approved',
130+
custom_id: 'approved-disabled',
131+
disabled: true,
132+
},
133+
],
134+
},
135+
],
136+
});
137+
138+
getContext().logger.info({ questionId, amaId: question.amaId }, 'Flagged question approved by moderator');
139+
} catch (error) {
140+
getContext().logger.error({ error, questionId }, 'Failed to approve flagged question');
141+
await client.api.interactions.followUp(interaction.application_id, interaction.token, {
142+
content: 'Failed to approve question. Please try again.',
143+
flags: MessageFlags.Ephemeral,
144+
});
145+
}
146+
}
147+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { getContext } from '@chatsift/backend-core';
2+
import type { AmaQuestions, AmaSessions } from '@chatsift/db';
3+
import type { APIMessageComponentInteraction } from '@discordjs/core';
4+
import { ButtonStyle, ComponentType, MessageFlags } from '@discordjs/core';
5+
import { client } from '../lib/client.js';
6+
import type { ComponentHandler } from '../lib/components.js';
7+
8+
export default class FlaggedDenyComponent implements ComponentHandler<string> {
9+
public readonly name = 'flagged-deny';
10+
11+
public readonly stateStore = null;
12+
13+
public async handle(interaction: APIMessageComponentInteraction, questionIdStr: string) {
14+
const questionId = Number.parseInt(questionIdStr, 10);
15+
16+
// Ack within Discord's 3s window before doing any DB/REST work below; everything past this point
17+
// finishes via editReply/followUp instead of reply/updateMessage.
18+
await client.api.interactions.deferMessageUpdate(interaction.id, interaction.token);
19+
20+
try {
21+
// Fetch the question to verify it exists
22+
const [question] = await getContext().db<AmaQuestions[]>`
23+
SELECT * FROM ama_questions WHERE id = ${questionId}
24+
`;
25+
26+
if (!question) {
27+
await client.api.interactions.followUp(interaction.application_id, interaction.token, {
28+
content: 'Question not found. It may have been deleted.',
29+
flags: MessageFlags.Ephemeral,
30+
});
31+
return;
32+
}
33+
34+
const [session] = await getContext().db<AmaSessions[]>`
35+
SELECT * FROM ama_sessions WHERE id = ${question.amaId}
36+
`;
37+
38+
if (!session) {
39+
throw new Error(`No AMA session found for id ${question.amaId}`);
40+
}
41+
42+
if (session.ended) {
43+
await client.api.interactions.followUp(interaction.application_id, interaction.token, {
44+
content: 'This AMA session has ended.',
45+
flags: MessageFlags.Ephemeral,
46+
});
47+
return;
48+
}
49+
50+
// Denial from the flagged queue is terminal. Only denies from FLAGGED so a concurrent
51+
// approve/deny can't both win.
52+
const [denied] = await getContext().db<AmaQuestions[]>`
53+
UPDATE ama_questions
54+
SET state = 'DENIED', updated_at = now()
55+
WHERE id = ${question.id} AND state = 'FLAGGED'
56+
RETURNING *
57+
`;
58+
59+
if (!denied) {
60+
await client.api.interactions.followUp(interaction.application_id, interaction.token, {
61+
content: 'This question was already handled by another moderator.',
62+
flags: MessageFlags.Ephemeral,
63+
});
64+
return;
65+
}
66+
67+
// Update the message to show it was denied
68+
await client.api.interactions.editReply(interaction.application_id, interaction.token, {
69+
components: [
70+
{
71+
type: ComponentType.ActionRow,
72+
components: [
73+
{
74+
type: ComponentType.Button,
75+
style: ButtonStyle.Danger,
76+
label: '❌ Denied',
77+
custom_id: 'denied-disabled',
78+
disabled: true,
79+
},
80+
],
81+
},
82+
],
83+
});
84+
85+
getContext().logger.info({ questionId, amaId: question.amaId }, 'Flagged question denied by moderator');
86+
} catch (error) {
87+
getContext().logger.error({ err: error, questionId }, 'Failed to deny flagged question');
88+
await client.api.interactions.followUp(interaction.application_id, interaction.token, {
89+
content: 'Failed to deny question. Please try again.',
90+
flags: MessageFlags.Ephemeral,
91+
});
92+
}
93+
}
94+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { getContext } from '@chatsift/backend-core';
2+
import type { AmaQuestions, AmaSessions } from '@chatsift/db';
3+
import type { APIMessageComponentInteraction } from '@discordjs/core';
4+
import { ButtonStyle, ComponentType, MessageFlags } from '@discordjs/core';
5+
import { client } from '../lib/client.js';
6+
import type { ComponentHandler } from '../lib/components.js';
7+
import { postToFlaggedQueue } from '../lib/queues.js';
8+
9+
export default class ModFlagComponent implements ComponentHandler<string> {
10+
public readonly name = 'mod-flag';
11+
12+
public readonly stateStore = null;
13+
14+
public async handle(interaction: APIMessageComponentInteraction, questionIdStr: string) {
15+
const questionId = Number.parseInt(questionIdStr, 10);
16+
17+
// Ack within Discord's 3s window before doing any DB/REST work below; everything past this point
18+
// finishes via editReply/followUp instead of reply/updateMessage.
19+
await client.api.interactions.deferMessageUpdate(interaction.id, interaction.token);
20+
21+
try {
22+
const [question] = await getContext().db<AmaQuestions[]>`
23+
SELECT * FROM ama_questions WHERE id = ${questionId}
24+
`;
25+
26+
if (!question) {
27+
await client.api.interactions.followUp(interaction.application_id, interaction.token, {
28+
content: 'Question not found. It may have been deleted.',
29+
flags: MessageFlags.Ephemeral,
30+
});
31+
return;
32+
}
33+
34+
const [session] = await getContext().db<AmaSessions[]>`
35+
SELECT * FROM ama_sessions WHERE id = ${question.amaId}
36+
`;
37+
38+
if (!session) {
39+
throw new Error(`No AMA session found for id ${question.amaId}`);
40+
}
41+
42+
if (session.ended) {
43+
await client.api.interactions.followUp(interaction.application_id, interaction.token, {
44+
content: 'This AMA session has ended.',
45+
flags: MessageFlags.Ephemeral,
46+
});
47+
return;
48+
}
49+
50+
const user = await client.api.users.get(question.authorId);
51+
const member = interaction.guild_id
52+
? await client.api.guilds.getMember(interaction.guild_id, question.authorId).catch(() => undefined)
53+
: undefined;
54+
55+
// Attachments aren't persisted on the row, so we carry them forward off the source message; the
56+
// question text itself comes straight from the DB (the source message's text has a footer baked in).
57+
const attachments = interaction.message.attachments ?? [];
58+
59+
// Post first, claim second: if the post throws, the row is never touched and stays
60+
// PENDING_MOD_REVIEW, so the button remains retryable. If we lose a claim race after posting
61+
// (another moderator got there first), we clean up the message we just created instead of
62+
// leaving a stray duplicate.
63+
const msg = await postToFlaggedQueue({
64+
attachments,
65+
content: question.content,
66+
member,
67+
question,
68+
session,
69+
user,
70+
});
71+
72+
const [claimed] = await getContext().db<AmaQuestions[]>`
73+
UPDATE ama_questions
74+
SET state = 'FLAGGED', flagged_queue_message_id = ${msg.id}, updated_at = now()
75+
WHERE id = ${question.id} AND state = 'PENDING_MOD_REVIEW'
76+
RETURNING *
77+
`;
78+
79+
if (!claimed) {
80+
// eslint-disable-next-line promise/prefer-await-to-then
81+
void client.api.channels.deleteMessage(session.flaggedQueueId!, msg.id).catch(() => null);
82+
await client.api.interactions.followUp(interaction.application_id, interaction.token, {
83+
content: 'This question was already handled by another moderator.',
84+
flags: MessageFlags.Ephemeral,
85+
});
86+
return;
87+
}
88+
89+
// Update the message to show it was flagged
90+
await client.api.interactions.editReply(interaction.application_id, interaction.token, {
91+
components: [
92+
{
93+
type: ComponentType.ActionRow,
94+
components: [
95+
{
96+
type: ComponentType.Button,
97+
style: ButtonStyle.Secondary,
98+
label: '⚠️ Flagged',
99+
custom_id: 'flagged-disabled',
100+
disabled: true,
101+
},
102+
],
103+
},
104+
],
105+
});
106+
107+
getContext().logger.info({ questionId, amaId: question.amaId }, 'Question flagged by moderator');
108+
} catch (error) {
109+
getContext().logger.error({ error, questionId }, 'Failed to flag question');
110+
await client.api.interactions.followUp(interaction.application_id, interaction.token, {
111+
content: 'Failed to flag question. Please try again.',
112+
flags: MessageFlags.Ephemeral,
113+
});
114+
}
115+
}
116+
}

0 commit comments

Comments
 (0)