@cavuno/board 1.25.0 → 1.26.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 (37) 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 +79 -3838
  12. package/dist/index.d.ts +79 -3838
  13. package/dist/index.js +154 -7
  14. package/dist/index.mjs +151 -4
  15. package/dist/jobs-CM67_J6H.d.mts +3836 -0
  16. package/dist/jobs-CM67_J6H.d.ts +3836 -0
  17. package/dist/theme.d.mts +63 -0
  18. package/dist/theme.d.ts +63 -0
  19. package/dist/theme.js +149 -0
  20. package/dist/theme.mjs +128 -0
  21. package/package.json +37 -2
  22. package/skills/cavuno-board-account/SKILL.md +147 -0
  23. package/skills/cavuno-board-applications/SKILL.md +110 -0
  24. package/skills/cavuno-board-blog/SKILL.md +99 -0
  25. package/skills/cavuno-board-companies/SKILL.md +99 -0
  26. package/skills/cavuno-board-filters/SKILL.md +89 -0
  27. package/skills/cavuno-board-format/SKILL.md +104 -0
  28. package/skills/cavuno-board-job-alerts/SKILL.md +115 -0
  29. package/skills/cavuno-board-job-posting/SKILL.md +130 -0
  30. package/skills/cavuno-board-jobs/SKILL.md +15 -0
  31. package/skills/cavuno-board-messaging/SKILL.md +121 -0
  32. package/skills/cavuno-board-paywall/SKILL.md +108 -0
  33. package/skills/cavuno-board-salaries/SKILL.md +87 -0
  34. package/skills/cavuno-board-setup/SKILL.md +26 -2
  35. package/skills/cavuno-board-smoke-test/SKILL.md +84 -0
  36. package/skills/cavuno-board-theme/SKILL.md +69 -0
  37. package/skills/manifest.json +92 -1
