@cavuno/board 2.0.5 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +1 -3
  3. package/dist/{_spec-BKtVFcV9.d.mts → _spec-nkWAraS0.d.mts} +4 -18
  4. package/dist/{_spec-BKtVFcV9.d.ts → _spec-nkWAraS0.d.ts} +4 -18
  5. package/dist/bin.mjs +40 -57
  6. package/dist/{board-3Xj_Y-Tx.d.ts → board-NE98PlwN.d.ts} +2 -3
  7. package/dist/{board-pNN710Zc.d.mts → board-muu8BI8P.d.mts} +2 -3
  8. package/dist/doctor.js +21 -47
  9. package/dist/doctor.mjs +24 -50
  10. package/dist/filters.d.mts +2 -2
  11. package/dist/filters.d.ts +2 -2
  12. package/dist/format.d.mts +3 -3
  13. package/dist/format.d.ts +3 -3
  14. package/dist/index.d.mts +11 -27
  15. package/dist/index.d.ts +11 -27
  16. package/dist/index.js +3 -3
  17. package/dist/index.mjs +3 -3
  18. package/dist/{jobs-C4qRfCdP.d.ts → jobs-BiEvL9d7.d.ts} +1 -1
  19. package/dist/{jobs-k84C0v_8.d.mts → jobs-lVvKCBCU.d.mts} +1 -1
  20. package/dist/{salaries--J5mTylN.d.ts → salaries-BahBpxWT.d.ts} +2 -2
  21. package/dist/{salaries-C9YLO5NG.d.mts → salaries-ChSqDBAM.d.mts} +2 -2
  22. package/dist/{search-CE_qQiM7.d.mts → search-CCyFWPi3.d.mts} +1 -1
  23. package/dist/{search-CLRQ_El9.d.ts → search-CyRvVMUp.d.ts} +1 -1
  24. package/dist/seo.d.mts +4 -4
  25. package/dist/seo.d.ts +4 -4
  26. package/dist/server.d.mts +5 -5
  27. package/dist/server.d.ts +5 -5
  28. package/dist/sitemap.d.mts +5 -5
  29. package/dist/sitemap.d.ts +5 -5
  30. package/dist/skills.d.mts +15 -4
  31. package/dist/skills.d.ts +15 -4
  32. package/dist/skills.js +30 -7
  33. package/dist/skills.mjs +30 -8
  34. package/dist/suggest.d.mts +2 -2
  35. package/dist/suggest.d.ts +2 -2
  36. package/package.json +1 -11
  37. package/skills/cavuno-board-account/SKILL.md +54 -66
  38. package/skills/cavuno-board-api-client/SKILL.md +74 -0
  39. package/skills/cavuno-board-applications/SKILL.md +38 -52
  40. package/skills/cavuno-board-auth/SKILL.md +36 -61
  41. package/skills/cavuno-board-blog/SKILL.md +60 -49
  42. package/skills/cavuno-board-companies/SKILL.md +71 -53
  43. package/skills/cavuno-board-errors/SKILL.md +29 -46
  44. package/skills/cavuno-board-filters/SKILL.md +64 -75
  45. package/skills/cavuno-board-format/SKILL.md +63 -68
  46. package/skills/cavuno-board-i18n/SKILL.md +33 -62
  47. package/skills/cavuno-board-job-alerts/SKILL.md +68 -57
  48. package/skills/cavuno-board-jobs/SKILL.md +63 -55
  49. package/skills/cavuno-board-messaging/SKILL.md +53 -59
  50. package/skills/cavuno-board-paywall/SKILL.md +48 -54
  51. package/skills/cavuno-board-post-a-job/SKILL.md +118 -0
  52. package/skills/cavuno-board-salaries/SKILL.md +73 -50
  53. package/skills/cavuno-board-search-suggestions/SKILL.md +82 -0
  54. package/skills/cavuno-board-seo/BLOG.md +40 -0
  55. package/skills/cavuno-board-seo/JOB_AND_BREADCRUMBS.md +49 -0
  56. package/skills/cavuno-board-seo/LISTING.md +36 -0
  57. package/skills/cavuno-board-seo/SALARY.md +61 -0
  58. package/skills/cavuno-board-seo/SKILL.md +32 -163
  59. package/skills/cavuno-board-server-sessions/SKILL.md +143 -0
  60. package/skills/cavuno-board-setup/SKILL.md +43 -80
  61. package/skills/cavuno-board-sitemap/SKILL.md +76 -93
  62. package/skills/cavuno-board-smoke-test/SKILL.md +34 -69
  63. package/skills/manifest.json +43 -57
  64. package/dist/theme.d.mts +0 -115
  65. package/dist/theme.d.ts +0 -115
  66. package/dist/theme.js +0 -195
  67. package/dist/theme.mjs +0 -174
  68. package/skills/cavuno-board-client/SKILL.md +0 -89
  69. package/skills/cavuno-board-job-posting/SKILL.md +0 -116
  70. package/skills/cavuno-board-server/SKILL.md +0 -229
  71. package/skills/cavuno-board-suggest/SKILL.md +0 -119
  72. package/skills/cavuno-board-theme/SKILL.md +0 -57
  73. package/skills/flavors/tanstack-start/SKILL.md +0 -102
