@acms-subir/core 0.3.3-next.20260929113209 → 0.3.4
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 +105 -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/preview.mjs +8 -4
- package/cli/sections.mjs +188 -0
- package/cli/themes.mjs +52 -8
- package/docs/mobile.md +527 -0
- package/docs/sections.md +203 -0
- package/docs/theme-authoring.md +19 -3
- package/integration.mjs +76 -5
- 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 +17 -13
- 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 +205 -174
- 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/booking/BookingFlow.astro +6 -3
- package/src/components/calendar/BookingsHead.astro +51 -0
- package/src/components/calendar/CalendarBoard.tsx +618 -0
- package/src/components/calendar/MiniMonth.tsx +54 -0
- package/src/components/calendar/NewEventDialog.tsx +44 -0
- package/src/components/calendar/QuickAdd.tsx +263 -0
- package/src/components/calendar/RoomHolds.tsx +203 -0
- package/src/components/calendar/WhereItLands.tsx +51 -0
- package/src/components/editor/EventPanel.tsx +23 -2
- package/src/components/editor/PostEditor.tsx +206 -14
- 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 +11 -7
- package/src/layouts/Site.astro +136 -21
- 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/backup/content.ts +6 -1
- package/src/lib/booking/agent.ts +3 -2
- package/src/lib/booking/approval.ts +70 -0
- package/src/lib/booking/holds.ts +120 -0
- package/src/lib/booking/service.ts +37 -3
- package/src/lib/calendar/agent-schema.ts +9 -1
- package/src/lib/calendar/agent.ts +16 -1
- package/src/lib/calendar/board.ts +171 -0
- package/src/lib/calendar/config.ts +3 -0
- package/src/lib/calendar/grid.ts +169 -0
- package/src/lib/calendar/migration.ts +16 -0
- package/src/lib/calendar/render.ts +9 -7
- package/src/lib/calendar/schedule.ts +166 -0
- package/src/lib/content-service.ts +203 -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 +26 -14
- 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 +14 -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 +14 -0
- package/src/lib/repo/sql.ts +65 -20
- package/src/lib/repo/types.ts +10 -4
- package/src/lib/roles.ts +5 -0
- package/src/lib/scheduler.ts +6 -1
- package/src/lib/schema.ts +76 -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 +242 -0
- package/src/lib/site-chrome.ts +34 -0
- package/src/lib/theme-assets.ts +16 -0
- package/src/lib/theme.ts +33 -4
- package/src/lib/translate.ts +97 -0
- package/src/lib/translations.ts +39 -0
- package/src/middleware.ts +38 -2
- 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/bookings.astro +32 -26
- package/src/pages/admin/calendar/calendars.astro +4 -263
- package/src/pages/admin/calendar/events/[id].astro +9 -0
- package/src/pages/admin/calendar/events/index.astro +41 -83
- package/src/pages/admin/calendar/index.astro +43 -0
- package/src/pages/admin/calendar/resources/[id].astro +27 -5
- package/src/pages/admin/calendar/resources/index.astro +16 -17
- package/src/pages/admin/calendar/resources/link.astro +52 -12
- package/src/pages/admin/calendar/settings.astro +458 -0
- package/src/pages/admin/integrations.astro +5 -11
- 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/book/[resource].ics.ts +7 -1
- 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 +117 -0
- package/src/styles/cards.css +5 -1
- package/src/styles/editor.css +57 -0
- package/src/styles/mobile-app.css +151 -0
- package/src/styles/schedule.css +325 -0
- package/src/styles/sections.css +21 -12
- 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 +330 -0
- package/src/theme-sdk/index.ts +14 -2
- package/src/theme-sdk/tokens.d.mts +11 -0
- package/src/virtual.d.ts +4 -0
- package/src/pages/admin/calendar/index.ts +0 -4
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { CalEvent, Booking, Calendar, BookingQuestion } from '../schema';
|
|
3
|
+
|
|
4
|
+
const str = z.string(), bool = z.boolean(), strings = z.array(str);
|
|
5
|
+
const date = str.datetime(), locale = str.nullable();
|
|
6
|
+
export const Pagination = z.object({ page: z.number().int(), limit: z.number().int(), nextPage: z.number().int().nullable() });
|
|
7
|
+
const Palette = z.object({ bg: str, ink: str, muted: str, line: str, accent: str, panel: str, soft: str });
|
|
8
|
+
export const Site = z.object({
|
|
9
|
+
name: str, description: str, logo: str, icon: str, menu: z.array(z.object({ label: str, href: str })),
|
|
10
|
+
languages: z.object({ default: locale, locales: z.array(z.object({ code: str, name: str })) }),
|
|
11
|
+
features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str })), calendar: bool, bookings: bool, search: bool }), apiVersion: z.literal(1),
|
|
12
|
+
});
|
|
13
|
+
export const Theme = z.object({
|
|
14
|
+
id: str, name: str, scheme: z.enum(['auto', 'light', 'dark']), colors: z.object({ light: Palette, dark: Palette }),
|
|
15
|
+
fonts: z.object({ heading: str, body: str, mono: str }), radius: z.enum(['sharp', 'soft', 'round', 'smooth']),
|
|
16
|
+
});
|
|
17
|
+
export const Section = z.object({ id: str, type: str, variant: str, data: z.record(z.unknown()) });
|
|
18
|
+
export const ContentSummary = z.object({
|
|
19
|
+
id: str, kind: z.enum(['post', 'page']), slug: str, title: str, collection: str.nullable(), locale, url: str,
|
|
20
|
+
excerpt: str, tags: strings, image: str, imageAlt: str, publishedAt: date.nullable(), createdAt: date, updatedAt: date,
|
|
21
|
+
});
|
|
22
|
+
export const Html = z.object({ format: z.literal('sanitized-html'), html: str });
|
|
23
|
+
export const Content = ContentSummary.extend({ content: Html.extend({ dynamicWidgets: z.literal('website-only') }) });
|
|
24
|
+
export const Post = Content.extend({ kind: z.literal('post'), collection: str });
|
|
25
|
+
export const Page = ContentSummary.extend({
|
|
26
|
+
kind: z.literal('page'), body: Html, cover: str, seo: z.object({ title: str, description: str }),
|
|
27
|
+
mode: z.enum(['content', 'sections']), sections: z.array(Section),
|
|
28
|
+
});
|
|
29
|
+
export const Event = CalEvent.pick({
|
|
30
|
+
id: true, slug: true, title: true, excerpt: true, status: true, cancelled: true, featured: true, start: true, end: true,
|
|
31
|
+
allDay: true, timezone: true, rrule: true, exdates: true, location: true, cta: true, tags: true, fields: true,
|
|
32
|
+
}).extend({ url: str, icsUrl: str, repeats: str, image: str, imageAlt: str, updatedAt: date,
|
|
33
|
+
calendars: z.array(z.object({ id: str, slug: str, name: str, color: str })),
|
|
34
|
+
});
|
|
35
|
+
export const Events = z.object({ locale, timezone: str, from: date, to: date, pagination: Pagination,
|
|
36
|
+
occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, event: Event })),
|
|
37
|
+
});
|
|
38
|
+
export const Calendars = z.object({ calendars: z.array(Calendar.pick({ id: true, slug: true, name: true, color: true, description: true, kind: true, visibility: true }).extend({ feedUrl: str })) });
|
|
39
|
+
export const Resources = z.object({ pagination: Pagination, resources: z.array(z.object({
|
|
40
|
+
id: str, slug: str, name: str, kind: str, description: str, capacity: z.number(), amenities: strings, photo: str, timezone: str, url: str,
|
|
41
|
+
links: z.array(z.object({ id: str, slug: str, name: str, description: str, mode: z.enum(['slots', 'day', 'range']), lengths: z.array(z.number()), rules: str,
|
|
42
|
+
askPhone: bool, autoConfirm: bool, url: str, questions: z.array(BookingQuestion.omit({ help: true })),
|
|
43
|
+
})),
|
|
44
|
+
})) });
|
|
45
|
+
export const Availability = z.object({ timezone: str, mode: z.enum(['slots', 'day', 'range']), lengths: z.array(z.number()), autoConfirm: bool,
|
|
46
|
+
days: z.array(z.object({ date: str, slots: z.array(z.object({ start: date, end: date, minutes: z.number() })) })),
|
|
47
|
+
});
|
|
48
|
+
export const BookingResult = z.object({ manageToken: str, booking: Booking.pick({
|
|
49
|
+
id: true, kind: true, status: true, name: true, email: true, phone: true, seats: true, answers: true, source: true,
|
|
50
|
+
}).extend({ start: date, end: date, resourceId: str.nullable(), linkId: str.nullable(), eventId: str.nullable(), createdAt: date }) });
|
|
51
|
+
export const Device = z.object({
|
|
52
|
+
token: str, platform: z.enum(['ios', 'android']), locale: str.nullable(), topics: strings, registeredAt: date, lastSeenAt: date,
|
|
53
|
+
});
|
|
54
|
+
export const DeviceRegistration = z.object({ created: bool, device: Device });
|
|
55
|
+
export const DeviceRemoved = z.object({ token: str, deleted: z.literal(true) });
|
|
56
|
+
export const PushTopics = z.object({ topics: z.array(z.object({ id: str, label: str, kind: z.enum(['general', 'collection', 'calendar']) })) });
|
|
57
|
+
export const Error = z.object({ version: z.literal(1), error: z.object({
|
|
58
|
+
code: z.enum(['invalid_request', 'unauthorized', 'forbidden', 'not_found', 'method_not_allowed', 'conflict', 'gone', 'payload_too_large', 'unsupported_media_type', 'rate_limited', 'upstream_error', 'internal_error']),
|
|
59
|
+
message: str, issues: z.array(z.object({ path: z.array(z.union([str, z.number()])), message: str })).optional(),
|
|
60
|
+
}) });
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ContentRepository } from '../repo/types';
|
|
3
|
+
import { CONTENT } from '../content';
|
|
4
|
+
import { I18N, localeName } from '../i18n';
|
|
5
|
+
import { CALENDAR, bookingsEnabled } from '../calendar/config';
|
|
6
|
+
import { SEARCH } from '../search-config';
|
|
7
|
+
import { siteOrigin } from '../seo';
|
|
8
|
+
import { resolveDesign } from '../theme';
|
|
9
|
+
import { darkPalette } from '../themeTokens';
|
|
10
|
+
import { safeUrl } from './content';
|
|
11
|
+
import { queryParams } from './http';
|
|
12
|
+
|
|
13
|
+
export async function getSite(repo: ContentRepository, url: URL) {
|
|
14
|
+
z.object({}).strict().parse(queryParams(url));
|
|
15
|
+
const [settings, nav, pages] = await Promise.all([repo.getSettings(), repo.listNavItems(), repo.listPages()]);
|
|
16
|
+
const origin = siteOrigin(settings, url);
|
|
17
|
+
const header = nav.filter(n => n.area === 'header').sort((a, b) => a.order - b.order);
|
|
18
|
+
const menu = header.length ? header : [
|
|
19
|
+
{ label: 'Home', url: '/' },
|
|
20
|
+
...pages.filter(p => p.status === 'published' && !p.isHome).map(p => ({ label: p.title, url: `/${p.slug}` })),
|
|
21
|
+
...Object.values(CONTENT.collections).map(c => ({ label: c.plural, url: c.base })),
|
|
22
|
+
{ label: 'Links', url: '/links' },
|
|
23
|
+
];
|
|
24
|
+
return {
|
|
25
|
+
name: settings.siteTitle, description: settings.tagline,
|
|
26
|
+
logo: safeUrl(settings.siteLogo, origin), icon: safeUrl(settings.siteFavicon, origin),
|
|
27
|
+
menu: menu.map(n => ({ label: n.label, href: safeUrl(n.url, origin, true) })),
|
|
28
|
+
languages: { default: I18N.defaultLocale || null, locales: I18N.locales.map(code => ({ code, name: localeName(code, true) })) },
|
|
29
|
+
features: { blog: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, name: c.plural, url: safeUrl(c.base, origin) })), calendar: CALENDAR.enabled, bookings: bookingsEnabled(), search: SEARCH.enabled },
|
|
30
|
+
apiVersion: 1,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export async function getThemeTokens(repo: ContentRepository, url: URL) {
|
|
34
|
+
z.object({}).strict().parse(queryParams(url));
|
|
35
|
+
const { theme, tokens: t } = resolveDesign(await repo.getSettings());
|
|
36
|
+
return {
|
|
37
|
+
id: theme.slug, name: theme.name, scheme: t.scheme,
|
|
38
|
+
colors: { light: { bg: t.bg, ink: t.ink, muted: t.muted, line: t.line, accent: t.accent, panel: t.panel, soft: t.soft }, dark: darkPalette(t) },
|
|
39
|
+
fonts: { heading: t.headingFont, body: t.bodyFont, mono: t.monoFont }, radius: t.radius,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Public device endpoints (/api/v1/devices, /api/v1/push/topics) and the
|
|
2
|
+
// owner-only admin state/actions behind Admin → Mobile app.
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import type { ContentRepository } from '../repo/types';
|
|
5
|
+
import { ApiError, readJson } from '../api';
|
|
6
|
+
import { sha256Hex } from '../api-keys';
|
|
7
|
+
import { CONTENT, collectionUrl } from '../content';
|
|
8
|
+
import { CALENDAR, eventUrl } from '../calendar/config';
|
|
9
|
+
import { eventIsListed } from '../calendar/events';
|
|
10
|
+
import { queryParams } from '../mobile/http';
|
|
11
|
+
import { EXPO_PUSH_BASE, PUSH, requirePush } from './config';
|
|
12
|
+
import { expoOptions } from './expo';
|
|
13
|
+
import { knownTopics, pushTopics, sendNotification, siteOriginOf, type PushRunOptions } from './service';
|
|
14
|
+
import { PushAuto, type PushDevice } from './types';
|
|
15
|
+
|
|
16
|
+
export const pushToken = z.string().max(250).regex(/^Expo(?:nent)?PushToken\[[A-Za-z0-9_-]{8,200}\]$/, 'Use an Expo push token such as ExponentPushToken[…].');
|
|
17
|
+
const topicList = z.array(z.string().min(1).max(200).regex(/^[a-z0-9][a-z0-9:_-]*$/i, 'Use topic ids from GET /api/v1/push/topics.')).max(50);
|
|
18
|
+
const locale = z.string().max(35).regex(/^[A-Za-z]{2,3}(?:[-_][A-Za-z0-9]{1,8})*$/, 'Use a language tag such as en or es-MX.');
|
|
19
|
+
export const deviceInput = z.object({
|
|
20
|
+
token: pushToken,
|
|
21
|
+
platform: z.enum(['ios', 'android']),
|
|
22
|
+
locale: locale.optional(),
|
|
23
|
+
/** Replaces the device's topics. Omit to keep them (new devices start with none). */
|
|
24
|
+
topics: topicList.optional(),
|
|
25
|
+
}).strict();
|
|
26
|
+
export const deviceUpdate = z.object({ locale: locale.optional(), topics: topicList.optional() }).strict();
|
|
27
|
+
|
|
28
|
+
export const deviceJson = (d: PushDevice) => ({
|
|
29
|
+
token: d.token, platform: d.platform, locale: d.locale || null, topics: d.topics,
|
|
30
|
+
registeredAt: new Date(d.createdAt).toISOString(), lastSeenAt: new Date(d.lastSeenAt).toISOString(),
|
|
31
|
+
});
|
|
32
|
+
const deviceId = (token: string) => sha256Hex(`expo-push:${token}`);
|
|
33
|
+
|
|
34
|
+
async function checkTopics(repo: ContentRepository, topics: string[] | undefined) {
|
|
35
|
+
if (!topics) return undefined;
|
|
36
|
+
const known = await knownTopics(repo);
|
|
37
|
+
const unique = [...new Set(topics)];
|
|
38
|
+
const unknown = unique.find((t) => !known.has(t));
|
|
39
|
+
if (unknown) throw new ApiError(400, `Unknown topic: ${unknown}. See GET /api/v1/push/topics.`);
|
|
40
|
+
return unique;
|
|
41
|
+
}
|
|
42
|
+
function tokenParam(raw: string) {
|
|
43
|
+
let value = raw;
|
|
44
|
+
try { if (/%[0-9a-f]{2}/i.test(raw)) value = decodeURIComponent(raw); } catch { /* keep raw */ }
|
|
45
|
+
const parsed = pushToken.safeParse(value);
|
|
46
|
+
if (!parsed.success) throw new ApiError(400, 'Use an Expo push token such as ExponentPushToken[…] in the path.');
|
|
47
|
+
return parsed.data;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function registerDevice(repo: ContentRepository, request: Request) {
|
|
51
|
+
requirePush();
|
|
52
|
+
const body = deviceInput.parse(await readJson(request, 8_000));
|
|
53
|
+
const topics = await checkTopics(repo, body.topics);
|
|
54
|
+
const { device, created } = await repo.push.upsertDevice({ id: await deviceId(body.token), token: body.token, platform: body.platform, locale: body.locale ?? '', topics }, Date.now());
|
|
55
|
+
return { created, device: deviceJson(device) };
|
|
56
|
+
}
|
|
57
|
+
export async function updateDevice(repo: ContentRepository, request: Request, rawToken: string) {
|
|
58
|
+
requirePush();
|
|
59
|
+
const token = tokenParam(rawToken);
|
|
60
|
+
const body = deviceUpdate.parse(await readJson(request, 8_000));
|
|
61
|
+
if (body.locale === undefined && body.topics === undefined) throw new ApiError(400, 'Send topics, locale or both.');
|
|
62
|
+
const device = await repo.push.updateDevice(await deviceId(token), { locale: body.locale, topics: await checkTopics(repo, body.topics) }, Date.now());
|
|
63
|
+
if (!device) throw new ApiError(404, 'This device is not registered. Register it with POST /api/v1/devices.');
|
|
64
|
+
return deviceJson(device);
|
|
65
|
+
}
|
|
66
|
+
export async function unregisterDevice(repo: ContentRepository, rawToken: string) {
|
|
67
|
+
requirePush();
|
|
68
|
+
const token = tokenParam(rawToken);
|
|
69
|
+
// idempotent: an unknown token is already opted out
|
|
70
|
+
await repo.push.deleteDevices([await deviceId(token)]);
|
|
71
|
+
return { token, deleted: true };
|
|
72
|
+
}
|
|
73
|
+
export async function listPushTopics(repo: ContentRepository, url: URL) {
|
|
74
|
+
requirePush();
|
|
75
|
+
z.object({}).strict().parse(queryParams(url));
|
|
76
|
+
return { topics: await pushTopics(repo) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---- admin -------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
export interface PushLinkTarget { ref: string; label: string; url: string; group: 'Pages' | 'Posts' | 'Events' }
|
|
82
|
+
async function linkTargets(repo: ContentRepository): Promise<PushLinkTarget[]> {
|
|
83
|
+
const [pages, posts, events, calendars] = await Promise.all([
|
|
84
|
+
repo.listPages(), repo.listPublishedPosts(),
|
|
85
|
+
CALENDAR.enabled ? repo.listPublishedEvents() : Promise.resolve([]),
|
|
86
|
+
CALENDAR.enabled ? repo.listCalendars() : Promise.resolve([]),
|
|
87
|
+
]);
|
|
88
|
+
const cals = new Map(calendars.map((c) => [c.id, c]));
|
|
89
|
+
return [
|
|
90
|
+
...pages.filter((p) => p.status === 'published').map((p): PushLinkTarget => ({ ref: `page:${p.id}`, label: p.title || p.slug, url: p.isHome ? '/' : `/${p.slug}`, group: 'Pages' })),
|
|
91
|
+
...posts.filter((p) => CONTENT.collections[p.collection]).slice(0, 200).map((p): PushLinkTarget => ({ ref: `post:${p.id}`, label: p.title, url: collectionUrl(p.collection, p.slug), group: 'Posts' })),
|
|
92
|
+
...events.filter((e) => eventIsListed(e, cals)).slice(0, 200).map((e): PushLinkTarget => ({ ref: `event:${e.id}`, label: e.title, url: eventUrl(e.slug), group: 'Events' })),
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function adminPushState(repo: ContentRepository, url: URL, locals?: unknown) {
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
const settings = await repo.getSettings();
|
|
99
|
+
const expo = expoOptions(locals);
|
|
100
|
+
const [stats, topics, calendars, links, log] = await Promise.all([
|
|
101
|
+
repo.push.stats(now), pushTopics(repo),
|
|
102
|
+
CALENDAR.enabled ? repo.listCalendars() : Promise.resolve([]),
|
|
103
|
+
linkTargets(repo), repo.push.listNotifications(50),
|
|
104
|
+
]);
|
|
105
|
+
return {
|
|
106
|
+
enabled: PUSH.enabled, retentionDays: PUSH.retentionDays,
|
|
107
|
+
site: { name: settings.siteTitle, icon: settings.siteFavicon, origin: siteOriginOf(settings, url.origin) },
|
|
108
|
+
stats, topics, auto: settings.push.auto, links, log,
|
|
109
|
+
collections: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, label: c.plural })),
|
|
110
|
+
calendarEnabled: CALENDAR.enabled,
|
|
111
|
+
calendars: calendars.filter((c) => c.visibility === 'public').map((c) => ({ id: c.id, slug: c.slug, name: c.name, color: c.color })),
|
|
112
|
+
expo: { accessToken: Boolean(expo.accessToken), customEndpoint: expo.base !== EXPO_PUSH_BASE },
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
export type AdminPushState = Awaited<ReturnType<typeof adminPushState>>;
|
|
116
|
+
|
|
117
|
+
const audience = z.array(z.string().min(1).max(200)).min(1, 'Choose who gets it.').max(50);
|
|
118
|
+
const link = z.string().trim().max(2000).refine((v) => !v || (/^\/(?!\/)/.test(v) || /^https?:\/\//i.test(v)), 'Use a site path such as /events or a full https:// address.');
|
|
119
|
+
export const AdminPushInput = z.discriminatedUnion('action', [
|
|
120
|
+
z.object({ action: z.literal('count'), audience }).strict(),
|
|
121
|
+
z.object({ action: z.literal('send'), title: z.string().trim().min(1, 'Add a title.').max(80), body: z.string().trim().max(240).default(''), url: link.default(''), audience }).strict(),
|
|
122
|
+
z.object({ action: z.literal('saveAuto'), auto: PushAuto }).strict(),
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
async function checkAudience(repo: ContentRepository, list: string[]) {
|
|
126
|
+
if (list.includes('*')) return ['*'];
|
|
127
|
+
const known = await knownTopics(repo);
|
|
128
|
+
const bad = list.find((t) => !known.has(t));
|
|
129
|
+
if (bad) throw new ApiError(400, `Unknown topic: ${bad}.`);
|
|
130
|
+
return [...new Set(list)];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function adminPushAction(repo: ContentRepository, input: z.infer<typeof AdminPushInput>, ctx: { url: URL; locals?: unknown; author: string; run?: PushRunOptions }) {
|
|
134
|
+
switch (input.action) {
|
|
135
|
+
case 'count':
|
|
136
|
+
return { recipients: await repo.push.countAudience(await checkAudience(repo, input.audience)) };
|
|
137
|
+
case 'send': {
|
|
138
|
+
const settings = await repo.getSettings();
|
|
139
|
+
const notification = await sendNotification(repo, {
|
|
140
|
+
title: input.title, body: input.body, url: input.url, audience: await checkAudience(repo, input.audience),
|
|
141
|
+
source: 'manual', author: ctx.author,
|
|
142
|
+
}, { locals: ctx.locals, origin: siteOriginOf(settings, ctx.url.origin), ...ctx.run });
|
|
143
|
+
return { notification };
|
|
144
|
+
}
|
|
145
|
+
case 'saveAuto': {
|
|
146
|
+
const collections = new Set(Object.keys(CONTENT.collections));
|
|
147
|
+
const calendars = new Set((CALENDAR.enabled ? await repo.listCalendars() : []).filter((c) => c.visibility === 'public').map((c) => c.id));
|
|
148
|
+
const cals = (ids: string[]) => [...new Set(ids)].filter((id) => id === '*' || calendars.has(id));
|
|
149
|
+
const auto = {
|
|
150
|
+
newPost: { collections: [...new Set(input.auto.newPost.collections)].filter((k) => collections.has(k)) },
|
|
151
|
+
newEvent: { calendars: CALENDAR.enabled ? cals(input.auto.newEvent.calendars) : [] },
|
|
152
|
+
startingSoon: { calendars: CALENDAR.enabled ? cals(input.auto.startingSoon.calendars) : [], minutes: input.auto.startingSoon.minutes },
|
|
153
|
+
};
|
|
154
|
+
await repo.updateSettings({ push: { auto } });
|
|
155
|
+
return { auto };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Push notifications config, set per project via the integration:
|
|
2
|
+
// acms({ mobile: { push: true } }) or { push: { enabled: true, retentionDays: 270 } }
|
|
3
|
+
// Off by default. When off, the device/topic endpoints and the admin Mobile
|
|
4
|
+
// app page are not registered at all (like other disabled modules), and the
|
|
5
|
+
// scheduler just marks any queued automatic notifications as skipped.
|
|
6
|
+
import { userConfig } from 'virtual:acms-config';
|
|
7
|
+
import { ApiError } from '../api';
|
|
8
|
+
|
|
9
|
+
// mutable so tests can exercise the disabled paths
|
|
10
|
+
export const PUSH = {
|
|
11
|
+
enabled: userConfig.mobile?.push?.enabled === true,
|
|
12
|
+
retentionDays: userConfig.mobile?.push?.retentionDays ?? 270,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function requirePush() {
|
|
16
|
+
if (!PUSH.enabled) throw new ApiError(404, 'This feature is disabled.');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Expo's push service. ACMS_EXPO_PUSH_URL overrides it (a local stub in tests/QA). */
|
|
20
|
+
export const EXPO_PUSH_BASE = 'https://exp.host/--/api/v2/push';
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Expo push service client: https://docs.expo.dev/push-notifications/sending-notifications/
|
|
2
|
+
// send: POST <base>/send with up to 100 messages → one ticket per message, in order.
|
|
3
|
+
// receipts: POST <base>/getReceipts with up to 1000 ticket ids, some minutes later.
|
|
4
|
+
// An Expo access token (EXPO_ACCESS_TOKEN) is only needed when "enhanced push
|
|
5
|
+
// security" is on for the Expo project. ACMS_EXPO_PUSH_URL points the sender at
|
|
6
|
+
// a stub for tests and local QA.
|
|
7
|
+
import { runtimeValue } from '../email/config';
|
|
8
|
+
import { EXPO_PUSH_BASE } from './config';
|
|
9
|
+
|
|
10
|
+
export interface ExpoMessage {
|
|
11
|
+
to: string; title: string; body: string; data: Record<string, unknown>;
|
|
12
|
+
sound?: 'default' | null; priority?: 'default' | 'normal' | 'high'; channelId?: string;
|
|
13
|
+
}
|
|
14
|
+
export type ExpoTicket = { status: 'ok'; id: string } | { status: 'error'; message: string; details?: { error?: string } };
|
|
15
|
+
export type ExpoReceipt = { status: 'ok' } | { status: 'error'; message: string; details?: { error?: string } };
|
|
16
|
+
export type Fetcher = (url: string, init: RequestInit) => Promise<Response>;
|
|
17
|
+
export interface ExpoOptions { base: string; accessToken: string; fetcher?: Fetcher; retryDelayMs?: number }
|
|
18
|
+
|
|
19
|
+
export const SEND_BATCH = 100;
|
|
20
|
+
export const RECEIPT_BATCH = 1000;
|
|
21
|
+
|
|
22
|
+
export function expoOptions(locals?: unknown, extra: Partial<ExpoOptions> = {}): ExpoOptions {
|
|
23
|
+
const base = (runtimeValue('ACMS_EXPO_PUSH_URL', locals) || EXPO_PUSH_BASE).replace(/\/+$/, '');
|
|
24
|
+
return { base, accessToken: runtimeValue('EXPO_ACCESS_TOKEN', locals), ...extra };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function post(opts: ExpoOptions, path: string, body: unknown): Promise<{ ok: true; json: any } | { ok: false; message: string }> {
|
|
28
|
+
const fetcher: Fetcher = opts.fetcher ?? ((url, init) => fetch(url, init));
|
|
29
|
+
const headers: Record<string, string> = { 'content-type': 'application/json', accept: 'application/json', 'user-agent': 'acms-push/1.0' };
|
|
30
|
+
if (opts.accessToken) headers.authorization = `Bearer ${opts.accessToken}`;
|
|
31
|
+
let last = 'Push service unreachable.';
|
|
32
|
+
// one retry for rate limits, server errors and network failures
|
|
33
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
34
|
+
if (attempt) await new Promise((r) => setTimeout(r, opts.retryDelayMs ?? 1500));
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetcher(`${opts.base}${path}`, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(15_000) });
|
|
37
|
+
const json = await res.json().catch(() => null);
|
|
38
|
+
if (res.ok && json && typeof json === 'object') return { ok: true, json };
|
|
39
|
+
last = json?.errors?.[0]?.message ?? `Push service answered ${res.status}.`;
|
|
40
|
+
if (res.status !== 429 && res.status < 500) break;
|
|
41
|
+
} catch (e) {
|
|
42
|
+
last = e instanceof Error && e.name === 'TimeoutError' ? 'Push service timed out.' : 'Push service unreachable.';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { ok: false, message: last };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One ticket per message, aligned by index. A failed batch yields error tickets for each of its messages. */
|
|
49
|
+
export async function sendMessages(messages: ExpoMessage[], opts: ExpoOptions): Promise<ExpoTicket[]> {
|
|
50
|
+
const tickets: ExpoTicket[] = [];
|
|
51
|
+
for (let i = 0; i < messages.length; i += SEND_BATCH) {
|
|
52
|
+
const batch = messages.slice(i, i + SEND_BATCH);
|
|
53
|
+
const res = await post(opts, '/send', batch);
|
|
54
|
+
const data = res.ok && Array.isArray(res.json.data) ? res.json.data : null;
|
|
55
|
+
for (let j = 0; j < batch.length; j++) {
|
|
56
|
+
const t = data?.[j];
|
|
57
|
+
if (t?.status === 'ok' && typeof t.id === 'string') tickets.push({ status: 'ok', id: t.id });
|
|
58
|
+
else if (t?.status === 'error') tickets.push({ status: 'error', message: String(t.message ?? 'Rejected.'), details: t.details });
|
|
59
|
+
else tickets.push({ status: 'error', message: res.ok ? 'No ticket returned.' : res.message, details: { error: 'RequestFailed' } });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return tickets;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Receipts by ticket id. Ids Expo doesn't know yet (or anymore) are simply missing. */
|
|
66
|
+
export async function fetchReceipts(ids: string[], opts: ExpoOptions): Promise<Record<string, ExpoReceipt>> {
|
|
67
|
+
const out: Record<string, ExpoReceipt> = {};
|
|
68
|
+
for (let i = 0; i < ids.length; i += RECEIPT_BATCH) {
|
|
69
|
+
const res = await post(opts, '/getReceipts', { ids: ids.slice(i, i + RECEIPT_BATCH) });
|
|
70
|
+
if (!res.ok) throw new Error(res.message);
|
|
71
|
+
Object.assign(out, res.json.data ?? {});
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const isGone = (x: { status: string; details?: { error?: string } }) => x.status === 'error' && x.details?.error === 'DeviceNotRegistered';
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// v37: push notifications. Devices hold the Expo push token (needed to send)
|
|
2
|
+
// and nothing personal. push_queue doubles as the "already announced" ledger:
|
|
3
|
+
// one row per post/event ever published (key post:<id> / event:<id>) and per
|
|
4
|
+
// "starting soon" occurrence (soon:<id>:<start ms>), so automatic notices go
|
|
5
|
+
// out on the FIRST publish only, never for edits or re-publishing.
|
|
6
|
+
//
|
|
7
|
+
// Rows are written by triggers, so every publish path (editor, scheduled
|
|
8
|
+
// publishing, API, agent) is covered without touching each caller. Imports and
|
|
9
|
+
// restores run with webhook_control.suppressed=1 and are recorded as skipped:
|
|
10
|
+
// migrating old posts is not news. The scheduler decides whether a pending row
|
|
11
|
+
// is actually sent (push enabled, matching automatic rule, still published).
|
|
12
|
+
const now = "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
|
|
13
|
+
const status = "CASE WHEN (SELECT suppressed FROM webhook_control WHERE id=1)=1 THEN 'skipped' ELSE 'pending' END";
|
|
14
|
+
function enqueue(kind: 'post' | 'event') {
|
|
15
|
+
// Plain INSERT … SELECT … WHERE NOT EXISTS: no conflict clause, so an outer
|
|
16
|
+
// INSERT OR REPLACE (restore) can't turn it into a replace of a sent row.
|
|
17
|
+
return `INSERT INTO push_queue (key,kind,doc_id,occurrence,status,notification_id,created_at,updated_at)
|
|
18
|
+
SELECT '${kind}:'||NEW.id,'${kind}',NEW.id,NULL,${status},NULL,${now},${now}
|
|
19
|
+
WHERE NOT EXISTS (SELECT 1 FROM push_queue WHERE key='${kind}:'||NEW.id);`;
|
|
20
|
+
}
|
|
21
|
+
function triggers(kind: 'post' | 'event') {
|
|
22
|
+
const table = `${kind}s`;
|
|
23
|
+
return [
|
|
24
|
+
`CREATE TRIGGER push_${kind}_insert AFTER INSERT ON ${table} WHEN NEW.status='published' BEGIN ${enqueue(kind)} END`,
|
|
25
|
+
`CREATE TRIGGER push_${kind}_publish AFTER UPDATE OF status ON ${table}
|
|
26
|
+
WHEN NEW.status='published' AND OLD.status IS NOT 'published' BEGIN ${enqueue(kind)} END`,
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const PUSH_MIGRATION = { version: 37, statements: [
|
|
31
|
+
`ALTER TABLE settings ADD COLUMN push TEXT NOT NULL DEFAULT '{}'`,
|
|
32
|
+
`CREATE TABLE push_devices (
|
|
33
|
+
id TEXT PRIMARY KEY, token TEXT NOT NULL UNIQUE, platform TEXT NOT NULL,
|
|
34
|
+
locale TEXT NOT NULL DEFAULT '', topics TEXT NOT NULL DEFAULT '[]',
|
|
35
|
+
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL
|
|
36
|
+
)`,
|
|
37
|
+
`CREATE INDEX push_devices_seen ON push_devices (last_seen_at)`,
|
|
38
|
+
`CREATE TABLE push_notifications (
|
|
39
|
+
id TEXT PRIMARY KEY, title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', url TEXT NOT NULL DEFAULT '',
|
|
40
|
+
topics TEXT NOT NULL DEFAULT '[]', source TEXT NOT NULL DEFAULT 'manual', source_key TEXT NOT NULL DEFAULT '',
|
|
41
|
+
status TEXT NOT NULL DEFAULT 'sending', recipients INTEGER NOT NULL DEFAULT 0, sent INTEGER NOT NULL DEFAULT 0,
|
|
42
|
+
ok INTEGER NOT NULL DEFAULT 0, failed INTEGER NOT NULL DEFAULT 0, removed INTEGER NOT NULL DEFAULT 0,
|
|
43
|
+
error TEXT NOT NULL DEFAULT '', author TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, finished_at INTEGER
|
|
44
|
+
)`,
|
|
45
|
+
`CREATE INDEX push_notifications_created ON push_notifications (created_at)`,
|
|
46
|
+
// Expo push tickets awaiting a receipt check (receipts expire after 24h)
|
|
47
|
+
`CREATE TABLE push_tickets (id TEXT PRIMARY KEY, notification_id TEXT NOT NULL, device_id TEXT NOT NULL, created_at INTEGER NOT NULL)`,
|
|
48
|
+
`CREATE INDEX push_tickets_created ON push_tickets (created_at)`,
|
|
49
|
+
`CREATE TABLE push_queue (
|
|
50
|
+
key TEXT PRIMARY KEY, kind TEXT NOT NULL, doc_id TEXT NOT NULL, occurrence INTEGER,
|
|
51
|
+
status TEXT NOT NULL, notification_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
52
|
+
)`,
|
|
53
|
+
`CREATE INDEX push_queue_status ON push_queue (status, created_at)`,
|
|
54
|
+
// everything already live counts as announced
|
|
55
|
+
`INSERT INTO push_queue (key,kind,doc_id,occurrence,status,notification_id,created_at,updated_at)
|
|
56
|
+
SELECT 'post:'||id,'post',id,NULL,'seen',NULL,${now},${now} FROM posts WHERE status='published'`,
|
|
57
|
+
`INSERT INTO push_queue (key,kind,doc_id,occurrence,status,notification_id,created_at,updated_at)
|
|
58
|
+
SELECT 'event:'||id,'event',id,NULL,'seen',NULL,${now},${now} FROM events WHERE status='published'`,
|
|
59
|
+
...triggers('post'), ...triggers('event'),
|
|
60
|
+
] };
|