@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.10
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Apps can now do most of what the website does, with protection against abuse built in.
|
|
8
|
+
|
|
9
|
+
- **Manage a booking from the app.** People can see, cancel, move or add to their calendar a booking made in the app or on the website, using its private link. The token goes in a header, never the URL, and repeated wrong tokens lock the caller out for a while. New `manageBooking`, `cancelBooking`, `rescheduleAvailability`, `rescheduleBooking` and `bookingIcs` in the client; `useManagedBooking`, `useCancelBooking`, `useRescheduleBooking` and `useRescheduleAvailability` in native.
|
|
10
|
+
- **"My bookings" with guest sign-in.** A visitor enters their email, gets a 6-digit code, and sees every booking and RSVP made with that address. Codes and tokens are stored hashed, codes expire after 10 minutes and 5 tries (counted safely even when requests arrive at once), each code signs in only once, and replies never reveal whether an address has bookings. Needs email sending. `useGuest()` and `useMyBookings()` in native; the guest token is saved per site and only ever sent to the site that issued it.
|
|
11
|
+
- **Links page and forms in apps.** `GET /api/v1/links` and published forms (`GET /api/v1/forms/{id}`, `POST …/responses`) with the web form's validation, retry-safe submissions and a daily limit per form (retries and invalid answers don't use it up). `useLinks`, `useForm` and `useSubmitForm`.
|
|
12
|
+
- **Staff run the schedule from the staff app.** A week or month schedule view in one request, booking on someone's behalf, moving bookings, blocking time, and managing rooms, booking types, hours and calendars. Form responses and the links page too. Changing rooms, hours and calendars is limited to administrators and owners by default; reading form responses and editing raw HTML link blocks always are. `me().permissions` reports what each person may do.
|
|
13
|
+
- **Admin → Mobile app → App access** (owners): switch each app feature off instantly, sign every guest device out, and set limits (bookings per email per day, default 10, which also applies to website bookings but not to staff booking for someone; responses per form per day) and who may change the schedule setup.
|
|
14
|
+
- **Change cutoff for booking types:** "Guests can change until X hours before" stops guests cancelling or moving close to the start, on the website and in apps. Off by default.
|
|
15
|
+
- **Fix: booking lengths.** A booking (website, app or staff) can only use one of its booking type's lengths. Before, sending a custom `minutes` could hold a 30-minute slot type for hours.
|
|
16
|
+
- **Fix: day bookings across a clock change.** A whole-day booking on a day when clocks go back or forward (23 or 25 hours long) could not be booked or moved. The end now snaps to the real local midnight on the website, in apps and for staff.
|
|
17
|
+
- **Fix: rooms with all-day hours and single-day overrides.** The staff app now accepts hours that run to midnight (`00:00` end) and a date override without an end date, and refuses impossible times like `09:99`.
|
|
18
|
+
- **Fix: client retries.** The client no longer automatically repeats requests that can't safely run twice (sign-in code requests and exchanges, cancelling and moving a booking). When a rate limit asks for a longer wait than the request timeout, the 429 is returned at once with its `retryAfter` instead of ending as a timeout. Rate limits now give their real wait in `Retry-After` (for example an hour for the sign-in email cap).
|
|
19
|
+
- **Links page edits can't overwrite each other.** The staff app's links page carries a revision: a save made from an out-of-date copy (someone else changed it in an app or on the website) answers 409 and saves nothing, and a save that fails partway leaves the page as it was. Click counts don't count as changes.
|
|
20
|
+
|
|
21
|
+
- Search in apps. Apps can now search the site the same way the website's search box does: titles count most, then excerpts, tags and body text, and results keep up as people type.
|
|
22
|
+
|
|
23
|
+
- **Search one collection:** `GET /api/v1/content?q=` returns ranked matches in the normal list format, so `posts({ collection: 'sermons', q })` in the client and `usePosts({ collection: 'sermons', q })` in native show a filtered sermon list with no other changes.
|
|
24
|
+
- **Search everything:** the new `GET /api/v1/search?q=` returns the top matches in posts, pages and events on public calendars, each with a plain-text snippet and the ranges of the matched words to highlight, plus a "more" flag for each group. Use `client.search(text)` or the native `useSearch(text)` hook, which waits for typing to pause and fetches nothing for a blank box. `useDebounced(value)` is exported for your own search fields.
|
|
25
|
+
- Both follow `acms({ search })`: when search is off, `/api/v1/search` isn't registered and `q` returns a 400. Documented in the OpenAPI spec and `docs/mobile-api-v1.md`.
|
|
26
|
+
- Event results: public events are found even when private events match the query more strongly, and `GET /api/v1/events?q=` matches the same events as `/search` (including body text and word beginnings), so a "more events" screen continues the same results by date.
|
|
27
|
+
|
|
28
|
+
- Uptime monitoring with Uptime Kuma, so you know within minutes when a site, its app API or its scheduled jobs stop.
|
|
29
|
+
|
|
30
|
+
- **`GET /api/health`**: a public health check for monitors. 503 when the database or the scheduler is down, `degraded` (still 200) when email is stuck. It names only the failing part.
|
|
31
|
+
- **Scheduler heartbeat**: after every scheduled run the site reports to a Kuma push monitor, which goes down when the reports stop (no reminders, scheduled posts or emails). Set the URL in Admin → Settings → Monitoring or `UPTIME_PUSH_URL`.
|
|
32
|
+
- **`npx acms monitor kuma`**: creates the website, app API, staff app API, health and scheduler monitors plus a status page in your Kuma (1.23 or 2.x) in one go. Reruns update instead of duplicating; `--print` lists them to add by hand. The Kuma login comes from `KUMA_USERNAME` / `KUMA_PASSWORD` (+ `KUMA_TOTP`).
|
|
33
|
+
- **Admin → Settings → Monitoring** (owners): this site's own health, each Kuma monitor's state and 24-hour uptime, and the heartbeat with a "Send test" button.
|
|
34
|
+
- `acms mobile doctor` warns when `/api/health` isn't ok, and the authenticated `/api/ops/status` now answers 503 when it reports a problem.
|
|
35
|
+
|
|
3
36
|
## 0.3.9
|
|
4
37
|
|
|
5
38
|
### Patch Changes
|
package/cli/common.mjs
CHANGED
|
@@ -15,8 +15,8 @@ export async function project(root=process.cwd()) {
|
|
|
15
15
|
return {cwd,pkg,core,version:(await readJson(resolve(core,'package.json'))).version};
|
|
16
16
|
}
|
|
17
17
|
export function parse(argv) {
|
|
18
|
-
const positionals=[],flags={};const values=new Set(['cwd','site','from','output','baseline','file','url','token-env','revision','days','port','format','tool','theme','as','name','dir','bundle']);
|
|
19
|
-
const booleans=new Set(['json','help','yes','dry-run','check','next','latest','provision','draft','build','optimize','force','native','offline','mobile','no-config']);
|
|
18
|
+
const positionals=[],flags={};const values=new Set(['cwd','site','from','output','baseline','file','url','token-env','revision','days','port','format','tool','theme','as','name','dir','bundle','kuma']);
|
|
19
|
+
const booleans=new Set(['json','help','yes','dry-run','check','next','latest','provision','draft','build','optimize','force','native','offline','mobile','no-config','print']);
|
|
20
20
|
for(let i=0;i<argv.length;i++) {
|
|
21
21
|
let a=argv[i];if(a==='-h')a='--help';if(a==='-y')a='--yes';
|
|
22
22
|
if(!a.startsWith('--')){positionals.push(a);continue;}
|
package/cli/main.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { sectionCommand } from './sections.mjs';
|
|
|
7
7
|
import { remoteCommand } from './remote.mjs';
|
|
8
8
|
import { addMobile, mobileCommand, listApps } from './mobile.mjs';
|
|
9
9
|
import { mobileDoctor, renderDoctor } from './mobile-doctor.mjs';
|
|
10
|
+
import { monitorCommand, renderMonitor } from './monitor.mjs';
|
|
10
11
|
const HELP=`acms — ACMS project, theme and agent tools
|
|
11
12
|
Discovery: inspect | schema theme | docs theme | docs sections | docs mobile
|
|
12
13
|
agent init [--mobile] write .agents/skills (theme; mobile when an app exists)
|
|
@@ -44,6 +45,10 @@ Mobile: add mobile [client] [--dir mobile] [--name "Riverside"]
|
|
|
44
45
|
client: events news booking push
|
|
45
46
|
editor: editor-content editor-writer editor-sections
|
|
46
47
|
editor-translations editor-media editor-bookings
|
|
48
|
+
Monitoring: monitor kuma [--url https://site] [--kuma https://kuma] [--print]
|
|
49
|
+
website, app API, staff app API, health and scheduler
|
|
50
|
+
monitors + a status page in Uptime Kuma; reruns update.
|
|
51
|
+
Login: KUMA_USERNAME, KUMA_PASSWORD (+ KUMA_TOTP)
|
|
47
52
|
Maintenance: update [--check|--next|--latest] | doctor
|
|
48
53
|
add cloudflare [--provision] | add docker
|
|
49
54
|
Global: --json --cwd <dir> --site <profile> --dry-run --yes --help
|
|
@@ -105,6 +110,7 @@ export async function main() {
|
|
|
105
110
|
if(!flags.json){console.log(renderDoctor(data));return;}
|
|
106
111
|
}
|
|
107
112
|
else if(cmd==='mobile')data=await mobileCommand(sub,rest,flags);
|
|
113
|
+
else if(cmd==='monitor'){data=await monitorCommand(sub,flags);if(!flags.json){console.log(renderMonitor(data));return;}}
|
|
108
114
|
else if(['update','doctor','add'].includes(cmd)) {
|
|
109
115
|
if(flags['dry-run']&&cmd!=='doctor')data={dryRun:true,command:parsed.args,action:cmd==='update'?'Check/install core version':'Write deployment configuration and install dependencies'};
|
|
110
116
|
else {
|
|
@@ -126,7 +132,7 @@ export async function main() {
|
|
|
126
132
|
}
|
|
127
133
|
else if(cmd==='agent'&&sub==='init')data=await agentInit(flags);
|
|
128
134
|
else if(cmd==='inspect') {
|
|
129
|
-
const p=await project();data={coreVersion:p.version,themeApiVersion:1,project:p.pkg.name,targets:{cloudflare:await exists('wrangler.jsonc')||await exists('wrangler.toml'),docker:await exists('Dockerfile')},themes:await listLocalThemes(p),capabilities:['theme-schema','theme-create','theme-check','theme-dev','theme-pack','scoped-design-api','editable-sections','section-library','section-types','page-presets','mobile-apps','mobile-editor-apps','mobile-recipes','mobile-docs','mobile-doctor'],mobileApps:await mobileApps(),configuration:await readJson(resolve(p.cwd,'.astro/acms-project.json')).catch(()=>({source:'Run astro sync or dev to discover configured collections.',collections:null}))};
|
|
135
|
+
const p=await project();data={coreVersion:p.version,themeApiVersion:1,project:p.pkg.name,targets:{cloudflare:await exists('wrangler.jsonc')||await exists('wrangler.toml'),docker:await exists('Dockerfile')},themes:await listLocalThemes(p),capabilities:['theme-schema','theme-create','theme-check','theme-dev','theme-pack','scoped-design-api','editable-sections','section-library','section-types','page-presets','mobile-apps','mobile-editor-apps','mobile-recipes','mobile-docs','mobile-doctor','monitor-kuma'],mobileApps:await mobileApps(),configuration:await readJson(resolve(p.cwd,'.astro/acms-project.json')).catch(()=>({source:'Run astro sync or dev to discover configured collections.',collections:null}))};
|
|
130
136
|
} else if(cmd==='theme')data=await themeCommand(sub,rest,flags);
|
|
131
137
|
else if(['site','design','content','media'].includes(cmd))data=await remoteCommand(cmd,sub,rest,flags);
|
|
132
138
|
else fail('USAGE','Unknown command.',[],['Run acms --help']);
|
package/cli/mobile-doctor.mjs
CHANGED
|
@@ -208,6 +208,15 @@ async function doctorApp(app, flags, ctx) {
|
|
|
208
208
|
else c.fail('site', 'Site API', `${url}/api/v1/site returned ${r.status}${r.json ? '' : ' (not the ACMS API)'}.`, { hint: 'Check EXPO_PUBLIC_ACMS_URL points at the site origin and the site runs @acms-subir/core with the mobile API.' });
|
|
209
209
|
}
|
|
210
210
|
const live = !!site;
|
|
211
|
+
// the site's own health (what Uptime Kuma watches): a warning, never a failure,
|
|
212
|
+
// since a local dev server usually has no scheduler running
|
|
213
|
+
if (live) {
|
|
214
|
+
const h = await getJson(`${url}/api/health`);
|
|
215
|
+
const bad = Object.entries(h.json?.checks ?? {}).filter(([, v]) => v !== 'ok' && v !== 'off').map(([k, v]) => `${k} ${v}`);
|
|
216
|
+
if (h.status === 200 && h.json?.status === 'ok') c.pass('health', 'Site health', 'Database, scheduler and email are working (/api/health).');
|
|
217
|
+
else if (h.status === 200 || h.status === 503) c.warn('health', 'Site health', `/api/health reports ${h.json?.status ?? 'down'}${bad.length ? `: ${bad.join(', ')}` : ''}.`, { hint: 'A stopped scheduler means no reminders, scheduled posts or emails: check the cron (Cloudflare) or the runner (Docker). Watch it with npx acms monitor kuma.' });
|
|
218
|
+
else c.warn('health', 'Site health', `/api/health returned ${h.status}.`, { hint: 'Update the site (npx acms update): older releases have no health check.' });
|
|
219
|
+
}
|
|
211
220
|
|
|
212
221
|
// 6. app ids
|
|
213
222
|
if (!appJson) c.fail('app-ids', 'App identity', 'app.json is missing or not valid JSON.', { hint: `Restore ${app.dir}/app.json (see the starter: npx acms add mobile --dry-run).` });
|
package/cli/monitor.mjs
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { randomInt } from 'node:crypto';
|
|
4
|
+
import { fail } from './common.mjs';
|
|
5
|
+
import { listApps } from './mobile.mjs';
|
|
6
|
+
|
|
7
|
+
// `acms monitor kuma`: puts a site's monitors into Uptime Kuma (website, app
|
|
8
|
+
// API, staff app API, health, scheduler heartbeat) in one group, plus a
|
|
9
|
+
// status page for Settings → Monitoring. Kuma has no REST API for this, so
|
|
10
|
+
// it speaks Kuma's own dashboard protocol (Socket.IO over a WebSocket),
|
|
11
|
+
// tested with Kuma 1.23 and 2.x. Running it again updates, never duplicates.
|
|
12
|
+
// `--print` lists the same monitors to add by hand.
|
|
13
|
+
// Login comes from KUMA_USERNAME / KUMA_PASSWORD (+ KUMA_TOTP for 2FA):
|
|
14
|
+
// never arguments, never stored.
|
|
15
|
+
|
|
16
|
+
const TIMEOUT = 15_000;
|
|
17
|
+
|
|
18
|
+
/** the monitors for one site; `kuma` is the Kuma version major (1 or 2) */
|
|
19
|
+
export function monitorPlan(site, opts = {}) {
|
|
20
|
+
const base = { method: 'GET', retryInterval: 60, resendInterval: 0, timeout: 30, ignoreTls: false, upsideDown: false, expiryNotification: false, maxredirects: 10, notificationIDList: {} };
|
|
21
|
+
const http = (name, path, extra = {}) => ({ ...base, type: 'http', name, url: `${site}${path}`, interval: 60, maxretries: 1, accepted_statuscodes: ['200-299'], ...extra });
|
|
22
|
+
const list = [
|
|
23
|
+
{ key: 'website', what: 'The website answers', ...http('Website', '/') },
|
|
24
|
+
// the case to catch: the app API removed or broken (a 404 or a wrong answer)
|
|
25
|
+
{ key: 'app-api', what: 'The app API answers as v1', ...http('App API', '/api/v1/site', { type: 'json-query', jsonPath: 'data.apiVersion', expectedValue: '1', ...(opts.kuma === 1 ? {} : { jsonPathOperator: '==' }) }) },
|
|
26
|
+
...(opts.staff === false ? [] : [{ key: 'staff-api', what: 'The staff app API exists (401 = up)', ...http('Staff app API', '/api/v1/admin/me', { accepted_statuscodes: ['401'] }) }]),
|
|
27
|
+
{ key: 'health', what: 'Database, scheduler and email (503 = down)', ...http('Health', '/api/health') },
|
|
28
|
+
{ key: 'scheduler', what: 'The scheduler reported in the last 3 min', ...base, type: 'push', name: 'Scheduler', url: 'https://', interval: 180, maxretries: 0, accepted_statuscodes: ['200-299'], pushToken: opts.pushToken ?? token() },
|
|
29
|
+
];
|
|
30
|
+
// Kuma 2 stores alert conditions; Kuma 1 refuses columns it doesn't have
|
|
31
|
+
return opts.kuma === 1 ? list : list.map((m) => ({ ...m, conditions: [] }));
|
|
32
|
+
}
|
|
33
|
+
function token(n = 32) {
|
|
34
|
+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
35
|
+
return Array.from({ length: n }, () => chars[randomInt(chars.length)]).join('');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---- a minimal Socket.IO v4 client (WebSocket transport only) -----------------
|
|
39
|
+
|
|
40
|
+
export function kumaSocket(base) {
|
|
41
|
+
const url = `${base.replace(/^http/, 'ws')}/socket.io/?EIO=4&transport=websocket`;
|
|
42
|
+
const ws = new WebSocket(url);
|
|
43
|
+
const acks = new Map(), latest = {};
|
|
44
|
+
let next = 0, closed = false;
|
|
45
|
+
const ready = new Promise((resolve, reject) => {
|
|
46
|
+
const timer = setTimeout(() => reject(new Error('timeout')), TIMEOUT);
|
|
47
|
+
ws.onerror = () => { clearTimeout(timer); reject(new Error('unreachable')); };
|
|
48
|
+
ws.onclose = () => { closed = true; for (const [, a] of acks) a.reject(new Error('closed')); acks.clear(); };
|
|
49
|
+
ws.onmessage = ({ data }) => {
|
|
50
|
+
const s = String(data);
|
|
51
|
+
if (s === '2') return ws.send('3'); // ping → pong
|
|
52
|
+
if (s.startsWith('0')) return ws.send('40'); // open → join the default namespace
|
|
53
|
+
if (s.startsWith('44')) { clearTimeout(timer); return reject(new Error('refused')); }
|
|
54
|
+
if (s.startsWith('40')) { clearTimeout(timer); return resolve(); }
|
|
55
|
+
const ack = /^43(\d+)([\s\S]*)$/.exec(s);
|
|
56
|
+
if (ack) { const a = acks.get(Number(ack[1])); acks.delete(Number(ack[1])); a?.resolve(JSON.parse(ack[2])[0]); return; }
|
|
57
|
+
if (s.startsWith('42')) { try { const [event, payload] = JSON.parse(s.slice(2)); latest[event] = payload; } catch { /* not ours */ } }
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
const call = (event, ...args) => new Promise((resolve, reject) => {
|
|
61
|
+
if (closed) return reject(new Error('closed'));
|
|
62
|
+
const id = next++;
|
|
63
|
+
const timer = setTimeout(() => { acks.delete(id); reject(new Error('timeout')); }, TIMEOUT);
|
|
64
|
+
acks.set(id, { resolve: (v) => { clearTimeout(timer); resolve(v); }, reject: (e) => { clearTimeout(timer); reject(e); } });
|
|
65
|
+
ws.send(`42${id}${JSON.stringify([event, ...args])}`);
|
|
66
|
+
});
|
|
67
|
+
/** waits until Kuma has pushed `event` (sent by the server after login), optionally one that passes `test` */
|
|
68
|
+
const wait = async (event, test = (v) => !!v, ms = 8000) => { for (let t = 0; t < ms && !test(latest[event]); t += 100) await new Promise((r) => setTimeout(r, 100)); return latest[event]; };
|
|
69
|
+
return { ready, call, wait, latest, close: () => { closed = true; ws.close(); } };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---- the command --------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
function origin(raw, what) {
|
|
75
|
+
let u;
|
|
76
|
+
try { u = new URL(raw); } catch { fail('USAGE', `${what} must be an address like https://example.com.`); }
|
|
77
|
+
if (!['http:', 'https:'].includes(u.protocol) || u.username || u.password) fail('USAGE', `${what} must be an http(s) address without credentials.`);
|
|
78
|
+
return u.origin;
|
|
79
|
+
}
|
|
80
|
+
/** --url, else the site address baked into the mobile app (.env) */
|
|
81
|
+
async function siteAddress(flags) {
|
|
82
|
+
if (flags.url) return origin(flags.url, '--url');
|
|
83
|
+
for (const app of await listApps().catch(() => [])) {
|
|
84
|
+
const env = await readFile(join(app.dir, '.env'), 'utf8').catch(() => '');
|
|
85
|
+
const v = env.match(/^EXPO_PUBLIC_ACMS_URL=(.*)$/m)?.[1]?.trim();
|
|
86
|
+
if (v && /^https:\/\//.test(v)) return origin(v, 'EXPO_PUBLIC_ACMS_URL');
|
|
87
|
+
}
|
|
88
|
+
fail('USAGE', 'Which site? Pass --url https://your-site.', [], ['acms monitor kuma --url https://your-site --kuma https://your-kuma']);
|
|
89
|
+
}
|
|
90
|
+
async function probe(url) {
|
|
91
|
+
try { const r = await fetch(url, { signal: AbortSignal.timeout(8000), headers: { accept: 'application/json' } }); return { status: r.status, json: await r.json().catch(() => null) }; }
|
|
92
|
+
catch { return { status: 0, json: null }; }
|
|
93
|
+
}
|
|
94
|
+
const slugFor = (host) => `acms-${host.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').toLowerCase()}`.slice(0, 60);
|
|
95
|
+
|
|
96
|
+
export async function monitorCommand(sub, flags) {
|
|
97
|
+
if (sub !== 'kuma') fail('USAGE', 'Use: acms monitor kuma --url https://your-site --kuma https://your-kuma [--print]');
|
|
98
|
+
const site = await siteAddress(flags);
|
|
99
|
+
const host = new URL(site).host;
|
|
100
|
+
// what the site has: staff app API (401) or not (404); the site name for labels
|
|
101
|
+
const [info, staff] = await Promise.all([probe(`${site}/api/v1/site`), probe(`${site}/api/v1/admin/me`)]);
|
|
102
|
+
const warnings = [];
|
|
103
|
+
if (info.status !== 200) warnings.push(`${site}/api/v1/site answered ${info.status || 'nothing'}: check the address (Kuma will show the App API as down until it answers).`);
|
|
104
|
+
const hasStaff = staff.status === 401;
|
|
105
|
+
if (!hasStaff) warnings.push('The staff app API isn’t on (no mobile.editor in the site config), so there is no Staff app API monitor.');
|
|
106
|
+
const group = info.json?.data?.name ? `${info.json.data.name} (${host})` : host;
|
|
107
|
+
|
|
108
|
+
const kumaRaw = flags.kuma ?? process.env.KUMA_URL;
|
|
109
|
+
if (flags.print || !kumaRaw) {
|
|
110
|
+
if (!flags.print) warnings.push('No --kuma address (or KUMA_URL): listing the monitors to add by hand.');
|
|
111
|
+
return { mode: 'print', site, group, monitors: monitorPlan(site, { staff: hasStaff }).map(view), warnings };
|
|
112
|
+
}
|
|
113
|
+
const kuma = origin(kumaRaw, '--kuma');
|
|
114
|
+
const username = process.env.KUMA_USERNAME, password = process.env.KUMA_PASSWORD;
|
|
115
|
+
if (!username || !password) fail('AUTH', 'Set KUMA_USERNAME and KUMA_PASSWORD (and KUMA_TOTP when Kuma uses two-factor sign-in).', [], ['read -rs KUMA_PASSWORD && export KUMA_PASSWORD # keeps it out of shell history']);
|
|
116
|
+
|
|
117
|
+
const k = kumaSocket(kuma);
|
|
118
|
+
try {
|
|
119
|
+
try { await k.ready; } catch (e) { fail('NETWORK', `Couldn’t reach Uptime Kuma at ${kuma} (${e.message}).`, [], ['Check --kuma, or run with --print to add the monitors by hand.']); }
|
|
120
|
+
let login = await k.call('login', { username, password, token: process.env.KUMA_TOTP ?? '' });
|
|
121
|
+
if (login?.tokenRequired) fail('AUTH', 'Kuma asks for a two-factor code: set KUMA_TOTP to the current code and run again.');
|
|
122
|
+
if (!login?.ok) fail('AUTH', `Kuma refused the sign-in${login?.msg ? `: ${login.msg}` : ''}.`);
|
|
123
|
+
// the version only comes with the info Kuma sends after login
|
|
124
|
+
const info = await k.wait('info', (v) => !!v?.version);
|
|
125
|
+
const major = Number(String(info?.version ?? '2').split('.')[0]) || 2;
|
|
126
|
+
const existing = Object.values(await k.wait('monitorList') ?? {});
|
|
127
|
+
const ok = (r, what) => { if (!r?.ok) throw Object.assign(new Error(`${what}: ${r?.msg ?? 'no answer'}`), { kuma: true }); return r; };
|
|
128
|
+
|
|
129
|
+
// the group, then each monitor inside it (matched by name, so reruns update)
|
|
130
|
+
let parent = existing.find((m) => m.type === 'group' && m.name === group);
|
|
131
|
+
const results = [];
|
|
132
|
+
if (!parent) {
|
|
133
|
+
const { key: _k, what: _w, ...first } = monitorPlan(site, { kuma: major })[0];
|
|
134
|
+
const r = ok(await k.call('add', { ...first, type: 'group', name: group, url: 'https://', parent: null }), 'group');
|
|
135
|
+
parent = { id: r.monitorID };
|
|
136
|
+
results.push({ name: group, action: 'created', type: 'group' });
|
|
137
|
+
}
|
|
138
|
+
const children = existing.filter((m) => m.parent === parent.id);
|
|
139
|
+
const pushToken = children.find((m) => m.type === 'push' && m.name === 'Scheduler')?.pushToken;
|
|
140
|
+
const ids = [];
|
|
141
|
+
for (const { key: _k, what: _w, ...m } of monitorPlan(site, { kuma: major, staff: hasStaff, pushToken })) {
|
|
142
|
+
const old = children.find((c) => c.name === m.name);
|
|
143
|
+
const r = old
|
|
144
|
+
? ok(await k.call('editMonitor', { ...old, ...m, id: old.id, parent: parent.id }), m.name)
|
|
145
|
+
: ok(await k.call('add', { ...m, parent: parent.id }), m.name);
|
|
146
|
+
ids.push(r.monitorID ?? old.id);
|
|
147
|
+
results.push({ name: m.name, action: old ? 'updated' : 'created', type: m.type, url: m.type === 'push' ? null : m.url });
|
|
148
|
+
}
|
|
149
|
+
const push = `${kuma}/api/push/${pushToken ?? (await k.call('getMonitor', ids[ids.length - 1]))?.monitor?.pushToken}`;
|
|
150
|
+
|
|
151
|
+
// a public status page with these monitors, for Settings → Monitoring
|
|
152
|
+
const slug = slugFor(host);
|
|
153
|
+
let page = await k.call('getStatusPage', slug);
|
|
154
|
+
if (!page?.ok) { ok(await k.call('addStatusPage', group, slug), 'status page'); page = await k.call('getStatusPage', slug); }
|
|
155
|
+
ok(page, 'status page');
|
|
156
|
+
ok(await k.call('saveStatusPage', slug, { ...page.config, title: group, published: true }, page.config.icon, [{ name: group, monitorList: ids.map((id) => ({ id })) }]), 'status page');
|
|
157
|
+
|
|
158
|
+
return { mode: 'kuma', site, kuma, kumaVersion: info?.version ?? null, group, monitors: results, pushUrl: push, statusPage: `${kuma}/status/${slug}`, warnings };
|
|
159
|
+
} catch (e) {
|
|
160
|
+
if (!e?.kuma) throw e;
|
|
161
|
+
// Kuma changed its dashboard protocol (or refused a field): fall back to the list
|
|
162
|
+
return { mode: 'print', site, group, monitors: monitorPlan(site, { staff: hasStaff }).map(view), warnings: [...warnings, `Kuma didn’t accept the setup (${e.message}). Add these by hand, or update ACMS.`] };
|
|
163
|
+
} finally { k.close(); }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const view = (m) => ({ name: m.name, type: m.type, url: m.type === 'push' ? null : m.url, interval: m.interval, check: m.what,
|
|
167
|
+
...(m.jsonPath ? { jsonQuery: `${m.jsonPath} == ${m.expectedValue}` } : {}), acceptedStatusCodes: m.accepted_statuscodes, retries: m.maxretries });
|
|
168
|
+
|
|
169
|
+
export function renderMonitor(d) {
|
|
170
|
+
const out = [];
|
|
171
|
+
for (const w of d.warnings) out.push(`! ${w}`);
|
|
172
|
+
if (d.mode === 'print') {
|
|
173
|
+
out.push(`Add these to Uptime Kuma in a group named "${d.group}":`, '');
|
|
174
|
+
for (const m of d.monitors) {
|
|
175
|
+
out.push(` ${m.name} (${m.type === 'json-query' ? 'HTTP(s) - Json Query' : m.type === 'push' ? 'Push' : 'HTTP(s)'}, every ${m.interval} s${m.retries ? `, ${m.retries} retry` : ''})`);
|
|
176
|
+
if (m.url) out.push(` URL: ${m.url}`);
|
|
177
|
+
if (m.jsonQuery) out.push(` Json Query: ${m.jsonQuery}`);
|
|
178
|
+
if (m.acceptedStatusCodes.join() !== '200-299') out.push(` Accepted status codes: ${m.acceptedStatusCodes.join(', ')}`);
|
|
179
|
+
out.push(` Checks: ${m.check}`);
|
|
180
|
+
}
|
|
181
|
+
out.push('', 'Then paste the Scheduler monitor’s push URL and a status page with these monitors into Admin → Settings → Monitoring.');
|
|
182
|
+
return out.join('\n');
|
|
183
|
+
}
|
|
184
|
+
out.push(`Uptime Kuma ${d.kumaVersion ?? ''} at ${d.kuma}: ${d.group}`);
|
|
185
|
+
for (const m of d.monitors) out.push(` ✓ ${m.action.padEnd(7)} ${m.name}${m.url ? ` ${m.url}` : ''}`);
|
|
186
|
+
out.push('', 'Paste into Admin → Settings → Monitoring:', ` Scheduler heartbeat: ${d.pushUrl}`, ` Status page: ${d.statusPage}`,
|
|
187
|
+
'', 'Alerts: in Kuma, add a notification (email, Telegram, Discord…) to the group’s monitors.');
|
|
188
|
+
return out.join('\n');
|
|
189
|
+
}
|
package/docs/mobile.md
CHANGED
|
@@ -113,11 +113,15 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
|
|
|
113
113
|
|
|
114
114
|
| Hook | For |
|
|
115
115
|
| --- | --- |
|
|
116
|
-
| `useSite()` | name, menu, languages, features (`calendar`, `bookings`, `blog`) |
|
|
116
|
+
| `useSite()` | name, menu, languages, features (`calendar`, `bookings`, `blog`, `search`, `bookingChanges`, `guestSignIn`, `forms`, `links`) |
|
|
117
117
|
| `usePage(slug)` | a published page: `mode` (`sections`/`content`), `sections`, `body.html`; 404 when missing |
|
|
118
|
-
| `usePosts({ collection, limit })` / `usePost(slug)` | posts (infinite: `items`, `fetchNextPage`) |
|
|
118
|
+
| `usePosts({ collection, limit, q })` / `usePost(slug)` | posts (infinite: `items`, `fetchNextPage`); `q` gives ranked search matches |
|
|
119
|
+
| `useSearch(text, { types, collection, limit })` | site-wide search: `data.posts`, `data.pages`, `data.events` (each with `snippet.text` + `snippet.highlights`), `data.more`; debounced, blank text fetches nothing |
|
|
119
120
|
| `useEvents({ calendar, from, to, limit, q })` | `data.occurrences[]` (`start`, `end`, `allDay`, `event`) in a window (default 30 days, max 90). `from`/`to` take a site-local day (`'2026-10-01'`) or an ISO date-time with a zone (`new Date().toISOString()`) |
|
|
120
121
|
| `useEvent(slug)` | one event |
|
|
122
|
+
| `useManagedBooking(token)` / `useCancelBooking` / `useRescheduleBooking` / `useRescheduleAvailability` | the booker's own booking (from `book()`'s `manageToken`): `can.cancel`, `can.reschedule`, `can.closed` (change cutoff passed: show "Contact us") |
|
|
123
|
+
| `useGuest()` / `useMyBookings({ when })` | guest sign-in with an emailed code, then every booking made with that email; check `features.guestSignIn` |
|
|
124
|
+
| `useLinks()` / `useForm(id)` / `useSubmitForm(id)` | the links page; published forms and sending answers; check `features.links` / `features.forms` |
|
|
121
125
|
| `useResources()` / `useAvailability({ resource, link })` / `useBooking()` | bookings |
|
|
122
126
|
| `useLocale()`, `useTheme()`, `useLayout()`, `useRefresh()`, `useOpenLink()` | language, tokens, layout, refresh, links |
|
|
123
127
|
| `usePushRegistration({ topics })` / `usePushTopics()` / `useNotificationTaps()` | push notifications (see Push notifications) |
|
|
@@ -228,6 +232,23 @@ choose the calendar, better still: they add an **Upcoming events** section
|
|
|
228
232
|
(its events source has a calendar and a limit) to the home page in the admin,
|
|
229
233
|
and `<Sections>` renders it with no app code at all. Say so in your summary.
|
|
230
234
|
|
|
235
|
+
**Search one collection as people type.** Check `useSite().data?.features.search`
|
|
236
|
+
first; when it is false, hide the field. Pass a debounced value to `usePosts`:
|
|
237
|
+
|
|
238
|
+
```tsx
|
|
239
|
+
const [text, setText] = useState('');
|
|
240
|
+
const q = useDebounced(text);
|
|
241
|
+
const sermons = usePosts({ collection: 'sermons', q });
|
|
242
|
+
// <TextInput value={text} onChangeText={setText} />, then sermons.items;
|
|
243
|
+
// q && !sermons.isFetching && !sermons.items.length → "No sermons match"
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
For a search screen across everything, `useSearch(text)` returns groups
|
|
247
|
+
(posts, pages, events) with snippets. Group `posts` by `collection` for
|
|
248
|
+
headings, and when `more.posts` is true link to the collection list with that
|
|
249
|
+
`q`. Search matches the main-language text, so Spanish words find content only
|
|
250
|
+
where the original is Spanish.
|
|
251
|
+
|
|
231
252
|
**A native version of a coded section.** From the site folder:
|
|
232
253
|
`npx acms section new visit-banner --theme <slug>` (new) or, for an existing
|
|
233
254
|
coded section, write `mobile/sections/VisitBanner.tsx` typed with
|
package/integration.mjs
CHANGED
|
@@ -71,6 +71,8 @@ const ROUTES = [
|
|
|
71
71
|
['/api/email/webhooks/[provider]', 'api/email/webhooks/[provider].ts'],
|
|
72
72
|
['/api/email/run', 'api/email/run.ts'],
|
|
73
73
|
['/api/ops/status', 'api/ops/status.ts'],
|
|
74
|
+
['/api/health', 'api/health.ts'],
|
|
75
|
+
['/api/monitoring', 'api/monitoring.ts'],
|
|
74
76
|
['/admin/ai', 'admin/ai.astro'],
|
|
75
77
|
['/admin/import', 'admin/import.astro'],
|
|
76
78
|
['/admin/tags', 'admin/tags.astro'],
|
|
@@ -231,7 +233,7 @@ function resolveI18n(i18n, contentBases) {
|
|
|
231
233
|
}
|
|
232
234
|
|
|
233
235
|
// Defaults + validation for acms({ search }). Search ships on; disabling it
|
|
234
|
-
// removes the /search page(s) and /api/search from the route table, and the
|
|
236
|
+
// removes the /search page(s), /api/search and /api/v1/search from the route table, and the
|
|
235
237
|
// layout skips the header trigger + modal (lib/search-config.ts).
|
|
236
238
|
function resolveSearch(search) {
|
|
237
239
|
if (search === undefined) return { enabled: true };
|
|
@@ -471,7 +473,8 @@ function acmsVitePlugin(userConfig) {
|
|
|
471
473
|
* browser (/admin/app-auth, OAuth 2.0 + PKCE; only the listed redirect URIs
|
|
472
474
|
* receive codes) and edit through the bearer-token /api/v1/admin/* API.
|
|
473
475
|
* @param {{enabled?: boolean}} [options.search]
|
|
474
|
-
* Public site search: the /search page, the /api/search JSON endpoint,
|
|
476
|
+
* Public site search: the /search page, the /api/search JSON endpoint, app
|
|
477
|
+
* search (/api/v1/search and /api/v1/content?q=), and
|
|
475
478
|
* the header search modal. On by default — set `enabled: false` to remove
|
|
476
479
|
* all three.
|
|
477
480
|
* @param {false | {enabled?: boolean, base?: string, eventsBase?: string, defaultView?: 'month' | 'week' | 'list', eventFields?: Array<{key: string, label?: string, type?: 'text' | 'textarea' | 'number' | 'url' | 'select', options?: string[]}>}} [options.calendar]
|
|
@@ -532,6 +535,9 @@ export default function acms(options = {}) {
|
|
|
532
535
|
['/api/v1/openapi.json', 'api/v1/openapi.json.ts'],
|
|
533
536
|
['/api/v1/content', 'api/v1/content/index.ts'],
|
|
534
537
|
['/api/v1/content/[id]', 'api/v1/content/[id].ts'],
|
|
538
|
+
['/api/v1/links', 'api/v1/links.ts'],
|
|
539
|
+
['/api/v1/forms/[id]', 'api/v1/forms/[id]/index.ts'],
|
|
540
|
+
['/api/v1/forms/[id]/responses', 'api/v1/forms/[id]/responses.ts'],
|
|
535
541
|
];
|
|
536
542
|
if (mobile.ios) routes.push(['/.well-known/apple-app-site-association', '.well-known/apple-app-site-association.ts']);
|
|
537
543
|
if (mobile.android) routes.push(['/.well-known/assetlinks.json', '.well-known/assetlinks.json.ts']);
|
|
@@ -541,9 +547,10 @@ export default function acms(options = {}) {
|
|
|
541
547
|
['/api/v1/devices/[token]', 'api/v1/devices/[token].ts'],
|
|
542
548
|
['/api/v1/push/topics', 'api/v1/push/topics.ts'],
|
|
543
549
|
['/api/push/admin', 'api/push/admin.ts'],
|
|
544
|
-
['/admin/mobile', 'admin/mobile.astro'],
|
|
545
550
|
);
|
|
546
551
|
}
|
|
552
|
+
// Admin → Mobile app: app access switches always; notifications when push is on
|
|
553
|
+
routes.push(['/admin/mobile', 'admin/mobile.astro'], ['/api/mobile/access', 'api/mobile/access.ts']);
|
|
547
554
|
if (mobile.editor.enabled) {
|
|
548
555
|
// editor apps: browser sign-in (OAuth 2.0 + PKCE) and the bearer-only admin API
|
|
549
556
|
routes.push(
|
|
@@ -564,6 +571,19 @@ export default function acms(options = {}) {
|
|
|
564
571
|
['/api/v1/admin/media', 'api/v1/admin/media/index.ts'],
|
|
565
572
|
['/api/v1/admin/bookings', 'api/v1/admin/bookings/index.ts'],
|
|
566
573
|
['/api/v1/admin/bookings/[id]/[action]', 'api/v1/admin/bookings/[id]/[action].ts'],
|
|
574
|
+
// schedule + setup (calendar/booking handlers answer 404 when those modules are off)
|
|
575
|
+
['/api/v1/admin/schedule', 'api/v1/admin/schedule.ts'],
|
|
576
|
+
['/api/v1/admin/resources', 'api/v1/admin/resources/index.ts'],
|
|
577
|
+
['/api/v1/admin/resources/[id]', 'api/v1/admin/resources/[id]/index.ts'],
|
|
578
|
+
['/api/v1/admin/resources/[id]/links', 'api/v1/admin/resources/[id]/links/index.ts'],
|
|
579
|
+
['/api/v1/admin/resources/[id]/links/[linkId]', 'api/v1/admin/resources/[id]/links/[linkId].ts'],
|
|
580
|
+
['/api/v1/admin/resources/[id]/blocks', 'api/v1/admin/resources/[id]/blocks/index.ts'],
|
|
581
|
+
['/api/v1/admin/resources/[id]/blocks/[blockId]', 'api/v1/admin/resources/[id]/blocks/[blockId].ts'],
|
|
582
|
+
['/api/v1/admin/calendars', 'api/v1/admin/calendars/index.ts'],
|
|
583
|
+
['/api/v1/admin/calendars/[id]', 'api/v1/admin/calendars/[id].ts'],
|
|
584
|
+
['/api/v1/admin/forms', 'api/v1/admin/forms/index.ts'],
|
|
585
|
+
['/api/v1/admin/forms/[id]/responses', 'api/v1/admin/forms/[id]/responses.ts'],
|
|
586
|
+
['/api/v1/admin/links', 'api/v1/admin/links.ts'],
|
|
567
587
|
);
|
|
568
588
|
}
|
|
569
589
|
if (legal.enabled) routes.push(['/privacy', 'legal/privacy.astro'], ['/terms', 'legal/terms.astro']);
|
|
@@ -577,7 +597,7 @@ export default function acms(options = {}) {
|
|
|
577
597
|
if (l !== i18n.defaultLocale) routes.push([`/${l}`, 'index.astro']);
|
|
578
598
|
}
|
|
579
599
|
if (search.enabled) {
|
|
580
|
-
routes.push(['/search', 'search.astro'], ['/api/search', 'api/search.ts']);
|
|
600
|
+
routes.push(['/search', 'search.astro'], ['/api/search', 'api/search.ts'], ['/api/v1/search', 'api/v1/search.ts']);
|
|
581
601
|
// a locale-prefixed search page per non-default locale, mirroring the
|
|
582
602
|
// per-locale home routes; search.astro derives its locale from the path
|
|
583
603
|
for (const l of i18n.locales) {
|
|
@@ -604,6 +624,17 @@ export default function acms(options = {}) {
|
|
|
604
624
|
['/admin/calendar/integrations', 'admin/calendar/integrations.astro'],
|
|
605
625
|
['/api/calendar/docs', 'api/calendar/docs.ts'],
|
|
606
626
|
[`${calendar.bookBase}/manage/[token]`, 'book/manage/[token].astro'],
|
|
627
|
+
// the booker's own booking from an app (token in the Booking-Token header)
|
|
628
|
+
['/api/v1/bookings/manage', 'api/v1/bookings/manage/index.ts'],
|
|
629
|
+
['/api/v1/bookings/manage/cancel', 'api/v1/bookings/manage/cancel.ts'],
|
|
630
|
+
['/api/v1/bookings/manage/availability', 'api/v1/bookings/manage/availability.ts'],
|
|
631
|
+
['/api/v1/bookings/manage/reschedule', 'api/v1/bookings/manage/reschedule.ts'],
|
|
632
|
+
['/api/v1/bookings/manage/ics', 'api/v1/bookings/manage/ics.ts'],
|
|
633
|
+
// guest sign-in for "My bookings" (answers 404 until email is set up)
|
|
634
|
+
['/api/v1/guest/code', 'api/v1/guest/code.ts'],
|
|
635
|
+
['/api/v1/guest/token', 'api/v1/guest/token.ts'],
|
|
636
|
+
['/api/v1/guest/bookings', 'api/v1/guest/bookings.ts'],
|
|
637
|
+
['/api/v1/guest/revoke', 'api/v1/guest/revoke.ts'],
|
|
607
638
|
['/admin/calendar/bookings', 'admin/calendar/bookings.astro'],
|
|
608
639
|
);
|
|
609
640
|
if (calendar.bookings) {
|
package/package.json
CHANGED
package/src/actions/index.ts
CHANGED
|
@@ -14,13 +14,14 @@ import { CONTENT, collectionUrl } from '../lib/content';
|
|
|
14
14
|
import { invalidateRedirectCache } from '../middleware';
|
|
15
15
|
import { isPrivateHost } from '../lib/net';
|
|
16
16
|
import { normalizeCalendarSrc } from '../lib/calendar/src';
|
|
17
|
+
import { saveCalendar } from '../lib/calendar/admin';
|
|
17
18
|
import { getCalendarEvents } from '../lib/calendar/ics';
|
|
18
19
|
import { siteLocale } from '../lib/dates';
|
|
19
20
|
import { normalizePageLocale, localeHomeUrl } from '../lib/i18n';
|
|
20
21
|
import { CALENDAR, bookingsEnabled } from '../lib/calendar/config';
|
|
21
22
|
import { holdConflicts } from '../lib/booking/holds';
|
|
22
23
|
import { setBookingStatus } from '../lib/booking/service';
|
|
23
|
-
import { eventTimeZone, featuredOccurrence, formatOccurrence, isValidTimeZone, normalizeEventTimes
|
|
24
|
+
import { eventTimeZone, featuredOccurrence, formatOccurrence, isValidTimeZone, normalizeEventTimes } from '../lib/calendar/events';
|
|
24
25
|
import { eventsFromCsv, eventsFromIcs, eventsFromVEvents } from '../lib/calendar/import';
|
|
25
26
|
import { loadFeed } from '../lib/calendar/ics';
|
|
26
27
|
import { NATIVE_ALL } from '../lib/calendar/render';
|
|
@@ -1212,32 +1213,11 @@ export const server = {
|
|
|
1212
1213
|
handler: async ({ id, ...data }, ctx) => {
|
|
1213
1214
|
await requireAdmin(ctx);
|
|
1214
1215
|
const repo = await getRepo(ctx.locals);
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
if (kind === 'feed' && (data.feedUrl !== undefined || !existing)) {
|
|
1220
|
-
const normalized = normalizeCalendarSrc(data.feedUrl ?? '');
|
|
1221
|
-
if (!normalized) throw new ActionError({ code: 'BAD_REQUEST', message: 'Paste a Google Calendar link, a calendar ID, or an .ics feed URL.' });
|
|
1222
|
-
feedUrl = normalized;
|
|
1216
|
+
try { return { calendar: await saveCalendar(repo, id, data) }; }
|
|
1217
|
+
catch (e) {
|
|
1218
|
+
if (e instanceof ApiError) throw new ActionError({ code: e.status === 404 ? 'NOT_FOUND' : 'BAD_REQUEST', message: e.message });
|
|
1219
|
+
throw e;
|
|
1223
1220
|
}
|
|
1224
|
-
const base = slugify(data.slug || data.name);
|
|
1225
|
-
let slug = base;
|
|
1226
|
-
for (let n = 2; ; n++) {
|
|
1227
|
-
const clash = await repo.getCalendarBySlug(slug);
|
|
1228
|
-
if (!clash || clash.id === id) break;
|
|
1229
|
-
slug = `${base}-${n}`;
|
|
1230
|
-
}
|
|
1231
|
-
const fields = {
|
|
1232
|
-
name: data.name, slug, color: safeColor(data.color ?? existing?.color ?? '#4f46e5'),
|
|
1233
|
-
description: data.description ?? existing?.description ?? '', feedUrl,
|
|
1234
|
-
visibility: data.visibility ?? existing?.visibility ?? 'public',
|
|
1235
|
-
};
|
|
1236
|
-
if (existing) {
|
|
1237
|
-
await repo.updateCalendar(existing.id, fields);
|
|
1238
|
-
return { calendar: (await repo.getCalendar(existing.id))! };
|
|
1239
|
-
}
|
|
1240
|
-
return { calendar: await repo.createCalendar({ ...fields, kind }) };
|
|
1241
1221
|
},
|
|
1242
1222
|
}),
|
|
1243
1223
|
reorder: defineAction({
|