@acms-subir/core 0.3.9 → 0.3.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/cli/common.mjs +2 -2
- package/cli/main.mjs +7 -1
- package/cli/mobile-doctor.mjs +9 -0
- package/cli/monitor.mjs +189 -0
- package/docs/mobile.md +23 -2
- package/integration.mjs +35 -4
- package/package.json +1 -1
- package/src/actions/index.ts +6 -26
- package/src/components/AppAccess.tsx +117 -0
- package/src/components/MonitoringSettings.tsx +112 -0
- package/src/layouts/Admin.astro +2 -3
- package/src/lib/api.ts +2 -1
- package/src/lib/backup/content.ts +1 -1
- package/src/lib/booking/availability.ts +15 -2
- package/src/lib/booking/service.ts +53 -1
- package/src/lib/calendar/admin.ts +41 -0
- package/src/lib/editor/me.ts +4 -0
- package/src/lib/editor/openapi.ts +49 -1
- package/src/lib/editor/roles.ts +20 -0
- package/src/lib/editor/schedule.ts +340 -0
- package/src/lib/editor/schemas.ts +36 -1
- package/src/lib/editor/site-data.ts +110 -0
- package/src/lib/email/service.ts +8 -1
- package/src/lib/email/store.ts +10 -0
- package/src/lib/email/templates.ts +6 -0
- package/src/lib/forms/store.ts +5 -0
- package/src/lib/mobile/access-admin.ts +34 -0
- package/src/lib/mobile/access.ts +28 -0
- package/src/lib/mobile/calendar.ts +26 -13
- package/src/lib/mobile/content.ts +21 -1
- package/src/lib/mobile/forms.ts +77 -0
- package/src/lib/mobile/guest-store.ts +62 -0
- package/src/lib/mobile/guest.ts +160 -0
- package/src/lib/mobile/http.ts +2 -2
- package/src/lib/mobile/links.ts +69 -0
- package/src/lib/mobile/manage.ts +114 -0
- package/src/lib/mobile/openapi.ts +40 -4
- package/src/lib/mobile/route.ts +24 -2
- package/src/lib/mobile/schemas.ts +52 -1
- package/src/lib/mobile/search.ts +95 -0
- package/src/lib/mobile/site.ts +8 -2
- package/src/lib/monitoring.ts +171 -0
- package/src/lib/repo/migrations.ts +28 -0
- package/src/lib/repo/sql.ts +66 -20
- package/src/lib/repo/types.ts +19 -2
- package/src/lib/scheduler.ts +7 -0
- package/src/lib/schema.ts +35 -1
- package/src/pages/admin/calendar/resources/link.astro +2 -0
- package/src/pages/admin/mobile.astro +6 -2
- package/src/pages/admin/settings.astro +12 -0
- package/src/pages/api/health.ts +17 -0
- package/src/pages/api/mobile/access.ts +22 -0
- package/src/pages/api/monitoring.ts +22 -0
- package/src/pages/api/ops/status.ts +2 -1
- package/src/pages/api/v1/admin/bookings/[id]/[action].ts +7 -1
- package/src/pages/api/v1/admin/bookings/index.ts +10 -2
- package/src/pages/api/v1/admin/calendars/[id].ts +16 -0
- package/src/pages/api/v1/admin/calendars/index.ts +12 -0
- package/src/pages/api/v1/admin/forms/[id]/responses.ts +7 -0
- package/src/pages/api/v1/admin/forms/index.ts +6 -0
- package/src/pages/api/v1/admin/links.ts +13 -0
- package/src/pages/api/v1/admin/resources/[id]/blocks/[blockId].ts +10 -0
- package/src/pages/api/v1/admin/resources/[id]/blocks/index.ts +12 -0
- package/src/pages/api/v1/admin/resources/[id]/index.ts +17 -0
- package/src/pages/api/v1/admin/resources/[id]/links/[linkId].ts +16 -0
- package/src/pages/api/v1/admin/resources/[id]/links/index.ts +12 -0
- package/src/pages/api/v1/admin/resources/index.ts +12 -0
- package/src/pages/api/v1/admin/schedule.ts +7 -0
- package/src/pages/api/v1/bookings/manage/availability.ts +6 -0
- package/src/pages/api/v1/bookings/manage/cancel.ts +6 -0
- package/src/pages/api/v1/bookings/manage/ics.ts +6 -0
- package/src/pages/api/v1/bookings/manage/index.ts +6 -0
- package/src/pages/api/v1/bookings/manage/reschedule.ts +6 -0
- package/src/pages/api/v1/forms/[id]/index.ts +7 -0
- package/src/pages/api/v1/forms/[id]/responses.ts +6 -0
- package/src/pages/api/v1/guest/bookings.ts +6 -0
- package/src/pages/api/v1/guest/code.ts +6 -0
- package/src/pages/api/v1/guest/revoke.ts +6 -0
- package/src/pages/api/v1/guest/token.ts +6 -0
- package/src/pages/api/v1/links.ts +7 -0
- package/src/pages/api/v1/search.ts +7 -0
- package/src/pages/book/[resource]/[link].astro +3 -3
- package/src/pages/book/manage/[token].astro +11 -7
- package/src/styles/admin.css +66 -0
package/src/lib/repo/sql.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { FormStore, formInsert, submissionInsert } from '../forms/store';
|
|
2
|
+
import { GuestStore } from '../mobile/guest-store';
|
|
2
3
|
import { LiveStore } from '../live/store';
|
|
3
4
|
import { PushStore } from '../push/store';
|
|
4
5
|
import { EditorStore } from '../editor/store';
|
|
@@ -183,7 +184,7 @@ function rowToLink2(r: Row): BookingLink {
|
|
|
183
184
|
id: r.id, resourceId: r.resource_id, slug: r.slug, name: r.name, description: r.description ?? '', mode: r.mode ?? 'slots',
|
|
184
185
|
duration: Number(r.duration), lengths: json(r.lengths, []), minDays: Number(r.min_days), maxDays: Number(r.max_days),
|
|
185
186
|
step: Number(r.step), bufferBefore: Number(r.buffer_before), bufferAfter: Number(r.buffer_after),
|
|
186
|
-
minNotice: Number(r.min_notice), maxAdvance: Number(r.max_advance), perDayCap: Number(r.per_day_cap),
|
|
187
|
+
minNotice: Number(r.min_notice), maxAdvance: Number(r.max_advance), perDayCap: Number(r.per_day_cap), changeCutoff: Number(r.change_cutoff ?? 0),
|
|
187
188
|
hours: r.hours ? json(r.hours, null) : null, autoConfirm: Boolean(r.auto_confirm), visibility: r.visibility ?? 'public',
|
|
188
189
|
approval: r.approval ?? 'default', approvalRules: json(r.approval_rules, {}),
|
|
189
190
|
askPhone: Boolean(r.ask_phone), questions: json(r.questions, []), confirmMessage: r.confirm_message ?? '',
|
|
@@ -259,6 +260,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
259
260
|
private ready: Promise<void>;
|
|
260
261
|
readonly email: EmailStore;
|
|
261
262
|
readonly forms: FormStore;
|
|
263
|
+
readonly guests: GuestStore;
|
|
262
264
|
readonly live: LiveStore;
|
|
263
265
|
readonly push: PushStore;
|
|
264
266
|
readonly editor: EditorStore;
|
|
@@ -266,6 +268,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
266
268
|
constructor(driver: SqlDriver | (() => Promise<SqlDriver>)) {
|
|
267
269
|
this.ready = this.init(driver);
|
|
268
270
|
this.forms = new FormStore(async () => { await this.ready; return this.driver; });
|
|
271
|
+
this.guests = new GuestStore(async () => { await this.ready; return this.driver; });
|
|
269
272
|
this.live = new LiveStore(async () => { await this.ready; return this.driver; });
|
|
270
273
|
this.push = new PushStore(async () => { await this.ready; return this.driver; });
|
|
271
274
|
this.editor = new EditorStore(async () => { await this.ready; return this.driver; });
|
|
@@ -1031,11 +1034,11 @@ export class SqlRepository implements ContentRepository {
|
|
|
1031
1034
|
return {
|
|
1032
1035
|
sql: `INSERT INTO booking_links (id, resource_id, slug, name, description, mode, duration, lengths, min_days, max_days, step, buffer_before,
|
|
1033
1036
|
buffer_after, min_notice, max_advance, per_day_cap, hours, auto_confirm, visibility, ask_phone, questions, confirm_message, redirect_url,
|
|
1034
|
-
active, ord, created_at, updated_at, approval, approval_rules) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1037
|
+
active, ord, created_at, updated_at, approval, approval_rules, change_cutoff) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1035
1038
|
args: [l.id, l.resourceId, l.slug, l.name, l.description, l.mode, l.duration, JSON.stringify(l.lengths), l.minDays, l.maxDays, l.step,
|
|
1036
1039
|
l.bufferBefore, l.bufferAfter, l.minNotice, l.maxAdvance, l.perDayCap, l.hours ? JSON.stringify(l.hours) : null, l.autoConfirm ? 1 : 0,
|
|
1037
1040
|
l.visibility, l.askPhone ? 1 : 0, JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.createdAt, l.updatedAt,
|
|
1038
|
-
l.approval, JSON.stringify(l.approvalRules)],
|
|
1041
|
+
l.approval, JSON.stringify(l.approvalRules), l.changeCutoff],
|
|
1039
1042
|
};
|
|
1040
1043
|
}
|
|
1041
1044
|
private bookingInsert(b: Booking): SqlStatement {
|
|
@@ -1126,10 +1129,10 @@ export class SqlRepository implements ContentRepository {
|
|
|
1126
1129
|
await this.driver.run(
|
|
1127
1130
|
`UPDATE booking_links SET slug = ?, name = ?, description = ?, mode = ?, duration = ?, lengths = ?, min_days = ?, max_days = ?, step = ?,
|
|
1128
1131
|
buffer_before = ?, buffer_after = ?, min_notice = ?, max_advance = ?, per_day_cap = ?, hours = ?, auto_confirm = ?, visibility = ?,
|
|
1129
|
-
ask_phone = ?, questions = ?, confirm_message = ?, redirect_url = ?, active = ?, ord = ?, updated_at = ?, approval = ?, approval_rules = ? WHERE id = ?`,
|
|
1132
|
+
ask_phone = ?, questions = ?, confirm_message = ?, redirect_url = ?, active = ?, ord = ?, updated_at = ?, approval = ?, approval_rules = ?, change_cutoff = ? WHERE id = ?`,
|
|
1130
1133
|
[l.slug, l.name, l.description, l.mode, l.duration, JSON.stringify(l.lengths), l.minDays, l.maxDays, l.step, l.bufferBefore, l.bufferAfter,
|
|
1131
1134
|
l.minNotice, l.maxAdvance, l.perDayCap, l.hours ? JSON.stringify(l.hours) : null, l.autoConfirm ? 1 : 0, l.visibility, l.askPhone ? 1 : 0,
|
|
1132
|
-
JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.updatedAt, l.approval, JSON.stringify(l.approvalRules), id]
|
|
1135
|
+
JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.updatedAt, l.approval, JSON.stringify(l.approvalRules), l.changeCutoff, id]
|
|
1133
1136
|
);
|
|
1134
1137
|
}
|
|
1135
1138
|
async deleteBookingLink(id: string) {
|
|
@@ -1147,9 +1150,12 @@ export class SqlRepository implements ContentRepository {
|
|
|
1147
1150
|
if (filter.email) { where.push('email = ?'); args.push(filter.email.toLowerCase()); }
|
|
1148
1151
|
if (filter.from !== undefined) { where.push('end_at > ?'); args.push(filter.from); }
|
|
1149
1152
|
if (filter.to !== undefined) { where.push('start_at < ?'); args.push(filter.to); }
|
|
1153
|
+
if (filter.endedBy !== undefined) { where.push('end_at <= ?'); args.push(filter.endedBy); }
|
|
1150
1154
|
if (filter.statuses?.length) { where.push(`status IN (${filter.statuses.map(() => '?').join(', ')})`); args.push(...filter.statuses); }
|
|
1151
1155
|
if (filter.kinds?.length) { where.push(`kind IN (${filter.kinds.map(() => '?').join(', ')})`); args.push(...filter.kinds); }
|
|
1152
|
-
const
|
|
1156
|
+
const dir = filter.newestFirst ? 'DESC' : 'ASC';
|
|
1157
|
+
const limit = Math.min(Math.max(Math.trunc(filter.limit ?? 5000), 1), 5000), offset = Math.max(Math.trunc(filter.offset ?? 0), 0);
|
|
1158
|
+
const rows = await this.driver.all(`SELECT * FROM bookings${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY start_at ${dir}, id ${dir} LIMIT ${limit} OFFSET ${offset}`, args);
|
|
1153
1159
|
return rows.map(rowToBooking);
|
|
1154
1160
|
}
|
|
1155
1161
|
async getBooking(id: string) {
|
|
@@ -1163,6 +1169,12 @@ export class SqlRepository implements ContentRepository {
|
|
|
1163
1169
|
const r = await this.first('SELECT * FROM bookings WHERE manage_token = ?', [token]);
|
|
1164
1170
|
return r ? rowToBooking(r) : null;
|
|
1165
1171
|
}
|
|
1172
|
+
async countBookingsByEmail(email: string, since: number) {
|
|
1173
|
+
await this.ready;
|
|
1174
|
+
// staff bookings made for someone don't use up that person's public allowance
|
|
1175
|
+
const r = await this.first("SELECT COUNT(*) AS n FROM bookings WHERE email = ? AND created_at >= ? AND kind != 'block' AND source != 'admin'", [email.trim().toLowerCase(), since]);
|
|
1176
|
+
return Number(r?.n ?? 0);
|
|
1177
|
+
}
|
|
1166
1178
|
// One INSERT … SELECT … WHERE NOT EXISTS: the overlap test and the write
|
|
1167
1179
|
// are a single statement, so two people racing for the same slot can't
|
|
1168
1180
|
// both win — the loser's insert simply affects no rows.
|
|
@@ -1445,6 +1457,30 @@ export class SqlRepository implements ContentRepository {
|
|
|
1445
1457
|
await this.ready;
|
|
1446
1458
|
await this.driver.run('DELETE FROM links WHERE id = ?', [id]);
|
|
1447
1459
|
}
|
|
1460
|
+
async linksRevision() {
|
|
1461
|
+
await this.ready;
|
|
1462
|
+
return Number((await this.first('SELECT links_revision FROM settings WHERE id = 1'))?.links_revision ?? 0);
|
|
1463
|
+
}
|
|
1464
|
+
async replaceLinks(expectedRevision: number, items: Parameters<ContentRepository['replaceLinks']>[1], profile: Parameters<ContentRepository['replaceLinks']>[2] = {}) {
|
|
1465
|
+
await this.ready;
|
|
1466
|
+
const keep = items.flatMap((i) => (i.keep ? [i.keep] : []));
|
|
1467
|
+
const settings = this.settingsStatement(profile);
|
|
1468
|
+
try {
|
|
1469
|
+
await this.driver.batch([
|
|
1470
|
+
// fails the CHECK (and so the whole batch) when someone saved in between
|
|
1471
|
+
{ sql: 'INSERT INTO revision_checks (expected, actual) SELECT ?, links_revision FROM settings WHERE id = 1', args: [expectedRevision] },
|
|
1472
|
+
{ sql: 'DELETE FROM revision_checks' },
|
|
1473
|
+
keep.length ? { sql: `DELETE FROM links WHERE id NOT IN (${keep.map(() => '?').join(', ')})`, args: keep } : { sql: 'DELETE FROM links' },
|
|
1474
|
+
...items.map((i, ord) => i.keep
|
|
1475
|
+
? { sql: 'UPDATE links SET label = ?, url = ?, icon = ?, html = ?, meta = ?, ord = ? WHERE id = ?', args: [i.label, i.url, i.icon, i.html, JSON.stringify(i.meta), ord, i.keep] }
|
|
1476
|
+
: { sql: 'INSERT INTO links (id, kind, label, url, icon, html, meta, ord) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', args: [crypto.randomUUID(), i.kind, i.label, i.url, i.icon, i.html, JSON.stringify(i.meta), ord] }),
|
|
1477
|
+
...(settings ? [settings] : []),
|
|
1478
|
+
]);
|
|
1479
|
+
} catch (e) {
|
|
1480
|
+
if (/CHECK constraint/i.test(String(e))) throw new ApiError(409, 'The links page changed since you opened it. Reload and try again.');
|
|
1481
|
+
throw e;
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1448
1484
|
async trackClick(id: string) {
|
|
1449
1485
|
await this.ready;
|
|
1450
1486
|
const r = await this.first('SELECT url FROM links WHERE id = ?', [id]);
|
|
@@ -1611,8 +1647,12 @@ export class SqlRepository implements ContentRepository {
|
|
|
1611
1647
|
}
|
|
1612
1648
|
|
|
1613
1649
|
// ---- search ----
|
|
1614
|
-
async searchPublished(query: string, opts?: { locale?: string }): Promise<SearchResults> {
|
|
1650
|
+
async searchPublished(query: string, opts?: { locale?: string; limit?: number; collections?: string[] }): Promise<SearchResults> {
|
|
1615
1651
|
await this.ready;
|
|
1652
|
+
// per-kind cap; the public API widens it for deeper result pages
|
|
1653
|
+
const limit = Math.min(Math.max(Math.trunc(opts?.limit ?? 50), 1), 200);
|
|
1654
|
+
const inCollections = opts?.collections ? ` AND p.collection IN (${opts.collections.map(() => '?').join(', ') || 'NULL'})` : '';
|
|
1655
|
+
const collectionArgs = opts?.collections ?? [];
|
|
1616
1656
|
const trimmed = query.trim();
|
|
1617
1657
|
if (!trimmed) return { posts: [], pages: [], events: [], tags: [], authors: [] };
|
|
1618
1658
|
const locale = opts?.locale ?? '';
|
|
@@ -1623,29 +1663,29 @@ export class SqlRepository implements ContentRepository {
|
|
|
1623
1663
|
[postRows, pageRows, eventRows] = await Promise.all([
|
|
1624
1664
|
this.driver.all(
|
|
1625
1665
|
`SELECT p.*, ${SNIPPET} AS snip FROM search_index JOIN posts p ON p.id = search_index.ref_id
|
|
1626
|
-
WHERE search_index MATCH ? AND search_index.kind = 'post' AND p.status = 'published'
|
|
1627
|
-
ORDER BY ${RANK} LIMIT
|
|
1628
|
-
[ftsQuery(trimmed)]
|
|
1666
|
+
WHERE search_index MATCH ? AND search_index.kind = 'post' AND p.status = 'published'${inCollections}
|
|
1667
|
+
ORDER BY ${RANK} LIMIT ${limit}`,
|
|
1668
|
+
[ftsQuery(trimmed), ...collectionArgs]
|
|
1629
1669
|
),
|
|
1630
1670
|
// locale is a boost, never a filter: the visitor's language sorts
|
|
1631
1671
|
// first, every locale stays visible (en/es cover different topics)
|
|
1632
1672
|
this.driver.all(
|
|
1633
1673
|
`SELECT g.*, ${SNIPPET} AS snip FROM search_index JOIN pages g ON g.id = search_index.ref_id
|
|
1634
1674
|
WHERE search_index MATCH ? AND search_index.kind = 'page' AND g.status = 'published'
|
|
1635
|
-
ORDER BY (search_index.locale = ?) DESC, ${RANK} LIMIT
|
|
1675
|
+
ORDER BY (search_index.locale = ?) DESC, ${RANK} LIMIT ${limit}`,
|
|
1636
1676
|
[ftsQuery(trimmed), locale]
|
|
1637
1677
|
),
|
|
1638
1678
|
this.driver.all(
|
|
1639
1679
|
`SELECT e.*, ${SNIPPET} AS snip FROM search_index JOIN events e ON e.id = search_index.ref_id
|
|
1640
1680
|
WHERE search_index MATCH ? AND search_index.kind = 'event' AND e.status = 'published'
|
|
1641
|
-
ORDER BY ${RANK} LIMIT
|
|
1681
|
+
ORDER BY ${RANK} LIMIT ${limit}`,
|
|
1642
1682
|
[ftsQuery(trimmed)]
|
|
1643
1683
|
),
|
|
1644
1684
|
]);
|
|
1645
1685
|
} catch {
|
|
1646
1686
|
// FTS5 unavailable or a query form it rejects — degrade to the old
|
|
1647
1687
|
// LIKE scan (recency-ordered, snippets fall back to excerpt/seo_desc)
|
|
1648
|
-
[postRows, pageRows, eventRows] = await this.likeSearch(trimmed, locale);
|
|
1688
|
+
[postRows, pageRows, eventRows] = await this.likeSearch(trimmed, locale, limit, opts?.collections);
|
|
1649
1689
|
}
|
|
1650
1690
|
const like = `%${trimmed.replace(/[%_]/g, ' ').trim()}%`;
|
|
1651
1691
|
const [authors, tagRows, userRows] = await Promise.all([
|
|
@@ -1675,20 +1715,20 @@ export class SqlRepository implements ContentRepository {
|
|
|
1675
1715
|
authors: userRows.map(rowToUser),
|
|
1676
1716
|
};
|
|
1677
1717
|
}
|
|
1678
|
-
private async likeSearch(query: string, locale: string): Promise<[Row[], Row[], Row[]]> {
|
|
1718
|
+
private async likeSearch(query: string, locale: string, limit: number, collections?: string[]): Promise<[Row[], Row[], Row[]]> {
|
|
1679
1719
|
const q = `%${query.replace(/[%_]/g, ' ').trim()}%`;
|
|
1680
1720
|
if (q === '%%') return [[], [], []];
|
|
1681
1721
|
return Promise.all([
|
|
1682
1722
|
this.driver.all(
|
|
1683
|
-
|
|
1684
|
-
[q, q, q, q]
|
|
1723
|
+
`SELECT * FROM posts WHERE status = 'published'${collections ? ` AND collection IN (${collections.map(() => '?').join(', ') || 'NULL'})` : ''} AND (title LIKE ? OR excerpt LIKE ? OR body_html LIKE ? OR body LIKE ?) ORDER BY published_at DESC LIMIT ${limit}`,
|
|
1724
|
+
[...(collections ?? []), q, q, q, q]
|
|
1685
1725
|
),
|
|
1686
1726
|
this.driver.all(
|
|
1687
|
-
|
|
1727
|
+
`SELECT * FROM pages WHERE status = 'published' AND (title LIKE ? OR body_html LIKE ?) ORDER BY (locale = ?) DESC, updated_at DESC LIMIT ${limit}`,
|
|
1688
1728
|
[q, q, locale]
|
|
1689
1729
|
),
|
|
1690
1730
|
this.driver.all(
|
|
1691
|
-
|
|
1731
|
+
`SELECT * FROM events WHERE status = 'published' AND (title LIKE ? OR excerpt LIKE ? OR body_html LIKE ?) ORDER BY start_at DESC LIMIT ${limit}`,
|
|
1692
1732
|
[q, q, q]
|
|
1693
1733
|
),
|
|
1694
1734
|
]);
|
|
@@ -1938,6 +1978,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
1938
1978
|
live: JSON.parse(String(r?.live ?? '{}')),
|
|
1939
1979
|
push: JSON.parse(String(r?.push ?? '{}')),
|
|
1940
1980
|
calendar: JSON.parse(String(r?.calendar ?? '{}')),
|
|
1981
|
+
mobileAccess: JSON.parse(String(r?.mobile_access ?? '{}')),
|
|
1982
|
+
monitoring: JSON.parse(String(r?.monitoring ?? '{}')),
|
|
1941
1983
|
agent: JSON.parse(String(r?.agent ?? '{}')),
|
|
1942
1984
|
draftsApiKey: r?.drafts_api_key ?? '',
|
|
1943
1985
|
draftsAllowedOrigins: JSON.parse(String(r?.drafts_allowed_origins ?? '[]')),
|
|
@@ -1949,14 +1991,18 @@ export class SqlRepository implements ContentRepository {
|
|
|
1949
1991
|
const statement = this.settingsStatement(data);
|
|
1950
1992
|
if (statement) await this.driver.run(statement.sql, statement.args);
|
|
1951
1993
|
}
|
|
1994
|
+
async patchMobileAccess(patch: Partial<Settings['mobileAccess']>) {
|
|
1995
|
+
await this.ready;
|
|
1996
|
+
await this.driver.run('UPDATE settings SET mobile_access = json_patch(mobile_access, ?) WHERE id = 1', [JSON.stringify(patch)]);
|
|
1997
|
+
}
|
|
1952
1998
|
private settingsStatement(data: Partial<Settings>): SqlStatement | undefined {
|
|
1953
1999
|
const columns: Record<string, string> = {
|
|
1954
2000
|
siteTitle:'site_title', tagline:'tagline', footerText:'footer_text', linksName:'links_name', linksBio:'links_bio', linksAvatar:'links_avatar',
|
|
1955
2001
|
activeTheme:'active_theme', designOverrides:'design_overrides', sectionPages:'section_pages', siteLogo:'site_logo', siteLogoDark:'site_logo_dark', siteUrl:'site_url', siteFavicon:'site_favicon', siteCover:'site_cover',
|
|
1956
2002
|
announcement:'announcement', analytics:'analytics', headCode:'head_code', footerCode:'footer_code', timezone:'timezone', locale:'locale',
|
|
1957
|
-
draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live', push:'push', calendar:'calendar',
|
|
2003
|
+
draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live', push:'push', calendar:'calendar', mobileAccess:'mobile_access', monitoring:'monitoring',
|
|
1958
2004
|
};
|
|
1959
|
-
const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live','push','calendar']);
|
|
2005
|
+
const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live','push','calendar','mobileAccess','monitoring']);
|
|
1960
2006
|
const entries = Object.entries(data).filter(([k,v]) => columns[k] && v !== undefined);
|
|
1961
2007
|
if (!entries.length) return;
|
|
1962
2008
|
const design = entries.some(([k]) => ['activeTheme','designOverrides','sectionPages','siteLogo','siteLogoDark','siteFavicon','siteCover','draftDesign'].includes(k));
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { FormStore } from '../forms/store';
|
|
2
|
+
import type { GuestStore } from '../mobile/guest-store';
|
|
2
3
|
import type { SavedForm, FormSubmission } from '../forms/schema';
|
|
3
4
|
import type { LiveStore } from '../live/store';
|
|
4
5
|
import type { BookingRequest } from '../backup/content';
|
|
@@ -51,6 +52,7 @@ export type EventUpdate = Partial<
|
|
|
51
52
|
export interface ContentRepository {
|
|
52
53
|
readonly email: EmailStore;
|
|
53
54
|
readonly forms: FormStore;
|
|
55
|
+
readonly guests: GuestStore;
|
|
54
56
|
readonly live: LiveStore;
|
|
55
57
|
readonly push: PushStore;
|
|
56
58
|
readonly editor: EditorStore;
|
|
@@ -168,9 +170,12 @@ export interface ContentRepository {
|
|
|
168
170
|
// write that can fail on a race: it inserts atomically only if the held
|
|
169
171
|
// range is still free (resource bookings) or seats remain (RSVPs), and
|
|
170
172
|
// returns null when it lost.
|
|
171
|
-
|
|
173
|
+
/** from/to: overlaps the window; endedBy: ended at or before; default order start ascending, at most 5000 rows */
|
|
174
|
+
listBookings(filter?: { resourceId?: string; eventId?: string; email?: string; from?: number; to?: number; endedBy?: number; statuses?: BookingStatus[]; kinds?: Booking['kind'][]; newestFirst?: boolean; limit?: number; offset?: number }): Promise<Booking[]>;
|
|
172
175
|
getBooking(id: string): Promise<Booking | null>;
|
|
173
176
|
getBookingByToken(token: string): Promise<Booking | null>;
|
|
177
|
+
// bookings + RSVPs (never staff blocks) this address made since `since`
|
|
178
|
+
countBookingsByEmail(email: string, since: number): Promise<number>;
|
|
174
179
|
createBooking(
|
|
175
180
|
data: Omit<Booking, 'id' | 'createdAt' | 'updatedAt' | 'reminderSentAt' | 'notificationVersion'>,
|
|
176
181
|
guard?: { capacity?: number; idempotency?: { keyHash: string; requestHash: string } }
|
|
@@ -222,6 +227,15 @@ export interface ContentRepository {
|
|
|
222
227
|
// full ordering in one call (drag-and-drop); ids in desired display order
|
|
223
228
|
reorderLinks(ids: string[]): Promise<void>;
|
|
224
229
|
deleteLink(id: string): Promise<void>;
|
|
230
|
+
/** changes whenever the links page's blocks or profile change, by any path (not on clicks) */
|
|
231
|
+
linksRevision(): Promise<number>;
|
|
232
|
+
/**
|
|
233
|
+
* the whole links page in one atomic write, only if it is still at
|
|
234
|
+
* `expectedRevision` (409 otherwise, nothing saved): `keep` ids are updated
|
|
235
|
+
* in place, other items are added, everything else is removed; `profile`
|
|
236
|
+
* (links name/bio/avatar) saves in the same batch.
|
|
237
|
+
*/
|
|
238
|
+
replaceLinks(expectedRevision: number, items: (Pick<Link, 'kind' | 'label' | 'url' | 'icon' | 'html' | 'meta'> & { keep?: string })[], profile?: Partial<Pick<Settings, 'linksName' | 'linksBio' | 'linksAvatar'>>): Promise<void>;
|
|
225
239
|
trackClick(id: string): Promise<string | null>; // returns url to redirect to
|
|
226
240
|
|
|
227
241
|
// tags — registry of tag entities; posts store tag names, this table adds
|
|
@@ -260,7 +274,8 @@ export interface ContentRepository {
|
|
|
260
274
|
// #tags excluded) and author profiles. opts.locale is a ranking boost,
|
|
261
275
|
// never a filter: pages in the visitor's locale sort first, every locale
|
|
262
276
|
// stays visible (en/es cover different topics, not translations).
|
|
263
|
-
|
|
277
|
+
// collections: only posts in these (configured) collections
|
|
278
|
+
searchPublished(query: string, opts?: { locale?: string; limit?: number; collections?: string[] }): Promise<SearchResults>;
|
|
264
279
|
|
|
265
280
|
// backup restore — id-preserving replace-all from a backup's content.json.
|
|
266
281
|
// Users restore WITHOUT password hashes (backups never contain them).
|
|
@@ -310,6 +325,8 @@ export interface ContentRepository {
|
|
|
310
325
|
// settings & dashboard
|
|
311
326
|
getSettings(): Promise<Settings>;
|
|
312
327
|
updateSettings(data: Partial<Settings>): Promise<void>;
|
|
328
|
+
/** merges just these App access fields in one statement, so concurrent saves don't undo each other */
|
|
329
|
+
patchMobileAccess(patch: Partial<Settings['mobileAccess']>): Promise<void>;
|
|
313
330
|
// Design-screen staging: save stages into settings.draftDesign, apply
|
|
314
331
|
// copies staged → live design fields and clears, discard just clears
|
|
315
332
|
saveDesignDraft(data: Partial<Pick<Settings, 'activeTheme' | 'designOverrides' | 'siteLogo' | 'siteLogoDark' | 'siteFavicon' | 'siteCover' | 'sectionPages'>> & {
|
package/src/lib/scheduler.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { processDueDeliveries } from './webhooks';
|
|
|
4
4
|
import { dispatchEmail } from './email/service';
|
|
5
5
|
import { decideOverdue, sendDueReminders } from './booking/service';
|
|
6
6
|
import { runPushJobs } from './push/service';
|
|
7
|
+
import { GUEST_IDLE_MS } from './mobile/guest-store';
|
|
8
|
+
import { pushHeartbeat, pushUrlFor } from './monitoring';
|
|
7
9
|
|
|
8
10
|
// All work is awaited on both hosts. Traffic-triggered fallbacks do not mark
|
|
9
11
|
// this heartbeat: monitoring must prove the independent scheduler ran.
|
|
@@ -20,6 +22,8 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
|
|
|
20
22
|
live: () => syncDueLive(repo, locals),
|
|
21
23
|
// queued first-publish notices, starting-soon reminders, receipts, retention
|
|
22
24
|
push: () => runPushJobs(repo, { locals, now: startedAt }),
|
|
25
|
+
// expired guest sign-in codes and app sessions unused for 90 days
|
|
26
|
+
guests: () => repo.guests.purge(GUEST_IDLE_MS, startedAt).then(() => 0),
|
|
23
27
|
};
|
|
24
28
|
const results: Record<string, { ok: boolean; count?: number }> = {};
|
|
25
29
|
for (const [name, run] of Object.entries(jobs)) {
|
|
@@ -28,5 +32,8 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
|
|
|
28
32
|
}
|
|
29
33
|
const report = { startedAt, finishedAt: Date.now(), ok: Object.values(results).every(r => r.ok), jobs: results };
|
|
30
34
|
await repo.email.recordScheduler(report);
|
|
35
|
+
// Uptime Kuma push monitor (Settings → Monitoring): a missing beat means the scheduler stopped
|
|
36
|
+
// (never allowed to fail the run)
|
|
37
|
+
await Promise.resolve().then(() => pushHeartbeat(pushUrlFor(settings.monitoring, locals), report)).catch(() => false);
|
|
31
38
|
return report;
|
|
32
39
|
}
|
package/src/lib/schema.ts
CHANGED
|
@@ -425,7 +425,8 @@ export type WeeklyHours = z.infer<typeof WeeklyHours>;
|
|
|
425
425
|
// a date (or inclusive date span) with replacement hours; [] = closed
|
|
426
426
|
export const DateOverride = z.object({
|
|
427
427
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
428
|
-
|
|
428
|
+
// '' = a single day
|
|
429
|
+
endDate: z.union([z.literal(''), z.string().regex(/^\d{4}-\d{2}-\d{2}$/)]).default(''),
|
|
429
430
|
ranges: z.array(TimeRange).default([]),
|
|
430
431
|
label: z.string().default(''),
|
|
431
432
|
});
|
|
@@ -526,6 +527,35 @@ export const CalendarSettings = z.object({
|
|
|
526
527
|
});
|
|
527
528
|
export type CalendarSettings = z.infer<typeof CalendarSettings>;
|
|
528
529
|
|
|
530
|
+
// Admin → Mobile app → App access: owner switches and abuse limits for the
|
|
531
|
+
// public app routes. Turning a switch off answers 404 at once, no redeploy.
|
|
532
|
+
export const MobileAccess = z.object({
|
|
533
|
+
// visitors view, cancel and move a booking with its private token
|
|
534
|
+
bookingChanges: z.boolean().default(true),
|
|
535
|
+
// "My bookings" through an emailed sign-in code (also needs email sending)
|
|
536
|
+
guestSignIn: z.boolean().default(true),
|
|
537
|
+
// published forms accept responses from apps
|
|
538
|
+
forms: z.boolean().default(true),
|
|
539
|
+
links: z.boolean().default(true),
|
|
540
|
+
// new bookings + RSVPs per email address per day, every channel; 0 = no cap
|
|
541
|
+
bookingsPerEmailDay: z.number().int().min(0).max(1000).default(10),
|
|
542
|
+
// responses per form per day from apps; 0 = no cap
|
|
543
|
+
responsesPerFormDay: z.number().int().min(0).max(100000).default(500),
|
|
544
|
+
// who changes rooms, booking types, hours and calendars from staff apps
|
|
545
|
+
scheduleRoles: z.enum(['admins', 'staff']).default('admins'),
|
|
546
|
+
});
|
|
547
|
+
export type MobileAccess = z.infer<typeof MobileAccess>;
|
|
548
|
+
|
|
549
|
+
// Settings → Monitoring (owner): Uptime Kuma. pushUrl is a Kuma push monitor
|
|
550
|
+
// the scheduler reports to after every run (a small secret: left out of
|
|
551
|
+
// exports; UPTIME_PUSH_URL overrides it). statusPage is a public Kuma status
|
|
552
|
+
// page the admin card reads.
|
|
553
|
+
export const Monitoring = z.object({
|
|
554
|
+
pushUrl: z.string().max(500).default(''),
|
|
555
|
+
statusPage: z.string().max(500).default(''),
|
|
556
|
+
});
|
|
557
|
+
export type Monitoring = z.infer<typeof Monitoring>;
|
|
558
|
+
|
|
529
559
|
export const BookingLink = z.object({
|
|
530
560
|
id: z.string(),
|
|
531
561
|
resourceId: z.string(),
|
|
@@ -543,6 +573,8 @@ export const BookingLink = z.object({
|
|
|
543
573
|
bufferAfter: z.number().int().default(0),
|
|
544
574
|
minNotice: z.number().int().default(120), // minutes
|
|
545
575
|
maxAdvance: z.number().int().default(90), // days
|
|
576
|
+
// guests can cancel or move until this many hours before the start; 0 = any time
|
|
577
|
+
changeCutoff: z.number().int().min(0).max(8760).default(0),
|
|
546
578
|
perDayCap: z.number().int().default(0), // 0 = unlimited
|
|
547
579
|
// narrows the resource's hours; null = use the resource's hours as-is
|
|
548
580
|
hours: WeeklyHours.nullable().default(null),
|
|
@@ -671,6 +703,8 @@ export function normalizeRedirectPath(input: string): string {
|
|
|
671
703
|
export const Settings = z.object({
|
|
672
704
|
live: LiveConfig.default({}),
|
|
673
705
|
calendar: CalendarSettings.default({}),
|
|
706
|
+
mobileAccess: MobileAccess.default({}),
|
|
707
|
+
monitoring: Monitoring.default({}),
|
|
674
708
|
// push notification automation (lib/push); the feature itself is acms({ mobile: { push } })
|
|
675
709
|
push: PushSettings.default({}),
|
|
676
710
|
siteTitle: z.string().default('My Site'),
|
|
@@ -80,6 +80,7 @@ if (Astro.request.method === 'POST') {
|
|
|
80
80
|
minNotice: int(form.get('minNoticeH'), 0, 24 * 60, 2) * 60,
|
|
81
81
|
maxAdvance: int(form.get('maxAdvance'), 1, 730, 90),
|
|
82
82
|
perDayCap: int(form.get('perDayCap'), 0, 100, 0),
|
|
83
|
+
changeCutoff: int(form.get('changeCutoff'), 0, 8760, 0),
|
|
83
84
|
hours: form.get('customHours') ? hoursFromForm(form, 'lh') : null,
|
|
84
85
|
approval, approvalRules,
|
|
85
86
|
// legacy flag for older API readers; approval is what decides
|
|
@@ -183,6 +184,7 @@ const effective = linkApproval(view, settings).mode;
|
|
|
183
184
|
<label class="field"><span>Max per day <em class="hint">0 = no limit</em></span><input type="number" name="perDayCap" min="0" max="100" value={view.perDayCap} /></label>
|
|
184
185
|
<label class="field"><span>Book at least (hours ahead)</span><input type="number" name="minNoticeH" min="0" max="1440" value={Math.round(view.minNotice / 60)} /></label>
|
|
185
186
|
<label class="field"><span>Book up to (days ahead)</span><input type="number" name="maxAdvance" min="1" max="730" value={view.maxAdvance} /></label>
|
|
187
|
+
<label class="field"><span>Guests can change until (hours before) <em class="hint">0 = any time</em></span><input type="number" name="changeCutoff" min="0" max="8760" value={view.changeCutoff} /></label>
|
|
186
188
|
</div>
|
|
187
189
|
</section>
|
|
188
190
|
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Admin from '../../layouts/Admin.astro';
|
|
3
|
+
import AppAccess from '../../components/AppAccess';
|
|
3
4
|
import MobileApp from '../../components/MobileApp';
|
|
4
5
|
import { getRepo } from '../../lib/repo';
|
|
5
6
|
import { requireSession } from '../../lib/api';
|
|
7
|
+
import { PUSH } from '../../lib/push/config';
|
|
6
8
|
const repo = await getRepo(Astro.locals);
|
|
7
9
|
try { await requireSession(Astro.request, Astro.cookies, repo, true); }
|
|
8
10
|
catch { return new Response('Owner access is required.', { status: 403 }); }
|
|
@@ -11,8 +13,10 @@ catch { return new Response('Owner access is required.', { status: 403 }); }
|
|
|
11
13
|
<div class="page-head">
|
|
12
14
|
<div>
|
|
13
15
|
<h1>Mobile app</h1>
|
|
14
|
-
<p class="sub">
|
|
16
|
+
<p class="sub">What your app can do on this site{PUSH.enabled ? ', and notifications for people who installed it' : ''}.</p>
|
|
15
17
|
</div>
|
|
16
18
|
</div>
|
|
17
|
-
<
|
|
19
|
+
<AppAccess client:only="react" />
|
|
20
|
+
{PUSH.enabled && <h2 class="section-title">Notifications</h2>}
|
|
21
|
+
{PUSH.enabled && <MobileApp client:only="react" />}
|
|
18
22
|
</Admin>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Admin from '../../layouts/Admin.astro';
|
|
3
3
|
import AiSettings from '../../components/AiSettings';
|
|
4
|
+
import MonitoringSettings from '../../components/MonitoringSettings';
|
|
4
5
|
import { getRepo } from '../../lib/repo';
|
|
5
6
|
import { validateSession, OWNER_ENV, SESSION_COOKIE } from '../../lib/auth';
|
|
6
7
|
import { invalidateRedirectCache } from '../../middleware';
|
|
@@ -227,6 +228,7 @@ const groups = [
|
|
|
227
228
|
{ id: 'ai-agents', label: 'AI & agents', terms: 'ai agents openai custom kimi deepseek cli proxy cpamc tailscale mcp' },
|
|
228
229
|
{ id: 'analytics', label: 'Analytics', terms: 'analytics umami replay meta pixel tiktok tracking' },
|
|
229
230
|
{ id: 'code', label: 'Code injection', terms: 'code injection head footer scripts' },
|
|
231
|
+
{ id: 'monitoring', label: 'Monitoring', terms: 'monitoring uptime kuma health status down alerts heartbeat scheduler' },
|
|
230
232
|
] }] : []),
|
|
231
233
|
{ label: 'Advanced', items: [
|
|
232
234
|
...(isOwner ? [{ id: 'import', label: 'Import & export', terms: 'import export ghost wordpress migrate migration move content' }] : []),
|
|
@@ -570,6 +572,16 @@ const groups = [
|
|
|
570
572
|
|
|
571
573
|
{isOwner && <section class="scard" id="ai-agents"><header class="scard-head"><a class="scard-back" href="#" aria-label="All settings">←</a><div class="grow"><h2>AI & agents</h2></div><a class="btn" href="/admin/ai">Import document</a></header><AiSettings client:load /></section>}
|
|
572
574
|
|
|
575
|
+
{isOwner && (
|
|
576
|
+
<section class="scard" id="monitoring">
|
|
577
|
+
<header class="scard-head">
|
|
578
|
+
<a class="scard-back" href="#" aria-label="All settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6" /></svg></a>
|
|
579
|
+
<div class="grow"><h2>Monitoring</h2><p>Uptime Kuma watches the website, the app APIs and the scheduler, and alerts you when one stops.</p></div>
|
|
580
|
+
</header>
|
|
581
|
+
<MonitoringSettings client:visible />
|
|
582
|
+
</section>
|
|
583
|
+
)}
|
|
584
|
+
|
|
573
585
|
<div class="set-group-title">Advanced</div>
|
|
574
586
|
|
|
575
587
|
{isOwner && (
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { getRepo } from '../../lib/repo';
|
|
3
|
+
import { healthReport } from '../../lib/monitoring';
|
|
4
|
+
|
|
5
|
+
// Public health check for uptime monitors (Uptime Kuma): 200 when the site
|
|
6
|
+
// works, 503 when the database or the scheduler is down. Names only which part
|
|
7
|
+
// failed; no settings, versions or secrets. Cached 10 s.
|
|
8
|
+
const headers = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' };
|
|
9
|
+
export const GET: APIRoute = async ({ locals }) => {
|
|
10
|
+
try {
|
|
11
|
+
const report = await healthReport(await getRepo(locals), locals);
|
|
12
|
+
return Response.json(report, { status: report.status === 'down' ? 503 : 200, headers });
|
|
13
|
+
} catch {
|
|
14
|
+
return Response.json({ status: 'down', checks: { database: 'down', scheduler: 'stalled', email: 'off' }, checkedAt: new Date().toISOString() }, { status: 503, headers });
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
export const HEAD: APIRoute = async (c) => { const r = await GET(c); return new Response(null, { status: r.status, headers }); };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { getRepo } from '../../../lib/repo';
|
|
3
|
+
import { ApiError, apiError, json, readJson, requireSession } from '../../../lib/api';
|
|
4
|
+
import { AppAccessInput, appAccessAction, appAccessState } from '../../../lib/mobile/access-admin';
|
|
5
|
+
|
|
6
|
+
// Admin → Mobile app → App access (owner only, this site's admin only).
|
|
7
|
+
export const GET: APIRoute = async ({ request, cookies, locals }) => {
|
|
8
|
+
try {
|
|
9
|
+
const repo = await getRepo(locals);
|
|
10
|
+
await requireSession(request, cookies, repo, true);
|
|
11
|
+
return json(await appAccessState(repo, locals));
|
|
12
|
+
} catch (e) { return apiError(e); }
|
|
13
|
+
};
|
|
14
|
+
export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
|
|
15
|
+
try {
|
|
16
|
+
const repo = await getRepo(locals);
|
|
17
|
+
await requireSession(request, cookies, repo, true);
|
|
18
|
+
if (request.headers.get('origin') !== url.origin) throw new ApiError(403, 'Change this from this site’s admin.');
|
|
19
|
+
await appAccessAction(repo, AppAccessInput.parse(await readJson(request, 5_000)));
|
|
20
|
+
return json(await appAccessState(repo, locals));
|
|
21
|
+
} catch (e) { return apiError(e); }
|
|
22
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { getRepo } from '../../lib/repo';
|
|
3
|
+
import { ApiError, apiError, json, readJson, requireSession } from '../../lib/api';
|
|
4
|
+
import { MonitoringInput, monitoringAction, monitoringState } from '../../lib/monitoring';
|
|
5
|
+
|
|
6
|
+
// Settings → Monitoring (owner only, this site's admin only).
|
|
7
|
+
export const GET: APIRoute = async ({ request, cookies, locals }) => {
|
|
8
|
+
try {
|
|
9
|
+
const repo = await getRepo(locals);
|
|
10
|
+
await requireSession(request, cookies, repo, true);
|
|
11
|
+
return json(await monitoringState(repo, locals));
|
|
12
|
+
} catch (e) { return apiError(e); }
|
|
13
|
+
};
|
|
14
|
+
export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
|
|
15
|
+
try {
|
|
16
|
+
const repo = await getRepo(locals);
|
|
17
|
+
await requireSession(request, cookies, repo, true);
|
|
18
|
+
if (request.headers.get('origin') !== url.origin) throw new ApiError(403, 'Change this from this site’s admin.');
|
|
19
|
+
await monitoringAction(repo, MonitoringInput.parse(await readJson(request, 5_000)), locals);
|
|
20
|
+
return json(await monitoringState(repo, locals));
|
|
21
|
+
} catch (e) { return apiError(e); }
|
|
22
|
+
};
|
|
@@ -17,7 +17,8 @@ export const GET: APIRoute = async ({ request, locals }) => {
|
|
|
17
17
|
return Response.json({ ok: canonicalHttps && secretsPresent && schedulerHealthy,
|
|
18
18
|
database: env?.DB ? 'd1' : 'sqlite', media: env?.MEDIA ? 'r2' : 'filesystem',
|
|
19
19
|
canonicalHttps, secretsPresent, schedulerHealthy, scheduler,
|
|
20
|
-
|
|
20
|
+
// 503 when anything is wrong, so a plain HTTP monitor sees it too
|
|
21
|
+
}, { status: canonicalHttps && secretsPresent && schedulerHealthy ? 200 : 503, headers });
|
|
21
22
|
} catch { return Response.json({ ok: false, error: 'Unable to check deployment.' }, { status: 503, headers }); }
|
|
22
23
|
};
|
|
23
24
|
// Explicit authenticated probe; writes only a random short-lived object.
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { editorRoute, logWrite } from '../../../../../../lib/editor/http';
|
|
2
2
|
import { BookingActionInput, bookingAction } from '../../../../../../lib/editor/bookings';
|
|
3
|
+
import { RescheduleInput, staffReschedule } from '../../../../../../lib/editor/schedule';
|
|
3
4
|
import { methodNotAllowed } from '../../../../../../lib/mobile/http';
|
|
4
5
|
import { readJson } from '../../../../../../lib/api';
|
|
5
6
|
export { preflight as OPTIONS } from '../../../../../../lib/mobile/http';
|
|
6
|
-
// approve | decline | cancel — the guest is emailed, as from Admin → Bookings
|
|
7
|
+
// approve | decline | cancel | reschedule — the guest is emailed, as from Admin → Bookings
|
|
7
8
|
export const POST = editorRoute(async (c) => {
|
|
8
9
|
const { id, action } = c.context.params;
|
|
10
|
+
if (action === 'reschedule') {
|
|
11
|
+
const result = await staffReschedule(c, id!, RescheduleInput.parse(await readJson(c.context.request, 5_000)));
|
|
12
|
+
await logWrite(c, 'booking-reschedule', `booking:${id}`);
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
9
15
|
const body = c.context.request.headers.get('content-type') ? BookingActionInput.parse(await readJson(c.context.request, 5_000)) : {};
|
|
10
16
|
const result = await bookingAction(c.repo, id!, action!, body, { url: c.context.url, locals: c.context.locals });
|
|
11
17
|
await logWrite(c, `booking-${action}`, `booking:${id}`);
|
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
import { editorRoute } from '../../../../../lib/editor/http';
|
|
1
|
+
import { editorRoute, logWrite } from '../../../../../lib/editor/http';
|
|
2
2
|
import { InboxQuery, inbox } from '../../../../../lib/editor/bookings';
|
|
3
|
+
import { StaffBookingInput, staffBook } from '../../../../../lib/editor/schedule';
|
|
3
4
|
import { methodNotAllowed, queryParams } from '../../../../../lib/mobile/http';
|
|
5
|
+
import { readJson } from '../../../../../lib/api';
|
|
4
6
|
export { preflight as OPTIONS } from '../../../../../lib/mobile/http';
|
|
5
7
|
export const GET = editorRoute(async ({ repo, context }) => inbox(repo, InboxQuery.parse(queryParams(context.url))));
|
|
6
|
-
|
|
8
|
+
// book on someone's behalf (the guest gets the usual emails)
|
|
9
|
+
export const POST = editorRoute(async (c) => {
|
|
10
|
+
const b = await staffBook(c, StaffBookingInput.parse(await readJson(c.context.request, 50_000)));
|
|
11
|
+
await logWrite(c, 'booking-create', `booking:${b.id}`);
|
|
12
|
+
return b;
|
|
13
|
+
}, { write: true, status: 201 });
|
|
14
|
+
export const ALL = () => methodNotAllowed('GET, POST, OPTIONS');
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { editorRoute, logWrite } from '../../../../../lib/editor/http';
|
|
2
|
+
import { CalendarPatch, deleteCalendarFor, updateCalendarFor } from '../../../../../lib/editor/schedule';
|
|
3
|
+
import { methodNotAllowed } from '../../../../../lib/mobile/http';
|
|
4
|
+
import { readJson } from '../../../../../lib/api';
|
|
5
|
+
export { preflight as OPTIONS } from '../../../../../lib/mobile/http';
|
|
6
|
+
export const PATCH = editorRoute(async (c) => {
|
|
7
|
+
const cal = await updateCalendarFor(c, c.context.params.id!, CalendarPatch.parse(await readJson(c.context.request, 10_000)));
|
|
8
|
+
await logWrite(c, 'calendar-update', `calendar:${cal.id}`);
|
|
9
|
+
return cal;
|
|
10
|
+
}, { write: true });
|
|
11
|
+
export const DELETE = editorRoute(async (c) => {
|
|
12
|
+
const cal = await deleteCalendarFor(c, c.context.params.id!);
|
|
13
|
+
await logWrite(c, 'calendar-delete', `calendar:${cal.id}`);
|
|
14
|
+
return cal;
|
|
15
|
+
}, { write: true });
|
|
16
|
+
export const ALL = () => methodNotAllowed('PATCH, DELETE, OPTIONS');
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { editorRoute, logWrite } from '../../../../../lib/editor/http';
|
|
2
|
+
import { CalendarBody, createCalendarFor, listCalendarsFor } from '../../../../../lib/editor/schedule';
|
|
3
|
+
import { methodNotAllowed } from '../../../../../lib/mobile/http';
|
|
4
|
+
import { readJson } from '../../../../../lib/api';
|
|
5
|
+
export { preflight as OPTIONS } from '../../../../../lib/mobile/http';
|
|
6
|
+
export const GET = editorRoute(async (c) => listCalendarsFor(c));
|
|
7
|
+
export const POST = editorRoute(async (c) => {
|
|
8
|
+
const cal = await createCalendarFor(c, CalendarBody.parse(await readJson(c.context.request, 10_000)));
|
|
9
|
+
await logWrite(c, 'calendar-create', `calendar:${cal.id}`);
|
|
10
|
+
return cal;
|
|
11
|
+
}, { write: true, status: 201 });
|
|
12
|
+
export const ALL = () => methodNotAllowed('GET, POST, OPTIONS');
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { editorRoute } from '../../../../../../lib/editor/http';
|
|
2
|
+
import { ResponsesQuery, formResponsesFor } from '../../../../../../lib/editor/site-data';
|
|
3
|
+
import { methodNotAllowed, queryParams } from '../../../../../../lib/mobile/http';
|
|
4
|
+
export { preflight as OPTIONS } from '../../../../../../lib/mobile/http';
|
|
5
|
+
// administrators and owners only: responses hold personal details
|
|
6
|
+
export const GET = editorRoute(async (c) => formResponsesFor(c, c.context.params.id!, ResponsesQuery.parse(queryParams(c.context.url))));
|
|
7
|
+
export const ALL = () => methodNotAllowed('GET, OPTIONS');
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { editorRoute } from '../../../../../lib/editor/http';
|
|
2
|
+
import { listFormsFor } from '../../../../../lib/editor/site-data';
|
|
3
|
+
import { methodNotAllowed } from '../../../../../lib/mobile/http';
|
|
4
|
+
export { preflight as OPTIONS } from '../../../../../lib/mobile/http';
|
|
5
|
+
export const GET = editorRoute(async (c) => listFormsFor(c));
|
|
6
|
+
export const ALL = () => methodNotAllowed('GET, OPTIONS');
|