@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.
Files changed (85) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/main.mjs +7 -1
  4. package/cli/mobile-doctor.mjs +9 -0
  5. package/cli/monitor.mjs +189 -0
  6. package/docs/mobile.md +23 -2
  7. package/integration.mjs +35 -4
  8. package/package.json +1 -1
  9. package/src/actions/index.ts +6 -26
  10. package/src/components/AppAccess.tsx +117 -0
  11. package/src/components/MonitoringSettings.tsx +112 -0
  12. package/src/layouts/Admin.astro +2 -3
  13. package/src/lib/api.ts +2 -1
  14. package/src/lib/backup/content.ts +1 -1
  15. package/src/lib/booking/availability.ts +15 -2
  16. package/src/lib/booking/service.ts +53 -1
  17. package/src/lib/calendar/admin.ts +41 -0
  18. package/src/lib/editor/me.ts +4 -0
  19. package/src/lib/editor/openapi.ts +49 -1
  20. package/src/lib/editor/roles.ts +20 -0
  21. package/src/lib/editor/schedule.ts +340 -0
  22. package/src/lib/editor/schemas.ts +36 -1
  23. package/src/lib/editor/site-data.ts +110 -0
  24. package/src/lib/email/service.ts +8 -1
  25. package/src/lib/email/store.ts +10 -0
  26. package/src/lib/email/templates.ts +6 -0
  27. package/src/lib/forms/store.ts +5 -0
  28. package/src/lib/mobile/access-admin.ts +34 -0
  29. package/src/lib/mobile/access.ts +28 -0
  30. package/src/lib/mobile/calendar.ts +26 -13
  31. package/src/lib/mobile/content.ts +21 -1
  32. package/src/lib/mobile/forms.ts +77 -0
  33. package/src/lib/mobile/guest-store.ts +62 -0
  34. package/src/lib/mobile/guest.ts +160 -0
  35. package/src/lib/mobile/http.ts +2 -2
  36. package/src/lib/mobile/links.ts +69 -0
  37. package/src/lib/mobile/manage.ts +114 -0
  38. package/src/lib/mobile/openapi.ts +40 -4
  39. package/src/lib/mobile/route.ts +24 -2
  40. package/src/lib/mobile/schemas.ts +52 -1
  41. package/src/lib/mobile/search.ts +95 -0
  42. package/src/lib/mobile/site.ts +8 -2
  43. package/src/lib/monitoring.ts +171 -0
  44. package/src/lib/repo/migrations.ts +28 -0
  45. package/src/lib/repo/sql.ts +66 -20
  46. package/src/lib/repo/types.ts +19 -2
  47. package/src/lib/scheduler.ts +7 -0
  48. package/src/lib/schema.ts +35 -1
  49. package/src/pages/admin/calendar/resources/link.astro +2 -0
  50. package/src/pages/admin/mobile.astro +6 -2
  51. package/src/pages/admin/settings.astro +12 -0
  52. package/src/pages/api/health.ts +17 -0
  53. package/src/pages/api/mobile/access.ts +22 -0
  54. package/src/pages/api/monitoring.ts +22 -0
  55. package/src/pages/api/ops/status.ts +2 -1
  56. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +7 -1
  57. package/src/pages/api/v1/admin/bookings/index.ts +10 -2
  58. package/src/pages/api/v1/admin/calendars/[id].ts +16 -0
  59. package/src/pages/api/v1/admin/calendars/index.ts +12 -0
  60. package/src/pages/api/v1/admin/forms/[id]/responses.ts +7 -0
  61. package/src/pages/api/v1/admin/forms/index.ts +6 -0
  62. package/src/pages/api/v1/admin/links.ts +13 -0
  63. package/src/pages/api/v1/admin/resources/[id]/blocks/[blockId].ts +10 -0
  64. package/src/pages/api/v1/admin/resources/[id]/blocks/index.ts +12 -0
  65. package/src/pages/api/v1/admin/resources/[id]/index.ts +17 -0
  66. package/src/pages/api/v1/admin/resources/[id]/links/[linkId].ts +16 -0
  67. package/src/pages/api/v1/admin/resources/[id]/links/index.ts +12 -0
  68. package/src/pages/api/v1/admin/resources/index.ts +12 -0
  69. package/src/pages/api/v1/admin/schedule.ts +7 -0
  70. package/src/pages/api/v1/bookings/manage/availability.ts +6 -0
  71. package/src/pages/api/v1/bookings/manage/cancel.ts +6 -0
  72. package/src/pages/api/v1/bookings/manage/ics.ts +6 -0
  73. package/src/pages/api/v1/bookings/manage/index.ts +6 -0
  74. package/src/pages/api/v1/bookings/manage/reschedule.ts +6 -0
  75. package/src/pages/api/v1/forms/[id]/index.ts +7 -0
  76. package/src/pages/api/v1/forms/[id]/responses.ts +6 -0
  77. package/src/pages/api/v1/guest/bookings.ts +6 -0
  78. package/src/pages/api/v1/guest/code.ts +6 -0
  79. package/src/pages/api/v1/guest/revoke.ts +6 -0
  80. package/src/pages/api/v1/guest/token.ts +6 -0
  81. package/src/pages/api/v1/links.ts +7 -0
  82. package/src/pages/api/v1/search.ts +7 -0
  83. package/src/pages/book/[resource]/[link].astro +3 -3
  84. package/src/pages/book/manage/[token].astro +11 -7
  85. package/src/styles/admin.css +66 -0