@@ -1,83 +1,97 @@
1
1
  ---
2
2
  name: cavuno-board-job-alerts
3
- description: Build job-alert flows with the @cavuno/board SDK — the anonymous double-opt-in surface (jobAlerts.subscribe/confirm/resendConfirmation + HMAC-token manage/unsubscribe/resubscribe/updatePreference/deletePreference) and the authenticated board.me.alerts CRUD. Covers which surface to use when, how the manage token rides, which filters actually scope delivery, and the full-replace update trap.
3
+ description: Job-alert two-lane model with @cavuno/board. Use for anonymous double opt-in, token-managed preferences, or signed-in alert CRUD.
4
4
  ---
5
5
 
6
- # Job alerts: two surfaces
6
+ # Job-alert two-lane model
7
7
 
8
- Two distinct surfaces. **Anonymous** (`board.jobAlerts.*`): email capture with double opt-in; later edits authenticate with an HMAC manage token from the digest email. **Authenticated** (`board.me.alerts.*`): CRUD for a signed-in board user (bearer token), active immediately — the authenticated action is the consent. Both are gated on `board.context()` → `features.jobAlerts`.
8
+ Choose exactly one lane:
9
9
 
10
- ## When to use
10
+ - Anonymous visitors use `board.jobAlerts.*`: double opt-in, then an HMAC manage token from email.
11
+ - Signed-in users use `board.me.alerts.*`: bearer-authenticated CRUD, active immediately, with up to 10 alerts per user.
11
12
 
12
- - Anonymous: the "email me new jobs" capture form for visitors, the confirm landing page, and the manage/unsubscribe page linked from digest emails.
13
- - Authenticated: an alerts section in the signed-in account area (up to 10 alerts per user).
13
+ Both lanes are available only when `board.context()` reports `features.jobAlerts`.
14
14
 
15
- ## When not to use
15
+ ## Anonymous lane: subscribe and confirm
16
16
 
17
- - Board-user sign-in itself — see `cavuno-board-auth`.
18
-
19
- ## Out of scope — do not invent exports
20
-
21
- Only the methods and fields shown here exist. There is no pause/suspend: deactivation is `unsubscribe` (anonymous) or `remove` (authenticated).
22
-
23
- ## Anonymous: subscribe → confirm
24
-
25
- `subscribe` requires `consent: true` (server-enforced) and sends a confirmation email. `confirm` always returns HTTP 200 — branch on `status`.
17
+ `subscribe` requires consent and deliberately returns the same submitted response for new and existing addresses. `confirm` always resolves; its status owns the landing-page branch.
26
18
 
27
19
  ```ts snippet
28
- const sub = await board.jobAlerts.subscribe({
20
+ const submitted = await board.jobAlerts.subscribe({
29
21
  email: 'ada@example.com',
30
22
  consent: true,
31
- frequency: 'weekly', // the only supported cadence
32
- filters: { jobFunctions: ['engineering'], remoteOptions: ['remote'], placeSlugs: ['berlin'] },
23
+ frequency: 'weekly',
24
+ filters: {
25
+ jobFunctions: ['engineering'],
26
+ remoteOptions: ['remote'],
27
+ placeSlugs: ['berlin'],
28
+ },
33
29
  });
34
- sub.status; // always 'submitted' — uniform, never reveals new vs. already-subscribed
35
30
 
36
- // Confirm page — token from the email link:
37
- const res = await board.jobAlerts.confirm({ token });
38
- res.status; // 'confirmed' | 'already_confirmed' | 'expired' | 'not_found'
39
- if (res.status === 'expired') {
40
- await board.jobAlerts.resendConfirmation({ email }); // status: always 'submitted'
31
+ const result = await board.jobAlerts.confirm({ token });
32
+ switch (result.status) {
33
+ case 'confirmed':
34
+ case 'already_confirmed':
35
+ case 'not_found':
36
+ break;
37
+ case 'expired':
38
+ await board.jobAlerts.resendConfirmation({ email });
39
+ break;
41
40
  }
42
41
  ```
