@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
@@ -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 rows = await this.driver.all(`SELECT * FROM bookings${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY start_at ASC LIMIT 5000`, args);
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 50`,
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 50`,
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 50`,
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
- "SELECT * FROM posts WHERE status = 'published' AND (title LIKE ? OR excerpt LIKE ? OR body_html LIKE ? OR body LIKE ?) ORDER BY published_at DESC LIMIT 50",
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
- "SELECT * FROM pages WHERE status = 'published' AND (title LIKE ? OR body_html LIKE ?) ORDER BY (locale = ?) DESC, updated_at DESC LIMIT 50",
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
- "SELECT * FROM events WHERE status = 'published' AND (title LIKE ? OR excerpt LIKE ? OR body_html LIKE ?) ORDER BY start_at DESC LIMIT 50",
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));
@@ -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
- listBookings(filter?: { resourceId?: string; eventId?: string; email?: string; from?: number; to?: number; statuses?: BookingStatus[]; kinds?: Booking['kind'][] }): Promise<Booking[]>;
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
- searchPublished(query: string, opts?: { locale?: string }): Promise<SearchResults>;
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'>> & {
@@ -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
- endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).default(''),
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">Notifications for people who installed your app.</p>
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
- <MobileApp client:only="react" />
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 &amp; 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
- }, { headers });
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
- export const ALL = () => methodNotAllowed('GET, OPTIONS');
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');