@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
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { AppAccessState } from '../lib/mobile/access-admin';
|
|
3
|
+
import type { MobileAccess } from '../lib/schema';
|
|
4
|
+
|
|
5
|
+
// Admin → Mobile app → App access: what visitor and staff apps may do on
|
|
6
|
+
// this site. Each change saves at once; switching something off makes its
|
|
7
|
+
// app routes answer "turned off" immediately.
|
|
8
|
+
|
|
9
|
+
const ENDPOINT = '/api/mobile/access';
|
|
10
|
+
async function api(body?: Record<string, unknown>): Promise<AppAccessState> {
|
|
11
|
+
const res = await fetch(ENDPOINT, {
|
|
12
|
+
method: body ? 'POST' : 'GET', credentials: 'same-origin',
|
|
13
|
+
headers: body ? { 'content-type': 'application/json', accept: 'application/json' } : { accept: 'application/json' },
|
|
14
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
15
|
+
});
|
|
16
|
+
const data = await res.json().catch(() => null);
|
|
17
|
+
if (!res.ok) throw new Error(data?.issues?.[0]?.message || data?.error || `${res.status} ${res.statusText || 'Request failed'}`);
|
|
18
|
+
return data as AppAccessState;
|
|
19
|
+
}
|
|
20
|
+
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
|
21
|
+
|
|
22
|
+
const BLOCKER: Record<NonNullable<AppAccessState['guestSignIn']['blocker']>, string> = {
|
|
23
|
+
calendar: 'Turn on the calendar to use guest sign-in.',
|
|
24
|
+
email: 'Set up email sending in Settings → Email first; sign-in codes go out by email.',
|
|
25
|
+
encryption: 'Add EMAIL_ENCRYPTION_KEY to the server first; it protects codes waiting to be sent.',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export default function AppAccess() {
|
|
29
|
+
const [state, setState] = useState<AppAccessState | null>(null);
|
|
30
|
+
const [error, setError] = useState('');
|
|
31
|
+
const [saved, setSaved] = useState('');
|
|
32
|
+
// overlapping saves: only the newest reply may replace what's on screen
|
|
33
|
+
const latest = useRef(0);
|
|
34
|
+
useEffect(() => { api().then(setState, (e) => setError(errText(e))); }, []);
|
|
35
|
+
if (!state) return error ? <p className="flash error">{error}</p> : <div className="ma-skel" aria-busy="true" aria-label="Loading"><i /><i /></div>;
|
|
36
|
+
const a = state.access;
|
|
37
|
+
|
|
38
|
+
const save = async (patch: Partial<MobileAccess>, note = 'Saved.') => {
|
|
39
|
+
setError(''); setSaved('');
|
|
40
|
+
const n = ++latest.current;
|
|
41
|
+
// show the change at once; reload the server's copy if it is refused
|
|
42
|
+
setState((s) => s && { ...s, access: { ...s.access, ...patch } });
|
|
43
|
+
try { const next = await api({ action: 'save', access: patch }); if (n === latest.current) { setState(next); setSaved(note); } }
|
|
44
|
+
catch (e) { setError(errText(e)); if (n === latest.current) api().then(setState, () => {}); }
|
|
45
|
+
};
|
|
46
|
+
const signOutAll = async () => {
|
|
47
|
+
if (!confirm(`Sign out ${state.guestSignIn.devices === 1 ? 'the 1 device' : `all ${state.guestSignIn.devices} devices`}? People sign in again with a new code.`)) return;
|
|
48
|
+
try { setState(await api({ action: 'signOutGuests' })); setSaved('Everyone was signed out.'); } catch (e) { setError(errText(e)); }
|
|
49
|
+
};
|
|
50
|
+
const Switch = ({ on, label, onChange, disabled }: { on: boolean; label: string; onChange: (v: boolean) => void; disabled?: boolean }) => (
|
|
51
|
+
<button type="button" role="switch" aria-checked={on} aria-label={label} disabled={disabled} className={`acc-switch${on ? ' on' : ''}`} onClick={() => onChange(!on)}><i /></button>
|
|
52
|
+
);
|
|
53
|
+
const Num = ({ value, min, max, label, field }: { value: number; min: number; max: number; label: string; field: 'bookingsPerEmailDay' | 'responsesPerFormDay' }) => (
|
|
54
|
+
<input className="acc-num" type="number" inputMode="numeric" min={min} max={max} defaultValue={value} aria-label={label}
|
|
55
|
+
onBlur={(e) => { const n = Math.round(Number(e.currentTarget.value)); if (Number.isFinite(n) && n !== value && n >= min && n <= max) save({ [field]: n }); else e.currentTarget.value = String(value); }} />
|
|
56
|
+
);
|
|
57
|
+
const guest = state.guestSignIn;
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className="acc">
|
|
61
|
+
{(error || saved) && <p className={`flash ${error ? 'error' : 'ok'}`} role="status">{error || saved}</p>}
|
|
62
|
+
<section className="card card-pad">
|
|
63
|
+
<div className="card-head"><b>App access</b><span className="hint">What visitor apps can do on this site</span></div>
|
|
64
|
+
{state.modules.calendar && (
|
|
65
|
+
<div className="acc-row">
|
|
66
|
+
<div><b>Change bookings from the app</b><small>Visitors can view, cancel and move a booking with its private link.</small></div>
|
|
67
|
+
<Switch on={a.bookingChanges} label="Change bookings from the app" onChange={(v) => save({ bookingChanges: v })} />
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
{state.modules.calendar && (
|
|
71
|
+
<div className="acc-row">
|
|
72
|
+
<div>
|
|
73
|
+
<b>Guest sign-in</b>
|
|
74
|
+
<small>“My bookings” with a code sent by email. {guest.devices > 0 ? `${guest.devices.toLocaleString()} ${guest.devices === 1 ? 'device' : 'devices'} signed in.` : 'No devices signed in.'}</small>
|
|
75
|
+
{guest.blocker && a.guestSignIn && <small className="acc-warn">{BLOCKER[guest.blocker]}</small>}
|
|
76
|
+
</div>
|
|
77
|
+
<div className="acc-actions">
|
|
78
|
+
{guest.devices > 0 && <button type="button" className="btn sm ghost danger" onClick={signOutAll}>Sign out all</button>}
|
|
79
|
+
<Switch on={a.guestSignIn} label="Guest sign-in" onChange={(v) => save({ guestSignIn: v })} />
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
<div className="acc-row">
|
|
84
|
+
<div><b>Form responses from the app</b><small>Published forms can be filled in from the app.</small></div>
|
|
85
|
+
<Switch on={a.forms} label="Form responses from the app" onChange={(v) => save({ forms: v })} />
|
|
86
|
+
</div>
|
|
87
|
+
<div className="acc-row">
|
|
88
|
+
<div><b>Links page</b><small>The app can show your links page.</small></div>
|
|
89
|
+
<Switch on={a.links} label="Links page" onChange={(v) => save({ links: v })} />
|
|
90
|
+
</div>
|
|
91
|
+
</section>
|
|
92
|
+
|
|
93
|
+
<section className="card card-pad">
|
|
94
|
+
<div className="card-head"><b>Limits</b><span className="hint">0 means no limit</span></div>
|
|
95
|
+
{state.modules.calendar && (
|
|
96
|
+
<div className="acc-row">
|
|
97
|
+
<div><b>Bookings per email per day</b><small>New bookings and RSVPs from one address, on the website and in apps. Staff bookings don’t count.</small></div>
|
|
98
|
+
<Num value={a.bookingsPerEmailDay} min={0} max={1000} label="Bookings per email per day" field="bookingsPerEmailDay" />
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
<div className="acc-row">
|
|
102
|
+
<div><b>Responses per form per day</b><small>From apps. Extra responses are refused until tomorrow.</small></div>
|
|
103
|
+
<Num value={a.responsesPerFormDay} min={0} max={100000} label="Responses per form per day" field="responsesPerFormDay" />
|
|
104
|
+
</div>
|
|
105
|
+
{state.modules.calendar && (
|
|
106
|
+
<div className="acc-row">
|
|
107
|
+
<div><b>Who changes rooms, hours and calendars</b><small>In the staff app. Bookings, events and content stay open to all staff.</small></div>
|
|
108
|
+
<div className="seg" role="group" aria-label="Who changes rooms, hours and calendars">
|
|
109
|
+
<button type="button" aria-pressed={a.scheduleRoles === 'admins'} onClick={() => a.scheduleRoles !== 'admins' && save({ scheduleRoles: 'admins' })}>Admins + owners</button>
|
|
110
|
+
<button type="button" aria-pressed={a.scheduleRoles === 'staff'} onClick={() => a.scheduleRoles !== 'staff' && save({ scheduleRoles: 'staff' })}>All staff</button>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
</section>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import type { MonitoringState, MonitorStatus } from '../lib/monitoring';
|
|
3
|
+
|
|
4
|
+
// Settings → Monitoring: this site's own health, its Uptime Kuma monitors
|
|
5
|
+
// (read from a public Kuma status page) and the scheduler's push URL.
|
|
6
|
+
|
|
7
|
+
const ENDPOINT = '/api/monitoring';
|
|
8
|
+
async function api(body?: Record<string, unknown>): Promise<MonitoringState> {
|
|
9
|
+
const res = await fetch(ENDPOINT, {
|
|
10
|
+
method: body ? 'POST' : 'GET', credentials: 'same-origin',
|
|
11
|
+
headers: body ? { 'content-type': 'application/json', accept: 'application/json' } : { accept: 'application/json' },
|
|
12
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
13
|
+
});
|
|
14
|
+
const data = await res.json().catch(() => null);
|
|
15
|
+
if (!res.ok) throw new Error(data?.issues?.[0]?.message || data?.error || `${res.status} ${res.statusText || 'Request failed'}`);
|
|
16
|
+
return data as MonitoringState;
|
|
17
|
+
}
|
|
18
|
+
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
|
19
|
+
|
|
20
|
+
function ago(iso: string | null, now = Date.now()) {
|
|
21
|
+
if (!iso) return '';
|
|
22
|
+
const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
|
|
23
|
+
if (s < 60) return `${s} s`;
|
|
24
|
+
if (s < 3600) return `${Math.round(s / 60)} min`;
|
|
25
|
+
if (s < 86_400) return `${Math.round(s / 3600)} h`;
|
|
26
|
+
return `${Math.round(s / 86_400)} d`;
|
|
27
|
+
}
|
|
28
|
+
// 99.98%, 50%, 100%: never rounds a bad day up to 100%
|
|
29
|
+
const pct = (n: number | null) => (n === null ? '' : `${String(Math.floor(n * 10_000) / 100)}%`);
|
|
30
|
+
const WORD: Record<MonitorStatus['status'], string> = { up: 'Up', down: 'Down', pending: 'Pending', maintenance: 'Maintenance', unknown: 'No data' };
|
|
31
|
+
const CHECK: Record<string, string> = { ok: 'OK', down: 'down', stalled: 'stopped', stuck: 'stuck', off: 'off' };
|
|
32
|
+
|
|
33
|
+
function Tile({ m }: { m: MonitorStatus }) {
|
|
34
|
+
// segments never break inside ("100%\u00a0(24\u00a0h)")
|
|
35
|
+
const sub = m.status === 'unknown' ? ['No checks yet']
|
|
36
|
+
: [`${WORD[m.status]}${m.since ? ` for ${ago(m.since)}` : ''}`, m.uptime24h !== null ? `${pct(m.uptime24h)} (24 h)` : ''].filter(Boolean);
|
|
37
|
+
return (
|
|
38
|
+
<div className={`mon-tile${m.status === 'down' ? ' is-down' : ''}`}>
|
|
39
|
+
<div className="mon-name"><span className={`mon-dot ${m.status}`} aria-hidden="true" />{m.name}</div>
|
|
40
|
+
<div className="mon-sub">{sub.map((s, i) => <span key={i}>{i > 0 && ' · '}<span className="nw">{s}</span></span>)}</div>
|
|
41
|
+
{m.status === 'down' && m.message && <div className="mon-msg">{m.message}</div>}
|
|
42
|
+
<div className="mon-beats" aria-hidden="true">{m.recent.map((b, i) => <i key={i} className={b} />)}</div>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default function MonitoringSettings() {
|
|
48
|
+
const [state, setState] = useState<MonitoringState | null>(null);
|
|
49
|
+
const [error, setError] = useState('');
|
|
50
|
+
const [note, setNote] = useState('');
|
|
51
|
+
const [busy, setBusy] = useState('');
|
|
52
|
+
const [push, setPush] = useState('');
|
|
53
|
+
const [page, setPage] = useState('');
|
|
54
|
+
useEffect(() => { api().then((s) => { setState(s); setPage(s.statusPage); }, (e) => setError(errText(e))); }, []);
|
|
55
|
+
if (!state) return error ? <p className="flash error">{error}</p> : <div className="ma-skel" aria-busy="true" aria-label="Loading"><i /><i /></div>;
|
|
56
|
+
|
|
57
|
+
const run = async (what: string, body: Record<string, unknown>, done: string) => {
|
|
58
|
+
setBusy(what); setError(''); setNote('');
|
|
59
|
+
try { const s = await api(body); setState(s); setPage(s.statusPage); setPush(''); setNote(done); }
|
|
60
|
+
catch (e) { setError(errText(e)); }
|
|
61
|
+
finally { setBusy(''); }
|
|
62
|
+
};
|
|
63
|
+
const save = () => run('save', { action: 'save', statusPage: page, ...(push.trim() ? { pushUrl: push } : {}) }, 'Saved.');
|
|
64
|
+
const h = state.health;
|
|
65
|
+
const changed = page.trim() !== state.statusPage || push.trim() !== '';
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="mon">
|
|
69
|
+
{(error || note) && <p className={`flash ${error ? 'error' : ''}`} role="status">{error || note}</p>}
|
|
70
|
+
|
|
71
|
+
<div className="mon-self">
|
|
72
|
+
<div><span className={`mon-dot ${h.status === 'ok' ? 'up' : h.status === 'degraded' ? 'pending' : 'down'}`} aria-hidden="true" /><b>This site</b></div>
|
|
73
|
+
<span>Database {CHECK[h.checks.database]}</span>
|
|
74
|
+
<span>Scheduler {CHECK[h.checks.scheduler]}{state.lastRun ? `, last ran ${ago(state.lastRun)} ago` : ', never ran'}</span>
|
|
75
|
+
<span>Email {CHECK[h.checks.email]}</span>
|
|
76
|
+
<a href="/api/health" target="_blank" rel="noreferrer">/api/health</a>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
{state.statusPage && state.monitors === null && <p className="flash error">Couldn’t read the status page. Check the address and that the page is published in Kuma.</p>}
|
|
80
|
+
{state.monitors && (state.monitors.length
|
|
81
|
+
? <div className="mon-grid">{state.monitors.map((m) => <Tile key={m.id} m={m} />)}</div>
|
|
82
|
+
: <p className="hint">The status page has no monitors yet. Add them to it in Kuma.</p>)}
|
|
83
|
+
{!state.statusPage && (
|
|
84
|
+
<div className="mon-empty">
|
|
85
|
+
<b>Set up Uptime Kuma</b>
|
|
86
|
+
<p>From the site folder, run:</p>
|
|
87
|
+
<code className="mon-cmd">npx acms monitor kuma --url https://your-site --kuma https://your-kuma</code>
|
|
88
|
+
<p>It creates the website, app API, staff app API, health and scheduler monitors plus a status page in Kuma, and prints the two addresses to paste below.</p>
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
<div className="mon-fields">
|
|
93
|
+
<label className="mon-field">
|
|
94
|
+
<span>Scheduler heartbeat <small>Kuma push monitor URL{state.pushFromEnv ? ' (set by UPTIME_PUSH_URL on the server)' : ''}</small></span>
|
|
95
|
+
<div className="mon-inline">
|
|
96
|
+
<input type="url" inputMode="url" placeholder={state.pushUrl || 'https://uptime.example.com/api/push/…'} value={push} onChange={(e) => setPush(e.currentTarget.value)} disabled={state.pushFromEnv} aria-label="Kuma push URL" />
|
|
97
|
+
<button type="button" className="btn" disabled={!state.pushUrl || !!busy} onClick={() => run('test', { action: 'test' }, 'Kuma received the test beat.')}>{busy === 'test' ? 'Sending…' : 'Send test'}</button>
|
|
98
|
+
</div>
|
|
99
|
+
{state.pushUrl && !state.pushFromEnv && <small>Saved as <code>{state.pushUrl}</code> · paste a new one to replace it</small>}
|
|
100
|
+
</label>
|
|
101
|
+
<label className="mon-field">
|
|
102
|
+
<span>Status page <small>Public Kuma status page with this site’s monitors</small></span>
|
|
103
|
+
<input type="url" inputMode="url" placeholder="https://uptime.example.com/status/my-site" value={page} onChange={(e) => setPage(e.currentTarget.value)} aria-label="Kuma status page" />
|
|
104
|
+
</label>
|
|
105
|
+
<div className="mon-actions">
|
|
106
|
+
{state.pushUrl && !state.pushFromEnv && <button type="button" className="btn ghost danger" disabled={!!busy} onClick={() => run('save', { action: 'save', pushUrl: '' }, 'Heartbeat removed.')}>Remove heartbeat</button>}
|
|
107
|
+
<button type="button" className="btn primary" disabled={!changed || !!busy} onClick={save}>{busy === 'save' ? 'Saving…' : 'Save'}</button>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
package/src/layouts/Admin.astro
CHANGED
|
@@ -4,7 +4,6 @@ import { getRepo } from '../lib/repo';
|
|
|
4
4
|
import { BRAND } from '../lib/brand';
|
|
5
5
|
import { CONTENT } from '../lib/content';
|
|
6
6
|
import { CALENDAR, bookingsEnabled } from '../lib/calendar/config';
|
|
7
|
-
import { PUSH } from '../lib/push/config';
|
|
8
7
|
import { validateSession, SESSION_COOKIE, OWNER_ENV } from '../lib/auth';
|
|
9
8
|
import ThemeScript from '../components/ThemeScript.astro';
|
|
10
9
|
import CommandPalette from '../components/CommandPalette';
|
|
@@ -128,8 +127,8 @@ const groups: { id: string; label: string; items: Item[] }[] = [
|
|
|
128
127
|
{ key: 'design', label: 'Design', href: '/admin/design', icon: icons.design, count: null },
|
|
129
128
|
{ key: 'navigation', label: 'Navigation', href: '/admin/navigation', icon: icons.navigation, count: null },
|
|
130
129
|
{ key: 'links', label: 'Links page', href: '/admin/links', icon: icons.links, count: links.length },
|
|
131
|
-
// push notifications
|
|
132
|
-
...(
|
|
130
|
+
// app access switches (+ push notifications when acms({ mobile: { push } }) is on), owners only
|
|
131
|
+
...(me.role === 'Owner' ? [{ key: 'mobile', label: 'Mobile app', href: '/admin/mobile', icon: icons.phone, count: null }] : []),
|
|
133
132
|
],
|
|
134
133
|
},
|
|
135
134
|
{
|
package/src/lib/api.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { validateSession, OWNER_ENV, SESSION_COOKIE } from './auth';
|
|
|
4
4
|
import type { ContentRepository } from './repo/types';
|
|
5
5
|
|
|
6
6
|
export class ApiError extends Error {
|
|
7
|
-
|
|
7
|
+
/** retryAfter: seconds a 429 should wait (mobile API Retry-After; default 60) */
|
|
8
|
+
constructor(public status: number, message: string, public retryAfter?: number) { super(message); }
|
|
8
9
|
}
|
|
9
10
|
export const json = (body: unknown, status = 200) => Response.json(body, {
|
|
10
11
|
status, headers: { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' },
|
|
@@ -38,7 +38,7 @@ export async function exportContent(repo: ContentRepository, now = Date.now()) {
|
|
|
38
38
|
]);
|
|
39
39
|
return {
|
|
40
40
|
format: 'acms-backup' as const, version: 6, exportedAt: new Date(now).toISOString(),
|
|
41
|
-
settings: { ...settings, live: { ...settings.live, mode: 'off' as const, manual: { watchUrl: '', title: 'We’re live', until: 0 } }, ai: { ...settings.ai, enabled: false, encryptedKey: '' }, agent: { ...settings.agent, enabled: false, keyHash: '', keyPrefix: '', allowPublish: false } },
|
|
41
|
+
settings: { ...settings, live: { ...settings.live, mode: 'off' as const, manual: { watchUrl: '', title: 'We’re live', until: 0 } }, ai: { ...settings.ai, enabled: false, encryptedKey: '' }, agent: { ...settings.agent, enabled: false, keyHash: '', keyPrefix: '', allowPublish: false }, monitoring: { ...settings.monitoring, pushUrl: '' } },
|
|
42
42
|
pages, posts, media, links, tags, navItems, users, redirects,
|
|
43
43
|
calendars: calendars.map(c => ({ ...c, feedToken: '' })), events,
|
|
44
44
|
resources: resources.map(r => ({ ...r, feedToken: '' })), bookingLinks, ...bookingState, places, forms, formSubmissions,
|
|
@@ -147,7 +147,8 @@ export function slotsForDay(ctx: AvailabilityContext, date: string, minutes?: nu
|
|
|
147
147
|
return out;
|
|
148
148
|
}
|
|
149
149
|
const step = Math.max(5, ctx.link.step) * MIN;
|
|
150
|
-
|
|
150
|
+
// a requested length must be one the booking type offers (a 30-minute slot can't be held for 8 hours)
|
|
151
|
+
const lengths = minutes ? linkLengths(ctx.link).filter((l) => l === minutes) : linkLengths(ctx.link);
|
|
151
152
|
const out: Slot[] = [];
|
|
152
153
|
const [y, m, d] = date.split('-').map(Number);
|
|
153
154
|
for (const [a, b] of dayRanges(ctx.resource, ctx.link, date)) {
|
|
@@ -195,10 +196,22 @@ export function nextOpenings(ctx: AvailabilityContext, count: number, horizonDay
|
|
|
195
196
|
return out;
|
|
196
197
|
}
|
|
197
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Day bookings run midnight to midnight in the resource zone, so across a DST
|
|
201
|
+
* change a "1 day" span lasts 23 or 25 hours. Callers send start + n × 1440
|
|
202
|
+
* minutes; this snaps the end to the real local midnight (other modes unchanged).
|
|
203
|
+
*/
|
|
204
|
+
export function spanEnd(resource: Resource, link: BookingLink, siteTimezone: string, start: number, end: number): number {
|
|
205
|
+
if (link.mode !== 'day') return end;
|
|
206
|
+
const tz = resourceZone(resource, siteTimezone);
|
|
207
|
+
return localMidnight(addDaysYmd(ymd(start, tz), Math.max(1, Math.round((end - start) / DAY))), tz);
|
|
208
|
+
}
|
|
209
|
+
|
|
198
210
|
// server-side check before an insert (the DB guard backs it up for races)
|
|
199
211
|
export function isBookable(ctx: AvailabilityContext, start: number, end: number): boolean {
|
|
200
212
|
const tz = resourceZone(ctx.resource, ctx.siteTimezone);
|
|
201
|
-
|
|
213
|
+
// day spans are counted in local days (a 25-hour DST day is still 1440)
|
|
214
|
+
const minutes = ctx.link.mode === 'day' ? Math.round((end - start) / DAY) * 1440 : Math.round((end - start) / MIN);
|
|
202
215
|
return slotsForDay(ctx, ymd(start, tz), minutes).some((s) => s.start === start && s.end === end);
|
|
203
216
|
}
|
|
204
217
|
|
|
@@ -12,7 +12,7 @@ import { kickEmail } from '../email/service';
|
|
|
12
12
|
import { manageUrl as manageUrlFor, eventUrl } from '../calendar/config';
|
|
13
13
|
import { eventTimeZone, locationLine } from '../calendar/events';
|
|
14
14
|
import { bookingEmail, whenText, type BookingEmail } from './emails';
|
|
15
|
-
import { holdOf, isBookable, resourceZone, type AvailabilityContext, type Interval } from './availability';
|
|
15
|
+
import { holdOf, isBookable, resourceZone, spanEnd, type AvailabilityContext, type Interval } from './availability';
|
|
16
16
|
import { decideStatus, linkApproval } from './approval';
|
|
17
17
|
import { eventHolds } from './holds';
|
|
18
18
|
|
|
@@ -165,6 +165,54 @@ export function validateBooker(link: Pick<BookingLink, 'questions' | 'askPhone'>
|
|
|
165
165
|
|
|
166
166
|
// ---- workflows ---------------------------------------------------------------
|
|
167
167
|
|
|
168
|
+
// App access limit: new bookings + RSVPs per address per day, on every public
|
|
169
|
+
// channel. Staff adding a booking by hand is never capped.
|
|
170
|
+
async function checkEmailCap(repo: ContentRepository, email: string, source: Booking['source'] | undefined, settings: Settings, now = Date.now()) {
|
|
171
|
+
const cap = settings.mobileAccess.bookingsPerEmailDay;
|
|
172
|
+
if (!cap || source === 'admin') return;
|
|
173
|
+
if (await repo.countBookingsByEmail(email, now - 86_400_000) >= cap) throw new BookingError(429, 'That’s the most bookings one email address can make in a day. Please try again tomorrow or contact us.');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* What the booker may still do themselves (manage page, apps). A booking
|
|
178
|
+
* type's change cutoff closes guest changes that many hours before the
|
|
179
|
+
* start; staff can always act from the admin.
|
|
180
|
+
*/
|
|
181
|
+
export function guestChanges(b: Booking, link: BookingLink | null, now = Date.now()) {
|
|
182
|
+
const active = b.status === 'pending' || b.status === 'confirmed';
|
|
183
|
+
const cutoff = b.kind === 'booking' ? (link?.changeCutoff ?? 0) : 0;
|
|
184
|
+
const closed = active && cutoff > 0 && b.start - now < cutoff * 3_600_000;
|
|
185
|
+
const open = active && b.end > now && !closed;
|
|
186
|
+
return {
|
|
187
|
+
cancel: open,
|
|
188
|
+
reschedule: open && b.kind === 'booking' && b.start > now && !!link?.active,
|
|
189
|
+
// hours before the start when guest changes close; 0 = until it ends
|
|
190
|
+
cutoffHours: cutoff,
|
|
191
|
+
closed,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function guestCancel(repo: ContentRepository, b: Booking, opts: FlowOptions): Promise<Booking> {
|
|
196
|
+
const link = b.linkId ? await repo.getBookingLink(b.linkId) : null;
|
|
197
|
+
if (b.status === 'cancelled') return b;
|
|
198
|
+
const can = guestChanges(b, link, opts.now);
|
|
199
|
+
if (!can.cancel) throw new BookingError(409, can.closed ? `Changes close ${can.cutoffHours} hours before the start. Please contact us to cancel.` : 'This booking can’t be cancelled.');
|
|
200
|
+
return setBookingStatus(repo, b, 'cancelled', opts);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** the link as a guest moving a booking sees it: no new time inside the change cutoff */
|
|
204
|
+
export const guestMoveLink = (link: BookingLink): BookingLink =>
|
|
205
|
+
link.changeCutoff > 0 ? { ...link, minNotice: Math.max(link.minNotice, link.changeCutoff * 60) } : link;
|
|
206
|
+
|
|
207
|
+
export async function guestReschedule(repo: ContentRepository, b: Booking, start: number, end: number, opts: FlowOptions): Promise<Booking> {
|
|
208
|
+
const link = b.linkId ? await repo.getBookingLink(b.linkId) : null;
|
|
209
|
+
const can = guestChanges(b, link, opts.now);
|
|
210
|
+
if (!can.reschedule) throw new BookingError(409, can.closed ? `Changes close ${can.cutoffHours} hours before the start. Please contact us to move it.` : 'This booking can’t be moved.');
|
|
211
|
+
// the new time must respect the cutoff too
|
|
212
|
+
if (link && link.changeCutoff > 0 && start - (opts.now ?? Date.now()) < link.changeCutoff * 3_600_000) throw new BookingError(409, `Pick a time at least ${link.changeCutoff} hours from now.`);
|
|
213
|
+
return rescheduleBooking(repo, b, start, end, opts);
|
|
214
|
+
}
|
|
215
|
+
|
|
168
216
|
export async function createResourceBooking(
|
|
169
217
|
repo: ContentRepository,
|
|
170
218
|
args: { resource: Resource; link: BookingLink; start: number; end: number; booker: BookerInput; source?: Booking['source']; status?: BookingStatus },
|
|
@@ -173,7 +221,9 @@ export async function createResourceBooking(
|
|
|
173
221
|
const { resource, link } = args;
|
|
174
222
|
if (!link.active) throw new BookingError(410, 'This booking link is turned off.');
|
|
175
223
|
const settings = await repo.getSettings();
|
|
224
|
+
args = { ...args, end: spanEnd(resource, link, settings.timezone, args.start, args.end) };
|
|
176
225
|
const booker = validateBooker(link, args.booker);
|
|
226
|
+
await checkEmailCap(repo, booker.email, args.source, settings, opts.now);
|
|
177
227
|
// staff adding a booking by hand may ignore notice windows, never overlaps
|
|
178
228
|
const ctx = await availabilityContext(repo, resource, link, settings, opts.now ?? Date.now());
|
|
179
229
|
if (args.source !== 'admin' && !isBookable(ctx, args.start, args.end)) throw new BookingError(409, 'That time was just taken or isn’t available. Please pick another.');
|
|
@@ -219,6 +269,7 @@ export async function createRsvp(
|
|
|
219
269
|
if (args.end <= (opts.now ?? Date.now())) throw new BookingError(410, 'This event has already happened.');
|
|
220
270
|
const seats = Math.max(1, Math.min(e.rsvp.maxSeats || 1, Math.floor(args.seats) || 1));
|
|
221
271
|
const booker = validateBooker(null, args.booker);
|
|
272
|
+
await checkEmailCap(repo, booker.email, args.source, await repo.getSettings(), opts.now);
|
|
222
273
|
const formResponse = e.rsvp.form ? await collectResponse(e.rsvp.form, args.booker.answers ?? {}, args.booker.formVersion) : undefined;
|
|
223
274
|
if (formResponse) booker.answers = displayAnswers(formResponse);
|
|
224
275
|
const b = await repo.createBooking({
|
|
@@ -264,6 +315,7 @@ export async function rescheduleBooking(repo: ContentRepository, b: Booking, sta
|
|
|
264
315
|
if (b.status !== 'pending' && b.status !== 'confirmed') throw new BookingError(409, 'Only active bookings can be moved.');
|
|
265
316
|
const [resource, link, settings] = await Promise.all([repo.getResource(b.resourceId), repo.getBookingLink(b.linkId), repo.getSettings()]);
|
|
266
317
|
if (!resource || !link) throw new BookingError(404, 'This booking’s room or link no longer exists.');
|
|
318
|
+
end = spanEnd(resource, link, settings.timezone, start, end);
|
|
267
319
|
if (!opts.skipRules) {
|
|
268
320
|
const ctx = await availabilityContext(repo, resource, link, settings, opts.now ?? Date.now(), b.id);
|
|
269
321
|
if (!isBookable(ctx, start, end)) throw new BookingError(409, 'That time isn’t available.');
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ContentRepository } from '../repo/types';
|
|
2
|
+
import type { Calendar } from '../schema';
|
|
3
|
+
import { slugify } from '../schema';
|
|
4
|
+
import { ApiError } from '../api';
|
|
5
|
+
import { normalizeCalendarSrc } from './src';
|
|
6
|
+
import { safeColor } from './events';
|
|
7
|
+
|
|
8
|
+
// Create or edit a calendar: the admin's Calendar settings and editor apps
|
|
9
|
+
// share these rules (feed URL normalization, unique slug, safe color).
|
|
10
|
+
export interface CalendarInput {
|
|
11
|
+
name: string; slug?: string; color?: string; description?: string;
|
|
12
|
+
kind?: 'native' | 'feed'; feedUrl?: string; visibility?: Calendar['visibility'];
|
|
13
|
+
}
|
|
14
|
+
export async function saveCalendar(repo: ContentRepository, id: string | undefined, data: CalendarInput): Promise<Calendar> {
|
|
15
|
+
const existing = id ? await repo.getCalendar(id) : null;
|
|
16
|
+
if (id && !existing) throw new ApiError(404, 'Calendar not found.');
|
|
17
|
+
const kind = existing?.kind ?? data.kind ?? 'native';
|
|
18
|
+
let feedUrl = existing?.feedUrl ?? '';
|
|
19
|
+
if (kind === 'feed' && (data.feedUrl !== undefined || !existing)) {
|
|
20
|
+
const normalized = normalizeCalendarSrc(data.feedUrl ?? '');
|
|
21
|
+
if (!normalized) throw new ApiError(400, 'Paste a Google Calendar link, a calendar ID, or an .ics feed URL.');
|
|
22
|
+
feedUrl = normalized;
|
|
23
|
+
}
|
|
24
|
+
const base = slugify(data.slug || data.name);
|
|
25
|
+
let slug = base;
|
|
26
|
+
for (let n = 2; ; n++) {
|
|
27
|
+
const clash = await repo.getCalendarBySlug(slug);
|
|
28
|
+
if (!clash || clash.id === id) break;
|
|
29
|
+
slug = `${base}-${n}`;
|
|
30
|
+
}
|
|
31
|
+
const fields = {
|
|
32
|
+
name: data.name, slug, color: safeColor(data.color ?? existing?.color ?? '#4f46e5'),
|
|
33
|
+
description: data.description ?? existing?.description ?? '', feedUrl,
|
|
34
|
+
visibility: data.visibility ?? existing?.visibility ?? 'public',
|
|
35
|
+
};
|
|
36
|
+
if (existing) {
|
|
37
|
+
await repo.updateCalendar(existing.id, fields);
|
|
38
|
+
return (await repo.getCalendar(existing.id))!;
|
|
39
|
+
}
|
|
40
|
+
return repo.createCalendar({ ...fields, kind });
|
|
41
|
+
}
|
package/src/lib/editor/me.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { I18N, extraLocales, localeName } from '../i18n';
|
|
|
5
5
|
import { translationEngine } from '../translate';
|
|
6
6
|
import { siteOrigin } from '../seo';
|
|
7
7
|
import type { EditorPrincipal } from './auth';
|
|
8
|
+
import { canManageSchedule, isAdmin } from './roles';
|
|
8
9
|
|
|
9
10
|
/** who is signed in on this device, and what this site lets them do */
|
|
10
11
|
export async function me(repo: ContentRepository, who: EditorPrincipal, url: URL, locals: unknown) {
|
|
@@ -25,6 +26,9 @@ export async function me(repo: ContentRepository, who: EditorPrincipal, url: URL
|
|
|
25
26
|
documents: { edit: true, publish: true },
|
|
26
27
|
sections: { editContent: true, editLocked: owner, publishSection: true, changeLayout: false },
|
|
27
28
|
bookings: bookingsEnabled(),
|
|
29
|
+
schedule: { view: CALENDAR.enabled, manage: CALENDAR.enabled && canManageSchedule(who, settings), ignoreRules: bookingsEnabled() && isAdmin(who) },
|
|
30
|
+
forms: { list: true, readResponses: isAdmin(who) },
|
|
31
|
+
links: { edit: true, editHtml: isAdmin(who) },
|
|
28
32
|
},
|
|
29
33
|
};
|
|
30
34
|
}
|
|
@@ -4,6 +4,8 @@ import { ActionInput, CreateInput, EventFields, PageFields, PostFields, listQuer
|
|
|
4
4
|
import { SectionPatch, SectionsQuery, RevisionInput } from './sections';
|
|
5
5
|
import { AiDocInput, AiSectionInput, ReviewDocInput, ReviewSectionInput, SetDocInput, SetSectionInput } from './translations';
|
|
6
6
|
import { BookingActionInput, InboxQuery } from './bookings';
|
|
7
|
+
import { BlockInput, CalendarBody, CalendarPatch, LinkInput, LinkPatch, RescheduleInput, ResourceInput, ResourcePatch, ScheduleQuery, StaffBookingInput } from './schedule';
|
|
8
|
+
import { LinksPut, ResponsesQuery } from './site-data';
|
|
7
9
|
import { pageQuery } from '../mobile/http';
|
|
8
10
|
|
|
9
11
|
// OpenAPI for editor apps: device sign-in (/api/v1/auth/*) and the bearer-only
|
|
@@ -106,7 +108,53 @@ export function editorPaths(jsonSchema: Json) {
|
|
|
106
108
|
description: 'Images up to 30 MB, documents up to 25 MB; optional variant_<width> WebP fields (max 8). Compress images on the device first.' }),
|
|
107
109
|
options: preflight,
|
|
108
110
|
},
|
|
109
|
-
[`${P}/bookings`]: {
|
|
111
|
+
[`${P}/bookings`]: {
|
|
112
|
+
get: op(`${P}/bookings`, 'Bookings inbox (pending first); requires bookings', 'EditorBookingList', { query: InboxQuery }),
|
|
113
|
+
post: op(`${P}/bookings`, 'Book on someone’s behalf (the guest gets the usual emails); requires bookings', 'EditorBooking', { body: StaffBookingInput, write: true, status: 201,
|
|
114
|
+
description: 'Normal availability rules apply. ignoreRules (administrators and owners) books outside hours and notice, never over another booking.' }),
|
|
115
|
+
options: preflight,
|
|
116
|
+
},
|
|
117
|
+
[`${P}/bookings/{id}/reschedule`]: {
|
|
118
|
+
post: op(`${P}/bookings/{id}/reschedule`, 'Move a booking (the guest is emailed); ignoreRules for administrators and owners', 'EditorBooking', { body: RescheduleInput, write: true }),
|
|
119
|
+
options: preflight,
|
|
120
|
+
},
|
|
121
|
+
[`${P}/schedule`]: { get: op(`${P}/schedule`, 'Bookings, blocked time, events and subscribed calendars for up to 63 days; requires calendar', 'EditorSchedule', { query: ScheduleQuery.innerType() }), options: preflight },
|
|
122
|
+
[`${P}/resources`]: {
|
|
123
|
+
get: op(`${P}/resources`, 'Rooms and services with hours and booking types; requires bookings', 'EditorResourceList'),
|
|
124
|
+
post: op(`${P}/resources`, 'Add a room or service (schedule role)', 'EditorResource', { body: ResourceInput, write: true, status: 201 }),
|
|
125
|
+
options: preflight,
|
|
126
|
+
},
|
|
127
|
+
[`${P}/resources/{id}`]: {
|
|
128
|
+
get: op(`${P}/resources/{id}`, 'One room or service', 'EditorResource'),
|
|
129
|
+
patch: op(`${P}/resources/{id}`, 'Edit a room or service: details, weekly hours, date overrides (schedule role)', 'EditorResource', { body: ResourcePatch, write: true }),
|
|
130
|
+
delete: op(`${P}/resources/{id}`, 'Delete a room or service (schedule role)', 'EditorDeleted', { write: true }),
|
|
131
|
+
options: preflight,
|
|
132
|
+
},
|
|
133
|
+
[`${P}/resources/{id}/links`]: { post: op(`${P}/resources/{id}/links`, 'Add a booking type (schedule role)', 'EditorBookingLink', { body: LinkInput, write: true, status: 201 }), options: preflight },
|
|
134
|
+
[`${P}/resources/{id}/links/{linkId}`]: {
|
|
135
|
+
patch: op(`${P}/resources/{id}/links/{linkId}`, 'Edit a booking type (schedule role)', 'EditorBookingLink', { body: LinkPatch, write: true }),
|
|
136
|
+
delete: op(`${P}/resources/{id}/links/{linkId}`, 'Delete a booking type (schedule role)', 'EditorDeleted', { write: true }),
|
|
137
|
+
options: preflight,
|
|
138
|
+
},
|
|
139
|
+
[`${P}/resources/{id}/blocks`]: { post: op(`${P}/resources/{id}/blocks`, 'Block time on a room (no booking can land on it)', 'EditorBlock', { body: BlockInput.innerType(), write: true, status: 201 }), options: preflight },
|
|
140
|
+
[`${P}/resources/{id}/blocks/{blockId}`]: { delete: op(`${P}/resources/{id}/blocks/{blockId}`, 'Remove blocked time', 'EditorDeleted', { write: true }), options: preflight },
|
|
141
|
+
[`${P}/calendars`]: {
|
|
142
|
+
get: op(`${P}/calendars`, 'Every calendar, private ones included; requires calendar', 'EditorCalendarList'),
|
|
143
|
+
post: op(`${P}/calendars`, 'Add a calendar or subscribe to a feed (schedule role)', 'EditorCalendar', { body: CalendarBody, write: true, status: 201 }),
|
|
144
|
+
options: preflight,
|
|
145
|
+
},
|
|
146
|
+
[`${P}/calendars/{id}`]: {
|
|
147
|
+
patch: op(`${P}/calendars/{id}`, 'Rename, recolor, change visibility or feed (schedule role)', 'EditorCalendar', { body: CalendarPatch, write: true }),
|
|
148
|
+
delete: op(`${P}/calendars/{id}`, 'Delete a calendar (schedule role)', 'EditorDeleted', { write: true }),
|
|
149
|
+
options: preflight,
|
|
150
|
+
},
|
|
151
|
+
[`${P}/forms`]: { get: op(`${P}/forms`, 'Forms with response counts', 'EditorFormList'), options: preflight },
|
|
152
|
+
[`${P}/forms/{id}/responses`]: { get: op(`${P}/forms/{id}/responses`, 'Form responses, newest first (administrators and owners)', 'EditorFormResponses', { query: ResponsesQuery }), options: preflight },
|
|
153
|
+
[`${P}/links`]: {
|
|
154
|
+
get: op(`${P}/links`, 'The links page: profile and every block (hidden ones included)', 'EditorLinksPage'),
|
|
155
|
+
put: op(`${P}/links`, 'Replace the links page (profile + blocks in order) at the revision from GET; 409 when someone changed it since (nothing is saved). HTML blocks need an administrator or owner', 'EditorLinksPage', { body: LinksPut, write: true }),
|
|
156
|
+
options: preflight,
|
|
157
|
+
},
|
|
110
158
|
...Object.fromEntries((['approve', 'decline', 'cancel'] as const).map((action) => [`${P}/bookings/{id}/${action}`, {
|
|
111
159
|
post: op(`${P}/bookings/{id}/${action}`, `${action[0].toUpperCase()}${action.slice(1)} a booking (the guest is emailed)`, 'EditorBooking', { body: BookingActionInput, write: true }),
|
|
112
160
|
options: preflight,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Settings } from '../schema';
|
|
2
|
+
import { ApiError } from '../api';
|
|
3
|
+
import type { EditorPrincipal } from './auth';
|
|
4
|
+
|
|
5
|
+
// Role checks for editor apps. The role is read live on every request
|
|
6
|
+
// (authenticateDevice), so a demotion takes effect on the next call.
|
|
7
|
+
|
|
8
|
+
const ADMIN = new Set(['administrator', 'owner']);
|
|
9
|
+
export const isAdmin = (who: EditorPrincipal) => ADMIN.has(who.user.role);
|
|
10
|
+
|
|
11
|
+
/** form responses, raw HTML link blocks, overriding booking rules */
|
|
12
|
+
export function requireAdmin(who: EditorPrincipal, what = 'do this') {
|
|
13
|
+
if (!isAdmin(who)) throw new ApiError(403, `Only administrators and owners can ${what}.`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** rooms, booking types, hours and calendars (Admin → Mobile app decides who) */
|
|
17
|
+
export const canManageSchedule = (who: EditorPrincipal, settings: Settings) => settings.mobileAccess.scheduleRoles === 'staff' || isAdmin(who);
|
|
18
|
+
export function requireScheduleRole(who: EditorPrincipal, settings: Settings) {
|
|
19
|
+
if (!canManageSchedule(who, settings)) throw new ApiError(403, 'Only administrators and owners can change rooms, booking types, hours and calendars.');
|
|
20
|
+
}
|