43
42
 
44
- Filter caveat: only `jobFunctions`, `placeSlugs`, and `remoteOptions` scope the digest server-side; `seniorityLevels`/`salaryMin`/`salaryMax`/`salaryCurrency` are stored but do not filter delivery.
43
+ Only `jobFunctions`, `placeSlugs`, and `remoteOptions` filter digest delivery. Seniority and salary fields are stored but do not scope delivery. The supported cadence is `weekly`.
45
44
 
46
- ## Anonymous: the HMAC manage token
45
+ ## Anonymous lane: manage-token transport
47
46
 
48
- Digest emails carry a per-subscription HMAC token. **`manage` is a GET — the token rides as query params `{ subscription, token }`. All writes are POST/DELETE — the token rides in the JSON body as `{ subscriptionId, token }`** (note the different key: `subscription` on the query, `subscriptionId` in bodies). Each preference in the manage state also carries its own `manageToken`.
47
+ The read and write transports intentionally use different keys:
48
+
49
+ - `manage` is GET with query `{ subscription, token }`.
50
+ - Writes carry `{ subscriptionId, token }` in their body.
49
51
 
50
52
  ```ts snippet
51
- // Manage page, from the email link's query string:
52
- const state = await board.jobAlerts.manage({ subscription: subscriptionId, token });
53
- state.email; state.confirmed; state.unsubscribed;
54
- state.preferences; // [{ id, label, frequency, isActive, filters, manageToken }]
53
+ const state = await board.jobAlerts.manage({
54
+ subscription: subscriptionId,
55
+ token,
56
+ });
57
+ state.email;
58
+ state.confirmed;
59
+ state.unsubscribed;
60
+ state.preferences;
55
61
 
56
62
  await board.jobAlerts.unsubscribe({ subscriptionId, token });
57
63
  await board.jobAlerts.resubscribe({ subscriptionId, token });
58
- await board.jobAlerts.deletePreference({ subscriptionId, preferenceId, token });
64
+ await board.jobAlerts.deletePreference({
65
+ subscriptionId,
66
+ preferenceId,
67
+ token,
68
+ });
59
69
  ```
60
70
 
61
- `unsubscribe`/`resubscribe` also accept an optional `preferenceId` to scope to one preference. `updatePreference` is a **full replace** — `frequency` is required every time; restate the filters you aren't editing or they reset:
71
+ `unsubscribe` and `resubscribe` accept an optional `preferenceId`. Each managed preference also includes its own `manageToken`.
72
+
73
+ `updatePreference` is a full replacement. Round-trip every retained value and always send `frequency`.
62
74
 
63
75
  ```ts snippet
64
- const pref = state.preferences[0];
76
+ const preference = state.preferences[0]!;
65
77
  await board.jobAlerts.updatePreference({
66
78
  subscriptionId,
67
- preferenceId: pref.id,
79
+ preferenceId: preference.id,
68
80
  token,
69
- frequency: 'weekly', // required — restate even if unchanged
70
- filters: pref.filters, // round-trip stored filters you aren't editing
81
+ frequency: 'weekly',
82
+ filters: preference.filters,
71
83
  });
72
84
  ```
73
85
 
74
- ## Authenticated: board.me.alerts
86
+ ## Signed-in lane
75
87
 
76
- Bearer-authenticated CRUD. Alerts are active on create (no opt-in email). The `AlertBody` uses `placeIds` (not `placeSlugs`), and `frequency` is required.
88
+ Authenticated alerts use `placeIds`, while anonymous alerts use `placeSlugs`. Create and update accept the same `AlertBody`; update is a full replacement.
77
89
 
