Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,17 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/dossier"
# Defaults for Settings → Job search; a choice saved in the app overrides them.
# Source: jsearch (LinkedIn, Indeed and more through Google for Jobs; needs a
# key from OpenWeb Ninja or RapidAPI), adzuna (needs an app ID and key),
# arbeitsagentur (Germany only, no key; unofficial, see below) or off. Off
# until you choose.
# arbeitsagentur (Germany only, no key; unofficial, see below), freehire
# (company career pages, no key) or off. Off until you choose.
# DOSSIER_JOBS_SOURCE=jsearch
# DOSSIER_JSEARCH_KEY=
# Where the JSearch key is from: openwebninja (default) or rapidapi.
# DOSSIER_JSEARCH_HOST=openwebninja
# DOSSIER_ADZUNA_APP_ID=
# DOSSIER_ADZUNA_KEY=
# FreeHire origin; unset uses https://freehire.me. Point at a self-hosted
# FreeHire when you run your own.
# DOSSIER_FREEHIRE_URL=
# The Bundesagentur für Arbeit has no official API: Dossier uses the access
# its own job search uses, without its permission, and its terms of use don't
# allow automated access. Whoever chooses it does so on their own
Expand Down
9 changes: 5 additions & 4 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,19 +160,20 @@ docker compose exec -e AI_LIVE=ollama app yarn vitest run src/lib/ai/providers.l

## Job search

Find jobs (`/jobs`) searches the job source chosen in Settings → Job search: JSearch, Adzuna or the Bundesagentur für Arbeit. Until a choice is saved, `DOSSIER_JOBS_SOURCE` and the key variables decide (see `.env.example`). In the code, "job" already means a background task (`Job`, `src/lib/jobs`), so a job ad is a "posting", and job search lives in `src/lib/job-search`:
Find jobs (`/jobs`) searches the job source chosen in Settings → Job search: JSearch, Adzuna, FreeHire or the Bundesagentur für Arbeit. Until a choice is saved, `DOSSIER_JOBS_SOURCE` and the key variables decide (see `.env.example`). In the code, "job" already means a background task (`Job`, `src/lib/jobs`), so a job ad is a "posting", and job search lives in `src/lib/job-search`:

- `search.ts` and `sources.ts`: the query and posting shapes every source maps to, the URL format, and what each source covers. Shared with the browser.
- One adapter per source (`jsearch.ts`, `adzuna.ts`, `arbeitsagentur.ts`), behind `index.ts`, which checks the country, caches answers in memory for 30 minutes and logs a source that changed or isn't answering, without the search. Failures are a `JobSearchError` with a message for the screen.
- `config.ts`: which source and key are in effect. Job source keys are encrypted like the AI key, each kind of secret under its own derived key (`src/lib/secrets.ts`).
- One adapter per source (`jsearch.ts`, `adzuna.ts`, `arbeitsagentur.ts`, `freehire.ts`), behind `index.ts`, which checks the country, caches answers in memory for 30 minutes and logs a source that changed or isn't answering, without the search. Failures are a `JobSearchError` with a message for the screen.
- `config.ts`: which source and key are in effect. Job source keys are encrypted like the AI key, each kind of secret under its own derived key (`src/lib/secrets.ts`). FreeHire needs no key; `DOSSIER_FREEHIRE_URL` points at a self-hosted catalogue when set.
- `src/lib/postings.ts`: the `Posting` table holds what the user saved, dismissed or started an application from. `src/lib/posting-searches.ts`: every search, kept with what it found (`PostingSearch`) until the user removes it, so opening one again asks the source nothing.

The tests use answers in each source's shape (`src/test/job-search-fixtures.ts`). The live tests check the real services, and are skipped otherwise. JSearch and Adzuna spend searches from your plan; the Arbeitsagentur is free:
The tests use answers in each source's shape (`src/test/job-search-fixtures.ts`). The live tests check the real services, and are skipped otherwise. JSearch and Adzuna spend searches from your plan; the Arbeitsagentur and FreeHire are free:

