@acms-subir/core 0.3.9 → 0.3.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/cli/common.mjs +2 -2
- package/cli/legacy.mjs +2 -1
- package/cli/main.mjs +17 -3
- package/cli/migrate.mjs +102 -0
- package/cli/migrations.generated.mjs +1044 -0
- package/cli/mobile-doctor.mjs +9 -0
- package/cli/monitor.mjs +189 -0
- package/cli/preview.mjs +1 -1
- package/deploy/Dockerfile +5 -1
- package/deploy/compose.yml +21 -0
- package/docs/live-video.md +1 -1
- package/docs/mobile.md +23 -2
- package/docs/theme-authoring.md +6 -0
- package/integration.mjs +74 -4
- package/migrate.mjs +5 -0
- package/package.json +9 -11
- package/src/actions/index.ts +11 -31
- package/src/components/AppAccess.tsx +117 -0
- package/src/components/DesignManager.tsx +29 -10
- package/src/components/EmailSettings.tsx +53 -11
- package/src/components/MonitoringSettings.tsx +112 -0
- package/src/components/ThemeTransfer.tsx +211 -0
- package/src/components/auth/ClerkSession.astro +80 -0
- package/src/components/auth/ReverifyForms.astro +61 -0
- package/src/components/auth/clerk.ts +169 -0
- package/src/components/import/ImportExport.tsx +16 -6
- package/src/components/import/runner.ts +3 -0
- package/src/layouts/Admin.astro +42 -12
- package/src/layouts/Site.astro +6 -1
- package/src/lib/api.ts +8 -4
- package/src/lib/auth/accounts.ts +94 -0
- package/src/lib/auth/config.ts +22 -0
- package/src/lib/auth/login-limit.ts +15 -0
- package/src/lib/auth/request.ts +161 -0
- package/src/lib/auth/store.ts +72 -0
- package/src/lib/auth.ts +3 -0
- package/src/lib/backup/archive.ts +26 -0
- package/src/lib/backup/content.ts +1 -1
- package/src/lib/backup/offsite.ts +173 -0
- package/src/lib/backup/store.ts +28 -0
- package/src/lib/booking/availability.ts +15 -2
- package/src/lib/booking/service.ts +53 -1
- package/src/lib/cache/config.ts +7 -0
- package/src/lib/cache/memory.ts +39 -0
- package/src/lib/cache/policy.ts +89 -0
- package/src/lib/cache/purge.ts +89 -0
- package/src/lib/calendar/admin.ts +41 -0
- package/src/lib/designPreview.ts +2 -2
- package/src/lib/dynamicBlocks.ts +1 -1
- package/src/lib/editor/auth.ts +35 -5
- package/src/lib/editor/http.ts +4 -1
- package/src/lib/editor/me.ts +4 -0
- package/src/lib/editor/openapi.ts +49 -1
- package/src/lib/editor/roles.ts +20 -0
- package/src/lib/editor/schedule.ts +340 -0
- package/src/lib/editor/schemas.ts +36 -1
- package/src/lib/editor/site-data.ts +110 -0
- package/src/lib/editor/store.ts +13 -7
- package/src/lib/editor/token-route.ts +2 -1
- package/src/lib/email/accounts.ts +4 -0
- package/src/lib/email/service.ts +9 -1
- package/src/lib/email/store.ts +10 -0
- package/src/lib/email/templates.ts +8 -1
- package/src/lib/email/types.ts +1 -0
- package/src/lib/forms/store.ts +5 -0
- package/src/lib/mobile/access-admin.ts +34 -0
- package/src/lib/mobile/access.ts +28 -0
- package/src/lib/mobile/calendar.ts +26 -13
- package/src/lib/mobile/content.ts +21 -1
- package/src/lib/mobile/forms.ts +77 -0
- package/src/lib/mobile/guest-store.ts +62 -0
- package/src/lib/mobile/guest.ts +160 -0
- package/src/lib/mobile/http.ts +6 -5
- package/src/lib/mobile/links.ts +69 -0
- package/src/lib/mobile/manage.ts +114 -0
- package/src/lib/mobile/openapi.ts +40 -4
- package/src/lib/mobile/route.ts +63 -4
- package/src/lib/mobile/schemas.ts +52 -1
- package/src/lib/mobile/search.ts +95 -0
- package/src/lib/mobile/site.ts +8 -2
- package/src/lib/monitoring.ts +171 -0
- package/src/lib/net.ts +82 -14
- package/src/lib/repo/migrate-runner.ts +96 -0
- package/src/lib/repo/migrations.ts +104 -0
- package/src/lib/repo/sql.ts +122 -44
- package/src/lib/repo/sqlite.ts +14 -1
- package/src/lib/repo/types.ts +31 -3
- package/src/lib/scheduler.ts +14 -0
- package/src/lib/schema.ts +35 -1
- package/src/lib/theme-transfer.ts +163 -0
- package/src/lib/theme.ts +5 -0
- package/src/lib/webhooks.ts +7 -2
- package/src/middleware.ts +41 -11
- package/src/pages/[...slug].astro +3 -2
- package/src/pages/account/[action].astro +6 -0
- package/src/pages/admin/app-auth.astro +21 -7
- package/src/pages/admin/calendar/resources/link.astro +2 -0
- package/src/pages/admin/design.astro +2 -1
- package/src/pages/admin/import.astro +5 -3
- package/src/pages/admin/index.astro +3 -2
- package/src/pages/admin/integrations.astro +3 -2
- package/src/pages/admin/login.astro +236 -25
- package/src/pages/admin/logout.ts +16 -2
- package/src/pages/admin/mobile.astro +6 -2
- package/src/pages/admin/settings.astro +43 -5
- package/src/pages/admin/users.astro +350 -59
- package/src/pages/api/backup.ts +10 -26
- package/src/pages/api/design/export.ts +72 -0
- package/src/pages/api/design/import.ts +41 -0
- package/src/pages/api/email/settings.ts +9 -5
- package/src/pages/api/health.ts +17 -0
- package/src/pages/api/live/oauth/youtube/callback.ts +5 -4
- package/src/pages/api/live/settings.ts +2 -2
- package/src/pages/api/media/upload.ts +2 -2
- package/src/pages/api/mobile/access.ts +22 -0
- package/src/pages/api/monitoring.ts +22 -0
- package/src/pages/api/ops/status.ts +2 -1
- package/src/pages/api/push/admin.ts +3 -2
- package/src/pages/api/restore.ts +6 -3
- package/src/pages/api/v1/admin/bookings/[id]/[action].ts +7 -1
- package/src/pages/api/v1/admin/bookings/index.ts +10 -2
- package/src/pages/api/v1/admin/calendars/[id].ts +16 -0
- package/src/pages/api/v1/admin/calendars/index.ts +12 -0
- package/src/pages/api/v1/admin/forms/[id]/responses.ts +7 -0
- package/src/pages/api/v1/admin/forms/index.ts +6 -0
- package/src/pages/api/v1/admin/links.ts +13 -0
- package/src/pages/api/v1/admin/resources/[id]/blocks/[blockId].ts +10 -0
- package/src/pages/api/v1/admin/resources/[id]/blocks/index.ts +12 -0
- package/src/pages/api/v1/admin/resources/[id]/index.ts +17 -0
- package/src/pages/api/v1/admin/resources/[id]/links/[linkId].ts +16 -0
- package/src/pages/api/v1/admin/resources/[id]/links/index.ts +12 -0
- package/src/pages/api/v1/admin/resources/index.ts +12 -0
- package/src/pages/api/v1/admin/schedule.ts +7 -0
- package/src/pages/api/v1/bookings/manage/availability.ts +6 -0
- package/src/pages/api/v1/bookings/manage/cancel.ts +6 -0
- package/src/pages/api/v1/bookings/manage/ics.ts +6 -0
- package/src/pages/api/v1/bookings/manage/index.ts +6 -0
- package/src/pages/api/v1/bookings/manage/reschedule.ts +6 -0
- package/src/pages/api/v1/calendars.ts +1 -1
- package/src/pages/api/v1/forms/[id]/index.ts +7 -0
- package/src/pages/api/v1/forms/[id]/responses.ts +6 -0
- package/src/pages/api/v1/guest/bookings.ts +6 -0
- package/src/pages/api/v1/guest/code.ts +6 -0
- package/src/pages/api/v1/guest/revoke.ts +6 -0
- package/src/pages/api/v1/guest/token.ts +6 -0
- package/src/pages/api/v1/links.ts +7 -0
- package/src/pages/api/v1/openapi.json.ts +1 -1
- package/src/pages/api/v1/push/topics.ts +1 -1
- package/src/pages/api/v1/resources.ts +1 -1
- package/src/pages/api/v1/search.ts +7 -0
- package/src/pages/api/v1/theme.ts +1 -1
- package/src/pages/blog/[slug].astro +3 -2
- package/src/pages/book/[resource]/[link].astro +3 -3
- package/src/pages/book/manage/[token].astro +11 -7
- package/src/pages/events/[slug].astro +3 -2
- package/src/pages/preview/[type]/[id].astro +3 -3
- package/src/styles/admin.css +169 -16
- package/src/styles/email-settings.css +6 -0
- package/src/virtual.d.ts +1 -0
- package/src/worker.ts +7 -1
|
@@ -19,6 +19,51 @@ export interface Migration {
|
|
|
19
19
|
statements: string[];
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// Columns whose change a visitor can see (v49 triggers bump content_version).
|
|
23
|
+
// Left out on purpose: drafts and autosave bookkeeping (draft, updated_at,
|
|
24
|
+
// revision counters), click counts, credentials, and staff-only settings.
|
|
25
|
+
// Frozen with v49 — a later column joins by a new migration that recreates
|
|
26
|
+
// that table's triggers (the cache test fails until every column is sorted).
|
|
27
|
+
export const CONTENT_VERSION_COLUMNS: Record<string, string[]> = {
|
|
28
|
+
settings: ['site_title', 'tagline', 'links_name', 'links_bio', 'links_avatar', 'footer_text', 'active_theme', 'design_overrides', 'site_logo',
|
|
29
|
+
'site_url', 'site_favicon', 'site_cover', 'announcement', 'head_code', 'footer_code', 'timezone', 'locale', 'analytics', 'section_pages',
|
|
30
|
+
'live', 'push', 'calendar', 'site_logo_dark', 'mobile_access'],
|
|
31
|
+
pages: ['id', 'slug', 'title', 'status', 'is_home', 'blocks', 'seo_title', 'seo_desc', 'body_json', 'body_html', 'template', 'locale', 'translations'],
|
|
32
|
+
posts: ['id', 'slug', 'title', 'body', 'tags', 'cover', 'status', 'published_at', 'body_json', 'body_html', 'excerpt', 'cover_alt', 'seo_title',
|
|
33
|
+
'seo_desc', 'scheduled_at', 'author_id', 'featured', 'template', 'collection', 'translations'],
|
|
34
|
+
post_authors: ['post_id', 'author_id', 'ord'],
|
|
35
|
+
events: ['id', 'slug', 'title', 'body_json', 'body_html', 'excerpt', 'cover', 'cover_alt', 'seo_title', 'seo_desc', 'status', 'featured', 'tags',
|
|
36
|
+
'template', 'starts', 'ends', 'all_day', 'timezone', 'rrule', 'exdates', 'cancelled', 'location', 'cta', 'calendar_ids', 'fields', 'source_uid',
|
|
37
|
+
'start_at', 'end_at', 'published_at', 'scheduled_at', 'rsvp', 'translations', 'room_hold'],
|
|
38
|
+
calendars: ['id', 'slug', 'name', 'color', 'description', 'kind', 'feed_url', 'visibility', 'feed_token', 'ord'],
|
|
39
|
+
places: ['id', 'name', 'address', 'url', 'lat', 'lng', 'notes', 'is_default', 'map_image', 'map_image_dark', 'ord'],
|
|
40
|
+
resources: ['id', 'slug', 'name', 'kind', 'description', 'photo', 'capacity', 'amenities', 'timezone', 'hours', 'overrides', 'busy_feeds',
|
|
41
|
+
'visibility', 'show_on_calendar', 'feed_token', 'ord', 'place_id'],
|
|
42
|
+
booking_links: ['id', 'resource_id', 'slug', 'name', 'description', 'mode', 'duration', 'lengths', 'min_days', 'max_days', 'step', 'buffer_before',
|
|
43
|
+
'buffer_after', 'min_notice', 'max_advance', 'per_day_cap', 'hours', 'auto_confirm', 'visibility', 'ask_phone', 'questions', 'confirm_message',
|
|
44
|
+
'redirect_url', 'active', 'ord', 'approval', 'approval_rules', 'change_cutoff'],
|
|
45
|
+
links: ['id', 'label', 'url', 'icon', 'ord', 'kind', 'html', 'meta'],
|
|
46
|
+
nav_items: ['id', 'label', 'url', 'area', 'ord'],
|
|
47
|
+
tags: ['id', 'name', 'slug', 'description'],
|
|
48
|
+
media: ['id', 'key', 'filename', 'mime', 'width', 'height', 'size', 'alt', 'variants'],
|
|
49
|
+
redirects: ['id', 'from_path', 'to_path', 'code'],
|
|
50
|
+
users: ['id', 'name', 'email', 'role', 'bio', 'avatar', 'slug'],
|
|
51
|
+
forms: ['id', 'name', 'kind', 'purpose', 'status', 'definition'],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const BUMP_CONTENT_VERSION = 'UPDATE settings SET content_version = content_version + 1 WHERE id = 1;';
|
|
55
|
+
function contentVersionTriggers(table: string, columns: string[]): string[] {
|
|
56
|
+
const changed = columns.map((c) => `OLD.${c} IS NOT NEW.${c}`).join(' OR ');
|
|
57
|
+
const update = `CREATE TRIGGER content_version_${table}_update AFTER UPDATE OF ${columns.join(', ')} ON ${table} WHEN ${changed} BEGIN ${BUMP_CONTENT_VERSION} END`;
|
|
58
|
+
// the settings row itself is created once and never deleted
|
|
59
|
+
if (table === 'settings') return [update];
|
|
60
|
+
return [
|
|
61
|
+
`CREATE TRIGGER content_version_${table}_insert AFTER INSERT ON ${table} BEGIN ${BUMP_CONTENT_VERSION} END`,
|
|
62
|
+
`CREATE TRIGGER content_version_${table}_delete AFTER DELETE ON ${table} BEGIN ${BUMP_CONTENT_VERSION} END`,
|
|
63
|
+
update,
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
|
|
22
67
|
export const MIGRATIONS_TABLE = `CREATE TABLE IF NOT EXISTS migrations (
|
|
23
68
|
version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL
|
|
24
69
|
)`;
|
|
@@ -526,6 +571,65 @@ export const MIGRATIONS: Migration[] = [
|
|
|
526
571
|
`CREATE TABLE form_submissions (id TEXT PRIMARY KEY, form_id TEXT NOT NULL, response TEXT NOT NULL, created_at INTEGER NOT NULL)`,
|
|
527
572
|
`CREATE INDEX form_submissions_form_created ON form_submissions(form_id, created_at)`,
|
|
528
573
|
] },
|
|
574
|
+
// app access: owner switches + limits, per-link guest change cutoff, and
|
|
575
|
+
// guest sign-in (emailed codes → per-device tokens; secrets stored hashed)
|
|
576
|
+
{ version: 45, statements: [
|
|
577
|
+
`ALTER TABLE settings ADD COLUMN mobile_access TEXT NOT NULL DEFAULT '{}'`,
|
|
578
|
+
`ALTER TABLE booking_links ADD COLUMN change_cutoff INTEGER NOT NULL DEFAULT 0`,
|
|
579
|
+
`CREATE TABLE guest_codes (email TEXT PRIMARY KEY, code_hash TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL)`,
|
|
580
|
+
`CREATE TABLE guest_sessions (id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, email TEXT NOT NULL, device_name TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL)`,
|
|
581
|
+
`CREATE INDEX guest_sessions_email ON guest_sessions(email)`,
|
|
582
|
+
`CREATE INDEX bookings_email ON bookings(email)`,
|
|
583
|
+
] },
|
|
584
|
+
// links page revision for whole-page saves (staff app): triggers bump it on
|
|
585
|
+
// every content change, whichever path made it (web admin, API, restore);
|
|
586
|
+
// click counts don't. revision_checks makes a stale save fail inside its
|
|
587
|
+
// own batch (CHECK fails → the whole batch rolls back).
|
|
588
|
+
{ version: 46, statements: [
|
|
589
|
+
`ALTER TABLE settings ADD COLUMN links_revision INTEGER NOT NULL DEFAULT 0`,
|
|
590
|
+
`CREATE TABLE revision_checks (expected INTEGER NOT NULL, actual INTEGER NOT NULL CHECK (expected = actual))`,
|
|
591
|
+
`CREATE TRIGGER links_revision_insert AFTER INSERT ON links BEGIN UPDATE settings SET links_revision = links_revision + 1 WHERE id = 1; END`,
|
|
592
|
+
`CREATE TRIGGER links_revision_delete AFTER DELETE ON links BEGIN UPDATE settings SET links_revision = links_revision + 1 WHERE id = 1; END`,
|
|
593
|
+
`CREATE TRIGGER links_revision_update AFTER UPDATE OF kind, label, url, icon, html, meta, ord ON links BEGIN UPDATE settings SET links_revision = links_revision + 1 WHERE id = 1; END`,
|
|
594
|
+
`CREATE TRIGGER links_revision_profile AFTER UPDATE OF links_name, links_bio, links_avatar ON settings
|
|
595
|
+
WHEN OLD.links_name IS NOT NEW.links_name OR OLD.links_bio IS NOT NEW.links_bio OR OLD.links_avatar IS NOT NEW.links_avatar
|
|
596
|
+
BEGIN UPDATE settings SET links_revision = links_revision + 1 WHERE id = 1; END`,
|
|
597
|
+
] },
|
|
598
|
+
// Settings → Monitoring: Uptime Kuma push URL + status page
|
|
599
|
+
{ version: 47, statements: [
|
|
600
|
+
`ALTER TABLE settings ADD COLUMN monitoring TEXT NOT NULL DEFAULT '{}'`,
|
|
601
|
+
] },
|
|
602
|
+
{ version: 48, statements: [
|
|
603
|
+
`CREATE TABLE auth_owner_checks (allowed INTEGER CONSTRAINT keep_clerk_owner CHECK(allowed=1))`,
|
|
604
|
+
`ALTER TABLE editor_codes ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'builtin'`,
|
|
605
|
+
`ALTER TABLE editor_devices ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'builtin'`,
|
|
606
|
+
`CREATE TABLE auth_identities (issuer TEXT NOT NULL, subject TEXT NOT NULL, user_id TEXT NOT NULL, disabled INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(issuer,subject), UNIQUE(issuer,user_id))`,
|
|
607
|
+
`CREATE TABLE auth_bootstrap (issuer TEXT PRIMARY KEY)`,
|
|
608
|
+
`CREATE TABLE auth_invitations (id TEXT PRIMARY KEY, issuer TEXT NOT NULL, email TEXT NOT NULL, name TEXT NOT NULL, role TEXT NOT NULL, state TEXT NOT NULL, remote_id TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL)`,
|
|
609
|
+
`CREATE UNIQUE INDEX auth_invitation_pending ON auth_invitations(issuer,email) WHERE state IN ('sending','pending')`,
|
|
610
|
+
`CREATE TABLE auth_mobile (kind TEXT NOT NULL, id TEXT NOT NULL, issuer TEXT NOT NULL, subject TEXT NOT NULL, session_id TEXT NOT NULL, PRIMARY KEY(kind,id))`,
|
|
611
|
+
`CREATE TRIGGER auth_user_deleted AFTER DELETE ON users BEGIN UPDATE auth_identities SET disabled=1 WHERE user_id=OLD.id; END`,
|
|
612
|
+
`CREATE TRIGGER auth_code_deleted AFTER DELETE ON editor_codes BEGIN DELETE FROM auth_mobile WHERE kind='code' AND id=OLD.hash; END`,
|
|
613
|
+
`CREATE TRIGGER auth_device_deleted AFTER DELETE ON editor_devices BEGIN DELETE FROM auth_mobile WHERE kind='device' AND id=OLD.id; END`,
|
|
614
|
+
] },
|
|
615
|
+
// Public cache: content_version goes up on every change a visitor could
|
|
616
|
+
// see, from any write path (admin, editor API, scheduler, design publish,
|
|
617
|
+
// restore/import, raw SQL), because triggers do the bumping. Cached pages
|
|
618
|
+
// and app ETags are keyed by it; the purge loop records how far it got.
|
|
619
|
+
{ version: 49, statements: [
|
|
620
|
+
`ALTER TABLE settings ADD COLUMN content_version INTEGER NOT NULL DEFAULT 0`,
|
|
621
|
+
`ALTER TABLE settings ADD COLUMN cache_purged_version INTEGER NOT NULL DEFAULT 0`,
|
|
622
|
+
// start at a random point so two databases (a reinstall, a test run)
|
|
623
|
+
// never hand out the same version, and an old ETag can't match by chance
|
|
624
|
+
`UPDATE settings SET content_version = abs(random() % 1000000000), cache_purged_version = content_version WHERE id = 1`,
|
|
625
|
+
...Object.entries(CONTENT_VERSION_COLUMNS).flatMap(([table, columns]) => contentVersionTriggers(table, columns)),
|
|
626
|
+
] },
|
|
627
|
+
// scheduled off-site backups (lib/backup/offsite.ts): last run + lease
|
|
628
|
+
{ version: 50, statements: [
|
|
629
|
+
`CREATE TABLE backup_state (id INTEGER PRIMARY KEY CHECK (id = 1), last_at INTEGER NOT NULL DEFAULT 0, lease_until INTEGER NOT NULL DEFAULT 0,
|
|
630
|
+
last_key TEXT NOT NULL DEFAULT '', last_error TEXT NOT NULL DEFAULT '', failed_at INTEGER NOT NULL DEFAULT 0)`,
|
|
631
|
+
`INSERT INTO backup_state (id) VALUES (1)`,
|
|
632
|
+
] },
|
|
529
633
|
];
|
|
530
634
|
|
|
531
635
|
export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
import { AUTH } from '../auth/config';
|
|
2
|
+
import { AuthStore } from '../auth/store';
|
|
1
3
|
import { FormStore, formInsert, submissionInsert } from '../forms/store';
|
|
4
|
+
import { GuestStore } from '../mobile/guest-store';
|
|
2
5
|
import { LiveStore } from '../live/store';
|
|
3
6
|
import { PushStore } from '../push/store';
|
|
4
7
|
import { EditorStore } from '../editor/store';
|
|
8
|
+
import { BackupStore } from '../backup/store';
|
|
5
9
|
import { LiveConfig } from '../live/types';
|
|
6
10
|
import { ApiError } from '../api';
|
|
7
11
|
import { EmailStore, bookingJobs } from '../email/store';
|
|
@@ -10,7 +14,7 @@ import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings,
|
|
|
10
14
|
import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
|
|
11
15
|
import { eventInstants, eventTimeZone, normalizeEventTimes } from '../calendar/events';
|
|
12
16
|
import { cleanRrule } from '../calendar/rrule';
|
|
13
|
-
import {
|
|
17
|
+
import { runMigrations } from './migrate-runner';
|
|
14
18
|
import { htmlToPlaintext } from '../plaintext';
|
|
15
19
|
import { SEED } from '../brand';
|
|
16
20
|
import { CONTENT, postsIndexUrl } from '../content';
|
|
@@ -183,7 +187,7 @@ function rowToLink2(r: Row): BookingLink {
|
|
|
183
187
|
id: r.id, resourceId: r.resource_id, slug: r.slug, name: r.name, description: r.description ?? '', mode: r.mode ?? 'slots',
|
|
184
188
|
duration: Number(r.duration), lengths: json(r.lengths, []), minDays: Number(r.min_days), maxDays: Number(r.max_days),
|
|
185
189
|
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),
|
|
190
|
+
minNotice: Number(r.min_notice), maxAdvance: Number(r.max_advance), perDayCap: Number(r.per_day_cap), changeCutoff: Number(r.change_cutoff ?? 0),
|
|
187
191
|
hours: r.hours ? json(r.hours, null) : null, autoConfirm: Boolean(r.auto_confirm), visibility: r.visibility ?? 'public',
|
|
188
192
|
approval: r.approval ?? 'default', approvalRules: json(r.approval_rules, {}),
|
|
189
193
|
askPhone: Boolean(r.ask_phone), questions: json(r.questions, []), confirmMessage: r.confirm_message ?? '',
|
|
@@ -257,18 +261,24 @@ function safeSnippet(raw: unknown): string {
|
|
|
257
261
|
export class SqlRepository implements ContentRepository {
|
|
258
262
|
private driver!: SqlDriver;
|
|
259
263
|
private ready: Promise<void>;
|
|
264
|
+
readonly auth: AuthStore;
|
|
260
265
|
readonly email: EmailStore;
|
|
261
266
|
readonly forms: FormStore;
|
|
267
|
+
readonly guests: GuestStore;
|
|
262
268
|
readonly live: LiveStore;
|
|
263
269
|
readonly push: PushStore;
|
|
264
270
|
readonly editor: EditorStore;
|
|
271
|
+
readonly backups: BackupStore;
|
|
265
272
|
|
|
266
273
|
constructor(driver: SqlDriver | (() => Promise<SqlDriver>)) {
|
|
267
274
|
this.ready = this.init(driver);
|
|
268
275
|
this.forms = new FormStore(async () => { await this.ready; return this.driver; });
|
|
276
|
+
this.guests = new GuestStore(async () => { await this.ready; return this.driver; });
|
|
269
277
|
this.live = new LiveStore(async () => { await this.ready; return this.driver; });
|
|
270
278
|
this.push = new PushStore(async () => { await this.ready; return this.driver; });
|
|
271
279
|
this.editor = new EditorStore(async () => { await this.ready; return this.driver; });
|
|
280
|
+
this.backups = new BackupStore(async () => { await this.ready; return this.driver; });
|
|
281
|
+
this.auth = new AuthStore(async () => { await this.ready; return this.driver; });
|
|
272
282
|
this.email = new EmailStore(async () => { await this.ready; return this.driver; });
|
|
273
283
|
}
|
|
274
284
|
|
|
@@ -284,27 +294,11 @@ export class SqlRepository implements ContentRepository {
|
|
|
284
294
|
}
|
|
285
295
|
|
|
286
296
|
private async migrate() {
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
let applied = Number(r?.v ?? 0);
|
|
290
|
-
if (applied === 0) {
|
|
291
|
-
// Pre-migrations DB: the baseline tables already exist, mark v1 applied.
|
|
292
|
-
const baseline = await this.first("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'pages'");
|
|
293
|
-
if (baseline) {
|
|
294
|
-
await this.driver.run('INSERT INTO migrations (version, applied_at) VALUES (1, ?)', [Date.now()]);
|
|
295
|
-
applied = 1;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
const pending = MIGRATIONS.filter((m) => m.version > applied);
|
|
299
|
-
for (const m of pending) {
|
|
300
|
-
await this.driver.batch([
|
|
301
|
-
...m.statements.map((sql) => ({ sql })),
|
|
302
|
-
{ sql: 'INSERT INTO migrations (version, applied_at) VALUES (?, ?)', args: [m.version, Date.now()] },
|
|
303
|
-
]);
|
|
304
|
-
}
|
|
297
|
+
// under the shared migration lock (also used by `acms migrate`)
|
|
298
|
+
const { applied } = await runMigrations(this.driver);
|
|
305
299
|
// v21 introduced the search index; plaintext can't be derived in SQL, so
|
|
306
300
|
// the backfill runs here once, right after that version lands.
|
|
307
|
-
if (
|
|
301
|
+
if (applied.includes(21)) await this.reindexSearch();
|
|
308
302
|
}
|
|
309
303
|
|
|
310
304
|
private async seed() {
|
|
@@ -317,7 +311,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
317
311
|
{ id: uuid(), type: 'text', config: { markdown: 'This home page was built in the **widget editor** — open `/admin` to change it.' } },
|
|
318
312
|
];
|
|
319
313
|
await this.driver.batch([
|
|
320
|
-
|
|
314
|
+
// random start: see migration 49
|
|
315
|
+
{ sql: 'INSERT INTO settings (id, site_title, tagline, content_version) VALUES (1, ?, ?, abs(random() % 1000000000))', args: [SEED.siteTitle, SEED.tagline] },
|
|
321
316
|
{
|
|
322
317
|
sql: 'INSERT INTO pages (id, slug, title, status, is_home, blocks, created_at, updated_at) VALUES (?, ?, ?, ?, 1, ?, ?, ?)',
|
|
323
318
|
args: [uuid(), 'home', 'Home', 'published', JSON.stringify(homeBlocks), now, now],
|
|
@@ -1031,11 +1026,11 @@ export class SqlRepository implements ContentRepository {
|
|
|
1031
1026
|
return {
|
|
1032
1027
|
sql: `INSERT INTO booking_links (id, resource_id, slug, name, description, mode, duration, lengths, min_days, max_days, step, buffer_before,
|
|
1033
1028
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1029
|
+
active, ord, created_at, updated_at, approval, approval_rules, change_cutoff) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1035
1030
|
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
1031
|
l.bufferBefore, l.bufferAfter, l.minNotice, l.maxAdvance, l.perDayCap, l.hours ? JSON.stringify(l.hours) : null, l.autoConfirm ? 1 : 0,
|
|
1037
1032
|
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)],
|
|
1033
|
+
l.approval, JSON.stringify(l.approvalRules), l.changeCutoff],
|
|
1039
1034
|
};
|
|
1040
1035
|
}
|
|
1041
1036
|
private bookingInsert(b: Booking): SqlStatement {
|
|
@@ -1126,10 +1121,10 @@ export class SqlRepository implements ContentRepository {
|
|
|
1126
1121
|
await this.driver.run(
|
|
1127
1122
|
`UPDATE booking_links SET slug = ?, name = ?, description = ?, mode = ?, duration = ?, lengths = ?, min_days = ?, max_days = ?, step = ?,
|
|
1128
1123
|
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 = ?`,
|
|
1124
|
+
ask_phone = ?, questions = ?, confirm_message = ?, redirect_url = ?, active = ?, ord = ?, updated_at = ?, approval = ?, approval_rules = ?, change_cutoff = ? WHERE id = ?`,
|
|
1130
1125
|
[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
1126
|
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]
|
|
1127
|
+
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
1128
|
);
|
|
1134
1129
|
}
|
|
1135
1130
|
async deleteBookingLink(id: string) {
|
|
@@ -1147,9 +1142,12 @@ export class SqlRepository implements ContentRepository {
|
|
|
1147
1142
|
if (filter.email) { where.push('email = ?'); args.push(filter.email.toLowerCase()); }
|
|
1148
1143
|
if (filter.from !== undefined) { where.push('end_at > ?'); args.push(filter.from); }
|
|
1149
1144
|
if (filter.to !== undefined) { where.push('start_at < ?'); args.push(filter.to); }
|
|
1145
|
+
if (filter.endedBy !== undefined) { where.push('end_at <= ?'); args.push(filter.endedBy); }
|
|
1150
1146
|
if (filter.statuses?.length) { where.push(`status IN (${filter.statuses.map(() => '?').join(', ')})`); args.push(...filter.statuses); }
|
|
1151
1147
|
if (filter.kinds?.length) { where.push(`kind IN (${filter.kinds.map(() => '?').join(', ')})`); args.push(...filter.kinds); }
|
|
1152
|
-
const
|
|
1148
|
+
const dir = filter.newestFirst ? 'DESC' : 'ASC';
|
|
1149
|
+
const limit = Math.min(Math.max(Math.trunc(filter.limit ?? 5000), 1), 5000), offset = Math.max(Math.trunc(filter.offset ?? 0), 0);
|
|
1150
|
+
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
1151
|
return rows.map(rowToBooking);
|
|
1154
1152
|
}
|
|
1155
1153
|
async getBooking(id: string) {
|
|
@@ -1163,6 +1161,12 @@ export class SqlRepository implements ContentRepository {
|
|
|
1163
1161
|
const r = await this.first('SELECT * FROM bookings WHERE manage_token = ?', [token]);
|
|
1164
1162
|
return r ? rowToBooking(r) : null;
|
|
1165
1163
|
}
|
|
1164
|
+
async countBookingsByEmail(email: string, since: number) {
|
|
1165
|
+
await this.ready;
|
|
1166
|
+
// staff bookings made for someone don't use up that person's public allowance
|
|
1167
|
+
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]);
|
|
1168
|
+
return Number(r?.n ?? 0);
|
|
1169
|
+
}
|
|
1166
1170
|
// One INSERT … SELECT … WHERE NOT EXISTS: the overlap test and the write
|
|
1167
1171
|
// are a single statement, so two people racing for the same slot can't
|
|
1168
1172
|
// both win — the loser's insert simply affects no rows.
|
|
@@ -1445,6 +1449,30 @@ export class SqlRepository implements ContentRepository {
|
|
|
1445
1449
|
await this.ready;
|
|
1446
1450
|
await this.driver.run('DELETE FROM links WHERE id = ?', [id]);
|
|
1447
1451
|
}
|
|
1452
|
+
async linksRevision() {
|
|
1453
|
+
await this.ready;
|
|
1454
|
+
return Number((await this.first('SELECT links_revision FROM settings WHERE id = 1'))?.links_revision ?? 0);
|
|
1455
|
+
}
|
|
1456
|
+
async replaceLinks(expectedRevision: number, items: Parameters<ContentRepository['replaceLinks']>[1], profile: Parameters<ContentRepository['replaceLinks']>[2] = {}) {
|
|
1457
|
+
await this.ready;
|
|
1458
|
+
const keep = items.flatMap((i) => (i.keep ? [i.keep] : []));
|
|
1459
|
+
const settings = this.settingsStatement(profile);
|
|
1460
|
+
try {
|
|
1461
|
+
await this.driver.batch([
|
|
1462
|
+
// fails the CHECK (and so the whole batch) when someone saved in between
|
|
1463
|
+
{ sql: 'INSERT INTO revision_checks (expected, actual) SELECT ?, links_revision FROM settings WHERE id = 1', args: [expectedRevision] },
|
|
1464
|
+
{ sql: 'DELETE FROM revision_checks' },
|
|
1465
|
+
keep.length ? { sql: `DELETE FROM links WHERE id NOT IN (${keep.map(() => '?').join(', ')})`, args: keep } : { sql: 'DELETE FROM links' },
|
|
1466
|
+
...items.map((i, ord) => i.keep
|
|
1467
|
+
? { 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] }
|
|
1468
|
+
: { 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] }),
|
|
1469
|
+
...(settings ? [settings] : []),
|
|
1470
|
+
]);
|
|
1471
|
+
} catch (e) {
|
|
1472
|
+
if (/CHECK constraint/i.test(String(e))) throw new ApiError(409, 'The links page changed since you opened it. Reload and try again.');
|
|
1473
|
+
throw e;
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1448
1476
|
async trackClick(id: string) {
|
|
1449
1477
|
await this.ready;
|
|
1450
1478
|
const r = await this.first('SELECT url FROM links WHERE id = ?', [id]);
|
|
@@ -1523,6 +1551,23 @@ export class SqlRepository implements ContentRepository {
|
|
|
1523
1551
|
await this.driver.run('DELETE FROM snippets WHERE id = ?', [id]);
|
|
1524
1552
|
}
|
|
1525
1553
|
|
|
1554
|
+
private ownerGuard(id: string): SqlStatement[] {
|
|
1555
|
+
if (AUTH.provider !== 'clerk') return [];
|
|
1556
|
+
// Execute in the same write transaction as demotion/deletion: concurrent
|
|
1557
|
+
// owners cannot each remove the other and leave the site without one.
|
|
1558
|
+
return [{ sql: `INSERT INTO auth_owner_checks(allowed) SELECT 0 FROM users target
|
|
1559
|
+
WHERE target.id=? AND target.role='owner' AND EXISTS (
|
|
1560
|
+
SELECT 1 FROM auth_identities a WHERE a.user_id=target.id AND a.disabled=0 AND NOT EXISTS (
|
|
1561
|
+
SELECT 1 FROM users other JOIN auth_identities b ON b.user_id=other.id
|
|
1562
|
+
WHERE other.id<>target.id AND other.role='owner' AND b.disabled=0 AND b.issuer=a.issuer))`, args: [id] }];
|
|
1563
|
+
}
|
|
1564
|
+
private async guardedUserWrite(statements: SqlStatement[]) {
|
|
1565
|
+
try { await this.driver.batch(statements); }
|
|
1566
|
+
catch (error) {
|
|
1567
|
+
if (String(error).includes('keep_clerk_owner')) throw new Error('Keep at least one linked owner. Add another owner before removing or demoting this account.');
|
|
1568
|
+
throw error;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1526
1571
|
// ---- users ----
|
|
1527
1572
|
async listUsers() {
|
|
1528
1573
|
await this.ready;
|
|
@@ -1556,16 +1601,28 @@ export class SqlRepository implements ContentRepository {
|
|
|
1556
1601
|
if (data.slug !== undefined) { sets.push('slug = ?'); args.push(data.slug); }
|
|
1557
1602
|
if (!sets.length) return;
|
|
1558
1603
|
args.push(id);
|
|
1559
|
-
await this.
|
|
1604
|
+
await this.guardedUserWrite([
|
|
1605
|
+
...(data.role !== undefined && data.role !== 'owner' ? this.ownerGuard(id) : []),
|
|
1560
1606
|
{sql:`UPDATE users SET ${sets.join(', ')} WHERE id = ?`,args},
|
|
1561
1607
|
...(data.passHash === undefined ? [] : [{sql:`INSERT INTO email_jobs(id,dedupe,kind,recipient,payload,config_version,next_at,created_at,updated_at)
|
|
1562
1608
|
SELECT ?,?,'password-changed',email,'{}',COALESCE((SELECT MAX(version) FROM email_configs),0),?,?,? FROM users WHERE id=?`,
|
|
1563
1609
|
args:[crypto.randomUUID(),crypto.randomUUID(),Date.now(),Date.now(),Date.now(),id]}]),
|
|
1564
1610
|
]);
|
|
1565
1611
|
}
|
|
1612
|
+
async changeUserEmail(id: string, email: string, { signOut, notify }: { signOut: boolean; notify: boolean }) {
|
|
1613
|
+
await this.ready;
|
|
1614
|
+
const now = Date.now();
|
|
1615
|
+
// the notice is queued before the update so it reads the previous address
|
|
1616
|
+
await this.driver.batch([
|
|
1617
|
+
...(notify ? [{sql:`INSERT INTO email_jobs(id,dedupe,kind,recipient,payload,config_version,next_at,created_at,updated_at)
|
|
1618
|
+
SELECT ?,?,'email-changed',email,'{}',COALESCE((SELECT MAX(version) FROM email_configs),0),?,?,? FROM users WHERE id=? AND email<>?`,
|
|
1619
|
+
args:[crypto.randomUUID(),crypto.randomUUID(),now,now,now,id,email.toLowerCase()]}] : []),
|
|
1620
|
+
{sql:`UPDATE users SET email = ?${signOut ? ', session_version = session_version + 1' : ''} WHERE id = ?`,args:[email.toLowerCase(),id]},
|
|
1621
|
+
]);
|
|
1622
|
+
}
|
|
1566
1623
|
async deleteUser(id: string) {
|
|
1567
1624
|
await this.ready;
|
|
1568
|
-
await this.
|
|
1625
|
+
await this.guardedUserWrite([...this.ownerGuard(id), { sql: 'DELETE FROM users WHERE id = ?', args: [id] }]);
|
|
1569
1626
|
}
|
|
1570
1627
|
async getUserAuth(email: string) {
|
|
1571
1628
|
await this.ready;
|
|
@@ -1611,8 +1668,12 @@ export class SqlRepository implements ContentRepository {
|
|
|
1611
1668
|
}
|
|
1612
1669
|
|
|
1613
1670
|
// ---- search ----
|
|
1614
|
-
async searchPublished(query: string, opts?: { locale?: string }): Promise<SearchResults> {
|
|
1671
|
+
async searchPublished(query: string, opts?: { locale?: string; limit?: number; collections?: string[] }): Promise<SearchResults> {
|
|
1615
1672
|
await this.ready;
|
|
1673
|
+
// per-kind cap; the public API widens it for deeper result pages
|
|
1674
|
+
const limit = Math.min(Math.max(Math.trunc(opts?.limit ?? 50), 1), 200);
|
|
1675
|
+
const inCollections = opts?.collections ? ` AND p.collection IN (${opts.collections.map(() => '?').join(', ') || 'NULL'})` : '';
|
|
1676
|
+
const collectionArgs = opts?.collections ?? [];
|
|
1616
1677
|
const trimmed = query.trim();
|
|
1617
1678
|
if (!trimmed) return { posts: [], pages: [], events: [], tags: [], authors: [] };
|
|
1618
1679
|
const locale = opts?.locale ?? '';
|
|
@@ -1623,29 +1684,29 @@ export class SqlRepository implements ContentRepository {
|
|
|
1623
1684
|
[postRows, pageRows, eventRows] = await Promise.all([
|
|
1624
1685
|
this.driver.all(
|
|
1625
1686
|
`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)]
|
|
1687
|
+
WHERE search_index MATCH ? AND search_index.kind = 'post' AND p.status = 'published'${inCollections}
|
|
1688
|
+
ORDER BY ${RANK} LIMIT ${limit}`,
|
|
1689
|
+
[ftsQuery(trimmed), ...collectionArgs]
|
|
1629
1690
|
),
|
|
1630
1691
|
// locale is a boost, never a filter: the visitor's language sorts
|
|
1631
1692
|
// first, every locale stays visible (en/es cover different topics)
|
|
1632
1693
|
this.driver.all(
|
|
1633
1694
|
`SELECT g.*, ${SNIPPET} AS snip FROM search_index JOIN pages g ON g.id = search_index.ref_id
|
|
1634
1695
|
WHERE search_index MATCH ? AND search_index.kind = 'page' AND g.status = 'published'
|
|
1635
|
-
ORDER BY (search_index.locale = ?) DESC, ${RANK} LIMIT
|
|
1696
|
+
ORDER BY (search_index.locale = ?) DESC, ${RANK} LIMIT ${limit}`,
|
|
1636
1697
|
[ftsQuery(trimmed), locale]
|
|
1637
1698
|
),
|
|
1638
1699
|
this.driver.all(
|
|
1639
1700
|
`SELECT e.*, ${SNIPPET} AS snip FROM search_index JOIN events e ON e.id = search_index.ref_id
|
|
1640
1701
|
WHERE search_index MATCH ? AND search_index.kind = 'event' AND e.status = 'published'
|
|
1641
|
-
ORDER BY ${RANK} LIMIT
|
|
1702
|
+
ORDER BY ${RANK} LIMIT ${limit}`,
|
|
1642
1703
|
[ftsQuery(trimmed)]
|
|
1643
1704
|
),
|
|
1644
1705
|
]);
|
|
1645
1706
|
} catch {
|
|
1646
1707
|
// FTS5 unavailable or a query form it rejects — degrade to the old
|
|
1647
1708
|
// LIKE scan (recency-ordered, snippets fall back to excerpt/seo_desc)
|
|
1648
|
-
[postRows, pageRows, eventRows] = await this.likeSearch(trimmed, locale);
|
|
1709
|
+
[postRows, pageRows, eventRows] = await this.likeSearch(trimmed, locale, limit, opts?.collections);
|
|
1649
1710
|
}
|
|
1650
1711
|
const like = `%${trimmed.replace(/[%_]/g, ' ').trim()}%`;
|
|
1651
1712
|
const [authors, tagRows, userRows] = await Promise.all([
|
|
@@ -1675,32 +1736,33 @@ export class SqlRepository implements ContentRepository {
|
|
|
1675
1736
|
authors: userRows.map(rowToUser),
|
|
1676
1737
|
};
|
|
1677
1738
|
}
|
|
1678
|
-
private async likeSearch(query: string, locale: string): Promise<[Row[], Row[], Row[]]> {
|
|
1739
|
+
private async likeSearch(query: string, locale: string, limit: number, collections?: string[]): Promise<[Row[], Row[], Row[]]> {
|
|
1679
1740
|
const q = `%${query.replace(/[%_]/g, ' ').trim()}%`;
|
|
1680
1741
|
if (q === '%%') return [[], [], []];
|
|
1681
1742
|
return Promise.all([
|
|
1682
1743
|
this.driver.all(
|
|
1683
|
-
|
|
1684
|
-
[q, q, q, q]
|
|
1744
|
+
`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}`,
|
|
1745
|
+
[...(collections ?? []), q, q, q, q]
|
|
1685
1746
|
),
|
|
1686
1747
|
this.driver.all(
|
|
1687
|
-
|
|
1748
|
+
`SELECT * FROM pages WHERE status = 'published' AND (title LIKE ? OR body_html LIKE ?) ORDER BY (locale = ?) DESC, updated_at DESC LIMIT ${limit}`,
|
|
1688
1749
|
[q, q, locale]
|
|
1689
1750
|
),
|
|
1690
1751
|
this.driver.all(
|
|
1691
|
-
|
|
1752
|
+
`SELECT * FROM events WHERE status = 'published' AND (title LIKE ? OR excerpt LIKE ? OR body_html LIKE ?) ORDER BY start_at DESC LIMIT ${limit}`,
|
|
1692
1753
|
[q, q, q]
|
|
1693
1754
|
),
|
|
1694
1755
|
]);
|
|
1695
1756
|
}
|
|
1696
1757
|
|
|
1697
1758
|
// ---- restore ----
|
|
1698
|
-
async restoreAll(content: Parameters<ContentRepository['restoreAll']>[0]) {
|
|
1759
|
+
async restoreAll(content: Parameters<ContentRepository['restoreAll']>[0], authRecovery?: { issuer: string; subject: string; userId: string }) {
|
|
1699
1760
|
await this.ready;
|
|
1700
1761
|
const stmts: SqlStatement[] = [
|
|
1701
1762
|
// Suppression and re-enabling share the restore transaction. A failed
|
|
1702
1763
|
// restore cannot leave notifications disabled or emit phantom deletes.
|
|
1703
1764
|
{ sql: 'UPDATE webhook_control SET suppressed=1 WHERE id=1' },
|
|
1765
|
+
{ sql: "UPDATE auth_invitations SET state='revoked' WHERE state<>'accepted'" },
|
|
1704
1766
|
...['email_jobs', 'email_attempts', 'email_delivery_events', 'email_tokens', 'posts', 'pages', 'media', 'links', 'tags', 'nav_items', 'users', 'post_authors', 'redirects', 'calendars', 'events', 'resources', 'booking_links', 'bookings', 'mobile_booking_requests', 'import_runs', 'import_records', 'places', 'forms', 'form_submissions'].map((t) => ({
|
|
1705
1767
|
sql: `DELETE FROM ${t}`,
|
|
1706
1768
|
})),
|
|
@@ -1776,6 +1838,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
1776
1838
|
args: [r.id, r.fromPath, r.toPath, r.code, r.createdAt],
|
|
1777
1839
|
})),
|
|
1778
1840
|
];
|
|
1841
|
+
if (authRecovery) stmts.push({ sql: `UPDATE auth_identities SET disabled=0 WHERE issuer=? AND subject=? AND user_id=? AND EXISTS(SELECT 1 FROM users WHERE id=? AND role='owner')`, args: [authRecovery.issuer, authRecovery.subject, authRecovery.userId, authRecovery.userId] });
|
|
1779
1842
|
const settingsWrite = this.settingsStatement({ ...content.settings, live: { ...LiveConfig.parse(content.settings.live ?? {}), mode: 'off', manual: { watchUrl: '', title: 'We’re live', until: 0 } } });
|
|
1780
1843
|
stmts.push({ sql: "UPDATE live_connections SET enabled=0,tested_at=0,revision=revision+1,lease='',lease_until=0,snapshot=NULL,upcoming=NULL,error='Restored content. Test this connection before enabling automatic checks.'" }, { sql: 'DELETE FROM live_oauth_states' });
|
|
1781
1844
|
if (settingsWrite) stmts.push(settingsWrite);
|
|
@@ -1938,6 +2001,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
1938
2001
|
live: JSON.parse(String(r?.live ?? '{}')),
|
|
1939
2002
|
push: JSON.parse(String(r?.push ?? '{}')),
|
|
1940
2003
|
calendar: JSON.parse(String(r?.calendar ?? '{}')),
|
|
2004
|
+
mobileAccess: JSON.parse(String(r?.mobile_access ?? '{}')),
|
|
2005
|
+
monitoring: JSON.parse(String(r?.monitoring ?? '{}')),
|
|
1941
2006
|
agent: JSON.parse(String(r?.agent ?? '{}')),
|
|
1942
2007
|
draftsApiKey: r?.drafts_api_key ?? '',
|
|
1943
2008
|
draftsAllowedOrigins: JSON.parse(String(r?.drafts_allowed_origins ?? '[]')),
|
|
@@ -1949,14 +2014,27 @@ export class SqlRepository implements ContentRepository {
|
|
|
1949
2014
|
const statement = this.settingsStatement(data);
|
|
1950
2015
|
if (statement) await this.driver.run(statement.sql, statement.args);
|
|
1951
2016
|
}
|
|
2017
|
+
async patchMobileAccess(patch: Partial<Settings['mobileAccess']>) {
|
|
2018
|
+
await this.ready;
|
|
2019
|
+
await this.driver.run('UPDATE settings SET mobile_access = json_patch(mobile_access, ?) WHERE id = 1', [JSON.stringify(patch)]);
|
|
2020
|
+
}
|
|
2021
|
+
async getCacheState() {
|
|
2022
|
+
await this.ready;
|
|
2023
|
+
const r = await this.first('SELECT content_version, cache_purged_version FROM settings WHERE id = 1');
|
|
2024
|
+
return { version: Number(r?.content_version ?? 0), purged: Number(r?.cache_purged_version ?? 0) };
|
|
2025
|
+
}
|
|
2026
|
+
async markCachePurged(version: number) {
|
|
2027
|
+
await this.ready;
|
|
2028
|
+
await this.driver.run('UPDATE settings SET cache_purged_version = MAX(cache_purged_version, ?) WHERE id = 1', [version]);
|
|
2029
|
+
}
|
|
1952
2030
|
private settingsStatement(data: Partial<Settings>): SqlStatement | undefined {
|
|
1953
2031
|
const columns: Record<string, string> = {
|
|
1954
2032
|
siteTitle:'site_title', tagline:'tagline', footerText:'footer_text', linksName:'links_name', linksBio:'links_bio', linksAvatar:'links_avatar',
|
|
1955
2033
|
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
2034
|
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',
|
|
2035
|
+
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
2036
|
};
|
|
1959
|
-
const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live','push','calendar']);
|
|
2037
|
+
const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live','push','calendar','mobileAccess','monitoring']);
|
|
1960
2038
|
const entries = Object.entries(data).filter(([k,v]) => columns[k] && v !== undefined);
|
|
1961
2039
|
if (!entries.length) return;
|
|
1962
2040
|
const design = entries.some(([k]) => ['activeTheme','designOverrides','sectionPages','siteLogo','siteLogoDark','siteFavicon','siteCover','draftDesign'].includes(k));
|
package/src/lib/repo/sqlite.ts
CHANGED
|
@@ -39,7 +39,20 @@ export class SqliteRepository extends SqlRepository {
|
|
|
39
39
|
}
|
|
40
40
|
// remote libSQL (Turso, sqld) needs a token; local files don't
|
|
41
41
|
const authToken = env('SQLITE_AUTH_TOKEN', '');
|
|
42
|
-
|
|
42
|
+
const client = createClient(authToken ? { url, authToken } : { url });
|
|
43
|
+
if (url.startsWith('file:')) await tuneLocalFile(client);
|
|
44
|
+
return new LibsqlDriver(client);
|
|
43
45
|
});
|
|
44
46
|
}
|
|
45
47
|
}
|
|
48
|
+
|
|
49
|
+
// Local files only (remote libSQL/Turso manage their own journal): WAL lets
|
|
50
|
+
// page reads continue while a write commits, busy_timeout waits out a
|
|
51
|
+
// concurrent writer (the scheduler, a second process) instead of failing
|
|
52
|
+
// with SQLITE_BUSY, and synchronous=NORMAL is the safe pairing for WAL.
|
|
53
|
+
// File-copy backups must take acms.db, acms.db-wal and acms.db-shm together.
|
|
54
|
+
export async function tuneLocalFile(client: Pick<Client, 'execute'>) {
|
|
55
|
+
await client.execute('PRAGMA journal_mode=WAL');
|
|
56
|
+
await client.execute('PRAGMA busy_timeout=5000');
|
|
57
|
+
await client.execute('PRAGMA synchronous=NORMAL');
|
|
58
|
+
}
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { AuthStore } from '../auth/store';
|
|
1
2
|
import type { FormStore } from '../forms/store';
|
|
3
|
+
import type { BackupStore } from '../backup/store';
|
|
4
|
+
import type { GuestStore } from '../mobile/guest-store';
|
|
2
5
|
import type { SavedForm, FormSubmission } from '../forms/schema';
|
|
3
6
|
import type { LiveStore } from '../live/store';
|
|
4
7
|
import type { BookingRequest } from '../backup/content';
|
|
@@ -49,8 +52,11 @@ export type EventUpdate = Partial<
|
|
|
49
52
|
// The single contract both backends implement. The app only ever imports
|
|
50
53
|
// this — never a concrete adapter.
|
|
51
54
|
export interface ContentRepository {
|
|
55
|
+
readonly auth: AuthStore;
|
|
52
56
|
readonly email: EmailStore;
|
|
53
57
|
readonly forms: FormStore;
|
|
58
|
+
readonly backups: BackupStore;
|
|
59
|
+
readonly guests: GuestStore;
|
|
54
60
|
readonly live: LiveStore;
|
|
55
61
|
readonly push: PushStore;
|
|
56
62
|
readonly editor: EditorStore;
|
|
@@ -168,9 +174,12 @@ export interface ContentRepository {
|
|
|
168
174
|
// write that can fail on a race: it inserts atomically only if the held
|
|
169
175
|
// range is still free (resource bookings) or seats remain (RSVPs), and
|
|
170
176
|
// returns null when it lost.
|
|
171
|
-
|
|
177
|
+
/** from/to: overlaps the window; endedBy: ended at or before; default order start ascending, at most 5000 rows */
|
|
178
|
+
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
179
|
getBooking(id: string): Promise<Booking | null>;
|
|
173
180
|
getBookingByToken(token: string): Promise<Booking | null>;
|
|
181
|
+
// bookings + RSVPs (never staff blocks) this address made since `since`
|
|
182
|
+
countBookingsByEmail(email: string, since: number): Promise<number>;
|
|
174
183
|
createBooking(
|
|
175
184
|
data: Omit<Booking, 'id' | 'createdAt' | 'updatedAt' | 'reminderSentAt' | 'notificationVersion'>,
|
|
176
185
|
guard?: { capacity?: number; idempotency?: { keyHash: string; requestHash: string } }
|
|
@@ -222,6 +231,15 @@ export interface ContentRepository {
|
|
|
222
231
|
// full ordering in one call (drag-and-drop); ids in desired display order
|
|
223
232
|
reorderLinks(ids: string[]): Promise<void>;
|
|
224
233
|
deleteLink(id: string): Promise<void>;
|
|
234
|
+
/** changes whenever the links page's blocks or profile change, by any path (not on clicks) */
|
|
235
|
+
linksRevision(): Promise<number>;
|
|
236
|
+
/**
|
|
237
|
+
* the whole links page in one atomic write, only if it is still at
|
|
238
|
+
* `expectedRevision` (409 otherwise, nothing saved): `keep` ids are updated
|
|
239
|
+
* in place, other items are added, everything else is removed; `profile`
|
|
240
|
+
* (links name/bio/avatar) saves in the same batch.
|
|
241
|
+
*/
|
|
242
|
+
replaceLinks(expectedRevision: number, items: (Pick<Link, 'kind' | 'label' | 'url' | 'icon' | 'html' | 'meta'> & { keep?: string })[], profile?: Partial<Pick<Settings, 'linksName' | 'linksBio' | 'linksAvatar'>>): Promise<void>;
|
|
225
243
|
trackClick(id: string): Promise<string | null>; // returns url to redirect to
|
|
226
244
|
|
|
227
245
|
// tags — registry of tag entities; posts store tag names, this table adds
|
|
@@ -244,6 +262,9 @@ export interface ContentRepository {
|
|
|
244
262
|
getUser(id: string): Promise<User | null>;
|
|
245
263
|
createUser(data: { name: string; email: string; role: Role; passHash: string }): Promise<User>;
|
|
246
264
|
updateUser(id: string, data: { name?: string; email?: string; role?: Role; passHash?: string; bio?: string; avatar?: string; slug?: string }): Promise<void>;
|
|
265
|
+
/** Replace a user's email. `signOut` ends their sessions (the email is their
|
|
266
|
+
* login); `notify` queues an `email-changed` notice to the previous address. */
|
|
267
|
+
changeUserEmail(id: string, email: string, opts: { signOut: boolean; notify: boolean }): Promise<void>;
|
|
247
268
|
deleteUser(id: string): Promise<void>;
|
|
248
269
|
getUserAuth(email: string): Promise<{ user: User; passHash: string; sessionVersion: number } | null>;
|
|
249
270
|
|
|
@@ -260,7 +281,8 @@ export interface ContentRepository {
|
|
|
260
281
|
// #tags excluded) and author profiles. opts.locale is a ranking boost,
|
|
261
282
|
// never a filter: pages in the visitor's locale sort first, every locale
|
|
262
283
|
// stays visible (en/es cover different topics, not translations).
|
|
263
|
-
|
|
284
|
+
// collections: only posts in these (configured) collections
|
|
285
|
+
searchPublished(query: string, opts?: { locale?: string; limit?: number; collections?: string[] }): Promise<SearchResults>;
|
|
264
286
|
|
|
265
287
|
// backup restore — id-preserving replace-all from a backup's content.json.
|
|
266
288
|
// Users restore WITHOUT password hashes (backups never contain them).
|
|
@@ -285,7 +307,7 @@ export interface ContentRepository {
|
|
|
285
307
|
places?: Place[];
|
|
286
308
|
forms?: SavedForm[];
|
|
287
309
|
formSubmissions?: FormSubmission[];
|
|
288
|
-
}): Promise<void>;
|
|
310
|
+
}, authRecovery?: { issuer: string; subject: string; userId: string }): Promise<void>;
|
|
289
311
|
|
|
290
312
|
// imports (lib/import/apply.ts drives these). writeImported is the
|
|
291
313
|
// id-preserving bulk write for imported posts/pages: original timestamps
|
|
@@ -310,6 +332,12 @@ export interface ContentRepository {
|
|
|
310
332
|
// settings & dashboard
|
|
311
333
|
getSettings(): Promise<Settings>;
|
|
312
334
|
updateSettings(data: Partial<Settings>): Promise<void>;
|
|
335
|
+
/** merges just these App access fields in one statement, so concurrent saves don't undo each other */
|
|
336
|
+
patchMobileAccess(patch: Partial<Settings['mobileAccess']>): Promise<void>;
|
|
337
|
+
/** public cache: version = bumped by triggers on every visible change; purged = last version cleared from caches */
|
|
338
|
+
getCacheState(): Promise<{ version: number; purged: number }>;
|
|
339
|
+
/** record that caches were cleared up to `version` (never moves backwards) */
|
|
340
|
+
markCachePurged(version: number): Promise<void>;
|
|
313
341
|
// Design-screen staging: save stages into settings.draftDesign, apply
|
|
314
342
|
// copies staged → live design fields and clears, discard just clears
|
|
315
343
|
saveDesignDraft(data: Partial<Pick<Settings, 'activeTheme' | 'designOverrides' | 'siteLogo' | 'siteLogoDark' | 'siteFavicon' | 'siteCover' | 'sectionPages'>> & {
|