78
90
  ```ts snippet
79
91
  const { data: alerts } = await board.me.alerts.list();
80
- alerts[0].isActive; alerts[0].lastSentAt; alerts[0].filters;
92
+ alerts[0]?.isActive;
93
+ alerts[0]?.lastSentAt;
94
+ alerts[0]?.filters;
81
95
 
82
96
  const alert = await board.me.alerts.create({
83
97
  frequency: 'weekly',
@@ -85,14 +99,8 @@ const alert = await board.me.alerts.create({
85
99
  remoteOptions: ['remote'],
86
100
  });
87
101
 
88
- await board.me.alerts.remove(alert.id); // 204 → void; the only way to stop an alert
89
- ```
90
-
91
- `update` is a PUT with **full-replace semantics** — the body is the same `AlertBody` as create, so round-trip every field you aren't changing:
92
-
93
- ```ts snippet
94
- const current = await board.me.alerts.retrieve(alertId);
95
- await board.me.alerts.update(alertId, {
102
+ const current = await board.me.alerts.retrieve(alert.id);
103
+ await board.me.alerts.update(alert.id, {
96
104
  frequency: 'weekly',
97
105
  jobFunctions: current.filters.jobFunctions,
98
106
  seniorityLevels: current.filters.seniorityLevels,
@@ -102,14 +110,17 @@ await board.me.alerts.update(alertId, {
102
110
  salaryMax: current.filters.salaryMax,
103
111
  salaryCurrency: current.filters.salaryCurrency,
104
112
  });
113
+
114
+ await board.me.alerts.remove(alert.id);
105
115
  ```
106
116
 
107
- ## Verify
117
+ Removal is the signed-in lane's stop action; there is no pause state.
118
+
119
+ ## Completion gate
108
120
 
109
- - [ ] Subscribe form sends `consent: true` and shows a uniform "check your email" message on `status: 'submitted'` (never reveals already-subscribed).
110
- - [ ] Confirm page branches on all four statuses (including `expired` → resend).
111
- - [ ] Manage page reads `subscription` + `token` from the email link's query string; writes send `subscriptionId` in the body.
112
- - [ ] Editing one preference field leaves the others intact (full-replace round-trip, both surfaces).
113
- - [ ] Alert UI only promises filtering on job function / place / remote option.
114
- - [ ] Alert UI offers weekly cadence only and never sends a legacy daily value.
115
- - [ ] Alert surfaces are hidden when `features.jobAlerts` is false.
121
+ - The feature flag hides both lanes when disabled.
122
+ - Anonymous subscribe sends `consent: true` and reveals no subscription existence.
123
+ - The confirm UI handles all four statuses, including resend after expiry.
124
+ - Manage reads use `subscription`; manage writes use `subscriptionId`.
125
+ - Editing one field preserves every other field in both full-replace APIs.
126
+ - UI promises only weekly delivery filtered by job function, place, and remote option.
@@ -1,58 +1,55 @@
1
1
  ---
2
2
  name: cavuno-board-jobs
3
- description: Browse, search, and render jobs with the @cavuno/board SDK — jobs.list, jobs.search, jobs.retrieve, jobs.similar. Covers the slim card vs full job shapes, catalog pagination (count/limit/offset + opaque cursor), filters, and the candidate-paywall gatedCount.
3
+ description: Job catalog reads with @cavuno/board. Use for job browse or search, job detail, similar jobs, candidate gating, or full-catalog iteration.
4
4
  ---
5
5
 
6
- # Jobs: browse, search, detail
6
+ # Jobs
7
7
 
8
- The highest-traffic surface. Listing and search return slim `PublicJobCard`s; the detail endpoint returns the full `PublicJob`.
8
+ Use cards for collections and the full job for detail: `jobs.list`,
9
+ `jobs.search`, and `jobs.similar` return `PublicJobCard`; only
10
+ `jobs.retrieve` returns `PublicJob`.
9
11
 
10
- ## When to use
12
+ Company-scoped collections use `companies.listJobs`. The ungated embeddable
13
+ widget uses `embed.jobs`.
11
14
 
12
- - Listing/search/keyword/location pages.
13
- - The job-detail page and its "similar jobs" rail.
15
+ ## Browse and paginate
14
16
 
15
- ## When not to use
17
+ `jobs.list(query)` returns `JobCardListEnvelope`: `data`, `count`, `limit`,
18
+ `offset`, `hasMore`, `nextCursor`, optional `relatedSearches`, and optional
19
+ `gatedCount`.
16
20
 
