@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.
Files changed (123) hide show
  1. package/README.md +136 -1
  2. package/dist/cli/args.js +4 -2
  3. package/dist/cli/args.js.map +1 -1
  4. package/dist/cli/index.js +227 -5
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/cli/jobfile.js +38 -2
  7. package/dist/cli/jobfile.js.map +1 -1
  8. package/dist/client/client.d.ts +157 -0
  9. package/dist/client/client.js +44 -0
  10. package/dist/client/client.js.map +1 -1
  11. package/dist/config.d.ts +35 -1
  12. package/dist/config.js +33 -1
  13. package/dist/config.js.map +1 -1
  14. package/dist/core/agentwriter.js +22 -5
  15. package/dist/core/agentwriter.js.map +1 -1
  16. package/dist/core/applications.js +1 -1
  17. package/dist/core/applications.js.map +1 -1
  18. package/dist/core/candidates.js +3 -5
  19. package/dist/core/candidates.js.map +1 -1
  20. package/dist/core/capacity.d.ts +1 -1
  21. package/dist/core/capacity.js +11 -19
  22. package/dist/core/capacity.js.map +1 -1
  23. package/dist/core/coinpay.d.ts +143 -0
  24. package/dist/core/coinpay.js +417 -0
  25. package/dist/core/coinpay.js.map +1 -0
  26. package/dist/core/import.js +19 -9
  27. package/dist/core/import.js.map +1 -1
  28. package/dist/core/inbox.d.ts +155 -0
  29. package/dist/core/inbox.js +353 -0
  30. package/dist/core/inbox.js.map +1 -0
  31. package/dist/core/invoices.d.ts +111 -0
  32. package/dist/core/invoices.js +292 -0
  33. package/dist/core/invoices.js.map +1 -0
  34. package/dist/core/jobs.d.ts +24 -6
  35. package/dist/core/jobs.js +116 -31
  36. package/dist/core/jobs.js.map +1 -1
  37. package/dist/core/mail.d.ts +16 -0
  38. package/dist/core/mail.js +22 -0
  39. package/dist/core/mail.js.map +1 -1
  40. package/dist/core/recommendations.d.ts +134 -0
  41. package/dist/core/recommendations.js +356 -0
  42. package/dist/core/recommendations.js.map +1 -0
  43. package/dist/core/updates.d.ts +15 -3
  44. package/dist/core/updates.js +58 -24
  45. package/dist/core/updates.js.map +1 -1
  46. package/dist/core/zip.js +29 -4
  47. package/dist/core/zip.js.map +1 -1
  48. package/dist/directory/federate.js +3 -1
  49. package/dist/directory/federate.js.map +1 -1
  50. package/dist/directory/fetch.d.ts +1 -1
  51. package/dist/markup/markdown.js +6 -2
  52. package/dist/markup/markdown.js.map +1 -1
  53. package/dist/markup/resume.js +18 -5
  54. package/dist/markup/resume.js.map +1 -1
  55. package/dist/mcp/tools.js +249 -7
  56. package/dist/mcp/tools.js.map +1 -1
  57. package/dist/schema/index.d.ts +1 -0
  58. package/dist/schema/index.js +1 -0
  59. package/dist/schema/index.js.map +1 -1
  60. package/dist/schema/instance.js +29 -0
  61. package/dist/schema/instance.js.map +1 -1
  62. package/dist/schema/job.d.ts +12 -0
  63. package/dist/schema/job.js +17 -3
  64. package/dist/schema/job.js.map +1 -1
  65. package/dist/schema/jsonld.js +7 -6
  66. package/dist/schema/jsonld.js.map +1 -1
  67. package/dist/schema/pay.d.ts +173 -0
  68. package/dist/schema/pay.js +668 -0
  69. package/dist/schema/pay.js.map +1 -0
  70. package/dist/server/app.d.ts +4 -3
  71. package/dist/server/app.js +10 -5
  72. package/dist/server/app.js.map +1 -1
  73. package/dist/server/deps.d.ts +5 -0
  74. package/dist/server/middleware.d.ts +11 -1
  75. package/dist/server/middleware.js +26 -3
  76. package/dist/server/middleware.js.map +1 -1
  77. package/dist/server/routes/api.js +437 -7
  78. package/dist/server/routes/api.js.map +1 -1
  79. package/dist/server/routes/discovery.js +29 -8
  80. package/dist/server/routes/discovery.js.map +1 -1
  81. package/dist/server/routes/inbox.d.ts +9 -0
  82. package/dist/server/routes/inbox.js +223 -0
  83. package/dist/server/routes/inbox.js.map +1 -0
  84. package/dist/server/routes/openapi.js +260 -2
  85. package/dist/server/routes/openapi.js.map +1 -1
  86. package/dist/server/routes/pages.d.ts +22 -0
  87. package/dist/server/routes/pages.js +244 -19
  88. package/dist/server/routes/pages.js.map +1 -1
  89. package/dist/tui/views.js +9 -3
  90. package/dist/tui/views.js.map +1 -1
  91. package/dist/views/candidates.d.ts +7 -0
  92. package/dist/views/candidates.js +3 -2
  93. package/dist/views/candidates.js.map +1 -1
  94. package/dist/views/docs.js +10 -1
  95. package/dist/views/docs.js.map +1 -1
  96. package/dist/views/inbox.d.ts +90 -0
  97. package/dist/views/inbox.js +73 -0
  98. package/dist/views/inbox.js.map +1 -0
  99. package/dist/views/jobs.d.ts +25 -0
  100. package/dist/views/jobs.js +35 -12
  101. package/dist/views/jobs.js.map +1 -1
  102. package/dist/views/layout.d.ts +24 -0
  103. package/dist/views/layout.js +20 -2
  104. package/dist/views/layout.js.map +1 -1
  105. package/dist/views/me.d.ts +10 -3
  106. package/dist/views/me.js +3 -2
  107. package/dist/views/me.js.map +1 -1
  108. package/dist/views/post.d.ts +18 -0
  109. package/dist/views/post.js +39 -7
  110. package/dist/views/post.js.map +1 -1
  111. package/dist/views/recommendations.d.ts +43 -0
  112. package/dist/views/recommendations.js +26 -0
  113. package/dist/views/recommendations.js.map +1 -0
  114. package/dist/views/updates.d.ts +13 -0
  115. package/dist/views/updates.js +6 -5
  116. package/dist/views/updates.js.map +1 -1
  117. package/docs/openjob.md +63 -0
  118. package/migrations/0014_inbox_and_billing.sql +138 -0
  119. package/migrations/0015_pay_lines.sql +38 -0
  120. package/migrations/0016_recommendations.sql +65 -0
  121. package/package.json +1 -1
  122. package/web/public/app.css +192 -2
  123. package/web/public/sw.js +1 -1
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Invoices: a message with money attached.
3
+ *
4
+ * The payee writes one in a conversation they are in. The other side sees it
5
+ * there, presses Pay, and is sent to CoinPay to settle it in the coin the
6
+ * payee chose, straight to the payee's wallet. The board mints the payment
7
+ * under its own business key and names the payee's address as the payout, so
8
+ * at no point does money sit anywhere the board controls.
9
+ *
10
+ * A CoinPay payment is a quote that expires. The invoice does not: when a
11
+ * quote lapses unpaid, Pay mints a fresh one under the same invoice, and
12
+ * `status` only ever moves sent -> paid or sent -> cancelled. Payment is
13
+ * confirmed by webhook, and by asking CoinPay when the page is opened, so a
14
+ * lost webhook delays the answer rather than losing it.
15
+ */
16
+ import { clean } from "../schema/text.js";
17
+ import { CoinPayProblem, getAccount } from "./coinpay.js";
18
+ const SELECT = `
19
+ select i.*, u.name as payee_name, u.email as payee_email
20
+ from invoices i join users u on u.id = i.payee_id`;
21
+ function toInvoice(row, coinpay) {
22
+ const name = (row.payee_name ?? '').trim();
23
+ return {
24
+ id: row.id,
25
+ threadId: row.thread_id,
26
+ payee: { id: row.payee_id, name: name === '' ? row.payee_email : name },
27
+ amountUsd: Number(row.amount_usd).toFixed(2),
28
+ currency: row.currency,
29
+ walletAddress: row.wallet_address,
30
+ description: row.description,
31
+ status: row.status,
32
+ payment: row.coinpay_payment_id === null || coinpay === null
33
+ ? null
34
+ : {
35
+ id: row.coinpay_payment_id,
36
+ address: row.payment_address,
37
+ amountCrypto: row.amount_crypto,
38
+ expiresAt: row.payment_expires_at,
39
+ url: coinpay.payUrl(row.coinpay_payment_id),
40
+ },
41
+ paidAt: row.paid_at,
42
+ paidBy: row.paid_by,
43
+ txHash: row.tx_hash,
44
+ createdAt: row.created_at,
45
+ };
46
+ }
47
+ /** A dollar amount as typed: "120", "120.50", "$1,200". Null when it is not one. */
48
+ export function parseAmount(value) {
49
+ if (typeof value !== 'string' && typeof value !== 'number')
50
+ return null;
51
+ const raw = String(value).replace(/[$,\s]/g, '');
52
+ if (!/^\d+(\.\d{1,2})?$/.test(raw))
53
+ return null;
54
+ const amount = Number(raw);
55
+ if (!(amount > 0) || amount > 1_000_000)
56
+ return null;
57
+ return amount.toFixed(2);
58
+ }
59
+ /**
60
+ * Send one. Returns the invoice, or a sentence saying why not.
61
+ *
62
+ * The payee has to be in the conversation and has to have connected a CoinPay
63
+ * account with a wallet on the chain they chose; the address is copied onto
64
+ * the invoice at this moment and never read again, so what the payer pays to
65
+ * is what the payee had connected when they asked.
66
+ */
67
+ export async function sendInvoice(pool, coinpay, input) {
68
+ if (coinpay === null)
69
+ return 'This board has no payment rail configured.';
70
+ const amount = parseAmount(input.amount);
71
+ if (amount === null)
72
+ return 'The amount has to be a number of dollars, like 1200 or 1200.50.';
73
+ const description = clean(input.description, 1000, { multiline: true });
74
+ const account = await getAccount(pool, input.payeeId);
75
+ if (account === null)
76
+ return 'Connect a CoinPay account first, so there is a wallet to be paid to.';
77
+ if (!account.usable)
78
+ return 'Your CoinPay connection has lapsed. Reconnect it, then send the invoice.';
79
+ if (account.wallets.length === 0) {
80
+ return 'Your CoinPay account has no wallet yet. Add one on CoinPay, refresh the connection here, then send the invoice.';
81
+ }
82
+ const wanted = clean(input.currency, 20).toUpperCase();
83
+ const wallet = wanted === ''
84
+ ? account.wallets.length === 1
85
+ ? account.wallets[0]
86
+ : undefined
87
+ : account.wallets.find((w) => w.chain === wanted);
88
+ if (wallet === undefined) {
89
+ return wanted === ''
90
+ ? `Say which coin to be paid in: ${account.wallets.map((w) => w.chain).join(', ')}.`
91
+ : `You have no ${wanted} wallet connected. You can be paid in: ${account.wallets.map((w) => w.chain).join(', ')}.`;
92
+ }
93
+ const client = await pool.connect();
94
+ try {
95
+ await client.query('begin');
96
+ const member = await client.query(`select 1 from thread_participants where thread_id = $1 and user_id = $2`, [input.threadId, input.payeeId]);
97
+ if (member.rows.length === 0) {
98
+ await client.query('rollback');
99
+ return 'You are not in that conversation.';
100
+ }
101
+ const inserted = await client.query(`insert into invoices (thread_id, payee_id, amount_usd, currency, wallet_address, description)
102
+ values ($1, $2, $3, $4, $5, $6) returning id`, [input.threadId, input.payeeId, amount, wallet.chain, wallet.address, description]);
103
+ const invoiceId = inserted.rows[0].id;
104
+ const body = description === '' ? `Invoice for $${amount}` : description;
105
+ const message = await client.query(`insert into messages (thread_id, sender_id, kind, body, invoice_id)
106
+ values ($1, $2, 'invoice', $3, $4) returning created_at`, [input.threadId, input.payeeId, body, invoiceId]);
107
+ await client.query(`update threads set last_message_at = $2 where id = $1`, [
108
+ input.threadId,
109
+ message.rows[0].created_at,
110
+ ]);
111
+ await client.query(`update thread_participants set last_read_at = $3 where thread_id = $1 and user_id = $2`, [input.threadId, input.payeeId, message.rows[0].created_at]);
112
+ await client.query('commit');
113
+ const row = await pool.query(`${SELECT} where i.id = $1`, [invoiceId]);
114
+ return toInvoice(row.rows[0], coinpay);
115
+ }
116
+ catch (error) {
117
+ await client.query('rollback');
118
+ throw error;
119
+ }
120
+ finally {
121
+ client.release();
122
+ }
123
+ }
124
+ /** The invoices in a conversation, oldest first, for rendering beside messages. */
125
+ export async function listInvoicesIn(pool, coinpay, threadId) {
126
+ const result = await pool.query(`${SELECT} where i.thread_id = $1 order by i.created_at asc`, [threadId]);
127
+ return result.rows.map((row) => toInvoice(row, coinpay));
128
+ }
129
+ /**
130
+ * Everything the viewer sent or can pay: their invoices, and the invoices in
131
+ * conversations they are in. Newest first.
132
+ */
133
+ export async function listInvoicesFor(pool, coinpay, viewerId) {
134
+ const result = await pool.query(`${SELECT}
135
+ where exists (select 1 from thread_participants tp
136
+ where tp.thread_id = i.thread_id and tp.user_id = $1)
137
+ order by i.created_at desc limit 200`, [viewerId]);
138
+ return result.rows.map((row) => toInvoice(row, coinpay));
139
+ }
140
+ /** One invoice, for a participant of its conversation. Null for anybody else. */
141
+ export async function getInvoice(pool, coinpay, invoiceId, viewerId) {
142
+ const result = await pool.query(`${SELECT}
143
+ where i.id = $1
144
+ and exists (select 1 from thread_participants tp
145
+ where tp.thread_id = i.thread_id and tp.user_id = $2)`, [invoiceId, viewerId]);
146
+ const row = result.rows[0];
147
+ return row === undefined ? null : toInvoice(row, coinpay);
148
+ }
149
+ /** The payee takes it back. Only while unpaid. */
150
+ export async function cancelInvoice(pool, invoiceId, payeeId) {
151
+ const result = await pool.query(`update invoices set status = 'cancelled', updated_at = now()
152
+ where id = $1 and payee_id = $2 and status = 'sent'`, [invoiceId, payeeId]);
153
+ return (result.rowCount ?? 0) > 0;
154
+ }
155
+ /**
156
+ * Get somewhere to pay. Returns the invoice with a live payment on it, or a
157
+ * sentence.
158
+ *
159
+ * Anyone in the conversation but the payee may pay. A payment still inside its
160
+ * quote window is reused; otherwise a new one is minted for the same invoice,
161
+ * and the id is remembered so the webhook can find its way back.
162
+ */
163
+ export async function requestPayment(pool, coinpay, input) {
164
+ if (coinpay === null)
165
+ return 'This board has no payment rail configured.';
166
+ const current = await getInvoice(pool, coinpay, input.invoiceId, input.payerId);
167
+ if (current === null)
168
+ return 'No such invoice.';
169
+ if (current.payee.id === input.payerId)
170
+ return 'You cannot pay your own invoice.';
171
+ if (current.status === 'paid')
172
+ return 'That invoice is already paid.';
173
+ if (current.status === 'cancelled')
174
+ return 'That invoice was cancelled.';
175
+ // Ask CoinPay about the payment we have before minting another: a payer who
176
+ // paid and came back should see "paid", not a second quote.
177
+ const synced = await syncInvoice(pool, coinpay, current);
178
+ if (synced.status === 'paid')
179
+ return synced;
180
+ if (synced.payment !== null &&
181
+ synced.payment.expiresAt !== null &&
182
+ new Date(synced.payment.expiresAt).getTime() > Date.now() + 60_000) {
183
+ return synced;
184
+ }
185
+ let minted;
186
+ try {
187
+ minted = await coinpay.createPayment({
188
+ amountUsd: current.amountUsd,
189
+ chain: current.currency,
190
+ payeeAddress: current.walletAddress,
191
+ description: current.description === '' ? `Invoice ${current.id.slice(0, 8)}` : current.description,
192
+ redirectUrl: `${input.publicUrl}/inbox/${current.threadId}`,
193
+ // One quote per invoice per minute: a double click is one payment.
194
+ idempotencyKey: `${current.id}:${Math.floor(Date.now() / 60_000)}`,
195
+ metadata: {
196
+ source: 'agenticjobs',
197
+ invoice_id: current.id,
198
+ thread_id: current.threadId,
199
+ payer_id: input.payerId,
200
+ },
201
+ });
202
+ }
203
+ catch (error) {
204
+ if (error instanceof CoinPayProblem)
205
+ return error.message;
206
+ throw error;
207
+ }
208
+ // Two statements, not one with a CTE: a data-modifying CTE is not visible to
209
+ // the SELECT beside it, which would read the row from before the update and
210
+ // hand back an invoice with no payment on it.
211
+ const updated = await pool.query(`update invoices set coinpay_payment_id = $2, payment_address = $3, amount_crypto = $4,
212
+ payment_expires_at = $5, updated_at = now()
213
+ where id = $1 and status = 'sent'`, [
214
+ current.id,
215
+ minted.id,
216
+ minted.address,
217
+ minted.amountCrypto,
218
+ minted.expiresAt ?? new Date(Date.now() + 15 * 60_000).toISOString(),
219
+ ]);
220
+ if ((updated.rowCount ?? 0) === 0)
221
+ return 'That invoice changed while the payment was being created.';
222
+ const saved = await pool.query(`${SELECT} where i.id = $1`, [current.id]);
223
+ const row = saved.rows[0];
224
+ return row === undefined ? 'No such invoice.' : toInvoice(row, coinpay);
225
+ }
226
+ const PAID = new Set(['confirmed', 'forwarded', 'completed', 'paid']);
227
+ const DEAD = new Set(['expired', 'failed', 'cancelled']);
228
+ /**
229
+ * Ask CoinPay how the current payment is doing and record the answer.
230
+ *
231
+ * Paid is paid, whichever way the news arrives. A dead quote is cleared so the
232
+ * next Pay mints a fresh one. Anything in between, or CoinPay not answering,
233
+ * leaves the invoice as it was: this is a read that happens on page load, and
234
+ * a provider hiccup must not turn into a changed status.
235
+ */
236
+ export async function syncInvoice(pool, coinpay, invoice) {
237
+ if (coinpay === null || invoice.status !== 'sent' || invoice.payment === null)
238
+ return invoice;
239
+ let payment;
240
+ try {
241
+ payment = await coinpay.getPayment(invoice.payment.id);
242
+ }
243
+ catch {
244
+ return invoice;
245
+ }
246
+ if (payment === null)
247
+ return invoice;
248
+ if (PAID.has(payment.status)) {
249
+ await markPaid(pool, { paymentId: payment.id, txHash: payment.txHash });
250
+ }
251
+ else if (DEAD.has(payment.status)) {
252
+ await pool.query(`update invoices set coinpay_payment_id = null, payment_address = null, amount_crypto = null,
253
+ payment_expires_at = null, updated_at = now()
254
+ where id = $1 and coinpay_payment_id = $2 and status = 'sent'`, [invoice.id, payment.id]);
255
+ }
256
+ else {
257
+ return invoice;
258
+ }
259
+ const fresh = await pool.query(`${SELECT} where i.id = $1`, [invoice.id]);
260
+ const row = fresh.rows[0];
261
+ return row === undefined ? invoice : toInvoice(row, coinpay);
262
+ }
263
+ /**
264
+ * Record a payment as settled. Idempotent: the webhook fires for `confirmed`
265
+ * and again for `forwarded`, and a poll may land between them.
266
+ */
267
+ export async function markPaid(pool, input) {
268
+ const result = await pool.query(`update invoices
269
+ set status = 'paid', paid_at = coalesce(paid_at, now()),
270
+ tx_hash = coalesce($2, tx_hash), paid_by = coalesce(paid_by, $3), updated_at = now()
271
+ where coinpay_payment_id = $1 and status in ('sent', 'paid')`, [input.paymentId, input.txHash, input.paidBy ?? null]);
272
+ return (result.rowCount ?? 0) > 0;
273
+ }
274
+ /**
275
+ * A webhook from CoinPay, already verified by the caller.
276
+ *
277
+ * Only the two settlement events change anything. An expired quote is left
278
+ * to the next page load, which clears it the same way and does not depend on
279
+ * a delivery that may never come.
280
+ */
281
+ export async function applyWebhook(pool, payload) {
282
+ const event = typeof payload['event'] === 'string' ? payload['event'] : '';
283
+ const paymentId = typeof payload['payment_id'] === 'string' ? payload['payment_id'] : '';
284
+ if (paymentId === '')
285
+ return 'ignored';
286
+ if (event !== 'payment.confirmed' && event !== 'payment.forwarded')
287
+ return 'ignored';
288
+ const txHash = typeof payload['tx_hash'] === 'string' ? payload['tx_hash'] : null;
289
+ const paid = await markPaid(pool, { paymentId, txHash });
290
+ return paid ? 'paid' : 'unknown';
291
+ }
292
+ //# sourceMappingURL=invoices.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"invoices.js","sourceRoot":"","sources":["../../src/core/invoices.ts"],"names":[],"mappings":"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"}
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type pg from 'pg';
9
9
  import { type AgentPolicy, type ApplyMethod, type ApplySchema, type EmploymentType, type Job, type JobPage, type JobQuery, type JobStatus, type Seniority, type Workplace } from '../schema/index.ts';
