@acms-subir/core 0.3.9 → 0.3.10
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/CHANGELOG.md +33 -0
- package/cli/common.mjs +2 -2
- package/cli/main.mjs +7 -1
- package/cli/mobile-doctor.mjs +9 -0
- package/cli/monitor.mjs +189 -0
- package/docs/mobile.md +23 -2
- package/integration.mjs +35 -4
- package/package.json +1 -1
- package/src/actions/index.ts +6 -26
- package/src/components/AppAccess.tsx +117 -0
- package/src/components/MonitoringSettings.tsx +112 -0
- package/src/layouts/Admin.astro +2 -3
- package/src/lib/api.ts +2 -1
- package/src/lib/backup/content.ts +1 -1
- package/src/lib/booking/availability.ts +15 -2
- package/src/lib/booking/service.ts +53 -1
- package/src/lib/calendar/admin.ts +41 -0
- package/src/lib/editor/me.ts +4 -0
- package/src/lib/editor/openapi.ts +49 -1
- package/src/lib/editor/roles.ts +20 -0
- package/src/lib/editor/schedule.ts +340 -0
- package/src/lib/editor/schemas.ts +36 -1
- package/src/lib/editor/site-data.ts +110 -0
- package/src/lib/email/service.ts +8 -1
- package/src/lib/email/store.ts +10 -0
- package/src/lib/email/templates.ts +6 -0
- package/src/lib/forms/store.ts +5 -0
- package/src/lib/mobile/access-admin.ts +34 -0
- package/src/lib/mobile/access.ts +28 -0
- package/src/lib/mobile/calendar.ts +26 -13
- package/src/lib/mobile/content.ts +21 -1
- package/src/lib/mobile/forms.ts +77 -0
- package/src/lib/mobile/guest-store.ts +62 -0
- package/src/lib/mobile/guest.ts +160 -0
- package/src/lib/mobile/http.ts +2 -2
- package/src/lib/mobile/links.ts +69 -0
- package/src/lib/mobile/manage.ts +114 -0
- package/src/lib/mobile/openapi.ts +40 -4
- package/src/lib/mobile/route.ts +24 -2
- package/src/lib/mobile/schemas.ts +52 -1
- package/src/lib/mobile/search.ts +95 -0
- package/src/lib/mobile/site.ts +8 -2
- package/src/lib/monitoring.ts +171 -0
- package/src/lib/repo/migrations.ts +28 -0
- package/src/lib/repo/sql.ts +66 -20
- package/src/lib/repo/types.ts +19 -2
- package/src/lib/scheduler.ts +7 -0
- package/src/lib/schema.ts +35 -1
- package/src/pages/admin/calendar/resources/link.astro +2 -0
- package/src/pages/admin/mobile.astro +6 -2
- package/src/pages/admin/settings.astro +12 -0
- package/src/pages/api/health.ts +17 -0
- package/src/pages/api/mobile/access.ts +22 -0
- package/src/pages/api/monitoring.ts +22 -0
- package/src/pages/api/ops/status.ts +2 -1
- package/src/pages/api/v1/admin/bookings/[id]/[action].ts +7 -1
- package/src/pages/api/v1/admin/bookings/index.ts +10 -2
- package/src/pages/api/v1/admin/calendars/[id].ts +16 -0
- package/src/pages/api/v1/admin/calendars/index.ts +12 -0
- package/src/pages/api/v1/admin/forms/[id]/responses.ts +7 -0
- package/src/pages/api/v1/admin/forms/index.ts +6 -0
- package/src/pages/api/v1/admin/links.ts +13 -0
- package/src/pages/api/v1/admin/resources/[id]/blocks/[blockId].ts +10 -0
- package/src/pages/api/v1/admin/resources/[id]/blocks/index.ts +12 -0
- package/src/pages/api/v1/admin/resources/[id]/index.ts +17 -0
- package/src/pages/api/v1/admin/resources/[id]/links/[linkId].ts +16 -0
- package/src/pages/api/v1/admin/resources/[id]/links/index.ts +12 -0
- package/src/pages/api/v1/admin/resources/index.ts +12 -0
- package/src/pages/api/v1/admin/schedule.ts +7 -0
- package/src/pages/api/v1/bookings/manage/availability.ts +6 -0
- package/src/pages/api/v1/bookings/manage/cancel.ts +6 -0
- package/src/pages/api/v1/bookings/manage/ics.ts +6 -0
- package/src/pages/api/v1/bookings/manage/index.ts +6 -0
- package/src/pages/api/v1/bookings/manage/reschedule.ts +6 -0
- package/src/pages/api/v1/forms/[id]/index.ts +7 -0
- package/src/pages/api/v1/forms/[id]/responses.ts +6 -0
- package/src/pages/api/v1/guest/bookings.ts +6 -0
- package/src/pages/api/v1/guest/code.ts +6 -0
- package/src/pages/api/v1/guest/revoke.ts +6 -0
- package/src/pages/api/v1/guest/token.ts +6 -0
- package/src/pages/api/v1/links.ts +7 -0
- package/src/pages/api/v1/search.ts +7 -0
- package/src/pages/book/[resource]/[link].astro +3 -3
- package/src/pages/book/manage/[token].astro +11 -7
- package/src/styles/admin.css +66 -0
|
@@ -5,6 +5,10 @@ import { contentQuery, contentDetailQuery } from './content';
|
|
|
5
5
|
import { eventsQuery, availabilityQuery } from './calendar';
|
|
6
6
|
import { bookingInput } from './bookings';
|
|
7
7
|
import { pagesQuery } from './pages';
|
|
8
|
+
import { searchQuery } from './search';
|
|
9
|
+
import { manageAvailabilityQuery, rescheduleInput } from './manage';
|
|
10
|
+
import { formResponseInput } from './forms';
|
|
11
|
+
import { codeInput, tokenInput, myBookingsQuery } from './guest';
|
|
8
12
|
import { deviceInput, deviceUpdate } from '../push/api';
|
|
9
13
|
import { pageQuery, queryParams } from './http';
|
|
10
14
|
import * as schemas from './schemas';
|
|
@@ -24,7 +28,7 @@ const readHeaders = {
|
|
|
24
28
|
};
|
|
25
29
|
const errors = Object.fromEntries([400, 404, 405, 429, 500].map(status => [status, {
|
|
26
30
|
description: status === 429 ? 'Read limit exceeded (300/minute/client address).' : 'Request failed.', content: media(ref('Error')),
|
|
27
|
-
...(status === 429 ? { headers: { 'Retry-After': {
|
|
31
|
+
...(status === 429 ? { headers: { 'Retry-After': { description: 'Seconds to wait: 60 for per-minute limits, longer for hourly, daily and lockout limits.', schema: { type: 'string' } } } } : {}),
|
|
28
32
|
}]));
|
|
29
33
|
function parameters(query: z.AnyZodObject) {
|
|
30
34
|
return Object.entries(query.shape).map(([name, schema]) => ({ name, in: 'query', required: !(schema as z.ZodTypeAny).isOptional(), schema: jsonSchema(schema as z.ZodTypeAny) }));
|
|
@@ -37,13 +41,16 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
37
41
|
['/theme', 'Active Design tokens including overrides', empty, ref('Theme')],
|
|
38
42
|
['/pages/home', 'Published home page and resolved sections', pagesQuery, ref('Page')],
|
|
39
43
|
['/pages/{slug}', 'Published page and resolved sections (slug may contain slashes)', pagesQuery, ref('Page')],
|
|
40
|
-
['/content', 'Published content in the requested language', contentQuery, jsonSchema(z.object({ locale: z.string().nullable(), items: z.array(schemas.ContentSummary), pagination: schemas.Pagination }))],
|
|
44
|
+
['/content', 'Published content in the requested language; q (requires search) returns ranked matches, posts before pages, up to 200 per kind', contentQuery, jsonSchema(z.object({ locale: z.string().nullable(), items: z.array(schemas.ContentSummary), pagination: schemas.Pagination }))],
|
|
41
45
|
['/content/{id}', 'Published content detail', contentDetailQuery, ref('Content')],
|
|
46
|
+
['/search', 'Ranked matches across posts, pages and events (top limit per group); requires search', searchQuery, ref('SearchResults')],
|
|
42
47
|
['/events', 'Public event occurrences; requires calendar', eventsQuery, ref('Events')],
|
|
43
48
|
['/calendars', 'Public calendars; requires calendar', empty, ref('Calendars')],
|
|
44
49
|
['/resources', 'Public resources and booking links; requires bookings', z.object(pageQuery).strict(), ref('Resources')],
|
|
45
50
|
['/availability', 'Current availability; requires bookings', availabilityQuery, ref('Availability'), false],
|
|
46
51
|
['/push/topics', 'Topics an app can offer as notification toggles; requires push', empty, ref('PushTopics')],
|
|
52
|
+
['/links', 'The links page: profile, socials and visible blocks in order (owner switch: links)', empty, ref('Links')],
|
|
53
|
+
['/forms/{id}', 'A published form’s questions; 410 when it stopped accepting responses (owner switch: forms)', empty, ref('Form')],
|
|
47
54
|
['/openapi.json', 'OpenAPI 3.1 document (no v1 envelope)', empty, { type: 'object', required: ['openapi', 'info', 'paths', 'components'] }],
|
|
48
55
|
];
|
|
49
56
|
for (const [path, summary, query, schema, cache = true] of reads) {
|
|
@@ -72,6 +79,35 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
72
79
|
},
|
|
73
80
|
options: { summary: 'Public CORS preflight', responses: { 204: { description: 'No body.' } } },
|
|
74
81
|
};
|
|
82
|
+
// private reads and writes: no-store, credential in a header, never the URL
|
|
83
|
+
const bookingToken = { name: 'Booking-Token', in: 'header', required: true, description: 'The booking’s private token (manageToken from POST /bookings or GET /guest/bookings). Unknown tokens count against the caller: 20 misses in 10 minutes locks the address out for a while.', schema: { type: 'string' } };
|
|
84
|
+
const priv = (summary: string, response: unknown, o: { query?: z.AnyZodObject; body?: z.ZodTypeAny; header?: unknown; guest?: boolean; status?: number; description?: string; write?: boolean } = {}) => ({
|
|
85
|
+
summary, ...(o.description ? { description: o.description } : {}),
|
|
86
|
+
...(o.guest ? { security: [{ guestToken: [] }] } : {}),
|
|
87
|
+
parameters: [...(o.query ? parameters(o.query) : []), ...(o.header ? [o.header] : [])],
|
|
88
|
+
...(o.body ? { requestBody: { required: true, content: media(jsonSchema(o.body)) } } : {}),
|
|
89
|
+
responses: { ...errors, ...(o.write ? Object.fromEntries([409, 410, 413, 415].map(code => [code, { description: 'Request rejected.', content: media(ref('Error')) }])) : {}),
|
|
90
|
+
...(o.guest ? { 401: { description: 'Missing, expired or signed-out guest token: sign in again.', content: media(ref('Error')) } } : {}),
|
|
91
|
+
[o.status ?? 200]: { description: 'Success.', headers: { 'Cache-Control': { schema: { type: 'string', const: 'no-store' } } }, content: media(envelope(response)) } },
|
|
92
|
+
});
|
|
93
|
+
const cors = { options: { summary: 'Public CORS preflight', responses: { 204: { description: 'No body.' } } } };
|
|
94
|
+
const manageNote = 'Requires the calendar and the owner switch bookingChanges. Same rules and emails as the booking’s web page; a booking type’s change cutoff closes guest changes that many hours before the start.';
|
|
95
|
+
paths['/api/v1/bookings/manage'] = { get: priv('The booker’s own booking: status, time, place, what they may still change', ref('ManagedBooking'), { header: bookingToken, description: manageNote }), ...cors };
|
|
96
|
+
paths['/api/v1/bookings/manage/cancel'] = { post: priv('Cancel the booking (repeating is harmless)', ref('ManagedBooking'), { header: bookingToken, write: true, description: manageNote }), ...cors };
|
|
97
|
+
paths['/api/v1/bookings/manage/availability'] = { get: priv('Open times for moving the booking (its own time counts as free)', ref('Availability'), { header: bookingToken, query: manageAvailabilityQuery, description: manageNote }), ...cors };
|
|
98
|
+
paths['/api/v1/bookings/manage/reschedule'] = { post: priv('Move the booking to a new start (room bookings, not RSVPs)', ref('ManagedBooking'), { header: bookingToken, body: rescheduleInput, write: true, description: manageNote }), ...cors };
|
|
99
|
+
paths['/api/v1/bookings/manage/ics'] = { get: { summary: 'Calendar file for the booking (text/calendar, no v1 envelope)', parameters: [bookingToken],
|
|
100
|
+
responses: { ...errors, 200: { description: 'An iCalendar file.', content: { 'text/calendar': { schema: { type: 'string' } } } } } }, ...cors };
|
|
101
|
+
paths['/api/v1/forms/{id}/responses'] = { post: { ...priv('Send a response to a published form', ref('FormReceipt'), { body: formResponseInput, write: true, status: 201,
|
|
102
|
+
description: 'Same validation as the web form. Idempotency-Key (UUID v4) is the response id: retries never duplicate. 10 responses per address per form per minute, plus the owner’s daily ceiling per form (429). version from GET /forms/{id}: a changed form answers 409. Leave website empty (spam trap).' }),
|
|
103
|
+
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', format: 'uuid' } }] }, ...cors };
|
|
104
|
+
const guestNote = 'Guest sign-in needs the calendar, working email, EMAIL_ENCRYPTION_KEY and the owner switch guestSignIn (site.features.guestSignIn); 404 otherwise.';
|
|
105
|
+
paths['/api/v1/guest/code'] = { post: priv('Email a 6-digit sign-in code (always answers the same, whether or not the address has bookings)', ref('GuestCodeSent'), { body: codeInput, write: true, status: 202,
|
|
106
|
+
description: `${guestNote} Codes last 10 minutes; a new request voids the old code. 3 codes per address and 20 per client address per 15 minutes.` }), ...cors };
|
|
107
|
+
paths['/api/v1/guest/token'] = { post: priv('Trade email + code for a guest token for this device', ref('GuestToken'), { body: tokenInput, write: true,
|
|
108
|
+
description: `${guestNote} 5 tries per code; 30 failures per client address in 10 minutes locks it out. Keep the token in secure storage; it ends after 90 days unused.` }), ...cors };
|
|
109
|
+
paths['/api/v1/guest/bookings'] = { get: priv('Bookings and RSVPs made with the signed-in email (upcoming or past)', ref('GuestBookings'), { query: myBookingsQuery, guest: true, description: guestNote }), ...cors };
|
|
110
|
+
paths['/api/v1/guest/revoke'] = { post: priv('Sign this device out', ref('GuestSignedOut'), { guest: true, write: true }), ...cors };
|
|
75
111
|
const writeErrors = { ...errors, 429: { ...errors[429], description: 'Write limit exceeded (20/minute/client address).' },
|
|
76
112
|
...Object.fromEntries([413, 415].map(code => [code, { description: 'Request rejected.', content: media(ref('Error')) }])) };
|
|
77
113
|
const noStore = { 'Cache-Control': { schema: { type: 'string', const: 'no-store' } } };
|
|
@@ -101,11 +137,11 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
101
137
|
};
|
|
102
138
|
Object.assign(paths, editorPaths(jsonSchema));
|
|
103
139
|
return {
|
|
104
|
-
openapi: '3.1.0', info: { title: 'ACMS Mobile API', version: '1.0.0', description: 'Public API: no authentication. Disabled modules are not registered. Unknown or duplicate query parameters are rejected. locale selects translated words with main-language fallback; unknown locales return 400. Editor API (tag Editor): /api/v1/auth/* and /api/v1/admin/*, registered only when acms({ mobile: { editor } }) is configured; staff sign in through the browser (OAuth 2.0 authorization code + PKCE) and send the device access token as a Bearer header. Cookies never authenticate it.' },
|
|
140
|
+
openapi: '3.1.0', info: { title: 'ACMS Mobile API', version: '1.0.0', description: 'Public API: no authentication, except a booking’s own routes (/bookings/manage/*: its private token in the Booking-Token header) and guest sign-in (/guest/*: a guest token from an emailed code). Owner switches in Admin → Mobile app turn app features off (404). Disabled modules are not registered. Unknown or duplicate query parameters are rejected. locale selects translated words with main-language fallback; unknown locales return 400. Editor API (tag Editor): /api/v1/auth/* and /api/v1/admin/*, registered only when acms({ mobile: { editor } }) is configured; staff sign in through the browser (OAuth 2.0 authorization code + PKCE) and send the device access token as a Bearer header. Cookies never authenticate it.' },
|
|
105
141
|
tags: [{ name: 'Public', description: 'Visitor apps: no authentication.' }, { name: 'Editor', description: 'Editor apps: device sign-in and the admin API.' }],
|
|
106
142
|
paths, components: {
|
|
107
143
|
schemas: Object.fromEntries([...Object.entries(schemas), ...Object.entries(editorSchemas)].map(([name, schema]) => [name, jsonSchema(schema as z.ZodTypeAny)])),
|
|
108
|
-
securitySchemes: editorSecuritySchemes,
|
|
144
|
+
securitySchemes: { ...editorSecuritySchemes, guestToken: { type: 'http', scheme: 'bearer', bearerFormat: 'acms_guest_…', description: 'Guest token from POST /api/v1/guest/token. Reads that email’s bookings only.' } },
|
|
109
145
|
},
|
|
110
146
|
};
|
|
111
147
|
}
|
package/src/lib/mobile/route.ts
CHANGED
|
@@ -2,14 +2,14 @@ import type { APIRoute } from 'astro';
|
|
|
2
2
|
import type { ContentRepository } from '../repo/types';
|
|
3
3
|
import { getRepo } from '../repo';
|
|
4
4
|
import { limitRequest, failure, publicResponse, response } from './http';
|
|
5
|
-
export function readRoute(load: (repo: ContentRepository, url: URL, id: string) => Promise<unknown>, cache = true, raw = false): APIRoute {
|
|
5
|
+
export function readRoute(load: (repo: ContentRepository, url: URL, id: string, locals: unknown) => Promise<unknown>, cache = true, raw = false): APIRoute {
|
|
6
6
|
return async context => {
|
|
7
7
|
try {
|
|
8
8
|
const repo = await getRepo(context.locals);
|
|
9
9
|
let address = 'unknown';
|
|
10
10
|
try { address = context.clientAddress || address; } catch { /* adapters without a client address share a bucket */ }
|
|
11
11
|
await limitRequest(repo, address);
|
|
12
|
-
const data = await load(repo, context.url, context.params.id ?? context.params.slug ?? '');
|
|
12
|
+
const data = await load(repo, context.url, context.params.id ?? context.params.slug ?? '', context.locals);
|
|
13
13
|
const res = cache ? await publicResponse(context.request, data, raw) : response(data);
|
|
14
14
|
return context.request.method === 'HEAD' ? new Response(null, { status: res.status, headers: res.headers }) : res;
|
|
15
15
|
} catch (error) {
|
|
@@ -18,3 +18,25 @@ export function readRoute(load: (repo: ContentRepository, url: URL, id: string)
|
|
|
18
18
|
}
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
export interface PublicContext { repo: ContentRepository; request: Request; url: URL; address: string; params: Record<string, string | undefined>; locals: unknown }
|
|
23
|
+
/**
|
|
24
|
+
* A private or write route on the public API: rate limited per client
|
|
25
|
+
* address (reads 300/min, writes 20/min), never cached, v1 envelope.
|
|
26
|
+
* Handlers may return a Response (files, custom status).
|
|
27
|
+
*/
|
|
28
|
+
export function privateRoute(handler: (c: PublicContext) => Promise<unknown>, opts: { write?: boolean; status?: number } = {}): APIRoute {
|
|
29
|
+
return async context => {
|
|
30
|
+
try {
|
|
31
|
+
const repo = await getRepo(context.locals);
|
|
32
|
+
let address = 'unknown';
|
|
33
|
+
try { address = context.clientAddress || address; } catch { /* adapters without a client address share a bucket */ }
|
|
34
|
+
await limitRequest(repo, address, opts.write);
|
|
35
|
+
const data = await handler({ repo, request: context.request, url: context.url, address, params: context.params, locals: context.locals });
|
|
36
|
+
if (data instanceof Response) return data;
|
|
37
|
+
return response(data, opts.status ?? 200);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return failure(error);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -8,7 +8,7 @@ const Palette = z.object({ bg: str, ink: str, muted: str, line: str, accent: str
|
|
|
8
8
|
export const Site = z.object({
|
|
9
9
|
name: str, description: str, logo: str, logoDark: str, icon: str, menu: z.array(z.object({ label: str, href: str })),
|
|
10
10
|
languages: z.object({ default: locale, locales: z.array(z.object({ code: str, name: str })) }),
|
|
11
|
-
features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str })), calendar: bool, bookings: bool, search: bool }), apiVersion: z.literal(1),
|
|
11
|
+
features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str })), calendar: bool, bookings: bool, search: bool, bookingChanges: bool, guestSignIn: bool, forms: bool, links: bool }), apiVersion: z.literal(1),
|
|
12
12
|
});
|
|
13
13
|
export const Theme = z.object({
|
|
14
14
|
id: str, name: str, scheme: z.enum(['auto', 'light', 'dark']), colors: z.object({ light: Palette, dark: Palette }),
|
|
@@ -35,6 +35,57 @@ export const Event = CalEvent.pick({
|
|
|
35
35
|
export const Events = z.object({ locale, timezone: str, from: date, to: date, pagination: Pagination,
|
|
36
36
|
occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, event: Event })),
|
|
37
37
|
});
|
|
38
|
+
const Snippet = z.object({ text: str, highlights: z.array(z.object({ start: z.number().int(), end: z.number().int() })) });
|
|
39
|
+
const ContentHit = ContentSummary.extend({ snippet: Snippet });
|
|
40
|
+
export const SearchResults = z.object({ locale, query: str,
|
|
41
|
+
posts: z.array(ContentHit), pages: z.array(ContentHit),
|
|
42
|
+
events: z.array(z.object({ snippet: Snippet, occurrence: z.object({ start: date, end: date, allDay: bool }).nullable(), event: Event })),
|
|
43
|
+
more: z.object({ posts: bool, pages: bool, events: bool }),
|
|
44
|
+
});
|
|
45
|
+
const Icon = z.object({ type: z.enum(['svg', 'image', 'emoji']), value: str }).nullable();
|
|
46
|
+
const ButtonStyle = { color: str.nullable(), textColor: str.nullable() };
|
|
47
|
+
export const Links = z.object({
|
|
48
|
+
name: str, bio: str, avatar: str, url: str,
|
|
49
|
+
socials: z.array(z.object({ label: str, url: str, iconPath: str.nullable() })),
|
|
50
|
+
items: z.array(z.discriminatedUnion('kind', [
|
|
51
|
+
z.object({ id: str, kind: z.literal('link'), label: str, url: str, destination: str, icon: Icon, ...ButtonStyle }),
|
|
52
|
+
z.object({ id: str, kind: z.literal('email'), label: str, url: str, icon: Icon, ...ButtonStyle }),
|
|
53
|
+
z.object({ id: str, kind: z.literal('phone'), label: str, url: str, icon: Icon, ...ButtonStyle }),
|
|
54
|
+
z.object({ id: str, kind: z.literal('contact'), label: str, icon: Icon, ...ButtonStyle, contact: z.object({ firstName: str, lastName: str, organization: str, phone: str, email: str, url: str }) }),
|
|
55
|
+
z.object({ id: str, kind: z.literal('heading'), label: str }),
|
|
56
|
+
z.object({ id: str, kind: z.literal('text'), text: str }),
|
|
57
|
+
z.object({ id: str, kind: z.literal('spacer'), size: z.enum(['s', 'm', 'l']) }),
|
|
58
|
+
z.object({ id: str, kind: z.literal('live') }),
|
|
59
|
+
z.object({ id: str, kind: z.literal('embed'), url: str }),
|
|
60
|
+
])),
|
|
61
|
+
});
|
|
62
|
+
export const Form = z.object({
|
|
63
|
+
id: str, version: str, title: str, intro: str, submitLabel: str, confirmation: str,
|
|
64
|
+
fields: z.array(z.object({
|
|
65
|
+
id: str, type: z.enum(['text', 'textarea', 'email', 'phone', 'number', 'select', 'checkbox', 'date', 'radio', 'checkboxes', 'url', 'consent', 'heading']),
|
|
66
|
+
label: str, required: bool, help: str, options: strings, placeholder: str, width: z.enum(['full', 'half']), allowOther: bool,
|
|
67
|
+
})),
|
|
68
|
+
});
|
|
69
|
+
export const FormReceipt = z.object({ received: z.literal(true), confirmation: str });
|
|
70
|
+
const GuestChanges = z.object({ cancel: bool, reschedule: bool, cutoffHours: z.number().int(), closed: bool });
|
|
71
|
+
export const ManagedBooking = z.object({
|
|
72
|
+
booking: z.object({
|
|
73
|
+
id: str, reference: str, kind: z.enum(['booking', 'rsvp']), status: z.enum(['pending', 'confirmed', 'declined', 'cancelled']),
|
|
74
|
+
start: date, end: date, allDay: bool, timezone: str, seats: z.number().int(), name: str, email: str, phone: str, answers: z.record(str), createdAt: date,
|
|
75
|
+
}),
|
|
76
|
+
title: str, location: str,
|
|
77
|
+
resource: z.object({ slug: str, name: str }).nullable(),
|
|
78
|
+
link: z.object({ slug: str, name: str, mode: z.enum(['slots', 'range', 'day']), lengths: z.array(z.number()) }).nullable(),
|
|
79
|
+
event: z.object({ slug: str, title: str, url: str }).nullable(),
|
|
80
|
+
manageUrl: str, can: GuestChanges,
|
|
81
|
+
});
|
|
82
|
+
export const GuestCodeSent = z.object({ sent: z.literal(true), expiresInMinutes: z.number().int() });
|
|
83
|
+
export const GuestToken = z.object({ accessToken: str, tokenType: z.literal('Bearer'), email: str, idleDays: z.number().int() });
|
|
84
|
+
export const GuestBookings = z.object({ email: str, pagination: Pagination, items: z.array(z.object({
|
|
85
|
+
id: str, reference: str, kind: z.enum(['booking', 'rsvp']), status: z.enum(['pending', 'confirmed', 'declined', 'cancelled']),
|
|
86
|
+
start: date, end: date, seats: z.number().int(), title: str, manageToken: str, manageUrl: str, can: GuestChanges,
|
|
87
|
+
})) });
|
|
88
|
+
export const GuestSignedOut = z.object({ signedOut: z.literal(true) });
|
|
38
89
|
export const Calendars = z.object({ calendars: z.array(Calendar.pick({ id: true, slug: true, name: true, color: true, description: true, kind: true, visibility: true }).extend({ feedUrl: str })) });
|
|
39
90
|
export const Resources = z.object({ pagination: Pagination, resources: z.array(z.object({
|
|
40
91
|
id: str, slug: str, name: str, kind: str, description: str, capacity: z.number(), amenities: strings, photo: str, timezone: str, url: str,
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ContentRepository } from '../repo/types';
|
|
3
|
+
import { CONTENT } from '../content';
|
|
4
|
+
import { normalizePageLocale } from '../i18n';
|
|
5
|
+
import { siteOrigin } from '../seo';
|
|
6
|
+
import { SEARCH } from '../search-config';
|
|
7
|
+
import { CALENDAR } from '../calendar/config';
|
|
8
|
+
import { eventIsListed, featuredOccurrence } from '../calendar/events';
|
|
9
|
+
import { localizeDoc } from '../translations';
|
|
10
|
+
import { ApiError } from '../api';
|
|
11
|
+
import { contentDto } from './content';
|
|
12
|
+
import { publicEventDto } from './calendar';
|
|
13
|
+
import { queryParams } from './http';
|
|
14
|
+
import { localeQuery, mobileLocale } from './locale';
|
|
15
|
+
|
|
16
|
+
// One query across posts, pages and events for an app's search screen: the
|
|
17
|
+
// same FTS index and ranking as the website's search box. Each group holds
|
|
18
|
+
// the top `limit` matches; `more` says a group has further matches, which
|
|
19
|
+
// apps page through with /content?q= (or /events?q=, date-ordered in a window,
|
|
20
|
+
// matching the same index plus title/excerpt/location).
|
|
21
|
+
|
|
22
|
+
const TYPES = ['post', 'page', 'event'] as const;
|
|
23
|
+
export const searchQuery = z.object({
|
|
24
|
+
q: z.string().trim().min(1).max(100),
|
|
25
|
+
types: z.string().max(40).optional()
|
|
26
|
+
.transform((v, ctx) => {
|
|
27
|
+
const list = v ? v.split(',').map((t) => t.trim()).filter(Boolean) : [...TYPES];
|
|
28
|
+
const bad = list.find((t) => !(TYPES as readonly string[]).includes(t));
|
|
29
|
+
if (bad) { ctx.addIssue({ code: 'custom', message: 'Use a comma-separated list of post, page and event.' }); return z.NEVER; }
|
|
30
|
+
return list as (typeof TYPES)[number][];
|
|
31
|
+
}),
|
|
32
|
+
collection: z.string().min(1).max(80).optional(),
|
|
33
|
+
limit: z.coerce.number().int().min(1).max(20).default(5),
|
|
34
|
+
locale: localeQuery,
|
|
35
|
+
}).strict();
|
|
36
|
+
|
|
37
|
+
const MARK = /<mark>(.*?)<\/mark>/g;
|
|
38
|
+
const decode = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
|
39
|
+
/** The repo's safe-HTML snippet as plain text plus the matched ranges. */
|
|
40
|
+
export function plainSnippet(html: string) {
|
|
41
|
+
let text = '';
|
|
42
|
+
const highlights: { start: number; end: number }[] = [];
|
|
43
|
+
let last = 0;
|
|
44
|
+
for (const m of html.matchAll(MARK)) {
|
|
45
|
+
text += decode(html.slice(last, m.index));
|
|
46
|
+
const start = text.length;
|
|
47
|
+
text += decode(m[1]);
|
|
48
|
+
highlights.push({ start, end: text.length });
|
|
49
|
+
last = m.index! + m[0].length;
|
|
50
|
+
}
|
|
51
|
+
return { text: text + decode(html.slice(last)), highlights };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function search(repo: ContentRepository, url: URL) {
|
|
55
|
+
if (!SEARCH.enabled) throw new ApiError(404, 'This feature is disabled.');
|
|
56
|
+
const q = searchQuery.parse(queryParams(url));
|
|
57
|
+
if (q.collection && !Object.hasOwn(CONTENT.collections, q.collection)) throw new ApiError(400, 'Unknown collection.');
|
|
58
|
+
const locale = mobileLocale(q.locale);
|
|
59
|
+
const [found, settings, calendars] = await Promise.all([
|
|
60
|
+
repo.searchPublished(q.q, { locale: normalizePageLocale(locale ?? ''), limit: q.limit + 1, collections: q.collection ? [q.collection] : Object.keys(CONTENT.collections) }),
|
|
61
|
+
repo.getSettings(), repo.listCalendars(),
|
|
62
|
+
]);
|
|
63
|
+
const origin = siteOrigin(settings, url);
|
|
64
|
+
const want = new Set(q.types);
|
|
65
|
+
const posts = want.has('post') ? found.posts : [];
|
|
66
|
+
// a collection narrows the search to that collection's posts
|
|
67
|
+
const pages = want.has('page') && !q.collection ? found.pages : [];
|
|
68
|
+
const all = new Map(calendars.map((c) => [c.id, c]));
|
|
69
|
+
const listed = new Map(calendars.filter((c) => c.visibility === 'public').map((c) => [c.id, c]));
|
|
70
|
+
let events = want.has('event') && CALENDAR.enabled && !q.collection ? found.events.filter(({ doc }) => eventIsListed(doc, all)) : [];
|
|
71
|
+
// the index ranks before visibility is known: if unlisted events filled the
|
|
72
|
+
// first batch, look deeper once (the repo's 200-row cap) for listed ones
|
|
73
|
+
if (want.has('event') && CALENDAR.enabled && !q.collection && events.length <= q.limit && found.events.length >= q.limit + 1) {
|
|
74
|
+
const deeper = await repo.searchPublished(q.q, { locale: normalizePageLocale(locale ?? ''), limit: 200, collections: [] });
|
|
75
|
+
events = deeper.events.filter(({ doc }) => eventIsListed(doc, all));
|
|
76
|
+
}
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
const iso = (n: number) => new Date(n).toISOString();
|
|
79
|
+
return {
|
|
80
|
+
locale, query: q.q,
|
|
81
|
+
posts: posts.slice(0, q.limit).map(({ doc, snippet }) => ({ ...contentDto(doc, 'post', origin, false, locale), snippet: plainSnippet(snippet) })),
|
|
82
|
+
pages: pages.slice(0, q.limit).map(({ doc, snippet }) => ({ ...contentDto(doc, 'page', origin, false, locale), snippet: plainSnippet(snippet) })),
|
|
83
|
+
events: events.slice(0, q.limit).map(({ doc, snippet }) => {
|
|
84
|
+
const e = localizeDoc(doc, locale ?? undefined);
|
|
85
|
+
const o = featuredOccurrence(e, settings, now);
|
|
86
|
+
return {
|
|
87
|
+
snippet: plainSnippet(snippet),
|
|
88
|
+
// the next occurrence, or the latest one when the event is over
|
|
89
|
+
occurrence: o ? { start: iso(o.start), end: iso(o.end), allDay: o.allDay } : null,
|
|
90
|
+
event: publicEventDto(e, origin, listed, settings.timezone, locale),
|
|
91
|
+
};
|
|
92
|
+
}),
|
|
93
|
+
more: { posts: posts.length > q.limit, pages: pages.length > q.limit, events: events.length > q.limit },
|
|
94
|
+
};
|
|
95
|
+
}
|
package/src/lib/mobile/site.ts
CHANGED
|
@@ -9,8 +9,9 @@ import { resolveDesign } from '../theme';
|
|
|
9
9
|
import { darkPalette } from '../themeTokens';
|
|
10
10
|
import { safeUrl } from './content';
|
|
11
11
|
import { queryParams } from './http';
|
|
12
|
+
import { guestSignInReady } from './guest';
|
|
12
13
|
|
|
13
|
-
export async function getSite(repo: ContentRepository, url: URL) {
|
|
14
|
+
export async function getSite(repo: ContentRepository, url: URL, _id = '', locals?: unknown) {
|
|
14
15
|
z.object({}).strict().parse(queryParams(url));
|
|
15
16
|
const [settings, nav, pages] = await Promise.all([repo.getSettings(), repo.listNavItems(), repo.listPages()]);
|
|
16
17
|
const origin = siteOrigin(settings, url);
|
|
@@ -26,7 +27,12 @@ export async function getSite(repo: ContentRepository, url: URL) {
|
|
|
26
27
|
logo: safeUrl(settings.siteLogo, origin), logoDark: safeUrl(settings.siteLogoDark, origin), icon: safeUrl(settings.siteFavicon, origin),
|
|
27
28
|
menu: menu.map(n => ({ label: n.label, href: safeUrl(n.url, origin, true) })),
|
|
28
29
|
languages: { default: I18N.defaultLocale || null, locales: I18N.locales.map(code => ({ code, name: localeName(code, true) })) },
|
|
29
|
-
features: {
|
|
30
|
+
features: {
|
|
31
|
+
blog: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, name: c.plural, url: safeUrl(c.base, origin) })), calendar: CALENDAR.enabled, bookings: bookingsEnabled(), search: SEARCH.enabled,
|
|
32
|
+
// Admin → Mobile app → App access
|
|
33
|
+
bookingChanges: CALENDAR.enabled && settings.mobileAccess.bookingChanges, guestSignIn: await guestSignInReady(repo, locals),
|
|
34
|
+
forms: settings.mobileAccess.forms, links: settings.mobileAccess.links,
|
|
35
|
+
},
|
|
30
36
|
apiVersion: 1,
|
|
31
37
|
};
|
|
32
38
|
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { ContentRepository } from './repo/types';
|
|
2
|
+
import type { Monitoring } from './schema';
|
|
3
|
+
import { resolveConfig, runtimeValue } from './email/config';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { ApiError } from './api';
|
|
6
|
+
import { fetchPublicUrl } from './net';
|
|
7
|
+
|
|
8
|
+
// Uptime monitoring with Uptime Kuma: a public health check Kuma polls
|
|
9
|
+
// (GET /api/health), a heartbeat the scheduler pushes to a Kuma push monitor
|
|
10
|
+
// after every run, and a reader for a Kuma status page (Settings → Monitoring).
|
|
11
|
+
|
|
12
|
+
/** the scheduler runs every minute; three missed runs means it stopped */
|
|
13
|
+
export const SCHEDULER_STALE_MS = 3 * 60_000;
|
|
14
|
+
/** queued mail this far past its send time means the queue is stuck */
|
|
15
|
+
const EMAIL_STUCK_MS = 15 * 60_000;
|
|
16
|
+
const CACHE_MS = 10_000;
|
|
17
|
+
|
|
18
|
+
export type CheckState = 'ok' | 'down' | 'stalled' | 'stuck' | 'off';
|
|
19
|
+
export interface HealthReport {
|
|
20
|
+
/** down: the site can't do its job (503); degraded: email is stuck (200) */
|
|
21
|
+
status: 'ok' | 'degraded' | 'down';
|
|
22
|
+
checks: { database: CheckState; scheduler: CheckState; email: CheckState };
|
|
23
|
+
checkedAt: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let cached: { at: number; report: HealthReport } | null = null;
|
|
27
|
+
/** test hook */
|
|
28
|
+
export const resetHealthCache = () => { cached = null; };
|
|
29
|
+
|
|
30
|
+
export async function healthReport(repo: ContentRepository, locals?: unknown, now = Date.now()): Promise<HealthReport> {
|
|
31
|
+
if (cached && now - cached.at < CACHE_MS) return cached.report;
|
|
32
|
+
const checks: HealthReport['checks'] = { database: 'down', scheduler: 'stalled', email: 'off' };
|
|
33
|
+
try {
|
|
34
|
+
await repo.getSettings();
|
|
35
|
+
checks.database = 'ok';
|
|
36
|
+
const run = await repo.email.schedulerStatus();
|
|
37
|
+
checks.scheduler = run && now - run.finishedAt < SCHEDULER_STALE_MS ? 'ok' : 'stalled';
|
|
38
|
+
const email = resolveConfig(await repo.email.config(), locals);
|
|
39
|
+
if (!email.error && email.provider !== 'none' && email.config.enabled) checks.email = await repo.email.stuckJobs(now - EMAIL_STUCK_MS) > 0 ? 'stuck' : 'ok';
|
|
40
|
+
} catch { /* whatever didn't answer stays failed */ }
|
|
41
|
+
const status = checks.database !== 'ok' || checks.scheduler !== 'ok' ? 'down' : checks.email === 'stuck' ? 'degraded' : 'ok';
|
|
42
|
+
const report: HealthReport = { status, checks, checkedAt: new Date(now).toISOString() };
|
|
43
|
+
cached = { at: now, report };
|
|
44
|
+
return report;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---- scheduler heartbeat ----------------------------------------------------
|
|
48
|
+
|
|
49
|
+
const PUSH = /^https?:\/\/[^/?#\s]+(?:\/[^?#\s]*)?\/api\/push\/[A-Za-z0-9_-]{4,100}$/;
|
|
50
|
+
/** a Kuma push URL without its query (Kuma shows "?status=up&msg=OK&ping=" after it) */
|
|
51
|
+
export function normalizePushUrl(raw: string): string | null {
|
|
52
|
+
const url = raw.trim().split('?')[0].replace(/\/+$/, '');
|
|
53
|
+
return url === '' ? '' : PUSH.test(url) ? url : null;
|
|
54
|
+
}
|
|
55
|
+
/** UPTIME_PUSH_URL wins over the admin setting */
|
|
56
|
+
export function pushUrlFor(monitoring: Partial<Monitoring> | undefined, locals?: unknown) {
|
|
57
|
+
return normalizePushUrl(runtimeValue('UPTIME_PUSH_URL', locals)) || normalizePushUrl(monitoring?.pushUrl ?? '') || '';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Tells the Kuma push monitor the scheduler ran (status=down names the
|
|
62
|
+
* failed jobs). Never throws and gives up after 5 s: a slow or missing Kuma
|
|
63
|
+
* must not affect the site.
|
|
64
|
+
*/
|
|
65
|
+
export async function pushHeartbeat(url: string, report: { ok: boolean; startedAt: number; finishedAt: number; jobs: Record<string, { ok: boolean }> }, message?: string) {
|
|
66
|
+
if (!url) return false;
|
|
67
|
+
const failed = Object.entries(report.jobs).filter(([, j]) => !j.ok).map(([name]) => name);
|
|
68
|
+
const q = new URLSearchParams({
|
|
69
|
+
status: report.ok ? 'up' : 'down',
|
|
70
|
+
msg: message ?? (report.ok ? `${Object.keys(report.jobs).length} jobs ok` : `failed: ${failed.join(', ')}`),
|
|
71
|
+
ping: String(Math.max(0, report.finishedAt - report.startedAt)),
|
|
72
|
+
});
|
|
73
|
+
try {
|
|
74
|
+
const res = await fetch(`${url}?${q}`, { signal: AbortSignal.timeout(5000), headers: { 'User-Agent': 'acms-scheduler' } });
|
|
75
|
+
const body = await res.json().catch(() => null) as { ok?: boolean } | null;
|
|
76
|
+
return res.ok && body?.ok !== false;
|
|
77
|
+
} catch { return false; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---- status page (admin card) -----------------------------------------------
|
|
81
|
+
|
|
82
|
+
/** https://kuma.example/status/my-site → { origin, slug } */
|
|
83
|
+
export function parseStatusPage(raw: string): { origin: string; slug: string } | null {
|
|
84
|
+
try {
|
|
85
|
+
const u = new URL(raw.trim());
|
|
86
|
+
const m = /^\/status\/([A-Za-z0-9_-]{1,100})\/?$/.exec(u.pathname);
|
|
87
|
+
return /^https?:$/.test(u.protocol) && m ? { origin: u.origin, slug: m[1] } : null;
|
|
88
|
+
} catch { return null; }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface MonitorStatus { id: number; name: string; status: 'up' | 'down' | 'pending' | 'maintenance' | 'unknown'; since: string | null; uptime24h: number | null; message: string; recent: ('up' | 'down' | 'pending' | 'maintenance')[] }
|
|
92
|
+
const STATES = ['down', 'up', 'pending', 'maintenance'] as const;
|
|
93
|
+
// Kuma writes "2026-10-03 12:00:00" in UTC without a zone
|
|
94
|
+
const iso = (t: string) => { const n = Date.parse(/[zZ]|[+-]\d\d:?\d\d$/.test(t) ? t : `${t.replace(' ', 'T')}Z`); return Number.isFinite(n) ? new Date(n).toISOString() : null; };
|
|
95
|
+
let pageCache: { key: string; at: number; data: MonitorStatus[] } | null = null;
|
|
96
|
+
|
|
97
|
+
/** the monitors on a public Kuma status page with their latest beats (cached 60 s) */
|
|
98
|
+
export async function readStatusPage(raw: string, now = Date.now()): Promise<MonitorStatus[] | null> {
|
|
99
|
+
const page = parseStatusPage(raw);
|
|
100
|
+
if (!page) return null;
|
|
101
|
+
if (pageCache && pageCache.key === raw && now - pageCache.at < 60_000) return pageCache.data;
|
|
102
|
+
const get = async (path: string) => {
|
|
103
|
+
const r = await fetchPublicUrl(`${page.origin}${path}`, { accept: 'application/json', userAgent: 'acms-monitoring', timeoutMs: 6000 });
|
|
104
|
+
return r && r.res.ok ? await r.res.json().catch(() => null) : null;
|
|
105
|
+
};
|
|
106
|
+
const [info, beats] = await Promise.all([get(`/api/status-page/${page.slug}`), get(`/api/status-page/heartbeat/${page.slug}`)]);
|
|
107
|
+
if (!info || !beats) return null;
|
|
108
|
+
const monitors = ((info.publicGroupList ?? []) as { monitorList?: { id: number; name: string }[] }[]).flatMap((g) => g.monitorList ?? []);
|
|
109
|
+
const data = monitors.map((m): MonitorStatus => {
|
|
110
|
+
const list = ((beats.heartbeatList?.[m.id] ?? []) as { status: number; time: string; msg?: string }[]).slice(-30);
|
|
111
|
+
const last = list[list.length - 1];
|
|
112
|
+
const state = (n: number) => STATES[n] ?? 'pending';
|
|
113
|
+
// when the current state began, if the change is among the recent beats
|
|
114
|
+
// (all beats alike: it began before them, so the duration is unknown)
|
|
115
|
+
let since: string | null = null;
|
|
116
|
+
if (last) { let i = list.length - 1; while (i > 0 && list[i - 1].status === last.status) i--; if (i > 0) since = iso(list[i].time); }
|
|
117
|
+
const uptime = beats.uptimeList?.[`${m.id}_24`];
|
|
118
|
+
return {
|
|
119
|
+
id: m.id, name: m.name, status: last ? state(last.status) : 'unknown', since,
|
|
120
|
+
uptime24h: typeof uptime === 'number' ? uptime : null, message: last?.msg ?? '', recent: list.map((b) => state(b.status)),
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
pageCache = { key: raw, at: now, data };
|
|
124
|
+
return data;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- Settings → Monitoring (owner) --------------------------------------------
|
|
128
|
+
|
|
129
|
+
/** "https://kuma.example/api/push/Xk29…": enough to recognise, not to use */
|
|
130
|
+
const masked = (url: string) => (url ? url.replace(/(\/api\/push\/)(.{4}).*$/, '$1$2…') : '');
|
|
131
|
+
export async function monitoringState(repo: ContentRepository, locals?: unknown) {
|
|
132
|
+
const settings = await repo.getSettings();
|
|
133
|
+
const m = settings.monitoring;
|
|
134
|
+
const fromEnv = !!normalizePushUrl(runtimeValue('UPTIME_PUSH_URL', locals));
|
|
135
|
+
const run = await repo.email.schedulerStatus();
|
|
136
|
+
const monitors = m.statusPage ? await readStatusPage(m.statusPage) : null;
|
|
137
|
+
return {
|
|
138
|
+
pushUrl: masked(pushUrlFor(m, locals)), pushFromEnv: fromEnv, statusPage: m.statusPage,
|
|
139
|
+
// null with a statusPage set: Kuma didn't answer or the page doesn't exist
|
|
140
|
+
monitors, lastRun: run ? new Date(run.finishedAt).toISOString() : null,
|
|
141
|
+
health: await healthReport(repo, locals),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
export type MonitoringState = Awaited<ReturnType<typeof monitoringState>>;
|
|
145
|
+
|
|
146
|
+
export const MonitoringInput = z.discriminatedUnion('action', [
|
|
147
|
+
z.object({ action: z.literal('save'), pushUrl: z.string().max(500).optional(), statusPage: z.string().max(500).optional() }).strict(),
|
|
148
|
+
z.object({ action: z.literal('test') }).strict(),
|
|
149
|
+
]);
|
|
150
|
+
export async function monitoringAction(repo: ContentRepository, input: z.infer<typeof MonitoringInput>, locals?: unknown) {
|
|
151
|
+
const settings = await repo.getSettings();
|
|
152
|
+
if (input.action === 'test') {
|
|
153
|
+
const url = pushUrlFor(settings.monitoring, locals);
|
|
154
|
+
if (!url) throw new ApiError(400, 'Add the push URL first.');
|
|
155
|
+
const now = Date.now();
|
|
156
|
+
if (!await pushHeartbeat(url, { ok: true, startedAt: now, finishedAt: now, jobs: {} }, 'Test from ACMS admin')) throw new ApiError(502, 'Kuma didn’t accept the test. Check the push URL and that the monitor is active.');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const next = { ...settings.monitoring };
|
|
160
|
+
if (input.pushUrl !== undefined) {
|
|
161
|
+
const url = normalizePushUrl(input.pushUrl);
|
|
162
|
+
if (url === null) throw new ApiError(400, 'Paste the push URL from the Kuma push monitor (…/api/push/…).');
|
|
163
|
+
next.pushUrl = url;
|
|
164
|
+
}
|
|
165
|
+
if (input.statusPage !== undefined) {
|
|
166
|
+
const page = input.statusPage.trim();
|
|
167
|
+
if (page && !parseStatusPage(page)) throw new ApiError(400, 'Paste the status page address (…/status/your-page).');
|
|
168
|
+
next.statusPage = page;
|
|
169
|
+
}
|
|
170
|
+
await repo.updateSettings({ monitoring: next });
|
|
171
|
+
}
|
|
@@ -526,6 +526,34 @@ export const MIGRATIONS: Migration[] = [
|
|
|
526
526
|
`CREATE TABLE form_submissions (id TEXT PRIMARY KEY, form_id TEXT NOT NULL, response TEXT NOT NULL, created_at INTEGER NOT NULL)`,
|
|
527
527
|
`CREATE INDEX form_submissions_form_created ON form_submissions(form_id, created_at)`,
|
|
528
528
|
] },
|
|
529
|
+
// app access: owner switches + limits, per-link guest change cutoff, and
|
|
530
|
+
// guest sign-in (emailed codes → per-device tokens; secrets stored hashed)
|
|
531
|
+
{ version: 45, statements: [
|
|
532
|
+
`ALTER TABLE settings ADD COLUMN mobile_access TEXT NOT NULL DEFAULT '{}'`,
|
|
533
|
+
`ALTER TABLE booking_links ADD COLUMN change_cutoff INTEGER NOT NULL DEFAULT 0`,
|
|
534
|
+
`CREATE TABLE guest_codes (email TEXT PRIMARY KEY, code_hash TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL)`,
|
|
535
|
+
`CREATE TABLE guest_sessions (id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, email TEXT NOT NULL, device_name TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL)`,
|
|
536
|
+
`CREATE INDEX guest_sessions_email ON guest_sessions(email)`,
|
|
537
|
+
`CREATE INDEX bookings_email ON bookings(email)`,
|
|
538
|
+
] },
|
|
539
|
+
// links page revision for whole-page saves (staff app): triggers bump it on
|
|
540
|
+
// every content change, whichever path made it (web admin, API, restore);
|
|
541
|
+
// click counts don't. revision_checks makes a stale save fail inside its
|
|
542
|
+
// own batch (CHECK fails → the whole batch rolls back).
|
|
543
|
+
{ version: 46, statements: [
|
|
544
|
+
`ALTER TABLE settings ADD COLUMN links_revision INTEGER NOT NULL DEFAULT 0`,
|
|
545
|
+
`CREATE TABLE revision_checks (expected INTEGER NOT NULL, actual INTEGER NOT NULL CHECK (expected = actual))`,
|
|
546
|
+
`CREATE TRIGGER links_revision_insert AFTER INSERT ON links BEGIN UPDATE settings SET links_revision = links_revision + 1 WHERE id = 1; END`,
|
|
547
|
+
`CREATE TRIGGER links_revision_delete AFTER DELETE ON links BEGIN UPDATE settings SET links_revision = links_revision + 1 WHERE id = 1; END`,
|
|
548
|
+
`CREATE TRIGGER links_revision_update AFTER UPDATE OF kind, label, url, icon, html, meta, ord ON links BEGIN UPDATE settings SET links_revision = links_revision + 1 WHERE id = 1; END`,
|
|
549
|
+
`CREATE TRIGGER links_revision_profile AFTER UPDATE OF links_name, links_bio, links_avatar ON settings
|
|
550
|
+
WHEN OLD.links_name IS NOT NEW.links_name OR OLD.links_bio IS NOT NEW.links_bio OR OLD.links_avatar IS NOT NEW.links_avatar
|
|
551
|
+
BEGIN UPDATE settings SET links_revision = links_revision + 1 WHERE id = 1; END`,
|
|
552
|
+
] },
|
|
553
|
+
// Settings → Monitoring: Uptime Kuma push URL + status page
|
|
554
|
+
{ version: 47, statements: [
|
|
555
|
+
`ALTER TABLE settings ADD COLUMN monitoring TEXT NOT NULL DEFAULT '{}'`,
|
|
556
|
+
] },
|
|
529
557
|
];
|
|
530
558
|
|
|
531
559
|
export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|