```bash
docker compose exec -e JOBS_LIVE=jsearch -e JSEARCH_TEST_KEY=… app yarn vitest run src/lib/job-search/jsearch.live.test.ts
docker compose exec -e JOBS_LIVE=arbeitsagentur app yarn vitest run src/lib/job-search/sources.live.test.ts
docker compose exec -e JOBS_LIVE=adzuna -e ADZUNA_TEST_APP_ID=… -e ADZUNA_TEST_KEY=… app yarn vitest run src/lib/job-search/sources.live.test.ts
docker compose exec -e JOBS_LIVE=freehire app yarn vitest run src/lib/job-search/sources.live.test.ts
```

## Your data
Expand Down
2 changes: 1 addition & 1 deletion PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Exports you download and database dumps you take are copies outside Dossier's re
|---|---|---|
| A hosted AI provider (OpenAI-compatible, Anthropic or Mistral) | Each AI task, while one is chosen in Settings → AI provider | See [below](#what-a-hosted-ai-provider-receives) |
| Google | While a Google account is connected (Settings → Google) | Signing in: your OAuth client's ID and secret, and the tokens. **Gmail:** the application emails and answers you send, with their PDFs, and a request for new mail on each check (every 10 minutes by default). **Calendar:** the next 14 days, to find your free times; each interview you add, change or remove: its time and a title with the role and company; and requests for the events in the months you look at in the dashboard's calendar, which Dossier shows and doesn't keep. Google already holds your mail and calendar; Dossier only asks for the permissions you give |
| The job source you chose (JSearch, Adzuna or the Bundesagentur für Arbeit) | Each search in Find jobs, which is off until you choose a source, and the job agent's search, at most three times in 24 hours while it's on (never on the Bundesagentur für Arbeit) | What you search for: keywords, place, country, radius and filters. Nothing from your profile: it only fills in the form. The Bundesagentur für Arbeit has no official API: Dossier uses the access its own job search uses, without its permission, so it's searched only after you tick "I understand" under that note, and `DOSSIER_JOBS_ARBEITSAGENTUR=off` takes it out |
| The job source you chose (JSearch, Adzuna, FreeHire or the Bundesagentur für Arbeit) | Each search in Find jobs, which is off until you choose a source, and the job agent's search, at most three times in 24 hours while it's on (never on the Bundesagentur für Arbeit) | What you search for: keywords, place, country, radius and filters. Nothing from your profile: it only fills in the form. FreeHire is a public catalogue (or your self-hosted FreeHire via `DOSSIER_FREEHIRE_URL`) and needs no key. The Bundesagentur für Arbeit has no official API: Dossier uses the access its own job search uses, without its permission, so it's searched only after you tick "I understand" under that note, and `DOSSIER_JOBS_ARBEITSAGENTUR=off` takes it out |
| Hacker News (its official API, and Algolia's search of it) | When you press **Refresh** in Startups, then once a day while you open Startups at least every 14 days | Requests for YC startups' job posts and this month's “Who is hiring?” thread. Nothing about you: which posts suit your city and country is worked out on your server |
| Startups' own sites | Each Startups refresh, for the 12 best new matches with a site of their own, and when you press **Find addresses on their site** or **Look for people's addresses** | Requests for the site's robots.txt and a few public pages. See [Startups](#startups) |
| Anthropic, through Claude | Only while the Claude connector is on (Settings → Claude connector, off by default), and only for the Claude you signed in and allowed: each time Claude uses one of Dossier's tools | What that tool answers. See [Claude connector](#claude-connector) |
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ It's for one person on a small server: one password, your data in your own Postg

- **Profile:** basics, skills and languages, experience, projects, education, publications and references, in the order you choose, with a strength meter that counts only what a profile needs. Projects (side, open-source, course or thesis work) show what you can do where your job titles don't yet. Publications are cited in your own style, with bold and italics, and go on resumes word for word. Start it from an existing resume (PDF, Word or Markdown) if you like.
- **Base resume:** twelve themes and a theme builder, previewed live and printed to PDF on the server. Duplicate themes, or share them as files.
- **Find jobs:** search postings near your city, or anywhere you choose, through JSearch (LinkedIn, Indeed and more) or Adzuna. Save the good ones, dismiss the rest, score any of them against your profile, and start an application from a posting with one click.
- **Find jobs:** search postings near your city, or anywhere you choose, through JSearch (LinkedIn, Indeed and more), Adzuna or FreeHire (company career pages). Save the good ones, dismiss the rest, score any of them against your profile, and start an application from a posting with one click.
- **Startups:** startups that are hiring, from YC startups' job posts and Hacker News' monthly “Who is hiring?” thread, kept to the ones that hire where you live or remotely from your country and ranked against your profile, with the address each one publishes for applications. Dossier reads their sites politely and follows their robots.txt. Start an application to one with a click; you decide whom to write to.
- **Job agent (optional):** once a day it runs one search for you, skips what you've seen, scores the new postings against your profile and keeps the best on the Agent screen, with its reasons. It can prepare tailored drafts too, which wait for your review. It never sends or applies.
- **New application:** paste a posting; Dossier pulls out the details and requirements, matches them against your profile, and writes a tailored resume and cover letter in the tone you pick.
Expand Down Expand Up @@ -112,7 +112,7 @@ To put Dossier on a server, on your own domain with HTTPS, follow the [installat
The [wiki](https://github.com/AienTech/dossier/wiki) has what comes after installing:

- [Choosing an AI provider](https://github.com/AienTech/dossier/wiki/Choosing-an-AI-provider): a hosted model, or a local one with Ollama or LM Studio.
- [Choosing a job source](https://github.com/AienTech/dossier/wiki/Choosing-a-job-source): JSearch or Adzuna (and an unofficial one for Germany), and the job agent.
- [Choosing a job source](https://github.com/AienTech/dossier/wiki/Choosing-a-job-source): JSearch, Adzuna or FreeHire (and an unofficial one for Germany), and the job agent.
- [Connecting Gmail and Calendar](https://github.com/AienTech/dossier/wiki/Connecting-Gmail-and-Calendar): your own Google OAuth client, step by step.
- [Connecting Claude](https://github.com/AienTech/dossier/wiki/Connecting-Claude): the Claude connector, in Claude's apps and on the command line.
- [Backups and restore](https://github.com/AienTech/dossier/wiki/Backups-and-restore): exports, nightly backups and database dumps.
Expand Down
Binary file added docs/pr-proof-freehire/find-jobs-freehire.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-proof-freehire/settings-freehire.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-proof-freehire/vitest-freehire.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions e2e/fake-jobs.mts
Original file line number Diff line number Diff line change
Expand Up @@ -124,5 +124,25 @@ createServer((request, response) => {
return send(response, 200, { ergebnisliste: [{ referenznummer: "e2e-ba", stellenangebotsTitel: "Produktdesigner (m/w/d)", firma: "Hafenlogik GmbH" }], maxErgebnisse: 1 });
}

if (url.pathname === "/freehire/api/v1/agent/jobs/search") {
return send(response, 200, {
data: [
{
public_slug: "e2e-ledgerly-designer",
source: "greenhouse",
title: "Senior Product Designer, Payments",
company: "Ledgerly",
location: "Rotterdam",
url: "https://jobs.example/e2e-ledgerly",
work_mode: "onsite",
posted_at: new Date(Date.now() - 2 * 86_400_000).toISOString(),
description: "Ledgerly is hiring a Senior Product Designer for its payments team.",
enrichment: { salary_min: 75000, salary_max: 90000, salary_currency: "EUR", salary_period: "year" },
},
],
meta: { total: 1, limit: 20, offset: 0 },
});
}

send(response, 404, { error: "not found" });
}).listen(port, "127.0.0.1", () => console.log(`Fake job sources on http://localhost:${port}`));
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "PostingSource" ADD VALUE 'FREEHIRE';
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,7 @@ enum PostingSource {
JSEARCH
ADZUNA
ARBEITSAGENTUR
FREEHIRE
}

/// A posting from Find jobs that the user kept (E14-5): saved, dismissed, or
Expand Down
3 changes: 3 additions & 0 deletions src/app/(app)/settings/job-search-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ export function JobSearchSection({ initial }: { initial: JobSearchSettingsView }
<p className="text-sm text-muted">No key needed. The job agent doesn&apos;t search this source; choose JSearch or Adzuna for it.</p>
</>
)}
{source === "FREEHIRE" && (
<p className="text-sm text-muted">No key needed. Searches go to FreeHire&apos;s public catalogue (or DOSSIER_FREEHIRE_URL when set).</p>
)}

{testable && (
<div className="flex flex-wrap items-center gap-3.5 pt-1">
Expand Down
7 changes: 6 additions & 1 deletion src/components/source-attribution.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,15 @@ describe("attributionHref", () => {
expect(attributionHref("ADZUNA", "")).toBe("https://www.adzuna.co.uk");
});

it("has no link for the other sources", () => {
it("has no link for sources without an attribution link", () => {
expect(attributionHref("JSEARCH", "https://www.adzuna.de/land/ad/1")).toBeNull();
expect(attributionHref("ARBEITSAGENTUR", "https://www.arbeitsagentur.de/jobsuche/jobdetail/1")).toBeNull();
});

it("links FreeHire to freehire.me, or keeps the origin when the posting is on it", () => {
expect(attributionHref("FREEHIRE", "https://boards.greenhouse.io/acme/jobs/1")).toBe("https://freehire.me");
expect(attributionHref("FREEHIRE", "https://freehire.me/jobs/senior-go-engineer-acme-1a2b")).toBe("https://freehire.me");
});
});

describe("SourceAttribution", () => {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/agent/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export async function agentSetupProblem(query: SearchQuery): Promise<string | nu
const config = await loadJobSearchConfig();
if (!config.source) return "Choose a job source in Settings → Job search first.";
// It searches on its own, which the BA's terms forbid most clearly (#223).
if (config.source === "ARBEITSAGENTUR") return "The job agent doesn't search the Bundesagentur für Arbeit. Choose JSearch or Adzuna in Settings → Job search for it.";
if (config.source === "ARBEITSAGENTUR") return "The job agent doesn't search the Bundesagentur für Arbeit. Choose JSearch, Adzuna or FreeHire in Settings → Job search for it.";
try {
credentialsFor(config, config.source);
} catch (error) {
Expand Down
5 changes: 4 additions & 1 deletion src/lib/env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,17 @@ describe("job search variables (E14-4)", () => {
DOSSIER_JSEARCH_HOST: "rapidapi",
DOSSIER_ADZUNA_APP_ID: "app",
DOSSIER_ADZUNA_KEY: "adz",
DOSSIER_FREEHIRE_URL: "https://jobs.example/",
}),
).toMatchObject({
DOSSIER_JOBS_SOURCE: "jsearch",
DOSSIER_JSEARCH_KEY: "ak_test",
DOSSIER_JSEARCH_HOST: "rapidapi",
DOSSIER_ADZUNA_APP_ID: "app",
DOSSIER_ADZUNA_KEY: "adz",
DOSSIER_FREEHIRE_URL: "https://jobs.example",
});
expect(parseEnv({ ...valid, DOSSIER_JOBS_SOURCE: "freehire" }).DOSSIER_JOBS_SOURCE).toBe("freehire");
});

it("reads whether the Bundesagentur für Arbeit is offered, and refuses choosing it while it's off (#223)", () => {
Expand All @@ -165,7 +168,7 @@ describe("job search variables (E14-4)", () => {

it("rejects sources and hosts it doesn't know, without echoing keys", () => {
expect(problems({ ...valid, DOSSIER_JOBS_SOURCE: "linkedin", DOSSIER_JSEARCH_HOST: "google", DOSSIER_JSEARCH_KEY: "ak_secret" })).toEqual([
'DOSSIER_JOBS_SOURCE must be one of jsearch, adzuna, arbeitsagentur, off (got "linkedin").',
'DOSSIER_JOBS_SOURCE must be one of jsearch, adzuna, arbeitsagentur, freehire, off (got "linkedin").',
'DOSSIER_JSEARCH_HOST must be openwebninja or rapidapi (got "google").',
]);
});
Expand Down
7 changes: 6 additions & 1 deletion src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const GENERATE_SECRET = "for example the output of `openssl rand -base64 32`";
const HASH_PASSWORD = "Make one with `docker run --rm -it ghcr.io/aientech/dossier hash-password`";

export const AI_PROVIDERS = ["local", "openai", "anthropic", "mistral", "lmstudio"] as const;
export const JOB_SOURCE_NAMES = ["jsearch", "adzuna", "arbeitsagentur", "off"] as const;
export const JOB_SOURCE_NAMES = ["jsearch", "adzuna", "arbeitsagentur", "freehire", "off"] as const;

// An empty value (`DOSSIER_AI_KEY=` in .env) counts as unset.
const unsetIfEmpty = (value: unknown) =>
Expand Down Expand Up @@ -122,6 +122,11 @@ const schema = z.object({
),
DOSSIER_ADZUNA_APP_ID: optional(z.string()),
DOSSIER_ADZUNA_KEY: optional(z.string()),
// FreeHire origin; unset uses https://freehire.me. Point at a self-hosted
// FreeHire, or leave unset for the public catalogue.
DOSSIER_FREEHIRE_URL: optional(
z.url({ protocol: /^https?$/, error: "DOSSIER_FREEHIRE_URL must be an http:// or https:// URL." }).transform((url) => url.replace(/\/+$/, "")),
),
// Off takes the Bundesagentur für Arbeit out everywhere (#223): it isn't an
// official API, so a server can choose not to offer it. Unset means on.
DOSSIER_JOBS_ARBEITSAGENTUR: optional(
Expand Down
1 change: 1 addition & 0 deletions src/lib/job-search/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type SourceCredentials = {
JSEARCH: { key: string; host: JSearchHost };
ADZUNA: { appId: string; key: string };
ARBEITSAGENTUR: Record<string, never>;
FREEHIRE: Record<string, never>;
};

export type RequestContext = {
Expand Down
11 changes: 11 additions & 0 deletions src/lib/job-search/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ describe("credentialsFor", () => {
it("gives each source its own key", () => {
expect(credentialsFor(config, "JSEARCH")).toEqual({ key: "ak_saved", host: "rapidapi" });
expect(credentialsFor({ ...config, arbeitsagentur: { offered: true, acceptedAt: ACCEPTED } }, "ARBEITSAGENTUR")).toEqual({});
expect(credentialsFor(config, "FREEHIRE")).toEqual({});
});

it("says what's missing", () => {
Expand Down Expand Up @@ -134,5 +135,15 @@ describe("requestContext", () => {
it("leaves sources at their own address, unless the end-to-end tests point them at a fake", () => {
expect(requestContext("JSEARCH", undefined)).toEqual({});
expect(requestContext("ADZUNA", "http://127.0.0.1:3998/")).toEqual({ baseUrl: "http://127.0.0.1:3998/adzuna" });
expect(requestContext("FREEHIRE", undefined, "https://jobs.example")).toEqual({ baseUrl: "https://jobs.example" });
expect(requestContext("FREEHIRE", "http://127.0.0.1:3998/", "https://jobs.example")).toEqual({
baseUrl: "http://127.0.0.1:3998/freehire",
});
});
});

describe("FreeHire from the environment", () => {
it("chooses FreeHire when DOSSIER_JOBS_SOURCE says so", () => {
expect(resolveJobSearchConfig(nothingSaved, { DOSSIER_JOBS_SOURCE: "freehire" }).source).toBe("FREEHIRE");
});
});
Loading