diff --git a/.env.example b/.env.example index da6ee35..db9e62e 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/app/api/greeting/route.ts b/app/api/greeting/route.ts index dcac777..8a35bad 100644 --- a/app/api/greeting/route.ts +++ b/app/api/greeting/route.ts @@ -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"; @@ -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( @@ -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), ); diff --git a/app/api/jobs/apply/route.ts b/app/api/jobs/apply/route.ts index da6d7f1..32687e5 100644 --- a/app/api/jobs/apply/route.ts +++ b/app/api/jobs/apply/route.ts @@ -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 }); +} diff --git a/app/api/jobs/external/route.ts b/app/api/jobs/external/route.ts index 8874c28..17b904d 100644 --- a/app/api/jobs/external/route.ts +++ b/app/api/jobs/external/route.ts @@ -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 }); } diff --git a/app/api/jobs/post/route.ts b/app/api/jobs/post/route.ts index 1b9e24c..452049c 100644 --- a/app/api/jobs/post/route.ts +++ b/app/api/jobs/post/route.ts @@ -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"; @@ -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 }); +} diff --git a/app/api/jobs/route.ts b/app/api/jobs/route.ts index 449b1db..285d3b7 100644 --- a/app/api/jobs/route.ts +++ b/app/api/jobs/route.ts @@ -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), + }); } diff --git a/app/api/jobs/status/route.ts b/app/api/jobs/status/route.ts index 69b6819..569c658 100644 --- a/app/api/jobs/status/route.ts +++ b/app/api/jobs/status/route.ts @@ -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"; @@ -9,30 +9,39 @@ 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.`, }); @@ -40,9 +49,9 @@ export async function POST(req: Request) { } 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) { diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts index 4a82922..8ee0962 100644 --- a/app/api/messages/route.ts +++ b/app/api/messages/route.ts @@ -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"; @@ -10,13 +10,6 @@ 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"); @@ -24,7 +17,7 @@ export async function GET(req: Request) { 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({ @@ -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 }); +} diff --git a/app/api/profile/route.ts b/app/api/profile/route.ts index c870f99..6fa6791 100644 --- a/app/api/profile/route.ts +++ b/app/api/profile/route.ts @@ -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"; @@ -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), @@ -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({ diff --git a/app/jobs/employer-gigs.tsx b/app/jobs/employer-gigs.tsx index b4e0726..d0c0f04 100644 --- a/app/jobs/employer-gigs.tsx +++ b/app/jobs/employer-gigs.tsx @@ -27,6 +27,32 @@ export function EmployerGigs({ const [openThread, setOpenThread] = useState(null); const [error, setError] = useState(null); + // Take a gig down. Confirmed first, because it also withdraws every pending + // application on it — and refused outright by the server once anyone has been + // hired, so a worker's agreed job cannot vanish from under them. + const removeGig = async (job: Job, pendingCount: number) => { + const warning = + pendingCount > 0 + ? `Remove "${job.title}"? This also withdraws ${pendingCount} pending application${pendingCount === 1 ? "" : "s"}. This cannot be undone.` + : `Remove "${job.title}"? This cannot be undone.`; + if (!window.confirm(warning)) return; + setBusyJob(job.id); + setError(null); + try { + const res = await fetch(`/api/jobs/post?jobId=${encodeURIComponent(job.id)}`, { method: "DELETE" }); + const data = await res.json().catch(() => null); + if (!res.ok) throw new Error(data?.error || "Could not remove the gig."); + await reload(); + speak(data?.message || "Gig removed."); + } catch (e) { + const msg = (e as Error).message; + setError(msg); + speak(msg); + } finally { + setBusyJob(null); + } + }; + const changeStatus = async (jobId: string, action: "hire" | "reject" | "pay") => { setBusyJob(jobId); setError(null); @@ -111,6 +137,17 @@ export function EmployerGigs({

{job.task}

+ {!jobApps.some((a: any) => a.status === "hired" || a.status === "paid") && ( + + )} +

Applications ({jobApps.length})

{jobApps.length === 0 ? ( diff --git a/app/jobs/message-thread.tsx b/app/jobs/message-thread.tsx index 26557b7..b2a1c7b 100644 --- a/app/jobs/message-thread.tsx +++ b/app/jobs/message-thread.tsx @@ -90,6 +90,20 @@ export function MessageThread({ jobId, role, id }: { jobId: string; role: "worke } }; + // Only your own messages, and the server checks that against the stored + // author rather than trusting this component. + const remove = async (messageId: string, preview: string) => { + if (!window.confirm(`Delete your message "${preview}"? This cannot be undone.`)) return; + setError(null); + try { + const res = await fetch(`/api/messages?messageId=${encodeURIComponent(messageId)}`, { method: "DELETE" }); + const data = await res.json().catch(() => null); + if (!res.ok) throw new Error(data?.error || "Could not delete the message."); + } catch (err) { + setError((err as Error).message); + } + }; + return (

Onboarding messages

@@ -119,6 +133,15 @@ export function MessageThread({ jobId, role, id }: { jobId: string; role: "worke {mine ? "You" : m.authorName} · {time(m.at)}

{m.text}

+ {mine && ( + + )}
); diff --git a/app/jobs/page.tsx b/app/jobs/page.tsx index 34503f8..df47a4e 100644 --- a/app/jobs/page.tsx +++ b/app/jobs/page.tsx @@ -100,6 +100,28 @@ function JobsPageInner() { const appFor = (jobId: string) => apps.find((a) => a.jobId === jobId); + // Withdraw an application. The server refuses once the assessment has begun; + // the button is hidden in that case too, but the server is what decides. + const withdrawFrom = async (job: Job) => { + setBusyJob(job.id); + setError(null); + try { + const res = await fetch(`/api/jobs/apply?jobId=${encodeURIComponent(job.id)}`, { method: "DELETE" }); + const data = await res.json().catch(() => null); + if (!res.ok) throw new Error(data?.error || "Could not withdraw."); + await load(); + speak(`Your application to ${job.title} has been withdrawn.`); + } catch (e) { + const msg = (e as Error).message; + setError(msg); + // Spoken as well as shown: a refusal the user cannot see is a button + // that silently did nothing. + speak(msg); + } finally { + setBusyJob(null); + } + }; + const applyTo = async (job: Job) => { setBusyJob(job.id); setError(null); @@ -415,6 +437,16 @@ function JobsPageInner() { {app.verified ? "✓ Skill verified" : app.status === "cancelled" ? "Locked — assessment cancelled" : `Applied — ${app.status}`} )} + {app && app.status === "applied" && !app.verified && ( + + )} {app && !app.verified && app.status !== "cancelled" && job.requiresAssessment && (