@trypattern/sdk 0.1.11 → 0.1.13
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 +73 -12
- package/dist/access.d.ts +0 -4
- package/dist/access.js +0 -5
- package/dist/api.d.ts +3 -1
- package/dist/api.js +5 -17
- package/dist/app.d.ts +20 -5
- package/dist/app.js +32 -2
- package/dist/blocks.d.ts +18 -0
- package/dist/blocks.js +36 -0
- package/dist/bookings.d.ts +400 -0
- package/dist/bookings.js +300 -0
- package/dist/bookingsMemory.d.ts +9 -0
- package/dist/bookingsMemory.js +79 -0
- package/dist/bookingsStore.d.ts +698 -0
- package/dist/bookingsStore.js +129 -0
- package/dist/cli/app.js +143 -59
- package/dist/cli/publishing.d.ts +3 -1
- package/dist/cli/publishing.js +33 -5
- package/dist/cli/starter.d.ts +17 -0
- package/dist/cli/starter.js +140 -0
- package/dist/components/Button.d.ts +2 -24
- package/dist/components/Button.js +4 -251
- package/dist/components/ButtonBody.d.ts +25 -0
- package/dist/components/ButtonBody.js +266 -0
- package/dist/components/Calendar.d.ts +20 -0
- package/dist/components/Calendar.js +195 -0
- package/dist/components/Card.d.ts +1 -1
- package/dist/components/Card.js +7 -29
- package/dist/components/Choice.js +27 -13
- package/dist/components/ChoiceGroup.d.ts +3 -1
- package/dist/components/ChoiceGroup.js +3 -4
- package/dist/components/ContinueWithGoogle.d.ts +11 -0
- package/dist/components/ContinueWithGoogle.js +5 -0
- package/dist/components/Dialog.d.ts +1 -1
- package/dist/components/Dialog.js +3 -1
- package/dist/components/Icon.js +1 -1
- package/dist/components/ListRow.js +2 -1
- package/dist/components/RadioGroup.d.ts +2 -1
- package/dist/components/RadioGroup.js +10 -2
- package/dist/components/Text.d.ts +1 -1
- package/dist/components/Text.js +43 -2
- package/dist/components/calendarDates.d.ts +14 -0
- package/dist/components/calendarDates.js +84 -0
- package/dist/components/controlLook.js +11 -11
- package/dist/components/icons.d.ts +9 -0
- package/dist/components/icons.js +17 -1
- package/dist/components/interaction.d.ts +1 -1
- package/dist/flows/browser.d.ts +7 -1
- package/dist/flows/browser.js +25 -7
- package/dist/flows/flowBrowser.d.ts +2 -2
- package/dist/flows/flowBrowser.js +2 -2
- package/dist/flows/flows.js +8 -2
- package/dist/index.d.ts +4 -1
- package/dist/index.js +1 -0
- package/dist/media.d.ts +11 -1
- package/dist/patternAccess.d.ts +11 -2
- package/dist/patternAccess.js +8 -4
- package/dist/publishWire.d.ts +24 -2
- package/dist/publishWire.js +22 -2
- package/dist/react.d.ts +3 -0
- package/dist/react.js +2 -0
- package/dist/rules/checkAllFiles.js +16 -2
- package/dist/rules/lintPlugin.js +1 -0
- package/dist/schema.d.ts +3 -1
- package/dist/schema.js +1 -0
- package/dist/server/bookings.d.ts +12 -0
- package/dist/server/bookings.js +142 -0
- package/dist/server/createServer.d.ts +3 -2
- package/dist/server/createServer.js +13 -4
- package/dist/server/env.d.ts +8 -0
- package/dist/server/index.d.ts +3 -1
- package/dist/server/index.js +1 -0
- package/dist/server/media.d.ts +16 -3
- package/dist/server/media.js +72 -23
- package/dist/server/patternViewer.js +16 -10
- package/dist/server/tables.d.ts +2 -0
- package/dist/server/tables.js +17 -1
- package/dist/start.js +12 -0
- package/dist/table.js +9 -3
- package/dist/theme/scales.d.ts +1 -1
- package/dist/theme/scales.js +1 -0
- package/dist/theme/tokens.stylex.d.ts +18 -0
- package/dist/theme/tokens.stylex.js +19 -1
- package/dist/vite/localWorker.d.ts +2 -0
- package/dist/vite/localWorker.js +48 -3
- package/dist/vite/vite.js +5 -1
- package/package.json +1 -1
package/dist/media.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import type { Row } from './table.ts';
|
|
2
|
+
import type { Fields, Row, Tables } from './table.ts';
|
|
3
3
|
export declare const media: import("./table.ts").TableOf<"media", {
|
|
4
4
|
readonly columns: {
|
|
5
5
|
readonly key: z.ZodString;
|
|
@@ -20,6 +20,16 @@ export declare const media: import("./table.ts").TableOf<"media", {
|
|
|
20
20
|
}>;
|
|
21
21
|
export type Media = Row<typeof media>;
|
|
22
22
|
export type MediaImage = Pick<Media, 'key' | 'width' | 'height' | 'placeholder' | 'alt'>;
|
|
23
|
+
export type StarterPhoto = {
|
|
24
|
+
id: string;
|
|
25
|
+
photo: string;
|
|
26
|
+
alt: string;
|
|
27
|
+
};
|
|
28
|
+
export type Starter<Group extends Tables> = {
|
|
29
|
+
[Key in keyof Group as Group[Key]['owned'] extends true ? never : Key]?: Group[Key] extends typeof media ? readonly StarterPhoto[] : readonly ({
|
|
30
|
+
id: string;
|
|
31
|
+
} & Fields<Group[Key]>)[];
|
|
32
|
+
};
|
|
23
33
|
export declare const MEDIA_LIMIT_BYTES: number;
|
|
24
34
|
export declare const IMAGE_WIDTHS: readonly [320, 640, 960, 1280, 1920];
|
|
25
35
|
export declare function mediaPath(key: string): string;
|
package/dist/patternAccess.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
export declare const TABLES_READ = "tables:read";
|
|
3
|
+
export declare const MEDIA_WRITE = "media:write";
|
|
3
4
|
export declare const ASSERTION_SECONDS = 60;
|
|
4
5
|
export declare const NEXT_PAGE_HEADER = "x-pattern-next";
|
|
5
6
|
export declare const assertionKey: z.ZodObject<{
|
|
@@ -10,12 +11,20 @@ export declare const assertionKey: z.ZodObject<{
|
|
|
10
11
|
d: z.ZodString;
|
|
11
12
|
}, z.core.$strip>;
|
|
12
13
|
export type AssertionKey = z.output<typeof assertionKey>;
|
|
13
|
-
|
|
14
|
+
declare const assertionScope: z.ZodEnum<{
|
|
15
|
+
"tables:read": "tables:read";
|
|
16
|
+
"media:write": "media:write";
|
|
17
|
+
}>;
|
|
18
|
+
export type AssertionScope = z.output<typeof assertionScope>;
|
|
19
|
+
export declare function signAssertion(key: AssertionKey, { sub, aud, scope }: {
|
|
14
20
|
sub: string;
|
|
15
21
|
aud: string;
|
|
22
|
+
scope: AssertionScope;
|
|
16
23
|
}, now: number): Promise<string>;
|
|
17
|
-
export declare function
|
|
24
|
+
export declare function verifiedAssertion(token: string, { hostname, keys, now, scope }: {
|
|
18
25
|
hostname: string;
|
|
19
26
|
keys: Readonly<Record<string, string>>;
|
|
20
27
|
now: number;
|
|
28
|
+
scope: AssertionScope;
|
|
21
29
|
}): Promise<string | undefined>;
|
|
30
|
+
export {};
|
package/dist/patternAccess.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
export const TABLES_READ = 'tables:read';
|
|
3
|
+
export const MEDIA_WRITE = 'media:write';
|
|
3
4
|
export const ASSERTION_SECONDS = 60;
|
|
4
5
|
export const NEXT_PAGE_HEADER = 'x-pattern-next';
|
|
5
6
|
const CLOCK_SKEW_SECONDS = 30;
|
|
@@ -11,6 +12,7 @@ export const assertionKey = z.object({
|
|
|
11
12
|
x: z.string(),
|
|
12
13
|
d: z.string()
|
|
13
14
|
});
|
|
15
|
+
const assertionScope = z.enum([TABLES_READ, MEDIA_WRITE]);
|
|
14
16
|
const assertionHeader = z.object({
|
|
15
17
|
alg: z.literal('EdDSA'),
|
|
16
18
|
typ: z.literal('JWT'),
|
|
@@ -19,7 +21,7 @@ const assertionHeader = z.object({
|
|
|
19
21
|
const assertionClaims = z.object({
|
|
20
22
|
sub: z.email(),
|
|
21
23
|
aud: z.string(),
|
|
22
|
-
scope:
|
|
24
|
+
scope: assertionScope,
|
|
23
25
|
iat: z.int(),
|
|
24
26
|
exp: z.int()
|
|
25
27
|
});
|
|
@@ -53,12 +55,12 @@ function jsonOf(text) {
|
|
|
53
55
|
function encoded(value) {
|
|
54
56
|
return base64url(encoder.encode(JSON.stringify(value)));
|
|
55
57
|
}
|
|
56
|
-
export async function
|
|
58
|
+
export async function signAssertion(key, { sub, aud, scope }, now) {
|
|
57
59
|
const privateKey = await crypto.subtle.importKey('jwk', { kty: key.kty, crv: key.crv, x: key.x, d: key.d }, ED25519, false, ['sign']);
|
|
58
60
|
const signed = `${encoded({ alg: 'EdDSA', typ: 'JWT', kid: key.kid })}.${encoded({
|
|
59
61
|
sub,
|
|
60
62
|
aud,
|
|
61
|
-
scope
|
|
63
|
+
scope,
|
|
62
64
|
iat: now,
|
|
63
65
|
exp: now + ASSERTION_SECONDS
|
|
64
66
|
})}`;
|
|
@@ -80,7 +82,7 @@ async function isSignedBy(publicKey, signed, signature) {
|
|
|
80
82
|
return false;
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
|
-
export async function
|
|
85
|
+
export async function verifiedAssertion(token, { hostname, keys, now, scope }) {
|
|
84
86
|
const [header, claims, signature, ...rest] = token.split('.');
|
|
85
87
|
if (header === undefined ||
|
|
86
88
|
claims === undefined ||
|
|
@@ -102,6 +104,8 @@ export async function verifiedTablesAssertion(token, { hostname, keys, now }) {
|
|
|
102
104
|
if (!parsed.success)
|
|
103
105
|
return undefined;
|
|
104
106
|
const { sub, aud, iat, exp } = parsed.data;
|
|
107
|
+
if (parsed.data.scope !== scope)
|
|
108
|
+
return undefined;
|
|
105
109
|
const isFresh = exp > iat &&
|
|
106
110
|
exp - iat <= ASSERTION_SECONDS &&
|
|
107
111
|
iat <= now + CLOCK_SKEW_SECONDS &&
|
package/dist/publishWire.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { appTitle } from './appTitle.ts';
|
|
3
3
|
export { appTitle };
|
|
4
|
-
export { ASSERTION_SECONDS, NEXT_PAGE_HEADER, TABLES_READ, assertionKey,
|
|
5
|
-
export type { AssertionKey } from './patternAccess.ts';
|
|
4
|
+
export { ASSERTION_SECONDS, MEDIA_WRITE, NEXT_PAGE_HEADER, TABLES_READ, assertionKey, signAssertion } from './patternAccess.ts';
|
|
5
|
+
export type { AssertionKey, AssertionScope } from './patternAccess.ts';
|
|
6
6
|
export declare const DASHBOARD_URL = "https://onpattern.app";
|
|
7
7
|
export declare const APPS_DOMAIN = "onpattern.app";
|
|
8
8
|
export declare const UPLOAD_LIMIT_BYTES: number;
|
|
@@ -21,7 +21,29 @@ export declare const UPLOAD: {
|
|
|
21
21
|
readonly sdkVersion: "sdkVersion";
|
|
22
22
|
readonly message: "message";
|
|
23
23
|
readonly runWorkerFirst: "runWorkerFirst";
|
|
24
|
+
readonly starter: "starter";
|
|
24
25
|
};
|
|
26
|
+
export declare const starterContent: z.ZodObject<{
|
|
27
|
+
photos: z.ZodArray<z.ZodObject<{
|
|
28
|
+
id: z.ZodString;
|
|
29
|
+
path: z.ZodString;
|
|
30
|
+
alt: z.ZodString;
|
|
31
|
+
}, z.core.$strip>>;
|
|
32
|
+
sql: z.ZodString;
|
|
33
|
+
}, z.core.$strip>;
|
|
34
|
+
export type StarterContent = z.output<typeof starterContent>;
|
|
35
|
+
export declare const mediaRow: z.ZodObject<{
|
|
36
|
+
id: z.ZodString;
|
|
37
|
+
key: z.ZodString;
|
|
38
|
+
width: z.ZodNumber;
|
|
39
|
+
height: z.ZodNumber;
|
|
40
|
+
placeholder: z.ZodNullable<z.ZodString>;
|
|
41
|
+
alt: z.ZodString;
|
|
42
|
+
type: z.ZodString;
|
|
43
|
+
bytes: z.ZodNumber;
|
|
44
|
+
createdAt: z.ZodString;
|
|
45
|
+
}, z.core.$strip>;
|
|
46
|
+
export type MediaRow = z.output<typeof mediaRow>;
|
|
25
47
|
export declare function workerFirstPaths(text: string | undefined): readonly string[];
|
|
26
48
|
export declare const PUBLISH_MESSAGE_LIMIT = 100;
|
|
27
49
|
export declare function publishMessage(text: string): string | null;
|
package/dist/publishWire.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { appTitle } from "./appTitle.js";
|
|
3
3
|
export { appTitle };
|
|
4
|
-
export { ASSERTION_SECONDS, NEXT_PAGE_HEADER, TABLES_READ, assertionKey,
|
|
4
|
+
export { ASSERTION_SECONDS, MEDIA_WRITE, NEXT_PAGE_HEADER, TABLES_READ, assertionKey, signAssertion } from "./patternAccess.js";
|
|
5
5
|
import { call, defineServices } from "./services.js";
|
|
6
6
|
export const DASHBOARD_URL = 'https://onpattern.app';
|
|
7
7
|
export const APPS_DOMAIN = 'onpattern.app';
|
|
@@ -92,8 +92,28 @@ export const UPLOAD = {
|
|
|
92
92
|
compatibilityDate: 'compatibilityDate',
|
|
93
93
|
sdkVersion: 'sdkVersion',
|
|
94
94
|
message: 'message',
|
|
95
|
-
runWorkerFirst: 'runWorkerFirst'
|
|
95
|
+
runWorkerFirst: 'runWorkerFirst',
|
|
96
|
+
starter: 'starter'
|
|
96
97
|
};
|
|
98
|
+
export const starterContent = z.object({
|
|
99
|
+
photos: z.array(z.object({
|
|
100
|
+
id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/),
|
|
101
|
+
path: z.string().startsWith('starter/photos/'),
|
|
102
|
+
alt: z.string().trim().min(1)
|
|
103
|
+
})),
|
|
104
|
+
sql: z.string()
|
|
105
|
+
});
|
|
106
|
+
export const mediaRow = z.object({
|
|
107
|
+
id: z.string(),
|
|
108
|
+
key: z.string(),
|
|
109
|
+
width: z.number(),
|
|
110
|
+
height: z.number(),
|
|
111
|
+
placeholder: z.string().nullable(),
|
|
112
|
+
alt: z.string(),
|
|
113
|
+
type: z.string(),
|
|
114
|
+
bytes: z.number(),
|
|
115
|
+
createdAt: z.string()
|
|
116
|
+
});
|
|
97
117
|
const WORKER_FIRST_PATH = /^!?\/[\w.~\-/*]{0,100}$/;
|
|
98
118
|
const WORKER_FIRST_LIMIT = 32;
|
|
99
119
|
const ALWAYS_WORKER_FIRST = ['/api/*', '/media/*'];
|
package/dist/react.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export { AppearancePicker } from './components/AppearancePicker.tsx';
|
|
|
5
5
|
export { Avatar } from './components/Avatar.tsx';
|
|
6
6
|
export { Badge } from './components/Badge.tsx';
|
|
7
7
|
export { Button } from './components/Button.tsx';
|
|
8
|
+
export { Calendar } from './components/Calendar.tsx';
|
|
9
|
+
export type { CalendarDay } from './components/Calendar.tsx';
|
|
8
10
|
export { Card } from './components/Card.tsx';
|
|
9
11
|
export { Checkbox } from './components/Checkbox.tsx';
|
|
10
12
|
export { Choice } from './components/Choice.tsx';
|
|
@@ -14,6 +16,7 @@ export { CommandPalette } from './components/CommandPalette.tsx';
|
|
|
14
16
|
export type { CommandMenuItem } from './components/CommandMenu.tsx';
|
|
15
17
|
export { Content } from './components/Content.tsx';
|
|
16
18
|
export { ContextMenu } from './components/ContextMenu.tsx';
|
|
19
|
+
export { ContinueWithGoogle } from './components/ContinueWithGoogle.tsx';
|
|
17
20
|
export { CopyButton } from './components/CopyButton.tsx';
|
|
18
21
|
export { Dialog } from './components/Dialog.tsx';
|
|
19
22
|
export { Divider } from './components/Divider.tsx';
|
package/dist/react.js
CHANGED
|
@@ -5,6 +5,7 @@ export { AppearancePicker } from "./components/AppearancePicker.js";
|
|
|
5
5
|
export { Avatar } from "./components/Avatar.js";
|
|
6
6
|
export { Badge } from "./components/Badge.js";
|
|
7
7
|
export { Button } from "./components/Button.js";
|
|
8
|
+
export { Calendar } from "./components/Calendar.js";
|
|
8
9
|
export { Card } from "./components/Card.js";
|
|
9
10
|
export { Checkbox } from "./components/Checkbox.js";
|
|
10
11
|
export { Choice } from "./components/Choice.js";
|
|
@@ -13,6 +14,7 @@ export { CommandMenu } from "./components/CommandMenu.js";
|
|
|
13
14
|
export { CommandPalette } from "./components/CommandPalette.js";
|
|
14
15
|
export { Content } from "./components/Content.js";
|
|
15
16
|
export { ContextMenu } from "./components/ContextMenu.js";
|
|
17
|
+
export { ContinueWithGoogle } from "./components/ContinueWithGoogle.js";
|
|
16
18
|
export { CopyButton } from "./components/CopyButton.js";
|
|
17
19
|
export { Dialog } from "./components/Dialog.js";
|
|
18
20
|
export { Divider } from "./components/Divider.js";
|
|
@@ -390,6 +390,14 @@ function isExported(node) {
|
|
|
390
390
|
return (ts.canHaveModifiers(node) &&
|
|
391
391
|
(ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword));
|
|
392
392
|
}
|
|
393
|
+
function definesTables(node, source) {
|
|
394
|
+
if (ts.isCallExpression(node)) {
|
|
395
|
+
return node.expression.getText(source) === 'defineTables';
|
|
396
|
+
}
|
|
397
|
+
return (ts.isObjectLiteralExpression(node) &&
|
|
398
|
+
node.properties.some((property) => ts.isSpreadAssignment(property) &&
|
|
399
|
+
definesTables(property.expression, source)));
|
|
400
|
+
}
|
|
393
401
|
function schemaFindings() {
|
|
394
402
|
if (!existsSync(SCHEMA_FILE))
|
|
395
403
|
return [];
|
|
@@ -411,8 +419,7 @@ function schemaFindings() {
|
|
|
411
419
|
node.declarationList.declarations.some((declaration) => ts.isIdentifier(declaration.name) &&
|
|
412
420
|
declaration.name.text === 'tables' &&
|
|
413
421
|
declaration.initializer !== undefined &&
|
|
414
|
-
|
|
415
|
-
declaration.initializer.expression.getText(source) === 'defineTables')) {
|
|
422
|
+
definesTables(declaration.initializer, source))) {
|
|
416
423
|
exportsTables = true;
|
|
417
424
|
}
|
|
418
425
|
if (ts.isCallExpression(node) &&
|
|
@@ -697,6 +704,11 @@ function themeScaleFindings() {
|
|
|
697
704
|
}
|
|
698
705
|
return findings;
|
|
699
706
|
}
|
|
707
|
+
function readsStarterRows(path, text, finding) {
|
|
708
|
+
return (finding.rule === 'import-from-root' &&
|
|
709
|
+
relative(ROOT, path) === 'seed.ts' &&
|
|
710
|
+
/^['"]\.\/starter\/rows\.ts['"]/.test(text.slice(finding.offset)));
|
|
711
|
+
}
|
|
700
712
|
const findingLines = [];
|
|
701
713
|
const violatedRules = new Set();
|
|
702
714
|
let scannedCount = 0;
|
|
@@ -707,6 +719,8 @@ for (const path of ownedFiles(ROOT)) {
|
|
|
707
719
|
continue;
|
|
708
720
|
scannedCount += 1;
|
|
709
721
|
for (const finding of scanner(text)) {
|
|
722
|
+
if (readsStarterRows(path, text, finding))
|
|
723
|
+
continue;
|
|
710
724
|
violatedRules.add(finding.rule);
|
|
711
725
|
findingLines.push(`${relative(ROOT, path)}:${lineAndColumn(text, finding.offset)}: pattern(${finding.rule})`);
|
|
712
726
|
}
|
package/dist/rules/lintPlugin.js
CHANGED
package/dist/schema.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { call, defineServices } from './services.ts';
|
|
2
2
|
export type { CallInput, CallReply, ServiceCall, ServiceMemory, Services } from './services.ts';
|
|
3
3
|
export { media } from './media.ts';
|
|
4
|
-
export
|
|
4
|
+
export { bookingTables } from './bookings.ts';
|
|
5
|
+
export type { BookingDay, BookingRequest, BookingRequestFields, BookingRules, BookingSlot, BookingStatus, BookingWindow } from './bookings.ts';
|
|
6
|
+
export type { Media, MediaImage, Starter, StarterPhoto } from './media.ts';
|
|
5
7
|
export { defineTables } from './table.ts';
|
|
6
8
|
export type { Access, Columns, Fields, Level, PublicRow, Row, Seed, Table, TableDefinition, TableOf, Tables, Value, Writes } from './table.ts';
|
package/dist/schema.js
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
import type { BookingRequest } from '../bookings.ts';
|
|
3
|
+
import type { ServerBlock, ServerEnv } from './env.ts';
|
|
4
|
+
export type BookingsOptions = {
|
|
5
|
+
timezone: string;
|
|
6
|
+
};
|
|
7
|
+
export type BookingEmail = {
|
|
8
|
+
kind: 'requested' | 'accepted' | 'declined';
|
|
9
|
+
request: BookingRequest;
|
|
10
|
+
};
|
|
11
|
+
export declare function sendBookingEmail(c: Context<ServerEnv>, email: BookingEmail): Promise<void>;
|
|
12
|
+
export declare function bookings({ timezone }: BookingsOptions): ServerBlock;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { FULL, RULES_ID, UNAVAILABLE, bookingBlackout, bookingRequests, bookingRules, bookingServices, bookingTables, daysOf, isValidTimezone, rulesOf, rulesProblem, slotFor, takenKey, windowsOn } from "../bookings.js";
|
|
2
|
+
import { refused } from "../services.js";
|
|
3
|
+
import { serveServices } from "./services.js";
|
|
4
|
+
import { readRow, readRows } from "./tables.js";
|
|
5
|
+
const HOLDING_SQL = "status IN ('requested', 'accepted')";
|
|
6
|
+
export async function sendBookingEmail(c, email) {
|
|
7
|
+
if (!import.meta.env.DEV)
|
|
8
|
+
return;
|
|
9
|
+
const to = email.kind === 'requested' ? 'the owner' : email.request.email;
|
|
10
|
+
console.log(`Booking email (${email.kind}) to ${to} from ${new URL(c.req.url).host}: ${email.request.name}, ${email.request.date} ${email.request.window}`);
|
|
11
|
+
}
|
|
12
|
+
function requireAdmin(c) {
|
|
13
|
+
const viewer = c.get('viewer');
|
|
14
|
+
if (viewer === null)
|
|
15
|
+
throw refused(401, 'Signed out');
|
|
16
|
+
if (!viewer.isAdmin)
|
|
17
|
+
throw refused(403, 'forbidden');
|
|
18
|
+
}
|
|
19
|
+
async function readRules(d1) {
|
|
20
|
+
const [row, blackouts] = await Promise.all([
|
|
21
|
+
readRow(d1, bookingRules, RULES_ID),
|
|
22
|
+
readRows(d1, bookingTables.bookingBlackouts)
|
|
23
|
+
]);
|
|
24
|
+
const parsedRow = row === undefined ? undefined : bookingRules.row.parse(row);
|
|
25
|
+
return rulesOf(parsedRow, blackouts
|
|
26
|
+
.map((blackout) => bookingBlackout.parse(blackout))
|
|
27
|
+
.toSorted((a, b) => a.date.localeCompare(b.date)));
|
|
28
|
+
}
|
|
29
|
+
async function takenBetween(d1, first, last) {
|
|
30
|
+
const { results } = await d1
|
|
31
|
+
.prepare(`SELECT date, "window" AS slot, COUNT(*) AS count FROM booking_requests WHERE date >= ?1 AND date <= ?2 AND ${HOLDING_SQL} GROUP BY date, "window"`)
|
|
32
|
+
.bind(first, last)
|
|
33
|
+
.all();
|
|
34
|
+
return new Map(results.map((row) => [takenKey(row.date, row.slot), Number(row.count)]));
|
|
35
|
+
}
|
|
36
|
+
async function availabilityFor(c, timezone, first, last) {
|
|
37
|
+
const [rules, taken] = await Promise.all([
|
|
38
|
+
readRules(c.env.DB),
|
|
39
|
+
takenBetween(c.env.DB, first, last)
|
|
40
|
+
]);
|
|
41
|
+
return { rules, taken, now: new Date(), timezone };
|
|
42
|
+
}
|
|
43
|
+
async function readRequest(d1, id) {
|
|
44
|
+
const row = await readRow(d1, bookingRequests, id);
|
|
45
|
+
return row === undefined ? undefined : bookingRequests.row.parse(row);
|
|
46
|
+
}
|
|
47
|
+
async function decide(c, id, status) {
|
|
48
|
+
requireAdmin(c);
|
|
49
|
+
const from = status === 'accepted' ? ['requested'] : ['requested', 'accepted'];
|
|
50
|
+
const existing = await readRequest(c.env.DB, id);
|
|
51
|
+
if (existing === undefined)
|
|
52
|
+
throw refused(404, 'No such request');
|
|
53
|
+
if (!from.includes(existing.status)) {
|
|
54
|
+
throw refused(409, `This request is already ${existing.status}`);
|
|
55
|
+
}
|
|
56
|
+
const { meta } = await c.env.DB.prepare(`UPDATE booking_requests SET status = ?1 WHERE id = ?2 AND status IN (${from.map((item) => `'${item}'`).join(', ')})`)
|
|
57
|
+
.bind(status, id)
|
|
58
|
+
.run();
|
|
59
|
+
if (meta.changes === 0)
|
|
60
|
+
throw refused(409, 'This request just changed');
|
|
61
|
+
const decided = { ...existing, status };
|
|
62
|
+
await sendBookingEmail(c, { kind: status, request: decided });
|
|
63
|
+
return decided;
|
|
64
|
+
}
|
|
65
|
+
function monthRange(month) {
|
|
66
|
+
return { first: `${month}-01`, last: `${month}-31` };
|
|
67
|
+
}
|
|
68
|
+
export function bookings({ timezone }) {
|
|
69
|
+
if (!isValidTimezone(timezone)) {
|
|
70
|
+
throw new Error(`bookings({ timezone: '${timezone}' }) is not a time zone: use an IANA name such as 'America/Toronto'`);
|
|
71
|
+
}
|
|
72
|
+
const routes = serveServices(bookingServices, {
|
|
73
|
+
bookings: {
|
|
74
|
+
days: async (c, { month }) => {
|
|
75
|
+
const { first, last } = monthRange(month);
|
|
76
|
+
const facts = await availabilityFor(c, timezone, first, last);
|
|
77
|
+
return [...daysOf(month, facts)];
|
|
78
|
+
},
|
|
79
|
+
windows: async (c, { date }) => {
|
|
80
|
+
const facts = await availabilityFor(c, timezone, date, date);
|
|
81
|
+
return [...windowsOn(date, facts)];
|
|
82
|
+
},
|
|
83
|
+
request: async (c, fields) => {
|
|
84
|
+
const facts = await availabilityFor(c, timezone, fields.date, fields.date);
|
|
85
|
+
const slot = slotFor(fields.date, fields.window, facts);
|
|
86
|
+
if (slot === undefined)
|
|
87
|
+
throw refused(400, UNAVAILABLE);
|
|
88
|
+
if (slot.remaining === 0)
|
|
89
|
+
throw refused(409, FULL);
|
|
90
|
+
const id = crypto.randomUUID();
|
|
91
|
+
const status = facts.rules.autoAccept ? 'accepted' : 'requested';
|
|
92
|
+
const { meta } = await c.env.DB.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`)
|
|
93
|
+
.bind(id, fields.offering, fields.date, fields.window, fields.name, fields.email, fields.phone ?? '', fields.address ?? '', fields.notes ?? '', status, slot.capacity)
|
|
94
|
+
.run();
|
|
95
|
+
if (meta.changes === 0)
|
|
96
|
+
throw refused(409, FULL);
|
|
97
|
+
const saved = await readRequest(c.env.DB, id);
|
|
98
|
+
if (saved === undefined)
|
|
99
|
+
throw new Error('The request was not saved');
|
|
100
|
+
await sendBookingEmail(c, {
|
|
101
|
+
kind: status === 'accepted' ? 'accepted' : 'requested',
|
|
102
|
+
request: saved
|
|
103
|
+
});
|
|
104
|
+
return saved;
|
|
105
|
+
},
|
|
106
|
+
requests: async (c) => {
|
|
107
|
+
requireAdmin(c);
|
|
108
|
+
const rows = await readRows(c.env.DB, bookingRequests);
|
|
109
|
+
return rows.map((row) => bookingRequests.row.parse(row));
|
|
110
|
+
},
|
|
111
|
+
accept: (c, { id }) => decide(c, id, 'accepted'),
|
|
112
|
+
decline: (c, { id }) => decide(c, id, 'declined'),
|
|
113
|
+
rules: async (c) => {
|
|
114
|
+
requireAdmin(c);
|
|
115
|
+
return readRules(c.env.DB);
|
|
116
|
+
},
|
|
117
|
+
saveRules: async (c, rules) => {
|
|
118
|
+
requireAdmin(c);
|
|
119
|
+
const problem = rulesProblem(rules);
|
|
120
|
+
if (problem !== undefined)
|
|
121
|
+
throw refused(400, problem);
|
|
122
|
+
const d1 = c.env.DB;
|
|
123
|
+
await d1.batch([
|
|
124
|
+
d1
|
|
125
|
+
.prepare('INSERT INTO booking_rules (id, windows, lead_time_hours, days_ahead, auto_accept) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT (id) DO UPDATE SET windows = excluded.windows, lead_time_hours = excluded.lead_time_hours, days_ahead = excluded.days_ahead, auto_accept = excluded.auto_accept')
|
|
126
|
+
.bind(RULES_ID, JSON.stringify(rules.windows), rules.leadTimeHours, rules.daysAhead, rules.autoAccept ? 1 : 0),
|
|
127
|
+
d1.prepare('DELETE FROM booking_blackouts'),
|
|
128
|
+
...rules.blackouts.map((blackout) => d1
|
|
129
|
+
.prepare('INSERT INTO booking_blackouts (id, date, reason) VALUES (?1, ?2, ?3)')
|
|
130
|
+
.bind(`blackout-${blackout.date}`, blackout.date, blackout.reason))
|
|
131
|
+
]);
|
|
132
|
+
return readRules(d1);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
name: 'bookings',
|
|
138
|
+
tables: bookingTables,
|
|
139
|
+
openPaths: ['/api/bookings/'],
|
|
140
|
+
routes
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import type { Tables } from '../table.ts';
|
|
3
|
-
import type { ServerBindings, ServerEnv } from './env.ts';
|
|
3
|
+
import type { ServerBindings, ServerBlock, ServerEnv } from './env.ts';
|
|
4
4
|
import type { Pages } from './pages.ts';
|
|
5
|
-
export declare function createServer<Group extends Tables, Bindings extends ServerBindings = ServerBindings>({ tables, admins, routes, pages }: {
|
|
5
|
+
export declare function createServer<Group extends Tables, Bindings extends ServerBindings = ServerBindings>({ tables, admins, routes, pages, blocks }: {
|
|
6
6
|
tables: Group;
|
|
7
7
|
admins: readonly string[];
|
|
8
8
|
routes?: Hono<ServerEnv<Bindings>>;
|
|
9
9
|
pages?: Pages;
|
|
10
|
+
blocks?: readonly ServerBlock[];
|
|
10
11
|
}): import("hono/hono-base").HonoBase<ServerEnv, import("hono/types").BlankSchema | import("hono/types").MergeSchemaPath<(((({
|
|
11
12
|
"/api/health": {
|
|
12
13
|
$get: {
|
|
@@ -2,6 +2,7 @@ import { Hono } from 'hono';
|
|
|
2
2
|
import { csrf } from 'hono/csrf';
|
|
3
3
|
import { adminEmails } from "../access.js";
|
|
4
4
|
import { appTitle } from "../appTitle.js";
|
|
5
|
+
import { blockWiringProblem } from "../blocks.js";
|
|
5
6
|
import { media } from "../media.js";
|
|
6
7
|
import { auth, devSignIn } from "./auth.js";
|
|
7
8
|
import { serveDatabase } from "./database.js";
|
|
@@ -23,14 +24,17 @@ function knowAdmins(admins) {
|
|
|
23
24
|
return next();
|
|
24
25
|
};
|
|
25
26
|
}
|
|
26
|
-
|
|
27
|
+
function attachViewer(optionalPaths) {
|
|
28
|
+
return (c, next) => viewerOf(c, next, [...VIEWER_OPTIONAL_PATHS, ...optionalPaths]);
|
|
29
|
+
}
|
|
30
|
+
async function viewerOf(c, next, optionalPaths) {
|
|
27
31
|
const bearer = bearerOf(c);
|
|
28
32
|
if (bearer !== undefined)
|
|
29
33
|
return attachPatternViewer(c, next, bearer);
|
|
30
34
|
if (PUBLIC_PATHS.some((path) => c.req.path.startsWith(path)))
|
|
31
35
|
return next();
|
|
32
36
|
const isOptional = c.req.path === APP_PATH ||
|
|
33
|
-
|
|
37
|
+
optionalPaths.some((path) => c.req.path.startsWith(path));
|
|
34
38
|
let pass = await readPass(c);
|
|
35
39
|
if (pass !== undefined && needsRecheck(pass)) {
|
|
36
40
|
const user = await renewSession(c.env.DB, pass.sessionId);
|
|
@@ -50,14 +54,17 @@ async function attachViewer(c, next) {
|
|
|
50
54
|
});
|
|
51
55
|
return next();
|
|
52
56
|
}
|
|
53
|
-
export function createServer({ tables, admins, routes, pages }) {
|
|
57
|
+
export function createServer({ tables, admins, routes, pages, blocks = [] }) {
|
|
58
|
+
const wiring = blockWiringProblem(tables, blocks, 'server');
|
|
59
|
+
if (wiring !== undefined)
|
|
60
|
+
throw new Error(wiring);
|
|
54
61
|
const authRoutes = import.meta.env.DEV ? auth.route('/', devSignIn) : auth;
|
|
55
62
|
const hasMedia = Object.values(tables).some((table) => table === media);
|
|
56
63
|
const server = new Hono()
|
|
57
64
|
.basePath('/api')
|
|
58
65
|
.use(csrf())
|
|
59
66
|
.use(knowAdmins(admins))
|
|
60
|
-
.use(attachViewer)
|
|
67
|
+
.use(attachViewer(blocks.flatMap((block) => block.openPaths)))
|
|
61
68
|
.get('/health', (c) => c.json({ ok: true }))
|
|
62
69
|
.route('/auth', authRoutes)
|
|
63
70
|
.get('/app', async (c) => c.json({ title: await readTitle(c.env.DB) }))
|
|
@@ -89,6 +96,8 @@ export function createServer({ tables, admins, routes, pages }) {
|
|
|
89
96
|
.route('/tables', serveTables(tables));
|
|
90
97
|
if (hasMedia)
|
|
91
98
|
server.post('/media', uploadMedia);
|
|
99
|
+
for (const block of blocks)
|
|
100
|
+
server.route('/', block.routes);
|
|
92
101
|
const own = routes;
|
|
93
102
|
const root = new Hono();
|
|
94
103
|
if (hasMedia)
|
package/dist/server/env.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { D1Database, Fetcher, R2Bucket } from '@cloudflare/workers-types';
|
|
2
|
+
import type { Hono } from 'hono';
|
|
2
3
|
import type { Viewer } from '../access.ts';
|
|
4
|
+
import type { Tables } from '../table.ts';
|
|
3
5
|
export type { Viewer };
|
|
4
6
|
export type ServerBindings = {
|
|
5
7
|
DB: D1Database;
|
|
@@ -15,3 +17,9 @@ export type ServerEnv<Bindings extends ServerBindings = ServerBindings> = {
|
|
|
15
17
|
admins: ReadonlySet<string>;
|
|
16
18
|
};
|
|
17
19
|
};
|
|
20
|
+
export type ServerBlock = {
|
|
21
|
+
readonly name: string;
|
|
22
|
+
readonly tables: Tables;
|
|
23
|
+
readonly openPaths: readonly string[];
|
|
24
|
+
readonly routes: Hono<ServerEnv>;
|
|
25
|
+
};
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { createServer } from './createServer.ts';
|
|
2
|
-
export type { ServerBindings, ServerEnv, Viewer } from './env.ts';
|
|
2
|
+
export type { ServerBindings, ServerBlock, ServerEnv, Viewer } from './env.ts';
|
|
3
|
+
export { bookings } from './bookings.ts';
|
|
4
|
+
export type { BookingsOptions } from './bookings.ts';
|
|
3
5
|
export { seedSql, serveTables, sqlTable, sqlTables } from './tables.ts';
|
|
4
6
|
export { refused } from '../services.ts';
|
|
5
7
|
export { serveServices } from './services.ts';
|
package/dist/server/index.js
CHANGED
package/dist/server/media.d.ts
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import type { Context } from 'hono';
|
|
2
2
|
import type { ServerEnv } from './env.ts';
|
|
3
|
+
export declare const IMMUTABLE = "public, max-age=31536000, immutable";
|
|
4
|
+
export type PhotoFacts = {
|
|
5
|
+
key: string;
|
|
6
|
+
type: string;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
bytes: number;
|
|
10
|
+
};
|
|
11
|
+
export type PhotoProblem = {
|
|
12
|
+
problem: string;
|
|
13
|
+
status: 413 | 415;
|
|
14
|
+
};
|
|
15
|
+
export declare function photoFacts(buffer: ArrayBuffer): Promise<PhotoFacts | PhotoProblem>;
|
|
3
16
|
export declare function uploadMedia(c: Context<ServerEnv>): Promise<(Response & import("hono").TypedResponse<{
|
|
4
17
|
error: string;
|
|
5
18
|
}, 401, "json">) | (Response & import("hono").TypedResponse<{
|
|
@@ -8,9 +21,9 @@ export declare function uploadMedia(c: Context<ServerEnv>): Promise<(Response &
|
|
|
8
21
|
error: string;
|
|
9
22
|
}, 503, "json">) | (Response & import("hono").TypedResponse<{
|
|
10
23
|
error: string;
|
|
11
|
-
}, 413, "json">) | (Response & import("hono").TypedResponse<{
|
|
12
|
-
error: string;
|
|
13
24
|
}, 400, "json">) | (Response & import("hono").TypedResponse<{
|
|
14
25
|
error: string;
|
|
15
|
-
}, 415, "json">) | (Response & import("hono").TypedResponse<{},
|
|
26
|
+
}, 413 | 415, "json">) | (Response & import("hono").TypedResponse<{}, 200, "json">) | (Response & import("hono").TypedResponse<{
|
|
27
|
+
error: string;
|
|
28
|
+
}, 409, "json">) | (Response & import("hono").TypedResponse<{}, 201, "json">)>;
|
|
16
29
|
export declare function serveMedia(c: Context<ServerEnv>): Promise<Response>;
|