Skip to content

Commit 6376a6d

Browse files
HugoGresseclaudefteychene
authored
Review fix (#273)
* feat(whatsapp): Part 1 — GreenAPI setup + test-send page Track-management messaging will run over WhatsApp via GreenAPI. This first part wires the plumbing: - Event gains greenApiInstanceId / greenApiToken (persisted through mapEventDevSettingsFormToMutateObject, guarded so other forms don't wipe them) - New admin page /whatsapp: save GreenAPI credentials, and once set, send a single test message to validate the setup - Reachable from the Integration & API page via an "Open" link - Backend POST /v1/:eventId/whatsapp/send-test (apiKey-protected) calls GreenAPI server-side so the token never reaches the browser Part 2 will add the per-track "ready" -> overall GO flow on top of this. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(whatsapp): Part 2 — interactive track buttons, ready loop, GO Drive track management over GreenAPI interactive buttons: - SendInteractiveButtonsReply with a 3-button-per-message limit; tracks are split across as many messages as needed - A GreenAPI incoming webhook (/v1/whatsapp/webhook, mapped to the event by instance id) receives a button tap, marks that track ready, edits the now-button-less message to a status recap, and re-offers buttons for the tracks still pending in that group - When every track is ready, a GO message is sent to the same chat (once) - Readiness is persisted per event (events/{id}/whatsapp/current) so the admin page can poll a status endpoint and show progress Flow logic lives in trackFlow.ts with injected senders and is covered by unit tests (chunking, ready/edit/re-offer, single GO, idempotent press). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(whatsapp): guard undefined tracks in track management status status?.tracks could be undefined (optional chaining stopped at status), crashing on .filter. Default to an empty list before reading it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(whatsapp): correct webhook URL formatting + clarify chat format - normalise API_URL trailing slash so the webhook URL isn't mangled (was producing "...frv1/whatsapp/webhook") - guide the shared chat input toward a chatId ending with @c.us / @g.us Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(whatsapp): drop unsupported 'type' field on interactive buttons GreenAPI sendInteractiveButtonsReply rejects buttons[].type ("'buttons[0].type' is not allowed"); send only buttonId + buttonText. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(whatsapp): persist shared chat + authorize the webhook - Prefill the shared chat input from the stored session chatId so the operator doesn't retype it each time. - Require an Authorization header on the incoming GreenAPI webhook, matched against the event apiKey (raw or "Bearer <key>"); reject forged button presses with 401. The page shows the value to configure in GreenAPI alongside the webhook URL. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(whatsapp): make button-tap webhook actually reachable Presses did nothing because GreenAPI was never reliably calling our webhook (URL/token not set), and the new auth check then rejected any call lacking the token. - Add an "auto-configure" path: POST .../whatsapp/configure-webhook sets the instance webhookUrl + webhookUrlToken (= event apiKey, sent back as "Authorization: Bearer <key>") + incomingWebhook=yes via GreenAPI setSettings. Frontend button passes the computed webhook URL. - Log every webhook call (typeWebhook, typeMessage, extracted button id, auth presence) so "press did nothing" is traceable in function logs. - Keep manual URL + token display as a fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(whatsapp): persist the shared chat id on the event The shared chat was only kept inside the session (written on send), so it looked unsaved. Store whatsappSharedChatId on the event doc, seed the input from it, add an explicit "Save chat" button, and also persist it when sending track buttons. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(whatsapp): status route returned {} due to Type.Any() reply schema fast-json-stringify with a bare Type.Any() response schema strips every field, so the status endpoint always returned an empty object. Give it an explicit schema (chatId/tracks/messages/goSent). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(whatsapp): use a poll instead of interactive buttons + event-scoped webhook Interactive/reply buttons don't reliably produce incoming webhooks on standard WhatsApp, so taps were never received. Switch to a WhatsApp poll (sendPoll), whose votes DO arrive as pollUpdateMessage webhooks. - One poll lists the tracks as options (up to 12 per poll, split beyond that). A vote marks the matching track ready (by option name, sticky); GO is sent once all are ready. - Webhook is now event-scoped: POST /v1/:eventId/whatsapp/webhook, so it starts with the API base + event id and needs no instance->event lookup. Auth via Authorization header == event apiKey (raw or Bearer). - configure-webhook enables pollMessageWebhook too; the page shows/sets the event-scoped URL. - Drop button send/edit/re-offer logic and findEventByInstanceId; tests updated for the poll flow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: Add automation of track panel after go * refacto: Rework the panels infos feedback on track mngt * Improve whatsapp UI, add schedule tasks and upcoming session date * Changew message if not all rdy * feta: Change panel configuration for track auto messages on GO * refacto: Set real production times for automatic panel talk configuration * Improve ui * Add cancel support * Review fixes --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: fteychene <francois.teychene@gmail.com>
1 parent 31d85bd commit 6376a6d

3 files changed

Lines changed: 51 additions & 45 deletions

File tree

functions/src/api/routes/whatsapp/whatsapp.ts

Lines changed: 40 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ const StatusReply = Type.Object({
3131
panelsSent: Type.Array(Type.String()),
3232
})
3333

34+
// Shared error reply shape. Declaring it (instead of Type.String()) keeps the serializer from
35+
// double-encoding the JSON body the handlers send via reply.send({ error, details }).
36+
const ErrorReply = Type.Object({ error: Type.String(), details: Type.Optional(Type.String()) })
37+
3438
const authMatches = (header: string, expected: string): boolean =>
3539
Boolean(expected) && (header === expected || header === `Bearer ${expected}`)
3640

@@ -46,8 +50,8 @@ export const whatsappRoutes = (fastify: FastifyInstance, options: any, done: ()
4650
body: SendBody,
4751
response: {
4852
200: Type.Object({ sent: Type.Boolean(), idMessage: Type.Optional(Type.String()) }),
49-
400: Type.String(),
50-
401: Type.String(),
53+
400: ErrorReply,
54+
401: ErrorReply,
5155
},
5256
security: [{ apiKey: [] }],
5357
},
@@ -57,19 +61,19 @@ export const whatsappRoutes = (fastify: FastifyInstance, options: any, done: ()
5761
const event = await EventDao.getEvent(fastify.firebase, request.params.eventId)
5862
const creds = credsFromEvent(event)
5963
if (!creds) {
60-
reply.status(400).send(JSON.stringify({ error: 'GreenAPI is not configured for this event.' }))
64+
reply.status(400).send({ error: 'GreenAPI is not configured for this event.' })
6165
return
6266
}
6367
const { to, message } = request.body
6468
if (!to || to.replace(/[^0-9]/g, '').length < 6) {
65-
reply.status(400).send(JSON.stringify({ error: 'A valid recipient phone number is required.' }))
69+
reply.status(400).send({ error: 'A valid recipient phone number is required.' })
6670
return
6771
}
6872
try {
6973
const idMessage = await sendMessage(creds, toChatId(to), message)
7074
reply.status(200).send({ sent: true, idMessage })
7175
} catch (err) {
72-
reply.status(400).send(JSON.stringify({ error: 'Failed to send', details: (err as Error).message }))
76+
reply.status(400).send({ error: 'Failed to send', details: (err as Error).message })
7377
}
7478
}
7579
)
@@ -85,8 +89,8 @@ export const whatsappRoutes = (fastify: FastifyInstance, options: any, done: ()
8589
body: StartBody,
8690
response: {
8791
200: Type.Object({ started: Type.Boolean(), trackCount: Type.Number() }),
88-
400: Type.String(),
89-
401: Type.String(),
92+
400: ErrorReply,
93+
401: ErrorReply,
9094
},
9195
security: [{ apiKey: [] }],
9296
},
@@ -97,12 +101,12 @@ export const whatsappRoutes = (fastify: FastifyInstance, options: any, done: ()
97101
const event = await EventDao.getEvent(fastify.firebase, eventId)
98102
const creds = credsFromEvent(event)
99103
if (!creds) {
100-
reply.status(400).send(JSON.stringify({ error: 'GreenAPI is not configured for this event.' }))
104+
reply.status(400).send({ error: 'GreenAPI is not configured for this event.' })
101105
return
102106
}
103107
const tracks = event.tracks || []
104108
if (tracks.length === 0) {
105-
reply.status(400).send(JSON.stringify({ error: 'This event has no tracks.' }))
109+
reply.status(400).send({ error: 'This event has no tracks.' })
106110
return
107111
}
108112
const chatId = toChatId(request.body.chatId)
@@ -111,7 +115,7 @@ export const whatsappRoutes = (fastify: FastifyInstance, options: any, done: ()
111115
await WhatsappSessionDao.saveSession(fastify.firebase, eventId, session)
112116
reply.status(200).send({ started: true, trackCount: tracks.length })
113117
} catch (err) {
114-
reply.status(400).send(JSON.stringify({ error: 'Failed to start', details: (err as Error).message }))
118+
reply.status(400).send({ error: 'Failed to start', details: (err as Error).message })
115119
}
116120
}
117121
)
@@ -134,8 +138,8 @@ export const whatsappRoutes = (fastify: FastifyInstance, options: any, done: ()
134138
})
135139
),
136140
}),
137-
400: Type.String(),
138-
401: Type.String(),
141+
400: ErrorReply,
142+
401: ErrorReply,
139143
},
140144
security: [{ apiKey: [] }],
141145
},
@@ -145,16 +149,14 @@ export const whatsappRoutes = (fastify: FastifyInstance, options: any, done: ()
145149
const event = await EventDao.getEvent(fastify.firebase, request.params.eventId)
146150
const creds = credsFromEvent(event)
147151
if (!creds) {
148-
reply.status(400).send(JSON.stringify({ error: 'GreenAPI is not configured for this event.' }))
152+
reply.status(400).send({ error: 'GreenAPI is not configured for this event.' })
149153
return
150154
}
151155
try {
152156
const contacts = await getChats(creds)
153157
reply.status(200).send({ contacts })
154158
} catch (err) {
155-
reply
156-
.status(400)
157-
.send(JSON.stringify({ error: 'Failed to fetch contacts', details: (err as Error).message }))
159+
reply.status(400).send({ error: 'Failed to fetch contacts', details: (err as Error).message })
158160
}
159161
}
160162
)
@@ -168,7 +170,7 @@ export const whatsappRoutes = (fastify: FastifyInstance, options: any, done: ()
168170
summary: 'Point the GreenAPI instance at our webhook so poll votes are received (reboots instance).',
169171
params: Type.Object({ eventId: Type.String() }),
170172
body: Type.Object({ webhookUrl: Type.String() }),
171-
response: { 200: Type.Object({ configured: Type.Boolean() }), 400: Type.String(), 401: Type.String() },
173+
response: { 200: Type.Object({ configured: Type.Boolean() }), 400: ErrorReply, 401: ErrorReply },
172174
security: [{ apiKey: [] }],
173175
},
174176
preHandler: fastify.auth([fastify.verifyApiKey]),
@@ -177,22 +179,18 @@ export const whatsappRoutes = (fastify: FastifyInstance, options: any, done: ()
177179
const event = await EventDao.getEvent(fastify.firebase, request.params.eventId)
178180
const creds = credsFromEvent(event)
179181
if (!creds) {
180-
reply.status(400).send(JSON.stringify({ error: 'GreenAPI is not configured for this event.' }))
182+
reply.status(400).send({ error: 'GreenAPI is not configured for this event.' })
181183
return
182184
}
183185
if (!event.apiKey) {
184-
reply
185-
.status(400)
186-
.send(JSON.stringify({ error: 'Generate an event API key first (used as the webhook token).' }))
186+
reply.status(400).send({ error: 'Generate an event API key first (used as the webhook token).' })
187187
return
188188
}
189189
try {
190190
await setSettings(creds, { webhookUrl: request.body.webhookUrl, webhookUrlToken: event.apiKey })
191191
reply.status(200).send({ configured: true })
192192
} catch (err) {
193-
reply
194-
.status(400)
195-
.send(JSON.stringify({ error: 'Failed to configure webhook', details: (err as Error).message }))
193+
reply.status(400).send({ error: 'Failed to configure webhook', details: (err as Error).message })
196194
}
197195
}
198196
)
@@ -205,7 +203,7 @@ export const whatsappRoutes = (fastify: FastifyInstance, options: any, done: ()
205203
tags: ['whatsapp'],
206204
summary: 'Current track-management readiness for the event.',
207205
params: Type.Object({ eventId: Type.String() }),
208-
response: { 200: StatusReply, 401: Type.String() },
206+
response: { 200: StatusReply, 401: ErrorReply },
209207
security: [{ apiKey: [] }],
210208
},
211209
preHandler: fastify.auth([fastify.verifyApiKey]),
@@ -244,7 +242,7 @@ export const whatsappRoutes = (fastify: FastifyInstance, options: any, done: ()
244242
Type.Boolean({ description: 'Send GO even if not every track is ready yet.' })
245243
),
246244
}),
247-
response: { 200: Type.Object({ sent: Type.Boolean() }), 400: Type.String(), 401: Type.String() },
245+
response: { 200: Type.Object({ sent: Type.Boolean() }), 400: ErrorReply, 401: ErrorReply },
248246
security: [{ apiKey: [] }],
249247
},
250248
preHandler: fastify.auth([fastify.verifyApiKey]),
@@ -254,33 +252,33 @@ export const whatsappRoutes = (fastify: FastifyInstance, options: any, done: ()
254252
const event = await EventDao.getEvent(fastify.firebase, eventId)
255253
const creds = credsFromEvent(event)
256254
if (!creds) {
257-
reply.status(400).send(JSON.stringify({ error: 'GreenAPI is not configured for this event.' }))
255+
reply.status(400).send({ error: 'GreenAPI is not configured for this event.' })
258256
return
259257
}
260258
const session = await WhatsappSessionDao.getSession(fastify.firebase, eventId)
261259
if (!session) {
262-
reply.status(400).send(JSON.stringify({ error: 'No track-management session in progress.' }))
260+
reply.status(400).send({ error: 'No track-management session in progress.' })
263261
return
264262
}
265263
if (session.goSent) {
266-
reply.status(400).send(JSON.stringify({ error: 'GO message was already sent.' }))
264+
reply.status(400).send({ error: 'GO message was already sent.' })
267265
return
268266
}
269267
if (!request.body?.force && !allReady(session.tracks)) {
270-
reply.status(400).send(JSON.stringify({ error: 'Not every track is ready yet.' }))
268+
reply.status(400).send({ error: 'Not every track is ready yet.' })
271269
return
272270
}
273271
try {
274272
const updated = await sendGoMessage(session, sendersFor(creds))
275273
await WhatsappSessionDao.saveSession(fastify.firebase, eventId, updated)
276-
reply.status(200).send({ sent: true })
277274
} catch (err) {
278-
reply.status(400).send(JSON.stringify({ error: 'Failed to send GO', details: (err as Error).message }))
275+
reply.status(400).send({ error: 'Failed to send GO', details: (err as Error).message })
279276
return
280277
}
281278

282-
// Best-effort: schedule the timing reminders. GO was already broadcast, so a scheduling
283-
// failure here shouldn't turn into an error response — just log it.
279+
// Schedule the timing reminders before responding: post-response async work is not
280+
// guaranteed to run in a serverless environment (CPU throttled after the body flushes).
281+
// GO is already broadcast, so a scheduling failure shouldn't become an error response — log it.
284282
try {
285283
// Default to all reminders; when the client sends an explicit list (with delays), use
286284
// those directly so the frontend-configured timing is authoritative.
@@ -294,6 +292,8 @@ export const whatsappRoutes = (fastify: FastifyInstance, options: any, done: ()
294292
} catch (err) {
295293
request.log?.error({ err }, 'failed to schedule whatsapp panel reminders')
296294
}
295+
296+
reply.status(200).send({ sent: true })
297297
}
298298
)
299299

