@profullstack/agenticjobs 0.11.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.
- package/README.md +66 -0
- package/dist/cli/args.js +3 -1
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/index.js +149 -0
- 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 +113 -0
- package/dist/client/client.js +25 -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/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 +9 -7
- 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/mail.d.ts +16 -0
- package/dist/core/mail.js +22 -0
- package/dist/core/mail.js.map +1 -1
- package/dist/directory/fetch.d.ts +1 -1
- package/dist/markup/markdown.js +4 -1
- package/dist/markup/markdown.js.map +1 -1
- package/dist/mcp/tools.js +150 -0
- package/dist/mcp/tools.js.map +1 -1
- package/dist/server/app.d.ts +4 -3
- package/dist/server/app.js +7 -4
- package/dist/server/app.js.map +1 -1
- package/dist/server/deps.d.ts +5 -0
- package/dist/server/middleware.js +9 -1
- package/dist/server/middleware.js.map +1 -1
- package/dist/server/routes/api.js +284 -0
- package/dist/server/routes/api.js.map +1 -1
- package/dist/server/routes/discovery.js +19 -0
- 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 +124 -0
- package/dist/server/routes/openapi.js.map +1 -1
- package/dist/server/routes/pages.d.ts +22 -0
- package/dist/server/routes/pages.js +91 -7
- package/dist/server/routes/pages.js.map +1 -1
- package/dist/views/candidates.d.ts +1 -0
- package/dist/views/candidates.js +1 -1
- 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 +9 -0
- package/dist/views/jobs.js +3 -2
- package/dist/views/jobs.js.map +1 -1
- package/dist/views/layout.d.ts +2 -0
- package/dist/views/layout.js +2 -2
- package/dist/views/layout.js.map +1 -1
- package/dist/views/me.d.ts +3 -3
- package/dist/views/me.js +2 -2
- package/dist/views/me.js.map +1 -1
- package/dist/views/updates.d.ts +13 -0
- package/dist/views/updates.js +2 -1
- package/dist/views/updates.js.map +1 -1
- package/migrations/0014_inbox_and_billing.sql +138 -0
- package/package.json +1 -1
- package/web/public/app.css +135 -0
- 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'>;
|