10
+ import { type Pay } from '../schema/pay.ts';
10
11
  interface JobRow {
11
12
  id: string;
12
13
  slug: string;
@@ -23,6 +24,8 @@ interface JobRow {
23
24
  salary_period: string;
24
25
  salary_equity: string | null;
25
26
  salary_unpaid: boolean | null;
27
+ pay_lines: unknown;
28
+ pay_method: string | null;
26
29
  tags: string[];
27
30
  stack: string[];
28
31
  requirements: string[];
@@ -46,6 +49,17 @@ interface JobRow {
46
49
  org_description: string | null;
47
50
  org_created_at: string;
48
51
  }
52
+ /**
53
+ * Why a listing cannot go live, or null when it can.
54
+ *
55
+ * Pay is required to publish. A listing that does not say what it pays gets
56
+ * fewer and worse applications, and on a board whose readers are agents
57
+ * deciding whether to bother, "not stated" is a listing nobody can act on.
58
+ * "Unpaid" counts: it is an answer. This is checked at publish rather than
59
+ * at creation so a draft can be written in pieces, by a person or a model,
60
+ * and only the moment it becomes public is held to it.
61
+ */
62
+ export declare function publishProblem(job: Pick<Job, 'pay'>): string | null;
49
63
  export declare function toJob(row: JobRow): Job;
50
64
  export declare function searchJobs(pool: pg.Pool, query: JobQuery, options?: {
51
65
  includeUnpublished?: boolean;
@@ -63,12 +77,7 @@ export interface JobInput {
63
77
  seniority: Seniority | null;
64
78
  location: string | null;
65
79
  remoteRegions: string[];
66
- salaryMin: number | null;
67
- salaryMax: number | null;
68
- salaryCurrency: string;
69
- salaryPeriod: string;
70
- salaryEquity: string | null;
71
- salaryUnpaid: boolean;
80
+ pay: Pay;
72
81
  tags: string[];
73
82
  stack: string[];
74
83
  requirements: string[];
@@ -113,6 +122,15 @@ export declare function updateJobFromImport(pool: pg.Pool, id: string, fields: {
113
122
  * the slug is the listing's public URL and editing must not break links to it.
114
123
  */
115
124
  export declare function editJob(pool: pg.Pool, id: string, input: JobInput): Promise<Job | null>;
125
+ /**
126
+ * Change only what a listing pays, leaving the rest of it alone.
127
+ *
128
+ * The one field that is required to publish deserves a way to be set
129
+ * without rewriting the listing around it: a draft written last week, or a
130
+ * listing that went live before pay was required, needs exactly this and
131
+ * nothing else.
132
+ */
133
+ export declare function setPay(pool: pg.Pool, id: string, pay: Pay): Promise<Job | null>;
116
134
  /** The listing imported from this URL, if there is one. */
117
135
  export declare function getJobBySourceUrl(pool: pg.Pool, url: string): Promise<Job | null>;
118
136
  export declare function countJobs(pool: pg.Pool): Promise<{
package/dist/core/jobs.js CHANGED
@@ -8,6 +8,7 @@
8
8
  import { DEFAULT_APPLY_SCHEMA, isAgentPolicy, isEmploymentType, isSeniority, isSalaryPeriod, isWorkplace, } from "../schema/index.js";
9
9
  import { clean, parseList, slugify, suffix } from "../schema/text.js";
10
10
  import { PER_YEAR } from "../schema/salary.js";
11
+ import { isPayType, normalisePay, payFromSalary, payStated, salaryFromPay, } from "../schema/pay.js";
11
12
  // Filtering and ordering must compare periods on the same basis as the
12
13
  // structured data and merged searches. Cast before multiplying: the stored
13
14
  // integer range can annualise beyond Postgres's 32-bit integer limit.
@@ -20,7 +21,8 @@ const ANNUAL_TOP_SALARY = `case when j.salary_unpaid then null else
20
21
  const SELECT = `
21
22
  select j.id, j.slug, j.title, j.description, j.employment_type, j.workplace, j.seniority,
22
23
  j.location, j.remote_regions, j.salary_min, j.salary_max, j.salary_currency,
23
- j.salary_period, j.salary_equity, j.salary_unpaid, j.tags, j.stack, j.requirements, j.responsibilities,
24
+ j.salary_period, j.salary_equity, j.salary_unpaid, j.pay_lines, j.pay_method,
25
+ j.tags, j.stack, j.requirements, j.responsibilities,
24
26
  j.agent_policy, j.apply_via, j.apply_url, j.apply_email, j.apply_schema,
25
27
  j.apply_source_url, j.status,
26
28
  j.published_at, j.expires_at, j.created_at, j.updated_at,
@@ -53,6 +55,61 @@ function toOrganisation(row) {
53
55
  function toApplyMethod(row) {
54
56
  return { via: 'board', schema: row.apply_schema ?? DEFAULT_APPLY_SCHEMA };
55
57
  }
58
+ /**
59
+ * The pay lines, read back out of JSON with the same suspicion as any other
60
+ * column that came from outside: a row written by an older build, a restore
61
+ * or a hand edit must render, not throw. A row with no lines but a salary
62
+ * range is one the backfill has not reached, and reads as that one line.
63
+ */
64
+ function payOf(row) {
65
+ const lines = [];
66
+ if (Array.isArray(row.pay_lines)) {
67
+ for (const entry of row.pay_lines) {
68
+ if (typeof entry !== 'object' || entry === null)
69
+ continue;
70
+ const line = entry;
71
+ if (!isPayType(line['type']))
72
+ continue;
73
+ const number = (value) => typeof value === 'number' && Number.isFinite(value) ? value : null;
74
+ lines.push({
75
+ type: line['type'],
76
+ min: number(line['min']),
77
+ max: number(line['max']),
78
+ currency: typeof line['currency'] === 'string' ? line['currency'] : 'USD',
79
+ unit: typeof line['unit'] === 'string' ? line['unit'] : null,
80
+ });
81
+ }
82
+ }
83
+ const unpaid = row.salary_unpaid === true;
84
+ if (lines.length === 0 && !unpaid && (row.salary_min !== null || row.salary_max !== null)) {
85
+ return {
86
+ ...payFromSalary({
87
+ min: row.salary_min,
88
+ max: row.salary_max,
89
+ currency: row.salary_currency,
90
+ period: row.salary_period,
91
+ }),
92
+ method: row.pay_method,
93
+ equity: row.salary_equity,
94
+ };
95
+ }
96
+ return { lines: unpaid ? [] : lines, method: row.pay_method, equity: row.salary_equity, unpaid };
97
+ }
98
+ /**
99
+ * Why a listing cannot go live, or null when it can.
100
+ *
101
+ * Pay is required to publish. A listing that does not say what it pays gets
102
+ * fewer and worse applications, and on a board whose readers are agents
103
+ * deciding whether to bother, "not stated" is a listing nobody can act on.
104
+ * "Unpaid" counts: it is an answer. This is checked at publish rather than
105
+ * at creation so a draft can be written in pieces, by a person or a model,
106
+ * and only the moment it becomes public is held to it.
107
+ */
108
+ export function publishProblem(job) {
109
+ if (payStated(job.pay))
110
+ return null;
111
+ return 'Say what it pays before publishing: an amount per hour, day, week, month or year, a fixed fee, a price per task, a revenue share, or that it is unpaid.';
112
+ }
56
113
  export function toJob(row) {
57
114
  return {
58
115
  id: row.id,
@@ -67,6 +124,7 @@ export function toJob(row) {
67
124
  seniority: isSeniority(row.seniority) ? row.seniority : null,
68
125
  location: row.location,
69
126
  remoteRegions: row.remote_regions ?? [],
127
+ pay: payOf(row),
70
128
  salary: {
71
129
  min: row.salary_min,
72
130
  max: row.salary_max,
@@ -195,15 +253,11 @@ export function normaliseInput(input, orgId) {
195
253
  const workplace = isWorkplace(input['workplace']) ? input['workplace'] : 'remote';
196
254
  const seniority = isSeniority(input['seniority']) ? input['seniority'] : null;
197
255
  const agentPolicy = isAgentPolicy(input['agentPolicy']) ? input['agentPolicy'] : 'disclose';
198
- // Unpaid wins over any number that came with it. A form can post a stale
199
- // range alongside a ticked box, and "unpaid, $40k - $60k a year" is not a
200
- // listing anybody can act on.
201
- const salaryUnpaid = truthy(input['salaryUnpaid']);
202
- const salaryMin = salaryUnpaid ? null : money(input['salaryMin']);
203
- const salaryMax = salaryUnpaid ? null : money(input['salaryMax']);
204
- if (salaryMin !== null && salaryMax !== null && salaryMax < salaryMin) {
205
- return 'The top of the salary range is below the bottom of it.';
206
- }
256
+ // Pay lines, the settlement method, equity and "unpaid", from the new
257
+ // `pay` field or from the flat salary fields older clients still send.
258
+ const pay = normalisePay(input);
259
+ if (typeof pay === 'string')
260
+ return pay;
207
261
  const apply = normaliseApply(input);
208
262
  if (typeof apply === 'string')
209
263
  return apply;
@@ -217,12 +271,7 @@ export function normaliseInput(input, orgId) {
217
271
  seniority,
218
272
  location: location === '' ? null : location,
219
273
  remoteRegions: parseList(input['remoteRegions'], 30, 8).map((code) => code.toUpperCase()),
220
- salaryMin,
221
- salaryMax,
222
- salaryCurrency: (clean(input['salaryCurrency'], 3) || 'USD').toUpperCase(),
223
- salaryPeriod: isSalaryPeriod(input['salaryPeriod']) ? input['salaryPeriod'] : 'year',
224
- salaryEquity: clean(input['salaryEquity'], 60) || null,
225
- salaryUnpaid,
274
+ pay,
226
275
  tags: parseList(input['tags'], 12),
227
276
  stack: parseList(input['stack'], 20),
228
277
  requirements: lines(input['requirements'], 20),
@@ -344,13 +393,16 @@ async function uniqueSlug(pool, title) {
344
393
  }
345
394
  export async function createJob(pool, input) {
346
395
  const slug = await uniqueSlug(pool, input.title);
396
+ const salary = salaryFromPay(input.pay);
347
397
  const result = await pool.query(`insert into jobs (
348
398
  slug, org_id, title, description, employment_type, workplace, seniority, location,
349
399
  remote_regions, salary_min, salary_max, salary_currency, salary_period, salary_equity,
350
400
  salary_unpaid, tags, stack, requirements, responsibilities, agent_policy, apply_via,
351
- apply_url, apply_email, apply_schema, apply_source_url, expires_at, status
401
+ apply_url, apply_email, apply_schema, apply_source_url, expires_at, status,
402
+ pay_lines, pay_method
352
403
  ) values (
353
- $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,'draft'
404
+ $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,'draft',
405
+ $27,$28
354
406
  ) returning id`, [
355
407
  slug,
356
408
  input.orgId,
@@ -361,12 +413,12 @@ export async function createJob(pool, input) {
361
413
  input.seniority,
362
414
  input.location,
363
415
  input.remoteRegions,
364
- input.salaryMin,
365
- input.salaryMax,
366
- input.salaryCurrency,
367
- input.salaryPeriod,
368
- input.salaryEquity,
369
- input.salaryUnpaid,
416
+ salary.min,
417
+ salary.max,
418
+ salary.currency,
419
+ salary.period,
420
+ input.pay.equity,
421
+ input.pay.unpaid,
370
422
  input.tags,
371
423
  input.stack,
372
424
  input.requirements,
@@ -378,6 +430,8 @@ export async function createJob(pool, input) {
378
430
  JSON.stringify(input.apply.schema),
379
431
  input.sourceUrl,
380
432
  input.expiresAt,
433
+ JSON.stringify(input.pay.lines),
434
+ input.pay.method,
381
435
  ]);
382
436
  const id = result.rows[0]?.id;
383
437
  if (id === undefined)
@@ -433,13 +487,15 @@ export async function updateJobFromImport(pool, id, fields) {
433
487
  * the slug is the listing's public URL and editing must not break links to it.
434
488
  */
435
489
  export async function editJob(pool, id, input) {
490
+ const salary = salaryFromPay(input.pay);
436
491
  await pool.query(`update jobs
437
492
  set title = $2, description = $3, employment_type = $4, workplace = $5,
438
493
  seniority = $6, location = $7, remote_regions = $8,
439
494
  salary_min = $9, salary_max = $10, salary_currency = $11,
440
495
  salary_period = $12, salary_equity = $13, salary_unpaid = $14,
441
496
  tags = $15, stack = $16, requirements = $17, responsibilities = $18,
442
- agent_policy = $19, expires_at = $20, updated_at = now()
497
+ agent_policy = $19, expires_at = $20, pay_lines = $21, pay_method = $22,
498
+ updated_at = now()
443
499
  where id = $1`, [
444
500
  id,
445
501
  input.title,
@@ -449,18 +505,47 @@ export async function editJob(pool, id, input) {
449
505
  input.seniority,
450
506
  input.location,
451
507
  input.remoteRegions,
452
- input.salaryMin,
453
- input.salaryMax,
454
- input.salaryCurrency,
455
- input.salaryPeriod,
456
- input.salaryEquity,
457
- input.salaryUnpaid,
508
+ salary.min,
509
+ salary.max,
510
+ salary.currency,
511
+ salary.period,
512
+ input.pay.equity,
513
+ input.pay.unpaid,
458
514
  input.tags,
459
515
  input.stack,
460
516
  input.requirements,
461
517
  input.responsibilities,
462
518
  input.agentPolicy,
463
519
  input.expiresAt,
520
+ JSON.stringify(input.pay.lines),
521
+ input.pay.method,
522
+ ]);
523
+ return getJobById(pool, id);
524
+ }
525
+ /**
526
+ * Change only what a listing pays, leaving the rest of it alone.
527
+ *
528
+ * The one field that is required to publish deserves a way to be set
529
+ * without rewriting the listing around it: a draft written last week, or a
530
+ * listing that went live before pay was required, needs exactly this and
531
+ * nothing else.
532
+ */
533
+ export async function setPay(pool, id, pay) {
534
+ const salary = salaryFromPay(pay);
535
+ await pool.query(`update jobs
536
+ set salary_min = $2, salary_max = $3, salary_currency = $4, salary_period = $5,
537
+ salary_equity = $6, salary_unpaid = $7, pay_lines = $8, pay_method = $9,
538
+ updated_at = now()
539
+ where id = $1`, [
540
+ id,
541
+ salary.min,
542
+ salary.max,
543
+ salary.currency,
544
+ salary.period,
545
+ pay.equity,
546
+ pay.unpaid,
547
+ JSON.stringify(pay.lines),
548
+ pay.method,
464
549
  ]);
465
550
  return getJobById(pool, id);
466
551
  }