Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions server/src/__integration__/agent-reply-push.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,66 @@ test('[integration] an agent-only conversation still pushes to nobody', async ()
const withRecipients = sent.filter((s) => s.recipientUserIds.length > 0)
assert.deepEqual(withRecipients, [], 'pushed for a conversation with no humans in it')
})

// ─── a poll is a message too ────────────────────────────────────────────────
//
// `createPoll` commits a kind='poll' row and enqueues the same message.new
// broadcast every other sender enqueues, and then stopped: it was the one
// human-visible message kind that never reached dispatchMessagePush.
//
// Not a missing feature, an asymmetry. NotificationToasts skips only
// `kind === 'system'`, so a poll already toasts on desktop and in the Electron
// notification window, and Message.tsx renders the poll bubble on both shells —
// so a phone-only member could see and vote on a poll nobody told them about.
// polls.ts says what the shadow body is for in its own comment: "notifications,
// search index, plain-text logs".

test('[integration] a poll reaches the phone', async () => {
const { companyId, agentId } = await seedCompanyWithAgent()
const humanId = 'u-poll-target'
await seedOfflineHuman(companyId, humanId)
await seedRoom(companyId, 'c-poll', [agentId, humanId])

const { createPoll } = await import('../polls.js')
const { messageId } = await createPoll({
conversationId: 'c-poll',
companyId,
authorId: agentId,
question: 'Ship Friday?',
mode: 'single',
options: ['yes', 'no'],
})
assert.ok(messageId)
await new Promise((r) => setTimeout(r, 150))

assert.equal(sent.length, 1, 'a poll produced no push — the phone is never told')
assert.deepEqual(sent[0].recipientUserIds, [humanId])
assert.match(sent[0].body, /Ship Friday\?/, 'the notification body does not name the question')
})

test('[integration] a poll obeys the same recipient filters', async () => {
// The guard rail: adding a dispatch must not add a delivery path that ignores
// mute or the "currently looking at the app" rule.
const { companyId, agentId } = await seedCompanyWithAgent()
const muted = 'u-poll-muted'
await seedOfflineHuman(companyId, muted)
await seedRoom(companyId, 'c-poll-muted', [agentId, muted])
await pool.query(
`INSERT INTO conversation_mutes (user_id, conversation_id, muted_until) VALUES ($1, 'c-poll-muted', NULL)`,
[muted],
)

const { createPoll } = await import('../polls.js')
await createPoll({
conversationId: 'c-poll-muted',
companyId,
authorId: agentId,
question: 'Ship Friday?',
mode: 'single',
options: ['yes', 'no'],
})
await new Promise((r) => setTimeout(r, 150))

const withRecipients = sent.filter((s) => s.recipientUserIds.length > 0)
assert.deepEqual(withRecipients, [], 'pushed a poll into a muted conversation')
})
24 changes: 24 additions & 0 deletions server/src/polls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
* tally shape stay consistent across actors.
*/
import { randomUUID } from 'node:crypto'
import { dispatchMessagePush } from './push.js'
import { pool } from './db/pool.js'
import { CH_MESSAGE_NEW, CH_POLLS, type PollUpdatedEvent } from './redis.js'
import {
Expand Down Expand Up @@ -175,6 +176,29 @@ export async function createPoll(input: CreatePollInput): Promise<CreatedPoll> {
client.release()
}

// The row is durable now, so the phone can be told. Fire-and-forget for the
// same reason the other four dispatches are: a push must never hold up the
// write.
//
// A poll was the one human-visible message kind that never reached a phone.
// It is not a missing feature, it is an asymmetry: NotificationToasts skips
// only `kind === 'system'`, so a poll already toasts on desktop and in the
// Electron notification window, and Message.tsx renders the poll bubble on
// both shells — so a phone-only member could see and vote on a poll nobody
// ever told them about. The body built above says what it is for in its own
// comment: "notifications, search index, plain-text logs".
//
// Recipients need no new filtering: computeMessageRecipients joins `users`,
// so agents are never notified, and the mute / "currently looking at the app"
// filters apply unchanged.
void dispatchMessagePush({
conversationId: input.conversationId,
authorId: input.authorId,
messageId,
body,
companyId: input.companyId,
})

return { messageId, sequence, poll: payload }
}

Expand Down
Loading