@cavuno/board 2.0.5 → 3.1.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.
- package/CHANGELOG.md +22 -0
- package/README.md +12 -3
- package/dist/{_spec-BKtVFcV9.d.mts → _spec-nkWAraS0.d.mts} +4 -18
- package/dist/{_spec-BKtVFcV9.d.ts → _spec-nkWAraS0.d.ts} +4 -18
- package/dist/bin.mjs +40 -57
- package/dist/{board-3Xj_Y-Tx.d.ts → board-NE98PlwN.d.ts} +2 -3
- package/dist/{board-pNN710Zc.d.mts → board-muu8BI8P.d.mts} +2 -3
- package/dist/doctor.js +21 -47
- package/dist/doctor.mjs +24 -50
- package/dist/filters.d.mts +2 -2
- package/dist/filters.d.ts +2 -2
- package/dist/format.d.mts +3 -3
- package/dist/format.d.ts +3 -3
- package/dist/index.d.mts +11 -27
- package/dist/index.d.ts +11 -27
- package/dist/index.js +3 -3
- package/dist/index.mjs +3 -3
- package/dist/{jobs-C4qRfCdP.d.ts → jobs-BiEvL9d7.d.ts} +1 -1
- package/dist/{jobs-k84C0v_8.d.mts → jobs-lVvKCBCU.d.mts} +1 -1
- package/dist/{salaries--J5mTylN.d.ts → salaries-BahBpxWT.d.ts} +2 -2
- package/dist/{salaries-C9YLO5NG.d.mts → salaries-ChSqDBAM.d.mts} +2 -2
- package/dist/{search-CE_qQiM7.d.mts → search-CCyFWPi3.d.mts} +1 -1
- package/dist/{search-CLRQ_El9.d.ts → search-CyRvVMUp.d.ts} +1 -1
- package/dist/seo.d.mts +4 -4
- package/dist/seo.d.ts +4 -4
- package/dist/server.d.mts +5 -5
- package/dist/server.d.ts +5 -5
- package/dist/sitemap.d.mts +5 -5
- package/dist/sitemap.d.ts +5 -5
- package/dist/skills.d.mts +15 -4
- package/dist/skills.d.ts +15 -4
- package/dist/skills.js +30 -7
- package/dist/skills.mjs +30 -8
- package/dist/suggest.d.mts +2 -2
- package/dist/suggest.d.ts +2 -2
- package/package.json +1 -11
- package/skills/cavuno-board-account/SKILL.md +58 -66
- package/skills/cavuno-board-api-client/SKILL.md +78 -0
- package/skills/cavuno-board-applications/SKILL.md +42 -52
- package/skills/cavuno-board-auth/SKILL.md +39 -60
- package/skills/cavuno-board-blog/SKILL.md +64 -49
- package/skills/cavuno-board-companies/SKILL.md +75 -53
- package/skills/cavuno-board-errors/SKILL.md +32 -45
- package/skills/cavuno-board-filters/SKILL.md +68 -75
- package/skills/cavuno-board-format/SKILL.md +65 -66
- package/skills/cavuno-board-i18n/SKILL.md +43 -68
- package/skills/cavuno-board-job-alerts/SKILL.md +72 -57
- package/skills/cavuno-board-jobs/SKILL.md +67 -55
- package/skills/cavuno-board-messaging/SKILL.md +57 -59
- package/skills/cavuno-board-paywall/SKILL.md +52 -54
- package/skills/cavuno-board-post-a-job/SKILL.md +122 -0
- package/skills/cavuno-board-salaries/SKILL.md +77 -50
- package/skills/cavuno-board-search-suggestions/SKILL.md +86 -0
- package/skills/cavuno-board-seo/BLOG.md +40 -0
- package/skills/cavuno-board-seo/JOB_AND_BREADCRUMBS.md +49 -0
- package/skills/cavuno-board-seo/LISTING.md +36 -0
- package/skills/cavuno-board-seo/SALARY.md +61 -0
- package/skills/cavuno-board-seo/SKILL.md +34 -161
- package/skills/cavuno-board-server-sessions/SKILL.md +147 -0
- package/skills/cavuno-board-setup/SKILL.md +46 -79
- package/skills/cavuno-board-sitemap/SKILL.md +78 -91
- package/skills/cavuno-board-smoke-test/SKILL.md +38 -69
- package/skills/manifest.json +43 -57
- package/dist/theme.d.mts +0 -115
- package/dist/theme.d.ts +0 -115
- package/dist/theme.js +0 -195
- package/dist/theme.mjs +0 -174
- package/skills/cavuno-board-client/SKILL.md +0 -89
- package/skills/cavuno-board-job-posting/SKILL.md +0 -116
- package/skills/cavuno-board-server/SKILL.md +0 -229
- package/skills/cavuno-board-suggest/SKILL.md +0 -119
- package/skills/cavuno-board-theme/SKILL.md +0 -57
- package/skills/flavors/tanstack-start/SKILL.md +0 -102
|
@@ -1,83 +1,97 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: cavuno-board-job-alerts
|
|
3
|
-
description:
|
|
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
|
|
6
|
+
# Job-alert two-lane model
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Choose exactly one lane:
|
|
9
9
|
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
15
|
+
## Anonymous lane: subscribe and confirm
|
|
16
16
|
|
|
17
|
-
|
|
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
|
|
20
|
+
const submitted = await board.jobAlerts.subscribe({
|
|
29
21
|
email: 'ada@example.com',
|
|
30
22
|
consent: true,
|
|
31
|
-
frequency: 'weekly',
|
|
32
|
-
filters: {
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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`.
|
|
44
|
+
|
|
45
|
+
## Anonymous lane: manage-token transport
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
The read and write transports intentionally use different keys:
|
|
47
48
|
|
|
48
|
-
|
|
49
|
+
- `manage` is GET with query `{ subscription, token }`.
|
|
50
|
+
- Writes carry `{ subscriptionId, token }` in their body.
|
|
49
51
|
|
|
50
52
|
```ts snippet
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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({
|
|
64
|
+
await board.jobAlerts.deletePreference({
|
|
65
|
+
subscriptionId,
|
|
66
|
+
preferenceId,
|
|
67
|
+
token,
|
|
68
|
+
});
|
|
59
69
|
```
|
|
60
70
|
|
|
61
|
-
`unsubscribe
|
|
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
|
|
76
|
+
const preference = state.preferences[0]!;
|
|
65
77
|
await board.jobAlerts.updatePreference({
|
|
66
78
|
subscriptionId,
|
|
67
|
-
preferenceId:
|
|
79
|
+
preferenceId: preference.id,
|
|
68
80
|
token,
|
|
69
|
-
frequency: 'weekly',
|
|
70
|
-
filters:
|
|
81
|
+
frequency: 'weekly',
|
|
82
|
+
filters: preference.filters,
|
|
71
83
|
});
|
|
72
84
|
```
|
|
73
85
|
|
|
74
|
-
##
|
|
86
|
+
## Signed-in lane
|
|
75
87
|
|
|
76
|
-
|
|
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]
|
|
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.
|
|
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,21 @@ 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
|
-
|
|
117
|
+
Removal is the signed-in lane's stop action; there is no pause state.
|
|
118
|
+
|
|
119
|
+
## Completion gate
|
|
120
|
+
|
|
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.
|
|
127
|
+
|
|
128
|
+
## Cavuno SDK reference
|
|
108
129
|
|
|
109
|
-
|
|
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.
|
|
130
|
+
For setup and API details beyond this workflow, use the [Cavuno Board SDK documentation](https://cavuno.com/docs/sdk).
|
|
@@ -1,58 +1,55 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: cavuno-board-jobs
|
|
3
|
-
description:
|
|
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
|
|
6
|
+
# Jobs
|
|
7
7
|
|
|
8
|
-
|
|
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
|
-
|
|
12
|
+
Company-scoped collections use `companies.listJobs`. The ungated embeddable
|
|
13
|
+
widget uses `embed.jobs`.
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
- The job-detail page and its "similar jobs" rail.
|
|
15
|
+
## Browse and paginate
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
`jobs.list(query)` returns `JobCardListEnvelope`: `data`, `count`, `limit`,
|
|
18
|
+
`offset`, `hasMore`, `nextCursor`, optional `relatedSearches`, and optional
|
|
19
|
+
`gatedCount`.
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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;
|
|
40
|
+
card.links.public;
|
|
33
41
|
}
|
|
34
42
|
```
|
|
35
43
|
|
|
36
|
-
|
|
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`:
|
|
39
|
-
|
|
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
|
-
```
|
|
44
|
+
Render `links.public` as the canonical
|
|
45
|
+
`/companies/:companySlug/jobs/:jobSlug` URL.
|
|
48
46
|
|
|
49
|
-
###
|
|
47
|
+
### Filter companies by public slug
|
|
50
48
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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
|
-
##
|
|
80
|
+
## Render detail and similar jobs
|
|
82
81
|
|
|
83
82
|
```ts snippet
|
|
84
|
-
const job = await board.jobs.retrieve('senior-chef');
|
|
85
|
-
job.description;
|
|
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
|
-
##
|
|
91
|
+
## Surface candidate gating
|
|
92
92
|
|
|
93
|
-
On gated
|
|
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
|
|
98
|
-
//
|
|
99
|
+
if ((page.gatedCount ?? 0) > 0) {
|
|
100
|
+
// Render “Sign in to see N more roles”.
|
|
99
101
|
}
|
|
100
102
|
```
|
|
101
103
|
|
|
102
|
-
|
|
104
|
+
## Walk the full catalog
|
|
103
105
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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,21 @@ 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
|
-
|
|
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
|
|
123
|
+
|
|
124
|
+
Finish only after every applicable check passes:
|
|
125
|
+
|
|
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.
|
|
118
133
|
|
|
119
|
-
##
|
|
134
|
+
## Cavuno SDK reference
|
|
120
135
|
|
|
121
|
-
|
|
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.
|
|
136
|
+
For setup and API details beyond this workflow, use the [Cavuno Board SDK documentation](https://cavuno.com/docs/sdk).
|
|
@@ -1,86 +1,77 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: cavuno-board-messaging
|
|
3
|
-
description:
|
|
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
|
-
#
|
|
6
|
+
# Polled messaging contract
|
|
7
7
|
|
|
8
|
-
|
|
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
|
-
|
|
10
|
+
## Keep the poll visible
|
|
11
11
|
|
|
12
|
-
|
|
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
|
|
15
|
+
const pollMs = 4000;
|
|
28
16
|
|
|
29
|
-
async function
|
|
17
|
+
async function refreshInbox() {
|
|
30
18
|
const [{ count }, inbox] = await Promise.all([
|
|
31
|
-
board.me.conversations.unreadCount(),
|
|
19
|
+
board.me.conversations.unreadCount(),
|
|
32
20
|
board.me.conversations.list({ limit: 20 }),
|
|
33
21
|
]);
|
|
34
|
-
|
|
22
|
+
renderInbox(count, inbox.data);
|
|
35
23
|
}
|
|
36
24
|
|
|
37
25
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
38
|
-
function
|
|
39
|
-
void
|
|
40
|
-
timer ??= setInterval(
|
|
26
|
+
function startPolling() {
|
|
27
|
+
void refreshInbox();
|
|
28
|
+
timer ??= setInterval(refreshInbox, pollMs);
|
|
41
29
|
}
|
|
42
|
-
function
|
|
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'
|
|
36
|
+
if (document.visibilityState === 'visible') startPolling();
|
|
37
|
+
else stopPolling();
|
|
48
38
|
});
|
|
49
|
-
|
|
39
|
+
startPolling();
|
|
50
40
|
```
|
|
51
41
|
|
|
52
|
-
|
|
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
|
-
##
|
|
44
|
+
## Render a thread faithfully
|
|
55
45
|
|
|
56
|
-
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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);
|
|
64
|
+
await board.me.conversations.markRead(conversationId);
|
|
73
65
|
```
|
|
74
66
|
|
|
75
|
-
Call `markRead` when the viewer opens the thread
|
|
67
|
+
Call `markRead` when the viewer opens the thread. It clears their unread state and advances read receipts for the counterparty.
|
|
76
68
|
|
|
77
|
-
##
|
|
69
|
+
## Send
|
|
78
70
|
|
|
79
|
-
`
|
|
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
|
-
|
|
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,35 @@ await board.me.conversations.reply(first.conversationId, {
|
|
|
93
84
|
});
|
|
94
85
|
```
|
|
95
86
|
|
|
96
|
-
|
|
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,
|
|
89
|
+
## Edit, moderate, and block
|
|
99
90
|
|
|
100
|
-
|
|
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: '
|
|
104
|
-
await board.me.messages.unsend(messageId);
|
|
94
|
+
await board.me.messages.edit(messageId, { body: 'Fixed typo' });
|
|
95
|
+
await board.me.messages.unsend(messageId);
|
|
105
96
|
|
|
106
|
-
const
|
|
107
|
-
reason: 'spam',
|
|
97
|
+
const report = await board.me.messages.report(messageId, {
|
|
98
|
+
reason: 'spam',
|
|
108
99
|
});
|
|
109
100
|
|
|
110
|
-
await board.me.blocks.create({ boardUserId });
|
|
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()`
|
|
106
|
+
Report reasons are `'spam' | 'harassment' | 'misrepresentation' | 'other'`. `board.me.blocks.list()` enumerates blocks.
|
|
107
|
+
|
|
108
|
+
## Completion gate
|
|
109
|
+
|
|
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.
|
|
114
115
|
|
|
115
|
-
##
|
|
116
|
+
## Cavuno SDK reference
|
|
116
117
|
|
|
117
|
-
|
|
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.
|
|
118
|
+
For setup and API details beyond this workflow, use the [Cavuno Board SDK documentation](https://cavuno.com/docs/sdk).
|