@acms-subir/core 0.3.3-next.20260929113209 → 0.3.3
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 +69 -0
- package/cli/common.mjs +2 -2
- package/cli/legacy.mjs +1 -1
- package/cli/main.mjs +82 -8
- package/cli/mobile-doctor.mjs +430 -0
- package/cli/mobile.mjs +315 -0
- package/cli/native-sections.mjs +168 -0
- package/cli/sections.mjs +186 -0
- package/cli/themes.mjs +52 -8
- package/docs/mobile.md +527 -0
- package/docs/sections.md +203 -0
- package/docs/theme-authoring.md +1 -1
- package/integration.mjs +55 -1
- package/mobile-template/client/AGENTS.md +97 -0
- package/mobile-template/client/README.md +65 -0
- package/mobile-template/client/app/_layout.tsx +56 -0
- package/mobile-template/client/app/index.tsx +21 -0
- package/mobile-template/client/app.json +21 -0
- package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
- package/mobile-template/client/eas.json +21 -0
- package/mobile-template/client/env.example +3 -0
- package/mobile-template/client/gitignore +42 -0
- package/mobile-template/client/package.json +38 -0
- package/mobile-template/client/sections/index.ts +11 -0
- package/mobile-template/client/tsconfig.json +7 -0
- package/mobile-template/editor/AGENTS.md +66 -0
- package/mobile-template/editor/README.md +73 -0
- package/mobile-template/editor/app/_layout.tsx +76 -0
- package/mobile-template/editor/app/auth.tsx +13 -0
- package/mobile-template/editor/app/index.tsx +55 -0
- package/mobile-template/editor/app.json +21 -0
- package/mobile-template/editor/components/Screen.tsx +51 -0
- package/mobile-template/editor/components/SignIn.tsx +36 -0
- package/mobile-template/editor/components/TextTabs.tsx +27 -0
- package/mobile-template/editor/eas.json +21 -0
- package/mobile-template/editor/env.example +5 -0
- package/mobile-template/editor/gitignore +42 -0
- package/mobile-template/editor/lib/appearance.tsx +12 -0
- package/mobile-template/editor/lib/config.ts +17 -0
- package/mobile-template/editor/lib/format.ts +24 -0
- package/mobile-template/editor/lib/tools.ts +16 -0
- package/mobile-template/editor/package.json +40 -0
- package/mobile-template/editor/tools/README.md +2 -0
- package/mobile-template/editor/tsconfig.json +7 -0
- package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
- package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
- package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
- package/mobile-template/recipes/booking/recipe.json +17 -0
- package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
- package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
- package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
- package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
- package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
- package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
- package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
- package/mobile-template/recipes/editor-content/recipe.json +8 -0
- package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
- package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
- package/mobile-template/recipes/editor-media/recipe.json +8 -0
- package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
- package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
- package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
- package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
- package/mobile-template/recipes/editor-sections/recipe.json +8 -0
- package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
- package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
- package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
- package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
- package/mobile-template/recipes/editor-translations/recipe.json +8 -0
- package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
- package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
- package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
- package/mobile-template/recipes/editor-writer/recipe.json +8 -0
- package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
- package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
- package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
- package/mobile-template/recipes/events/recipe.json +15 -0
- package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
- package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
- package/mobile-template/recipes/news/recipe.json +13 -0
- package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
- package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
- package/mobile-template/recipes/push/files/lib/push.ts +15 -0
- package/mobile-template/recipes/push/recipe.json +20 -0
- package/package.json +4 -2
- package/section-library/README.md +16 -0
- package/section-library/contact/Contact.astro +49 -0
- package/section-library/contact/section.json +106 -0
- package/section-library/cta-band/CtaBand.astro +28 -0
- package/section-library/cta-band/section.json +51 -0
- package/section-library/feature-grid/FeatureGrid.astro +54 -0
- package/section-library/feature-grid/section.json +107 -0
- package/section-library/gallery/Gallery.astro +29 -0
- package/section-library/gallery/section.json +51 -0
- package/section-library/hero-banner/HeroBanner.astro +42 -0
- package/section-library/hero-banner/section.json +81 -0
- package/section-library/latest-posts/LatestPosts.astro +22 -0
- package/section-library/latest-posts/section.json +37 -0
- package/section-library/pricing/Pricing.astro +40 -0
- package/section-library/pricing/section.json +117 -0
- package/section-library/questions/Questions.astro +38 -0
- package/section-library/questions/section.json +69 -0
- package/section-library/service-times/ServiceTimes.astro +42 -0
- package/section-library/service-times/section.json +127 -0
- package/section-library/team/Team.astro +45 -0
- package/section-library/team/section.json +88 -0
- package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
- package/section-library/upcoming-events/section.json +51 -0
- package/src/actions/index.ts +19 -171
- package/src/components/AiSettings.tsx +45 -5
- package/src/components/DesignManager.tsx +112 -16
- package/src/components/MobileApp.tsx +392 -0
- package/src/components/ThemeSections.astro +40 -21
- package/src/components/ThemeSectionsEditor.tsx +757 -229
- package/src/components/editor/PostEditor.tsx +174 -10
- package/src/components/import/ImportExport.tsx +4 -1
- package/src/components/import/runner.ts +1 -0
- package/src/components/sections/SectionFields.tsx +560 -0
- package/src/components/sections/SectionPicker.tsx +120 -0
- package/src/components/sections/TranslationPanel.tsx +143 -0
- package/src/components/sections/checks.ts +64 -0
- package/src/layouts/Admin.astro +6 -1
- package/src/layouts/Site.astro +107 -19
- package/src/lib/agent-discovery.ts +11 -7
- package/src/lib/agent.ts +35 -2
- package/src/lib/ai-schema.ts +5 -0
- package/src/lib/ai.ts +5 -0
- package/src/lib/api.ts +12 -0
- package/src/lib/content-service.ts +198 -0
- package/src/lib/design-service.ts +153 -5
- package/src/lib/drafts.ts +3 -3
- package/src/lib/editor/auth.ts +264 -0
- package/src/lib/editor/bookings.ts +60 -0
- package/src/lib/editor/config.ts +25 -0
- package/src/lib/editor/documents.ts +227 -0
- package/src/lib/editor/http.ts +48 -0
- package/src/lib/editor/me.ts +30 -0
- package/src/lib/editor/migration.ts +57 -0
- package/src/lib/editor/next.ts +11 -0
- package/src/lib/editor/openapi.ts +124 -0
- package/src/lib/editor/schemas.ts +91 -0
- package/src/lib/editor/sections.ts +96 -0
- package/src/lib/editor/store.ts +184 -0
- package/src/lib/editor/token-route.ts +74 -0
- package/src/lib/editor/translations.ts +163 -0
- package/src/lib/email/accounts.ts +2 -1
- package/src/lib/email/types.ts +2 -1
- package/src/lib/home-source.ts +34 -0
- package/src/lib/i18n.ts +34 -0
- package/src/lib/import/apply.ts +31 -2
- package/src/lib/import/bundle.ts +4 -0
- package/src/lib/import/export.ts +1 -0
- package/src/lib/import/ghost.ts +1 -0
- package/src/lib/media/upload.ts +83 -0
- package/src/lib/mobile/associations.ts +25 -0
- package/src/lib/mobile/calendar.ts +19 -11
- package/src/lib/mobile/config.mjs +59 -0
- package/src/lib/mobile/content.ts +18 -13
- package/src/lib/mobile/embed.ts +83 -0
- package/src/lib/mobile/http.ts +8 -6
- package/src/lib/mobile/locale.ts +18 -0
- package/src/lib/mobile/openapi.ts +111 -0
- package/src/lib/mobile/pages.ts +71 -0
- package/src/lib/mobile/route.ts +3 -3
- package/src/lib/mobile/schemas.ts +60 -0
- package/src/lib/mobile/site.ts +41 -0
- package/src/lib/push/api.ts +158 -0
- package/src/lib/push/config.ts +20 -0
- package/src/lib/push/expo.ts +76 -0
- package/src/lib/push/migration.ts +60 -0
- package/src/lib/push/service.ts +302 -0
- package/src/lib/push/store.ts +144 -0
- package/src/lib/push/types.ts +43 -0
- package/src/lib/repo/migrations.ts +11 -0
- package/src/lib/repo/sql.ts +40 -7
- package/src/lib/repo/types.ts +9 -3
- package/src/lib/roles.ts +5 -0
- package/src/lib/scheduler.ts +3 -0
- package/src/lib/schema.ts +27 -1
- package/src/lib/section-ai.ts +55 -0
- package/src/lib/section-targets.ts +62 -0
- package/src/lib/sections-agent.ts +146 -0
- package/src/lib/sections.ts +217 -0
- package/src/lib/theme.ts +16 -1
- package/src/lib/translate.ts +97 -0
- package/src/lib/translations.ts +39 -0
- package/src/middleware.ts +32 -0
- package/src/pages/.well-known/apple-app-site-association.ts +6 -0
- package/src/pages/.well-known/assetlinks.json.ts +6 -0
- package/src/pages/[...slug].astro +8 -3
- package/src/pages/admin/app-auth.astro +159 -0
- package/src/pages/admin/c/[collection]/[id].astro +9 -0
- package/src/pages/admin/calendar/events/[id].astro +9 -0
- package/src/pages/admin/login.astro +6 -2
- package/src/pages/admin/logout.ts +5 -2
- package/src/pages/admin/mobile.astro +18 -0
- package/src/pages/admin/pages/[id].astro +62 -0
- package/src/pages/admin/section-preview.astro +61 -0
- package/src/pages/admin/users.astro +107 -11
- package/src/pages/api/agent/docs.ts +6 -2
- package/src/pages/api/agent/index.ts +1 -1
- package/src/pages/api/ai/settings.ts +7 -4
- package/src/pages/api/design/ai.ts +15 -0
- package/src/pages/api/design/index.ts +12 -42
- package/src/pages/api/design/translate.ts +15 -0
- package/src/pages/api/live/thumb.ts +1 -1
- package/src/pages/api/mcp.ts +2 -2
- package/src/pages/api/media/upload.ts +10 -78
- package/src/pages/api/push/admin.ts +27 -0
- package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
- package/src/pages/api/v1/admin/bookings/index.ts +6 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
- package/src/pages/api/v1/admin/documents/index.ts +13 -0
- package/src/pages/api/v1/admin/me.ts +6 -0
- package/src/pages/api/v1/admin/media/index.ts +32 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
- package/src/pages/api/v1/admin/sections/index.ts +6 -0
- package/src/pages/api/v1/auth/revoke.ts +5 -0
- package/src/pages/api/v1/auth/token.ts +5 -0
- package/src/pages/api/v1/devices/[token].ts +20 -0
- package/src/pages/api/v1/devices/index.ts +17 -0
- package/src/pages/api/v1/openapi.json.ts +7 -0
- package/src/pages/api/v1/pages/[...slug].ts +7 -0
- package/src/pages/api/v1/pages/home.ts +7 -0
- package/src/pages/api/v1/push/topics.ts +7 -0
- package/src/pages/api/v1/site.ts +7 -0
- package/src/pages/api/v1/theme.ts +7 -0
- package/src/pages/blog/[slug].astro +8 -2
- package/src/pages/blog/index.astro +11 -4
- package/src/pages/embed/section/[...path].astro +42 -0
- package/src/pages/events/[slug].astro +7 -2
- package/src/pages/index.astro +11 -5
- package/src/pages/sitemap.xml.ts +8 -0
- package/src/styles/admin.css +69 -0
- package/src/styles/editor.css +21 -0
- package/src/styles/mobile-app.css +151 -0
- package/src/styles/sections.css +7 -1
- package/src/styles/theme-editor.css +255 -0
- package/src/theme-sdk/contract.d.mts +2193 -89
- package/src/theme-sdk/contract.mjs +77 -20
- package/src/theme-sdk/fields.d.mts +88 -0
- package/src/theme-sdk/fields.mjs +326 -0
- package/src/theme-sdk/index.ts +10 -2
- package/src/theme-sdk/tokens.d.mts +11 -0
- package/src/virtual.d.ts +1 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { SqlDriver } from '../repo/sql';
|
|
2
|
+
import type { PushDevice, PushNotification, PushQueueRow, PushStats, PushPlatform } from './types';
|
|
3
|
+
|
|
4
|
+
type Row = Record<string, unknown>;
|
|
5
|
+
const list = (raw: unknown): string[] => { try { const v = JSON.parse(String(raw ?? '[]')); return Array.isArray(v) ? v.map(String) : []; } catch { return []; } };
|
|
6
|
+
function device(r: Row): PushDevice {
|
|
7
|
+
return { id: String(r.id), token: String(r.token), platform: r.platform as PushPlatform, locale: String(r.locale ?? ''), topics: list(r.topics),
|
|
8
|
+
createdAt: Number(r.created_at), updatedAt: Number(r.updated_at), lastSeenAt: Number(r.last_seen_at) };
|
|
9
|
+
}
|
|
10
|
+
function notification(r: Row): PushNotification {
|
|
11
|
+
return { id: String(r.id), title: String(r.title), body: String(r.body ?? ''), url: String(r.url ?? ''), topics: list(r.topics),
|
|
12
|
+
source: r.source as PushNotification['source'], sourceKey: String(r.source_key ?? ''), status: r.status as PushNotification['status'],
|
|
13
|
+
recipients: Number(r.recipients), sent: Number(r.sent), ok: Number(r.ok), failed: Number(r.failed), removed: Number(r.removed ?? 0),
|
|
14
|
+
error: String(r.error ?? ''), author: String(r.author ?? ''), createdAt: Number(r.created_at), finishedAt: r.finished_at == null ? null : Number(r.finished_at) };
|
|
15
|
+
}
|
|
16
|
+
// audience filter: ['*'] = every device, otherwise any shared topic
|
|
17
|
+
function audienceWhere(topics: string[]): { sql: string; args: unknown[] } {
|
|
18
|
+
if (topics.includes('*')) return { sql: '1', args: [] };
|
|
19
|
+
if (!topics.length) return { sql: '0', args: [] };
|
|
20
|
+
return { sql: `EXISTS (SELECT 1 FROM json_each(push_devices.topics) t WHERE t.value IN (${topics.map(() => '?').join(',')}))`, args: topics };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Storage for devices, the delivery log, Expo tickets and the automatic
|
|
24
|
+
// notification queue. Shared by the D1 and libSQL adapters (plain SQLite).
|
|
25
|
+
export class PushStore {
|
|
26
|
+
constructor(private driver: () => Promise<SqlDriver>) {}
|
|
27
|
+
private async all(sql: string, args: unknown[] = []) { return (await this.driver()).all(sql, args); }
|
|
28
|
+
private async run(sql: string, args: unknown[] = []) { return (await this.driver()).run(sql, args); }
|
|
29
|
+
|
|
30
|
+
// ---- devices ----
|
|
31
|
+
/** Register or refresh. `topics` undefined keeps a known device's topics (new devices start with none). */
|
|
32
|
+
async upsertDevice(d: { id: string; token: string; platform: PushPlatform; locale: string; topics?: string[] }, now: number): Promise<{ device: PushDevice; created: boolean }> {
|
|
33
|
+
const topics = d.topics === undefined ? null : JSON.stringify(d.topics);
|
|
34
|
+
// RETURNING also yields the previous row's created_at on the update branch,
|
|
35
|
+
// so "created" is exact: only the insert branch writes created_at = now.
|
|
36
|
+
const rows = await this.all(`INSERT INTO push_devices (id,token,platform,locale,topics,created_at,updated_at,last_seen_at) VALUES (?,?,?,?,COALESCE(?,'[]'),?,?,?)
|
|
37
|
+
ON CONFLICT(id) DO UPDATE SET platform=excluded.platform, locale=excluded.locale, topics=COALESCE(?,push_devices.topics),
|
|
38
|
+
updated_at=excluded.updated_at, last_seen_at=excluded.last_seen_at RETURNING *`, [d.id, d.token, d.platform, d.locale, topics, now, now, now, topics]);
|
|
39
|
+
const row = device(rows[0]);
|
|
40
|
+
return { device: row, created: row.createdAt === now };
|
|
41
|
+
}
|
|
42
|
+
async updateDevice(id: string, data: { locale?: string; topics?: string[] }, now: number): Promise<PushDevice | null> {
|
|
43
|
+
const rows = await this.all(`UPDATE push_devices SET locale=COALESCE(?,locale), topics=COALESCE(?,topics), updated_at=?, last_seen_at=? WHERE id=? RETURNING *`,
|
|
44
|
+
[data.locale ?? null, data.topics ? JSON.stringify(data.topics) : null, now, now, id]);
|
|
45
|
+
return rows[0] ? device(rows[0]) : null;
|
|
46
|
+
}
|
|
47
|
+
async getDevice(id: string) { const r = await this.all('SELECT * FROM push_devices WHERE id=?', [id]); return r[0] ? device(r[0]) : null; }
|
|
48
|
+
async deleteDevices(ids: string[]) {
|
|
49
|
+
for (let i = 0; i < ids.length; i += 90) {
|
|
50
|
+
const chunk = ids.slice(i, i + 90);
|
|
51
|
+
await this.run(`DELETE FROM push_devices WHERE id IN (${chunk.map(() => '?').join(',')})`, chunk);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async audience(topics: string[]): Promise<PushDevice[]> {
|
|
55
|
+
const w = audienceWhere(topics);
|
|
56
|
+
return (await this.all(`SELECT * FROM push_devices WHERE ${w.sql} ORDER BY created_at`, w.args)).map(device);
|
|
57
|
+
}
|
|
58
|
+
async countAudience(topics: string[]): Promise<number> {
|
|
59
|
+
const w = audienceWhere(topics);
|
|
60
|
+
return Number((await this.all(`SELECT COUNT(*) AS n FROM push_devices WHERE ${w.sql}`, w.args))[0]?.n ?? 0);
|
|
61
|
+
}
|
|
62
|
+
async stats(now: number): Promise<PushStats> {
|
|
63
|
+
const [totals, topics] = await Promise.all([
|
|
64
|
+
this.all(`SELECT COUNT(*) AS n, SUM(platform='ios') AS ios, SUM(platform='android') AS android, SUM(last_seen_at>=?) AS active FROM push_devices`, [now - 30 * 86400000]),
|
|
65
|
+
this.all(`SELECT t.value AS topic, COUNT(*) AS n FROM push_devices, json_each(push_devices.topics) t GROUP BY t.value`),
|
|
66
|
+
]);
|
|
67
|
+
const t = totals[0] ?? {};
|
|
68
|
+
return { devices: Number(t.n ?? 0), platforms: { ios: Number(t.ios ?? 0), android: Number(t.android ?? 0) }, active30: Number(t.active ?? 0),
|
|
69
|
+
topics: Object.fromEntries(topics.map((r) => [String(r.topic), Number(r.n)])) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---- delivery log ----
|
|
73
|
+
async createNotification(n: PushNotification) {
|
|
74
|
+
await this.run(`INSERT INTO push_notifications (id,title,body,url,topics,source,source_key,status,recipients,sent,ok,failed,removed,error,author,created_at,finished_at)
|
|
75
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, [n.id, n.title, n.body, n.url, JSON.stringify(n.topics), n.source, n.sourceKey, n.status,
|
|
76
|
+
n.recipients, n.sent, n.ok, n.failed, n.removed, n.error, n.author, n.createdAt, n.finishedAt]);
|
|
77
|
+
}
|
|
78
|
+
async finishNotification(id: string, d: Pick<PushNotification, 'status' | 'sent' | 'ok' | 'failed' | 'removed' | 'error' | 'finishedAt'>) {
|
|
79
|
+
await this.run('UPDATE push_notifications SET status=?,sent=?,ok=?,failed=?,removed=?,error=?,finished_at=? WHERE id=?',
|
|
80
|
+
[d.status, d.sent, d.ok, d.failed, d.removed, d.error, d.finishedAt, id]);
|
|
81
|
+
}
|
|
82
|
+
/** a later receipt turned an accepted ticket into a failure */
|
|
83
|
+
async receiptFailed(counts: Map<string, { failed: number; removed: number }>) {
|
|
84
|
+
for (const [id, c] of counts) await this.run('UPDATE push_notifications SET ok=MAX(ok-?,0), failed=failed+?, removed=removed+? WHERE id=?', [c.failed, c.failed, c.removed, id]);
|
|
85
|
+
}
|
|
86
|
+
async listNotifications(limit = 50) { return (await this.all('SELECT * FROM push_notifications ORDER BY created_at DESC LIMIT ?', [limit])).map(notification); }
|
|
87
|
+
async getNotification(id: string) { const r = await this.all('SELECT * FROM push_notifications WHERE id=?', [id]); return r[0] ? notification(r[0]) : null; }
|
|
88
|
+
|
|
89
|
+
// ---- Expo tickets awaiting receipts ----
|
|
90
|
+
async addTickets(rows: { id: string; notificationId: string; deviceId: string }[], now: number) {
|
|
91
|
+
// D1 allows 100 bound parameters per statement: 4 per row
|
|
92
|
+
for (let i = 0; i < rows.length; i += 25) {
|
|
93
|
+
const chunk = rows.slice(i, i + 25);
|
|
94
|
+
await this.run(`INSERT OR IGNORE INTO push_tickets (id,notification_id,device_id,created_at) VALUES ${chunk.map(() => '(?,?,?,?)').join(',')}`,
|
|
95
|
+
chunk.flatMap((t) => [t.id, t.notificationId, t.deviceId, now]));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async dueTickets(before: number, limit: number) {
|
|
99
|
+
return (await this.all('SELECT * FROM push_tickets WHERE created_at<=? ORDER BY created_at LIMIT ?', [before, limit]))
|
|
100
|
+
.map((r) => ({ id: String(r.id), notificationId: String(r.notification_id), deviceId: String(r.device_id), createdAt: Number(r.created_at) }));
|
|
101
|
+
}
|
|
102
|
+
async deleteTickets(ids: string[]) {
|
|
103
|
+
for (let i = 0; i < ids.length; i += 90) {
|
|
104
|
+
const chunk = ids.slice(i, i + 90);
|
|
105
|
+
await this.run(`DELETE FROM push_tickets WHERE id IN (${chunk.map(() => '?').join(',')})`, chunk);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---- automatic notification queue ----
|
|
110
|
+
/** Claims up to `limit` pending rows (status → sending); rows queued before `staleBefore` are skipped instead. */
|
|
111
|
+
async claimPending(now: number, staleBefore: number, limit: number): Promise<PushQueueRow[]> {
|
|
112
|
+
await this.run(`UPDATE push_queue SET status='skipped', updated_at=? WHERE status='pending' AND created_at<?`, [now, staleBefore]);
|
|
113
|
+
const due = await this.all(`SELECT key FROM push_queue WHERE status='pending' ORDER BY created_at LIMIT ?`, [limit]);
|
|
114
|
+
const out: PushQueueRow[] = [];
|
|
115
|
+
for (const { key } of due) {
|
|
116
|
+
const r = await this.all(`UPDATE push_queue SET status='sending', updated_at=? WHERE key=? AND status='pending' RETURNING *`, [now, key]);
|
|
117
|
+
if (r[0]) out.push({ key: String(r[0].key), kind: r[0].kind as PushQueueRow['kind'], docId: String(r[0].doc_id), occurrence: r[0].occurrence == null ? null : Number(r[0].occurrence), status: 'sending', createdAt: Number(r[0].created_at) });
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
/** Inserts a new key as `sending`; false when it already exists (dedupe for "starting soon"). */
|
|
122
|
+
async claimKey(key: string, kind: PushQueueRow['kind'], docId: string, occurrence: number | null, now: number): Promise<boolean> {
|
|
123
|
+
const r = await this.all(`INSERT INTO push_queue (key,kind,doc_id,occurrence,status,notification_id,created_at,updated_at)
|
|
124
|
+
VALUES (?,?,?,?,'sending',NULL,?,?) ON CONFLICT(key) DO NOTHING RETURNING key`, [key, kind, docId, occurrence, now, now]);
|
|
125
|
+
return r.length > 0;
|
|
126
|
+
}
|
|
127
|
+
async markQueue(key: string, status: 'sent' | 'skipped' | 'failed', notificationId: string | null, now: number) {
|
|
128
|
+
await this.run('UPDATE push_queue SET status=?, notification_id=?, updated_at=? WHERE key=?', [status, notificationId, now, key]);
|
|
129
|
+
}
|
|
130
|
+
async skipPending(now: number) { await this.run(`UPDATE push_queue SET status='skipped', updated_at=? WHERE status='pending'`, [now]); }
|
|
131
|
+
async queueStatus(key: string) { return (await this.all('SELECT status FROM push_queue WHERE key=?', [key]))[0]?.status as string | undefined; }
|
|
132
|
+
|
|
133
|
+
// ---- retention ----
|
|
134
|
+
/** Devices not seen since `devicesBefore`, log rows older than a year, expired tickets and old occurrence keys. */
|
|
135
|
+
async purge(now: number, devicesBefore: number): Promise<number> {
|
|
136
|
+
const gone = await this.all('DELETE FROM push_devices WHERE last_seen_at<? RETURNING id', [devicesBefore]);
|
|
137
|
+
await (await this.driver()).batch([
|
|
138
|
+
{ sql: 'DELETE FROM push_notifications WHERE created_at<?', args: [now - 365 * 86400000] },
|
|
139
|
+
{ sql: 'DELETE FROM push_tickets WHERE created_at<?', args: [now - 86400000] },
|
|
140
|
+
{ sql: `DELETE FROM push_queue WHERE kind='soon' AND updated_at<?`, args: [now - 30 * 86400000] },
|
|
141
|
+
]);
|
|
142
|
+
return gone.length;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
// '*' in a calendars list = every public event
|
|
4
|
+
const keys = z.array(z.string().min(1).max(200)).max(100);
|
|
5
|
+
export const PushAuto = z.object({
|
|
6
|
+
/** collection keys whose first publish sends a notification */
|
|
7
|
+
newPost: z.object({ collections: keys.default([]) }).default({}),
|
|
8
|
+
/** calendar ids (or '*') whose events notify on first publish */
|
|
9
|
+
newEvent: z.object({ calendars: keys.default([]) }).default({}),
|
|
10
|
+
/** "starting soon" reminders, sent `minutes` before each occurrence */
|
|
11
|
+
startingSoon: z.object({ calendars: keys.default([]), minutes: z.number().int().min(5).max(1440).default(30) }).default({}),
|
|
12
|
+
});
|
|
13
|
+
export type PushAuto = z.infer<typeof PushAuto>;
|
|
14
|
+
export const PushSettings = z.object({ auto: PushAuto.default({}) });
|
|
15
|
+
export type PushSettings = z.infer<typeof PushSettings>;
|
|
16
|
+
|
|
17
|
+
export type PushPlatform = 'ios' | 'android';
|
|
18
|
+
export interface PushDevice {
|
|
19
|
+
id: string; token: string; platform: PushPlatform; locale: string; topics: string[];
|
|
20
|
+
createdAt: number; updatedAt: number; lastSeenAt: number;
|
|
21
|
+
}
|
|
22
|
+
export type PushSource = 'manual' | 'post' | 'event' | 'soon';
|
|
23
|
+
export interface PushNotification {
|
|
24
|
+
id: string; title: string; body: string; url: string;
|
|
25
|
+
/** ['*'] = everyone */
|
|
26
|
+
topics: string[];
|
|
27
|
+
source: PushSource; sourceKey: string;
|
|
28
|
+
status: 'sending' | 'sent' | 'failed';
|
|
29
|
+
recipients: number; sent: number; ok: number; failed: number;
|
|
30
|
+
/** devices removed because Expo said they are no longer registered */
|
|
31
|
+
removed: number;
|
|
32
|
+
error: string; author: string; createdAt: number; finishedAt: number | null;
|
|
33
|
+
}
|
|
34
|
+
export interface PushQueueRow { key: string; kind: 'post' | 'event' | 'soon'; docId: string; occurrence: number | null; status: string; createdAt: number }
|
|
35
|
+
export interface PushTopic { id: string; label: string; kind: 'general' | 'collection' | 'calendar' }
|
|
36
|
+
export interface PushStats {
|
|
37
|
+
devices: number;
|
|
38
|
+
platforms: Record<PushPlatform, number>;
|
|
39
|
+
/** devices per topic id */
|
|
40
|
+
topics: Record<string, number>;
|
|
41
|
+
/** devices whose app checked in during the last 30 days */
|
|
42
|
+
active30: number;
|
|
43
|
+
}
|
|
@@ -2,6 +2,8 @@ import { LIVE_MIGRATION } from '../live/migration';
|
|
|
2
2
|
import { IMPORT_MIGRATION } from '../import/migration';
|
|
3
3
|
import { EMAIL_MIGRATION } from '../email/migration';
|
|
4
4
|
import { WEBHOOK_MIGRATION } from '../webhook-sql';
|
|
5
|
+
import { PUSH_MIGRATION } from '../push/migration';
|
|
6
|
+
import { EDITOR_MIGRATION, EDITOR_GRACE_MIGRATION } from '../editor/migration';
|
|
5
7
|
// Versioned schema migrations shared by the two SQLite-dialect adapters
|
|
6
8
|
// (D1 and libSQL). Applied versions are recorded in the `migrations` table;
|
|
7
9
|
// each adapter's runner applies whatever is newer, in order, at startup.
|
|
@@ -501,6 +503,15 @@ export const MIGRATIONS: Migration[] = [
|
|
|
501
503
|
WEBHOOK_MIGRATION,
|
|
502
504
|
LIVE_MIGRATION,
|
|
503
505
|
IMPORT_MIGRATION,
|
|
506
|
+
// translations: per-language words for posts, pages and events (facts stay shared)
|
|
507
|
+
{ version: 36, statements: [
|
|
508
|
+
`ALTER TABLE posts ADD COLUMN translations TEXT NOT NULL DEFAULT '{}'`,
|
|
509
|
+
`ALTER TABLE pages ADD COLUMN translations TEXT NOT NULL DEFAULT '{}'`,
|
|
510
|
+
`ALTER TABLE events ADD COLUMN translations TEXT NOT NULL DEFAULT '{}'`,
|
|
511
|
+
] },
|
|
512
|
+
PUSH_MIGRATION,
|
|
513
|
+
EDITOR_MIGRATION,
|
|
514
|
+
EDITOR_GRACE_MIGRATION,
|
|
504
515
|
];
|
|
505
516
|
|
|
506
517
|
export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { LiveStore } from '../live/store';
|
|
2
|
+
import { PushStore } from '../push/store';
|
|
3
|
+
import { EditorStore } from '../editor/store';
|
|
2
4
|
import { LiveConfig } from '../live/types';
|
|
3
5
|
import { ApiError } from '../api';
|
|
4
6
|
import { EmailStore, bookingJobs } from '../email/store';
|
|
5
7
|
import type { ContentRepository, DesignVersion, EventUpdate, ImportRecord, ImportRecordKind, ImportRun, SearchResults, Stats } from './types';
|
|
6
|
-
import { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
|
|
8
|
+
import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
|
|
7
9
|
import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
|
|
8
10
|
import { eventInstants, eventTimeZone, normalizeEventTimes } from '../calendar/events';
|
|
9
11
|
import { cleanRrule } from '../calendar/rrule';
|
|
@@ -43,6 +45,11 @@ function parseDraft(raw: unknown): Record<string, unknown> | null {
|
|
|
43
45
|
}
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
// translations column: tolerate old rows / junk (never break a page render)
|
|
49
|
+
function parseTranslations(v: unknown) {
|
|
50
|
+
const parsed = DocTranslations.safeParse(json(v, {}));
|
|
51
|
+
return parsed.success ? parsed.data : {};
|
|
52
|
+
}
|
|
46
53
|
function rowToPage(r: Row): Page {
|
|
47
54
|
return Page.parse({
|
|
48
55
|
id: r.id, slug: r.slug, title: r.title, status: r.status,
|
|
@@ -50,7 +57,7 @@ function rowToPage(r: Row): Page {
|
|
|
50
57
|
blocks: JSON.parse(String(r.blocks ?? '[]')),
|
|
51
58
|
bodyJson: r.body_json ?? '', bodyHtml: r.body_html ?? '',
|
|
52
59
|
seoTitle: r.seo_title ?? '', seoDesc: r.seo_desc ?? '',
|
|
53
|
-
template: r.template ?? '', draft: parseDraft(r.draft),
|
|
60
|
+
template: r.template ?? '', draft: parseDraft(r.draft), translations: parseTranslations(r.translations),
|
|
54
61
|
createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
55
62
|
});
|
|
56
63
|
}
|
|
@@ -62,7 +69,7 @@ function rowToPost(r: Row, authorIds?: string[]): Post {
|
|
|
62
69
|
seoTitle: r.seo_title ?? '', seoDesc: r.seo_desc ?? '', status: r.status,
|
|
63
70
|
featured: Boolean(r.featured), authorId: r.author_id ?? null,
|
|
64
71
|
authorIds: authorIds ?? (r.author_id ? [String(r.author_id)] : []),
|
|
65
|
-
template: r.template ?? '', draft: parseDraft(r.draft),
|
|
72
|
+
template: r.template ?? '', draft: parseDraft(r.draft), translations: parseTranslations(r.translations),
|
|
66
73
|
publishedAt: r.published_at === null ? null : Number(r.published_at),
|
|
67
74
|
scheduledAt: r.scheduled_at === null ? null : Number(r.scheduled_at),
|
|
68
75
|
createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
@@ -138,7 +145,7 @@ function rowToEvent(r: Row): CalEvent {
|
|
|
138
145
|
rrule: r.rrule ?? '', exdates: json(r.exdates, []), cancelled: Boolean(r.cancelled),
|
|
139
146
|
location: json(r.location, {}), cta: json(r.cta, {}), calendarIds: json(r.calendar_ids, []),
|
|
140
147
|
fields: json(r.fields, {}), sourceUid: r.source_uid ?? '', rsvp: json(r.rsvp, {}),
|
|
141
|
-
startAt: Number(r.start_at), endAt: Number(r.end_at), draft: parseDraft(r.draft),
|
|
148
|
+
startAt: Number(r.start_at), endAt: Number(r.end_at), draft: parseDraft(r.draft), translations: parseTranslations(r.translations),
|
|
142
149
|
publishedAt: r.published_at === null || r.published_at === undefined ? null : Number(r.published_at),
|
|
143
150
|
scheduledAt: r.scheduled_at === null || r.scheduled_at === undefined ? null : Number(r.scheduled_at),
|
|
144
151
|
createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
@@ -233,10 +240,14 @@ export class SqlRepository implements ContentRepository {
|
|
|
233
240
|
private ready: Promise<void>;
|
|
234
241
|
readonly email: EmailStore;
|
|
235
242
|
readonly live: LiveStore;
|
|
243
|
+
readonly push: PushStore;
|
|
244
|
+
readonly editor: EditorStore;
|
|
236
245
|
|
|
237
246
|
constructor(driver: SqlDriver | (() => Promise<SqlDriver>)) {
|
|
238
247
|
this.ready = this.init(driver);
|
|
239
248
|
this.live = new LiveStore(async () => { await this.ready; return this.driver; });
|
|
249
|
+
this.push = new PushStore(async () => { await this.ready; return this.driver; });
|
|
250
|
+
this.editor = new EditorStore(async () => { await this.ready; return this.driver; });
|
|
240
251
|
this.email = new EmailStore(async () => { await this.ready; return this.driver; });
|
|
241
252
|
}
|
|
242
253
|
|
|
@@ -466,6 +477,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
466
477
|
if (data.seoTitle !== undefined) { sets.push('seo_title = ?'); args.push(data.seoTitle); }
|
|
467
478
|
if (data.seoDesc !== undefined) { sets.push('seo_desc = ?'); args.push(data.seoDesc); }
|
|
468
479
|
if (data.template !== undefined) { sets.push('template = ?'); args.push(data.template); }
|
|
480
|
+
if (data.translations !== undefined) { sets.push('translations = ?'); args.push(JSON.stringify(data.translations)); }
|
|
469
481
|
if (data.blocks !== undefined) { sets.push('blocks = ?'); args.push(JSON.stringify(data.blocks)); }
|
|
470
482
|
if (data.bodyJson !== undefined) { sets.push('body_json = ?'); args.push(data.bodyJson); }
|
|
471
483
|
if (data.bodyHtml !== undefined) { sets.push('body_html = ?'); args.push(data.bodyHtml); }
|
|
@@ -598,6 +610,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
598
610
|
if (data.seoDesc !== undefined) { sets.push('seo_desc = ?'); args.push(data.seoDesc); }
|
|
599
611
|
if (data.featured !== undefined) { sets.push('featured = ?'); args.push(data.featured ? 1 : 0); }
|
|
600
612
|
if (data.template !== undefined) { sets.push('template = ?'); args.push(data.template); }
|
|
613
|
+
if (data.translations !== undefined) { sets.push('translations = ?'); args.push(JSON.stringify(data.translations)); }
|
|
601
614
|
// authorIds is the source of truth; author_id mirrors the first entry.
|
|
602
615
|
// Main row, join rows and webhook notifications share one transaction.
|
|
603
616
|
let authorJoin: string[] | null = null;
|
|
@@ -832,7 +845,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
832
845
|
if (v !== undefined) { sets.push(`${col} = ?`); args.push(v); }
|
|
833
846
|
}
|
|
834
847
|
const jsonCols: Record<string, string> = {
|
|
835
|
-
tags: 'tags', exdates: 'exdates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp',
|
|
848
|
+
tags: 'tags', exdates: 'exdates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations',
|
|
836
849
|
};
|
|
837
850
|
for (const [k, col] of Object.entries(jsonCols)) {
|
|
838
851
|
const v = (data as Record<string, unknown>)[k];
|
|
@@ -1633,6 +1646,10 @@ export class SqlRepository implements ContentRepository {
|
|
|
1633
1646
|
args: [p.id, aid, i] as unknown[],
|
|
1634
1647
|
}))
|
|
1635
1648
|
),
|
|
1649
|
+
// translations (v36): written after the rows so older backups need no change
|
|
1650
|
+
...[['pages', content.pages], ['posts', content.posts], ['events', content.events ?? []]].flatMap(([table, rows]) =>
|
|
1651
|
+
(rows as { id: string; translations?: Record<string, unknown> }[]).filter((r) => r.translations && Object.keys(r.translations).length)
|
|
1652
|
+
.map((r) => ({ sql: `UPDATE ${table} SET translations = ? WHERE id = ?`, args: [JSON.stringify(r.translations), r.id] as unknown[] }))),
|
|
1636
1653
|
...content.media.map((m) => ({
|
|
1637
1654
|
sql: 'INSERT INTO media (id, key, filename, mime, width, height, size, alt, variants, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
1638
1655
|
args: [m.id, m.key, m.filename, m.mime, m.width, m.height, m.size, m.alt, JSON.stringify(m.variants), m.createdAt],
|
|
@@ -1818,6 +1835,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
1818
1835
|
timezone: r?.timezone ?? '', locale: r?.locale ?? '',
|
|
1819
1836
|
ai: JSON.parse(String(r?.ai ?? '{}')),
|
|
1820
1837
|
live: JSON.parse(String(r?.live ?? '{}')),
|
|
1838
|
+
push: JSON.parse(String(r?.push ?? '{}')),
|
|
1821
1839
|
agent: JSON.parse(String(r?.agent ?? '{}')),
|
|
1822
1840
|
draftsApiKey: r?.drafts_api_key ?? '',
|
|
1823
1841
|
draftsAllowedOrigins: JSON.parse(String(r?.drafts_allowed_origins ?? '[]')),
|
|
@@ -1834,9 +1852,9 @@ export class SqlRepository implements ContentRepository {
|
|
|
1834
1852
|
siteTitle:'site_title', tagline:'tagline', footerText:'footer_text', linksName:'links_name', linksBio:'links_bio', linksAvatar:'links_avatar',
|
|
1835
1853
|
activeTheme:'active_theme', designOverrides:'design_overrides', sectionPages:'section_pages', siteLogo:'site_logo', siteUrl:'site_url', siteFavicon:'site_favicon', siteCover:'site_cover',
|
|
1836
1854
|
announcement:'announcement', analytics:'analytics', headCode:'head_code', footerCode:'footer_code', timezone:'timezone', locale:'locale',
|
|
1837
|
-
draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live',
|
|
1855
|
+
draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live', push:'push',
|
|
1838
1856
|
};
|
|
1839
|
-
const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live']);
|
|
1857
|
+
const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live','push']);
|
|
1840
1858
|
const entries = Object.entries(data).filter(([k,v]) => columns[k] && v !== undefined);
|
|
1841
1859
|
if (!entries.length) return;
|
|
1842
1860
|
const design = entries.some(([k]) => ['activeTheme','designOverrides','sectionPages','siteLogo','siteFavicon','siteCover','draftDesign'].includes(k));
|
|
@@ -1901,6 +1919,21 @@ export class SqlRepository implements ContentRepository {
|
|
|
1901
1919
|
} }));
|
|
1902
1920
|
await this.designWrite(opts.expectedRevision ?? current.designRevision, opts.author ?? 'admin', 'publish', statements);
|
|
1903
1921
|
}
|
|
1922
|
+
async publishSectionPages(live: Settings['sectionPages'], staged: Settings['sectionPages'] | null, opts: { author?: string; summary?: string; expectedRevision: number }) {
|
|
1923
|
+
const current = await this.getSettings();
|
|
1924
|
+
const liveNav = await this.listNavItems();
|
|
1925
|
+
const draft: Record<string, unknown> = { ...(current.draftDesign ?? {}) };
|
|
1926
|
+
if (staged) draft.sectionPages = staged; else delete draft.sectionPages;
|
|
1927
|
+
// a draft with nothing left to publish goes away entirely
|
|
1928
|
+
const remaining = Object.keys(pickDesignDraft(draft)).length > 0 || Array.isArray(draft.navItems);
|
|
1929
|
+
const statements: SqlStatement[] = [
|
|
1930
|
+
{ sql: 'INSERT INTO design_versions (id,created_at,author,summary,snapshot) VALUES (?,?,?,?,?)', args: [crypto.randomUUID(), Date.now(), opts.author ?? '', opts.summary ?? '', JSON.stringify(designSnapshot(current, liveNav))] },
|
|
1931
|
+
{ sql: 'DELETE FROM design_versions WHERE id NOT IN (SELECT id FROM design_versions ORDER BY created_at DESC LIMIT 20)' },
|
|
1932
|
+
{ sql: 'UPDATE settings SET section_pages=?,draft_design=? WHERE id=1', args: [JSON.stringify(live), remaining ? JSON.stringify({ ...draft, savedAt: Date.now() }) : null] },
|
|
1933
|
+
webhookStatement('design.published', { design: { revision: current.designRevision + 1, activeTheme: current.activeTheme, navigationChanged: false, sectionPageIds: Object.keys(live) } }),
|
|
1934
|
+
];
|
|
1935
|
+
await this.designWrite(opts.expectedRevision, opts.author ?? 'admin', 'publish', statements);
|
|
1936
|
+
}
|
|
1904
1937
|
async discardDesignDraft() { await this.setDesignDraft(null); }
|
|
1905
1938
|
async listDesignVersions(limit = 20) {
|
|
1906
1939
|
await this.ready;
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { LiveStore } from '../live/store';
|
|
2
2
|
import type { BookingRequest } from '../backup/content';
|
|
3
3
|
import type { EmailStore } from '../email/store';
|
|
4
|
+
import type { PushStore } from '../push/store';
|
|
5
|
+
import type { EditorStore } from '../editor/store';
|
|
4
6
|
export interface ImportRun { id: string; source: string; label: string; status: 'running' | 'done' | 'undone'; report: Record<string, unknown>; createdAt: number; updatedAt: number }
|
|
5
7
|
export type ImportRecordKind = 'post' | 'page' | 'user' | 'tag' | 'media' | 'redirect' | 'nav';
|
|
6
8
|
export interface ImportRecord { runId: string; kind: ImportRecordKind; sourceId: string; acmsId: string; action: 'created' | 'updated'; stamp: number }
|
|
@@ -37,7 +39,7 @@ export type EventUpdate = Partial<
|
|
|
37
39
|
| 'title' | 'slug' | 'bodyJson' | 'bodyHtml' | 'excerpt' | 'cover' | 'coverAlt'
|
|
38
40
|
| 'seoTitle' | 'seoDesc' | 'status' | 'featured' | 'tags' | 'template'
|
|
39
41
|
| 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled'
|
|
40
|
-
| 'location' | 'cta' | 'calendarIds' | 'fields' | 'sourceUid' | 'rsvp'
|
|
42
|
+
| 'location' | 'cta' | 'calendarIds' | 'fields' | 'sourceUid' | 'rsvp' | 'translations'
|
|
41
43
|
| 'publishedAt' | 'scheduledAt'
|
|
42
44
|
>
|
|
43
45
|
>;
|
|
@@ -47,6 +49,8 @@ export type EventUpdate = Partial<
|
|
|
47
49
|
export interface ContentRepository {
|
|
48
50
|
readonly email: EmailStore;
|
|
49
51
|
readonly live: LiveStore;
|
|
52
|
+
readonly push: PushStore;
|
|
53
|
+
readonly editor: EditorStore;
|
|
50
54
|
// Bounded public catalog; fetch limit + 1 IDs without loading every body.
|
|
51
55
|
listPublicContent(options: { page: number; limit: number; kind?: 'post' | 'page'; collection?: string; locale?: string; collections: string[]; defaultLocale: string }): Promise<{ id: string; kind: 'post' | 'page' }[]>;
|
|
52
56
|
exportBookingState(): Promise<{ bookings: Booking[]; bookingRequests: BookingRequest[] }>;
|
|
@@ -60,7 +64,7 @@ export interface ContentRepository {
|
|
|
60
64
|
createPage(data: { title: string; slug: string }): Promise<Page>;
|
|
61
65
|
updatePage(
|
|
62
66
|
id: string,
|
|
63
|
-
data: Partial<Pick<Page, 'title' | 'slug' | 'status' | 'isHome' | 'locale' | 'seoTitle' | 'seoDesc' | 'bodyJson' | 'bodyHtml' | 'template'>> & {
|
|
67
|
+
data: Partial<Pick<Page, 'title' | 'slug' | 'status' | 'isHome' | 'locale' | 'seoTitle' | 'seoDesc' | 'bodyJson' | 'bodyHtml' | 'template' | 'translations'>> & {
|
|
64
68
|
blocks?: WidgetInstance[];
|
|
65
69
|
}
|
|
66
70
|
): Promise<void>;
|
|
@@ -90,7 +94,7 @@ export interface ContentRepository {
|
|
|
90
94
|
| 'title' | 'slug' | 'collection' | 'body' | 'bodyJson' | 'bodyHtml' | 'excerpt'
|
|
91
95
|
| 'tags' | 'cover' | 'coverAlt' | 'seoTitle' | 'seoDesc'
|
|
92
96
|
| 'status' | 'featured' | 'authorId' | 'authorIds' | 'template'
|
|
93
|
-
| 'publishedAt' | 'scheduledAt'
|
|
97
|
+
| 'publishedAt' | 'scheduledAt' | 'translations'
|
|
94
98
|
>
|
|
95
99
|
>
|
|
96
100
|
): Promise<void>;
|
|
@@ -298,6 +302,8 @@ export interface ContentRepository {
|
|
|
298
302
|
// publish: snapshot the outgoing live design into design_versions (last 20
|
|
299
303
|
// kept), apply the draft's settings + staged navigation, clear the draft
|
|
300
304
|
applyDesignDraft(opts?: { author?: string; summary?: string; expectedRevision?: number }): Promise<void>;
|
|
305
|
+
/** publish section layouts only (one section's edits); the rest of the draft stays staged */
|
|
306
|
+
publishSectionPages(live: Settings['sectionPages'], staged: Settings['sectionPages'] | null, opts: { author?: string; summary?: string; expectedRevision: number }): Promise<void>;
|
|
301
307
|
discardDesignDraft(): Promise<void>;
|
|
302
308
|
listDesignVersions(limit?: number): Promise<DesignVersion[]>;
|
|
303
309
|
getDesignVersion(id: string): Promise<DesignVersion | null>;
|
package/src/lib/roles.ts
ADDED
package/src/lib/scheduler.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ContentRepository } from './repo/types';
|
|
|
3
3
|
import { processDueDeliveries } from './webhooks';
|
|
4
4
|
import { dispatchEmail } from './email/service';
|
|
5
5
|
import { sendDueReminders } from './booking/service';
|
|
6
|
+
import { runPushJobs } from './push/service';
|
|
6
7
|
|
|
7
8
|
// All work is awaited on both hosts. Traffic-triggered fallbacks do not mark
|
|
8
9
|
// this heartbeat: monitoring must prove the independent scheduler ran.
|
|
@@ -15,6 +16,8 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
|
|
|
15
16
|
reminders: () => sendDueReminders(repo, { origin: settings.siteUrl, locals, now: startedAt }),
|
|
16
17
|
email: () => dispatchEmail(repo, locals, 10),
|
|
17
18
|
live: () => syncDueLive(repo, locals),
|
|
19
|
+
// queued first-publish notices, starting-soon reminders, receipts, retention
|
|
20
|
+
push: () => runPushJobs(repo, { locals, now: startedAt }),
|
|
18
21
|
};
|
|
19
22
|
const results: Record<string, { ok: boolean; count?: number }> = {};
|
|
20
23
|
for (const [name, run] of Object.entries(jobs)) {
|
package/src/lib/schema.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LiveConfig } from './live/types';
|
|
2
|
+
import { PushSettings } from './push/types';
|
|
2
3
|
import { SectionPages } from '../theme-sdk/contract.mjs';
|
|
3
4
|
import { AiConfig, AgentConfig } from './ai-schema';
|
|
4
5
|
import { z } from 'zod';
|
|
@@ -64,6 +65,26 @@ export function parseConfig<T extends WidgetType>(type: T, raw: unknown): z.infe
|
|
|
64
65
|
export const Status = z.enum(['draft', 'scheduled', 'published', 'archived']);
|
|
65
66
|
export type Status = z.infer<typeof Status>;
|
|
66
67
|
|
|
68
|
+
// Per-language words for a post, page or event (the facts stay shared):
|
|
69
|
+
// translations: { es: { title, excerpt, bodyHtml, seoTitle, seoDesc, slug,
|
|
70
|
+
// locationName, ctaLabel, meta: { title: { state, src } } } }
|
|
71
|
+
// state 'ai' = machine draft (shown, flagged for review), 'reviewed' = a person
|
|
72
|
+
// wrote/approved it; src = hash of the main-language text it was made from.
|
|
73
|
+
export const DocTranslation = z.object({
|
|
74
|
+
title: z.string().max(1000).default(''),
|
|
75
|
+
excerpt: z.string().max(5000).default(''),
|
|
76
|
+
bodyHtml: z.string().max(2_000_000).default(''),
|
|
77
|
+
seoTitle: z.string().max(1000).default(''),
|
|
78
|
+
seoDesc: z.string().max(5000).default(''),
|
|
79
|
+
slug: z.string().max(300).default(''),
|
|
80
|
+
locationName: z.string().max(500).default(''),
|
|
81
|
+
ctaLabel: z.string().max(300).default(''),
|
|
82
|
+
meta: z.record(z.string().max(40), z.object({ state: z.enum(['ai', 'reviewed']), src: z.string().max(20) })).default({}),
|
|
83
|
+
});
|
|
84
|
+
export type DocTranslation = z.infer<typeof DocTranslation>;
|
|
85
|
+
export const DocTranslations = z.record(z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/), DocTranslation).default({});
|
|
86
|
+
export type DocTranslations = z.infer<typeof DocTranslations>;
|
|
87
|
+
|
|
67
88
|
export const Page = z.object({
|
|
68
89
|
id: z.string(),
|
|
69
90
|
slug: z.string(),
|
|
@@ -83,6 +104,7 @@ export const Page = z.object({
|
|
|
83
104
|
// theme template name (templates/page-<name>.astro); empty = default layout
|
|
84
105
|
template: z.string().default(''),
|
|
85
106
|
// pending edits on a live page (see lib/drafts.ts); null = none staged
|
|
107
|
+
translations: DocTranslations,
|
|
86
108
|
draft: z.record(z.any()).nullable().default(null),
|
|
87
109
|
createdAt: z.number(),
|
|
88
110
|
updatedAt: z.number(),
|
|
@@ -117,6 +139,7 @@ export const Post = z.object({
|
|
|
117
139
|
// theme template name (templates/post-<name>.astro); empty = default layout
|
|
118
140
|
template: z.string().default(''),
|
|
119
141
|
// pending edits on a live post (see lib/drafts.ts); null = none staged
|
|
142
|
+
translations: DocTranslations,
|
|
120
143
|
draft: z.record(z.any()).nullable().default(null),
|
|
121
144
|
publishedAt: z.number().nullable(),
|
|
122
145
|
scheduledAt: z.number().nullable().default(null),
|
|
@@ -171,7 +194,7 @@ export const Snippet = z.object({
|
|
|
171
194
|
});
|
|
172
195
|
export type Snippet = z.infer<typeof Snippet>;
|
|
173
196
|
|
|
174
|
-
export const Role = z.enum(['
|
|
197
|
+
export const Role = z.enum(['contributor', 'author', 'editor', 'administrator', 'owner']);
|
|
175
198
|
export type Role = z.infer<typeof Role>;
|
|
176
199
|
|
|
177
200
|
// Password hashes never leave the repo layer — User is the public shape.
|
|
@@ -334,6 +357,7 @@ export const CalEvent = z.object({
|
|
|
334
357
|
}).default({}),
|
|
335
358
|
startAt: z.number(),
|
|
336
359
|
endAt: z.number(),
|
|
360
|
+
translations: DocTranslations,
|
|
337
361
|
draft: z.record(z.any()).nullable().default(null),
|
|
338
362
|
publishedAt: z.number().nullable().default(null),
|
|
339
363
|
scheduledAt: z.number().nullable().default(null),
|
|
@@ -560,6 +584,8 @@ export function normalizeRedirectPath(input: string): string {
|
|
|
560
584
|
|
|
561
585
|
export const Settings = z.object({
|
|
562
586
|
live: LiveConfig.default({}),
|
|
587
|
+
// push notification automation (lib/push); the feature itself is acms({ mobile: { push } })
|
|
588
|
+
push: PushSettings.default({}),
|
|
563
589
|
siteTitle: z.string().default('My Site'),
|
|
564
590
|
tagline: z.string().default(''),
|
|
565
591
|
// site-wide footer, markdown allowed; empty falls back to "title — tagline"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import sanitizeHtml from 'sanitize-html';
|
|
3
|
+
import type { AiConfig } from './ai-schema';
|
|
4
|
+
import { completeText } from './ai';
|
|
5
|
+
import { ApiError } from './api';
|
|
6
|
+
|
|
7
|
+
// "Help me write" for section fields: rewrite one field's text with the
|
|
8
|
+
// site's configured AI provider. The owner reviews the suggestion before it
|
|
9
|
+
// replaces anything; nothing is saved here.
|
|
10
|
+
|
|
11
|
+
export const AssistInput = z.object({
|
|
12
|
+
action: z.enum(['shorter', 'friendlier', 'clearer', 'translate', 'draft']),
|
|
13
|
+
text: z.string().max(20_000).default(''),
|
|
14
|
+
label: z.string().max(100),
|
|
15
|
+
kind: z.enum(['text', 'textarea', 'richtext']).default('text'),
|
|
16
|
+
hint: z.string().max(300).optional(),
|
|
17
|
+
maxLength: z.number().int().min(1).max(20_000).optional(),
|
|
18
|
+
language: z.string().max(40).optional(),
|
|
19
|
+
// other filled-in fields of the same section, for context ("Heading: Join us")
|
|
20
|
+
context: z.string().max(4000).optional(),
|
|
21
|
+
}).strict();
|
|
22
|
+
|
|
23
|
+
const ACTIONS: Record<z.infer<typeof AssistInput>['action'], string> = {
|
|
24
|
+
shorter: 'Make it noticeably shorter while keeping every fact (names, days, times, places, prices).',
|
|
25
|
+
friendlier: 'Make it warmer and more welcoming, plain words, no hype or exclamation marks.',
|
|
26
|
+
clearer: 'Fix spelling and grammar and make it clearer. Change as little as possible.',
|
|
27
|
+
translate: 'Translate it into the requested language. Keep names of people and places as they are.',
|
|
28
|
+
draft: 'Write it from scratch for this field, using the other fields as context. Be concrete and plain.',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export async function assistField(config: AiConfig, raw: unknown): Promise<{ text: string }> {
|
|
32
|
+
const a = AssistInput.parse(raw);
|
|
33
|
+
if (a.action !== 'draft' && !a.text.trim()) throw new ApiError(400, 'Write something first, or use “Write a first draft”.');
|
|
34
|
+
if (a.action === 'translate' && !a.language) throw new ApiError(400, 'Choose a language.');
|
|
35
|
+
const rules = [
|
|
36
|
+
'You edit short text for a section of a website (for example a church, club or small business homepage).',
|
|
37
|
+
`The field is “${a.label}”.`,
|
|
38
|
+
ACTIONS[a.action],
|
|
39
|
+
a.action === 'translate' ? `Target language: ${a.language}.` : 'Keep the original language.',
|
|
40
|
+
a.hint ? `The site owner's guidance for this field: ${a.hint}` : '',
|
|
41
|
+
a.maxLength ? `Stay under ${a.maxLength} characters.` : '',
|
|
42
|
+
a.kind === 'richtext' ? 'Return simple HTML using only <p>, <strong>, <em>, <a href>, <ul>, <ol>, <li>. Keep existing links.' : 'Return plain text only.',
|
|
43
|
+
a.kind === 'text' ? 'One line, no line breaks.' : '',
|
|
44
|
+
'Return only the new text: no quotes, labels, notes or alternatives. Never invent facts, dates, prices or contact details.',
|
|
45
|
+
].filter(Boolean).join('\n');
|
|
46
|
+
const input = [a.context ? `Other fields in this section:\n${a.context}` : '', a.text ? `Current text:\n${a.text}` : 'The field is empty.'].filter(Boolean).join('\n\n');
|
|
47
|
+
let out = (await completeText(config, rules, input)).replace(/^```\w*\n?|\n?```$/g, '').trim();
|
|
48
|
+
if (/^["“].*["”]$/s.test(out)) out = out.slice(1, -1).trim();
|
|
49
|
+
out = a.kind === 'richtext'
|
|
50
|
+
? sanitizeHtml(out, { allowedTags: ['p', 'br', 'strong', 'b', 'em', 'i', 'a', 'ul', 'ol', 'li'], allowedAttributes: { a: ['href'] }, allowedSchemes: ['http', 'https', 'mailto', 'tel'] })
|
|
51
|
+
: sanitizeHtml(out, { allowedTags: [], allowedAttributes: {} }).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'");
|
|
52
|
+
if (a.kind === 'text') out = out.replace(/\s*\n+\s*/g, ' ');
|
|
53
|
+
if (!out) throw new ApiError(502, 'The AI returned nothing useful. Try again.');
|
|
54
|
+
return { text: out };
|
|
55
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ContentRepository } from './repo/types';
|
|
2
|
+
import { CONTENT, collectionUrl } from './content';
|
|
3
|
+
import { I18N, extraLocales, localeName } from './i18n';
|
|
4
|
+
import { translationEngine } from './translate';
|
|
5
|
+
import { CALENDAR, bookingLinkUrl, bookingsEnabled, eventUrl } from './calendar/config';
|
|
6
|
+
|
|
7
|
+
const NAMES: Record<string, string> = { en: 'English', es: 'Spanish', fr: 'French', de: 'German', pt: 'Portuguese', it: 'Italian', nl: 'Dutch', ko: 'Korean', zh: 'Chinese', ja: 'Japanese', tl: 'Tagalog', vi: 'Vietnamese', ar: 'Arabic', ru: 'Russian', uk: 'Ukrainian', pl: 'Polish' };
|
|
8
|
+
// the site's own languages first, then common ones
|
|
9
|
+
const languageNames = () => [...new Set([...(I18N.enabled ? I18N.locales : []).map((l) => NAMES[l.split('-')[0]] ?? l), 'English', 'Spanish', 'French', 'Portuguese', 'Korean', 'Chinese'])];
|
|
10
|
+
|
|
11
|
+
// Choices for the Design screen's section fields: what a link field can point
|
|
12
|
+
// at (stored as a ref so it follows slug changes) and what a source field can
|
|
13
|
+
// show. Published/public things only — this is what visitors can reach.
|
|
14
|
+
|
|
15
|
+
export interface LinkTarget { ref: string; label: string; url: string; group: 'Pages' | 'Posts' | 'Events' | 'Booking' }
|
|
16
|
+
export interface SectionTargets {
|
|
17
|
+
links: LinkTarget[];
|
|
18
|
+
calendars: { slug: string; name: string }[];
|
|
19
|
+
collections: { key: string; label: string }[];
|
|
20
|
+
tags: string[];
|
|
21
|
+
resources: { slug: string; name: string }[];
|
|
22
|
+
features: { events: boolean; bookings: boolean };
|
|
23
|
+
/** AI help for text fields: provider configured + languages to translate into */
|
|
24
|
+
ai: { enabled: boolean; languages: string[] };
|
|
25
|
+
/** extra site languages to translate into, and whether machine drafts are available */
|
|
26
|
+
i18n: { defaultLocale: string; locales: { code: string; name: string }[]; drafts: boolean };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function sectionTargets(repo: ContentRepository, locals?: unknown): Promise<SectionTargets> {
|
|
30
|
+
const bookings = bookingsEnabled();
|
|
31
|
+
const settings = await repo.getSettings();
|
|
32
|
+
const [pages, posts, events, calendars, tags, resources, links] = await Promise.all([
|
|
33
|
+
repo.listPages(),
|
|
34
|
+
repo.listPublishedPosts(),
|
|
35
|
+
CALENDAR.enabled ? repo.listPublishedEvents() : Promise.resolve([]),
|
|
36
|
+
CALENDAR.enabled ? repo.listCalendars() : Promise.resolve([]),
|
|
37
|
+
repo.listTags(),
|
|
38
|
+
bookings ? repo.listResources() : Promise.resolve([]),
|
|
39
|
+
bookings ? repo.listBookingLinks() : Promise.resolve([]),
|
|
40
|
+
]);
|
|
41
|
+
const publicResources = resources.filter((r) => r.visibility === 'public');
|
|
42
|
+
const bySlug = new Map(resources.map((r) => [r.id, r]));
|
|
43
|
+
const out: LinkTarget[] = [
|
|
44
|
+
...pages.filter((p) => p.status === 'published').map((p): LinkTarget => ({ ref: `page:${p.id}`, label: p.title || p.slug, url: p.isHome ? '/' : `/${p.slug}`, group: 'Pages' })),
|
|
45
|
+
...posts.slice(0, 200).map((p): LinkTarget => ({ ref: `post:${p.id}`, label: p.title, url: collectionUrl(p.collection, p.slug), group: 'Posts' })),
|
|
46
|
+
...events.slice(0, 200).map((e): LinkTarget => ({ ref: `event:${e.id}`, label: e.title, url: eventUrl(e.slug), group: 'Events' })),
|
|
47
|
+
...links.filter((l) => l.active && bySlug.has(l.resourceId)).map((l): LinkTarget => {
|
|
48
|
+
const r = bySlug.get(l.resourceId)!;
|
|
49
|
+
return { ref: `booking:${l.id}`, label: `${r.name} · ${l.name}`, url: bookingLinkUrl(r.slug, l.slug), group: 'Booking' };
|
|
50
|
+
}),
|
|
51
|
+
];
|
|
52
|
+
return {
|
|
53
|
+
links: out,
|
|
54
|
+
calendars: calendars.filter((c) => c.visibility !== 'private').map((c) => ({ slug: c.slug, name: c.name })),
|
|
55
|
+
collections: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, label: c.plural })),
|
|
56
|
+
tags: [...new Set(tags.map((t) => t.name))].sort((a, b) => a.localeCompare(b)),
|
|
57
|
+
resources: publicResources.map((r) => ({ slug: r.slug, name: r.name })),
|
|
58
|
+
features: { events: CALENDAR.enabled, bookings },
|
|
59
|
+
ai: { enabled: Boolean(settings.ai.enabled && settings.ai.model), languages: languageNames() },
|
|
60
|
+
i18n: { defaultLocale: I18N.defaultLocale, locales: extraLocales().map((code) => ({ code, name: localeName(code, true) })), drafts: translationEngine(settings.ai, locals) !== null },
|
|
61
|
+
};
|
|
62
|
+
}
|