17
- - Company-scoped listings — use `companies.listJobs` (same card shape).
18
- - The ungated embeddable widget — use `embed.jobs`.
19
-
20
- ## List and render cards
21
-
22
- `jobs.list` returns a `JobCardListEnvelope`: catalog pagination fields plus `data: PublicJobCard[]` and optional `relatedSearches`.
21
+ `JobsListQuery` accepts `limit` (1–100), `cursor`, `offset`, `companyId`,
22
+ `companySlug`, `remoteOption`, `employmentType`, `seniority`, `location` with
23
+ `radius` in kilometres, `category`, and `skill`. Repeated `seniority` values
24
+ are OR-matched. `offset` takes precedence over `cursor`, so choose one paging
25
+ mode per request.
23
26
 
24
27
  ```ts snippet
25
- const page = await board.jobs.list({ limit: 20, seniority: ['senior', 'lead'] });
26
- page.count; // total matches ("X jobs")
27
- page.hasMore; // more pages exist
28
- page.nextCursor; // opaque forward token, or null
29
- for (const card of page.data) {
28
+ const first = await board.jobs.list({
29
+ limit: 20,
30
+ seniority: ['senior', 'lead'],
31
+ });
32
+ const second = first.nextCursor
33
+ ? await board.jobs.list({ limit: 20, cursor: first.nextCursor })
34
+ : null;
35
+ const page3 = await board.jobs.list({ limit: 20, offset: 40 });
36
+
37
+ for (const card of first.data) {
30
38
  card.title;
31
39
  card.company?.name;
32
- card.links.public; // canonical /companies/:companySlug/jobs/:jobSlug
40
+ card.links.public;
33
41
  }
34
42
  ```
35
43
 
36
- ### Filters and pagination
37
-
38
- `JobsListQuery` supports `limit` (1–100), `offset` (takes precedence over `cursor`), `cursor`, and filters: `companyId`, `companySlug`, `remoteOption`, `employmentType`, `seniority` (single or repeated → OR-matched), `location` + `radius` (km), `category`, `skill`. Paginate by passing back `nextCursor`, or use numbered pages with `offset`:
44
+ Render `links.public` as the canonical
45
+ `/companies/:companySlug/jobs/:jobSlug` URL.
39
46
 
40
- ```ts snippet
41
- const p1 = await board.jobs.list({ limit: 20 });
42
- const p2 = p1.nextCursor
43
- ? await board.jobs.list({ limit: 20, cursor: p1.nextCursor })
44
- : null;
45
- // or numbered pages:
46
- const page3 = await board.jobs.list({ limit: 20, offset: 40 });
47
- ```
47
+ ### Filter companies by public slug
48
48
 
49
- ### Company filter via public slug
50
-
51
- `companySlug` is the public URL identity (what listing URLs carry). Prefer it
52
- over `companyId` in frontends — the API resolves slugs server-side. Unknown
53
- slugs are **ignored** (they contribute no matches, not an error); an entirely
54
- unknown set yields an empty result list with `count: 0`. Combined with
55
- `companyId` as a union when both are set. Cap is 10 values.
49
+ Frontend URLs carry `companySlug`; pass it directly to the API. Unknown slugs
50
+ contribute no matches, and a wholly unknown set returns `count: 0` with empty
51
+ `data`. When `companyId` and `companySlug` are both present, their matches form
52
+ a union. Each accepts at most 10 values.
56
53
 
57
54
  ```ts snippet
58
55
  const page = await board.jobs.list({
@@ -63,7 +60,9 @@ const page = await board.jobs.list({
63
60
 
64
61
  ## Search
65
62
 
66
- `jobs.search` posts a `JobsSearchBody` (free-text `query` + structured `filters`) and returns a `JobCardSearchEnvelope`. The same `companySlug` rule applies under `filters`:
63
+ `jobs.search` posts `JobsSearchBody`: free-text `query`, structured `filters`,
64
+ and pagination. It returns `JobCardSearchEnvelope`; `companySlug` has the same
65
+ semantics inside `filters`.
67
66
 
68
67
  ```ts snippet
