@cavuno/board 1.25.0 → 1.27.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 (49) hide show
  1. package/README.md +88 -0
  2. package/dist/bin.mjs +67 -34
  3. package/dist/filters.d.mts +55 -0
  4. package/dist/filters.d.ts +55 -0
  5. package/dist/filters.js +193 -0
  6. package/dist/filters.mjs +170 -0
  7. package/dist/format.d.mts +240 -0
  8. package/dist/format.d.ts +240 -0
  9. package/dist/format.js +453 -0
  10. package/dist/format.mjs +430 -0
  11. package/dist/index.d.mts +81 -3965
  12. package/dist/index.d.ts +81 -3965
  13. package/dist/index.js +154 -7
  14. package/dist/index.mjs +151 -4
  15. package/dist/jobs-DK5mPBgq.d.mts +3836 -0
  16. package/dist/jobs-DK5mPBgq.d.ts +3836 -0
  17. package/dist/salaries-CXt6Vkrp.d.ts +130 -0
  18. package/dist/salaries-CrJsaZe6.d.mts +130 -0
  19. package/dist/seo.d.mts +288 -0
  20. package/dist/seo.d.ts +288 -0
  21. package/dist/seo.js +1102 -0
  22. package/dist/seo.mjs +1079 -0
  23. package/dist/sitemap.d.mts +63 -0
  24. package/dist/sitemap.d.ts +63 -0
  25. package/dist/sitemap.js +353 -0
  26. package/dist/sitemap.mjs +330 -0
  27. package/dist/theme.d.mts +63 -0
  28. package/dist/theme.d.ts +63 -0
  29. package/dist/theme.js +149 -0
  30. package/dist/theme.mjs +128 -0
  31. package/package.json +57 -2
  32. package/skills/cavuno-board-account/SKILL.md +147 -0
  33. package/skills/cavuno-board-applications/SKILL.md +110 -0
  34. package/skills/cavuno-board-blog/SKILL.md +99 -0
  35. package/skills/cavuno-board-companies/SKILL.md +99 -0
  36. package/skills/cavuno-board-filters/SKILL.md +89 -0
  37. package/skills/cavuno-board-format/SKILL.md +104 -0
  38. package/skills/cavuno-board-job-alerts/SKILL.md +115 -0
  39. package/skills/cavuno-board-job-posting/SKILL.md +130 -0
  40. package/skills/cavuno-board-jobs/SKILL.md +15 -0
  41. package/skills/cavuno-board-messaging/SKILL.md +121 -0
  42. package/skills/cavuno-board-paywall/SKILL.md +108 -0
  43. package/skills/cavuno-board-salaries/SKILL.md +87 -0
  44. package/skills/cavuno-board-seo/SKILL.md +180 -0
  45. package/skills/cavuno-board-setup/SKILL.md +26 -2
  46. package/skills/cavuno-board-sitemap/SKILL.md +147 -0
  47. package/skills/cavuno-board-smoke-test/SKILL.md +84 -0
  48. package/skills/cavuno-board-theme/SKILL.md +69 -0
  49. package/skills/manifest.json +106 -1
