@profullstack/agenticjobs 0.10.0 → 0.12.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 (108) hide show
  1. package/README.md +69 -1
  2. package/dist/cli/args.js +31 -3
  3. package/dist/cli/args.js.map +1 -1
  4. package/dist/cli/index.js +396 -3
  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 +149 -0
  9. package/dist/client/client.js +79 -12
  10. package/dist/client/client.js.map +1 -1
  11. package/dist/client/fanout.js +7 -1
  12. package/dist/client/fanout.js.map +1 -1
  13. package/dist/config.d.ts +35 -1
  14. package/dist/config.js +33 -1
  15. package/dist/config.js.map +1 -1
  16. package/dist/core/candidates.js +33 -6
  17. package/dist/core/candidates.js.map +1 -1
  18. package/dist/core/capacity-alert.d.ts +84 -0
  19. package/dist/core/capacity-alert.js +144 -0
  20. package/dist/core/capacity-alert.js.map +1 -0
  21. package/dist/core/capacity.d.ts +96 -0
  22. package/dist/core/capacity.js +174 -0
  23. package/dist/core/capacity.js.map +1 -0
  24. package/dist/core/coinpay.d.ts +143 -0
  25. package/dist/core/coinpay.js +417 -0
  26. package/dist/core/coinpay.js.map +1 -0
  27. package/dist/core/import-job.js +18 -7
  28. package/dist/core/import-job.js.map +1 -1
  29. package/dist/core/import.js +18 -15
  30. package/dist/core/import.js.map +1 -1
  31. package/dist/core/inbox.d.ts +155 -0
  32. package/dist/core/inbox.js +353 -0
  33. package/dist/core/inbox.js.map +1 -0
  34. package/dist/core/invoices.d.ts +111 -0
  35. package/dist/core/invoices.js +292 -0
  36. package/dist/core/invoices.js.map +1 -0
  37. package/dist/core/jobs.js +12 -2
  38. package/dist/core/jobs.js.map +1 -1
  39. package/dist/core/mail.d.ts +16 -0
  40. package/dist/core/mail.js +22 -0
  41. package/dist/core/mail.js.map +1 -1
  42. package/dist/core/orgs.d.ts +39 -0
  43. package/dist/core/orgs.js +69 -0
  44. package/dist/core/orgs.js.map +1 -1
  45. package/dist/directory/federate.js +2 -4
  46. package/dist/directory/federate.js.map +1 -1
  47. package/dist/directory/fetch.d.ts +1 -1
  48. package/dist/markup/markdown.js +4 -1
  49. package/dist/markup/markdown.js.map +1 -1
  50. package/dist/markup/resume.js +66 -1
  51. package/dist/markup/resume.js.map +1 -1
  52. package/dist/mcp/server.js +5 -1
  53. package/dist/mcp/server.js.map +1 -1
  54. package/dist/mcp/stdio.js +3 -2
  55. package/dist/mcp/stdio.js.map +1 -1
  56. package/dist/mcp/tools.js +151 -1
  57. package/dist/mcp/tools.js.map +1 -1
  58. package/dist/schema/jsonld.js +1 -8
  59. package/dist/schema/jsonld.js.map +1 -1
  60. package/dist/schema/salary.d.ts +5 -0
  61. package/dist/schema/salary.js +15 -0
  62. package/dist/schema/salary.js.map +1 -0
  63. package/dist/schema/text.js +7 -3
  64. package/dist/schema/text.js.map +1 -1
  65. package/dist/server/app.d.ts +4 -3
  66. package/dist/server/app.js +7 -4
  67. package/dist/server/app.js.map +1 -1
  68. package/dist/server/deps.d.ts +5 -0
  69. package/dist/server/middleware.js +9 -1
  70. package/dist/server/middleware.js.map +1 -1
  71. package/dist/server/routes/api.js +347 -1
  72. package/dist/server/routes/api.js.map +1 -1
  73. package/dist/server/routes/discovery.js +75 -0
  74. package/dist/server/routes/discovery.js.map +1 -1
  75. package/dist/server/routes/inbox.d.ts +9 -0
  76. package/dist/server/routes/inbox.js +223 -0
  77. package/dist/server/routes/inbox.js.map +1 -0
  78. package/dist/server/routes/openapi.js +141 -1
  79. package/dist/server/routes/openapi.js.map +1 -1
  80. package/dist/server/routes/pages.d.ts +22 -0
  81. package/dist/server/routes/pages.js +91 -7
  82. package/dist/server/routes/pages.js.map +1 -1
  83. package/dist/views/candidates.d.ts +8 -0
  84. package/dist/views/candidates.js +2 -1
  85. package/dist/views/candidates.js.map +1 -1
  86. package/dist/views/docs.js +48 -4
  87. package/dist/views/docs.js.map +1 -1
  88. package/dist/views/inbox.d.ts +90 -0
  89. package/dist/views/inbox.js +73 -0
  90. package/dist/views/inbox.js.map +1 -0
  91. package/dist/views/jobs.d.ts +9 -0
  92. package/dist/views/jobs.js +3 -2
  93. package/dist/views/jobs.js.map +1 -1
  94. package/dist/views/layout.d.ts +2 -0
  95. package/dist/views/layout.js +2 -2
  96. package/dist/views/layout.js.map +1 -1
  97. package/dist/views/me.d.ts +3 -3
  98. package/dist/views/me.js +2 -2
  99. package/dist/views/me.js.map +1 -1
  100. package/dist/views/updates.d.ts +13 -0
  101. package/dist/views/updates.js +2 -1
  102. package/dist/views/updates.js.map +1 -1
  103. package/docs/openjob.md +5 -0
  104. package/docs/openresume.md +36 -0
  105. package/migrations/0014_inbox_and_billing.sql +138 -0
  106. package/package.json +15 -17
  107. package/web/public/app.css +146 -3
  108. package/web/public/sw.js +1 -1
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The inbox: private conversations between the people on this board.
3
+ *
4
+ * There is no public comment box anywhere on the site, and that is the design
5
+ * rather than an omission. A board with one got used as the place to hand in
6
+ * invoices, because an invoice has to go *somewhere* and a comment under a
7
+ * listing was the only somewhere. A conversation here is between the people in
8
+ * it and nobody else, and an invoice is a message in one with money attached
9
+ * (see invoices.ts).
10
+ *
11
+ * Who can be written to is whoever has a page: a candidate with a published
12
+ * resume, or an employer. Writing to an employer reaches every member of it.
13
+ * Who can write is anybody signed in, which is the cheapest thing that keeps
14
+ * the inbox from being a contact form for strangers: twenty new conversations
15
+ * a day, per account, in the database, however the message arrived.
16
+ */
17
+ import type pg from 'pg';
18
+ import type { Mailer } from './mail.ts';
19
+ /** The most a message can say. */
20
+ export declare const MESSAGE_MAX = 4000;
21
+ export declare const SUBJECT_MAX = 140;
22
+ /** New conversations per person, per day. Replies are not counted. */
23
+ export declare const DAILY_THREADS = 20;
24
+ /** Who a conversation is with. */
25
+ export type Counterparty = {
26
+ kind: 'candidate';
27
+ userId: string;
28
+ } | {
29
+ kind: 'employer';
30
+ orgId: string;
31
+ };
32
+ export interface Party {
33
+ kind: 'candidate' | 'employer';
34
+ name: string;
35
+ /** Their page, when they have one. */
36
+ slug: string | null;
37
+ }
38
+ export interface ThreadSummary {
39
+ id: string;
40
+ subject: string;
41
+ job: {
42
+ slug: string;
43
+ title: string;
44
+ } | null;
45
+ /** The other side, as the viewer sees it. */
46
+ with: Party;
47
+ lastMessageAt: string;
48
+ /** Messages from the other side newer than the viewer last opened it. */
49
+ unread: number;
50
+ /** The start of the latest message. */
51
+ preview: string;
52
+ }
53
+ export interface Message {
54
+ id: string;
55
+ kind: 'text' | 'invoice';
56
+ body: string;
57
+ invoiceId: string | null;
58
+ createdAt: string;
59
+ sender: {
60
+ id: string;
61
+ name: string;
62
+ party: Party | null;
63
+ };
64
+ /** Whether the viewer wrote it. */
65
+ mine: boolean;
66
+ }
67
+ export interface Thread {
68
+ id: string;
69
+ subject: string;
70
+ job: {
71
+ slug: string;
72
+ title: string;
73
+ } | null;
74
+ with: Party;
75
+ /** Everyone in it, for the page and for "who gets paid". */
76
+ participants: {
77
+ userId: string;
78
+ name: string;
79
+ orgId: string | null;
80
+ }[];
81
+ messages: Message[];
82
+ createdAt: string;
83
+ }
84
+ /**
85
+ * Start a conversation, or continue the one that already exists.
86
+ *
87
+ * The same two parties about the same listing (or about nothing in
88
+ * particular) is one conversation, not one per click: a second "message"
89
+ * from a candidate page lands as a new message in the thread they already
90
+ * have, which is what the person meant. Returns the thread id, or a sentence
91
+ * saying why not.
92
+ *
93
+ * `as` lets a member write as their employer, so the candidate sees the
94
+ * company they applied to rather than a name they do not know. It has to be
95
+ * an employer the sender belongs to, checked here rather than trusted.
96
+ */
97
+ export declare function startThread(pool: pg.Pool, senderId: string, to: Counterparty, input: {
98
+ subject: unknown;
99
+ body: unknown;
100
+ jobId?: string | null;
101
+ as?: string | null;
102
+ }): Promise<{
103
+ threadId: string;
104
+ messageId: string;
105
+ created: boolean;
106
+ } | string>;
107
+ /**
108
+ * Reply. Only a participant can, and the reply marks the thread read for
109
+ * them: you have seen what you are answering.
110
+ */
111
+ export declare function sendMessage(pool: pg.Pool, threadId: string, senderId: string, rawBody: unknown, invoice?: {
112
+ id: string;
113
+ } | null): Promise<{
114
+ id: string;
115
+ createdAt: string;
116
+ } | string>;
117
+ /** Everything the viewer is in, newest activity first. */
118
+ export declare function listThreads(pool: pg.Pool, viewerId: string): Promise<ThreadSummary[]>;
119
+ /**
120
+ * One conversation, for a participant. Null for anybody else, and null rather
121
+ * than 403 so the existence of a conversation is not itself something a
122
+ * stranger can learn by guessing ids.
123
+ */
124
+ export declare function getThread(pool: pg.Pool, threadId: string, viewerId: string): Promise<Thread | null>;
125
+ /** Opening a thread is reading it. */
126
+ export declare function markRead(pool: pg.Pool, threadId: string, viewerId: string): Promise<void>;
127
+ /** Threads with something the viewer has not seen, for the nav. */
128
+ export declare function unreadThreads(pool: pg.Pool, viewerId: string): Promise<number>;
129
+ /**
130
+ * Tell the other participants there is something to read.
131
+ *
132
+ * One email per person per hour per thread, and none to anyone who has the
133
+ * thread open (their last read is newer than the message). The email says
134
+ * who wrote and that there is a message; it does not carry the body, because
135
+ * an inbox on this board is private and somebody else's mail server is not.
136
+ */
137
+ export declare function notifyParticipants(pool: pg.Pool, options: {
138
+ mailer: Mailer | null;
139
+ boardName: string;
140
+ publicUrl: string;
141
+ threadId: string;
142
+ senderId: string;
143
+ kind: 'text' | 'invoice';
144
+ }): Promise<number>;
145
+ /**
146
+ * Resolve who a new message is for from the slugs a URL or a body carries.
147
+ *
148
+ * Exactly one of the two, and it has to be somebody with a page: a candidate
149
+ * who published a resume, or an employer. Null means "no such person", and is
150
+ * the same null for a private resume as for a typo, on purpose.
151
+ */
152
+ export declare function counterpartyFrom(pool: pg.Pool, input: {
153
+ candidate?: string | null;
154
+ employer?: string | null;
155
+ }): Promise<Counterparty | null>;
@@ -0,0 +1,353 @@
1
+ /**
2
+ * The inbox: private conversations between the people on this board.
3
+ *
4
+ * There is no public comment box anywhere on the site, and that is the design
5
+ * rather than an omission. A board with one got used as the place to hand in
6
+ * invoices, because an invoice has to go *somewhere* and a comment under a
7
+ * listing was the only somewhere. A conversation here is between the people in
8
+ * it and nobody else, and an invoice is a message in one with money attached
9
+ * (see invoices.ts).
10
+ *
11
+ * Who can be written to is whoever has a page: a candidate with a published
12
+ * resume, or an employer. Writing to an employer reaches every member of it.
13
+ * Who can write is anybody signed in, which is the cheapest thing that keeps
14
+ * the inbox from being a contact form for strangers: twenty new conversations
15
+ * a day, per account, in the database, however the message arrived.
16
+ */
17
+ import { clean } from "../schema/text.js";
18
+ import { inboxMessage } from "./mail.js";
19
+ /** The most a message can say. */
20
+ export const MESSAGE_MAX = 4000;
21
+ export const SUBJECT_MAX = 140;
22
+ /** New conversations per person, per day. Replies are not counted. */
23
+ export const DAILY_THREADS = 20;
24
+ /** How long a thread stays quiet in somebody's email after they were told. */
25
+ const NOTIFY_GAP_MS = 60 * 60 * 1000;
26
+ /**
27
+ * A participant, named for display.
28
+ *
29
+ * An employer's member shows as the employer: the other side wrote to Acme
30
+ * and should see Acme answer. A person shows by the name on their account,
31
+ * then the email they signed in with. The email is fine here where it is not
32
+ * on an update: a conversation is between signed-in members, who can read the
33
+ * contact block of a resume anyway.
34
+ */
35
+ function partyOf(row) {
36
+ if (row.org_id !== null) {
37
+ return { kind: 'employer', name: row.org_name ?? 'An employer', slug: row.org_slug };
38
+ }
39
+ const name = (row.user_name ?? '').trim();
40
+ return { kind: 'candidate', name: name === '' ? row.email : name, slug: row.public_slug };
41
+ }
42
+ function personName(row) {
43
+ const name = (row.user_name ?? '').trim();
44
+ return name === '' ? row.email : name;
45
+ }
46
+ const PARTICIPANTS = `
47
+ select tp.thread_id, tp.user_id, tp.org_id, tp.last_read_at,
48
+ u.name as user_name, u.email,
49
+ (select r.public_slug from resumes r
50
+ where r.user_id = tp.user_id and r.public_slug is not null
51
+ and r.visibility in ('link', 'public')
52
+ order by r.updated_at desc limit 1) as public_slug,
53
+ o.name as org_name, o.slug as org_slug
54
+ from thread_participants tp
55
+ join users u on u.id = tp.user_id
56
+ left join organisations o on o.id = tp.org_id`;
57
+ /**
58
+ * The other side of a thread, from one participant's point of view.
59
+ *
60
+ * When the other side is an employer, every member is a participant and they
61
+ * are all the same party, so the first is as good as any.
62
+ */
63
+ function otherParty(rows, viewerId) {
64
+ const mine = rows.find((row) => row.user_id === viewerId);
65
+ const others = rows.filter((row) => row.user_id !== viewerId &&
66
+ // A member of the same employer as the viewer is on the viewer's side.
67
+ (mine?.org_id === null || mine?.org_id === undefined || row.org_id !== mine.org_id));
68
+ const first = others[0];
69
+ if (first === undefined)
70
+ return { kind: 'candidate', name: 'Nobody', slug: null };
71
+ return partyOf(first);
72
+ }
73
+ function preview(body) {
74
+ const line = body.replace(/\s+/g, ' ').trim();
75
+ return line.length > 120 ? `${line.slice(0, 117)}...` : line;
76
+ }
77
+ async function isParticipant(pool, threadId, userId) {
78
+ const result = await pool.query(`select 1 from thread_participants where thread_id = $1 and user_id = $2`, [threadId, userId]);
79
+ return result.rows.length > 0;
80
+ }
81
+ /**
82
+ * Start a conversation, or continue the one that already exists.
83
+ *
84
+ * The same two parties about the same listing (or about nothing in
85
+ * particular) is one conversation, not one per click: a second "message"
86
+ * from a candidate page lands as a new message in the thread they already
87
+ * have, which is what the person meant. Returns the thread id, or a sentence
88
+ * saying why not.
89
+ *
90
+ * `as` lets a member write as their employer, so the candidate sees the
91
+ * company they applied to rather than a name they do not know. It has to be
92
+ * an employer the sender belongs to, checked here rather than trusted.
93
+ */
94
+ export async function startThread(pool, senderId, to, input) {
95
+ const subject = clean(input.subject, SUBJECT_MAX);
96
+ const body = clean(input.body, MESSAGE_MAX, { multiline: true });
97
+ if (body === '')
98
+ return 'Write something.';
99
+ if (to.kind === 'candidate' && to.userId === senderId)
100
+ return 'That is you.';
101
+ const asOrg = input.as ?? null;
102
+ if (asOrg !== null) {
103
+ const member = await pool.query(`select 1 from memberships where user_id = $1 and org_id = $2`, [senderId, asOrg]);
104
+ if (member.rows.length === 0)
105
+ return 'You can only write as an employer you belong to.';
106
+ if (to.kind === 'employer' && to.orgId === asOrg)
107
+ return 'That is you.';
108
+ }
109
+ const jobId = input.jobId ?? null;
110
+ // Find the existing conversation between these parties about this listing.
111
+ const existing = await pool.query(`select t.id from threads t
112
+ where t.job_id is not distinct from $3
113
+ and exists (select 1 from thread_participants me
114
+ where me.thread_id = t.id and me.user_id = $1)
115
+ and exists (select 1 from thread_participants them
116
+ where them.thread_id = t.id
117
+ and ${to.kind === 'candidate' ? 'them.user_id = $2 and them.org_id is null' : 'them.org_id = $2'})
118
+ order by t.last_message_at desc limit 1`, [senderId, to.kind === 'candidate' ? to.userId : to.orgId, jobId]);
119
+ const found = existing.rows[0];
120
+ if (found !== undefined) {
121
+ const message = await sendMessage(pool, found.id, senderId, body);
122
+ if (typeof message === 'string')
123
+ return message;
124
+ return { threadId: found.id, messageId: message.id, created: false };
125
+ }
126
+ // The limit is on *new* conversations: replying in one you already have is
127
+ // never what a spammer is doing.
128
+ const recent = await pool.query(`select count(*)::int as count from threads
129
+ where created_by = $1 and created_at > now() - interval '1 day'`, [senderId]);
130
+ if ((recent.rows[0]?.count ?? 0) >= DAILY_THREADS) {
131
+ return `That is ${DAILY_THREADS} new conversations today. Reply in one you have, or come back tomorrow.`;
132
+ }
133
+ const client = await pool.connect();
134
+ try {
135
+ await client.query('begin');
136
+ const thread = await client.query(`insert into threads (subject, job_id, created_by) values ($1, $2, $3) returning id`, [subject === '' ? preview(body).slice(0, SUBJECT_MAX) : subject, jobId, senderId]);
137
+ const threadId = thread.rows[0].id;
138
+ // The sender, as themselves or as every member of their employer.
139
+ if (asOrg === null) {
140
+ await client.query(`insert into thread_participants (thread_id, user_id, org_id, last_read_at)
141
+ values ($1, $2, null, now())`, [threadId, senderId]);
142
+ }
143
+ else {
144
+ await client.query(`insert into thread_participants (thread_id, user_id, org_id, last_read_at)
145
+ select $1, m.user_id, $2, case when m.user_id = $3 then now() else null end
146
+ from memberships m where m.org_id = $2`, [threadId, asOrg, senderId]);
147
+ }
148
+ // The other side.
149
+ if (to.kind === 'candidate') {
150
+ await client.query(`insert into thread_participants (thread_id, user_id, org_id) values ($1, $2, null)
151
+ on conflict do nothing`, [threadId, to.userId]);
152
+ }
153
+ else {
154
+ await client.query(`insert into thread_participants (thread_id, user_id, org_id)
155
+ select $1, m.user_id, $2 from memberships m where m.org_id = $2
156
+ on conflict do nothing`, [threadId, to.orgId]);
157
+ }
158
+ const message = await client.query(`insert into messages (thread_id, sender_id, kind, body) values ($1, $2, 'text', $3)
159
+ returning id`, [threadId, senderId, body]);
160
+ await client.query('commit');
161
+ return { threadId, messageId: message.rows[0].id, created: true };
162
+ }
163
+ catch (error) {
164
+ await client.query('rollback');
165
+ throw error;
166
+ }
167
+ finally {
168
+ client.release();
169
+ }
170
+ }
171
+ /**
172
+ * Reply. Only a participant can, and the reply marks the thread read for
173
+ * them: you have seen what you are answering.
174
+ */
175
+ export async function sendMessage(pool, threadId, senderId, rawBody, invoice = null) {
176
+ const body = clean(rawBody, MESSAGE_MAX, { multiline: true });
177
+ if (body === '')
178
+ return 'Write something.';
179
+ if (!(await isParticipant(pool, threadId, senderId)))
180
+ return 'You are not in that conversation.';
181
+ const inserted = await pool.query(`with m as (
182
+ insert into messages (thread_id, sender_id, kind, body, invoice_id)
183
+ values ($1, $2, $3, $4, $5)
184
+ returning id, created_at
185
+ ), t as (
186
+ update threads set last_message_at = (select created_at from m) where id = $1
187
+ ), r as (
188
+ update thread_participants set last_read_at = (select created_at from m)
189
+ where thread_id = $1 and user_id = $2
190
+ )
191
+ select id, created_at from m`, [threadId, senderId, invoice === null ? 'text' : 'invoice', body, invoice?.id ?? null]);
192
+ const row = inserted.rows[0];
193
+ return { id: row.id, createdAt: row.created_at };
194
+ }
195
+ /** Everything the viewer is in, newest activity first. */
196
+ export async function listThreads(pool, viewerId) {
197
+ const threads = await pool.query(`select t.id, t.subject, t.last_message_at, j.slug as job_slug, j.title as job_title,
198
+ (select count(*)::int from messages m
199
+ where m.thread_id = t.id and m.sender_id <> $1
200
+ and m.created_at > coalesce(me.last_read_at, 'epoch'::timestamptz)) as unread,
201
+ (select m.body from messages m where m.thread_id = t.id
202
+ order by m.created_at desc limit 1) as preview
203
+ from threads t
204
+ join thread_participants me on me.thread_id = t.id and me.user_id = $1
205
+ left join jobs j on j.id = t.job_id
206
+ order by t.last_message_at desc
207
+ limit 200`, [viewerId]);
208
+ if (threads.rows.length === 0)
209
+ return [];
210
+ const ids = threads.rows.map((row) => row.id);
211
+ const participants = await pool.query(`${PARTICIPANTS} where tp.thread_id = any($1::uuid[])`, [ids]);
212
+ const byThread = new Map();
213
+ for (const row of participants.rows) {
214
+ const list = byThread.get(row.thread_id) ?? [];
215
+ list.push(row);
216
+ byThread.set(row.thread_id, list);
217
+ }
218
+ return threads.rows.map((row) => ({
219
+ id: row.id,
220
+ subject: row.subject,
221
+ job: row.job_slug === null ? null : { slug: row.job_slug, title: row.job_title ?? row.job_slug },
222
+ with: otherParty(byThread.get(row.id) ?? [], viewerId),
223
+ lastMessageAt: row.last_message_at,
224
+ unread: row.unread,
225
+ preview: preview(row.preview ?? ''),
226
+ }));
227
+ }
228
+ /**
229
+ * One conversation, for a participant. Null for anybody else, and null rather
230
+ * than 403 so the existence of a conversation is not itself something a
231
+ * stranger can learn by guessing ids.
232
+ */
233
+ export async function getThread(pool, threadId, viewerId) {
234
+ const participants = await pool.query(`${PARTICIPANTS} where tp.thread_id = $1`, [
235
+ threadId,
236
+ ]);
237
+ if (!participants.rows.some((row) => row.user_id === viewerId))
238
+ return null;
239
+ const head = await pool.query(`select t.id, t.subject, t.created_at, j.slug as job_slug, j.title as job_title
240
+ from threads t left join jobs j on j.id = t.job_id where t.id = $1`, [threadId]);
241
+ const row = head.rows[0];
242
+ if (row === undefined)
243
+ return null;
244
+ const messages = await pool.query(`select id, kind, body, invoice_id, created_at, sender_id
245
+ from messages where thread_id = $1 order by created_at asc limit 500`, [threadId]);
246
+ const byUser = new Map(participants.rows.map((p) => [p.user_id, p]));
247
+ return {
248
+ id: row.id,
249
+ subject: row.subject,
250
+ createdAt: row.created_at,
251
+ job: row.job_slug === null ? null : { slug: row.job_slug, title: row.job_title ?? row.job_slug },
252
+ with: otherParty(participants.rows, viewerId),
253
+ participants: participants.rows.map((p) => ({
254
+ userId: p.user_id,
255
+ name: personName(p),
256
+ orgId: p.org_id,
257
+ })),
258
+ messages: messages.rows.map((m) => {
259
+ const sender = byUser.get(m.sender_id);
260
+ return {
261
+ id: m.id,
262
+ kind: m.kind,
263
+ body: m.body,
264
+ invoiceId: m.invoice_id,
265
+ createdAt: m.created_at,
266
+ sender: {
267
+ id: m.sender_id,
268
+ name: sender === undefined ? 'Someone who left' : personName(sender),
269
+ party: sender === undefined ? null : partyOf(sender),
270
+ },
271
+ mine: m.sender_id === viewerId,
272
+ };
273
+ }),
274
+ };
275
+ }
276
+ /** Opening a thread is reading it. */
277
+ export async function markRead(pool, threadId, viewerId) {
278
+ await pool.query(`update thread_participants set last_read_at = now() where thread_id = $1 and user_id = $2`, [threadId, viewerId]);
279
+ }
280
+ /** Threads with something the viewer has not seen, for the nav. */
281
+ export async function unreadThreads(pool, viewerId) {
282
+ const result = await pool.query(`select count(*)::int as count from thread_participants me
283
+ where me.user_id = $1
284
+ and exists (select 1 from messages m
285
+ where m.thread_id = me.thread_id and m.sender_id <> $1
286
+ and m.created_at > coalesce(me.last_read_at, 'epoch'::timestamptz))`, [viewerId]);
287
+ return result.rows[0]?.count ?? 0;
288
+ }
289
+ /**
290
+ * Tell the other participants there is something to read.
291
+ *
292
+ * One email per person per hour per thread, and none to anyone who has the
293
+ * thread open (their last read is newer than the message). The email says
294
+ * who wrote and that there is a message; it does not carry the body, because
295
+ * an inbox on this board is private and somebody else's mail server is not.
296
+ */
297
+ export async function notifyParticipants(pool, options) {
298
+ if (options.mailer === null)
299
+ return 0;
300
+ const due = await pool.query(`${PARTICIPANTS}
301
+ join threads t on t.id = tp.thread_id
302
+ where tp.thread_id = $1 and tp.user_id <> $2
303
+ and (tp.notified_at is null or tp.notified_at < now() - make_interval(secs => $3))
304
+ and (tp.last_read_at is null or tp.last_read_at < t.last_message_at)`, [options.threadId, options.senderId, NOTIFY_GAP_MS / 1000]);
305
+ if (due.rows.length === 0)
306
+ return 0;
307
+ const sender = await pool.query(`${PARTICIPANTS} where tp.thread_id = $1 and tp.user_id = $2`, [options.threadId, options.senderId]);
308
+ const from = sender.rows[0];
309
+ const fromName = from === undefined ? 'Someone' : partyOf(from).name;
310
+ const subject = (await pool.query(`select subject from threads where id = $1`, [
311
+ options.threadId,
312
+ ])).rows[0]?.subject;
313
+ let sent = 0;
314
+ for (const row of due.rows) {
315
+ const ok = await options.mailer.send(inboxMessage({
316
+ to: row.email,
317
+ boardName: options.boardName,
318
+ from: fromName,
319
+ subject: subject ?? '',
320
+ url: `${options.publicUrl}/inbox/${options.threadId}`,
321
+ kind: options.kind,
322
+ }));
323
+ if (ok) {
324
+ sent += 1;
325
+ await pool.query(`update thread_participants set notified_at = now() where thread_id = $1 and user_id = $2`, [options.threadId, row.user_id]);
326
+ }
327
+ }
328
+ return sent;
329
+ }
330
+ /**
331
+ * Resolve who a new message is for from the slugs a URL or a body carries.
332
+ *
333
+ * Exactly one of the two, and it has to be somebody with a page: a candidate
334
+ * who published a resume, or an employer. Null means "no such person", and is
335
+ * the same null for a private resume as for a typo, on purpose.
336
+ */
337
+ export async function counterpartyFrom(pool, input) {
338
+ const candidate = (input.candidate ?? '').trim();
339
+ const employer = (input.employer ?? '').trim();
340
+ if (candidate !== '') {
341
+ const result = await pool.query(`select user_id from resumes
342
+ where public_slug = $1 and visibility in ('link', 'public') limit 1`, [candidate]);
343
+ const userId = result.rows[0]?.user_id;
344
+ return userId === undefined ? null : { kind: 'candidate', userId };
345
+ }
346
+ if (employer !== '') {
347
+ const result = await pool.query(`select id from organisations where slug = $1`, [employer]);
348
+ const orgId = result.rows[0]?.id;
349
+ return orgId === undefined ? null : { kind: 'employer', orgId };
350
+ }
351
+ return null;
352
+ }
353
+ //# sourceMappingURL=inbox.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inbox.js","sourceRoot":"","sources":["../../src/core/inbox.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,111 @@
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 type pg from 'pg';
17
+ import { type CoinPayClient } from './coinpay.ts';
18
+ export type InvoiceStatus = 'sent' | 'paid' | 'cancelled';
19
+ export interface Invoice {
20
+ id: string;
21
+ threadId: string;
22
+ payee: {
23
+ id: string;
24
+ name: string;
25
+ };
26
+ amountUsd: string;
27
+ /** The chain, in CoinPay's spelling. */
28
+ currency: string;
29
+ walletAddress: string;
30
+ description: string;
31
+ status: InvoiceStatus;
32
+ payment: {
33
+ id: string;
34
+ address: string | null;
35
+ amountCrypto: string | null;
36
+ expiresAt: string | null;
37
+ /** Where to pay. */
38
+ url: string;
39
+ } | null;
40
+ paidAt: string | null;
41
+ paidBy: string | null;
42
+ txHash: string | null;
43
+ createdAt: string;
44
+ }
45
+ /** A dollar amount as typed: "120", "120.50", "$1,200". Null when it is not one. */
46
+ export declare function parseAmount(value: unknown): string | null;
47
+ /**
48
+ * Send one. Returns the invoice, or a sentence saying why not.
49
+ *
50
+ * The payee has to be in the conversation and has to have connected a CoinPay
51
+ * account with a wallet on the chain they chose; the address is copied onto
52
+ * the invoice at this moment and never read again, so what the payer pays to
53
+ * is what the payee had connected when they asked.
54
+ */
55
+ export declare function sendInvoice(pool: pg.Pool, coinpay: CoinPayClient | null, input: {
56
+ threadId: string;
57
+ payeeId: string;
58
+ amount: unknown;
59
+ currency: unknown;
60
+ description: unknown;
61
+ }): Promise<Invoice | string>;
62
+ /** The invoices in a conversation, oldest first, for rendering beside messages. */
63
+ export declare function listInvoicesIn(pool: pg.Pool, coinpay: CoinPayClient | null, threadId: string): Promise<Invoice[]>;
64
+ /**
65
+ * Everything the viewer sent or can pay: their invoices, and the invoices in
66
+ * conversations they are in. Newest first.
67
+ */
68
+ export declare function listInvoicesFor(pool: pg.Pool, coinpay: CoinPayClient | null, viewerId: string): Promise<Invoice[]>;
69
+ /** One invoice, for a participant of its conversation. Null for anybody else. */
70
+ export declare function getInvoice(pool: pg.Pool, coinpay: CoinPayClient | null, invoiceId: string, viewerId: string): Promise<Invoice | null>;
71
+ /** The payee takes it back. Only while unpaid. */
72
+ export declare function cancelInvoice(pool: pg.Pool, invoiceId: string, payeeId: string): Promise<boolean>;
73
+ /**
74
+ * Get somewhere to pay. Returns the invoice with a live payment on it, or a
75
+ * sentence.
76
+ *
77
+ * Anyone in the conversation but the payee may pay. A payment still inside its
78
+ * quote window is reused; otherwise a new one is minted for the same invoice,
79
+ * and the id is remembered so the webhook can find its way back.
80
+ */
81
+ export declare function requestPayment(pool: pg.Pool, coinpay: CoinPayClient | null, input: {
82
+ invoiceId: string;
83
+ payerId: string;
84
+ publicUrl: string;
85
+ }): Promise<Invoice | string>;
86
+ /**
87
+ * Ask CoinPay how the current payment is doing and record the answer.
88
+ *
89
+ * Paid is paid, whichever way the news arrives. A dead quote is cleared so the
90
+ * next Pay mints a fresh one. Anything in between, or CoinPay not answering,
91
+ * leaves the invoice as it was: this is a read that happens on page load, and
92
+ * a provider hiccup must not turn into a changed status.
93
+ */
94
+ export declare function syncInvoice(pool: pg.Pool, coinpay: CoinPayClient | null, invoice: Invoice): Promise<Invoice>;
95
+ /**
96
+ * Record a payment as settled. Idempotent: the webhook fires for `confirmed`
97
+ * and again for `forwarded`, and a poll may land between them.
98
+ */
99
+ export declare function markPaid(pool: pg.Pool, input: {
100
+ paymentId: string;
101
+ txHash: string | null;
102
+ paidBy?: string | null;
103
+ }): Promise<boolean>;
104
+ /**
105
+ * A webhook from CoinPay, already verified by the caller.
106
+ *
107
+ * Only the two settlement events change anything. An expired quote is left
108
+ * to the next page load, which clears it the same way and does not depend on
109
+ * a delivery that may never come.
110
+ */
111
+ export declare function applyWebhook(pool: pg.Pool, payload: Record<string, unknown>): Promise<'paid' | 'ignored' | 'unknown'>;