Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@ docs
coverage
__tests__
e2e
src/generated
scripts/dist
11 changes: 10 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
# Note: Copy these variables in your .env file

# DATABASE_URL=file:./dev.db
# Database. The bundled Postgres runs automatically and needs no
# configuration — it is not exposed outside the Docker network.
#
# To use your own Postgres instead, uncomment and set:
# DATABASE_URL=postgres://user:pass@db.example.internal:5432/jobsync
# Optional: also stop the then-unused bundled database container
# BUNDLED_DB=0
#
# For local development (npm run dev) DATABASE_URL must be set: point it at
# any Postgres you can reach. See CONTRIBUTING.md.

# If you are running it on a remote server/homelab, you must update timezone otherwise activities times may shift
TZ=America/Edmonton
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ next-env.d.ts
/data/
/jobsyncdb/

# generated: the frozen SQLite snapshot client and the compiled boot scripts
/src/generated/
/scripts/dist/

/test-results/
/playwright-report/
/blob-report/
Expand Down
15 changes: 14 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ This project follows a [Code of Conduct](./CODE_OF_CONDUCT.md). By participating
- Node.js 20+
- npm
- Git
- A Postgres database (any reachable Postgres 17+; Docker is one way to get one, not a requirement). Prefer one with the `pgvector` extension available — the bundled `pgvector/pgvector:pg18` image has it — since resume embeddings will need it

### Local Setup