@@ -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(),
@@ -0,0 +1,110 @@
1
+ import { z } from 'zod';
2
+ import type { Link } from '../schema';
3
+ import { LinkKind } from '../schema';
4
+ import { ApiError } from '../api';
5
+ import { displayAnswers } from '../forms/service';
6
+ import type { EditorContext } from './http';
7
+ import { isAdmin, requireAdmin } from './roles';
8
+
9
+ // Forms and the links page from editor apps. Any staff account sees which
10
+ // forms exist and how many responses they have; reading responses (personal
11
+ // details) takes an administrator or owner. The links page is editable by
12
+ // staff, except raw HTML blocks and inline SVG icons, which run on the
13
+ // public page and so need an administrator or owner.
14
+
15
+ const iso = (n: number | null) => (n === null ? null : new Date(n).toISOString());
16
+
17
+ export async function listFormsFor(c: EditorContext) {
18
+ const [forms, stats] = await Promise.all([c.repo.forms.list(), c.repo.forms.stats()]);
19
+ return {
20
+ canReadResponses: isAdmin(c.who),
21
+ forms: forms.filter((f) => f.kind === 'form').map((f) => ({
22
+ id: f.id, name: f.name, title: f.definition.title || f.name, purpose: f.purpose, status: f.status, hasDraft: Boolean(f.draft),
23
+ responses: stats.get(f.id)?.count ?? 0, lastResponseAt: iso(stats.get(f.id)?.last ?? null), updatedAt: iso(f.updatedAt),
24
+ })),
25
+ };
26
+ }
27
+
28
+ export const ResponsesQuery = z.object({
29
+ q: z.string().max(100).optional(), since: z.string().datetime({ offset: true }).optional(),
30
+ page: z.coerce.number().int().min(1).max(10000).default(1), limit: z.coerce.number().int().min(1).max(100).default(25),
31
+ }).strict();
32
+ export async function formResponsesFor(c: EditorContext, id: string, q: z.infer<typeof ResponsesQuery>) {
33
+ requireAdmin(c.who, 'read form responses');
34
+ const form = await c.repo.forms.get(id);
35
+ if (!form || form.kind !== 'form') throw new ApiError(404, 'Form not found.');
36
+ const rows = await c.repo.forms.searchSubmissions(id, { q: q.q, since: q.since ? Date.parse(q.since) : undefined, limit: q.limit + 1, offset: (q.page - 1) * q.limit });
37
+ return {
38
+ form: { id: form.id, name: form.name },
39
+ items: rows.slice(0, q.limit).map((s) => ({
40
+ id: s.id, createdAt: iso(s.createdAt)!,
41
+ // labelled answers from the questions as they were when submitted
42
+ answers: Object.entries(displayAnswers(s.response)).map(([label, value]) => ({ label, value })),
43
+ })),
44
+ pagination: { page: q.page, limit: q.limit, nextPage: rows.length > q.limit ? q.page + 1 : null },
45
+ };
46
+ }
47
+
48
+ // ---- links page ----------------------------------------------------------------
49
+
50
+ const linkView = (l: Link) => ({ id: l.id, kind: l.kind, label: l.label, url: l.url, icon: l.icon, html: l.html, meta: l.meta, clicks: l.clicks });
51
+
52
+ export async function getLinksFor(c: EditorContext) {
53
+ // revision first: a change landing between the reads leaves it stale (the
54
+ // next save gets a 409), never newer than the blocks it is sent with
55
+ const revision = await c.repo.linksRevision();
56
+ const [settings, links] = await Promise.all([c.repo.getSettings(), c.repo.listLinks()]);
57
+ return { revision, name: settings.linksName, bio: settings.linksBio, avatar: settings.linksAvatar, canEditHtml: isAdmin(c.who), items: links.map(linkView) };
58
+ }
59
+
60
+ const Item = z.object({
61
+ // omit for a new block; an existing id keeps its click count
62
+ id: z.string().max(100).optional(),
63
+ kind: LinkKind.default('link'), label: z.string().max(200).default(''), url: z.string().max(2000).default(''),
64
+ icon: z.string().max(5000).default(''), html: z.string().max(20_000).default(''),
65
+ meta: z.record(z.unknown()).default({}).refine((m) => JSON.stringify(m).length <= 4000, 'Block settings are too large.'),
66
+ }).strict();
67
+ export const LinksPut = z.object({
68
+ // from GET /admin/links; a stale revision is a 409 and nothing is saved
69
+ revision: z.number().int().min(0),
70
+ name: z.string().max(120).optional(), bio: z.string().max(500).optional(),
71
+ avatar: z.string().max(1000).refine((v) => !v || /^(https?:\/\/|\/)/.test(v), 'Use an http(s) or site path image.').optional(),
72
+ // the whole page in display order; blocks left out are removed
73
+ items: z.array(Item).max(200),
74
+ }).strict();
75
+
76
+ const urlKinds = new Set(['link']);
77
+ function checkItem(i: z.infer<typeof Item>) {
78
+ const url = i.url.trim();
79
+ // http(s) or a path on this site; "//host" and "/\\host" would leave it (open redirect through /go)
80
+ if (urlKinds.has(i.kind) && url && !/^https?:\/\//i.test(url) && !/^\/(?![\/\\])/.test(url)) throw new ApiError(400, `“${i.label || 'Link'}” needs an http(s) address.`);
81
+ }
82
+ // inline SVG icons render as markup on the public page, exactly like HTML blocks
83
+ const isMarkup = (icon: string) => icon.trim().toLowerCase().startsWith('<svg');
84
+
85
+ export async function putLinksFor(c: EditorContext, body: z.infer<typeof LinksPut>) {
86
+ const current = await c.repo.listLinks();
87
+ const byId = new Map(current.map((l) => [l.id, l]));
88
+ const admin = isAdmin(c.who);
89
+ const markupChanged = (i: z.infer<typeof Item>) => {
90
+ const old = i.id ? byId.get(i.id) : undefined;
91
+ const html = i.kind === 'html' && (!old || old.kind !== 'html' || old.html !== i.html);
92
+ const svg = isMarkup(i.icon) && (!old || old.icon !== i.icon);
93
+ return html || svg;
94
+ };
95
+ const ids = body.items.map((i) => i.id).filter(Boolean);
96
+ if (new Set(ids).size !== ids.length) throw new ApiError(400, 'Each block can appear once.');
97
+ for (const i of body.items) {
98
+ if (i.id && !byId.has(i.id)) throw new ApiError(409, 'The links page changed since you opened it. Reload and try again.');
99
+ checkItem(i);
100
+ if (!admin && markupChanged(i)) requireAdmin(c.who, 'add or change HTML blocks or SVG icons');
101
+ }
102
+ // one atomic write: a failure leaves the page as it was. Blocks left out
103
+ // are removed (fine for anyone, even HTML: it only takes code away); a
104
+ // block whose kind changed is replaced
105
+ await c.repo.replaceLinks(body.revision, body.items.map((i) => {
106
+ const old = i.id ? byId.get(i.id) : undefined;
107
+ return { kind: i.kind, label: i.label, url: i.url, icon: i.icon, html: i.html, meta: i.meta, keep: old && old.kind === i.kind ? old.id : undefined };
108
+ }), { linksName: body.name, linksBio: body.bio, linksAvatar: body.avatar });
109
+ return getLinksFor(c);
110
+ }
@@ -1,13 +1,20 @@
1
1
  import type { ContentRepository } from '../repo/types';
2
2
  import { resolveConfig, decrypt, encrypt, runtimeValue } from './config';
3
3
  import { sendWithProvider } from './providers';
4
- import { accountMail, canonicalOrigin, EmailLinkError } from './templates';
4
+ import { accountMail, canonicalOrigin, guestCodeMail, EmailLinkError } from './templates';
5
5
  import { bookingMail } from '../booking/service';
6
6
  import type { EmailJob, Mail } from './types';
7
7
 
8
8
  async function render(repo: ContentRepository, job: EmailJob, locals?: unknown, from = ''): Promise<Mail | null> {
9
9
  const settings = await repo.getSettings();
10
10
  if (job.kind === 'test') return accountMail('test', job.recipient, settings.siteTitle);
11
+ if (job.kind === 'guest-code') {
12
+ // only the latest, unexpired code is worth sending
13
+ const payload = JSON.parse(job.payload) as { issuedAt: number; code: string };
14
+ const current = await repo.guests.code(job.recipient);
15
+ if (!current || current.createdAt !== payload.issuedAt || current.expiresAt <= Date.now()) return null;
16
+ return guestCodeMail(job.recipient, settings.siteTitle, decrypt<string>(payload.code, `guest-code:${job.dedupe}`, locals));
17
+ }
11
18
  const origin = canonicalOrigin(settings.siteUrl);
12
19
  if (job.kind === 'booking') {
13
20
  const payload = JSON.parse(job.payload);
@@ -112,9 +112,19 @@ export class EmailStore {
112
112
  count=CASE WHEN expires_at <= ? THEN 1 ELSE count+1 END, expires_at=CASE WHEN expires_at <= ? THEN ? ELSE expires_at END RETURNING count`, [key,now+windowMs,now,now,now+windowMs]);
113
113
  return Number(rows[0].count) <= max;
114
114
  }
115
+ /** true when key's current window already holds max hits (checks without counting) */
116
+ async rateLimited(key: string, max: number): Promise<boolean> {
117
+ const rows = await (await this.getDriver()).all('SELECT count FROM email_rate_limits WHERE key=? AND expires_at > ?', [key, Date.now()]);
118
+ return rows.length > 0 && Number(rows[0].count) >= max;
119
+ }
115
120
  async recordScheduler(report: { startedAt: number; finishedAt: number; ok: boolean; jobs: Record<string, { ok: boolean; count?: number }> }) {
116
121
  await (await this.getDriver()).run("INSERT INTO email_meta(key,value) VALUES ('scheduler-heartbeat',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", [JSON.stringify(report)]);
117
122
  }
123
+ /** mail that should have gone out before `before` but is still waiting (a stuck queue) */
124
+ async stuckJobs(before: number): Promise<number> {
125
+ const rows = await (await this.getDriver()).all("SELECT COUNT(*) AS n FROM email_jobs WHERE state IN ('queued','retrying') AND next_at < ?", [before]);
126
+ return Number(rows[0]?.n ?? 0);
127
+ }
118
128
  async schedulerStatus(): Promise<{ startedAt: number; finishedAt: number; ok: boolean; jobs: Record<string, { ok: boolean; count?: number }> } | null> {
119
129
  const rows = await (await this.getDriver()).all("SELECT value FROM email_meta WHERE key='scheduler-heartbeat'");
120
130
  return rows[0] ? JSON.parse(String(rows[0].value)) : null;
@@ -11,6 +11,12 @@ export function accountMail(kind: 'invite'|'reset'|'welcome'|'password-changed'|
11
11
  return { to, subject: `${copy[0]} · ${site}`, text: [copy[0], '', copy[1], ...(url ? ['',`${copy[2]}: ${url}`] : [])].join('\n'),
12
12
  html: `<!doctype html><html><body style="margin:0;background:#f6f5f2;color:#24211c;font:16px/1.6 system-ui,sans-serif"><div style="max-width:520px;margin:auto;padding:32px 24px"><p style="color:#7d776e">${escape(site)}</p><h1 style="font-size:24px">${escape(copy[0])}</h1><p>${escape(copy[1])}</p>${url ? `<p><a style="display:inline-block;background:#ee5d20;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none" href="${escape(url)}">${escape(copy[2])}</a></p>` : ''}</div></body></html>` };
13
13
  }
14
+ /** a guest's app sign-in code; no link, so it works on any device */
15
+ export function guestCodeMail(to: string, site: string, code: string): Mail {
16
+ const intro = `Enter this code in the ${site} app to see your bookings. It expires in 10 minutes. If you did not ask for it, ignore this message.`;
17
+ return { to, subject: `${code} is your sign-in code · ${site}`, text: ['Your sign-in code', '', code, '', intro].join('\n'),
18
+ html: `<!doctype html><html><body style="margin:0;background:#f6f5f2;color:#24211c;font:16px/1.6 system-ui,sans-serif"><div style="max-width:520px;margin:auto;padding:32px 24px"><p style="color:#7d776e">${escape(site)}</p><h1 style="font-size:24px">Your sign-in code</h1><p style="font:700 32px/1.2 ui-monospace,Menlo,monospace;letter-spacing:.2em">${escape(code)}</p><p>${escape(intro)}</p></div></body></html>` };
19
+ }
14
20
  export class EmailLinkError extends Error {}
15
21
  export function canonicalOrigin(value: string): string {
16
22
  const raw = value.trim();
@@ -25,6 +25,11 @@ export class FormStore {
25
25
  async submissions(formId?: string, limit = 100, offset = 0) {
26
26
  return (await (await this.driver()).all(`SELECT * FROM form_submissions ${formId ? 'WHERE form_id=?' : ''} ORDER BY created_at DESC LIMIT ? OFFSET ?`, [...(formId ? [formId] : []), limit, offset])).map(r => FormSubmission.parse({ id: r.id, formId: r.form_id, response: JSON.parse(String(r.response)), createdAt: r.created_at }));
27
27
  }
28
+ /** the response this request id already saved to this form, if any (as stored JSON) */
29
+ async receipt(formId: string, requestId: string) {
30
+ const r = (await (await this.driver()).all('SELECT response FROM form_submissions WHERE id=? AND form_id=?', [requestId, formId]))[0];
31
+ return r ? String(r.response) : null;
32
+ }
28
33
  async submit(formId: string, response: FormResponse, requestId: string) {
29
34
  response = FormResponse.parse(response);
30
35
  const db = await this.driver();
@@ -0,0 +1,34 @@
1
+ import { z } from 'zod';
2
+ import type { ContentRepository } from '../repo/types';
3
+ import { MobileAccess } from '../schema';
4
+ import { CALENDAR, bookingsEnabled } from '../calendar/config';
5
+ import { encryptionAvailable, resolveConfig } from '../email/config';
6
+ import { GUEST_IDLE_MS } from './guest-store';
7
+
8
+ // Admin → Mobile app → App access (owner only): switches and limits for the
9
+ // public app routes, plus "Sign out all" for guest sessions.
10
+
11
+ export async function appAccessState(repo: ContentRepository, locals?: unknown) {
12
+ const settings = await repo.getSettings();
13
+ const email = resolveConfig(await repo.email.config(), locals);
14
+ const emailReady = !email.error && email.provider !== 'none' && email.config.enabled;
15
+ return {
16
+ access: settings.mobileAccess,
17
+ modules: { calendar: CALENDAR.enabled, bookings: bookingsEnabled() },
18
+ // why guest sign-in can't run yet, if it can't
19
+ guestSignIn: {
20
+ blocker: !CALENDAR.enabled ? 'calendar' as const : !emailReady ? 'email' as const : !encryptionAvailable(locals) ? 'encryption' as const : null,
21
+ devices: await repo.guests.count(Date.now() - GUEST_IDLE_MS),
22
+ },
23
+ };
24
+ }
25
+ export type AppAccessState = Awaited<ReturnType<typeof appAccessState>>;
26
+
27
+ export const AppAccessInput = z.discriminatedUnion('action', [
28
+ z.object({ action: z.literal('save'), access: MobileAccess.partial().strict() }).strict(),
29
+ z.object({ action: z.literal('signOutGuests') }).strict(),
30
+ ]);
31
+ export async function appAccessAction(repo: ContentRepository, input: z.infer<typeof AppAccessInput>) {
32
+ if (input.action === 'signOutGuests') { await repo.guests.deleteAll(); return; }
33
+ await repo.patchMobileAccess(input.access);
34
+ }
@@ -0,0 +1,28 @@
1
+ import type { ContentRepository } from '../repo/types';
2
+ import type { MobileAccess, Settings } from '../schema';
3
+ import { ApiError } from '../api';
4
+ import { sha256Hex } from '../api-keys';
5
+
6
+ type Switch = Exclude<{ [K in keyof MobileAccess]: MobileAccess[K] extends boolean ? K : never }[keyof MobileAccess], undefined>;
7
+
8
+ /** Owner switch (Admin → Mobile app → App access); off answers 404 like a disabled module. */
9
+ export async function requireAccess(repo: ContentRepository, key: Switch): Promise<Settings> {
10
+ const settings = await repo.getSettings();
11
+ if (!settings.mobileAccess[key]) throw new ApiError(404, 'This feature is turned off.');
12
+ return settings;
13
+ }
14
+
15
+ /**
16
+ * Lockout for secret guessing: only failures count, per client address.
17
+ * `check` before the lookup (already locked → 429), `miss` after a failure.
18
+ */
19
+ export async function failureLock(repo: ContentRepository, scope: string, address: string, max = 20, windowMs = 10 * 60_000) {
20
+ const key = `lock:${scope}:${await sha256Hex(address)}`;
21
+ // without a real client address everyone would share one bucket, and a few
22
+ // junk requests would lock every visitor out; per-target limits still apply
23
+ if (!address || address === 'unknown') return { async check() {}, async miss() {} };
24
+ return {
25
+ async check() { if (await repo.email.rateLimited(key, max)) throw new ApiError(429, 'Too many failed attempts. Try again in a few minutes.', windowMs / 1000); },
26
+ async miss() { await repo.email.rateLimit(key, max, windowMs); },
27
+ };
28
+ }