@acms-subir/core 0.3.9 → 0.3.11
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 +51 -0
- package/cli/common.mjs +2 -2
- package/cli/legacy.mjs +2 -1
- package/cli/main.mjs +17 -3
- package/cli/migrate.mjs +102 -0
- package/cli/migrations.generated.mjs +1044 -0
- package/cli/mobile-doctor.mjs +9 -0
- package/cli/monitor.mjs +189 -0
- package/cli/preview.mjs +1 -1
- package/deploy/Dockerfile +5 -1
- package/deploy/compose.yml +21 -0
- package/docs/live-video.md +1 -1
- package/docs/mobile.md +23 -2
- package/docs/theme-authoring.md +6 -0
- package/integration.mjs +74 -4
- package/migrate.mjs +5 -0
- package/package.json +9 -11
- package/src/actions/index.ts +11 -31
- package/src/components/AppAccess.tsx +117 -0
- package/src/components/DesignManager.tsx +29 -10
- package/src/components/EmailSettings.tsx +53 -11
- package/src/components/MonitoringSettings.tsx +112 -0
- package/src/components/ThemeTransfer.tsx +211 -0
- package/src/components/auth/ClerkSession.astro +80 -0
- package/src/components/auth/ReverifyForms.astro +61 -0
- package/src/components/auth/clerk.ts +169 -0
- package/src/components/import/ImportExport.tsx +16 -6
- package/src/components/import/runner.ts +3 -0
- package/src/layouts/Admin.astro +42 -12
- package/src/layouts/Site.astro +6 -1
- package/src/lib/api.ts +8 -4
- package/src/lib/auth/accounts.ts +94 -0
- package/src/lib/auth/config.ts +22 -0
- package/src/lib/auth/login-limit.ts +15 -0
- package/src/lib/auth/request.ts +161 -0
- package/src/lib/auth/store.ts +72 -0
- package/src/lib/auth.ts +3 -0
- package/src/lib/backup/archive.ts +26 -0
- package/src/lib/backup/content.ts +1 -1
- package/src/lib/backup/offsite.ts +173 -0
- package/src/lib/backup/store.ts +28 -0
- package/src/lib/booking/availability.ts +15 -2
- package/src/lib/booking/service.ts +53 -1
- package/src/lib/cache/config.ts +7 -0
- package/src/lib/cache/memory.ts +39 -0
- package/src/lib/cache/policy.ts +89 -0
- package/src/lib/cache/purge.ts +89 -0
- package/src/lib/calendar/admin.ts +41 -0
- package/src/lib/designPreview.ts +2 -2
- package/src/lib/dynamicBlocks.ts +1 -1
- package/src/lib/editor/auth.ts +35 -5
- package/src/lib/editor/http.ts +4 -1
- 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/editor/store.ts +13 -7
- package/src/lib/editor/token-route.ts +2 -1
- package/src/lib/email/accounts.ts +4 -0
- package/src/lib/email/service.ts +9 -1
- package/src/lib/email/store.ts +10 -0
- package/src/lib/email/templates.ts +8 -1
- package/src/lib/email/types.ts +1 -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 +6 -5
- 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 +63 -4
- 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/net.ts +82 -14
- package/src/lib/repo/migrate-runner.ts +96 -0
- package/src/lib/repo/migrations.ts +104 -0
- package/src/lib/repo/sql.ts +122 -44
- package/src/lib/repo/sqlite.ts +14 -1
- package/src/lib/repo/types.ts +31 -3
- package/src/lib/scheduler.ts +14 -0
- package/src/lib/schema.ts +35 -1
- package/src/lib/theme-transfer.ts +163 -0
- package/src/lib/theme.ts +5 -0
- package/src/lib/webhooks.ts +7 -2
- package/src/middleware.ts +41 -11
- package/src/pages/[...slug].astro +3 -2
- package/src/pages/account/[action].astro +6 -0
- package/src/pages/admin/app-auth.astro +21 -7
- package/src/pages/admin/calendar/resources/link.astro +2 -0
- package/src/pages/admin/design.astro +2 -1
- package/src/pages/admin/import.astro +5 -3
- package/src/pages/admin/index.astro +3 -2
- package/src/pages/admin/integrations.astro +3 -2
- package/src/pages/admin/login.astro +236 -25
- package/src/pages/admin/logout.ts +16 -2
- package/src/pages/admin/mobile.astro +6 -2
- package/src/pages/admin/settings.astro +43 -5
- package/src/pages/admin/users.astro +350 -59
- package/src/pages/api/backup.ts +10 -26
- package/src/pages/api/design/export.ts +72 -0
- package/src/pages/api/design/import.ts +41 -0
- package/src/pages/api/email/settings.ts +9 -5
- package/src/pages/api/health.ts +17 -0
- package/src/pages/api/live/oauth/youtube/callback.ts +5 -4
- package/src/pages/api/live/settings.ts +2 -2
- package/src/pages/api/media/upload.ts +2 -2
- 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/push/admin.ts +3 -2
- package/src/pages/api/restore.ts +6 -3
- 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/calendars.ts +1 -1
- 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/openapi.json.ts +1 -1
- package/src/pages/api/v1/push/topics.ts +1 -1
- package/src/pages/api/v1/resources.ts +1 -1
- package/src/pages/api/v1/search.ts +7 -0
- package/src/pages/api/v1/theme.ts +1 -1
- package/src/pages/blog/[slug].astro +3 -2
- package/src/pages/book/[resource]/[link].astro +3 -3
- package/src/pages/book/manage/[token].astro +11 -7
- package/src/pages/events/[slug].astro +3 -2
- package/src/pages/preview/[type]/[id].astro +3 -3
- package/src/styles/admin.css +169 -16
- package/src/styles/email-settings.css +6 -0
- package/src/virtual.d.ts +1 -0
- package/src/worker.ts +7 -1
package/src/lib/editor/auth.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { AUTH } from '../auth/config';
|
|
2
|
+
import { validateMobileBinding, authEnvironment, ClerkUnavailableError } from '../auth/request';
|
|
3
|
+
import type { ClerkBinding } from '../auth/store';
|
|
1
4
|
import { Role } from '../schema';
|
|
2
5
|
import { createHmac } from 'node:crypto';
|
|
3
6
|
import { env } from '../env';
|
|
@@ -129,10 +132,12 @@ export function checkConsentTicket(ticket: string, session: string, p: Authorize
|
|
|
129
132
|
}
|
|
130
133
|
|
|
131
134
|
/** approve: a one-time code bound to the user, app, return address and challenge */
|
|
132
|
-
export async function issueCode(repo: ContentRepository, user: { id: string; sessionVersion: number }, p: AuthorizeParams, deviceName: string, now = Date.now()): Promise<string> {
|
|
135
|
+
export async function issueCode(repo: ContentRepository, user: { id: string; sessionVersion: number }, p: AuthorizeParams, deviceName: string, now = Date.now(), binding?: ClerkBinding): Promise<string> {
|
|
136
|
+
if (AUTH.provider === 'clerk' && !binding) throw new ApiError(401, 'Clerk sign-in is required.');
|
|
133
137
|
const code = randomToken(CODE_PREFIX);
|
|
134
138
|
await repo.editor.createCode({ hash: await sha256Hex(code), userId: user.id, clientId: p.clientId, redirectUri: p.redirectUri,
|
|
135
|
-
challenge: p.codeChallenge, deviceName: cleanDeviceName(deviceName) || 'Phone', sessionVersion: user.sessionVersion, expiresAt: now + EDITOR.codeMs }, now);
|
|
139
|
+
challenge: p.codeChallenge, authProvider: AUTH.provider, deviceName: cleanDeviceName(deviceName) || 'Phone', sessionVersion: user.sessionVersion, expiresAt: now + EDITOR.codeMs }, now);
|
|
140
|
+
if (binding) await repo.auth.bindMobile('code', await sha256Hex(code), binding);
|
|
136
141
|
return code;
|
|
137
142
|
}
|
|
138
143
|
|
|
@@ -164,13 +169,28 @@ function tokenResponse(t: { access: string; refresh: string }, user: { id: strin
|
|
|
164
169
|
user: { id: user.id, name: user.name, email: user.email, role: Role.parse(user.role) }, device };
|
|
165
170
|
}
|
|
166
171
|
|
|
167
|
-
|
|
172
|
+
async function mobileBindingActive(...args: Parameters<typeof validateMobileBinding>) {
|
|
173
|
+
try { return await validateMobileBinding(...args); }
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (error instanceof ClerkUnavailableError) throw new ApiError(503, error.message);
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function tokenGrant(repo: ContentRepository, form: Record<string, string>, now = Date.now(), locals?: unknown): Promise<TokenResponse> {
|
|
168
181
|
const grant = form.grant_type;
|
|
169
182
|
if (grant === 'authorization_code') {
|
|
170
183
|
const { code = '', code_verifier: verifier = '', redirect_uri: redirectUri = '', client_id: clientId = '' } = form;
|
|
171
184
|
if (!code || !verifier || !redirectUri || !clientId) throw new OAuthError(400, 'invalid_request', 'code, code_verifier, redirect_uri and client_id are required.');
|
|
172
185
|
if (!wellFormed(code, CODE_PREFIX)) throw invalidGrant('The sign-in code is invalid or expired.');
|
|
173
|
-
const
|
|
186
|
+
const hash = await sha256Hex(code);
|
|
187
|
+
// Provider outages must not burn a usable one-time credential. The later
|
|
188
|
+
// atomic consume still enforces single use and detects concurrent replays.
|
|
189
|
+
if (AUTH.provider === 'clerk') {
|
|
190
|
+
const candidate = await repo.editor.getCode(hash);
|
|
191
|
+
if (!candidate || candidate.authProvider !== AUTH.provider || !await mobileBindingActive(repo, 'code', hash, candidate.userId, locals)) throw invalidGrant('The original sign-in is no longer active. Sign in again.');
|
|
192
|
+
}
|
|
193
|
+
const spent = await repo.editor.consumeCode(hash, now);
|
|
174
194
|
if (!spent) throw invalidGrant('The sign-in code is invalid or expired.');
|
|
175
195
|
const c = spent.code;
|
|
176
196
|
if (spent.reused) {
|
|
@@ -182,11 +202,14 @@ export async function tokenGrant(repo: ContentRepository, form: Record<string, s
|
|
|
182
202
|
// every check runs; the code is already burned whatever the outcome
|
|
183
203
|
const checks = [safeEqual(clientId, c.clientId), safeEqual(redirectUri, c.redirectUri), VERIFIER.test(verifier) && safeEqual(await s256(verifier), c.challenge), allowedRedirect(c.redirectUri)];
|
|
184
204
|
if (checks.includes(false)) throw invalidGrant('The sign-in code doesn’t match this app, return address or verifier.');
|
|
205
|
+
if (c.authProvider !== AUTH.provider) throw invalidGrant('The original sign-in is no longer active. Sign in again.');
|
|
185
206
|
const user = await liveUser(repo, c.userId, c.sessionVersion);
|
|
186
207
|
if (!user) throw invalidGrant('This account changed since sign-in. Sign in again.');
|
|
187
208
|
const device = { id: crypto.randomUUID(), name: c.deviceName };
|
|
188
209
|
const t = await pair(now);
|
|
189
|
-
await repo.editor.createDevice({ id: device.id, userId: user.id, name: device.name, clientId: c.clientId, sessionVersion: c.sessionVersion }, t.hashed, now);
|
|
210
|
+
await repo.editor.createDevice({ id: device.id, userId: user.id, name: device.name, clientId: c.clientId, sessionVersion: c.sessionVersion, authProvider: c.authProvider }, t.hashed, now);
|
|
211
|
+
const binding = AUTH.provider === 'clerk' ? await repo.auth.mobile('code', c.hash) : null;
|
|
212
|
+
if (binding) await repo.auth.bindMobile('device', device.id, binding);
|
|
190
213
|
await repo.editor.bindCode(c.hash, device.id);
|
|
191
214
|
await repo.editor.prune(now).catch(() => {});
|
|
192
215
|
return tokenResponse(t.plain, user, device);
|
|
@@ -196,6 +219,11 @@ export async function tokenGrant(repo: ContentRepository, form: Record<string, s
|
|
|
196
219
|
if (!token) throw new OAuthError(400, 'invalid_request', 'refresh_token is required.');
|
|
197
220
|
if (!wellFormed(token, REFRESH_PREFIX)) throw invalidGrant('The refresh token is invalid or expired.');
|
|
198
221
|
const hash = await sha256Hex(token);
|
|
222
|
+
if (AUTH.provider === 'clerk') {
|
|
223
|
+
const deviceId = await repo.editor.deviceForToken(hash);
|
|
224
|
+
const candidate = deviceId ? await repo.editor.getDevice(deviceId) : null;
|
|
225
|
+
if (!candidate || candidate.revokedAt !== null || candidate.authProvider !== AUTH.provider || !await mobileBindingActive(repo, 'device', candidate.id, candidate.userId, locals)) throw invalidGrant('The original sign-in is no longer active. Sign in again.');
|
|
226
|
+
}
|
|
199
227
|
const spent = await repo.editor.spendRefresh(hash, now);
|
|
200
228
|
if (!spent) throw invalidGrant('The refresh token is invalid or expired.');
|
|
201
229
|
// A rotated token presented again is theft, except for a short grace: the
|
|
@@ -209,6 +237,7 @@ export async function tokenGrant(repo: ContentRepository, form: Record<string, s
|
|
|
209
237
|
const device = await repo.editor.getDevice(spent.deviceId);
|
|
210
238
|
if (!device || device.revokedAt !== null) throw invalidGrant('This device was signed out. Sign in again.');
|
|
211
239
|
if (form.client_id && !safeEqual(form.client_id, device.clientId)) throw invalidGrant('The refresh token belongs to another app.');
|
|
240
|
+
if (device.authProvider !== AUTH.provider) throw invalidGrant('The original sign-in is no longer active. Sign in again.');
|
|
212
241
|
const user = await liveUser(repo, device.userId, device.sessionVersion);
|
|
213
242
|
if (!user) {
|
|
214
243
|
await repo.editor.revokeDevice(device.id, 'account changed', now);
|
|
@@ -254,6 +283,7 @@ export async function authenticateDevice(request: Request, repo: ContentReposito
|
|
|
254
283
|
await repo.editor.revokeDevice(access.device.id, 'password changed', now);
|
|
255
284
|
throw new ApiError(401, 'This account changed. Sign in again.');
|
|
256
285
|
}
|
|
286
|
+
if (access.device.authProvider !== AUTH.provider || (AUTH.provider === 'clerk' && !await mobileBindingActive(repo, 'device', access.device.id, access.user.id, authEnvironment(request), !['GET','HEAD'].includes(request.method)))) throw new ApiError(401, 'The original sign-in is no longer active. Sign in again.');
|
|
257
287
|
await repo.editor.touch(access.device.id, now);
|
|
258
288
|
const { user, device } = access;
|
|
259
289
|
return {
|
package/src/lib/editor/http.ts
CHANGED
|
@@ -25,6 +25,7 @@ export function editorRoute(handler: (c: EditorContext) => Promise<unknown>, opt
|
|
|
25
25
|
let who: EditorPrincipal;
|
|
26
26
|
try { who = await authenticateDevice(context.request, repo, now); }
|
|
27
27
|
catch (error) {
|
|
28
|
+
if (error instanceof ApiError && error.status === 503) throw error;
|
|
28
29
|
// failed bearer attempts are throttled per client address
|
|
29
30
|
if (!await repo.email.rateLimit(`editor:fail:${await sha256Hex(address(context))}`, 30, 60_000)) throw new ApiError(429, 'Too many requests. Try again in a minute.');
|
|
30
31
|
throw error;
|
|
@@ -37,7 +38,9 @@ export function editorRoute(handler: (c: EditorContext) => Promise<unknown>, opt
|
|
|
37
38
|
} catch (error) {
|
|
38
39
|
// expected failures carry a status (ApiError, BookingError) or zod issues; only surprises are logged (never request data)
|
|
39
40
|
if (typeof (error as { status?: unknown })?.status !== 'number' && !(error instanceof Error && 'issues' in error)) console.error('acms editor api: request failed:', error instanceof Error ? error.message : error);
|
|
40
|
-
|
|
41
|
+
const result = failure(error);
|
|
42
|
+
if (result.status === 503) result.headers.set('Retry-After', '10');
|
|
43
|
+
return result;
|
|
41
44
|
}
|
|
42
45
|
};
|
|
43
46
|
}
|
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
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ContentRepository } from '../repo/types';
|
|
3
|
+
import type { Booking, BookingLink, Resource, Settings } from '../schema';
|
|
4
|
+
import { ApiError } from '../api';
|
|
5
|
+
import { ApprovalMode, ApprovalRules, BookingMode, DateOverride, QUESTION_TYPES, TimeRange, WeeklyHours, slugify } from '../schema';
|
|
6
|
+
import { CALENDAR, bookingsEnabled } from '../calendar/config';
|
|
7
|
+
import { collectViewEvents } from '../calendar/data';
|
|
8
|
+
import { isValidTimeZone, safeColor } from '../calendar/events';
|
|
9
|
+
import { describeRepeat } from '../calendar/rrule';
|
|
10
|
+
import { zonedToUtc } from '../calendar/ics';
|
|
11
|
+
import { normalizeCalendarSrc } from '../calendar/src';
|
|
12
|
+
import { saveCalendar } from '../calendar/admin';
|
|
13
|
+
import { availabilityContext, bookingJson, createBlock, createResourceBooking, rescheduleBooking } from '../booking/service';
|
|
14
|
+
import { isBookable } from '../booking/availability';
|
|
15
|
+
import { siteOrigin } from '../seo';
|
|
16
|
+
import { waitUntilFrom } from '../webhooks';
|
|
17
|
+
import { isoDay } from '../mobile/http';
|
|
18
|
+
import type { EditorContext } from './http';
|
|
19
|
+
import { requireBookings } from './bookings';
|
|
20
|
+
import { requireAdmin, requireScheduleRole, canManageSchedule } from './roles';
|
|
21
|
+
|
|
22
|
+
// The schedule from editor apps: one-request week/month view, booking on
|
|
23
|
+
// someone's behalf, moving bookings, blocked time, and setup (rooms, booking
|
|
24
|
+
// types, hours, calendars). Setup writes need the schedule role (Admin →
|
|
25
|
+
// Mobile app; administrators + owners by default). Every write goes through
|
|
26
|
+
// the same services as the web admin and lands in the audit log.
|
|
27
|
+
|
|
28
|
+
const DAY = 86_400_000;
|
|
29
|
+
const iso = (n: number) => new Date(n).toISOString();
|
|
30
|
+
const flow = (c: EditorContext, settings: Settings) => ({ origin: siteOrigin(settings, c.context.url), locals: c.context.locals, waitUntil: waitUntilFrom(c.context.locals) });
|
|
31
|
+
function requireCalendarModule() { if (!CALENDAR.enabled) throw new ApiError(404, 'The calendar is turned off on this site.'); }
|
|
32
|
+
|
|
33
|
+
// ---- schedule view -----------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
export const ScheduleQuery = z.object({ from: isoDay, to: isoDay, resource: z.string().max(200).optional() }).strict()
|
|
36
|
+
.refine((q) => q.to >= q.from && Date.parse(q.to) - Date.parse(q.from) <= 62 * DAY, 'Use a range of 1 to 63 days.');
|
|
37
|
+
|
|
38
|
+
export async function schedule(c: EditorContext, q: z.infer<typeof ScheduleQuery>) {
|
|
39
|
+
requireCalendarModule();
|
|
40
|
+
const settings = await c.repo.getSettings();
|
|
41
|
+
const tz = settings.timezone || 'UTC';
|
|
42
|
+
const at = (d: string) => zonedToUtc(+d.slice(0, 4), +d.slice(5, 7), +d.slice(8, 10), 0, 0, 0, tz);
|
|
43
|
+
// site-local midnights: from the start of `from` to the end of `to`
|
|
44
|
+
const startMs = at(q.from), endMs = at(new Date(Date.parse(q.to) + DAY).toISOString().slice(0, 10));
|
|
45
|
+
const win = { displayTz: tz, now: Date.now(), windowStartMs: startMs, windowEndMs: endMs };
|
|
46
|
+
const resources = bookingsEnabled() ? await c.repo.listResources() : [];
|
|
47
|
+
const resource = q.resource ? resources.find((r) => r.id === q.resource || r.slug === q.resource) : null;
|
|
48
|
+
if (q.resource && !resource) throw new ApiError(404, 'Room or service not found.');
|
|
49
|
+
const [{ events, calendars, errors }, bookings] = await Promise.all([
|
|
50
|
+
collectViewEvents(c.repo, settings, win, { audience: 'admin', includeFeeds: true, includeBookings: false }),
|
|
51
|
+
bookingsEnabled() ? c.repo.listBookings({ from: startMs, to: win.windowEndMs, resourceId: resource?.id, statuses: ['pending', 'confirmed'], kinds: ['booking', 'block'] }) : Promise.resolve([] as Booking[]),
|
|
52
|
+
]);
|
|
53
|
+
const calById = new Map(calendars.map((cal) => [cal.id, cal]));
|
|
54
|
+
const items = [
|
|
55
|
+
...events.filter((v) => !resource || v.event?.roomHold.resourceIds.includes(resource.id)).map((v) => {
|
|
56
|
+
const e = v.event;
|
|
57
|
+
const cal = e ? e.calendarIds.map((id) => calById.get(id)).find(Boolean) : calendars.find((x) => x.name === v.calendarName && x.kind === 'feed');
|
|
58
|
+
return {
|
|
59
|
+
kind: e ? 'event' as const : 'feed' as const, id: e?.id ?? '', title: v.title,
|
|
60
|
+
start: iso(v.start), end: iso(v.end), allDay: v.allDay, status: e ? e.status : 'feed',
|
|
61
|
+
calendarId: cal?.id ?? '', color: v.color ?? (cal ? safeColor(cal.color) : '#8a8378'),
|
|
62
|
+
location: v.location, cancelled: Boolean(v.cancelled), repeat: e?.rrule ? describeRepeat(e.rrule, e.start) : '',
|
|
63
|
+
resourceIds: e?.roomHold.resourceIds ?? [],
|
|
64
|
+
};
|
|
65
|
+
}),
|
|
66
|
+
...bookings.map((b) => ({
|
|
67
|
+
kind: b.kind as 'booking' | 'block', id: b.id, title: b.kind === 'block' ? b.notes || 'Blocked' : b.name,
|
|
68
|
+
start: iso(b.start), end: iso(b.end), allDay: false, status: b.status, resourceId: b.resourceId, linkId: b.linkId || null,
|
|
69
|
+
name: b.name, email: b.email, phone: b.phone, notes: b.notes,
|
|
70
|
+
})),
|
|
71
|
+
].sort((a, b) => a.start.localeCompare(b.start));
|
|
72
|
+
return {
|
|
73
|
+
timezone: tz, from: q.from, to: q.to,
|
|
74
|
+
resources: resources.map((r) => ({ id: r.id, slug: r.slug, name: r.name, kind: r.kind })),
|
|
75
|
+
calendars: calendars.map((cal) => ({ id: cal.id, name: cal.name, color: safeColor(cal.color), kind: cal.kind, visibility: cal.visibility })),
|
|
76
|
+
items, errors,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---- bookings: on someone's behalf, moving ----------------------------------
|
|
81
|
+
|
|
82
|
+
async function resourceAndLink(repo: ContentRepository, resourceRef: string, linkRef: string) {
|
|
83
|
+
const resource = await repo.getResource(resourceRef) ?? await repo.getResourceBySlug(resourceRef);
|
|
84
|
+
const link = resource ? (await repo.listBookingLinks(resource.id)).find((l) => l.id === linkRef || l.slug === linkRef) ?? null : null;
|
|
85
|
+
if (!resource || !link) throw new ApiError(404, 'Room or booking type not found.');
|
|
86
|
+
return { resource, link };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const StaffBookingInput = z.object({
|
|
90
|
+
resource: z.string().min(1).max(200), link: z.string().min(1).max(200),
|
|
91
|
+
start: z.string().datetime({ offset: true }), minutes: z.number().int().min(1).max(44640).optional(),
|
|
92
|
+
name: z.string().trim().min(1).max(120), email: z.string().trim().email().max(200), phone: z.string().trim().max(40).default(''),
|
|
93
|
+
// question id → answer, as on the public form
|
|
94
|
+
answers: z.record(z.string().max(2000)).default({}),
|
|
95
|
+
status: z.enum(['confirmed', 'pending']).default('confirmed'),
|
|
96
|
+
// book outside hours/notice (never over another booking); administrators and owners only
|
|
97
|
+
ignoreRules: z.boolean().default(false),
|
|
98
|
+
}).strict();
|
|
99
|
+
|
|
100
|
+
export async function staffBook(c: EditorContext, body: z.infer<typeof StaffBookingInput>) {
|
|
101
|
+
requireBookings();
|
|
102
|
+
if (body.ignoreRules) requireAdmin(c.who, 'book outside the normal rules');
|
|
103
|
+
const settings = await c.repo.getSettings();
|
|
104
|
+
const { resource, link } = await resourceAndLink(c.repo, body.resource, body.link);
|
|
105
|
+
const start = Date.parse(body.start);
|
|
106
|
+
const minutes = body.minutes ?? (link.mode === 'day' ? 1440 : link.mode === 'range' ? link.lengths[0] ?? link.duration : link.duration);
|
|
107
|
+
const end = start + minutes * 60_000;
|
|
108
|
+
if (!body.ignoreRules && !isBookable(await availabilityContext(c.repo, resource, link, settings, Date.now()), start, end)) throw new ApiError(409, 'That time isn’t available. Pick another, or (as an administrator) book it outside the rules.');
|
|
109
|
+
// source 'admin' skips the rule check inside (done above) but never overlaps
|
|
110
|
+
const b = await createResourceBooking(c.repo, { resource, link, start, end, status: body.status, source: 'admin',
|
|
111
|
+
booker: { name: body.name, email: body.email, phone: body.phone, answers: body.answers } }, flow(c, settings));
|
|
112
|
+
return staffBookingView(b, resource, link);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const RescheduleInput = z.object({
|
|
116
|
+
start: z.string().datetime({ offset: true }), minutes: z.number().int().min(1).max(44640).optional(),
|
|
117
|
+
ignoreRules: z.boolean().default(false),
|
|
118
|
+
}).strict();
|
|
119
|
+
export async function staffReschedule(c: EditorContext, id: string, body: z.infer<typeof RescheduleInput>) {
|
|
120
|
+
requireBookings();
|
|
121
|
+
if (body.ignoreRules) requireAdmin(c.who, 'move bookings outside the normal rules');
|
|
122
|
+
const b = await c.repo.getBooking(id);
|
|
123
|
+
if (!b || b.kind !== 'booking') throw new ApiError(404, 'Booking not found.');
|
|
124
|
+
const settings = await c.repo.getSettings();
|
|
125
|
+
const start = Date.parse(body.start);
|
|
126
|
+
const minutes = body.minutes ?? Math.round((b.end - b.start) / 60_000);
|
|
127
|
+
const next = await rescheduleBooking(c.repo, b, start, start + minutes * 60_000, { ...flow(c, settings), skipRules: body.ignoreRules });
|
|
128
|
+
const [resource, link] = await Promise.all([c.repo.getResource(next.resourceId), c.repo.getBookingLink(next.linkId)]);
|
|
129
|
+
return staffBookingView(next, resource, link);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function staffBookingView(b: Booking, resource: Resource | null, link: BookingLink | null) {
|
|
133
|
+
return { ...bookingJson(b), title: [resource?.name, link?.name].filter(Boolean).join(' · ') || 'Booking', notes: b.notes, updatedAt: iso(b.updatedAt) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---- blocked time -------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
export const BlockInput = z.object({
|
|
139
|
+
start: z.string().datetime({ offset: true }), end: z.string().datetime({ offset: true }), note: z.string().trim().max(500).default(''),
|
|
140
|
+
}).strict().refine((b) => Date.parse(b.end) > Date.parse(b.start) && Date.parse(b.end) - Date.parse(b.start) <= 366 * DAY, 'The block must end after it starts (up to a year).');
|
|
141
|
+
export async function addBlock(c: EditorContext, resourceId: string, body: z.infer<typeof BlockInput>) {
|
|
142
|
+
requireBookings();
|
|
143
|
+
const resource = await c.repo.getResource(resourceId);
|
|
144
|
+
if (!resource) throw new ApiError(404, 'Room or service not found.');
|
|
145
|
+
const b = await createBlock(c.repo, resource, Date.parse(body.start), Date.parse(body.end), body.note);
|
|
146
|
+
if (!b) throw new ApiError(409, 'That time overlaps a booking. Move or cancel it first.');
|
|
147
|
+
return { id: b.id, resourceId: resource.id, start: iso(b.start), end: iso(b.end), note: b.notes };
|
|
148
|
+
}
|
|
149
|
+
export async function removeBlock(c: EditorContext, resourceId: string, blockId: string) {
|
|
150
|
+
requireBookings();
|
|
151
|
+
const b = await c.repo.getBooking(blockId);
|
|
152
|
+
if (!b || b.kind !== 'block' || b.resourceId !== resourceId) throw new ApiError(404, 'Blocked time not found.');
|
|
153
|
+
await c.repo.deleteBooking(b.id);
|
|
154
|
+
return { id: b.id, deleted: true as const };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---- rooms and services -------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
// a real clock time; as in the booking engine an end of 00:00 means midnight (end of day)
|
|
160
|
+
const clock = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'Use a 24-hour time like 09:30.');
|
|
161
|
+
const hhmm = (s: string, end = false) => (end && s === '00:00' ? 1440 : Number(s.slice(0, 2)) * 60 + Number(s.slice(3, 5)));
|
|
162
|
+
const Range = TimeRange.extend({ start: clock, end: clock }).refine((r) => hhmm(r.start) < hhmm(r.end, true), 'Each time range must end after it starts.');
|
|
163
|
+
const Hours = WeeklyHours.extend(Object.fromEntries(['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'].map((d) => [d, z.array(Range).max(12).default([])])) as Record<'su', z.ZodDefault<z.ZodArray<typeof Range>>>);
|
|
164
|
+
// endDate: leave out (or '') for a single day
|
|
165
|
+
const Override = DateOverride.extend({
|
|
166
|
+
ranges: z.array(Range).max(12).default([]), label: z.string().max(120).default(''),
|
|
167
|
+
}).refine((o) => !o.endDate || o.endDate >= o.date, 'An override must end on or after its first day.');
|
|
168
|
+
const imageUrl = z.string().max(1000).refine((v) => !v || /^(https?:\/\/|\/)/.test(v), 'Use an http(s) or site path image.');
|
|
169
|
+
|
|
170
|
+
export const ResourceInput = z.object({
|
|
171
|
+
name: z.string().trim().min(1).max(120), slug: z.string().max(120).optional(), kind: z.string().trim().max(40).optional(),
|
|
172
|
+
description: z.string().max(2000).optional(), photo: imageUrl.optional(), capacity: z.number().int().min(1).max(100000).nullable().optional(),
|
|
173
|
+
amenities: z.array(z.string().trim().min(1).max(80)).max(20).optional(),
|
|
174
|
+
timezone: z.string().max(64).refine((t) => !t || isValidTimeZone(t), 'Unknown timezone.').optional(),
|
|
175
|
+
hours: Hours.optional(), overrides: z.array(Override).max(200).optional(),
|
|
176
|
+
visibility: z.enum(['public', 'unlisted']).optional(), showOnCalendar: z.boolean().optional(), placeId: z.string().max(100).optional(),
|
|
177
|
+
// ICS / Google calendars whose events block this room (fetched like calendar feeds)
|
|
178
|
+
busyFeeds: z.array(z.string().max(2000)).max(10).optional(),
|
|
179
|
+
notifyEmails: z.array(z.string().trim().email().max(200)).max(10).optional(),
|
|
180
|
+
}).strict();
|
|
181
|
+
export const ResourcePatch = ResourceInput.partial();
|
|
182
|
+
|
|
183
|
+
async function uniqueResourceSlug(repo: ContentRepository, wanted: string, id?: string) {
|
|
184
|
+
const base = slugify(wanted) || 'room';
|
|
185
|
+
for (let n = 1; ; n++) {
|
|
186
|
+
const slug = n === 1 ? base : `${base}-${n}`;
|
|
187
|
+
const hit = await repo.getResourceBySlug(slug);
|
|
188
|
+
if (!hit || hit.id === id) return slug;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async function resourceFields(repo: ContentRepository, body: z.infer<typeof ResourcePatch>) {
|
|
192
|
+
const out: Partial<Resource> = { ...body } as Partial<Resource>;
|
|
193
|
+
delete (out as { slug?: string }).slug;
|
|
194
|
+
if (body.busyFeeds) {
|
|
195
|
+
const feeds = body.busyFeeds.map((f) => normalizeCalendarSrc(f));
|
|
196
|
+
if (feeds.some((f) => !f)) throw new ApiError(400, 'Each busy calendar must be an ICS or Google Calendar link.');
|
|
197
|
+
out.busyFeeds = feeds as string[];
|
|
198
|
+
}
|
|
199
|
+
if (body.placeId) { if (!(await repo.listPlaces()).some((p) => p.id === body.placeId)) throw new ApiError(400, 'Unknown place.'); }
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
function resourceView(r: Resource, links: BookingLink[], canManage: boolean) {
|
|
203
|
+
// the staff feed token is a secret; it never leaves the web admin. Busy
|
|
204
|
+
// calendars are often private Google "secret address" links: setup only.
|
|
205
|
+
const { feedToken: _secret, ...rest } = r;
|
|
206
|
+
return { ...rest, busyFeeds: canManage ? r.busyFeeds : [], createdAt: iso(r.createdAt), updatedAt: iso(r.updatedAt), links: links.filter((l) => l.resourceId === r.id).map(linkView) };
|
|
207
|
+
}
|
|
208
|
+
const linkView = (l: BookingLink) => ({ ...l, createdAt: iso(l.createdAt), updatedAt: iso(l.updatedAt) });
|
|
209
|
+
|
|
210
|
+
export async function listResourcesFor(c: EditorContext) {
|
|
211
|
+
requireBookings();
|
|
212
|
+
const [resources, links, settings] = await Promise.all([c.repo.listResources(), c.repo.listBookingLinks(), c.repo.getSettings()]);
|
|
213
|
+
const canEdit = canManageSchedule(c.who, settings);
|
|
214
|
+
return { canEdit, resources: resources.map((r) => resourceView(r, links, canEdit)) };
|
|
215
|
+
}
|
|
216
|
+
export async function getResourceFor(c: EditorContext, id: string) {
|
|
217
|
+
requireBookings();
|
|
218
|
+
const r = await c.repo.getResource(id);
|
|
219
|
+
if (!r) throw new ApiError(404, 'Room or service not found.');
|
|
220
|
+
return resourceView(r, await c.repo.listBookingLinks(r.id), canManageSchedule(c.who, await c.repo.getSettings()));
|
|
221
|
+
}
|
|
222
|
+
export async function createResourceFor(c: EditorContext, body: z.infer<typeof ResourceInput>) {
|
|
223
|
+
requireBookings();
|
|
224
|
+
requireScheduleRole(c.who, await c.repo.getSettings());
|
|
225
|
+
const r = await c.repo.createResource({ ...(await resourceFields(c.repo, body)), name: body.name, slug: await uniqueResourceSlug(c.repo, body.slug || body.name) });
|
|
226
|
+
return resourceView(r, [], true);
|
|
227
|
+
}
|
|
228
|
+
export async function updateResourceFor(c: EditorContext, id: string, body: z.infer<typeof ResourcePatch>) {
|
|
229
|
+
requireBookings();
|
|
230
|
+
requireScheduleRole(c.who, await c.repo.getSettings());
|
|
231
|
+
const cur = await c.repo.getResource(id);
|
|
232
|
+
if (!cur) throw new ApiError(404, 'Room or service not found.');
|
|
233
|
+
await c.repo.updateResource(id, { ...(await resourceFields(c.repo, body)), ...(body.slug !== undefined ? { slug: await uniqueResourceSlug(c.repo, body.slug || cur.name, id) } : {}) });
|
|
234
|
+
return getResourceFor(c, id);
|
|
235
|
+
}
|
|
236
|
+
export async function deleteResourceFor(c: EditorContext, id: string) {
|
|
237
|
+
requireBookings();
|
|
238
|
+
requireScheduleRole(c.who, await c.repo.getSettings());
|
|
239
|
+
if (!await c.repo.getResource(id)) throw new ApiError(404, 'Room or service not found.');
|
|
240
|
+
await c.repo.deleteResource(id);
|
|
241
|
+
return { id, deleted: true as const };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---- booking types (links) ----------------------------------------------------
|
|
245
|
+
|
|
246
|
+
const Question = z.object({
|
|
247
|
+
id: z.string().regex(/^[a-zA-Z0-9_-]{1,40}$/), label: z.string().trim().min(1).max(200), type: z.enum(QUESTION_TYPES).default('text'),
|
|
248
|
+
required: z.boolean().default(false), options: z.array(z.string().trim().min(1).max(200)).max(50).default([]), help: z.string().max(300).default(''),
|
|
249
|
+
}).strict();
|
|
250
|
+
export const LinkInput = z.object({
|
|
251
|
+
name: z.string().trim().min(1).max(120), slug: z.string().max(120).optional(), description: z.string().max(2000).optional(),
|
|
252
|
+
mode: BookingMode.optional(), duration: z.number().int().min(5).max(1440).optional(), lengths: z.array(z.number().int().min(5).max(1440)).max(12).optional(),
|
|
253
|
+
minDays: z.number().int().min(1).max(60).optional(), maxDays: z.number().int().min(1).max(60).optional(), step: z.number().int().min(5).max(240).optional(),
|
|
254
|
+
bufferBefore: z.number().int().min(0).max(480).optional(), bufferAfter: z.number().int().min(0).max(480).optional(),
|
|
255
|
+
minNotice: z.number().int().min(0).max(86_400).optional(), maxAdvance: z.number().int().min(1).max(730).optional(), perDayCap: z.number().int().min(0).max(100).optional(),
|
|
256
|
+
hours: Hours.nullable().optional(), approval: z.union([ApprovalMode, z.literal('default')]).optional(), approvalRules: ApprovalRules.optional(),
|
|
257
|
+
visibility: z.enum(['public', 'unlisted']).optional(), askPhone: z.boolean().optional(), questions: z.array(Question).max(20).optional(),
|
|
258
|
+
confirmMessage: z.string().max(1000).optional(), redirectUrl: z.string().max(1000).refine((v) => !v || /^https?:\/\//.test(v), 'Use an http(s) URL.').optional(),
|
|
259
|
+
active: z.boolean().optional(),
|
|
260
|
+
changeCutoff: z.number().int().min(0).max(8760).optional(),
|
|
261
|
+
}).strict();
|
|
262
|
+
export const LinkPatch = LinkInput.partial();
|
|
263
|
+
|
|
264
|
+
async function uniqueLinkSlug(repo: ContentRepository, resourceId: string, wanted: string, id?: string) {
|
|
265
|
+
const base = slugify(wanted) || 'booking';
|
|
266
|
+
for (let n = 1; ; n++) {
|
|
267
|
+
const slug = n === 1 ? base : `${base}-${n}`;
|
|
268
|
+
const hit = await repo.getBookingLinkBySlug(resourceId, slug);
|
|
269
|
+
if (!hit || hit.id === id) return slug;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
async function linkOf(repo: ContentRepository, resourceId: string, linkId: string) {
|
|
273
|
+
const l = await repo.getBookingLink(linkId);
|
|
274
|
+
if (!l || l.resourceId !== resourceId) throw new ApiError(404, 'Booking type not found.');
|
|
275
|
+
return l;
|
|
276
|
+
}
|
|
277
|
+
export async function createLinkFor(c: EditorContext, resourceId: string, body: z.infer<typeof LinkInput>) {
|
|
278
|
+
requireBookings();
|
|
279
|
+
requireScheduleRole(c.who, await c.repo.getSettings());
|
|
280
|
+
if (!await c.repo.getResource(resourceId)) throw new ApiError(404, 'Room or service not found.');
|
|
281
|
+
const { slug, ...rest } = body;
|
|
282
|
+
const created = await c.repo.createBookingLink({ resourceId, name: body.name, slug: await uniqueLinkSlug(c.repo, resourceId, slug || body.name) });
|
|
283
|
+
if (Object.keys(rest).length > 1) await c.repo.updateBookingLink(created.id, rest);
|
|
284
|
+
return linkView((await c.repo.getBookingLink(created.id))!);
|
|
285
|
+
}
|
|
286
|
+
export async function updateLinkFor(c: EditorContext, resourceId: string, linkId: string, body: z.infer<typeof LinkPatch>) {
|
|
287
|
+
requireBookings();
|
|
288
|
+
requireScheduleRole(c.who, await c.repo.getSettings());
|
|
289
|
+
const cur = await linkOf(c.repo, resourceId, linkId);
|
|
290
|
+
const { slug, ...rest } = body;
|
|
291
|
+
await c.repo.updateBookingLink(cur.id, { ...rest, ...(slug !== undefined ? { slug: await uniqueLinkSlug(c.repo, resourceId, slug || cur.name, cur.id) } : {}) });
|
|
292
|
+
return linkView((await c.repo.getBookingLink(cur.id))!);
|
|
293
|
+
}
|
|
294
|
+
export async function deleteLinkFor(c: EditorContext, resourceId: string, linkId: string) {
|
|
295
|
+
requireBookings();
|
|
296
|
+
requireScheduleRole(c.who, await c.repo.getSettings());
|
|
297
|
+
const cur = await linkOf(c.repo, resourceId, linkId);
|
|
298
|
+
await c.repo.deleteBookingLink(cur.id);
|
|
299
|
+
return { id: cur.id, deleted: true as const };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---- calendars ----------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
export const CalendarBody = z.object({
|
|
305
|
+
name: z.string().trim().min(1).max(120), slug: z.string().max(120).optional(), color: z.string().max(20).optional(),
|
|
306
|
+
description: z.string().max(1000).optional(), kind: z.enum(['native', 'feed']).optional(), feedUrl: z.string().max(2000).optional(),
|
|
307
|
+
visibility: z.enum(['public', 'unlisted', 'private']).optional(),
|
|
308
|
+
}).strict();
|
|
309
|
+
export const CalendarPatch = CalendarBody.omit({ kind: true }).partial();
|
|
310
|
+
const calendarView = (cal: Awaited<ReturnType<ContentRepository['listCalendars']>>[number], canManage = true) => {
|
|
311
|
+
// the private feed token stays in the web admin; a subscribed feed's URL can be a secret link (setup only)
|
|
312
|
+
const { feedToken: _secret, ...rest } = cal;
|
|
313
|
+
return { ...rest, feedUrl: canManage ? cal.feedUrl : '', createdAt: iso(cal.createdAt), updatedAt: iso(cal.updatedAt) };
|
|
314
|
+
};
|
|
315
|
+
export async function listCalendarsFor(c: EditorContext) {
|
|
316
|
+
requireCalendarModule();
|
|
317
|
+
const [list, settings] = await Promise.all([c.repo.listCalendars(), c.repo.getSettings()]);
|
|
318
|
+
const canEdit = canManageSchedule(c.who, settings);
|
|
319
|
+
return { canEdit, calendars: list.map((cal) => calendarView(cal, canEdit)) };
|
|
320
|
+
}
|
|
321
|
+
export async function createCalendarFor(c: EditorContext, body: z.infer<typeof CalendarBody>) {
|
|
322
|
+
requireCalendarModule();
|
|
323
|
+
requireScheduleRole(c.who, await c.repo.getSettings());
|
|
324
|
+
return calendarView(await saveCalendar(c.repo, undefined, body));
|
|
325
|
+
}
|
|
326
|
+
export async function updateCalendarFor(c: EditorContext, id: string, body: z.infer<typeof CalendarPatch>) {
|
|
327
|
+
requireCalendarModule();
|
|
328
|
+
requireScheduleRole(c.who, await c.repo.getSettings());
|
|
329
|
+
const cur = await c.repo.getCalendar(id);
|
|
330
|
+
if (!cur) throw new ApiError(404, 'Calendar not found.');
|
|
331
|
+
return calendarView(await saveCalendar(c.repo, id, { ...body, name: body.name ?? cur.name, slug: body.slug ?? cur.slug }));
|
|
332
|
+
}
|
|
333
|
+
export async function deleteCalendarFor(c: EditorContext, id: string) {
|
|
334
|
+
requireCalendarModule();
|
|
335
|
+
requireScheduleRole(c.who, await c.repo.getSettings());
|
|
336
|
+
if (!await c.repo.getCalendar(id)) throw new ApiError(404, 'Calendar not found.');
|
|
337
|
+
await c.repo.deleteCalendar(id);
|
|
338
|
+
return { id, deleted: true as const };
|
|
339
|
+
}
|
|
340
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { FormDefinition } from '../forms/schema';
|
|
3
|
-
import { Booking } from '../schema';
|
|
3
|
+
import { Booking, BookingLink, Calendar, LinkKind, Resource } from '../schema';
|
|
4
4
|
|
|
5
5
|
// Response shapes of the editor-app API (OpenAPI components, client types).
|
|
6
6
|
const str = z.string(), bool = z.boolean(), num = z.number(), date = str.datetime();
|
|
@@ -25,6 +25,10 @@ export const EditorMe = z.object({
|
|
|
25
25
|
documents: z.object({ edit: bool, publish: bool }),
|
|
26
26
|
sections: z.object({ editContent: bool, editLocked: bool, publishSection: bool, changeLayout: bool }),
|
|
27
27
|
bookings: bool,
|
|
28
|
+
// rooms, booking types, hours and calendars; book or move outside the rules
|
|
29
|
+
schedule: z.object({ view: bool, manage: bool, ignoreRules: bool }),
|
|
30
|
+
forms: z.object({ list: bool, readResponses: bool }),
|
|
31
|
+
links: z.object({ edit: bool, editHtml: bool }),
|
|
28
32
|
}),
|
|
29
33
|
});
|
|
30
34
|
|
|
@@ -84,6 +88,37 @@ export const EditorBooking = Booking.pick({ id: true, kind: true, status: true,
|
|
|
84
88
|
.extend({ start: date, end: date, resourceId: str.nullable(), linkId: str.nullable(), eventId: str.nullable(), createdAt: date, updatedAt: date, title: str });
|
|
85
89
|
export const EditorBookingList = z.object({ pending: num.int(), items: z.array(EditorBooking), pagination: Pagination });
|
|
86
90
|
|
|
91
|
+
// ---- schedule, setup, forms, links page ----
|
|
92
|
+
export const EditorSchedule = z.object({
|
|
93
|
+
timezone: str, from: str, to: str,
|
|
94
|
+
resources: z.array(z.object({ id: str, slug: str, name: str, kind: str })),
|
|
95
|
+
calendars: z.array(z.object({ id: str, name: str, color: str, kind: z.enum(['native', 'feed']), visibility: z.enum(['public', 'unlisted', 'private']) })),
|
|
96
|
+
items: z.array(z.union([
|
|
97
|
+
z.object({ kind: z.enum(['event', 'feed']), id: str, title: str, start: date, end: date, allDay: bool, status: str, calendarId: str, color: str, location: str, cancelled: bool, repeat: str, resourceIds: z.array(str) }),
|
|
98
|
+
z.object({ kind: z.enum(['booking', 'block']), id: str, title: str, start: date, end: date, allDay: bool, status: z.enum(['pending', 'confirmed']), resourceId: str, linkId: str.nullable(), name: str, email: str, phone: str, notes: str }),
|
|
99
|
+
])),
|
|
100
|
+
errors: z.array(str),
|
|
101
|
+
});
|
|
102
|
+
export const EditorBookingLink = BookingLink.extend({ createdAt: date, updatedAt: date });
|
|
103
|
+
export const EditorResource = Resource.omit({ feedToken: true }).extend({ createdAt: date, updatedAt: date, links: z.array(EditorBookingLink) });
|
|
104
|
+
export const EditorResourceList = z.object({ canEdit: bool, resources: z.array(EditorResource) });
|
|
105
|
+
export const EditorBlock = z.object({ id: str, resourceId: str, start: date, end: date, note: str });
|
|
106
|
+
export const EditorCalendar = Calendar.omit({ feedToken: true }).extend({ createdAt: date, updatedAt: date });
|
|
107
|
+
export const EditorCalendarList = z.object({ canEdit: bool, calendars: z.array(EditorCalendar) });
|
|
108
|
+
export const EditorDeleted = z.object({ id: str, deleted: z.literal(true) });
|
|
109
|
+
export const EditorFormList = z.object({ canReadResponses: bool, forms: z.array(z.object({
|
|
110
|
+
id: str, name: str, title: str, purpose: z.enum(['event', 'general']), status: z.enum(['draft', 'published', 'archived']), hasDraft: bool,
|
|
111
|
+
responses: num.int(), lastResponseAt: date.nullable(), updatedAt: date.nullable(),
|
|
112
|
+
})) });
|
|
113
|
+
export const EditorFormResponses = z.object({
|
|
114
|
+
form: z.object({ id: str, name: str }),
|
|
115
|
+
items: z.array(z.object({ id: str, createdAt: date, answers: z.array(z.object({ label: str, value: str })) })),
|
|
116
|
+
pagination: Pagination,
|
|
117
|
+
});
|
|
118
|
+
export const EditorLinksPage = z.object({ revision: num.int(), name: str, bio: str, avatar: str, canEditHtml: bool, items: z.array(z.object({
|
|
119
|
+
id: str, kind: LinkKind, label: str, url: str, icon: str, html: str, meta: z.record(z.unknown()), clicks: num.int(),
|
|
120
|
+
})) });
|
|
121
|
+
|
|
87
122
|
/** documented request body for POST /api/v1/auth/token (form-encoded or JSON) */
|
|
88
123
|
export const EditorTokenRequest = z.discriminatedUnion('grant_type', [
|
|
89
124
|
z.object({ grant_type: z.literal('authorization_code'), code: str, code_verifier: str, redirect_uri: str, client_id: str }).strict(),
|