Expand All @@ -65,10 +66,22 @@ This project follows a [Code of Conduct](./CODE_OF_CONDUCT.md). By participating
```bash
cp .env.example .env
```
6. **Set up the database:**
6. **Point the app at a Postgres database.** Set `DATABASE_URL` in `.env`. Either use a Postgres you already have:
```
DATABASE_URL=postgres://user:password@localhost:5432/jobsync
```
or start only the bundled database from this repo and use it:
```bash
docker compose -f docker-compose.yml -f docker-compose.lan-db.yml up -d db
```
```
DATABASE_URL=postgres://jobsync:jobsync@localhost:5432/jobsync
```
Then create the schema:
```bash
npx prisma migrate dev
```
The role needs `CREATEDB` so Prisma can make its temporary shadow database (the bundled one has it). Tests need no database: `npm test` mocks Prisma.
7. **Start the development server:**
```bash
npm run dev
Expand Down
13 changes: 10 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

ENV DATABASE_URL=file:/data/dev.db
# Two clients: the Postgres runtime client, and the frozen SQLite snapshot
# client that only the one-time import reads. Neither needs DATABASE_URL.
RUN npx -y prisma@6.19.0 generate \
&& npx -y prisma@6.19.0 generate --schema=prisma/schema.sqlite-snapshot.prisma

# Generate Prisma client
RUN npx prisma generate
# Boot scripts (wait-for-db, import-sqlite), compiled to plain CommonJS
RUN npx tsc -p scripts/tsconfig.json

RUN npm run build

Expand All @@ -45,6 +48,10 @@ COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/scripts/dist ./scripts/dist
# Nothing in the app imports the snapshot client, so Next's standalone trace
# never includes it or its SQLite engine binary: copy it explicitly.
COPY --from=builder /app/src/generated/sqlite-snapshot ./src/generated/sqlite-snapshot

COPY docker-entrypoint.sh /app/docker-entrypoint.sh
RUN chmod +x /app/docker-entrypoint.sh
Expand Down
18 changes: 12 additions & 6 deletions Jenkinsfile
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,18 @@ pipeline {
sh '''
set -e

docker compose \
-f docker-compose.yml \
-f docker-compose.otel.yml \
up -d \
--force-recreate \
--remove-orphans
# An explicit -f overrides COMPOSE_FILE, so pass none
# when .env already names the files.
if grep -qE '^[[:space:]]*COMPOSE_FILE=' .env 2>/dev/null; then
docker compose up -d --force-recreate --remove-orphans
else
docker compose \
-f docker-compose.yml \
-f docker-compose.otel.yml \
up -d \
--force-recreate \
--remove-orphans
fi
'''
}
}
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Track every application, get AI resume reviews and job matches, and discover new
- **Contacts:** Keep recruiters, interviewers and references, linked to the jobs they touched. [Details](https://github.com/Gsync/jobsync/wiki/Contacts)
- **Company Pages:** One page per company with its jobs, people and links, plus a watchlist. [Details](https://github.com/Gsync/jobsync/wiki/Companies)
- **Question Bank:** Build a library of interview questions and answers, tagged by skill.
- **Data Backup & Restore:** Export everything as one zip, import it anywhere, and undo an import. [Details](https://github.com/Gsync/jobsync/wiki/Data-Backup)
- **Data Backup & Restore:** Export everything as one zip, import it anywhere, and undo an import. [Details](https://github.com/Gsync/jobsync/wiki/Backup)
- **AI Assistant:** A docked chat that reviews resumes, scores job matches, writes cover letters and adds jobs you paste in. [Details](#ai-assistant-in-app-chat)
- **AI Agent Integration (MCP):** Add jobs and questions from Claude Desktop and other agents. [Details](#mcp-server-ai-agent-integration)

Expand All @@ -69,18 +69,21 @@ API keys for AI providers can be configured in **Settings** after signing in.

### Configuration (Optional)

Environment variables can be set in `docker-compose.yml`:
Environment variables are set in a `.env` file beside `docker-compose.yml` (copy `.env.example`). Do not edit `docker-compose.yml` itself — updates replace it.

| Variable | Description |
|---|---|
| `TZ` | Your timezone (e.g. `America/Edmonton`). **Set this on remote servers** to avoid activity time shifts. |
| `AUTH_SECRET` | Auto-generated if not set. To set manually: `openssl rand -base64 32` |
| `ENCRYPTION_KEY` | Encrypts the API keys you save in Settings. **Set your own** (`openssl rand -base64 32`) before adding any keys, and never change it afterwards — a new value makes every stored API key unrecoverable. |
| `NEXTAUTH_URL` | The address you open JobSync at. Defaults to `http://localhost:3737`; on a homelab server, use the server's IP or hostname. |
| `MCP_ENABLED` | Set to `false` to turn off the [MCP server](#mcp-server-ai-agent-integration). Enabled by default in `docker-compose.yml`. |
| `MCP_ENABLED` | Set to `false` to turn off the [MCP server](#mcp-server-ai-agent-integration). Enabled by default. |
| `DATABASE_URL` | Leave unset to use the bundled Postgres. To use your own, set it to your server's address; optionally add `BUNDLED_DB=0` to stop the then-idle bundled container. The bundled image includes the `pgvector` extension, which an upcoming release uses for resume embeddings; your own server will need it then too. See [Upgrading to Postgres](https://github.com/Gsync/jobsync/wiki/Upgrading-To-Postgres). |

### Updating

**Upgrading from a SQLite release?** Read [Upgrading to Postgres](https://github.com/Gsync/jobsync/wiki/Upgrading-To-Postgres) first — the upgrade is one-way.

From the project directory, run the deploy script to pull the latest image and restart:

```sh
Expand Down Expand Up @@ -226,7 +229,7 @@ We welcome contributions! Please read our [Contributing Guidelines](./CONTRIBUTI
- <a href="https://github.com/tailwindlabs/tailwindcss">Tailwind</a>
- <a href="https://github.com/ueberdosis/tiptap">Tiptap</a>
- <a href="https://github.com/plouc/nivo">Nivo</a>
- <a href="https://github.com/sqlite/sqlite">Sqlite</a>
- <a href="https://github.com/postgres/postgres">Postgres</a>
- <a href="https://github.com/vercel/ai">Vercel AI-SDK</a>
- <a href="https://github.com/ollama/ollama">Ollama</a>

Expand Down
20 changes: 10 additions & 10 deletions __tests__/activity.actions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,9 @@ describe("activity.actions", () => {
userId: mockUser.id,
endTime: { not: null },
OR: [
{ activityName: { contains: "TypeScript" } },
{ description: { contains: "TypeScript" } },
{ activityType: { label: { contains: "TypeScript" } } },
{ activityName: { contains: "TypeScript", mode: "insensitive" } },
{ description: { contains: "TypeScript", mode: "insensitive" } },
{ activityType: { label: { contains: "TypeScript", mode: "insensitive" } } },
],
}),
})
Expand All @@ -128,9 +128,9 @@ describe("activity.actions", () => {
expect.objectContaining({
where: expect.objectContaining({
OR: [
{ activityName: { contains: "TypeScript" } },
{ description: { contains: "TypeScript" } },
{ activityType: { label: { contains: "TypeScript" } } },
{ activityName: { contains: "TypeScript", mode: "insensitive" } },
{ description: { contains: "TypeScript", mode: "insensitive" } },
{ activityType: { label: { contains: "TypeScript", mode: "insensitive" } } },
],
}),
})
Expand All @@ -147,7 +147,7 @@ describe("activity.actions", () => {
const findManyCall = (prisma.activity.findMany as any).mock
.calls[0][0];
expect(findManyCall.where.OR).toContainEqual({
activityName: { contains: "Portfolio" },
activityName: { contains: "Portfolio", mode: "insensitive" },
});
});

Expand All @@ -161,7 +161,7 @@ describe("activity.actions", () => {
const findManyCall = (prisma.activity.findMany as any).mock
.calls[0][0];
expect(findManyCall.where.OR).toContainEqual({
description: { contains: "developer" },
description: { contains: "developer", mode: "insensitive" },
});
});

Expand All @@ -175,7 +175,7 @@ describe("activity.actions", () => {
const findManyCall = (prisma.activity.findMany as any).mock
.calls[0][0];
expect(findManyCall.where.OR).toContainEqual({
activityType: { label: { contains: "Learning" } },
activityType: { label: { contains: "Learning", mode: "insensitive" } },
});
});

Expand Down Expand Up @@ -244,7 +244,7 @@ describe("activity.actions", () => {

expect(prisma.activity.findMany).toHaveBeenCalledWith(
expect.objectContaining({
orderBy: { createdAt: "desc" },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
})
);
});
Expand Down
14 changes: 14 additions & 0 deletions __tests__/backupImportGuards.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,20 @@ describe("importBackup guards", () => {
expect(syncSchedulerState).toHaveBeenCalled();
});

it("strips NULs a SQLite-era backup can carry before inserting", async () => {
const data = BackupDataSchema.parse({
Company: [{ id: "c1", label: "Acme\u0000 Corp", value: "acme corp", logoUrl: null }],
});
const zip = new JSZip();
zip.file("manifest.json", JSON.stringify(buildManifest(data, EMAIL)));
zip.file("data.json", JSON.stringify(data));
await importBackup(await zip.generateAsync({ type: "nodebuffer" }), "user-1", EMAIL, true);

expect(mockDb.company.create).toHaveBeenCalledWith({
data: expect.objectContaining({ label: "Acme Corp" }),
});
});

it("wipes ChatConversation but never ApiKey or McpAccessToken", async () => {
await importBackup(await emptyBackup(), "user-1", EMAIL, true);
expect(mockDb.chatConversation.deleteMany).toHaveBeenCalled();
Expand Down
19 changes: 13 additions & 6 deletions __tests__/backupRoundTrip.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,19 @@ import { APP_CONSTANTS, CONTACT_ROLES, JOB_STAGES } from "@/lib/constants";
import { buildBackupZip } from "@/lib/backup/export";
import { importBackup } from "@/lib/backup/import";
import { listSnapshots, readSnapshot } from "@/lib/backup/snapshot";
import { seedAccount } from "./helpers/backupTestDb";
import { dropTestSchema, seedAccount } from "./helpers/backupTestDb";

// Async so the helper can be pulled in with a dynamic import — `require` is
// not defined in a Vite-transformed ESM test file.
const ctx = await vi.hoisted(async () => {
const helpers = await import("./helpers/backupTestDb");
const { url, dir } = helpers.makeTestDbUrl();
helpers.pushSchema(url);
return { url, dir };
const db = helpers.makeTestDb();
if (db) helpers.pushSchema(db.url);
return db;
});

vi.mock("@/lib/db", async () => {
if (!ctx) return { default: {} };
const { PrismaClient } = await import("@prisma/client");
return {
default: new PrismaClient({ datasources: { db: { url: ctx.url } } }),
Expand All @@ -27,13 +28,17 @@ vi.mock("@/lib/db", async () => {
// Never start the cron in a test run.
vi.mock("@/lib/scheduler", () => ({ syncSchedulerState: vi.fn() }));

const prisma = new PrismaClient({ datasources: { db: { url: ctx.url } } });
// Only dereferenced inside the skipped-when-absent describe below
const prisma = ctx
? new PrismaClient({ datasources: { db: { url: ctx.url } } })
: (undefined as unknown as PrismaClient);

let userId: string;
let uploadsDir: string;
const originalUploads = APP_CONSTANTS.UPLOADS_DIR;

beforeAll(async () => {
if (!ctx) return;
uploadsDir = path.join(ctx.dir, "uploads");
fs.mkdirSync(path.join(uploadsDir, "files", "resumes"), { recursive: true });
(APP_CONSTANTS as { UPLOADS_DIR: string }).UPLOADS_DIR = uploadsDir;
Expand All @@ -43,7 +48,9 @@ beforeAll(async () => {
}, 120_000);

afterAll(async () => {
if (!ctx) return;
(APP_CONSTANTS as { UPLOADS_DIR: string }).UPLOADS_DIR = originalUploads;
await dropTestSchema(prisma, ctx.schema);
await prisma.$disconnect();
fs.rmSync(ctx.dir, { recursive: true, force: true });
});
Expand Down Expand Up @@ -229,7 +236,7 @@ async function seedFullAccount() {
});
}

describe("backup round trip", () => {
describe.skipIf(!ctx)("backup round trip", () => {
it("restores every row, relation and file over an existing account", async () => {
const { buffer } = await buildBackupZip(userId, "owner@example.com");

Expand Down
Loading
Loading