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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ WEBHOOK_PORT=4000
# The demo worker and employer accounts seed themselves on first request, so a
# brand-new deployment works immediately with no extra step.

# ---- Session signing (REQUIRED in production) ----
# Signs the cookies that decide who a request is. Without it, anyone can forge
# a session for any account, so the app refuses to start in production rather
# than run with a key that ships in this repository.
# Generate one with:
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# Locally you can leave it unset — a development-only default is used.
SESSION_SECRET=

# ---- Aide agent (voice loop) ----
# DeepSeek API key for the Aide agent. Get one at platform.deepseek.com.
# DeepSeek is OpenAI-compatible, supports tool calling, and is far cheaper than Claude.
Expand Down
6 changes: 3 additions & 3 deletions app/api/greeting/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getAccount, getApplications, getBalance, getJob, getWallet, listJobs } from "@/lib/store";
import { getAccount, getApplications, getBalance, getJob, getWallet, listApplicantsForJobs, listJobs } from "@/lib/store";
import { userIdFrom } from "@/lib/session";

export const runtime = "nodejs";
Expand All @@ -14,7 +14,7 @@ export async function GET(req: Request) {

if (acc.role === "employer") {
const posted = (await listJobs()).filter((j) => j.employer.toLowerCase() === acc.name.toLowerCase());
const apps = (await getApplications()).filter((a) => posted.some((j) => j.id === a.jobId));
const apps = await listApplicantsForJobs(posted.map((j) => j.id));
const readyToHire = apps.filter((a) => a.status === "assessed");
const parts = [`${hello} ${acc.name}, I'm Aide. I'm listening — just talk to me.`];
parts.push(
Expand Down Expand Up @@ -46,7 +46,7 @@ export async function GET(req: Request) {
/* greet without the money line */
}

const apps = await getApplications();
const apps = await getApplications(acc.id);
const pendingChecks = await Promise.all(
apps.map(async (a) => a.status === "applied" && !a.verified && !!(await getJob(a.jobId))?.requiresAssessment),
);
Expand Down
17 changes: 15 additions & 2 deletions app/api/jobs/apply/route.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
import { apply, getJob } from "@/lib/store";
import { apply, getAccount, getJob, unapply } from "@/lib/store";
import { userIdFrom } from "@/lib/session";

export const runtime = "nodejs";

export async function POST(req: Request) {
const acc = await getAccount(userIdFrom(req));
const { jobId } = (await req.json().catch(() => ({}))) as { jobId?: string };
const job = jobId ? await getJob(jobId) : undefined;
if (!job) return Response.json({ error: "No job with that id." }, { status: 400 });
const app = await apply(job.id);
const app = await apply(acc.id, job.id);
if (app.status === "cancelled") {
return Response.json({ error: "You cancelled the assessment for this job earlier, so you can no longer apply to it." }, { status: 403 });
}
return Response.json({ ok: true, application: app, requiresAssessment: job.requiresAssessment });
}

// Withdraw an application. Allowed only while it is still just an application:
// once the assessment has started there is a record of an attempt, and letting
// it be deleted would be a way to quietly retake a test meant to be taken once.
export async function DELETE(req: Request) {
const acc = await getAccount(userIdFrom(req));
const jobId = new URL(req.url).searchParams.get("jobId");
if (!jobId) return Response.json({ error: "jobId is required." }, { status: 400 });
const r = await unapply(acc.id, jobId);
return Response.json(r.ok ? { ok: true, message: r.message } : { error: r.message }, { status: r.ok ? 200 : 409 });
}
23 changes: 12 additions & 11 deletions app/api/jobs/external/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,42 +4,43 @@ import {
getExternalApplications,
getExternalJobs,
getJob,
getWorker,
setExternalJobs,
trackExternalJob,
} from "@/lib/store";
import { searchExternalJobs } from "@/lib/external";
import { userIdFrom } from "@/lib/session";

export const runtime = "nodejs";

// External listings belong to the worker who scanned for them — the same
// account that owns the applications.
const ownerId = () => getWorker().id;

export async function GET() {
const [jobs, applications] = await Promise.all([getExternalJobs(ownerId()), getExternalApplications(ownerId())]);
// External listings belong to the account that scanned for them. This used to
// resolve to the demo worker no matter who was signed in, so one person's web
// scan results — and the listings they were tracking — were everybody's.
export async function GET(req: Request) {
const acc = await getAccount(userIdFrom(req));
const [jobs, applications] = await Promise.all([getExternalJobs(acc.id), getExternalApplications(acc.id)]);
return Response.json({ jobs, applications });
}

// { action: "scan" } → search the web for listings matching the worker's skills
// { action: "track", id } → record that the worker applied to a listing
export async function POST(req: Request) {
const acc = await getAccount(userIdFrom(req));
const body = (await req.json().catch(() => ({}))) as { action?: string; id?: string };

if (body.action === "scan") {
const w = await getAccount(getWorker().id);
const apps = (await getApplications()).filter((a) => a.verified);
const w = acc;
const apps = (await getApplications(w.id)).filter((a) => a.verified);
const verifiedSkills = (await Promise.all(apps.map(async (a) => (await getJob(a.jobId))?.skill))).filter(
(s): s is string => !!s,
);
const skills = [...new Set([...(w.skills ?? []), ...verifiedSkills])];
const jobs = await searchExternalJobs(skills);
await setExternalJobs(ownerId(), jobs);
await setExternalJobs(acc.id, jobs);
return Response.json({ ok: true, jobs, matchedSkills: skills });
}

if (body.action === "track" && body.id) {
const app = await trackExternalJob(ownerId(), body.id);
const app = await trackExternalJob(acc.id, body.id);
if (!app) return Response.json({ error: "No external listing with that id." }, { status: 400 });
return Response.json({ ok: true, application: app });
}
Expand Down
18 changes: 16 additions & 2 deletions app/api/jobs/post/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getAccount, postJob, validateGig, type McqQuestion } from "@/lib/store";
import { getAccount, postJob, validateGig, type McqQuestion, deletePostedJob } from "@/lib/store";
import { userIdFrom } from "@/lib/session";

export const runtime = "nodejs";
Expand Down Expand Up @@ -27,6 +27,20 @@ export async function POST(req: Request) {
const v = validateGig(body);
if (!v.ok) return Response.json({ error: v.message }, { status: 400 });

const job = await postJob({ ...v.gig, employer: acc.name });
const job = await postJob({ ...v.gig, employer: acc.name, employerAccountId: acc.id });
return Response.json({ ok: true, job });
}

// Take down a gig you posted. Refused once anyone has been hired or paid on
// it: at that point the gig is the record of work that was agreed, and removing
// it would strand the worker's application and their onboarding thread.
export async function DELETE(req: Request) {
const acc = await getAccount(userIdFrom(req));
if (acc.role !== "employer") {
return Response.json({ error: "Only employers can remove gigs." }, { status: 403 });
}
const jobId = new URL(req.url).searchParams.get("jobId");
if (!jobId) return Response.json({ error: "jobId is required." }, { status: 400 });
const r = await deletePostedJob(acc.id, jobId);
return Response.json(r.ok ? { ok: true, message: r.message } : { error: r.message }, { status: r.ok ? 200 : 403 });
}
52 changes: 31 additions & 21 deletions app/api/jobs/route.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,49 @@
import { listJobs, getApplications, getJob, getAccount, getWorker, publicJob } from "@/lib/store";
import { listJobs, getApplications, getJob, getAccount, listApplicantsForJobs, ownsJob, publicJob } from "@/lib/store";
import { userIdFrom } from "@/lib/session";

export const runtime = "nodejs";

// Role-aware jobs data. Workers see every listing (with who posted it) plus
// their own applications; employers see only the jobs they posted, with the
// state of applications on them.
// their OWN applications; employers see only the gigs they posted, with the
// real applicants on them.
//
// Both halves used to read one hardcoded worker: every signed-in worker was
// shown the demo worker's applications as their own, and every employer saw
// that same worker's name and bio attached to whatever had been applied for.
export async function GET(req: Request) {
const acc = await getAccount(userIdFrom(req));
// Applicant display data comes from the worker's Convex account, not the

// Applicant display data comes from each applicant's Convex account, not an
// in-memory copy, so an employer on another instance sees current details.
const w = await getAccount(getWorker().id);
const applications = await Promise.all(
(await getApplications()).map(async (a) => {
const originalJob = await getJob(a.jobId);
const sanitizedJob = originalJob ? publicJob(originalJob) : undefined;
return {
...a,
workerName: w.name,
workerSkills: w.skills ?? [],
workerBio: w.bio ?? "",
job: sanitizedJob
};
}),
);
const decorate = async (apps: { jobId: string; accountId?: string }[], fallbackAccountId: string) =>
await Promise.all(
apps.map(async (a) => {
const applicant = await getAccount(a.accountId ?? fallbackAccountId);
const originalJob = await getJob(a.jobId);
return {
...a,
workerName: applicant.name,
workerSkills: applicant.skills ?? [],
workerBio: applicant.bio ?? "",
job: originalJob ? publicJob(originalJob) : undefined,
};
}),
);

if (acc.role === "employer") {
const jobs = (await listJobs()).filter((j) => j.employer.toLowerCase() === acc.name.toLowerCase());
const jobs = (await listJobs()).filter((j) => ownsJob(acc, j));
const applicants = await listApplicantsForJobs(jobs.map((j) => j.id));
return Response.json({
role: "employer",
employerName: acc.name,
jobs,
applications: applications.filter((a) => jobs.some((j) => j.id === a.jobId)),
applications: await decorate(applicants, acc.id),
});
}

return Response.json({ role: "worker", jobs: (await listJobs()).map(publicJob), applications });
return Response.json({
role: "worker",
jobs: (await listJobs()).map(publicJob),
applications: await decorate(await getApplications(acc.id), acc.id),
});
}
27 changes: 18 additions & 9 deletions app/api/jobs/status/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getAccount, getJob, getWorker, hireWorker, payWorker, publishEvent, rejectWorker, verifyPaymentCoverage } from "@/lib/store";
import { getAccount, getJob, hireWorker, ownsJob, payWorker, publishEvent, rejectWorker, resolveApplicant, verifyPaymentCoverage } from "@/lib/store";
import { userIdFrom } from "@/lib/session";

export const runtime = "nodejs";
Expand All @@ -9,40 +9,49 @@ export async function POST(req: Request) {
return Response.json({ error: "Only employers can modify application status." }, { status: 403 });
}

const { jobId, action } = (await req.json().catch(() => ({}))) as { jobId?: string; action?: "hire" | "reject" | "pay" };
const { jobId, action, workerAccountId } = (await req.json().catch(() => ({}))) as {
jobId?: string;
action?: "hire" | "reject" | "pay";
workerAccountId?: string;
};
if (!jobId || !action) {
return Response.json({ error: "jobId and action are required." }, { status: 400 });
}

const job = await getJob(jobId);
if (!job || job.employer.toLowerCase() !== acc.name.toLowerCase()) {
if (!job || !ownsJob(acc, job)) {
return Response.json({ error: "That gig is not one of your postings." }, { status: 403 });
}

// Act on a named applicant rather than on whoever the server assumed.
const chosen = await resolveApplicant(jobId, workerAccountId);
if (!chosen.ok) return Response.json({ error: chosen.message }, { status: 400 });
const workerId = chosen.accountId;

let app;
if (action === "hire") {
app = await hireWorker(jobId);
app = await hireWorker(workerId, jobId);
if (app) {
// Aide tells the worker out loud, the moment the decision is made.
publishEvent(getWorker().id, {
publishEvent(workerId, {
type: "notify",
message: `Great news from ${job.employer}: you have been hired for ${job.title}. Say "help me with my job" and I will guide you through the task.`,
});
}
} else if (action === "reject") {
app = await rejectWorker(jobId);
app = await rejectWorker(workerId, jobId);
if (app) {
publishEvent(getWorker().id, {
publishEvent(workerId, {
type: "notify",
message: `An update on ${job.title} from ${job.employer}: they went with another applicant this time. Your assessment result stays on your profile — I can find you more jobs whenever you're ready.`,
});
}
} else if (action === "pay") {
// "Paid" must mean paid: only allowed when a confirmed Monnify inbound
// payment actually covers this gig.
const coverage = await verifyPaymentCoverage(jobId);
const coverage = await verifyPaymentCoverage(workerId, jobId);
if (!coverage.ok) return Response.json({ error: coverage.message }, { status: 409 });
app = await payWorker(jobId);
app = await payWorker(workerId, jobId);
}

if (!app) {
Expand Down
25 changes: 14 additions & 11 deletions app/api/messages/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getAccount, getJob, listMessages, messagingUnlocked, sendMessage } from "@/lib/store";
import { deleteMessage, getAccount, getJob, listMessages, messagingUnlocked, partyToThread, sendMessage } from "@/lib/store";
import { userIdFrom } from "@/lib/session";

export const runtime = "nodejs";
Expand All @@ -10,21 +10,14 @@ const MAX_LEN = 2000;
// the reactive live delivery is handled by Convex + the events feed, so this
// route is just the gated read/write surface for the on-screen thread.

// Is this account a party to the gig's conversation?
function partyTo(job: { employer: string }, acc: { role: string; name: string }): "employer" | "worker" | null {
if (acc.role === "employer") return job.employer.toLowerCase() === acc.name.toLowerCase() ? "employer" : null;
if (acc.role === "worker") return "worker";
return null;
}

export async function GET(req: Request) {
const acc = await getAccount(userIdFrom(req));
const jobId = new URL(req.url).searchParams.get("jobId");
if (!jobId) return Response.json({ error: "jobId is required." }, { status: 400 });

const job = await getJob(jobId);
if (!job) return Response.json({ error: "No job with that id." }, { status: 404 });
if (!partyTo(job, acc)) return Response.json({ error: "That conversation is not yours." }, { status: 403 });
if (!(await partyToThread(acc, jobId))) return Response.json({ error: "That conversation is not yours." }, { status: 403 });

const unlocked = await messagingUnlocked(jobId);
return Response.json({
Expand All @@ -44,13 +37,23 @@ export async function POST(req: Request) {
const job = await getJob(jobId);
if (!job) return Response.json({ error: "No job with that id." }, { status: 404 });

const from = partyTo(job, acc);
const from = await partyToThread(acc, jobId);
if (!from) return Response.json({ error: "That conversation is not yours." }, { status: 403 });

if (!(await messagingUnlocked(jobId))) {
return Response.json({ error: "Messaging opens once the worker is hired for this gig." }, { status: 409 });
}

const message = await sendMessage(jobId, from, acc.name, text);
const message = await sendMessage(jobId, from, acc.id, acc.name, text);
return Response.json({ ok: true, message });
}

// Delete one of your own messages. Ownership is enforced against the stored
// author id, not against anything the caller says about themselves.
export async function DELETE(req: Request) {
const acc = await getAccount(userIdFrom(req));
const messageId = new URL(req.url).searchParams.get("messageId");
if (!messageId) return Response.json({ error: "messageId is required." }, { status: 400 });
const r = await deleteMessage(acc.id, messageId);
return Response.json(r.ok ? { ok: true } : { error: r.message }, { status: r.ok ? 200 : 403 });
}
6 changes: 3 additions & 3 deletions app/api/profile/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getAccount, getApplications, getBalance, getJob, listJobs, publicAccount, updateProfile } from "@/lib/store";
import { getAccount, getApplications, getBalance, getJob, listApplicantsForJobs, listJobs, publicAccount, updateProfile } from "@/lib/store";
import { userIdFrom } from "@/lib/session";

export const runtime = "nodejs";
Expand All @@ -11,7 +11,7 @@ export async function GET(req: Request) {

if (acc.role === "employer") {
const posted = (await listJobs()).filter((j) => j.employer.toLowerCase() === acc.name.toLowerCase());
const apps = await getApplications();
const apps = await listApplicantsForJobs(posted.map((j) => j.id));
const completed = posted.filter((j) => apps.some((a) => a.jobId === j.id && a.verified));
return Response.json({
account: publicAccount(acc),
Expand All @@ -32,7 +32,7 @@ export async function GET(req: Request) {
wallet = b;
} catch {}
const applications = await Promise.all(
(await getApplications()).map(async (a) => ({ ...a, job: await getJob(a.jobId) })),
(await getApplications(acc.id)).map(async (a) => ({ ...a, job: await getJob(a.jobId) })),
);
const verified = applications.filter((a) => a.verified);
return Response.json({
Expand Down
Loading
Loading