@@ -0,0 +1,110 @@
1
+ ---
2
+ name: cavuno-board-applications
3
+ description: The native apply flow with the @cavuno/board SDK — jobs.apply (signed-in or guest), attaching a resume via jobs.uploadApplicationResume, the jobs.myApplication "have I applied?" check, managing submitted applications (me.applications list/retrieve/updateFacts/withdraw), and saved jobs (me.savedJobs). Use when building an apply form, an application-tracker page, or a save-job button.
4
+ ---
5
+
6
+ # Apply flow and application tracking
7
+
8
+ Apply lives on the job (`board.jobs.apply` — optional-auth); everything after submission lives under `board.me.applications` (bearer token required, see `cavuno-board-auth`). Saved jobs follow the same authenticated pattern.
9
+
10
+ ## When to use
11
+
12
+ - The apply form on a job page (native apply, signed-in or guest).
13
+ - Attaching a resume to an application.
14
+ - "My applications" tracker: list, detail, edit facts, withdraw.
15
+ - Save/unsave-job buttons and the saved-jobs page.
16
+
17
+ ## When not to use
18
+
19
+ - Jobs whose `applicationUrl` points off-board (external apply) — just link out.
20
+ - The employer's pipeline view of the same data (`me.companies.applicants.*`).
21
+ - Candidate profile/resume self-service — `cavuno-board-account`.
22
+
23
+ Out of scope — do not invent exports: the SDK provides no apply-form components, file-picker UI, or auth cookie plumbing — the host app owns those. There is also **no guest-claim method** on the SDK: after a guest signs up, their applications are associated via a server-driven magic-link email flow, not an SDK call.
24
+
25
+ ## Submit an application
26
+
27
+ `jobs.apply` is optional-auth on one URL. Signed in, name/email derive from the candidate profile — send at most a `coverNote`. A guest supplies `name` + `email` (allowed only when the board permits applying without sign-up; otherwise the call fails). Idempotent — a repeat apply returns the existing application.
28
+
29
+ ```ts snippet
30
+ // Signed-in candidate:
31
+ const application = await board.jobs.apply('senior-chef', {
32
+ coverNote: 'Excited to cook here.',
33
+ });
34
+
35
+ // Guest (board must allow applications without sign-up):
36
+ const guestApp = await board.jobs.apply('senior-chef', {
37
+ name: 'Ada Lovelace',
38
+ email: 'ada@example.com',
39
+ });
40
+ ```
41
+
42
+ ## Attach a resume
43
+
44
+ A separate multipart call — pass a `Blob`/`File`; the SDK builds the `FormData`. A signed-in candidate targets their own application for the job; a guest passes the `applicationId` returned by `apply`. Returns the updated application (`resumeFilename` set).
45
+
46
+ ```ts snippet
47
+ // Signed-in:
48
+ await board.jobs.uploadApplicationResume('senior-chef', file);
49
+ // Guest:
50
+ await board.jobs.uploadApplicationResume('senior-chef', file, {
51
+ applicationId: guestApp.id,
52
+ });
53
+ ```
54
+
55
+ ## "Have I applied?" — the apply-button state
56
+
57
+ `jobs.myApplication` returns the authenticated candidate's application for the job, and **throws a 404 `BoardApiError` when there is none** — branch with `isNotFound`:
58
+
59
+ ```ts snippet
60
+ import { isNotFound } from '@cavuno/board';
61
+
62
+ try {
63
+ const mine = await board.jobs.myApplication('senior-chef');
64
+ // already applied — show status instead of the apply button
65
+ } catch (err) {
66
+ if (!isNotFound(err)) throw err;
67
+ // not applied yet — show the apply form
68
+ }
69
+ ```
70
+
71
+ ## Application status — polled, not realtime
72
+
73
+ `Application.status` is a coarse candidate-facing state **derived from the employer's pipeline stage**: `'applied' | 'interviewing' | 'negotiation' | 'hired' | 'archived'`. It changes server-side as the employer moves the applicant — the surface is plain REST, so re-fetch to observe updates; nothing pushes to the client. `job` on the application is nullable (the job may have been removed).
74
+
75
+ ## Manage my applications
76
+
77
+ ```ts snippet
78
+ const { data, nextCursor } = await board.me.applications.list({ limit: 20 }); // newest first
79
+ const app = await board.me.applications.retrieve(applicationId);
80
+
81
+ // Merge-patch the candidate-facing facts — only while still editable:
82
+ await board.me.applications.updateFacts(applicationId, {
83
+ coverNote: 'Updated after our call.',
84
+ });
85
+
86
+ await board.me.applications.withdraw(applicationId); // 204 — permanently deletes
87
+ ```
88
+
89
+ `updateFacts` accepts `candidateName`, `candidateEmail`, `candidateHeadline`, `candidateLocation`, `coverNote` (all optional). Withdraw is permanent deletion, not a status flip — confirm before calling.
90
+
91
+ ## Saved jobs — the adjacent pattern
92
+
93
+ Same authenticated `me.*` shape. Each saved row embeds the full `PublicJob`, so the saved-jobs page renders without extra fetches.
94
+
95
+ ```ts snippet
96
+ const saved = await board.me.savedJobs.list({ limit: 20 });
97
+ saved.data[0]?.job.title; // full PublicJob embedded
98
+
99
+ await board.me.savedJobs.save({ jobId: job.id }); // converges — re-saving returns the same row
100
+ await board.me.savedJobs.unsave(job.id); // idempotent — unknown ids still 204
101
+ ```
102
+
103
+ ## Verify
104
+
105
+ - [ ] Signed-in apply sends no name/email; guest apply sends both.
106
+ - [ ] A second apply to the same job returns the same application id (no duplicate).
107
+ - [ ] After `uploadApplicationResume`, the application's `resumeFilename` is set.
108
+ - [ ] The apply button flips state via `myApplication` + `isNotFound`, not a local flag.
109
+ - [ ] Withdraw removes the row from `me.applications.list` on re-fetch.
110
+ - [ ] Unsave leaves the saved-jobs list consistent even when clicked twice.
@@ -0,0 +1,99 @@
1
+ ---
2
+ name: cavuno-board-blog
3
+ description: Build the blog with the @cavuno/board SDK — post archives (blog.posts.list with tagSlug/authorSlug/featured), the post page (blog.posts.retrieve + adjacent + similar), blog.tags and blog.authors, and free-text blog.search. Covers the summary-vs-detail split (html on the single read only), cursor pagination, slug-change redirects (redirected/newSlug), and the SEO fields the API actually returns.
4
+ ---
5
+
6
+ # Blog: posts, tags, authors, search
7
+
8
+ Every list and search returns `PublicBlogPostSummary`; only `posts.retrieve` returns the full `PublicBlogPost` with the `html` body.
9
+
10
+ ## When to use
11
+
12
+ - The blog index, tag archives, author archives, and featured rails.
13
+ - The post page: body, prev/next nav, related-posts rail.
14
+ - Blog search boxes.
15
+
16
+ ## When not to use
17
+
18
+ - Writing posts/tags/authors — that is the admin API; this SDK is the public read surface.
19
+ - Deciding whether the board has a blog at all — read `features.blog` from the board context.
20
+
21
+ Out of scope — do not invent exports: no RSS feed, sitemap, or OG-image generation — the host app builds those routes from the data here (`publishedAt`, `canonicalUrl`, `ogImageUrl` are all returned).
22
+
23
+ ## List posts and archives
24
+
25
+ `blog.posts.list` returns a `ListEnvelope<PublicBlogPostSummary>`. `BlogPostsListQuery` supports `limit` (1–100), `cursor`, `tagSlug`, `authorSlug`, and `featured: 'true'` (opt-in only — the string literal, not a boolean). Blog lists page by cursor only; there is no `offset` and no total `count`.
26
+
27
+ ```ts snippet
28
+ const page = await board.blog.posts.list({ tagSlug: 'news', limit: 12 });
29
+ for (const post of page.data) {
30
+ post.title;
31
+ post.customExcerpt; // null unless the author wrote one
32
+ post.coverUrl; // null when no cover image
33
+ post.readingTimeMin;
34
+ post.authors; // embedded: id, name, slug, bio, avatarUrl, social URLs
35
+ post.tags; // embedded: id, name, slug, description
36
+ }
37
+ const next = page.nextCursor
38
+ ? await board.blog.posts.list({ tagSlug: 'news', limit: 12, cursor: page.nextCursor })
39
+ : null; // nextCursor is null when hasMore is false
40
+ ```
41
+
42
+ ## Render a post
43
+
44
+ `posts.retrieve` adds the detail-only fields: `html` (the rendered rich-text body, nullable), `ogImageUrl`, `featureImageCaption`, `seoTitle`, `seoDescription`, `redirected`, `newSlug`.
45
+
46
+ ```ts snippet
47
+ const post = await board.blog.posts.retrieve('hello-world');
48
+ if (post.redirected && post.newSlug) {
49
+ // the slug changed — redirect to the post at newSlug instead of rendering here
50
+ }
51
+ post.html; // inject as the article body
52
+ post.seoTitle ?? post.title; // <title>
53
+ post.seoDescription ?? post.customExcerpt; // meta description
54
+ post.canonicalUrl; // author-set canonical override, or null
55
+ post.ogImageUrl ?? post.coverUrl; // social card image
56
+ ```
57
+
58
+ Anti-pattern: don't render a post at a stale URL. When `redirected` is true, issue a redirect to `newSlug` — old slugs keep resolving, but the canonical location moved.
59
+
60
+ Anti-pattern: don't look for `html` on list items. Summaries never carry it; fetch `retrieve` for the post page only.
61
+
62
+ ## Prev/next and related
63
+
64
+ ```ts snippet
65
+ const { previous, next } = await board.blog.posts.adjacent('hello-world');
66
+ // previous = older post, next = newer; each a summary or null
67
+
68
+ const rail = await board.blog.posts.similar('hello-world', { limit: 6 }); // 1–20, default 6
69
+ ```
70
+
71
+ ## Tags and authors
72
+
73
+ Both are list + retrieve-by-slug. `PublicBlogTag`: `id`, `name`, `slug`, `description`. `PublicBlogAuthor`: `id`, `name`, `slug`, `bio`, `avatarUrl`, `websiteUrl`, `twitterUrl`, `linkedinUrl`, `githubUrl`.
74
+
75
+ ```ts snippet
76
+ const { data: tags } = await board.blog.tags.list();
77
+ const tag = await board.blog.tags.retrieve('news');
78
+ const { data: authors } = await board.blog.authors.list();
79
+ const author = await board.blog.authors.retrieve('jane');
80
+ ```
81
+
82
+ Archive pages combine the two: `tags.retrieve` for the header, `posts.list({ tagSlug })` for the posts.
83
+
84
+ ## Search
85
+
86
+ `blog.search` posts a `BlogSearchBody` (`query` up to 200 chars, optional `cursor`, `limit` 1–50 — note the lower cap than lists) and returns a `SearchEnvelope<PublicBlogPostSummary>`:
87
+
88
+ ```ts snippet
89
+ const results = await board.blog.search({ query: 'launch', limit: 10 });
90
+ results.data[0]?.slug;
91
+ ```
92
+
93
+ ## Verify
94
+
95
+ - A list item has no `html` key; `posts.retrieve` of the same slug returns it (string or null).
96
+ - Retrieving a post by an old slug returns `redirected: true` with `newSlug` set, and your route redirects there.
97
+ - `posts.list({ featured: 'true' })` returns only posts with `featured: true`.
98
+ - Cursor paging terminates: `nextCursor` is `null` on the last page.
99
+ - `adjacent` on the newest post returns `next: null`; on the oldest, `previous: null`.
@@ -0,0 +1,99 @@
1
+ ---
2
+ name: cavuno-board-companies
3
+ description: Build company surfaces with the @cavuno/board SDK — the companies index (companies.list / companies.search with the market filter), the company profile (companies.retrieve, listJobs, similar), the markets sub-surface (companies.markets + markets.resolve with its 308 redirectTo), and company salary pages (companies.salaries + salaries.category). Covers PublicCompany vs PublicCompanyDetail and envelope pagination.
4
+ ---
5
+
6
+ # Companies: index, profile, markets, salaries
7
+
8
+ Lists and search return `PublicCompany`; only `retrieve` returns `PublicCompanyDetail` (which adds `markets`). Company salary pages are their own sub-surface under `companies.salaries`.
9
+
10
+ ## When to use
11
+
12
+ - The companies index page and its market (sector) filter.
13
+ - The company profile page: detail, open-jobs list, similar-companies rail.
14
+ - Per-company salary pages (`/companies/:slug/salaries[/:category]`).
15
+
16
+ ## When not to use
17
+
18
+ - Board-wide job browse/search — `jobs.*` (see cavuno-board-jobs).
19
+ - Board-wide salary hubs (titles/skills/locations) — `board.salaries.*`.
20
+ - Employer self-service (claiming/managing a company) — the authed `board.me.companies.*` surface.
21
+
22
+ Out of scope — do not invent exports: no company create/update/logo upload (that is the admin API, not this SDK), and no sitemap or OG-image generation — the host app owns those routes.
23
+
24
+ ## List, search, market filter
25
+
26
+ `companies.list` returns a `CompanyListEnvelope`: `ListEnvelope<PublicCompany>` plus optional `relatedSearches` (market suggestions). `CompaniesListQuery` supports `limit` (1–100), `cursor`, `offset` (takes precedence over `cursor`; pair with the response `count` to page in parallel), and `marketSlug` — unknown market slugs 404.
27
+
28
+ ```ts snippet
29
+ const page = await board.companies.list({ limit: 20, marketSlug: 'cybersecurity' });
30
+ page.hasMore;
31
+ page.nextCursor; // null when hasMore is false
32
+ for (const company of page.data) {
33
+ company.name;
34
+ company.publishedJobCount;
35
+ company.links.public; // canonical URL, or null when the company lacks a slug
36
+ }
37
+ ```
38
+
39
+ `companies.search` posts a `CompaniesSearchBody` (`query` free text matched against the company name, up to 200 chars; optional `marketSlug`, `cursor`, `limit` 1–100) and returns a `SearchEnvelope<PublicCompany>`:
40
+
41
+ ```ts snippet
42
+ const results = await board.companies.search({ query: 'acme', limit: 20 });
43
+ ```
44
+
45
+ ## Markets (sectors)
46
+
47
+ `companies.markets` is callable AND carries `.resolve`. The call lists the board's markets ranked by company count (`CompanyMarket`: `slug`, `name`, `companyCount`; `limit` 1–200, default 100 — a top-N preview; optional `search`). `.resolve(slug)` returns a `TaxonomyResolution` — `sourceSlug`, `canonicalSlug`, `displayName`, and `redirectTo` (the canonical slug to 308 to when the inbound slug differs; `null` otherwise).
48
+
49
+ ```ts snippet
50
+ const { data: markets } = await board.companies.markets({ search: 'robotics' });
51
+ const market = await board.companies.markets.resolve('cybersecurity');
52
+ if (market.redirectTo) {
53
+ // 308 the market-scoped browse to the canonical slug
54
+ }
55
+ ```
56
+
57
+ Resolve first, then pass the resolved slug as `marketSlug` — don't guess slugs (unknown ones 404).
58
+
59
+ ## Profile: retrieve, jobs, similar
60
+
61
+ `PublicCompany` (lists/search) carries: `id`, `name`, `slug`, `website`, `logoUrl`, `description`, `jobCount`, `publishedJobCount`, `links.public`. `PublicCompanyDetail` (retrieve only) adds `markets: CompanyMarketRef[]` (`name` + source `slug`; empty when none).
62
+
63
+ ```ts snippet
64
+ const company = await board.companies.retrieve('acme'); // PublicCompanyDetail
65
+ company.markets; // detail-only
66
+
67
+ const jobs = await board.companies.listJobs('acme', { limit: 10 });
68
+ // JobCardListEnvelope — same PublicJobCard shape as jobs.list; cursor + limit only
69
+
70
+ const rail = await board.companies.similar('acme', { limit: 6 }); // 1–20, default 6
71
+ ```
72
+
73
+ `similar` uses the hosted ranking (most open roles first) and excludes the company itself.
74
+
75
+ Anti-pattern: don't render `markets` from a list row — it only exists on the detail. Fetch `retrieve` for the profile page.
76
+
77
+ ## Company salaries
78
+
79
+ `companies.salaries` is callable AND carries `.category`. The call is the company salary overview (`CompanySalary`): `overallSalary` (nullable), `bySeniority` rows vs the board baseline (`diffPercent`), `competitors`, `topLocations`, `byCategory`, board-wide baselines, and `currency`. `.category(companySlug, categorySlug, { locale })` is one job category at the company (`CompanyCategorySalary`).
80
+
81
+ ```ts snippet
82
+ const overview = await board.companies.salaries('acme');
83
+ overview.bySeniority[0]?.diffPercent; // vs the board baseline, or null
84
+
85
+ const cat = await board.companies.salaries.category('acme', 'software-engineer', {
86
+ locale: 'de',
87
+ });
88
+ cat.categorySourceSlug; // immutable English slug
89
+ cat.categoryCanonicalSlug; // board-language slug — 308 to it when the inbound differs
90
+ ```
91
+
92
+ Pass `{ locale }` for board-language category names; the company slug/name are never localized. The response returns BOTH `categorySourceSlug` and `categoryCanonicalSlug` — the consumer issues the 308 itself.
93
+
94
+ ## Verify
95
+
96
+ - `companies.retrieve('<known-slug>')` returns `object: 'public_company'` with a `markets` array; the same company in `companies.list` has no `markets` key.
97
+ - A slug from `companies.markets()` works as `marketSlug`; a made-up slug returns 404 (`isNotFound`).
98
+ - Paginating with `nextCursor` terminates: `nextCursor` is `null` on the last page.
99
+ - `companies.salaries.category(...)` with a non-canonical category slug returns a differing `categoryCanonicalSlug`, and your route 308s to it.
@@ -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.