@@ -18,67 +18,61 @@ export default class ModApproveComponent implements ComponentHandler<string> {
1818 // finishes via editReply/followUp instead of reply/updateMessage.
1919 await client . api . interactions . deferMessageUpdate ( interaction . id , interaction . token ) ;
2020
21- const [ question ] = await getContext ( ) . db < AmaQuestions [ ] > `
22- SELECT * FROM ama_questions WHERE id = ${ questionId }
23- ` ;
24-
25- if ( ! question ) {
26- await client . api . interactions . followUp ( interaction . application_id , interaction . token , {
27- content : 'Question not found. It may have been deleted.' ,
28- flags : MessageFlags . Ephemeral ,
29- } ) ;
30- return ;
31- }
32-
33- const [ session ] = await getContext ( ) . db < AmaSessions [ ] > `
34- SELECT * FROM ama_sessions WHERE id = ${ question . amaId }
35- ` ;
36-
37- if ( ! session ) {
38- throw new Error ( `No AMA session found for id ${ question . amaId } ` ) ;
39- }
40-
41- if ( session . ended ) {
42- await client . api . interactions . followUp ( interaction . application_id , interaction . token , {
43- content : 'This AMA session has ended.' ,
44- flags : MessageFlags . Ephemeral ,
45- } ) ;
46- return ;
47- }
48-
49- // Determine the next queue up front so the claim below can move the row straight to its target
50- // state; this also doubles as a lock — only one concurrent click can win the row.
51- const nextQueue = getNextQueue ( CurrentlyInQueue . mod , session ) ;
52- const targetState = nextQueue ?. kind === CurrentlyInQueue . guest ? 'PENDING_GUEST_REVIEW' : 'APPROVED' ;
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+ }
5333
54- const [ claimed ] = await getContext ( ) . db < AmaQuestions [ ] > `
55- UPDATE ama_questions
56- SET state = ${ targetState } , updated_at = now()
57- WHERE id = ${ question . id } AND state = 'PENDING_MOD_REVIEW'
58- RETURNING *
59- ` ;
34+ const [ session ] = await getContext ( ) . db < AmaSessions [ ] > `
35+ SELECT * FROM ama_sessions WHERE id = ${ question . amaId }
36+ ` ;
6037
61- if ( ! claimed ) {
62- await client . api . interactions . followUp ( interaction . application_id , interaction . token , {
63- content : 'This question was already handled by another moderator.' ,
64- flags : MessageFlags . Ephemeral ,
65- } ) ;
66- return ;
67- }
38+ if ( ! session ) {
39+ throw new Error ( `No AMA session found for id ${ question . amaId } ` ) ;
40+ }
6841
69- // Get user details from the interaction
70- const user = await client . api . users . get ( question . authorId ) ;
71- const member = interaction . guild_id
72- ? await client . api . guilds . getMember ( interaction . guild_id , question . authorId ) . catch ( ( ) => undefined )
73- : undefined ;
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+ }
7449
75- // Attachments aren't persisted on the row, so we carry them forward off the source message; the
76- // question text itself comes straight from the DB (the source message's text has a footer baked in).
77- const attachments = interaction . message . attachments ?? [ ] ;
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+ const nextQueue = getNextQueue ( CurrentlyInQueue . mod , session ) ;
61+
62+ // Post first, claim second: if the post throws, the row is never touched and stays
63+ // PENDING_MOD_REVIEW, so the button remains retryable. If we lose a claim race after posting
64+ // (another moderator got there first), we clean up the message we just created instead of
65+ // leaving a stray duplicate.
66+ const reportLostRace = async ( channelId : string , messageId : string ) => {
67+ // eslint-disable-next-line promise/prefer-await-to-then
68+ void client . api . channels . deleteMessage ( channelId , messageId ) . catch ( ( ) => null ) ;
69+ await client . api . interactions . followUp ( interaction . application_id , interaction . token , {
70+ content : 'This question was already handled by another moderator.' ,
71+ flags : MessageFlags . Ephemeral ,
72+ } ) ;
73+ } ;
7874
79- try {
8075 if ( nextQueue ?. kind === CurrentlyInQueue . guest ) {
81- // Post to guest queue
8276 const msg = await postToGuestQueue ( {
8377 attachments,
8478 content : question . content ,
@@ -88,11 +82,18 @@ export default class ModApproveComponent implements ComponentHandler<string> {
8882 user,
8983 } ) ;
9084
91- await getContext ( ) . db `
92- UPDATE ama_questions SET guest_queue_message_id = ${ msg . id } WHERE id = ${ question . id }
85+ const [ claimed ] = await getContext ( ) . db < AmaQuestions [ ] > `
86+ UPDATE ama_questions
87+ SET state = 'PENDING_GUEST_REVIEW', guest_queue_message_id = ${ msg . id } , updated_at = now()
88+ WHERE id = ${ question . id } AND state = 'PENDING_MOD_REVIEW'
89+ RETURNING *
9390 ` ;
91+
92+ if ( ! claimed ) {
93+ await reportLostRace ( session . guestQueueId ! , msg . id ) ;
94+ return ;
95+ }
9496 } else {
95- // Post directly to answers channel
9697 const msg = await postToAnswersChannel ( {
9798 attachments,
9899 content : question . content ,
@@ -102,9 +103,17 @@ export default class ModApproveComponent implements ComponentHandler<string> {
102103 user,
103104 } ) ;
104105
105- await getContext ( ) . db `
106- UPDATE ama_questions SET answers_message_id = ${ msg . id } WHERE id = ${ question . id }
106+ const [ claimed ] = await getContext ( ) . db < AmaQuestions [ ] > `
107+ UPDATE ama_questions
108+ SET state = 'APPROVED', answers_message_id = ${ msg . id } , updated_at = now()
109+ WHERE id = ${ question . id } AND state = 'PENDING_MOD_REVIEW'
110+ RETURNING *
107111 ` ;
112+
113+ if ( ! claimed ) {
114+ await reportLostRace ( session . answersChannelId , msg . id ) ;
115+ return ;
116+ }
108117 }
109118
110119 // Update the message to show it was approved
@@ -125,9 +134,6 @@ export default class ModApproveComponent implements ComponentHandler<string> {
125134 ] ,
126135 } ) ;
127136 } catch ( error ) {
128- // The row is already claimed (state flipped) at this point; if the queue post itself failed, the
129- // question is stuck claimed with no downstream message and needs manual follow-up — logged loudly
130- // here rather than attempting a rollback/saga for what should be a rare failure mode.
131137 getContext ( ) . logger . error ( { error, questionId } , 'Failed to approve question' ) ;
132138 await client . api . interactions . followUp ( interaction . application_id , interaction . token , {
133139 content : 'Failed to approve question. Please try again.' ,
0 commit comments