Messaging

Build a polled candidate-employer inbox with read state, editing, moderation, and blocks.

Messaging is authenticated, polled REST. Cavuno owns conversation permissions and message state; your frontend owns refresh cadence, optimistic presentation, and visibility-aware polling.

Prerequisites

  • A server-owned board-user session.
  • Candidate or employer account routes protected before rendering.
  • Report and block controls anywhere a message is displayed.

Load and reply to a thread

ts
// src/data/messages.ts
import type { FetchOptions } from '@cavuno/board';
import { board } from '../lib/board';
function session(accessToken: string): FetchOptions {
return {
cache: 'no-store',
headers: { authorization: `Bearer ${accessToken}` },
};
}
export async function loadInbox(accessToken: string, cursor?: string) {
const options = session(accessToken);
const [threads, unread] = await Promise.all([
board.me.conversations.list({ limit: 20, cursor }, options),
board.me.conversations.unreadCount(options),
]);
return { threads, unread: unread.count };
}
export async function loadThread(accessToken: string, conversationId: string) {
const options = session(accessToken);
const [conversation, messages] = await Promise.all([
board.me.conversations.retrieve(conversationId, options),
board.me.conversations.listMessages(
conversationId,
{ limit: 50 },
options,
),
]);
await board.me.conversations.markRead(conversationId, options);
return { conversation, messages: messages.data };
}
export function reply(
accessToken: string,
conversationId: string,
body: string,
) {
return board.me.conversations.reply(
conversationId,
{ body },
session(accessToken),
);
}

Thread messages are oldest-first. An unsent message remains as a tombstone with an empty body and deletedAt; preserve its place in the timeline.

Poll without wasting requests

Poll inbox, message, or unread methods every 3–5 seconds while the relevant view is visible. Stop when document.visibilityState is hidden, when navigation aborts, or when the user signs out. Reconcile an optimistic message with the Message returned by reply; its id, timestamps, and author state are authoritative.

Add safety controls

ts
export async function reportSpam(
accessToken: string,
messageId: string,
) {
return board.me.messages.report(
messageId,
{ reason: 'spam' },
session(accessToken),
);
}
export async function blockUser(
accessToken: string,
boardUserId: string,
) {
return board.me.blocks.create(
{ boardUserId },
session(accessToken),
);
}

Reporting a message also auto-blocks its author. Use the returned blocked value instead of applying a second speculative block.

Expected result

The inbox returns paginated conversations and a distinct unread-thread count. A thread returns its header and oldest-first messages; reply returns the authoritative created Message.

Verify the result

  • Two signed-in users cannot read each other’s unrelated conversations.
  • Opening a thread updates unread state only after markRead succeeds.
  • A sent message is replaced by the server-returned message in optimistic UI.
  • Polling stops on hidden tabs and after sign-out.
  • Reporting and blocking are available from the thread interface.

Errors and edge cases

  • Editing and unsending are limited to the author’s own message within 15 minutes.
  • start is employer-only and converges on an existing employer-candidate thread.
  • startAboutApplication uses application context and does not require talent-directory access.
  • Conversation pagination order is approximate across pages; deduplicate by conversation id when polling.

Production cautions

  • Never cache board.me messaging responses publicly.
  • Do not log message bodies or bearer tokens.
  • Add backoff after repeated failures instead of multiplying poll traffic.
  • Keep moderation outcomes and API request IDs available to support staff.

Next, let approved employers manage their presence with Employer self-service.