69
68
  const results = await board.jobs.search({
@@ -78,32 +77,35 @@ const results = await board.jobs.search({
78
77
  });
79
78
  ```
80
79
 
81
- ## Detail and similar
80
+ ## Render detail and similar jobs
82
81
 
83
82
  ```ts snippet
84
- const job = await board.jobs.retrieve('senior-chef'); // full PublicJob
85
- job.description; // HTML
83
+ const job = await board.jobs.retrieve('senior-chef');
84
+ job.description; // HTML
86
85
  job.officeLocations;
87
86
  job.company?.slug;
87
+
88
88
  const rail = await board.jobs.similar('senior-chef', { limit: 5 });
89
89
  ```
90
90
 
91
- ## The candidate paywall: gatedCount
91
+ ## Surface candidate gating
92
92
 
93
- On gated boards, some results are hidden from anonymous/unentitled viewers. `gatedCount` is how many were withheld for the current viewer (absent/0 when entitled). Surface it as an upsell rather than pretending the list is complete:
93
+ On a gated board, `gatedCount` reports results withheld from the current
94
+ viewer. Render it as an upsell. The same optional-auth endpoint returns the
95
+ entitled view when called with that board user's bearer token.
94
96
 
95
97
  ```ts snippet
96
98
  const page = await board.jobs.list({ limit: 20 });
97
- if (page.gatedCount && page.gatedCount > 0) {
98
- // e.g. "Sign in to see N more roles"
99
+ if ((page.gatedCount ?? 0) > 0) {
100
+ // Render “Sign in to see N more roles”.
99
101
  }
100
102
  ```
101
103
 
102
- A board-user bearer token on the same call returns the entitled (ungated) view — the endpoint is optional-auth, one URL for both anonymous and personalized reads.
104
+ ## Walk the full catalog
103
105
 
104
- ## Full-catalog walks: paginate()
105
-
106
- For sitemaps, feeds, or exports, never hand-roll the cursor loop — `paginate()` iterates items (or raw pages via `.pages()`) until `hasMore` is false, and drops `offset` after the first page (offset would win over the cursor and re-serve the same page forever):
106
+ Use `paginate()` for sitemaps, feeds, and exports. It advances the opaque
107
+ cursor until `hasMore` is false and removes `offset` after the first request;
108
+ retaining the offset would make it win over the cursor and repeat a page.
107
109
 
108
110
  ```ts snippet
109
111
  import { paginate } from '@cavuno/board';
@@ -114,11 +116,17 @@ for await (const card of paginate(board.jobs.list, { limit: 100 })) {
114
116
  const first500 = await paginate(board.jobs.list).toArray({ limit: 500 });
115
117
  ```
116
118
 
117
- Iteration order is stable only under an explicit sort/search — a churning board reorders default browse results between pages.
119
+ Default browse order can move while a board changes. Supply an explicit sort
120
+ or search whenever iteration order must stay stable.
121
+
122
+ ## Completion gate
118
123
 
119
- ## Checklist
124
+ Finish only after every applicable check passes:
120
125
 
121
- - [ ] Listing/search use `PublicJobCard`; detail uses `PublicJob`.
122
- - [ ] Job links use `links.public` (canonical `/companies/:companySlug/jobs/:jobSlug`).
123
- - [ ] Pagination via `nextCursor` or `offset`, not client-side slicing.
124
- - [ ] `gatedCount` surfaced as an upsell, not hidden.
126
+ - Collections render `PublicJobCard`; detail renders `PublicJob`.
127
+ - Every job link comes from `links.public`.
128
+ - Pagination returns the final page once and ends with `nextCursor: null`.
129
+ - A gated anonymous response renders its `gatedCount` upsell, while an
130
+ entitled request renders the ungated view.
131
+ - Full-catalog code uses `paginate()` and any order-sensitive walk has an
132
+ explicit sort or search.
@@ -1,86 +1,77 @@
1
1
  ---
2
2
  name: cavuno-board-messaging
3
- description: Build the board-user messaging inbox with the @cavuno/board SDK — me.conversations (list, unreadCount, retrieve, listMessages, start, reply, markRead, archive), me.messages (edit, unsend, report), me.blocks. Covers the polled-REST contract (no realtime transport in v1), visibility-aware polling, the unread badge, and read receipts.
3
+ description: Polled messaging contract with @cavuno/board. Use for inboxes, unread badges, threads, read receipts, message moderation, or user blocks.
4
4
  ---
5
5
 
6
- # Messaging: the polled inbox
6
+ # Polled messaging contract
7
7
 
8
- Employer↔candidate direct messaging for signed-in board users. The v1 transport is **polled REST by design** — there is no realtime primitive; near-live UX comes from re-fetching on an interval (3–5s is the reference cadence).
8
+ Messaging is authenticated employer-to-candidate REST. Near-live behavior comes from polling at a 3–5 second cadence while the page is visible. `list`, `listMessages`, and `unreadCount` are the transport; the SDK exposes no realtime subscription.
9
9
 
10
- Out of scope — do not invent exports: no websockets, no SSE, no `subscribe`/`onMessage` — polling `list` / `listMessages` / `unreadCount` IS the contract.
10
+ ## Keep the poll visible
11
11
 
12
- ## When to use
13
-
14
- - The inbox page, unread badge, thread view, and composer.
15
- - Edit/unsend, report-a-message, and block flows.
16
-
17
- ## When not to use
18
-
19
- - Anonymous visitors — every method here requires a signed-in board user (see `cavuno-board-auth`).
20
- - Job applications themselves — messaging an applicant *starts from* an application, but the application surface is `board.me.applications.*`.
21
-
22
- ## Poll while visible
23
-
24
- Poll only while the tab is visible — stop on hide, resume (with an immediate refresh) on show:
12
+ Refresh immediately on start or return to the tab, and maintain at most one timer.
25
13
 
26
14
  ```ts snippet
27
- const POLL_MS = 4000; // 3–5s reference cadence
15
+ const pollMs = 4000;
28
16
 
29
- async function refresh() {
17
+ async function refreshInbox() {
30
18
  const [{ count }, inbox] = await Promise.all([
31
- board.me.conversations.unreadCount(), // distinct unread threads → badge
19
+ board.me.conversations.unreadCount(),
32
20
  board.me.conversations.list({ limit: 20 }),
33
21
  ]);
34
- render(count, inbox.data);
22
+ renderInbox(count, inbox.data);
35
23
  }
36
24
 
37
25
  let timer: ReturnType<typeof setInterval> | undefined;
38
- function start() {
39
- void refresh();
40
- timer ??= setInterval(refresh, POLL_MS);
26
+ function startPolling() {
27
+ void refreshInbox();
28
+ timer ??= setInterval(refreshInbox, pollMs);
41
29
  }
42
- function stop() {
43
- clearInterval(timer);
30
+ function stopPolling() {
31
+ if (timer) clearInterval(timer);
44
32
  timer = undefined;
45
33
  }
34
+
46
35
  document.addEventListener('visibilitychange', () => {
47
- document.visibilityState === 'visible' ? start() : stop();
36
+ if (document.visibilityState === 'visible') startPolling();
37
+ else stopPolling();
48
38
  });
49
- start();
39
+ startPolling();
50
40
  ```
51
41
 
52
- Each inbox row is a `Conversation`: `lastMessageAt`, `lastMessageSnippet`, `hasUnread`, and a live-resolved `counterparty` (`displayName`, `avatarUrl`, `companyName`, `handle`). `list({ archived: true })` is the archived view; `archive`/`unarchive` move a thread per-side and are idempotent.
42
+ Inbox rows include `lastMessageAt`, `lastMessageSnippet`, `hasUnread`, and the live-resolved `counterparty` with `displayName`, `avatarUrl`, `companyName`, and `handle`. `list({ archived: true })` reads the archived view; `archive` and `unarchive` are idempotent and per-side.
53
43
 
54
- ## The thread: header, messages, read receipts
44
+ ## Render a thread faithfully
55
45
 
56
- The header and the messages are separate calls. Messages come oldest-first; unsent messages are tombstones (empty `body`, `deletedAt` set) — render a placeholder, don't filter them out.
46
+ Header and messages are separate reads. Messages arrive oldest-first. An unsent message is a tombstone with empty `body` and non-null `deletedAt`; preserve its position with a placeholder.
57
47
 
58
48
  ```ts snippet
59
- const convo = await board.me.conversations.retrieve(conversationId);
60
- convo.viewerRole; // 'employer' | 'candidate'
61
- convo.viewerLastReadMessageId; // my last-read pointer (null = never)
62
-
49
+ const conversation = await board.me.conversations.retrieve(conversationId);
63
50
  const { data: messages } = await board.me.conversations.listMessages(
64
51
  conversationId,
65
52
  { limit: 50 });
66
- for (const m of messages) {
67
- m.body; // '' when unsent (tombstone)
68
- m.readAt; // read receipt — when the recipient read it, or null
69
- m.editedAt; // non-null after an edit
53
+
54
+ conversation.viewerRole;
55
+ conversation.viewerLastReadMessageId;
56
+
57
+ for (const message of messages) {
58
+ message.body;
59
+ message.deletedAt;
60
+ message.readAt;
61
+ message.editedAt;
70
62
  }
71
63
 
72
- await board.me.conversations.markRead(conversationId); // idempotent
64
+ await board.me.conversations.markRead(conversationId);
73
65
  ```
74
66
 
75
- Call `markRead` when the viewer opens the thread — it clears `hasUnread` and drives the counterparty's `readAt` receipts.
67
+ Call `markRead` when the viewer opens the thread. It clears their unread state and advances read receipts for the counterparty.
76
68
 
77
- ## Sending
69
+ ## Send
78
70
 
79
- `start` cold-initiates a candidate (employer-only) and converges on the existing thread; `startAboutApplication` messages an applicant in application context; `reply` continues a thread. Each returns the created `Message`.
71
+ `findExisting` routes to an existing thread. Employer-only `start` cold-initiates a candidate; `startAboutApplication` begins in application context; `reply` continues a thread. Each send returns a `Message`.
80
72
 
81
73
  ```ts snippet
82
- // Route to an existing thread instead of opening a composer:
83
- const { conversationId } = await board.me.conversations.findExisting({
74
+ const existing = await board.me.conversations.findExisting({
84
75
  candidateBoardUserId,
85
76
  });
86
77
 
@@ -93,28 +84,31 @@ await board.me.conversations.reply(first.conversationId, {
93
84
  });
94
85
  ```
95
86
 
96
- Sends are gated server-side (messaging enabled, cold-message rule, per-pair daily limits, blocks) — failures arrive as `messaging_*` `BoardApiError` codes; branch with the guards from `cavuno-board-errors`.
87
+ The server enforces messaging enablement, cold-message rules, pair limits, and blocks. Handle `messaging_*` `BoardApiError` codes with `cavuno-board-errors`.
97
88
 
98
- ## Edit, unsend, report, block
89
+ ## Edit, moderate, and block
99
90
 
100
- Your own messages can be edited or unsent within a 15-minute window. Reporting a message addressed to you auto-blocks its author.
91
+ Own-message edits and unsends have a 15-minute window. Unsend is idempotent and creates a tombstone. Reporting a message addressed to the viewer automatically blocks its author.
101
92
 
102
93
  ```ts snippet
103
- await board.me.messages.edit(messageId, { body: 'fixed typo' });
104
- await board.me.messages.unsend(messageId); // tombstones; idempotent
94
+ await board.me.messages.edit(messageId, { body: 'Fixed typo' });
95
+ await board.me.messages.unsend(messageId);
105
96
 
106
- const { blocked } = await board.me.messages.report(messageId, {
107
- reason: 'spam', // 'spam' | 'harassment' | 'misrepresentation' | 'other'
97
+ const report = await board.me.messages.report(messageId, {
98
+ reason: 'spam',
108
99
  });
109
100
 
110
- await board.me.blocks.create({ boardUserId }); // silent; idempotent
101
+ await board.me.blocks.create({ boardUserId });
102
+ const blocked = await board.me.blocks.status(boardUserId);
103
+ await board.me.blocks.remove(boardUserId);
111
104
  ```
112
105
 
113
- `board.me.blocks.list()` / `.remove(boardUserId)` / `.status(boardUserId)` round out blocking.
106
+ Report reasons are `'spam' | 'harassment' | 'misrepresentation' | 'other'`. `board.me.blocks.list()` enumerates blocks.
114
107
 
115
- ## Verify
108
+ ## Completion gate
116
109
 
117
- - [ ] Kill the network tab timer: polling stops when the tab is hidden, resumes on focus with a fresh read.
118
- - [ ] Send from a second account: the badge (`unreadCount().count`) and `hasUnread` flip within one poll interval.
119
- - [ ] Open the thread: `markRead` fires once and the sender sees `readAt` populate on their next poll.
120
- - [ ] Unsend a message: it renders as a tombstone (empty `body`), not a gap.
110
+ - Hiding the tab stops the timer; returning starts one timer and refreshes immediately.
111
+ - A second account's message updates `unreadCount().count` and `hasUnread` within one interval.
112
+ - Opening a thread calls `markRead`; the sender later sees `readAt`.
113
+ - Unsend renders a tombstone in place.
114
+ - Messaging policy errors produce deliberate UI states rather than a generic success path.