@@ -0,0 +1,89 @@
1
+ ---
2
+ name: cavuno-board-filters
3
+ description: The canonical listing-filter vocabulary and URL search-param parsing with @cavuno/board/filters — remote/employment/seniority/sort sets, localized seniority labels, and parseListingFilters for jobs-listing routes. Use when building listing pages, filter sidebars, sort dropdowns, or validating listing URL params.
4
+ ---
5
+
6
+ # Filters: vocabulary + URL parsing
7
+
8
+ `@cavuno/board/filters` is the one filter vocabulary every tenant frontend
9
+ shares — the seniority set is golden-tested against the platform source, and
10
+ URL parsing follows the hosted semantics (hand-typed URLs are messy; parsing
11
+ never throws).
12
+
13
+ ## When to use
14
+
15
+ - The jobs index and every programmatic listing page (category / skill /
16
+ location) — they all layer the same cross-cutting filters on any seed.
17
+ - `validateSearch`-style route param validation (server-safe, SSR-ready).
18
+
19
+ ## When not to use
20
+
21
+ - Turning values into card display text — `cavuno-board-format`.
22
+ - Search QUERIES (`jobs.search` bodies) — this module is about the listing
23
+ URL contract, not the search POST body.
24
+
25
+ ## Parse listing URLs
26
+
27
+ ```ts snippet
28
+ import { parseListingFilters, DEFAULT_SORT } from '@cavuno/board/filters';
29
+
30
+ const filters = parseListingFilters(rawSearchParams);
31
+ // { q?, remoteOption?, employmentType?, seniority?: Seniority[], sort? }
32
+
33
+ const page = await board.jobs.list({
34
+ limit: 20,
35
+ seniority: filters.seniority,
36
+ remoteOption: filters.remoteOption ? [filters.remoteOption] : undefined,
37
+ employmentType: filters.employmentType ? [filters.employmentType] : undefined,
38
+ });
39
+ ```
40
+
41
+ Unknown values are dropped silently (public URLs, never throw). Seniority
42
+ accepts repeated params or a comma-string and normalizes with the hosted
43
+ rules: trim, lowercase, dedupe, keep order.
44
+
45
+ ## Render the filter UI from the vocabulary
46
+
47
+ ```ts snippet
48
+ import {
49
+ REMOTE_OPTIONS,
50
+ EMPLOYMENT_TYPES,
51
+ SENIORITIES,
52
+ JOB_SORTS,
53
+ seniorityLabels,
54
+ sortLabels,
55
+ } from '@cavuno/board/filters';
56
+
57
+ const { language } = await board.context();
58
+ const labels = seniorityLabels(language); // de: executive → "Führungskraft"
59
+ const sorts = sortLabels(language); // English defaults on every locale
60
+ ```
61
+
62
+ Seniority renders as a MULTI-select (hosted parity). `EMPLOYMENT_TYPES`
63
+ deliberately offers 5 of the 7 wire values (`volunteer`/`other` exist on
64
+ jobs but are not filter options). `JOB_SORTS` deliberately excludes
65
+ `oldest` (ADR-0048); `relevance` is the featured-ranked default.
66
+
67
+ ## Anti-patterns
68
+
69
+ ```ts no-check
70
+ // NEVER hand-roll the vocab per page — one page offering 'oldest' or a
71
+ // 6th employment type diverges from every other board frontend:
72
+ const sorts = ['relevance', 'newest', 'oldest'];
73
+ // NEVER trust raw search params into the SDK query:
74
+ board.jobs.list({ seniority: rawSearch.seniority });
75
+ ```
76
+
77
+ ## Out of scope — do not invent exports
78
+
79
+ No URL BUILDER (routes are app-owned — serialize with your router), no
80
+ category/skill/location taxonomy lists (those come from the API:
81
+ `board.taxonomy.*`, `jobs.list` filters), no saved-filter persistence.
82
+
83
+ ## Verify
84
+
85
+ - [ ] `/jobs?seniority=Senior,%20lead&sort=oldest` renders the senior+lead
86
+ selection and the default sort — no crash, no leaked invalid value.
87
+ - [ ] The seniority multi-select shows all 8 levels, labeled in the board
88
+ language.
89
+ - [ ] The sort dropdown offers exactly relevance / newest / salary_high.
@@ -0,0 +1,104 @@
1
+ ---
2
+ name: cavuno-board-format
3
+ description: Display formatting for board frontends with @cavuno/board/format — salary ranges, seniority/employment/remote labels, location labels, dates, and the board-language salary lexicon. Use when rendering job cards, detail pages, or salary figures, and whenever a value like salaryMin/salaryTimeframe/seniority needs to become display text.
4
+ ---
5
+
6
+ # Format: board-language display text
7
+
8
+ `@cavuno/board/format` turns wire values into the exact display text the
9
+ hosted board renders — golden-tested against the hosted formatters, so a
10
+ tenant frontend cannot drift from the platform's rendering rules.
11
+
12
+ ## When to use
13
+
14
+ - Rendering salary ranges, dates, locations, or enum labels anywhere.
15
+ - Building salary-page metadata sentences (the lexicon frames).
16
+
17
+ ## When not to use
18
+
19
+ - Filter vocabulary and URL parsing — `cavuno-board-filters`.
20
+ - Money math. Never compute or convert amounts client-side; format what the
21
+ wire provides.
22
+
23
+ ## The board language is a required first argument
24
+
25
+ Every label-producing helper takes the board language — read it once from
26
+ context. There is no `en` default: a German-native board must never
27
+ silently render English.
28
+
29
+ ```ts snippet
30
+ import {
31
+ formatSalaryRange,
32
+ formatPublishedRelativeDate,
33
+ formatDate,
34
+ fieldLabel,
35
+ } from '@cavuno/board/format';
36
+
37
+ const { language } = await board.context();
38
+
39
+ formatSalaryRange(language, job.salaryMin, job.salaryMax, job.salaryTimeframe, job.salaryCurrency);
40
+ // en: "$90K – $120K Yearly" · de: "90.000 € – 120.000 € pro Jahr"
41
+ formatPublishedRelativeDate(language, job.publishedAt); // "5d ago" — what job CARDS render
42
+ formatDate(language, job.publishedAt); // "Jun 24, 2026" — blog metadata / detail facts (UTC-pinned)
43
+ fieldLabel(language, job.seniority); // de: 'executive' → "Führungskraft"
44
+ ```
45
+
46
+ Job cards and the job-detail header show the RELATIVE form; the absolute
47
+ medium date is for blog metadata and detail fact rows — matching hosted.
48
+
49
+ Localization matches hosted exactly: seniority + timeframe words are
50
+ localized (en+de lexicon, other locales fall back to English words with
51
+ locale-correct number formatting); employment/remote labels are English on
52
+ every board because hosted ships no other vocabulary yet.
53
+
54
+ ## Location labels
55
+
56
+ Cards carry server-computed labels — use them via `cardLocationLabel`. Only
57
+ the full `PublicJob` needs client-side derivation:
58
+
59
+ ```ts snippet
60
+ import { cardLocationLabel, locationLabel, fullJobToCard } from '@cavuno/board/format';
61
+
62
+ cardLocationLabel(language, card); // "Remote · Europe" | "Berlin, Germany"
63
+ locationLabel(language, job); // full PublicJob (detail page)
64
+ const card = fullJobToCard(language, job); // embed a saved job in a card list
65
+ ```
66
+
67
+ ## Salary lexicon (metadata sentences)
68
+
69
+ `getSalaryLexicon(language)` exposes the words and the `<title>`/meta
70
+ sentence frames the hosted salary pages use:
71
+
72
+ ```ts snippet
73
+ import { getSalaryLexicon } from '@cavuno/board/format';
74
+
75
+ const lexicon = getSalaryLexicon(language);
76
+ lexicon.frames.entitySalariesTitle({ entity: 'JavaScript', range: '$70K – $90K' });
77
+ // "JavaScript Salaries ($70K – $90K/yr)"
78
+ lexicon.seniority.senior; // "Senior"
79
+ ```
80
+
81
+ ## Anti-patterns
82
+
83
+ ```ts no-check
84
+ // NEVER hand-format money — symbol placement and words are locale rules:
85
+ `$${(job.salaryMin / 1000).toFixed(0)}k`; // wrong on de boards
86
+ // NEVER default the locale:
87
+ formatSalaryRange('en', ...); // hardcoded 'en' on a de board
88
+ // NEVER rebuild card location labels from scratch — the wire pre-computes them.
89
+ ```
90
+
91
+ ## Out of scope — do not invent exports
92
+
93
+ No currency conversion, no relative-time ("3 days ago") helper, no
94
+ number-only formatter — the SDK formats exactly what the hosted board
95
+ formats. Sort-dropdown copy lives in `cavuno-board-filters`.
96
+
97
+ ## Verify
98
+
99
+ - [ ] A de board renders "pro Jahr"/"ab"/"Führungskraft" where an en board
100
+ renders "Yearly"/"From"/"Executive" — same code path, different
101
+ `language`.
102
+ - [ ] An en board's salary strings are byte-identical to the hosted board's
103
+ for the same job.
104
+ - [ ] No call site passes a hardcoded locale string.
@@ -0,0 +1,115 @@
1
+ ---
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.
4
+ ---
5
+
6
+ # Job alerts: two surfaces
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`.
9
+
10
+ ## When to use
11
+
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).
14
+
15
+ ## When not to use
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`.
26
+
27
+ ```ts snippet
28
+ const sub = await board.jobAlerts.subscribe({
29
+ email: 'ada@example.com',
30
+ consent: true,
31
+ frequency: 'weekly', // 'daily' | 'weekly'
32
+ filters: { jobFunctions: ['engineering'], remoteOptions: ['remote'], placeSlugs: ['berlin'] },
33
+ });
34
+ sub.status; // 'created' | 'duplicate'
35
+ sub.requiresConfirmation; // true when the opt-in email was sent
36
+
37
+ // Confirm page — token from the email link:
38
+ const res = await board.jobAlerts.confirm({ token });
39
+ res.status; // 'confirmed' | 'already_confirmed' | 'expired' | 'not_found'
40
+ if (res.status === 'expired') {
41
+ await board.jobAlerts.resendConfirmation({ email }); // status: 'sent' | 'throttled' | ...
42
+ }
43
+ ```
44
+
45
+ Filter caveat: only `jobFunctions`, `placeSlugs`, and `remoteOptions` scope the digest server-side; `seniorityLevels`/`salaryMin`/`salaryMax`/`salaryCurrency` are stored but do not filter delivery.
46
+
47
+ ## Anonymous: the HMAC manage token
48
+
49
+ 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`.
50
+
51
+ ```ts snippet
52
+ // Manage page, from the email link's query string:
53
+ const state = await board.jobAlerts.manage({ subscription: subscriptionId, token });
54
+ state.email; state.confirmed; state.unsubscribed;
55
+ state.preferences; // [{ id, label, frequency, isActive, filters, manageToken }]
56
+
57
+ await board.jobAlerts.unsubscribe({ subscriptionId, token });
58
+ await board.jobAlerts.resubscribe({ subscriptionId, token });
59
+ await board.jobAlerts.deletePreference({ subscriptionId, preferenceId, token });
60
+ ```
61
+
62
+ `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:
63
+
64
+ ```ts snippet
65
+ const pref = state.preferences[0];
66
+ await board.jobAlerts.updatePreference({
67
+ subscriptionId,
68
+ preferenceId: pref.id,
69
+ token,
70
+ frequency: 'daily', // required — restate even if unchanged
71
+ filters: pref.filters, // round-trip stored filters you aren't editing
72
+ });
73
+ ```
74
+
75
+ ## Authenticated: board.me.alerts
76
+
77
+ Bearer-authenticated CRUD. Alerts are active on create (no opt-in email). The `AlertBody` uses `placeIds` (not `placeSlugs`), and `frequency` is required.
78
+
79
+ ```ts snippet
80
+ const { data: alerts } = await board.me.alerts.list();
81
+ alerts[0].isActive; alerts[0].lastSentAt; alerts[0].filters;
82
+
83
+ const alert = await board.me.alerts.create({
84
+ frequency: 'weekly',
85
+ jobFunctions: ['engineering'],
86
+ remoteOptions: ['remote'],
87
+ });
88
+
89
+ await board.me.alerts.remove(alert.id); // 204 → void; the only way to stop an alert
90
+ ```
91
+
92
+ `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:
93
+
94
+ ```ts snippet
95
+ const current = await board.me.alerts.retrieve(alertId);
96
+ await board.me.alerts.update(alertId, {
97
+ frequency: 'daily', // the one edit
98
+ jobFunctions: current.filters.jobFunctions,
99
+ seniorityLevels: current.filters.seniorityLevels,
100
+ remoteOptions: current.filters.remoteOptions,
101
+ placeIds: current.filters.placeIds,
102
+ salaryMin: current.filters.salaryMin,
103
+ salaryMax: current.filters.salaryMax,
104
+ salaryCurrency: current.filters.salaryCurrency,
105
+ });
106
+ ```
107
+
108
+ ## Verify
109
+
110
+ - [ ] Subscribe form sends `consent: true` and handles `status: 'duplicate'` without erroring.
111
+ - [ ] Confirm page branches on all four statuses (including `expired` → resend).
112
+ - [ ] Manage page reads `subscription` + `token` from the email link's query string; writes send `subscriptionId` in the body.
113
+ - [ ] Editing one preference field leaves the others intact (full-replace round-trip, both surfaces).
114
+ - [ ] Alert UI only promises filtering on job function / place / remote option.
115
+ - [ ] Alert surfaces are hidden when `features.jobAlerts` is false.
@@ -0,0 +1,130 @@
1
+ ---
2
+ name: cavuno-board-job-posting
3
+ description: Build the anonymous "Post a job" funnel with the @cavuno/board SDK — jobPosting.plans, create and its status-discriminated JobPostingResult (checkout / published / pending_approval / invoice_sent), logo upload + fetchLogoByDomain, and the email-verified billing helpers (checkBilling, sendBillingVerification, getBillingOptions, checkSubscriptionEntitlements).
4
+ ---
5
+
6
+ # Job posting: the anonymous submission funnel
7
+
8
+ The no-account "Post a job" wizard (ADR-0042): pick a plan, upload a logo, submit — then branch on the status-discriminated result. Anonymous by design (no board-user token) and rate-limited server-side.
9
+
10
+ Out of scope — do not invent exports: the `checkout` branch returns a Stripe Checkout **URL** — the full-page redirect is host-app-owned, and the webhook publishes the job after payment. The SDK ships no Stripe code and no publish/confirm method for the paid path.
11
+
12
+ ## When to use
13
+
14
+ - The public `/post` wizard: plan picker, logo step, submission, and the post-submit screen.
15
+ - Letting an existing plan/bundle holder submit against remaining capacity via email verification.
16
+
17
+ ## When not to use
18
+
19
+ - Authenticated employer job management (drafts, edit, republish, ATS) — that is `board.me.companies.jobs.*`.
20
+ - The candidate-access paywall — see `cavuno-board-paywall`.
21
+
22
+ ## Plans
23
+
24
+ The chosen plan's `id` goes in `submission.selectedPlan`:
25
+
26
+ ```ts snippet
27
+ const { data: plans } = await board.jobPosting.plans();
28
+ for (const plan of plans) {
29
+ plan.id; // → submission.selectedPlan
30
+ plan.prices; // [{ currency, amountCents, isActive }]
31
+ plan.invoiceOnly; // collect invoiceBilling instead of paying now
32
+ plan.isRecommended; // highlight in the picker
33
+ plan.features; // [{ key, value }] rows for the plan card
34
+ }
35
+ ```
36
+
37
+ ## Logo
38
+
39
+ Upload a file, or fetch by company domain (Brandfetch). Both return a stored `publicUrl` you pass back as `logoUrl` on `create(...)`:
40
+
41
+ ```ts snippet
42
+ const logo = await board.jobPosting.uploadLogo(file); // JPEG/PNG/WebP/GIF, ≤2 MB
43
+ // or:
44
+ const fetched = await board.jobPosting.fetchLogoByDomain('acme.com');
45
+ // a BoardApiError code 'job_posting_logo_not_found' = no usable logo — continue without one
46
+ ```
47
+
48
+ ## Submit and branch on the result
49
+
50
+ `create` returns a `JobPostingResult` — a discriminated union on `.status` with exactly four variants. The host app **must** branch on it; a rejected submission throws a `BoardApiError` instead.
51
+
52
+ ```ts snippet
53
+ const result = await board.jobPosting.create({
54
+ submission: {
55
+ companyName: 'Acme',
56
+ contactName: 'Ada',
57
+ contactEmail: 'ada@acme.com',
58
+ title: 'Staff Engineer',
59
+ description: '<p>…</p>',
60
+ employmentType: 'full_time',
61
+ remoteOption: 'remote',
62
+ officeLocations: [],
63
+ applicationUrl: 'https://acme.com/apply',
64
+ salaryRangeEnabled: false,
65
+ selectedPlan: plan.id,
66
+ },
67
+ logoUrl: logo.publicUrl,
68
+ });
69
+
70
+ switch (result.status) {
71
+ case 'checkout': // paid plan → send the poster to Stripe
72
+ location.href = result.checkoutUrl; // webhook publishes on payment
73
+ break;
74
+ case 'published': // live now
75
+ result.jobSlug; // link straight to the job
76
+ break;
77
+ case 'pending_approval': // moderated board — live after review
78
+ result.jobId;
79
+ break;
80
+ case 'invoice_sent': // invoice plan — a Stripe invoice was emailed
81
+ result.jobId;
82
+ break;
83
+ }
84
+ ```
85
+
86
+ The five billing paths map onto those four statuses: paid-Stripe → `checkout`; free and existing bundle/subscription credit publish without payment (`published`, or `pending_approval` on moderated boards); invoice plans → `invoice_sent`.
87
+
88
+ ## Existing credit: the verified-email branch
89
+
90
+ A submitter whose email already holds a plan/bundle can post against remaining capacity — no new charge. Verify the email first; an option's fields become `selectedBilling`:
91
+
92
+ ```ts snippet
93
+ const { hasActiveBilling } = await board.jobPosting.checkBilling({ email });
94
+ if (hasActiveBilling) {
95
+ await board.jobPosting.sendBillingVerification({ email }); // emails a token
96
+ // …the poster pastes/clicks the token…
97
+ const { options } = await board.jobPosting.getBillingOptions({
98
+ verificationToken,
99
+ });
100
+ const option = options[0]!;
101
+ option.jobsRemaining; // capacity left
102
+ option.featuredRemaining; // featured slots left
103
+ option.renewsAt;
104
+
105
+ await board.jobPosting.create({
106
+ submission, // as above — selectedPlan not needed on this path
107
+ selectedBilling: { type: option.type, id: option.id, planId: option.planId },
108
+ });
109
+ }
110
+ ```
111
+
112
+ For subscription holders, gate the "featured" toggle on entitlements:
113
+
114
+ ```ts snippet
115
+ const ent = await board.jobPosting.checkSubscriptionEntitlements({
116
+ email,
117
+ planId: plan.id,
118
+ });
119
+ ent.hasSubscription;
120
+ ent.canFeature; // may the wizard offer isFeatured?
121
+ ent.featuredSlotsRemaining; // and how many
122
+ ent.maxActiveRemaining;
123
+ ```
124
+
125
+ ## Verify
126
+
127
+ - [ ] Every `create` call site handles all four `result.status` variants — no fallthrough that assumes `published`.
128
+ - [ ] A paid-plan submission lands on Stripe via `result.checkoutUrl`, and the job appears on the board only after payment (webhook), not on redirect.
129
+ - [ ] A free-plan submission on an unmoderated board returns `published` with a `jobSlug` that resolves.
130
+ - [ ] `fetchLogoByDomain` on a bogus domain surfaces the not-found error path, and the wizard still submits without a logo.
@@ -85,6 +85,21 @@ if (page.gatedCount && page.gatedCount > 0) {
85
85
 
86
86
  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.
87
87
 
88
+ ## Full-catalog walks: paginate()
89
+
90
+ 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):
91
+
92
+ ```ts snippet
93
+ import { paginate } from '@cavuno/board';
94
+
95
+ for await (const card of paginate(board.jobs.list, { limit: 100 })) {
96
+ urls.push(card.links.public);
97
+ }
98
+ const first500 = await paginate(board.jobs.list).toArray({ limit: 500 });
99
+ ```
100
+
101
+ Iteration order is stable only under an explicit sort/search — a churning board reorders default browse results between pages.
102
+
88
103
  ## Checklist
89
104
 
90
105
  - [ ] Listing/search use `PublicJobCard`; detail uses `PublicJob`.
@@ -0,0 +1,121 @@
1
+ ---
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.
4
+ ---
5
+
6
+ # Messaging: the polled inbox
7
+
8
+ Employer↔candidate direct messaging for signed-in board users. The v1 transport is **polled REST by design (ADR-0053)** — there is no realtime primitive; near-live UX comes from re-fetching on an interval (3–5s is the reference cadence).
9
+
10
+ Out of scope — do not invent exports: no websockets, no SSE, no `subscribe`/`onMessage` — polling `list` / `listMessages` / `unreadCount` IS the contract.
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:
25
+
26
+ ```ts snippet
27
+ const POLL_MS = 4000; // 3–5s reference cadence
28
+
29
+ async function refresh() {
30
+ const [{ count }, inbox] = await Promise.all([
31
+ board.me.conversations.unreadCount(), // distinct unread threads → badge
32
+ board.me.conversations.list({ limit: 20 }),
33
+ ]);
34
+ render(count, inbox.data);
35
+ }
36
+
37
+ let timer: ReturnType<typeof setInterval> | undefined;
38
+ function start() {
39
+ void refresh();
40
+ timer ??= setInterval(refresh, POLL_MS);
41
+ }
42
+ function stop() {
43
+ clearInterval(timer);
44
+ timer = undefined;
45
+ }
46
+ document.addEventListener('visibilitychange', () => {
47
+ document.visibilityState === 'visible' ? start() : stop();
48
+ });
49
+ start();
50
+ ```
51
+
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.
53
+
54
+ ## The thread: header, messages, read receipts
55
+
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.
57
+
58
+ ```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
+
63
+ const { data: messages } = await board.me.conversations.listMessages(
64
+ conversationId,
65
+ { limit: 50 },
66
+ );
67
+ for (const m of messages) {
68
+ m.body; // '' when unsent (tombstone)
69
+ m.readAt; // read receipt — when the recipient read it, or null
70
+ m.editedAt; // non-null after an edit
71
+ }
72
+
73
+ await board.me.conversations.markRead(conversationId); // idempotent
74
+ ```
75
+
76
+ Call `markRead` when the viewer opens the thread — it clears `hasUnread` and drives the counterparty's `readAt` receipts.
77
+
78
+ ## Sending
79
+
80
+ `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`.
81
+
82
+ ```ts snippet
83
+ // Route to an existing thread instead of opening a composer:
84
+ const { conversationId } = await board.me.conversations.findExisting({
85
+ candidateBoardUserId,
86
+ });
87
+
88
+ const first = await board.me.conversations.start({
89
+ candidateBoardUserId,
90
+ body: 'Hi — your profile looks like a great fit.',
91
+ });
92
+ await board.me.conversations.reply(first.conversationId, {
93
+ body: 'Following up!',
94
+ });
95
+ ```
96
+
97
+ 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`.
98
+
99
+ ## Edit, unsend, report, block
100
+
101
+ Your own messages can be edited or unsent within a 15-minute window. Reporting a message addressed to you auto-blocks its author.
102
+
103
+ ```ts snippet
104
+ await board.me.messages.edit(messageId, { body: 'fixed typo' });
105
+ await board.me.messages.unsend(messageId); // tombstones; idempotent
106
+
107
+ const { blocked } = await board.me.messages.report(messageId, {
108
+ reason: 'spam', // 'spam' | 'harassment' | 'misrepresentation' | 'other'
109
+ });
110
+
111
+ await board.me.blocks.create({ boardUserId }); // silent; idempotent
112
+ ```
113
+
114
+ `board.me.blocks.list()` / `.remove(boardUserId)` / `.status(boardUserId)` round out blocking.
115
+
116
+ ## Verify
117
+
118
+ - [ ] Kill the network tab timer: polling stops when the tab is hidden, resumes on focus with a fresh read.
119
+ - [ ] Send from a second account: the badge (`unreadCount().count`) and `hasUnread` flip within one poll interval.
120
+ - [ ] Open the thread: `markRead` fires once and the sender sees `readAt` populate on their next poll.
121
+ - [ ] Unsend a message: it renders as a tombstone (empty `body`), not a gap.