@profullstack/agenticjobs 0.11.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +136 -1
- package/dist/cli/args.js +4 -2
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/index.js +227 -5
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/jobfile.js +38 -2
- package/dist/cli/jobfile.js.map +1 -1
- package/dist/client/client.d.ts +157 -0
- package/dist/client/client.js +44 -0
- package/dist/client/client.js.map +1 -1
- package/dist/config.d.ts +35 -1
- package/dist/config.js +33 -1
- package/dist/config.js.map +1 -1
- package/dist/core/agentwriter.js +22 -5
- package/dist/core/agentwriter.js.map +1 -1
- package/dist/core/applications.js +1 -1
- package/dist/core/applications.js.map +1 -1
- package/dist/core/candidates.js +3 -5
- package/dist/core/candidates.js.map +1 -1
- package/dist/core/capacity.d.ts +1 -1
- package/dist/core/capacity.js +11 -19
- package/dist/core/capacity.js.map +1 -1
- package/dist/core/coinpay.d.ts +143 -0
- package/dist/core/coinpay.js +417 -0
- package/dist/core/coinpay.js.map +1 -0
- package/dist/core/import.js +19 -9
- package/dist/core/import.js.map +1 -1
- package/dist/core/inbox.d.ts +155 -0
- package/dist/core/inbox.js +353 -0
- package/dist/core/inbox.js.map +1 -0
- package/dist/core/invoices.d.ts +111 -0
- package/dist/core/invoices.js +292 -0
- package/dist/core/invoices.js.map +1 -0
- package/dist/core/jobs.d.ts +24 -6
- package/dist/core/jobs.js +116 -31
- package/dist/core/jobs.js.map +1 -1
- package/dist/core/mail.d.ts +16 -0
- package/dist/core/mail.js +22 -0
- package/dist/core/mail.js.map +1 -1
- package/dist/core/recommendations.d.ts +134 -0
- package/dist/core/recommendations.js +356 -0
- package/dist/core/recommendations.js.map +1 -0
- package/dist/core/updates.d.ts +15 -3
- package/dist/core/updates.js +58 -24
- package/dist/core/updates.js.map +1 -1
- package/dist/core/zip.js +29 -4
- package/dist/core/zip.js.map +1 -1
- package/dist/directory/federate.js +3 -1
- package/dist/directory/federate.js.map +1 -1
- package/dist/directory/fetch.d.ts +1 -1
- package/dist/markup/markdown.js +6 -2
- package/dist/markup/markdown.js.map +1 -1
- package/dist/markup/resume.js +18 -5
- package/dist/markup/resume.js.map +1 -1
- package/dist/mcp/tools.js +249 -7
- package/dist/mcp/tools.js.map +1 -1
- package/dist/schema/index.d.ts +1 -0
- package/dist/schema/index.js +1 -0
- package/dist/schema/index.js.map +1 -1
- package/dist/schema/instance.js +29 -0
- package/dist/schema/instance.js.map +1 -1
- package/dist/schema/job.d.ts +12 -0
- package/dist/schema/job.js +17 -3
- package/dist/schema/job.js.map +1 -1
- package/dist/schema/jsonld.js +7 -6
- package/dist/schema/jsonld.js.map +1 -1
- package/dist/schema/pay.d.ts +173 -0
- package/dist/schema/pay.js +668 -0
- package/dist/schema/pay.js.map +1 -0
- package/dist/server/app.d.ts +4 -3
- package/dist/server/app.js +10 -5
- package/dist/server/app.js.map +1 -1
- package/dist/server/deps.d.ts +5 -0
- package/dist/server/middleware.d.ts +11 -1
- package/dist/server/middleware.js +26 -3
- package/dist/server/middleware.js.map +1 -1
- package/dist/server/routes/api.js +437 -7
- package/dist/server/routes/api.js.map +1 -1
- package/dist/server/routes/discovery.js +29 -8
- package/dist/server/routes/discovery.js.map +1 -1
- package/dist/server/routes/inbox.d.ts +9 -0
- package/dist/server/routes/inbox.js +223 -0
- package/dist/server/routes/inbox.js.map +1 -0
- package/dist/server/routes/openapi.js +260 -2
- package/dist/server/routes/openapi.js.map +1 -1
- package/dist/server/routes/pages.d.ts +22 -0
- package/dist/server/routes/pages.js +244 -19
- package/dist/server/routes/pages.js.map +1 -1
- package/dist/tui/views.js +9 -3
- package/dist/tui/views.js.map +1 -1
- package/dist/views/candidates.d.ts +7 -0
- package/dist/views/candidates.js +3 -2
- package/dist/views/candidates.js.map +1 -1
- package/dist/views/docs.js +10 -1
- package/dist/views/docs.js.map +1 -1
- package/dist/views/inbox.d.ts +90 -0
- package/dist/views/inbox.js +73 -0
- package/dist/views/inbox.js.map +1 -0
- package/dist/views/jobs.d.ts +25 -0
- package/dist/views/jobs.js +35 -12
- package/dist/views/jobs.js.map +1 -1
- package/dist/views/layout.d.ts +24 -0
- package/dist/views/layout.js +20 -2
- package/dist/views/layout.js.map +1 -1
- package/dist/views/me.d.ts +10 -3
- package/dist/views/me.js +3 -2
- package/dist/views/me.js.map +1 -1
- package/dist/views/post.d.ts +18 -0
- package/dist/views/post.js +39 -7
- package/dist/views/post.js.map +1 -1
- package/dist/views/recommendations.d.ts +43 -0
- package/dist/views/recommendations.js +26 -0
- package/dist/views/recommendations.js.map +1 -0
- package/dist/views/updates.d.ts +13 -0
- package/dist/views/updates.js +6 -5
- package/dist/views/updates.js.map +1 -1
- package/docs/openjob.md +63 -0
- package/migrations/0014_inbox_and_billing.sql +138 -0
- package/migrations/0015_pay_lines.sql +38 -0
- package/migrations/0016_recommendations.sql +65 -0
- package/package.json +1 -1
- package/web/public/app.css +192 -2
- package/web/public/sw.js +1 -1
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { Hono } from 'hono';
|
|
17
17
|
import { approveDeviceCode, createSession, ensureUser, normaliseEmail, pollDeviceAuth, requireAdmin, safeRedirect, startDeviceAuth, startMagicLink, } from "../../core/auth.js";
|
|
18
18
|
import { createApplication, decideApplication, listApplications, listDraftApplications, recentApplicationCount, submitApplication, validateApplication, } from "../../core/applications.js";
|
|
19
|
-
import { countJobs, createJob, getJobBySlug, getJobBySourceUrl, normaliseInput, searchJobs, setStatus, editJob, updateJobFromImport, } from "../../core/jobs.js";
|
|
19
|
+
import { countJobs, createJob, getJobBySlug, getJobBySourceUrl, normaliseInput, publishProblem, searchJobs, setStatus, editJob, updateJobFromImport, } from "../../core/jobs.js";
|
|
20
20
|
import { extractJob, JobImportProblem } from "../../core/import-job.js";
|
|
21
21
|
import { createOrg, deleteOrg, getOrgBySlug, isMember, listOrgs, listOrgsForUser, updateOrg, } from "../../core/orgs.js";
|
|
22
22
|
import { createResume, deleteResume, getPublicResume, getResume, listPublicResumes, getResumeById, getSharedResume, isVisibility, listResumes, updateResume, } from "../../core/resumes.js";
|
|
@@ -24,6 +24,7 @@ import { importDocument, ImportProblem, MAX_UPLOAD_BYTES } from "../../core/impo
|
|
|
24
24
|
import { deliverMagicLink } from "../../core/mail.js";
|
|
25
25
|
import { resumeForViewer, tagsFrom, toCandidateSummary, withTags, } from "../../core/candidates.js";
|
|
26
26
|
import { candidateSlugFor, deleteUpdate, follow, followerCount, isFollowing, listFollowedUpdates, listFollowing, listScoped, postUpdate, scopeFrom, unfollow, userForCandidate, } from "../../core/updates.js";
|
|
27
|
+
import { decide as decideRecommendation, listApproved, listGiven, listReceived, notifySubject, resolve as resolveRecommendation, withdraw as withdrawRecommendation, writeRecommendation, } from "../../core/recommendations.js";
|
|
27
28
|
import { sameOrigin } from "../../config.js";
|
|
28
29
|
import { announce, Blocked, listInstances, listTopics } from "../../directory/registry.js";
|
|
29
30
|
import { federatedSearch, targetsFromDescriptors } from "../../directory/federate.js";
|
|
@@ -33,6 +34,10 @@ import { APPLICATION_DECISIONS, isApplicationDecision } from "../../schema/job.j
|
|
|
33
34
|
import { jobPostingJsonLd } from "../../schema/jsonld.js";
|
|
34
35
|
import { parseResume } from "../../markup/resume.js";
|
|
35
36
|
import { renderMarkdown } from "../../markup/markdown.js";
|
|
37
|
+
import { counterpartyFrom, getThread, listThreads, markRead, notifyParticipants, sendMessage, startThread, } from "../../core/inbox.js";
|
|
38
|
+
import { applyWebhook, cancelInvoice, getInvoice, listInvoicesFor, listInvoicesIn, requestPayment, sendInvoice, syncInvoice, } from "../../core/invoices.js";
|
|
39
|
+
import { disconnect, finishConnect, getAccount } from "../../core/coinpay.js";
|
|
40
|
+
import { coinpayRedirectUri } from "./pages.js";
|
|
36
41
|
import { descriptorFor } from "./descriptor.js";
|
|
37
42
|
import { openApiDocument } from "./openapi.js";
|
|
38
43
|
function fail(c, status, code, message, fields) {
|
|
@@ -42,6 +47,9 @@ function fail(c, status, code, message, fields) {
|
|
|
42
47
|
function viewerOf(c) {
|
|
43
48
|
return c.get('viewer');
|
|
44
49
|
}
|
|
50
|
+
function isUuid(value) {
|
|
51
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
|
|
52
|
+
}
|
|
45
53
|
/** Body from JSON or a form, so curl, a browser and a model all work. */
|
|
46
54
|
async function readBody(c) {
|
|
47
55
|
const type = c.req.header('content-type') ?? '';
|
|
@@ -59,6 +67,38 @@ async function readBody(c) {
|
|
|
59
67
|
return {};
|
|
60
68
|
}
|
|
61
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* The pay fields of a PATCH, merged onto what the listing already says.
|
|
72
|
+
*
|
|
73
|
+
* Any pay field in the body replaces the whole of the pay: `pay` (lines, in
|
|
74
|
+
* either form), the flat salary fields, `payMethod`, `unpaid`. A body with
|
|
75
|
+
* none of them keeps the listing's pay as it is, so editing a description
|
|
76
|
+
* does not quietly clear a rate.
|
|
77
|
+
*/
|
|
78
|
+
function payPatch(body, job) {
|
|
79
|
+
const sent = (key) => body[key] !== undefined && body[key] !== null;
|
|
80
|
+
const hasLines = sent('pay') || sent('payLines') || sent('salaryMin') || sent('salaryMax') ||
|
|
81
|
+
sent('salaryPeriod') || sent('salaryCurrency');
|
|
82
|
+
const hasUnpaid = sent('salaryUnpaid') || sent('unpaid') || sent('payUnpaid');
|
|
83
|
+
return {
|
|
84
|
+
...(hasLines
|
|
85
|
+
? {
|
|
86
|
+
pay: body['pay'] ?? body['payLines'],
|
|
87
|
+
salaryMin: body['salaryMin'],
|
|
88
|
+
salaryMax: body['salaryMax'],
|
|
89
|
+
salaryPeriod: body['salaryPeriod'],
|
|
90
|
+
salaryCurrency: body['salaryCurrency'],
|
|
91
|
+
}
|
|
92
|
+
: { pay: { lines: job.pay.lines } }),
|
|
93
|
+
salaryUnpaid: hasUnpaid
|
|
94
|
+
? (body['salaryUnpaid'] ?? body['unpaid'] ?? body['payUnpaid'])
|
|
95
|
+
: hasLines
|
|
96
|
+
? false
|
|
97
|
+
: job.pay.unpaid,
|
|
98
|
+
payMethod: body['payMethod'] ?? body['paymentMethod'] ?? body['paymentCoin'] ?? job.pay.method,
|
|
99
|
+
payEquity: body['payEquity'] ?? body['salaryEquity'] ?? body['equity'] ?? job.pay.equity,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
62
102
|
export function apiRoutes() {
|
|
63
103
|
const api = new Hono();
|
|
64
104
|
// --- jobs -------------------------------------------------------------
|
|
@@ -188,11 +228,19 @@ export function apiRoutes() {
|
|
|
188
228
|
const input = normaliseInput(body, org.id);
|
|
189
229
|
if (typeof input === 'string')
|
|
190
230
|
return fail(c, 400, 'invalid', input);
|
|
191
|
-
const job = await createJob(pool, input);
|
|
192
231
|
// A job posted through the API arrives as a draft like any other, unless
|
|
193
232
|
// the caller asked for it to go live. An agent that posts a job the author
|
|
194
233
|
// has not read is the failure mode worth designing against.
|
|
195
|
-
|
|
234
|
+
const publish = body['publish'] === true || body['publish'] === 'true';
|
|
235
|
+
if (publish) {
|
|
236
|
+
// Checked before the insert: a caller who asked for a live listing and
|
|
237
|
+
// gets a 400 should not find a draft they did not ask for either.
|
|
238
|
+
const problem = publishProblem(input);
|
|
239
|
+
if (problem !== null)
|
|
240
|
+
return fail(c, 400, 'pay_required', problem);
|
|
241
|
+
}
|
|
242
|
+
const job = await createJob(pool, input);
|
|
243
|
+
if (publish) {
|
|
196
244
|
const published = await setStatus(pool, job.id, 'published');
|
|
197
245
|
return c.json({ job: published ?? job }, 201);
|
|
198
246
|
}
|
|
@@ -321,13 +369,17 @@ export function apiRoutes() {
|
|
|
321
369
|
requirements: body['requirements'] ?? job.requirements,
|
|
322
370
|
responsibilities: body['responsibilities'] ?? job.responsibilities,
|
|
323
371
|
agentPolicy: body['agentPolicy'] ?? job.agentPolicy,
|
|
324
|
-
|
|
325
|
-
salaryMax: body['salaryMax'] ?? job.salary.max ?? undefined,
|
|
326
|
-
salaryCurrency: body['salaryCurrency'] ?? job.salary.currency,
|
|
327
|
-
salaryPeriod: body['salaryPeriod'] ?? job.salary.period,
|
|
372
|
+
...payPatch(body, job),
|
|
328
373
|
}, job.org.id);
|
|
329
374
|
if (typeof merged === 'string')
|
|
330
375
|
return fail(c, 400, 'invalid', merged);
|
|
376
|
+
// A live listing stays held to the publish rule: an edit that removes
|
|
377
|
+
// the pay would leave it public without one.
|
|
378
|
+
if (job.status === 'published') {
|
|
379
|
+
const problem = publishProblem(merged);
|
|
380
|
+
if (problem !== null)
|
|
381
|
+
return fail(c, 400, 'pay_required', problem);
|
|
382
|
+
}
|
|
331
383
|
const updated = await editJob(pool, job.id, merged);
|
|
332
384
|
return c.json({ job: updated ?? job });
|
|
333
385
|
});
|
|
@@ -344,6 +396,13 @@ export function apiRoutes() {
|
|
|
344
396
|
}
|
|
345
397
|
const action = c.req.param('action');
|
|
346
398
|
const status = action === 'close' ? 'closed' : 'published';
|
|
399
|
+
if (status === 'published') {
|
|
400
|
+
// Pay is required to go live. The message says what to send, because
|
|
401
|
+
// the caller is as likely to be an agent as a person.
|
|
402
|
+
const problem = publishProblem(job);
|
|
403
|
+
if (problem !== null)
|
|
404
|
+
return fail(c, 400, 'pay_required', problem);
|
|
405
|
+
}
|
|
347
406
|
const updated = await setStatus(pool, job.id, status);
|
|
348
407
|
return c.json({ job: updated });
|
|
349
408
|
});
|
|
@@ -845,6 +904,100 @@ export function apiRoutes() {
|
|
|
845
904
|
const items = await listFollowing(pool, viewer.id);
|
|
846
905
|
return c.json({ items, total: items.length });
|
|
847
906
|
});
|
|
907
|
+
// --- recommendations ----------------------------------------------------
|
|
908
|
+
/**
|
|
909
|
+
* Written by somebody who worked with the subject, shown once the subject
|
|
910
|
+
* approves. Reading is public: an approved recommendation is on the page.
|
|
911
|
+
*/
|
|
912
|
+
api.get('/candidates/:slug/recommendations', async (c) => {
|
|
913
|
+
const { pool } = c.get('deps');
|
|
914
|
+
const userId = await userForCandidate(pool, c.req.param('slug'));
|
|
915
|
+
if (userId === null)
|
|
916
|
+
return fail(c, 404, 'not_found', `Nobody here is "${c.req.param('slug')}".`);
|
|
917
|
+
const items = await listApproved(pool, { kind: 'candidate', userId });
|
|
918
|
+
return c.json({ items, total: items.length });
|
|
919
|
+
});
|
|
920
|
+
api.get('/orgs/:slug/recommendations', async (c) => {
|
|
921
|
+
const { pool } = c.get('deps');
|
|
922
|
+
const org = await getOrgBySlug(pool, c.req.param('slug'));
|
|
923
|
+
if (org === null)
|
|
924
|
+
return fail(c, 404, 'not_found', `No employer here is "${c.req.param('slug')}".`);
|
|
925
|
+
const items = await listApproved(pool, { kind: 'employer', orgId: org.id });
|
|
926
|
+
return c.json({ items, total: items.length });
|
|
927
|
+
});
|
|
928
|
+
/**
|
|
929
|
+
* Write one. As yourself, which needs a published resume so it is signed
|
|
930
|
+
* by a page, or as an employer you post for ("as"). It is pending until
|
|
931
|
+
* the subject approves it, and writing again replaces it.
|
|
932
|
+
*/
|
|
933
|
+
const recommend = async (c, target) => {
|
|
934
|
+
const { pool, config, mailer } = c.get('deps');
|
|
935
|
+
const viewer = viewerOf(c);
|
|
936
|
+
if (viewer === null)
|
|
937
|
+
return fail(c, 401, 'unauthorised', 'Sign in to write a recommendation.');
|
|
938
|
+
const body = await readBody(c);
|
|
939
|
+
const resolved = await resolveRecommendation(pool, viewer.id, {
|
|
940
|
+
...target,
|
|
941
|
+
...(typeof body['as'] === 'string' ? { as: body['as'] } : {}),
|
|
942
|
+
});
|
|
943
|
+
if (typeof resolved === 'string') {
|
|
944
|
+
const notFound = /^Nobody here|^No employer/.test(resolved);
|
|
945
|
+
const noPage = /Publish a resume|signed by a page/.test(resolved);
|
|
946
|
+
return fail(c, notFound ? 404 : noPage ? 403 : 400, notFound ? 'not_found' : noPage ? 'no_profile' : 'invalid', resolved);
|
|
947
|
+
}
|
|
948
|
+
const written = await writeRecommendation(pool, viewer.id, {
|
|
949
|
+
as: resolved.as,
|
|
950
|
+
subject: resolved.subject,
|
|
951
|
+
body: body['body'],
|
|
952
|
+
relationship: body['relationship'],
|
|
953
|
+
});
|
|
954
|
+
if (typeof written === 'string') {
|
|
955
|
+
return fail(c, /limit/.test(written) ? 429 : 400, /limit/.test(written) ? 'rate_limited' : 'invalid', written);
|
|
956
|
+
}
|
|
957
|
+
void notifySubject({
|
|
958
|
+
pool,
|
|
959
|
+
mailer,
|
|
960
|
+
boardName: config.boardName,
|
|
961
|
+
publicUrl: config.publicUrl,
|
|
962
|
+
recommendation: written,
|
|
963
|
+
subject: resolved.subject,
|
|
964
|
+
}).catch(() => undefined);
|
|
965
|
+
return c.json({ recommendation: written }, 201);
|
|
966
|
+
};
|
|
967
|
+
api.post('/candidates/:slug/recommendations', (c) => recommend(c, { candidate: c.req.param('slug') }));
|
|
968
|
+
api.post('/orgs/:slug/recommendations', (c) => recommend(c, { org: c.req.param('slug') }));
|
|
969
|
+
/** About you and your employers, every status, and what you wrote. */
|
|
970
|
+
api.get('/me/recommendations', async (c) => {
|
|
971
|
+
const { pool } = c.get('deps');
|
|
972
|
+
const viewer = viewerOf(c);
|
|
973
|
+
if (viewer === null)
|
|
974
|
+
return fail(c, 401, 'unauthorised', 'Sign in to see your recommendations.');
|
|
975
|
+
const [received, given] = await Promise.all([listReceived(pool, viewer.id), listGiven(pool, viewer.id)]);
|
|
976
|
+
return c.json({ received, given, pending: received.filter((item) => item.status === 'pending').length });
|
|
977
|
+
});
|
|
978
|
+
/**
|
|
979
|
+
* Approve or reject one written about you, or withdraw one you wrote. A
|
|
980
|
+
* 404 covers "no such recommendation" and "not yours to decide", which
|
|
981
|
+
* are the same fact to a caller who should not be able to tell.
|
|
982
|
+
*/
|
|
983
|
+
api.post('/recommendations/:id/:action{approve|reject|withdraw}', async (c) => {
|
|
984
|
+
const { pool } = c.get('deps');
|
|
985
|
+
const viewer = viewerOf(c);
|
|
986
|
+
if (viewer === null)
|
|
987
|
+
return fail(c, 401, 'unauthorised', 'Sign in first.');
|
|
988
|
+
const id = c.req.param('id');
|
|
989
|
+
const action = c.req.param('action');
|
|
990
|
+
if (action === 'withdraw') {
|
|
991
|
+
const gone = await withdrawRecommendation(pool, viewer.id, id);
|
|
992
|
+
if (!gone)
|
|
993
|
+
return fail(c, 404, 'not_found', 'No such recommendation, or it is not yours to withdraw.');
|
|
994
|
+
return c.json({ withdrawn: true });
|
|
995
|
+
}
|
|
996
|
+
const decided = await decideRecommendation(pool, viewer.id, id, action === 'approve' ? 'approved' : 'rejected');
|
|
997
|
+
if (decided === null)
|
|
998
|
+
return fail(c, 404, 'not_found', 'No such recommendation, or it is not yours to decide.');
|
|
999
|
+
return c.json({ recommendation: decided });
|
|
1000
|
+
});
|
|
848
1001
|
// --- sign in ----------------------------------------------------------
|
|
849
1002
|
api.post('/auth/magic-link', async (c) => {
|
|
850
1003
|
const { pool, config, mailer } = c.get('deps');
|
|
@@ -954,6 +1107,283 @@ export function apiRoutes() {
|
|
|
954
1107
|
const result = await federatedSearch(targetsFromDescriptors(instances.map((instance) => instance.descriptor)), query);
|
|
955
1108
|
return c.json(result);
|
|
956
1109
|
});
|
|
1110
|
+
// --- inbox ------------------------------------------------------------
|
|
1111
|
+
/**
|
|
1112
|
+
* Private conversations. Everything here needs the caller's identity, and
|
|
1113
|
+
* nothing here is visible to anyone outside the conversation: a thread id
|
|
1114
|
+
* that is not yours is a 404, not a 403, so ids cannot be probed.
|
|
1115
|
+
*/
|
|
1116
|
+
api.get('/inbox', async (c) => {
|
|
1117
|
+
const { pool } = c.get('deps');
|
|
1118
|
+
const viewer = viewerOf(c);
|
|
1119
|
+
if (viewer === null)
|
|
1120
|
+
return fail(c, 401, 'unauthenticated', 'Sign in to read your inbox.');
|
|
1121
|
+
const items = await listThreads(pool, viewer.id);
|
|
1122
|
+
return c.json({ items, total: items.length, unread: items.filter((t) => t.unread > 0).length });
|
|
1123
|
+
});
|
|
1124
|
+
/**
|
|
1125
|
+
* Start a conversation, or continue the one these two parties already have.
|
|
1126
|
+
*
|
|
1127
|
+
* `candidate` or `employer` names who it is to, by slug. `as` is an employer
|
|
1128
|
+
* slug to write as, for a member writing on the company's behalf. `job` ties
|
|
1129
|
+
* it to a listing.
|
|
1130
|
+
*/
|
|
1131
|
+
api.post('/inbox', async (c) => {
|
|
1132
|
+
const { pool, config, mailer } = c.get('deps');
|
|
1133
|
+
const viewer = viewerOf(c);
|
|
1134
|
+
if (viewer === null)
|
|
1135
|
+
return fail(c, 401, 'unauthenticated', 'Sign in to send a message.');
|
|
1136
|
+
const body = await readBody(c);
|
|
1137
|
+
const to = await counterpartyFrom(pool, {
|
|
1138
|
+
candidate: typeof body['candidate'] === 'string' ? body['candidate'] : null,
|
|
1139
|
+
employer: typeof body['employer'] === 'string' ? body['employer'] : null,
|
|
1140
|
+
});
|
|
1141
|
+
if (to === null) {
|
|
1142
|
+
return fail(c, 404, 'not_found', 'Name a candidate or an employer by slug. Nobody here matches.');
|
|
1143
|
+
}
|
|
1144
|
+
const jobSlug = typeof body['job'] === 'string' ? body['job'].trim() : '';
|
|
1145
|
+
const job = jobSlug === '' ? null : await getJobBySlug(pool, jobSlug);
|
|
1146
|
+
if (jobSlug !== '' && job === null)
|
|
1147
|
+
return fail(c, 404, 'not_found', `No job with the slug "${jobSlug}".`);
|
|
1148
|
+
const asSlug = typeof body['as'] === 'string' ? body['as'].trim() : '';
|
|
1149
|
+
const asOrg = asSlug === '' ? null : await getOrgBySlug(pool, asSlug);
|
|
1150
|
+
if (asSlug !== '' && asOrg === null)
|
|
1151
|
+
return fail(c, 404, 'not_found', `No employer with the slug "${asSlug}".`);
|
|
1152
|
+
const started = await startThread(pool, viewer.id, to, {
|
|
1153
|
+
subject: body['subject'],
|
|
1154
|
+
body: body['body'],
|
|
1155
|
+
jobId: job?.id ?? null,
|
|
1156
|
+
as: asOrg?.id ?? null,
|
|
1157
|
+
});
|
|
1158
|
+
if (typeof started === 'string') {
|
|
1159
|
+
return fail(c, started.includes('conversations today') ? 429 : 400, 'rejected', started);
|
|
1160
|
+
}
|
|
1161
|
+
await notifyParticipants(pool, {
|
|
1162
|
+
mailer,
|
|
1163
|
+
boardName: config.boardName,
|
|
1164
|
+
publicUrl: config.publicUrl,
|
|
1165
|
+
threadId: started.threadId,
|
|
1166
|
+
senderId: viewer.id,
|
|
1167
|
+
kind: 'text',
|
|
1168
|
+
});
|
|
1169
|
+
return c.json({ ...started, url: `${config.publicUrl}/inbox/${started.threadId}` }, started.created ? 201 : 200);
|
|
1170
|
+
});
|
|
1171
|
+
api.get('/inbox/:id', async (c) => {
|
|
1172
|
+
const { pool, coinpay } = c.get('deps');
|
|
1173
|
+
const viewer = viewerOf(c);
|
|
1174
|
+
if (viewer === null)
|
|
1175
|
+
return fail(c, 401, 'unauthenticated', 'Sign in to read your inbox.');
|
|
1176
|
+
const id = c.req.param('id');
|
|
1177
|
+
if (!isUuid(id))
|
|
1178
|
+
return fail(c, 404, 'not_found', 'No such conversation.');
|
|
1179
|
+
const thread = await getThread(pool, id, viewer.id);
|
|
1180
|
+
if (thread === null)
|
|
1181
|
+
return fail(c, 404, 'not_found', 'No such conversation.');
|
|
1182
|
+
const invoices = await Promise.all((await listInvoicesIn(pool, coinpay, id)).map((invoice) => syncInvoice(pool, coinpay, invoice)));
|
|
1183
|
+
await markRead(pool, id, viewer.id);
|
|
1184
|
+
return c.json({ thread, invoices });
|
|
1185
|
+
});
|
|
1186
|
+
api.post('/inbox/:id/messages', async (c) => {
|
|
1187
|
+
const { pool, config, mailer } = c.get('deps');
|
|
1188
|
+
const viewer = viewerOf(c);
|
|
1189
|
+
if (viewer === null)
|
|
1190
|
+
return fail(c, 401, 'unauthenticated', 'Sign in to send a message.');
|
|
1191
|
+
const id = c.req.param('id');
|
|
1192
|
+
if (!isUuid(id))
|
|
1193
|
+
return fail(c, 404, 'not_found', 'No such conversation.');
|
|
1194
|
+
const body = await readBody(c);
|
|
1195
|
+
const sent = await sendMessage(pool, id, viewer.id, body['body']);
|
|
1196
|
+
if (typeof sent === 'string') {
|
|
1197
|
+
return fail(c, sent.includes('not in that') ? 404 : 400, 'rejected', sent);
|
|
1198
|
+
}
|
|
1199
|
+
await notifyParticipants(pool, {
|
|
1200
|
+
mailer,
|
|
1201
|
+
boardName: config.boardName,
|
|
1202
|
+
publicUrl: config.publicUrl,
|
|
1203
|
+
threadId: id,
|
|
1204
|
+
senderId: viewer.id,
|
|
1205
|
+
kind: 'text',
|
|
1206
|
+
});
|
|
1207
|
+
return c.json({ message: sent }, 201);
|
|
1208
|
+
});
|
|
1209
|
+
// --- invoices and billing ------------------------------------------------
|
|
1210
|
+
/**
|
|
1211
|
+
* Send an invoice into a conversation. The caller is the payee; the money
|
|
1212
|
+
* settles to the wallet on their connected CoinPay account, in `currency`
|
|
1213
|
+
* (a chain they have a wallet on). Omit `currency` when there is one wallet.
|
|
1214
|
+
*/
|
|
1215
|
+
api.post('/inbox/:id/invoices', async (c) => {
|
|
1216
|
+
const { pool, config, mailer, coinpay } = c.get('deps');
|
|
1217
|
+
const viewer = viewerOf(c);
|
|
1218
|
+
if (viewer === null)
|
|
1219
|
+
return fail(c, 401, 'unauthenticated', 'Sign in to send an invoice.');
|
|
1220
|
+
const id = c.req.param('id');
|
|
1221
|
+
if (!isUuid(id))
|
|
1222
|
+
return fail(c, 404, 'not_found', 'No such conversation.');
|
|
1223
|
+
const body = await readBody(c);
|
|
1224
|
+
const invoice = await sendInvoice(pool, coinpay, {
|
|
1225
|
+
threadId: id,
|
|
1226
|
+
payeeId: viewer.id,
|
|
1227
|
+
amount: body['amount'],
|
|
1228
|
+
currency: body['currency'],
|
|
1229
|
+
description: body['description'],
|
|
1230
|
+
});
|
|
1231
|
+
if (typeof invoice === 'string') {
|
|
1232
|
+
return fail(c, invoice.includes('not in that') ? 404 : 400, 'rejected', invoice);
|
|
1233
|
+
}
|
|
1234
|
+
await notifyParticipants(pool, {
|
|
1235
|
+
mailer,
|
|
1236
|
+
boardName: config.boardName,
|
|
1237
|
+
publicUrl: config.publicUrl,
|
|
1238
|
+
threadId: id,
|
|
1239
|
+
senderId: viewer.id,
|
|
1240
|
+
kind: 'invoice',
|
|
1241
|
+
});
|
|
1242
|
+
return c.json({ invoice }, 201);
|
|
1243
|
+
});
|
|
1244
|
+
/** Every invoice the caller sent or can pay, newest first. */
|
|
1245
|
+
api.get('/invoices', async (c) => {
|
|
1246
|
+
const { pool, coinpay } = c.get('deps');
|
|
1247
|
+
const viewer = viewerOf(c);
|
|
1248
|
+
if (viewer === null)
|
|
1249
|
+
return fail(c, 401, 'unauthenticated', 'Sign in to read your invoices.');
|
|
1250
|
+
const items = await listInvoicesFor(pool, coinpay, viewer.id);
|
|
1251
|
+
return c.json({ items, total: items.length });
|
|
1252
|
+
});
|
|
1253
|
+
api.get('/invoices/:id', async (c) => {
|
|
1254
|
+
const { pool, coinpay } = c.get('deps');
|
|
1255
|
+
const viewer = viewerOf(c);
|
|
1256
|
+
if (viewer === null)
|
|
1257
|
+
return fail(c, 401, 'unauthenticated', 'Sign in to read an invoice.');
|
|
1258
|
+
const id = c.req.param('id');
|
|
1259
|
+
if (!isUuid(id))
|
|
1260
|
+
return fail(c, 404, 'not_found', 'No such invoice.');
|
|
1261
|
+
const invoice = await getInvoice(pool, coinpay, id, viewer.id);
|
|
1262
|
+
if (invoice === null)
|
|
1263
|
+
return fail(c, 404, 'not_found', 'No such invoice.');
|
|
1264
|
+
return c.json({ invoice: await syncInvoice(pool, coinpay, invoice) });
|
|
1265
|
+
});
|
|
1266
|
+
/**
|
|
1267
|
+
* Get a live quote to pay an invoice. The response carries `payment.url`,
|
|
1268
|
+
* the CoinPay page to pay on, and `payment.address` plus `amountCrypto` for
|
|
1269
|
+
* a wallet that would rather pay directly.
|
|
1270
|
+
*/
|
|
1271
|
+
api.post('/invoices/:id/pay', async (c) => {
|
|
1272
|
+
const { pool, config, coinpay } = c.get('deps');
|
|
1273
|
+
const viewer = viewerOf(c);
|
|
1274
|
+
if (viewer === null)
|
|
1275
|
+
return fail(c, 401, 'unauthenticated', 'Sign in to pay an invoice.');
|
|
1276
|
+
const id = c.req.param('id');
|
|
1277
|
+
if (!isUuid(id))
|
|
1278
|
+
return fail(c, 404, 'not_found', 'No such invoice.');
|
|
1279
|
+
const result = await requestPayment(pool, coinpay, {
|
|
1280
|
+
invoiceId: id,
|
|
1281
|
+
payerId: viewer.id,
|
|
1282
|
+
publicUrl: config.publicUrl,
|
|
1283
|
+
});
|
|
1284
|
+
if (typeof result === 'string') {
|
|
1285
|
+
return fail(c, result === 'No such invoice.' ? 404 : 400, 'rejected', result);
|
|
1286
|
+
}
|
|
1287
|
+
return c.json({ invoice: result });
|
|
1288
|
+
});
|
|
1289
|
+
api.post('/invoices/:id/cancel', async (c) => {
|
|
1290
|
+
const { pool, coinpay } = c.get('deps');
|
|
1291
|
+
const viewer = viewerOf(c);
|
|
1292
|
+
if (viewer === null)
|
|
1293
|
+
return fail(c, 401, 'unauthenticated', 'Sign in first.');
|
|
1294
|
+
const id = c.req.param('id');
|
|
1295
|
+
if (!isUuid(id))
|
|
1296
|
+
return fail(c, 404, 'not_found', 'No such invoice.');
|
|
1297
|
+
const invoice = await getInvoice(pool, coinpay, id, viewer.id);
|
|
1298
|
+
if (invoice === null)
|
|
1299
|
+
return fail(c, 404, 'not_found', 'No such invoice.');
|
|
1300
|
+
const cancelled = await cancelInvoice(pool, id, viewer.id);
|
|
1301
|
+
if (!cancelled) {
|
|
1302
|
+
return fail(c, 409, 'not_cancellable', 'Only the sender can cancel an invoice, and only while it is unpaid.');
|
|
1303
|
+
}
|
|
1304
|
+
return c.json({ ok: true });
|
|
1305
|
+
});
|
|
1306
|
+
/**
|
|
1307
|
+
* The caller's CoinPay connection: whether this board has billing at all,
|
|
1308
|
+
* and whether this account is connected well enough to be paid.
|
|
1309
|
+
*/
|
|
1310
|
+
api.get('/coinpay', async (c) => {
|
|
1311
|
+
const { pool, config, coinpay } = c.get('deps');
|
|
1312
|
+
const viewer = viewerOf(c);
|
|
1313
|
+
if (viewer === null)
|
|
1314
|
+
return fail(c, 401, 'unauthenticated', 'Sign in first.');
|
|
1315
|
+
if (coinpay === null)
|
|
1316
|
+
return c.json({ configured: false, account: null });
|
|
1317
|
+
const account = await getAccount(pool, viewer.id);
|
|
1318
|
+
return c.json({
|
|
1319
|
+
configured: true,
|
|
1320
|
+
account,
|
|
1321
|
+
// Connecting needs a browser: it is CoinPay's consent screen.
|
|
1322
|
+
connectUrl: `${config.publicUrl}/me/coinpay/connect`,
|
|
1323
|
+
});
|
|
1324
|
+
});
|
|
1325
|
+
api.delete('/coinpay', async (c) => {
|
|
1326
|
+
const { pool } = c.get('deps');
|
|
1327
|
+
const viewer = viewerOf(c);
|
|
1328
|
+
if (viewer === null)
|
|
1329
|
+
return fail(c, 401, 'unauthenticated', 'Sign in first.');
|
|
1330
|
+
return c.json({ ok: await disconnect(pool, viewer.id) });
|
|
1331
|
+
});
|
|
1332
|
+
/**
|
|
1333
|
+
* Where CoinPay sends the person after consent. Under /api/v1 because that
|
|
1334
|
+
* is the path registered on the OAuth client; it answers a browser, with a
|
|
1335
|
+
* redirect, not JSON.
|
|
1336
|
+
*/
|
|
1337
|
+
api.get('/coinpay/callback', async (c) => {
|
|
1338
|
+
const { pool, config, coinpay } = c.get('deps');
|
|
1339
|
+
const params = new URL(c.req.url).searchParams;
|
|
1340
|
+
const code = params.get('code') ?? '';
|
|
1341
|
+
const state = params.get('state') ?? '';
|
|
1342
|
+
if (params.get('error') !== null) {
|
|
1343
|
+
const said = params.get('error_description') ?? params.get('error') ?? 'refused';
|
|
1344
|
+
return c.redirect(`/me?coinpay=${encodeURIComponent(`CoinPay said: ${said}`)}#billing`, 303);
|
|
1345
|
+
}
|
|
1346
|
+
if (code === '' || state === '')
|
|
1347
|
+
return fail(c, 400, 'bad_request', 'Missing code or state.');
|
|
1348
|
+
if (coinpay === null)
|
|
1349
|
+
return fail(c, 404, 'not_found', 'This board has no payment rail configured.');
|
|
1350
|
+
const done = await finishConnect(pool, coinpay, {
|
|
1351
|
+
state,
|
|
1352
|
+
code,
|
|
1353
|
+
redirectUri: coinpayRedirectUri(config.publicUrl),
|
|
1354
|
+
});
|
|
1355
|
+
if (typeof done === 'string') {
|
|
1356
|
+
return c.redirect(`/me?coinpay=${encodeURIComponent(done)}#billing`, 303);
|
|
1357
|
+
}
|
|
1358
|
+
const back = done.redirect ?? '/me#billing';
|
|
1359
|
+
if (back.startsWith('/me'))
|
|
1360
|
+
return c.redirect('/me?coinpay=connected#billing', 303);
|
|
1361
|
+
return c.redirect(`${back}${back.includes('?') ? '&' : '?'}coinpay=connected`, 303);
|
|
1362
|
+
});
|
|
1363
|
+
/**
|
|
1364
|
+
* CoinPay telling us a payment settled. Verified against the business's
|
|
1365
|
+
* webhook secret; an unsigned or mis-signed body is dropped with a 401 and
|
|
1366
|
+
* the invoice waits for the next page load to ask CoinPay directly.
|
|
1367
|
+
*/
|
|
1368
|
+
api.post('/coinpay/webhook', async (c) => {
|
|
1369
|
+
const { pool, coinpay } = c.get('deps');
|
|
1370
|
+
if (coinpay === null)
|
|
1371
|
+
return fail(c, 404, 'not_found', 'This board has no payment rail configured.');
|
|
1372
|
+
const raw = await c.req.text();
|
|
1373
|
+
if (!coinpay.verifyWebhook(raw, c.req.header('x-coinpay-signature'))) {
|
|
1374
|
+
return fail(c, 401, 'bad_signature', "The signature does not match this board's webhook secret.");
|
|
1375
|
+
}
|
|
1376
|
+
let payload;
|
|
1377
|
+
try {
|
|
1378
|
+
const parsed = JSON.parse(raw);
|
|
1379
|
+
payload = typeof parsed === 'object' && parsed !== null ? parsed : {};
|
|
1380
|
+
}
|
|
1381
|
+
catch {
|
|
1382
|
+
return fail(c, 400, 'bad_request', 'The body is not JSON.');
|
|
1383
|
+
}
|
|
1384
|
+
const outcome = await applyWebhook(pool, payload);
|
|
1385
|
+
return c.json({ received: true, outcome });
|
|
1386
|
+
});
|
|
957
1387
|
// --- this instance ----------------------------------------------------
|
|
958
1388
|
api.get('/openapi.json', (c) => {
|
|
959
1389
|
const { config } = c.get('deps');
|