@@ -315,8 +315,8 @@ export const whatsappRoutes = (fastify: FastifyInstance, options: any, done: ()
315315
})
316316
),
317317
}),
318-
400: Type.String(),
319-
401: Type.String(),
318+
400: ErrorReply,
319+
401: ErrorReply,
320320
},
321321
security: [{ apiKey: [] }],
322322
},
@@ -327,9 +327,7 @@ export const whatsappRoutes = (fastify: FastifyInstance, options: any, done: ()
327327
const tasks = await listScheduledPanels(fastify.firebase, request.params.eventId)
328328
reply.status(200).send({ tasks })
329329
} catch (err) {
330-
reply
331-
.status(400)
332-
.send(JSON.stringify({ error: 'Failed to list scheduled tasks', details: (err as Error).message }))
330+
reply.status(400).send({ error: 'Failed to list scheduled tasks', details: (err as Error).message })
333331
}
334332
}
335333
)
@@ -343,7 +341,7 @@ export const whatsappRoutes = (fastify: FastifyInstance, options: any, done: ()
343341
summary: 'Cancel a pending scheduled WhatsApp reminder task by its Cloud Tasks name.',
344342
params: Type.Object({ eventId: Type.String() }),
345343
body: Type.Object({ name: Type.String() }),
346-
response: { 200: Type.Object({ deleted: Type.Boolean() }), 400: Type.String(), 401: Type.String() },
344+
response: { 200: Type.Object({ deleted: Type.Boolean() }), 400: ErrorReply, 401: ErrorReply },
347345
security: [{ apiKey: [] }],
348346
},
349347
preHandler: fastify.auth([fastify.verifyApiKey]),
@@ -352,14 +350,12 @@ export const whatsappRoutes = (fastify: FastifyInstance, options: any, done: ()
352350
try {
353351
const deleted = await deleteScheduledPanel(fastify.firebase, request.params.eventId, request.body.name)
354352
if (!deleted) {
355-
reply.status(400).send(JSON.stringify({ error: 'Task not found for this event.' }))
353+
reply.status(400).send({ error: 'Task not found for this event.' })
356354
return
357355
}
358356
reply.status(200).send({ deleted: true })
359357
} catch (err) {
360-
reply
361-
.status(400)
362-
.send(JSON.stringify({ error: 'Failed to delete task', details: (err as Error).message }))
358+
reply.status(400).send({ error: 'Failed to delete task', details: (err as Error).message })
363359
}
364360
}
365361
)

