@trypattern/sdk 0.1.33 → 0.1.34
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/dist/app.d.ts +6 -2
- package/dist/app.js +6 -1
- package/dist/blocks.js +11 -3
- package/dist/bookings.d.ts +78 -0
- package/dist/bookings.js +7 -1
- package/dist/bookingsStore.d.ts +78 -0
- package/dist/cli/app.js +17 -2
- package/dist/cli/requests.d.ts +5 -0
- package/dist/cli/requests.js +115 -0
- package/dist/components/AppFrame.js +12 -2
- package/dist/components/MenuItems.d.ts +1 -0
- package/dist/components/MenuItems.js +13 -2
- package/dist/components/SegmentedControl.d.ts +1 -0
- package/dist/components/SegmentedControl.js +9 -2
- package/dist/components/Sidebar.d.ts +1 -0
- package/dist/components/Sidebar.js +18 -2
- package/dist/components/TableRow.d.ts +2 -1
- package/dist/components/TableRow.js +5 -2
- package/dist/components/icons.js +1 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/inquiriesStore.d.ts +134 -0
- package/dist/inquiriesStore.js +64 -0
- package/dist/patternAccess.d.ts +8 -2
- package/dist/patternAccess.js +17 -5
- package/dist/publishWire.d.ts +866 -0
- package/dist/publishWire.js +78 -0
- package/dist/requests.d.ts +361 -0
- package/dist/requests.js +180 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +1 -0
- package/dist/server/authService.d.ts +1 -0
- package/dist/server/bookings.d.ts +2 -9
- package/dist/server/bookings.js +149 -31
- package/dist/server/createServer.js +4 -0
- package/dist/server/env.d.ts +3 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1 -0
- package/dist/server/inquiries.d.ts +9 -0
- package/dist/server/inquiries.js +150 -0
- package/dist/server/patternViewer.js +7 -1
- package/dist/server/requests.d.ts +76 -0
- package/dist/server/requests.js +328 -0
- package/dist/server/services.js +9 -1
- package/package.json +1 -1
package/dist/server/bookings.js
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
|
-
import { FULL, RULES_ID, UNAVAILABLE, bookingBlackout, bookingRequests, bookingRules, bookingServices, bookingTables, daysOf, isValidTimezone, rulesOf, rulesProblem, slotFor, takenKey, windowsOn } from "../bookings.js";
|
|
1
|
+
import { FULL, RULES_ID, UNAVAILABLE, bookingBlackout, bookingRequests, bookingRules, bookingServices, bookingTables, daysOf, isValidTimezone, rulesOf, rulesProblem, slotFor, takenKey, WEEKDAYS, windowId, windowsOn } from "../bookings.js";
|
|
2
2
|
import { refused } from "../services.js";
|
|
3
|
+
import { storedRows, textOf } from "./inquiries.js";
|
|
3
4
|
import { notifyOwner } from "./authService.js";
|
|
5
|
+
import { afterResponse, drainNotices, receivedStatements, requestsLink, takesRequests, transitionStatements } from "./requests.js";
|
|
4
6
|
import { serveServices } from "./services.js";
|
|
5
7
|
import { readRow, readRows } from "./tables.js";
|
|
6
8
|
const HOLDING_SQL = "status IN ('requested', 'accepted')";
|
|
7
|
-
|
|
8
|
-
if (!import.meta.env.DEV)
|
|
9
|
-
return;
|
|
10
|
-
const to = email.kind === 'requested' ? 'the owner' : email.request.email;
|
|
11
|
-
console.log(`Booking email (${email.kind}) to ${to} from ${new URL(c.req.url).host}: ${email.request.name}, ${email.request.date} ${email.request.window}`);
|
|
12
|
-
}
|
|
9
|
+
const SOURCE = 'bookings';
|
|
13
10
|
function clockOf(time) {
|
|
14
11
|
const [hours = 0, minutes = 0] = time.split(':').map(Number);
|
|
15
12
|
const hour = hours % 12 === 0 ? 12 : hours % 12;
|
|
@@ -23,7 +20,7 @@ function whenOf(request, label) {
|
|
|
23
20
|
const [start = '', end = ''] = request.window.split('-');
|
|
24
21
|
return `${day}, ${label} (${clockOf(start)}–${clockOf(end)})`;
|
|
25
22
|
}
|
|
26
|
-
function ownerNoteOf(request, label,
|
|
23
|
+
function ownerNoteOf(request, label, link) {
|
|
27
24
|
const lines = [
|
|
28
25
|
`${request.name} asked to book ${request.offering} on ${whenOf(request, label)}.`,
|
|
29
26
|
'',
|
|
@@ -33,8 +30,8 @@ function ownerNoteOf(request, label, manage) {
|
|
|
33
30
|
...(request.notes === '' ? [] : ['', request.notes]),
|
|
34
31
|
'',
|
|
35
32
|
request.status === 'accepted'
|
|
36
|
-
? `It is booked: your booking rules accept requests on their own. See it at ${
|
|
37
|
-
: `Accept or decline it at ${
|
|
33
|
+
? `It is booked: your booking rules accept requests on their own. See it at ${link}`
|
|
34
|
+
: `Accept or decline it at ${link}`,
|
|
38
35
|
`Reply to this email to write to ${request.name}.`
|
|
39
36
|
];
|
|
40
37
|
return {
|
|
@@ -78,28 +75,121 @@ async function readRequest(d1, id) {
|
|
|
78
75
|
const row = await readRow(d1, bookingRequests, id);
|
|
79
76
|
return row === undefined ? undefined : bookingRequests.row.parse(row);
|
|
80
77
|
}
|
|
78
|
+
const DECIDABLE = {
|
|
79
|
+
accepted: ['requested'],
|
|
80
|
+
declined: ['requested', 'accepted']
|
|
81
|
+
};
|
|
82
|
+
function decisionStatements(d1, id, status, expected) {
|
|
83
|
+
const from = DECIDABLE[status].map((item) => `'${item}'`).join(', ');
|
|
84
|
+
const guard = expected === null
|
|
85
|
+
? ''
|
|
86
|
+
: ' AND (SELECT revision FROM request_states WHERE id = ?3) = ?4';
|
|
87
|
+
return [
|
|
88
|
+
d1
|
|
89
|
+
.prepare(`UPDATE booking_requests SET status = ?1 WHERE id = ?2 AND status IN (${from})${guard}`)
|
|
90
|
+
.bind(status, id, ...(expected === null ? [] : [`${SOURCE}:${id}`, expected])),
|
|
91
|
+
...transitionStatements(d1, {
|
|
92
|
+
source: SOURCE,
|
|
93
|
+
requestId: id,
|
|
94
|
+
event: status,
|
|
95
|
+
state: 'done',
|
|
96
|
+
expected
|
|
97
|
+
})
|
|
98
|
+
];
|
|
99
|
+
}
|
|
81
100
|
async function decide(c, id, status) {
|
|
82
101
|
requireAdmin(c);
|
|
83
|
-
const from = status === 'accepted' ? ['requested'] : ['requested', 'accepted'];
|
|
84
102
|
const existing = await readRequest(c.env.DB, id);
|
|
85
103
|
if (existing === undefined)
|
|
86
104
|
throw refused(404, 'No such request');
|
|
87
|
-
if (!
|
|
105
|
+
if (!DECIDABLE[status].includes(existing.status)) {
|
|
88
106
|
throw refused(409, `This request is already ${existing.status}`);
|
|
89
107
|
}
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (meta.changes === 0)
|
|
108
|
+
const statements = decisionStatements(c.env.DB, id, status, null);
|
|
109
|
+
const [result] = await c.env.DB.batch((await takesRequests(c.env.DB)) ? statements : statements.slice(0, 1));
|
|
110
|
+
if (result?.meta.changes === 0) {
|
|
94
111
|
throw refused(409, 'This request just changed');
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
112
|
+
}
|
|
113
|
+
return { ...existing, status };
|
|
114
|
+
}
|
|
115
|
+
function longDay(date) {
|
|
116
|
+
return new Date(`${date}T12:00:00Z`).toLocaleDateString('en-GB', {
|
|
117
|
+
weekday: 'long',
|
|
118
|
+
day: 'numeric',
|
|
119
|
+
month: 'long',
|
|
120
|
+
timeZone: 'UTC'
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
function rangeOf(window) {
|
|
124
|
+
const [start = '', end = ''] = window.split('-');
|
|
125
|
+
return `${clockOf(start)}–${clockOf(end)}`;
|
|
98
126
|
}
|
|
127
|
+
function windowLabel(rules, date, window) {
|
|
128
|
+
const weekday = new Date(`${date}T12:00:00Z`).getUTCDay();
|
|
129
|
+
const rule = rules.windows.find((item) => item.weekday === WEEKDAYS[weekday] && windowId(item) === window);
|
|
130
|
+
return rule === undefined
|
|
131
|
+
? rangeOf(window)
|
|
132
|
+
: `${rule.label}, ${rangeOf(window)}`;
|
|
133
|
+
}
|
|
134
|
+
function bookingContent(request, rules) {
|
|
135
|
+
const [head = request.offering, ...rest] = request.offering.split(', ');
|
|
136
|
+
const time = windowLabel(rules, request.date, request.window);
|
|
137
|
+
const isRequested = request.status === 'requested';
|
|
138
|
+
return {
|
|
139
|
+
kind: 'booking',
|
|
140
|
+
title: 'Booking request',
|
|
141
|
+
label: head,
|
|
142
|
+
summary: rest.join(', '),
|
|
143
|
+
name: request.name,
|
|
144
|
+
email: request.email,
|
|
145
|
+
when: request.date,
|
|
146
|
+
whenDetail: time,
|
|
147
|
+
fields: [
|
|
148
|
+
{ label: 'Booking', value: request.offering },
|
|
149
|
+
{ label: 'Day', value: longDay(request.date) },
|
|
150
|
+
{ label: 'Time', value: time },
|
|
151
|
+
...(request.address === ''
|
|
152
|
+
? []
|
|
153
|
+
: [{ label: 'Address', value: request.address }])
|
|
154
|
+
],
|
|
155
|
+
contact: [
|
|
156
|
+
{ label: 'Email', value: request.email },
|
|
157
|
+
...(request.phone === ''
|
|
158
|
+
? []
|
|
159
|
+
: [{ label: 'Phone', value: request.phone }])
|
|
160
|
+
],
|
|
161
|
+
message: request.notes,
|
|
162
|
+
status: request.status,
|
|
163
|
+
needsAnswer: isRequested,
|
|
164
|
+
actions: isRequested ? ['accept', 'decline'].slice() : [],
|
|
165
|
+
receivedAt: request.createdAt
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const bookingSource = {
|
|
169
|
+
name: SOURCE,
|
|
170
|
+
table: 'booking_requests',
|
|
171
|
+
contents: async (d1, progress) => {
|
|
172
|
+
const [rules, rows] = await Promise.all([
|
|
173
|
+
readRules(d1),
|
|
174
|
+
storedRows(d1, bookingRequests, [...progress.keys()])
|
|
175
|
+
]);
|
|
176
|
+
return new Map([...rows].flatMap(([id, row]) => {
|
|
177
|
+
const request = bookingRequests.row.safeParse({
|
|
178
|
+
...row,
|
|
179
|
+
id: textOf(row['id'])
|
|
180
|
+
});
|
|
181
|
+
return request.success
|
|
182
|
+
? [[id, bookingContent(request.data, rules)]]
|
|
183
|
+
: [];
|
|
184
|
+
}));
|
|
185
|
+
},
|
|
186
|
+
decide: async (d1, id, action, expected) => decisionStatements(d1, id, action === 'accept' ? 'accepted' : 'declined', expected),
|
|
187
|
+
backfill: (d1) => d1.prepare(`INSERT INTO request_states (id, source, request_id, state, revision, sequence, updated_at) SELECT '${SOURCE}:' || id, '${SOURCE}', id, CASE status WHEN 'requested' THEN 'open' ELSE 'done' END, 1, (SELECT COALESCE(MAX(sequence), 0) FROM request_states) + ROW_NUMBER() OVER (ORDER BY created_at, id), created_at FROM booking_requests WHERE '${SOURCE}:' || id NOT IN (SELECT id FROM request_states)`)
|
|
188
|
+
};
|
|
99
189
|
function monthRange(month) {
|
|
100
190
|
return { first: `${month}-01`, last: `${month}-31` };
|
|
101
191
|
}
|
|
102
|
-
export function bookings({ timezone
|
|
192
|
+
export function bookings({ timezone }) {
|
|
103
193
|
if (!isValidTimezone(timezone)) {
|
|
104
194
|
throw new Error(`bookings({ timezone: '${timezone}' }) is not a time zone: use an IANA name such as 'America/Toronto'`);
|
|
105
195
|
}
|
|
@@ -123,19 +213,46 @@ export function bookings({ timezone, managePath = '/' }) {
|
|
|
123
213
|
throw refused(409, FULL);
|
|
124
214
|
const id = crypto.randomUUID();
|
|
125
215
|
const status = facts.rules.autoAccept ? 'accepted' : 'requested';
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
216
|
+
const d1 = c.env.DB;
|
|
217
|
+
const shown = {
|
|
218
|
+
...fields,
|
|
219
|
+
id,
|
|
220
|
+
createdAt: new Date().toISOString(),
|
|
221
|
+
phone: fields.phone ?? '',
|
|
222
|
+
address: fields.address ?? '',
|
|
223
|
+
notes: fields.notes ?? '',
|
|
224
|
+
status
|
|
225
|
+
};
|
|
226
|
+
const tracked = await takesRequests(d1);
|
|
227
|
+
const notice = ownerNoteOf(shown, slot.label, requestsLink(c.env));
|
|
228
|
+
const [inserted] = await d1.batch([
|
|
229
|
+
d1
|
|
230
|
+
.prepare(`INSERT INTO booking_requests (id, offering, date, "window", name, email, phone, address, notes, status) SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10 WHERE (SELECT COUNT(*) FROM booking_requests WHERE date = ?3 AND "window" = ?4 AND ${HOLDING_SQL}) < ?11`)
|
|
231
|
+
.bind(id, shown.offering, shown.date, shown.window, shown.name, shown.email, shown.phone, shown.address, shown.notes, status, slot.capacity),
|
|
232
|
+
...(tracked
|
|
233
|
+
? receivedStatements(d1, {
|
|
234
|
+
source: SOURCE,
|
|
235
|
+
table: 'booking_requests',
|
|
236
|
+
requestId: id,
|
|
237
|
+
notice
|
|
238
|
+
})
|
|
239
|
+
: []),
|
|
240
|
+
...(tracked && status === 'accepted'
|
|
241
|
+
? transitionStatements(d1, {
|
|
242
|
+
source: SOURCE,
|
|
243
|
+
requestId: id,
|
|
244
|
+
event: 'accepted',
|
|
245
|
+
state: 'done',
|
|
246
|
+
expected: null
|
|
247
|
+
})
|
|
248
|
+
: [])
|
|
249
|
+
]);
|
|
250
|
+
if (inserted?.meta.changes === 0)
|
|
130
251
|
throw refused(409, FULL);
|
|
131
|
-
const saved = await readRequest(
|
|
252
|
+
const saved = await readRequest(d1, id);
|
|
132
253
|
if (saved === undefined)
|
|
133
254
|
throw new Error('The request was not saved');
|
|
134
|
-
await
|
|
135
|
-
await sendBookingEmail(c, {
|
|
136
|
-
kind: status === 'accepted' ? 'accepted' : 'requested',
|
|
137
|
-
request: saved
|
|
138
|
-
});
|
|
255
|
+
await afterResponse(c, tracked ? drainNotices(c.env) : notifyOwner(c.env, notice));
|
|
139
256
|
return saved;
|
|
140
257
|
},
|
|
141
258
|
requests: async (c) => {
|
|
@@ -172,6 +289,7 @@ export function bookings({ timezone, managePath = '/' }) {
|
|
|
172
289
|
name: 'bookings',
|
|
173
290
|
tables: bookingTables,
|
|
174
291
|
openPaths: ['/api/bookings/'],
|
|
175
|
-
routes
|
|
292
|
+
routes,
|
|
293
|
+
requests: [bookingSource]
|
|
176
294
|
};
|
|
177
295
|
}
|
|
@@ -7,6 +7,7 @@ import { media } from "../media.js";
|
|
|
7
7
|
import { auth, devSignIn } from "./auth.js";
|
|
8
8
|
import { serveDatabase } from "./database.js";
|
|
9
9
|
import { attachPatternViewer, bearerOf } from "./patternViewer.js";
|
|
10
|
+
import { requestGateway } from "./requests.js";
|
|
10
11
|
import { forgetSession, issuePass, needsRecheck, readPass, rememberUser } from "./session.js";
|
|
11
12
|
import { renewSession } from "./sessions.js";
|
|
12
13
|
import { readTitle, writeTitle } from "./settings.js";
|
|
@@ -97,6 +98,9 @@ export function createServer({ tables, admins, routes, pages, blocks = [] }) {
|
|
|
97
98
|
.route('/tables', serveTables(tables));
|
|
98
99
|
if (hasMedia)
|
|
99
100
|
server.post('/media', uploadMedia);
|
|
101
|
+
const sources = blocks.flatMap((block) => block.requests ?? []);
|
|
102
|
+
if (sources.length > 0)
|
|
103
|
+
server.route('/', requestGateway(sources));
|
|
100
104
|
for (const block of blocks)
|
|
101
105
|
server.route('/', block.routes);
|
|
102
106
|
const own = routes;
|
package/dist/server/env.d.ts
CHANGED
|
@@ -2,12 +2,14 @@ import type { D1Database, Fetcher, R2Bucket } from '@cloudflare/workers-types';
|
|
|
2
2
|
import type { Hono } from 'hono';
|
|
3
3
|
import type { Viewer } from '../access.ts';
|
|
4
4
|
import type { Tables } from '../table.ts';
|
|
5
|
+
import type { RequestSource } from './requests.ts';
|
|
5
6
|
export type { Viewer };
|
|
6
7
|
export type ServerBindings = {
|
|
7
8
|
DB: D1Database;
|
|
8
9
|
SESSION_SECRET: string;
|
|
9
10
|
AUTH_SECRET?: string;
|
|
10
11
|
OWNER_EMAIL?: string;
|
|
12
|
+
APP_NAME?: string;
|
|
11
13
|
MEDIA?: R2Bucket;
|
|
12
14
|
ASSETS?: Fetcher;
|
|
13
15
|
};
|
|
@@ -23,4 +25,5 @@ export type ServerBlock = {
|
|
|
23
25
|
readonly tables: Tables;
|
|
24
26
|
readonly openPaths: readonly string[];
|
|
25
27
|
readonly routes: Hono<ServerEnv>;
|
|
28
|
+
readonly requests?: readonly RequestSource[];
|
|
26
29
|
};
|
package/dist/server/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export type { ServerBindings, ServerBlock, ServerEnv, Viewer } from './env.ts';
|
|
|
3
3
|
export { notifyOwner } from './authService.ts';
|
|
4
4
|
export type { OwnerNote } from './authService.ts';
|
|
5
5
|
export { bookings } from './bookings.ts';
|
|
6
|
+
export { inquiries } from './inquiries.ts';
|
|
6
7
|
export type { BookingsOptions } from './bookings.ts';
|
|
7
8
|
export { seedSql, serveTables, sqlTable, sqlTables } from './tables.ts';
|
|
8
9
|
export { refused } from '../services.ts';
|
package/dist/server/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { createServer } from "./createServer.js";
|
|
2
2
|
export { notifyOwner } from "./authService.js";
|
|
3
3
|
export { bookings } from "./bookings.js";
|
|
4
|
+
export { inquiries } from "./inquiries.js";
|
|
4
5
|
export { seedSql, serveTables, sqlTable, sqlTables } from "./tables.js";
|
|
5
6
|
export { refused } from "../services.js";
|
|
6
7
|
export { serveServices } from "./services.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { D1Database } from '@cloudflare/workers-types';
|
|
2
|
+
import type { Inquiry } from '../requests.ts';
|
|
3
|
+
import type { Table } from '../table.ts';
|
|
4
|
+
import type { ServerBlock } from './env.ts';
|
|
5
|
+
type Stored = Readonly<Record<string, unknown>>;
|
|
6
|
+
export declare function textOf(value: unknown): string;
|
|
7
|
+
export declare function storedRows(d1: D1Database, table: Table, ids: readonly string[]): Promise<ReadonlyMap<string, Stored>>;
|
|
8
|
+
export declare function inquiries(sources: readonly Inquiry[]): ServerBlock;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { drizzle } from 'drizzle-orm/d1';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { inquiryServices, requestTables } from "../requests.js";
|
|
4
|
+
import { refused } from "../services.js";
|
|
5
|
+
import { snakeCase } from "../table.js";
|
|
6
|
+
import { afterResponse, drainNotices, receivedStatements, requestsLink } from "./requests.js";
|
|
7
|
+
import { serveServices } from "./services.js";
|
|
8
|
+
import { sqlTable } from "./tables.js";
|
|
9
|
+
export function textOf(value) {
|
|
10
|
+
if (value === null || value === undefined)
|
|
11
|
+
return '';
|
|
12
|
+
if (typeof value === 'string')
|
|
13
|
+
return value.trim();
|
|
14
|
+
return typeof value === 'number' || typeof value === 'boolean'
|
|
15
|
+
? String(value)
|
|
16
|
+
: '';
|
|
17
|
+
}
|
|
18
|
+
export async function storedRows(d1, table, ids) {
|
|
19
|
+
if (ids.length === 0)
|
|
20
|
+
return new Map();
|
|
21
|
+
const { results } = await d1
|
|
22
|
+
.prepare(`SELECT * FROM ${snakeCase(table.name)} WHERE id IN (SELECT value FROM json_each(?1))`)
|
|
23
|
+
.bind(JSON.stringify(ids))
|
|
24
|
+
.all();
|
|
25
|
+
const keys = ['id', 'createdAt', ...Object.keys(table.columns)];
|
|
26
|
+
return new Map(results.map((row) => [
|
|
27
|
+
textOf(row['id']),
|
|
28
|
+
Object.fromEntries(keys.map((key) => [key, row[snakeCase(key)]]))
|
|
29
|
+
]));
|
|
30
|
+
}
|
|
31
|
+
function fieldsOf(inquiry, row) {
|
|
32
|
+
return [
|
|
33
|
+
...inquiry.fields.flatMap(({ key, label }) => {
|
|
34
|
+
const value = textOf(row[key]);
|
|
35
|
+
return value === '' ? [] : [{ label, value }];
|
|
36
|
+
}),
|
|
37
|
+
{ label: 'Email', value: textOf(row[inquiry.email]) }
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
function noticeOf(inquiry, row, link) {
|
|
41
|
+
const name = textOf(row[inquiry.name]);
|
|
42
|
+
const message = inquiry.message === null ? '' : textOf(row[inquiry.message]);
|
|
43
|
+
const noun = inquiry.title.toLowerCase();
|
|
44
|
+
return {
|
|
45
|
+
subject: `New ${noun} from ${name}`,
|
|
46
|
+
text: [
|
|
47
|
+
`${name} sent a ${noun} from your site.`,
|
|
48
|
+
'',
|
|
49
|
+
...fieldsOf(inquiry, row).map(({ label, value }) => `${label}: ${value}`),
|
|
50
|
+
...(message === '' ? [] : ['', message]),
|
|
51
|
+
'',
|
|
52
|
+
`Answer it at ${link}`,
|
|
53
|
+
`Or reply to this email to write to ${name}.`
|
|
54
|
+
].join('\n'),
|
|
55
|
+
replyTo: textOf(row[inquiry.email])
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function contentOf(inquiry, row, progress) {
|
|
59
|
+
const isOpen = progress.state === 'open';
|
|
60
|
+
const message = inquiry.message === null ? '' : textOf(row[inquiry.message]);
|
|
61
|
+
const brief = inquiry.summary === null ? message : textOf(row[inquiry.summary]);
|
|
62
|
+
const when = inquiry.when === null ? '' : textOf(row[inquiry.when.field]);
|
|
63
|
+
return {
|
|
64
|
+
kind: 'inquiry',
|
|
65
|
+
title: inquiry.title,
|
|
66
|
+
label: inquiry.title,
|
|
67
|
+
summary: brief === '' ? inquiry.form : `${inquiry.form} · ${brief}`,
|
|
68
|
+
name: textOf(row[inquiry.name]),
|
|
69
|
+
email: textOf(row[inquiry.email]),
|
|
70
|
+
when: when === '' ? null : when,
|
|
71
|
+
whenDetail: inquiry.when === null ? null : inquiry.when.detail,
|
|
72
|
+
fields: fieldsOf(inquiry, row),
|
|
73
|
+
contact: [],
|
|
74
|
+
message,
|
|
75
|
+
status: !isOpen
|
|
76
|
+
? 'done'
|
|
77
|
+
: progress.replied
|
|
78
|
+
? 'replied'
|
|
79
|
+
: 'open',
|
|
80
|
+
needsAnswer: isOpen,
|
|
81
|
+
actions: isOpen ? ['reply', 'done'].slice() : [],
|
|
82
|
+
receivedAt: textOf(row['createdAt'])
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function sourceOf(inquiry) {
|
|
86
|
+
return {
|
|
87
|
+
name: inquiry.source,
|
|
88
|
+
table: snakeCase(inquiry.table.name),
|
|
89
|
+
contents: async (d1, progress) => {
|
|
90
|
+
const rows = await storedRows(d1, inquiry.table, [...progress.keys()]);
|
|
91
|
+
return new Map([...progress].flatMap(([id, step]) => {
|
|
92
|
+
const row = rows.get(id);
|
|
93
|
+
return row === undefined
|
|
94
|
+
? []
|
|
95
|
+
: [[id, contentOf(inquiry, row, step)]];
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function checked(sources) {
|
|
101
|
+
const names = new Set();
|
|
102
|
+
for (const inquiry of sources) {
|
|
103
|
+
if (names.has(inquiry.source)) {
|
|
104
|
+
throw new Error(`Two inquiries are called '${inquiry.source}': give each its own source name`);
|
|
105
|
+
}
|
|
106
|
+
names.add(inquiry.source);
|
|
107
|
+
}
|
|
108
|
+
return sources;
|
|
109
|
+
}
|
|
110
|
+
export function inquiries(sources) {
|
|
111
|
+
const known = checked(sources);
|
|
112
|
+
const routes = serveServices(inquiryServices, {
|
|
113
|
+
inquiries: {
|
|
114
|
+
send: async (c, { source, fields }) => {
|
|
115
|
+
const inquiry = known.find((item) => item.source === source);
|
|
116
|
+
if (inquiry === undefined)
|
|
117
|
+
throw refused(404, 'No such form');
|
|
118
|
+
const parsed = inquiry.table.fields.safeParse(fields);
|
|
119
|
+
if (!parsed.success) {
|
|
120
|
+
throw refused(400, z.prettifyError(parsed.error));
|
|
121
|
+
}
|
|
122
|
+
const id = crypto.randomUUID();
|
|
123
|
+
const row = { ...parsed.data, id };
|
|
124
|
+
const d1 = c.env.DB;
|
|
125
|
+
const insert = drizzle(d1)
|
|
126
|
+
.insert(sqlTable(inquiry.table))
|
|
127
|
+
.values(row)
|
|
128
|
+
.toSQL();
|
|
129
|
+
await d1.batch([
|
|
130
|
+
d1.prepare(insert.sql).bind(...insert.params),
|
|
131
|
+
...receivedStatements(d1, {
|
|
132
|
+
source: inquiry.source,
|
|
133
|
+
table: snakeCase(inquiry.table.name),
|
|
134
|
+
requestId: id,
|
|
135
|
+
notice: noticeOf(inquiry, row, requestsLink(c.env))
|
|
136
|
+
})
|
|
137
|
+
]);
|
|
138
|
+
await afterResponse(c, drainNotices(c.env));
|
|
139
|
+
return { id };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
return {
|
|
144
|
+
name: 'inquiries',
|
|
145
|
+
tables: requestTables,
|
|
146
|
+
openPaths: ['/api/requests/'],
|
|
147
|
+
routes,
|
|
148
|
+
requests: known.map(sourceOf)
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { MEDIA_WRITE, TABLES_READ, TABLES_WRITE, verifiedAssertion } from "../patternAccess.js";
|
|
1
|
+
import { MEDIA_WRITE, REQUESTS_ACT, REQUESTS_READ, TABLES_READ, TABLES_WRITE, verifiedAssertion } from "../patternAccess.js";
|
|
2
|
+
import { REQUESTS_PATH } from "../requests.js";
|
|
2
3
|
import { PATTERN_KEYS } from "./patternKeys.js";
|
|
3
4
|
const BEARER = /^bearer\s+/i;
|
|
4
5
|
const SCHEMA_PATH = '/api/schema';
|
|
@@ -17,6 +18,11 @@ function scopeOf(c) {
|
|
|
17
18
|
}
|
|
18
19
|
if (method === 'POST' && path === CHANGES_PATH)
|
|
19
20
|
return TABLES_WRITE;
|
|
21
|
+
if (method === 'GET' && path === `${REQUESTS_PATH}/sync`) {
|
|
22
|
+
return REQUESTS_READ;
|
|
23
|
+
}
|
|
24
|
+
if (method === 'POST' && path === `${REQUESTS_PATH}/act`)
|
|
25
|
+
return REQUESTS_ACT;
|
|
20
26
|
return method === 'POST' && path === MEDIA_PATH ? MEDIA_WRITE : undefined;
|
|
21
27
|
}
|
|
22
28
|
export async function attachPatternViewer(c, next, token) {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { D1Database, D1PreparedStatement } from '@cloudflare/workers-types';
|
|
2
|
+
import type { Context } from 'hono';
|
|
3
|
+
import type { RequestCard } from '../requests.ts';
|
|
4
|
+
import type { ServerBindings, ServerEnv } from './env.ts';
|
|
5
|
+
export type RequestContent = Omit<RequestCard, 'revision' | 'history' | 'notice' | 'source' | 'id' | 'receivedAt'>;
|
|
6
|
+
export type RequestProgress = {
|
|
7
|
+
state: 'open' | 'done';
|
|
8
|
+
replied: boolean;
|
|
9
|
+
};
|
|
10
|
+
export type RequestSource = {
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly table: string;
|
|
13
|
+
readonly contents: (d1: D1Database, progress: ReadonlyMap<string, RequestProgress>) => Promise<ReadonlyMap<string, RequestContent & {
|
|
14
|
+
receivedAt: string;
|
|
15
|
+
}>>;
|
|
16
|
+
readonly decide?: (d1: D1Database, id: string, action: 'accept' | 'decline', expected: number) => Promise<readonly D1PreparedStatement[]>;
|
|
17
|
+
readonly backfill?: (d1: D1Database) => D1PreparedStatement;
|
|
18
|
+
};
|
|
19
|
+
export type OwnerNotice = {
|
|
20
|
+
subject: string;
|
|
21
|
+
text: string;
|
|
22
|
+
replyTo: string;
|
|
23
|
+
};
|
|
24
|
+
export declare function takesRequests(d1: D1Database): Promise<boolean>;
|
|
25
|
+
export declare const NEXT_SEQUENCE = "(SELECT COALESCE(MAX(sequence), 0) + 1 FROM request_states)";
|
|
26
|
+
export declare function requestsLink(env: ServerBindings): string;
|
|
27
|
+
export declare function receivedStatements(d1: D1Database, { source, table, requestId, notice }: {
|
|
28
|
+
source: string;
|
|
29
|
+
table: string;
|
|
30
|
+
requestId: string;
|
|
31
|
+
notice: OwnerNotice;
|
|
32
|
+
}): D1PreparedStatement[];
|
|
33
|
+
export declare function transitionStatements(d1: D1Database, { source, requestId, event, state, expected }: {
|
|
34
|
+
source: string;
|
|
35
|
+
requestId: string;
|
|
36
|
+
event: 'accepted' | 'declined' | 'replied' | 'done';
|
|
37
|
+
state: 'open' | 'done';
|
|
38
|
+
expected: number | null;
|
|
39
|
+
}): D1PreparedStatement[];
|
|
40
|
+
export declare function drainNotices(env: ServerBindings, { force }?: {
|
|
41
|
+
force?: boolean;
|
|
42
|
+
}): Promise<void>;
|
|
43
|
+
export declare function afterResponse(c: Context<ServerEnv>, work: Promise<unknown>): Promise<void>;
|
|
44
|
+
export declare function requestGateway(sources: readonly RequestSource[]): import("hono/hono-base").HonoBase<ServerEnv, {
|
|
45
|
+
"/pattern/requests/sync": {
|
|
46
|
+
$get: {
|
|
47
|
+
input: {};
|
|
48
|
+
output: never;
|
|
49
|
+
outputFormat: "json";
|
|
50
|
+
status: import("hono/utils/http-status").ContentfulStatusCode;
|
|
51
|
+
} | {
|
|
52
|
+
input: {};
|
|
53
|
+
output: {
|
|
54
|
+
error: string;
|
|
55
|
+
};
|
|
56
|
+
outputFormat: "json";
|
|
57
|
+
status: 400;
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
} & {
|
|
61
|
+
"/pattern/requests/act": {
|
|
62
|
+
$post: {
|
|
63
|
+
input: {};
|
|
64
|
+
output: never;
|
|
65
|
+
outputFormat: "json";
|
|
66
|
+
status: import("hono/utils/http-status").ContentfulStatusCode;
|
|
67
|
+
} | {
|
|
68
|
+
input: {};
|
|
69
|
+
output: {
|
|
70
|
+
error: string;
|
|
71
|
+
};
|
|
72
|
+
outputFormat: "json";
|
|
73
|
+
status: 400;
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
}, "/", "/pattern/requests/act">;
|