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.
# firecrawl (one web search across LinkedIn, Indeed, Naukri, Glassdoor,
# Greenhouse, Lever and company sites at once; needs a key from
# firecrawl.dev — this fork, see README), arbeitsagentur (Germany only, no
# key; unofficial, see below) 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=
# DOSSIER_FIRECRAWL_KEY=
# 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ yarn-error.log*
.env*
!.env.example

# local port overrides for running a second dev stack side by side
compose.override.yml

# vercel
.vercel

Expand Down
39 changes: 38 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ It's for one person on a small server: one password, your data in your own Postg

**Get started:** [run it on your computer](#run-it-on-your-computer) in a few minutes, or [self-host it](#self-hosting) on a server. The [installation guide](https://github.com/AienTech/dossier/wiki) covers every way: Docker with or without Compose, Node.js, a plain Ubuntu server, CapRover, Coolify and Dokploy.

## This fork

This is a fork of [AienTech/dossier](https://github.com/AienTech/dossier) with changes made for my own job search:

- **JSearch's 10 s timeout was too tight.** A live request against OpenWeb Ninja's JSearch API took 10.3 s to answer, so every search lost the race against Dossier's hard-coded 10-second limit and came back "JSearch isn't answering right now" even with a working key. [`src/lib/job-search/http.ts`](src/lib/job-search/http.ts) now waits 25 s.
- **Blocked companies.** Settings → Job search has a new "Blocked companies" list: Find jobs, the job agent and the Claude connector never show, score or draft an application for a posting that names one of these as the company, or mentions it in the title or description — which also catches a staffing listing for a role placed there. See [`src/lib/job-search/blocklist.ts`](src/lib/job-search/blocklist.ts).
- **Firecrawl, a fourth job source.** JSearch and Adzuna each index one source; Firecrawl instead runs two web searches — one biased toward LinkedIn, Indeed, Naukri, Glassdoor, Monster, Greenhouse, Lever, Ashby, Workday and SmartRecruiters, one wide open with no site restriction at all for whatever that list misses (a smaller board, a company's own careers page) — merged into one page, the way actually googling "data engineer jobs in Bengaluru" would, without Dossier maintaining a scraper per site (which breaks the moment a site's markup changes, and risks its terms of use). Get a key at [firecrawl.dev](https://www.firecrawl.dev/app/api-keys) and choose it in Settings → Job search. See [`src/lib/job-search/firecrawl.ts`](src/lib/job-search/firecrawl.ts).
- **Falls back across sources.** Save a key for more than one source (JSearch, Adzuna and Firecrawl each keep their own, independent of which is "chosen") and a fresh search tries the chosen one first, then moves on to the next with a key saved if it's out of quota, down, or doesn't cover that country — instead of just failing. A kept search's "Show more" always stays on the exact source it started with. See [`searchWithFallback` in `src/lib/job-search/find.ts`](src/lib/job-search/find.ts).
- **Hyperlinks on the resume.** Email, website and LinkedIn in the header were plain text; they're links now (`mailto:`, `tel:` and `https://`), styled the same muted, underline-free way as a publication's citation link. See [`src/lib/resume-content.ts`](src/lib/resume-content.ts) and [`Contact`/`ContactLine` in `document-page.tsx`](src/components/documents/document-page.tsx).
- **One-page resumes already existed** — it just wasn't on by default. Settings → Documents → Resume length was already "Up to two pages"; nothing to fix, just set it to "One page" if that's what you want (upstream feature, not this fork's).

Everything else — the profile, resumes, cover letters, tracking, the job agent, the Claude connector — is upstream Dossier, unchanged. [Build and run this fork](#build-and-run-this-fork) below; [upstream's own instructions](#run-it-on-your-computer) pull their published image instead, without these changes.

## Demo

![Dossier in use: the dashboard, dragging a card on the board, a new application from a pasted posting, refining the tailored resume in the chat, the cover letter, and switching resume themes](.github/images/demo.gif)
Expand All @@ -33,7 +46,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 Firecrawl (this fork: one web search across LinkedIn, Indeed, Naukri, Glassdoor, Greenhouse, Lever and company sites at once — see [This fork](#this-fork)). Save the good ones, dismiss the rest, score any of them against your profile, and start an application from a posting with one click. Block a company in Settings → Job search (this fork) and no search shows it again.
- **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 @@ -103,6 +116,30 @@ The first start downloads the image (about 1.7 GB) and sets up the database. Ope

`docker compose stop` stops it and `docker compose start` starts it again, with your data kept. It's only reachable from your own computer. The [installation guide's Docker Compose page](https://github.com/AienTech/dossier/wiki/Run-locally-with-Docker-Compose) has more: another port, your time zone, upgrades and removing it.

## Build and run this fork

The steps above pull upstream's published image, without this fork's changes. To build this fork's own image instead:

```bash
git clone https://github.com/rahulrathore95/dossier.git
cd dossier
docker build -t dossier-fork:latest .
```

This runs the same `Dockerfile` the published image is built from (the `production` stage, built by default), so the result is a drop-in replacement for `ghcr.io/aientech/dossier`. Then run it with [`compose.production.yml`](compose.production.yml), pointing `DOSSIER_IMAGE` at the image you just built instead of the published one:

```bash
mkdir -p ~/dossier && cd ~/dossier
cp /path/to/this/repo/compose.production.yml compose.yml
echo "DOSSIER_IMAGE=dossier-fork:latest" > .env
echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> .env
echo "DOSSIER_SECRET=$(openssl rand -base64 32)" >> .env
docker run --rm -it dossier-fork:latest hash-password # add the printed line to .env as DOSSIER_PASSWORD=…
docker compose up -d
```

Open [http://localhost:3000](http://localhost:3000) and sign in. Rebuilding after a code change is the same `docker build` command, then `docker compose up -d` again to pick it up. [Choosing a job source](https://github.com/AienTech/dossier/wiki/Choosing-a-job-source) covers JSearch and Adzuna; for Firecrawl, get a key at [firecrawl.dev](https://www.firecrawl.dev/app/api-keys) and enter it in **Settings → Job search**, or set `DOSSIER_FIRECRAWL_KEY` the same way as the other sources' keys.

## Self-hosting

To put Dossier on a server, on your own domain with HTTPS, follow the [installation guide](https://github.com/AienTech/dossier/wiki#self-host-on-a-server): a plain Ubuntu server with Docker Compose and Caddy (or Node.js, without Docker), CapRover, Coolify or Dokploy. Give the server at least 2 vCPUs and 2 GB of memory (see [Performance and sizing](#performance-and-sizing)).
Expand Down
1 change: 1 addition & 0 deletions compose.production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ services:
DOSSIER_JSEARCH_HOST: ${DOSSIER_JSEARCH_HOST:-}
DOSSIER_ADZUNA_APP_ID: ${DOSSIER_ADZUNA_APP_ID:-}
DOSSIER_ADZUNA_KEY: ${DOSSIER_ADZUNA_KEY:-}
DOSSIER_FIRECRAWL_KEY: ${DOSSIER_FIRECRAWL_KEY:-}
# Gmail and Calendar: a Google OAuth client (or enter it in Settings → Google).
DOSSIER_GOOGLE_CLIENT_ID: ${DOSSIER_GOOGLE_CLIENT_ID:-}
DOSSIER_GOOGLE_CLIENT_SECRET: ${DOSSIER_GOOGLE_CLIENT_SECRET:-}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "PostingSource" ADD VALUE 'FIRECRAWL';
3 changes: 2 additions & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -575,10 +575,11 @@ model Note {

// ─── Job search (E14) ────────────────────────────────────────────────────────

/// The job sources Find jobs can search (E14-1 to E14-3).
/// The job sources Find jobs can search (E14-1 to E14-3, E14-7).
enum PostingSource {
JSEARCH
ADZUNA
FIRECRAWL
ARBEITSAGENTUR
}

Expand Down
2 changes: 2 additions & 0 deletions src/app/(app)/settings/actions.db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,9 @@ describe("job search settings (E14-4)", () => {
source: "JSEARCH",
jsearch: { host: "openwebninja", keyState: "none" },
adzuna: { appId: "", keyState: "none" },
firecrawl: { keyState: "none" },
arbeitsagentur: { offered: true, acceptedAt: null },
blockedCompanies: [],
},
});
await saveJobSource({ source: "OFF" });
Expand Down
34 changes: 32 additions & 2 deletions src/app/(app)/settings/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,17 +140,19 @@ export const saveJobSearchDetails = action(
},
);

const JOB_SOURCE_KEY_FIELD = { JSEARCH: "jsearchKeyEnc", ADZUNA: "adzunaKeyEnc", FIRECRAWL: "firecrawlKeyEnc" } as const;

/** Saves a source's key, encrypted for job sources only, or removes it. The key never comes back to the browser. */
export const saveJobSourceKey = action(
z.object({
source: z.enum(["JSEARCH", "ADZUNA"]),
source: z.enum(["JSEARCH", "ADZUNA", "FIRECRAWL"]),
change: z.discriminatedUnion("action", [
z.object({ action: z.literal("set"), value: z.string().trim().min(1).max(1000) }),
z.object({ action: z.literal("clear") }),
]),
}),
async ({ source, change }) => {
const field = source === "JSEARCH" ? "jsearchKeyEnc" : "adzunaKeyEnc";
const field = JOB_SOURCE_KEY_FIELD[source];
const current = await loadSettings();
await saveSettings({ [field]: applySecretChange(JOB_SOURCE_KEY, current[field], change) });
return loadView();
Expand All @@ -171,6 +173,34 @@ export const testJobSource = action(z.object({}), async () => {
}
});

/** The most companies Settings → Job search keeps blocked at once; past that, ask the user to trim the list rather than letting it grow without bound. */
const MAX_BLOCKED_COMPANIES = 50;

/**
* Adds a company to the blocklist (this fork, E14-8): no search shows, scores
* or drafts for it again. Case-insensitive against what's already there, so
* "Best Buy" twice stays one entry.
*/
export const blockCompany = action(
z.object({ name: z.string().trim().min(1, "Type a company name first.").max(200) }),
async ({ name }) => {
const { blockedCompanies } = await loadSettings();
if (blockedCompanies.some((entry) => entry.toLocaleLowerCase() === name.toLocaleLowerCase())) return { blockedCompanies };
if (blockedCompanies.length >= MAX_BLOCKED_COMPANIES) throw new ActionError(`You can block up to ${MAX_BLOCKED_COMPANIES} companies; remove one first.`);
const next = [...blockedCompanies, name];
await saveSettings({ blockedCompanies: next });
return { blockedCompanies: next };
},
);

/** Removes a company from the blocklist. */
export const unblockCompany = action(z.object({ name: z.string().min(1) }), async ({ name }) => {
const { blockedCompanies } = await loadSettings();
const next = blockedCompanies.filter((entry) => entry !== name);
await saveSettings({ blockedCompanies: next });
return { blockedCompanies: next };
});

/**
* Saves the settings that are a plain choice or switch, each as it changes
* (E10-2, E10-3). Only the fields given are touched.
Expand Down
33 changes: 31 additions & 2 deletions src/app/(app)/settings/job-search-section.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,26 @@ import { beforeEach, describe, expect, it, vi } from "vitest";

import type { JobSearchSettingsView } from "@/lib/job-search/sources";

import { saveJobSearchDetails, saveJobSource, saveJobSourceKey, testJobSource } from "./actions";
import { blockCompany, saveJobSearchDetails, saveJobSource, saveJobSourceKey, testJobSource, unblockCompany } from "./actions";
import { JobSearchSection } from "./job-search-section";

vi.mock("./actions", () => ({
saveJobSource: vi.fn(),
saveJobSearchDetails: vi.fn(),
saveJobSourceKey: vi.fn(),
testJobSource: vi.fn(),
blockCompany: vi.fn(),
unblockCompany: vi.fn(),
}));
vi.mock("@/components/toast", () => ({ toast: vi.fn(), toastResult: (result: unknown) => result }));

const off: JobSearchSettingsView = {
source: "OFF",
jsearch: { host: "openwebninja", keyState: "none" },
adzuna: { appId: "", keyState: "none" },
firecrawl: { keyState: "none" },
arbeitsagentur: { offered: true, acceptedAt: null },
blockedCompanies: [],
};
const ACCEPTED = "2026-10-23T09:00:00.000Z";
const withSource = (view: Partial<JobSearchSettingsView>): JobSearchSettingsView => ({ ...off, ...view });
Expand All @@ -32,6 +36,10 @@ beforeEach(() => {
}));
vi.mocked(saveJobSourceKey).mockReset().mockResolvedValue({ ok: true, data: withSource({ source: "JSEARCH", jsearch: { host: "openwebninja", keyState: "set" } }) });
vi.mocked(testJobSource).mockReset().mockResolvedValue({ ok: true, data: { connected: true, seconds: 0.8 } });
vi.mocked(blockCompany)
.mockReset()
.mockImplementation(async ({ name }) => ({ ok: true, data: { blockedCompanies: [name] } }));
vi.mocked(unblockCompany).mockReset().mockResolvedValue({ ok: true, data: { blockedCompanies: [] } });
});

describe("JobSearchSection", () => {
Expand All @@ -41,6 +49,7 @@ describe("JobSearchSection", () => {
expect(screen.getAllByRole("radio").map((radio) => radio.getAttribute("aria-label"))).toEqual([
"JSearch",
"Adzuna",
"Firecrawl",
"Bundesagentur für Arbeit",
"Off",
]);
Expand Down Expand Up @@ -126,7 +135,27 @@ describe("JobSearchSection", () => {

it("leaves the Arbeitsagentur out when the server doesn't offer it", () => {
render(<JobSearchSection initial={withSource({ arbeitsagentur: { offered: false, acceptedAt: null } })} />);
expect(screen.getAllByRole("radio").map((radio) => radio.getAttribute("aria-label"))).toEqual(["JSearch", "Adzuna", "Off"]);
expect(screen.getAllByRole("radio").map((radio) => radio.getAttribute("aria-label"))).toEqual(["JSearch", "Adzuna", "Firecrawl", "Off"]);
});

it("asks Firecrawl for a key, like JSearch and Adzuna", async () => {
vi.mocked(saveJobSourceKey).mockResolvedValue({ ok: true, data: withSource({ source: "FIRECRAWL", firecrawl: { keyState: "set" } }) });
render(<JobSearchSection initial={withSource({ source: "FIRECRAWL" })} />);
expect(screen.getByRole("link", { name: "Firecrawl" })).toHaveAttribute("href", "https://www.firecrawl.dev/app/api-keys");
await userEvent.type(screen.getByLabelText("API key"), "fc_secret");
await userEvent.tab();
expect(saveJobSourceKey).toHaveBeenCalledWith({ source: "FIRECRAWL", change: { action: "set", value: "fc_secret" } });
});

it("blocks and unblocks a company, kept regardless of the chosen source", async () => {
render(<JobSearchSection initial={off} />);
await userEvent.type(screen.getByLabelText("Block a company"), "Best Buy{Enter}");
expect(blockCompany).toHaveBeenCalledWith({ name: "Best Buy" });
expect(await screen.findByText("Best Buy")).toBeInTheDocument();

await userEvent.click(screen.getByRole("button", { name: "Unblock Best Buy" }));
expect(unblockCompany).toHaveBeenCalledWith({ name: "Best Buy" });
await waitFor(() => expect(screen.queryByText("Best Buy")).not.toBeInTheDocument());
});

it("shows keys from the environment and keys that can't be read", () => {
Expand Down
Loading