functions/src/api/routes/whatsapp/whatsappPanelTask.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ export const sendWhatsappPanel = onTaskDispatched<PanelTaskPayload>(
3636
const session = await WhatsappSessionDao.getSession(app, eventId)
3737
if (!session?.chatId) return
3838

39+
// Idempotency: retries (maxAttempts: 3) must not re-send a reminder that already went out
40+
// (e.g. send succeeded but the addPanelSent write failed on the previous attempt).
41+
if (session.panelsSent?.includes(message)) return
42+
3943
await sendMessage(creds, session.chatId, message)
4044
await WhatsappSessionDao.addPanelSent(app, eventId, message)
4145
}

src/services/hooks/useOpenPlannerApi.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,13 @@ export async function fetchOpenPlannerApi<T>(
4747

4848
const response = await fetch(url.href, fetchOptions)
4949
if (!response.ok) {
50-
throw new Error(`Failed to fetch from ${endpoint}`)
50+
// Surface the API's error message ({ error, details }) instead of a generic failure.
51+
const detail = await response
52+
.clone()
53+
.json()
54+
.then((body) => (body && typeof body.error === 'string' ? body.error : null))
55+
.catch(() => null)
56+
throw new Error(detail || `Failed to fetch from ${endpoint}`)
5157
}
5258
return await response.json()
5359
}

0 commit comments

Comments
 (0)