diff --git a/.cursor/rules/function-naming.mdc b/.cursor/rules/function-naming.mdc new file mode 100644 index 00000000..b969e224 --- /dev/null +++ b/.cursor/rules/function-naming.mdc @@ -0,0 +1,53 @@ +--- +description: Function and method naming conventions for backend, frontend, and shared code +globs: src/**/*.{ts,vue} +alwaysApply: false +--- + +# Function Naming + +Follow [docs/function-naming.md](../../docs/function-naming.md) for full detail. +Summary for agents writing or reviewing code: + +## Principles + +- camelCase for all functions/methods +- Class/module name supplies the noun; methods use verbs +- Prefix signals intent; do not mass-rename legacy code + +## Shared + +| Prefix | Use | +|---|---| +| `isX` / `hasX` / `canX` | Boolean predicates | +| `toX` | Pure converters | +| `getX` / `fetchX` / `loadX` / `findX` | Retrieval; omit `get`/`fetch` when bare domain name is clear (`blockKind`, `previousDevotionChapterBlocks`) | +| `parseX` / `normalizeX` / `normalizedX` / `formatX` | Parse; `normalizedX` = pure return transform; `normalizeX` = in-place/async processing | +| `buildX` | Assemble complex objects | +| `XFromY` / `XForY` | Derive from source / scoped to context | +| `*BlockedMessages` / `blockingX` | Validation blockers | +| `compareX` / `sortX` | Sort helpers | + +## Frontend + +- **Pinia:** `setX`, `clearX`, `addX`, `removeX`, `toggleX`; boolean state as `isX`/`hasX` computed refs +- **Composables:** export `useX()` matching `use-*.ts` filename +- **Template handlers:** `onX` (`onFilter`, `onDragStart`, `onRemove`) — not `handleX` +- **Page commands:** bare verbs (`saveStory`, `addPage`, `publishDraft`) +- **Form payloads:** `getPayload()` or `buildXPayload()` +- **Avoid:** `setIsDirty` → prefer `setDirty` or `markDirty` + +## Backend + +- **Services:** bare CRUD on `*Service` classes; `listX`, `fetchX`, shared derivation patterns +- **Controllers:** REST (`index`, `store`, `destroy`) + domain verbs (`publish`, `toggleBookmark`); prefer `destroy` over `delete` for new resources +- **Validators:** `*Validator.validate()` or camelCase vine exports +- **Middleware:** `handle(ctx, next)` +- **Models:** getters for derived shape; instance methods for mutations + +## Legacy (do not copy in new code) + +- `handle*` event handlers → use `onX` +- `subscribed()` → `isSubscribed()` +- `Analytics` class name (not `*Service`) +- Stories `delete` action (prefer `destroy`) diff --git a/.cursor/rules/vue-components.mdc b/.cursor/rules/vue-components.mdc index a49aa50d..2977ce20 100644 --- a/.cursor/rules/vue-components.mdc +++ b/.cursor/rules/vue-components.mdc @@ -58,6 +58,23 @@ const emit = defineEmits<{ }>(); ``` +### Event Handlers + +Follow [function-naming.md](../../docs/function-naming.md) for full conventions. + +- Use **`onX`** for handlers bound in the template (`@click`, `@keydown`, drag/drop, child emits) +- Use **bare domain verbs** for page-level commands not tied to a single DOM event (`saveStory`, `addPage`) +- Do not introduce new **`handleX`** handlers; rename to `onX` when editing legacy code + +```typescript +// template-bound +const onFilter = (query: string) => { /* ... */ }; +const onDragStart = () => { /* ... */ }; + +// page command +const saveStory = () => router.post(/* ... */); +``` + ## State Management ### Store Usage diff --git a/docs/function-naming.md b/docs/function-naming.md new file mode 100644 index 00000000..8b635a9f --- /dev/null +++ b/docs/function-naming.md @@ -0,0 +1,357 @@ +# Function Naming Conventions + +This project follows a consistent set of naming patterns for functions and +methods across the backend (`src/backend`), frontend (`src/frontend`), and +shared code (`src/shared`). + +## Principles + +- **camelCase** for all functions and methods. File names may be kebab-case or + snake_case. +- **Context supplies the noun.** Class or module name carries the domain; + methods use verbs (`ResourceService.delete`, not `deleteResource`). +- **Prefix signals intent.** Use prefixes for booleans, conversions, retrieval, + derivation, and mutations so callers can read intent from the name. +- **Prefer consistency over perfection.** Document legacy exceptions; do not + mass-rename existing code. + +--- + +## Quick reference + +| Prefix / pattern | Meaning | Example | +|---|---|---| +| `isX` | Boolean state or type check | `isValidLanguageTag`, `isPopulated` | +| `hasX` | Boolean presence or ownership | `hasNoContent`, `hasFeedback` | +| `canX` | Boolean capability or permission | `canPublishStory` | +| `toX` | Pure type converter | `toResourceItem`, `toRelativeTime` | +| `getX` | General accessor (sync or async) | `getField`, `getDraftBundle` | +| `fetchX` | Async external I/O | `fetchMetricsForAllTime`, `fetchVideoTitle` | +| `loadX` | Async fetch that mutates UI/store state | `loadBibleTranslations` | +| `findX` | Lookup returning optional result | `findRegionCode` | +| `listX` / `listForY` | Collection retrieval | `listForLocale`, `listIndexItems` | +| `parseX` | Parse string/unknown into typed value | `parseReference`, `parseIsoDateForDisplay` | +| `normalizedX` | Pure sync function returning canonical form | `normalizedDevotionDraftBundle`, `normalizedBlocks` | +| `normalizeX` | In-place or async processing | `normalizeDateForStorage` (legacy pure returns prefer `normalizedX`) | +| `formatX` | Display-oriented string | `formatDate` | +| `buildX` | Assemble complex object from parts | `buildIndex`, `buildAppUpdatePayload` | +| `XFromY` | Derive X from source Y | `specFrom`, `paramsFromPath` | +| `XForY` | Result scoped to context Y | `usageCountForLocale`, `templatesForEditDisplay` | +| `*BlockedMessages` | Validation reasons blocking an action | `storyMetadataBlockedMessages` | +| `blockingX` | Service method returning blockers | `blockingPublishMessages` | +| `compareX` / `sortX` | Sort comparator or sort helper | `compareLanguagesByDisplayName` | +| `setX` / `clearX` | Pinia store replace / reset | `setField`, `clearErrors` | +| `addX` / `removeX` | Pinia collection mutate | `addListItem`, `removeListItem` | +| `toggleX` | Pinia or domain toggle | `toggleRemovedIndex`, `toggleBookmark` | +| `useX` | Vue composable or Pinia store | `useSidebarNav`, `useDraftsStore` | +| `onX` | Template-bound event handler | `onFilter`, `onDragStart`, `onRemove` | +| bare verb | Page command or service CRUD | `saveStory`, `create`, `destroy` | + +--- + +## Shared patterns + +These apply in `src/shared`, `src/backend`, and `src/frontend`. + +### `isX` / `hasX` / `canX` — boolean predicates + +Used for functions that return a boolean. + +- **`isX`** — state, type, or condition (`isValidLanguageTag`, `isLucideIcon`) +- **`hasX`** — presence or ownership (`hasNoContent`, `hasTranslationContent`) +- **`canX`** — capability or permission (`canPublishStory`, `canPublishStoryMetadata`) + +```ts +isValidLanguageTag(tag: string): boolean +hasNoContent(id: number): Promise +canPublishStory(publishedCount: number, chapterLimit: number): boolean +``` + +### `toX` — pure converters + +Map one type to another with no side effects. + +```ts +toResourceItem(model: Resource): ResourceItem +toResourceIndexItem(model: Resource): ResourceIndexItem +toRelativeTime(date: string): string +``` + +### `getX` / `fetchX` / `loadX` / `findX` — retrieval + +- **`getX`** — general accessor; sync or async, local or cheap (`getField`, `getDraftBundle`, `getBibleVersions`) +- **`fetchX`** — async external I/O such as network or third-party APIs (`fetchMetricsForAllTime`, `fetchVideoTitle`) +- **`loadX`** — async fetch that also updates component or store state (`loadBibleTranslations` in `bible-translations-modal.vue`) +- **`findX`** — lookup that may return `undefined` (`findRegionCode`) + +**When to omit `get` / `fetch`:** use a bare domain name when the function name already reads as a derivation and context is clear — e.g. `blockKind(block)` not `getBlockKind(block)`, `previousDevotionChapterBlocks(specifier, loadBundle)` not `fetchPreviousDevotionChapterBlocks`. Keep `getX` / `fetchX` for generic accessors (`getField`, `fetchMetricsForAllTime`) or when the prefix adds disambiguation. + +```ts +blockKind(block) // sync field extraction +previousDevotionChapterBlocks(specifier, loadBundle) // async, name describes result +getBookmarks(storyId: number): Bookmark[] +fetchMetricsForAllTime(): Promise +loadBibleTranslations(): Promise +findRegionCode(searchTerm: string): string | null +``` + +### `XFromY` — derive X from a source Y + +Build a result from a specific input source. + +```ts +storyFromPath(ctx: HttpContext): Promise +specFrom(story: Story): StorySpec +fieldsFromTemplate(id: string): FieldSpec[] +getFreshBundleFrom(model: Resource): ResourceBundle +paramsFromPath(path: string): StoryParams +``` + +### `XForY` — compute X scoped to context Y + +Result only makes sense relative to some context. + +```ts +usageCountForLocale(locale: string): Promise> +storyUsageFor(resourceId: string, locale: string): Promise +templatesForEditDisplay(): BundleTemplate[] +updatePayloadFor(storyId: number, locale: string): Promise +parentPathForBack(path: string): string +``` + +### `parseX` / `normalizeX` / `normalizedX` / `formatX` — parsing and display + +- **`parseX`** — string or unknown input to typed value (`parseReference`, `parseIsoDateForDisplay`, `parseLanguageSpecification`) +- **`normalizedX`** — pure sync function **returning** a canonical copy (`normalizedDevotionDraftBundle`, `normalizedBlocks`) +- **`normalizeX`** — processing action; especially in-place mutation or async work where `await normalizeX(...)` reads naturally (`normalizeDateForStorage` is legacy — prefer `normalizedX` for new pure transforms) +- **`formatX`** — human-readable display string (`formatDate`, `formatResourceDate`) + +```ts +const bundle = normalizedDevotionDraftBundle(draft.bundle, draft.number); +const blocks = normalizedBlocks(rawBlocks); +await normalizeRecordsInPlace(records); // mutating processor — verb form +``` + +### `buildX` — assembly + +Compose a complex object from parts. + +```ts +buildIndex(items: IndexItem[]): Index +buildAppUpdatePayload(reasons: string[]): AppUpdatePayload +getPayload(): StoryUpdatePayload // standard name in form components +``` + +### `*BlockedMessages` / `blockingX` — validation blockers + +- **`*BlockedMessages`** — returns `string[]` of reasons an action is blocked (`storyMetadataBlockedMessages`, `storyTypeBlockedMessages`) +- **`blockingX`** — async service methods returning blockers before a mutation (`blockingPublishMessages`, `blockingDeleteMessages`) + +### `compareX` / `sortX` — sorting + +- **`compareX`** — comparator for `.sort()` (`compareLanguagesByDisplayName`) +- **`sortX`** — sort helper wrapping a comparator (`sortLanguagesByDisplayName`) + +Prefer one canonical helper in `src/frontend/shared/helpers.ts` rather than duplicating sort logic in composables. + +### `useX` — Vue composables and Pinia stores + +Standard Vue convention. One exported `useX()` per composable file, matching +the `use-*.ts` filename. + +```ts +useDraftsStore() +useSidebarNav() +``` + +Private helpers inside composables stay unexported (`isEnglishLanguage`, `compareLanguages`). + +--- + +## Frontend conventions + +### Pinia stores (`src/frontend/store/`) + +Stores use the setup style: actions and state are returned from the store +function. + +| Action type | Pattern | Examples | +|---|---|---| +| Replace state | `setX` | `setField`, `setModel`, `setFromProps`, `setLanguage` | +| Reset | `clearX` | `clearErrors`, `clearListState` | +| Collection mutate | `addX` / `removeX` | `addListItem`, `removeListItem`, `addDivider` | +| Toggle | `toggleX` | `toggleRemovedIndex` | +| Read accessor | `getX` or bare `computed` ref | `getField`, `getListToggles`, `locale`, `isRtl` | +| Boolean state | `isX` / `hasX` as `computed` | `isPopulated`, `hasFeedback`, `isBookmarked` | + +Avoid doubling the `is` prefix: prefer `setDirty(value)` or `markDirty()` over +`setIsDirty(value)`. + +```ts +// useModelStore — src/frontend/store/model.ts +setField(path: string, value: unknown): void +getField(path: string, defaultValue?: unknown): unknown +addListItem(path: string): void +removeListItem(path: string, index: number): void +isPopulated: ComputedRef +``` + +### Event handlers + +Use **`onX` for handlers bound in the template** — clicks, keyboard events, drag +and drop, file uploads, and child emit callbacks. + +```vue + + + + +``` + +```ts +const onFilter = (query: string) => { /* ... */ }; // stream-index.vue +const onDragStart = () => { /* ... */ }; // pages-index.vue +const onAttached = async (data: AttachmentModel) => { /* ... */ }; +``` + +Use **bare domain verbs** for page-level commands not tied to a single DOM +event — navigation, saves, publishes, and CRUD orchestration. + +```ts +const saveStory = () => router.post(/* ... */); // story-edit.vue +const deleteStory = () => router.delete(/* ... */); +const addPage = () => { /* ... */ }; // pages-index.vue +const publishDraft = () => { /* ... */ }; +``` + +**`handleX` is legacy — do not use in new code.** Existing uses (settings +modals, `pill-field.vue` keyboard handlers) remain; rename to `onX` when +touching a file. + +```ts +// legacy — rename to onClose / onEnterKey when editing +const handleClose = () => { /* ... */ }; +const handleEnterKey = (event: KeyboardEvent) => { /* ... */ }; +``` + +### Payload builders + +- **`getPayload()`** — standard name for form submit payloads (`story-edit.vue`, `resources-edit.vue`, `pages-index.vue`) +- **`buildXPayload()`** — when multiple payload shapes exist (`buildAppUpdatePayload` in `settings-index.vue`) + +### Callback parameters + +Match library or child-component convention for callback props and constructor +options: `onProgress`, `onError`, `onSuccess` (attachment services). + +### Attachment service classes + +Class methods follow the same bare-verb pattern as backend services, since the +class name supplies the noun. + +```ts +S3Service.upload(file, options): Promise +BunnyService.fetchVideoTitle(url): Promise +``` + +--- + +## Backend conventions + +### Service classes (`src/backend/services/`) + +Service classes use bare verbs for primary CRUD operations because the class +name already supplies the noun. Class names end in `Service` (except `Analytics`, +which is a legacy exception). + +```ts +class ResourceService { + create(locale: string, payload: ResourcePayload, userId: number): Promise + update(id: string, payload: ResourcePayload, userId: number): Promise + delete(id: string): Promise + listForLocale(locale: string): Promise + hydrate(model: Resource): Promise + toggleBookmark(userId: number, storyId: number): Promise +} +``` + +Additional service patterns: + +- **`listX` / `listForY`** — collections (`listIndexItems`, `galleryIndex`) +- **`getX`** — single-item or computed retrieval (`getPageItems`, `getUserBookmarks`) +- **`fetchX`** — external API calls (`fetchMetricsForBothPeriods`) +- **`fillX` / `toggleX` / `hydrate`** — domain-specific mutations (`fillMissing`, `toggleBookmark`) +- **`toX` / `XFromY` / `XForY`** — same shared patterns as above + +Module-level mappers in files like `resource_mapper.ts` export pure functions: +`toResourceItem`, `toResourceIndexItem`, `extractResourceContent`. + +### Controllers (`src/backend/stubs/controllers/`) + +Controllers shipped as stubs follow AdonisJS REST naming plus domain actions. + +**REST baseline:** + +| Action | Purpose | +|---|---| +| `index` | List page | +| `create` | New form | +| `edit` | Edit form | +| `store` | POST create | +| `update` | PUT/PATCH update | +| `destroy` | DELETE | + +**Domain actions** use verbs matching the user action: `publish`, `preview`, +`toggleBookmark`, `exportAudience`, `translateBulk`, `sort`. + +**Private helpers** describe the work: `applyStoryUpdate`, `sanitizeReturnPath`, +`createFirstUser`, `isFreshInstall`. + +**Legacy inconsistency:** stories and drafts controllers use `delete`; other +resources use `destroy`. Prefer **`destroy`** for new resources (Adonis +convention). + +### Validators (`src/backend/validators/`) + +Two export styles coexist: + +- **Class validators** — `*Validator` class with a `validate()` method (`StoryUpdateValidator`, `ResourceValidator`) +- **Vine exports** — camelCase factory exports (`createUserValidator`, `updateUserValidator`) +- **Custom Vine rules** — domain noun (`audio`, `video`, `dateRange`) + +Message exports: `*ValidationMessages`, `*ErrorMessages`. + +### Middleware (`src/backend/middleware/`) + +Always `async handle(ctx, next)` — Adonis standard. Inertia middleware also +exposes `share()` for shared page props. + +### Models (`src/backend/models/`) + +Logic stays in services; models are mostly data plus getters and small instance +methods. No Lucid hooks or query scopes in this project. + +- **Getters** for derived or serialized shape: `isAdmin`, `initials`, `forApi`, `meta`, `isLink` +- **Instance methods** for mutations or checks: `updateBundle`, `sortItems`, `isAllowed`, `freshValue` + +--- + +## Legacy and exceptions + +Do not mass-rename; apply preferred patterns in new code and when editing +existing files. + +| Issue | Existing example | Preferred going forward | +|---|---|---| +| `get` for computed status | `getInvitationStatus()` | `invitationStatus()` or keep if widely used | +| Boolean without prefix | `subscribed()` in `use-sidebar-nav.ts` | `isSubscribed()` | +| Duplicate sort helpers | `compareLanguages` vs `compareLanguagesByDisplayName` | One canonical helper in `helpers.ts` | +| `handle*` event handlers | `handleClose`, `handleEnterKey` | `onClose`, `onEnterKey` | +| Wrapper name mismatch | `toIndexItem` vs `toResourceIndexItem` | Align wrapper with underlying export when touched | +| `delete` vs `destroy` | Stories/Drafts vs Resources/Users | `destroy` for new resources | +| `Analytics` vs `*Service` | `Analytics` class | `*Service` for new service classes | +| `setIsX` setter | `setIsDirty` | `setDirty` or `markDirty` | + +There is no ESLint naming enforcement today; this document is the source of +truth. See also [function-naming.mdc](../.cursor/rules/function-naming.mdc) for +Cursor agent guidance. diff --git a/src/backend/configure.ts b/src/backend/configure.ts index 28b2500a..b230e302 100644 --- a/src/backend/configure.ts +++ b/src/backend/configure.ts @@ -152,6 +152,8 @@ export async function configure(command: Configure) { await codemods.makeUsingStub(stubsRoot, 'resources/layout.stub', {}); await codemods.makeUsingStub(stubsRoot, 'resources/views/preview.stub', {}); + await codemods.makeUsingStub(stubsRoot, 'resources/views/preview_devotion.stub', {}); + await codemods.makeUsingStub(stubsRoot, 'resources/views/preview_course.stub', {}); await codemods.makeUsingStub(stubsRoot, 'resources/views/scripture.stub', {}); await codemods.makeUsingStub(stubsRoot, 'commands/migrate.stub', {}); diff --git a/src/backend/define_config.ts b/src/backend/define_config.ts index 3326ac92..eec080ce 100644 --- a/src/backend/define_config.ts +++ b/src/backend/define_config.ts @@ -1,10 +1,11 @@ -import type { CmsConfig, FieldSpec } from '../types.js'; +import type { CmsConfig } from '../types.js'; +import { assertTemplateCollectionsMatchGlobals } from '../shared/media_helpers.js'; /** * Define shield configuration */ export function defineConfig(config: Partial): CmsConfig { - return { + const resolved = { name: config.name || 'Journeys Studio', logo: @@ -21,6 +22,10 @@ export function defineConfig(config: Partial): CmsConfig { videoCollectionId: config.videoCollectionId || '', + imageCollectionId: config.imageCollectionId || '', + + audioCollectionId: config.audioCollectionId || '', + /** * A list of languages to be used in the app * @@ -59,108 +64,8 @@ export function defineConfig(config: Partial): CmsConfig { storyTemplates: config.storyTemplates || [], } satisfies CmsConfig; -} -// ------------------------------------- -// standard content templates -// ------------------------------------- - -export interface mediaConfig { - collection: string; - description: string; - extensions: string[]; - maxSize: number; -} + assertTemplateCollectionsMatchGlobals(resolved); -export function courseFields(video: mediaConfig, image: mediaConfig): FieldSpec[] { - return [ - { - name: 'title', - label: 'Title', - widget: 'string', - }, - // TODO(sections): re-enable when spec is ready - // { - // name: 'section', - // label: 'Section', - // widget: 'string', - // }, - { - name: 'imageUrl', - label: 'Cover Image', - widget: 'image', - description: image.description, - extensions: image.extensions, - maxSize: image.maxSize, - uploadPreset: image.collection, - }, - { - label: 'Screens', - name: '', - widget: 'panel', - fields: [ - { - name: 'screens', - label: 'Screen', - widget: 'list', - fields: [ - { - name: 'screenName', - label: 'Screen Name', - widget: 'string', - }, - { - name: 'displayTitle', - label: 'Display Title', - widget: 'string', - }, - { - name: 'heroImage', - label: 'Hero Image', - widget: 'image', - description: image.description, - extensions: image.extensions, - maxSize: image.maxSize, - uploadPreset: image.collection, - }, - { - name: 'sessionVideo', - label: 'Session Video', - widget: 'video', - description: video.description, - extensions: video.extensions, - maxSize: video.maxSize, - collectionId: video.collection, - }, - { - name: 'bodyText', - label: 'Body Text', - widget: 'markdown', - toolbar: [ - 'bold', - 'italic', - 'heading-1', - 'heading-2', - 'heading-3', - 'unordered-list', - 'ordered-list', - 'link', - 'horizontal-rule', - ], - }, - { - name: 'screenStyle', - label: 'Screen Style', - widget: 'select', - options: [ - { label: 'Primary', value: 'primary' }, - { label: 'Secondary', value: 'secondary' }, - ], - default: 'primary', - }, - ], - }, - ], - }, - ]; + return resolved; } diff --git a/src/backend/draft_edit_page.ts b/src/backend/draft_edit_page.ts new file mode 100644 index 00000000..151f057b --- /dev/null +++ b/src/backend/draft_edit_page.ts @@ -0,0 +1,24 @@ +import { isCourseTemplate, isDevotionTemplate } from '../shared/story_helpers.js'; + +export type DraftEditPage = + | 'DraftIndex' + | 'TranslationIndex' + | 'DevotionDraftEdit' + | 'DevotionDraftTranslationEdit' + | 'CourseDraftEdit' + | 'CourseDraftTranslationEdit'; + +export const draftEditPage = ( + template: string | null | undefined, + isTranslation: boolean, +): DraftEditPage => { + if (isDevotionTemplate(template)) { + return isTranslation ? 'DevotionDraftTranslationEdit' : 'DevotionDraftEdit'; + } + + if (isCourseTemplate(template)) { + return isTranslation ? 'CourseDraftTranslationEdit' : 'CourseDraftEdit'; + } + + return isTranslation ? 'TranslationIndex' : 'DraftIndex'; +}; diff --git a/src/backend/index.ts b/src/backend/index.ts index 0ca3236b..74d830be 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -38,6 +38,7 @@ export * from './services/bundle_service.js'; export * from './services/invitation_service.js'; export * from './services/cms_service.js'; export * from './services/draft_service.js'; +export * from './draft_edit_page.js'; export * from './services/index_service.js'; export * from './services/page_service.js'; export * from './services/progress_service.js'; @@ -54,6 +55,32 @@ export * from './validators/invitation.js'; export * from './validators/bundle.js'; export * from './validators/chapter.js'; export * from './validators/course.js'; +export * from './validators/devotion_draft.js'; +export { + createDevotionDraftBundle, + normalizedDevotionDraftBundle, +} from '../shared/devotion_draft.js'; +export { + createCourseDraftBundle, + normalizedCourseDraftBundle, +} from '../shared/course_draft.js'; +export { previewBundleFrom } from '../shared/preview_bundle.js'; +export { cloneBlocksStructure } from '../shared/block_structure.js'; +export { + audioUploadConfig, + imageUploadConfig, + videoUploadConfig, +} from '../shared/media_upload_configs.js'; +export { + assertTemplateCollectionsMatchGlobals, + buildMediaFieldSpec, + globalMediaCollections, + mediaCollectionsForTemplate, +} from '../shared/media_helpers.js'; +export { + previousDevotionChapterBlocks, + previousCourseChapterBlocks, +} from '../shared/previous_chapter_blocks.js'; export { default as DropValidator } from './validators/drop.js'; export { default as PageValidator } from './validators/page.js'; export * from './validators/user.js'; diff --git a/src/backend/services/cms_service.ts b/src/backend/services/cms_service.ts index c7c02181..471b4fbc 100644 --- a/src/backend/services/cms_service.ts +++ b/src/backend/services/cms_service.ts @@ -150,6 +150,8 @@ export class CmsService { supportEmail: this.#config.supportEmail, hasAppPreview: this.#config.hasAppPreview, videoCollectionId: this.#config.videoCollectionId, + imageCollectionId: this.#config.imageCollectionId, + audioCollectionId: this.#config.audioCollectionId, languages: this.#config.languages, subscriptions: this.#config.subscriptions, } as UiConfig, diff --git a/src/backend/services/draft_service.ts b/src/backend/services/draft_service.ts index 46163318..249c1e4a 100644 --- a/src/backend/services/draft_service.ts +++ b/src/backend/services/draft_service.ts @@ -1,7 +1,47 @@ import Chapter from '../models/chapter.js'; -import type { FieldMap, FieldSpec, StorySpec, StoryVersion, JSON } from '../../types'; +import Draft from '../models/draft.js'; +import type { + CourseDraftBundle, + CourseDraftEditProps, + DevotionDraftBundle, + DevotionDraftEditProps, + DraftEditProps, + FieldMap, + FieldSpec, + Providers, + ResourceItem, + StoryChapterSpecifier, + StorySpec, + StoryVersion, + JSON, +} from '../../types.js'; import { BundleService } from './bundle_service.js'; import { CmsService } from './cms_service.js'; +import { + createDevotionDraftBundle, + normalizedDevotionDraftBundle, +} from '../../shared/devotion_draft.js'; +import { + createCourseDraftBundle, + normalizedCourseDraftBundle, +} from '../../shared/course_draft.js'; +import { isCourseTemplate, isDevotionTemplate } from '../../shared/story_helpers.js'; +import { cloneBlocksStructure } from '../../shared/block_structure.js'; +import { + previousCourseChapterBlocks, + previousDevotionChapterBlocks, +} from '../../shared/previous_chapter_blocks.js'; + +type BlockTemplate = 'devotion' | 'course'; + +export interface DraftResourceService { + listForLocale(locale: string): Promise; + hydrate(ids: string[]): Promise; +} + +export interface DraftServiceDependencies { + resourceService?: DraftResourceService; +} export class DraftService { public story: StorySpec; @@ -15,16 +55,90 @@ export class DraftService { constructor( story: StorySpec, protected cms: CmsService, + private readonly dependencies: DraftServiceDependencies = {}, ) { this.story = story; } + public async create(version: StoryVersion, number: number): Promise { + const bundle = await this.getDraftBundle(version, number); + if (bundle === null) return null; + + return Draft.create({ + ...version, + number, + bundle, + }); + } + + public async editProps(options: { + version: StoryVersion; + number: number; + providers: Providers; + newDraftId?: number | string | null; + }): Promise { + const specifier: StoryChapterSpecifier = { + apiVersion: options.version.apiVersion, + locale: options.version.locale, + storyId: options.version.storyId, + number: options.number, + }; + + const resolved = await this.findOrCreateDraft(specifier); + if (resolved === null) return null; + + const { draft, lastPublished } = resolved; + + const isTranslation = options.version.locale !== this.cms.sourceLocale; + const base = this.baseDraftEditProps(draft, lastPublished, options.providers); + + if (isDevotionTemplate(this.story.template)) { + return this.blockTemplateEditProps({ + template: 'devotion', + isTranslation, + draft, + base, + specifier, + newDraftId: options.newDraftId, + }); + } + + if (isCourseTemplate(this.story.template)) { + return this.blockTemplateEditProps({ + template: 'course', + isTranslation, + draft, + base, + specifier, + newDraftId: options.newDraftId, + }); + } + + if (!isTranslation) { + return base; + } + + const sourceChapter = await this.loadSourceChapter(specifier); + return { + ...base, + source: sourceChapter?.bundle, + }; + } + public async getDraftBundle( version: StoryVersion, number: number, ): Promise | null> { // is this the source language? if (version.locale === this.cms.sourceLocale) { + if (isDevotionTemplate(this.story.template)) { + return JSON.stringify(createDevotionDraftBundle(number)); + } + + if (isCourseTemplate(this.story.template)) { + return JSON.stringify(createCourseDraftBundle(number)); + } + const bundleService = new BundleService(this.story.fields); return bundleService.defaultBundle; } @@ -39,6 +153,31 @@ export class DraftService { const source = await Chapter.query().where(specifier).first(); if (!source) return null; + if (isDevotionTemplate(this.story.template)) { + const normalized = normalizedDevotionDraftBundle(source.bundle, number); + const translationBundle: DevotionDraftBundle = { + ...normalized, + title: '', + description: '', + devotionAudio: { url: null, length: null }, + blocks: cloneBlocksStructure(normalized.blocks), + resources: [], + }; + return JSON.stringify(translationBundle); + } + + if (isCourseTemplate(this.story.template)) { + const normalized = normalizedCourseDraftBundle(source.bundle, number); + const translationBundle: CourseDraftBundle = { + ...normalized, + title: '', + description: '', + blocks: cloneBlocksStructure(normalized.blocks), + resources: [], + }; + return JSON.stringify(translationBundle); + } + const fresh = this.getFreshBundleFrom(source.bundle as any); return JSON.stringify(fresh); } @@ -157,4 +296,159 @@ export class DraftService { break; } } + + private async findOrCreateDraft( + specifier: StoryChapterSpecifier, + ): Promise<{ draft: Draft; lastPublished: string } | null> { + let draft = await Draft.query().where(specifier).first(); + let lastPublished = ''; + + const chapter = await Chapter.query().where(specifier).first(); + + if (chapter) { + lastPublished = chapter.updatedAt ? chapter.updatedAt.toString() : ''; + } + + if (!draft) { + if (!chapter) { + return null; + } + + lastPublished = chapter.updatedAt.toString(); + draft = await Draft.create({ + ...specifier, + bundle: chapter.bundle, + }); + } + + return { draft, lastPublished }; + } + + private async loadSourceChapter(specifier: StoryChapterSpecifier) { + return Chapter.query() + .where({ + ...specifier, + locale: this.cms.sourceLocale, + }) + .first(); + } + + private baseDraftEditProps( + draft: Draft, + lastPublished: string, + providers: Providers, + ): DraftEditProps { + return { + draft: draft.meta, + bundle: draft.bundle, + lastPublished, + providers, + story: this.story, + hasEditReview: this.cms.config.storiesHasEditReview, + }; + } + + private async blockTemplateEditProps(options: { + template: BlockTemplate; + isTranslation: boolean; + draft: Draft; + base: DraftEditProps; + specifier: StoryChapterSpecifier; + newDraftId?: number | string | null; + }): Promise { + const { template, isTranslation, draft, base, specifier, newDraftId } = options; + const normalized = this.normalizeBlockDraftBundle( + template, + draft.bundle, + draft.number, + ); + const { availableResources, resources } = await this.hydrateBlockDraftResources( + specifier.locale, + normalized.resources, + ); + + const sourceChapter = isTranslation ? await this.loadSourceChapter(specifier) : null; + const sourceBundle = isTranslation + ? this.normalizeBlockDraftBundle(template, sourceChapter?.bundle, draft.number) + : undefined; + + const previousChapterBlocks = + !isTranslation && draft.number > 1 + ? await this.previousChapterBlocks(template, { + ...specifier, + number: draft.number, + }) + : []; + + const props = { + ...base, + bundle: { + ...normalized, + resources, + }, + availableResources, + ...(isTranslation + ? { source: sourceBundle, previousChapterBlocks: [] } + : { + isCreate: Number(newDraftId) === draft.id, + previousChapterBlocks, + }), + }; + + if (template === 'devotion') { + return props as DevotionDraftEditProps; + } + + return props as CourseDraftEditProps; + } + + private normalizeBlockDraftBundle( + template: BlockTemplate, + bundle: unknown, + draftNumber: number, + ): DevotionDraftBundle | CourseDraftBundle { + if (template === 'devotion') { + return normalizedDevotionDraftBundle(bundle, draftNumber); + } + + return normalizedCourseDraftBundle(bundle, draftNumber); + } + + private async hydrateBlockDraftResources(locale: string, resourceIds: string[]) { + const resourceService = await this.getResourceService(); + const [availableResources, resources] = await Promise.all([ + resourceService.listForLocale(locale), + resourceService.hydrate(resourceIds), + ]); + + return { availableResources, resources }; + } + + private async previousChapterBlocks( + template: BlockTemplate, + specifier: StoryChapterSpecifier, + ) { + const loadBundle = async (spec: StoryChapterSpecifier) => { + const previousDraft = await Draft.query().where(spec).first(); + if (previousDraft) { + return previousDraft.bundle; + } + + const previousChapter = await Chapter.query().where(spec).first(); + return previousChapter?.bundle ?? null; + }; + + if (template === 'devotion') { + return previousDevotionChapterBlocks(specifier, loadBundle); + } + + return previousCourseChapterBlocks(specifier, loadBundle); + } + + private async getResourceService(): Promise { + if (this.dependencies.resourceService) return this.dependencies.resourceService; + + const { ResourceService } = await import('./resource_service.js'); + return new ResourceService(); + } } diff --git a/src/backend/services/helpers.ts b/src/backend/services/helpers.ts index cdc4e7bb..2b45a15b 100644 --- a/src/backend/services/helpers.ts +++ b/src/backend/services/helpers.ts @@ -8,6 +8,10 @@ export { canPublishStory, canPublishStoryMetadata, canPublishStoryReady, + DEVOTION_TEMPLATE_ID, + COURSE_TEMPLATE_ID, + isDevotionTemplate, + isCourseTemplate, missingPublishedChapters, storyDetailsBlockedMessages, publishBlockedMessage, diff --git a/src/backend/services/story_service.ts b/src/backend/services/story_service.ts index ac3bb652..0420d32f 100644 --- a/src/backend/services/story_service.ts +++ b/src/backend/services/story_service.ts @@ -429,6 +429,7 @@ export class StoryService { return { id: story.id, name: localisation?.title ?? '', + template: story.template, coverImage: localisation?.coverImage ?? '', chapterLimit: story.chapterLimit, chapterType: story.chapterType ?? '', diff --git a/src/backend/stubs/config/cms.stub b/src/backend/stubs/config/cms.stub index 55a155ff..a1519e74 100644 --- a/src/backend/stubs/config/cms.stub +++ b/src/backend/stubs/config/cms.stub @@ -1,8 +1,7 @@ {{{ exports({ to: app.configPath('cms.ts') }) }}} -import type { FieldSpec, CmsConfig, mediaConfig, StreamSpec, Subscription } from '@story-cms/kit'; -import { courseFields } from '@story-cms/kit'; +import type { FieldSpec, CmsConfig, StreamSpec, Subscription } from '@story-cms/kit'; // --------------------------------------- // Subscriptions @@ -22,22 +21,12 @@ const subscriptions: Subscription[] = [ ]; // --------------------------------------- -// Media upload configurations +// Media collection IDs // --------------------------------------- -const videoUploadConfig: mediaConfig = { - collection: 'ceaec8fa-9293-48b0-948e-1ae49c9f3a4b', - description: 'MP4 and MOV files up to 4GB', - extensions: ['.mp4', '.mov'], - maxSize: 4000000000, -}; - -const imageUploadConfig: mediaConfig = { - collection: 'journeys_template', - description: 'SVG, PNG, JPG, GIF up to 5MB', - extensions: ['.jpeg', '.jpg', '.png', '.svg'], - maxSize: 5000000, -}; +const videoCollectionId = 'ceaec8fa-9293-48b0-948e-1ae49c9f3a4b'; +const imageCollectionId = 'journeys_template'; +const audioCollectionId = 'audio/default'; // --------------------------------------- // Custom templates @@ -108,12 +97,9 @@ const cmsConfig: Partial = { */ microcopySource: '', - /** - * Bunny collection ID for resource video uploads - * example: videoUploadConfig.collection - */ - videoCollectionId: videoUploadConfig.collection, - + videoCollectionId, + imageCollectionId, + audioCollectionId, streams: [devotionStream], @@ -138,7 +124,18 @@ const cmsConfig: Partial = { { id: 'course', name: 'Course', - fields: [courseFields(videoUploadConfig, imageUploadConfig)], + fields: [], + collections: { video: videoCollectionId, image: imageCollectionId }, + }, + { + id: 'devotion', + name: 'Devotion', + fields: [], + collections: { + video: videoCollectionId, + image: imageCollectionId, + audio: audioCollectionId, + }, }, ], diff --git a/src/backend/stubs/controllers/chapters_controller.stub b/src/backend/stubs/controllers/chapters_controller.stub index 561847ec..6385eaf0 100644 --- a/src/backend/stubs/controllers/chapters_controller.stub +++ b/src/backend/stubs/controllers/chapters_controller.stub @@ -10,7 +10,10 @@ import { type AddStatus, IndexService, Chapter, + Draft, + Story, StoryService, + previewBundleFrom, } from '@story-cms/kit'; import cms from '#services/cms'; @@ -47,8 +50,15 @@ export default class ChaptersController { }; const chapter = await Chapter.query().where(specifier).firstOrFail(); - const bundleView = await edge.render({{ '`preview_${story.id}`' }}, { - bundle: chapter.bundle, + const draft = await Draft.query().where(specifier).first(); + const storyRecord = await Story.findOrFail(story.id); + const bundle = previewBundleFrom({ + chapter, + draft, + template: storyRecord.template, + }); + const bundleView = await edge.render(`preview_${storyRecord.template}`, { + bundle, marked: marked, number: chapter.number, }); @@ -56,8 +66,8 @@ export default class ChaptersController { const props: PreviewProps = { chapter: chapter.meta, bundleView: bundleView, - title: chapter.index.title, - bundle: chapter.bundle, + title: (typeof bundle.title === 'string' ? bundle.title : '') || chapter.index.title, + bundle, story, }; diff --git a/src/backend/stubs/controllers/drafts_controller.stub b/src/backend/stubs/controllers/drafts_controller.stub index ad42d259..579d3ab2 100644 --- a/src/backend/stubs/controllers/drafts_controller.stub +++ b/src/backend/stubs/controllers/drafts_controller.stub @@ -11,7 +11,7 @@ import { DraftService, IndexService, StoryService, - type DraftEditProps, + draftEditPage, } from '@story-cms/kit'; import cms from '#services/cms'; import providers from '#config/providers'; @@ -30,14 +30,10 @@ export default class DraftsController { const service = new DraftService(story, cms); // TODO: overrride default prefill fields as needed // service.setPrefilledFields(['imageUrl', 'type']); - const bundle = await service.getDraftBundle(version, number); - if (bundle === null) return ctx.response.redirect('/'); + const draft = await service.create(version, number); + if (draft === null) return ctx.response.redirect('/'); - await Draft.create({ - ...version, - number, - bundle, - }); + ctx.session.flash('newDraftId', draft.id); await Activity.create({ userId: ctx.auth.user?.id, @@ -55,67 +51,28 @@ export default class DraftsController { const { story, version } = await storyService.parsePath(ctx); if (story === undefined) return ctx.response.notFound(); - const specifier = { - apiVersion: version.apiVersion, - locale: version.locale, - storyId: story.id, - number: Number(ctx.params.number), - }; - - let draft = await Draft.query().where(specifier).first(); - let lastPublished = ''; - - const chapter = await Chapter.query().where(specifier).first(); + const draftService = new DraftService(story, cms); + const isTranslation = version.locale !== cms.sourceLocale; - if (chapter) { - lastPublished = chapter.updatedAt ? chapter.updatedAt.toString() : ''; - } + const props = await draftService.editProps({ + version, + number: Number(ctx.params.number), + providers, + newDraftId: ctx.session.flashMessages.get('newDraftId'), + }); - if (!draft) { - if (!chapter) { - return ctx.response.redirect().toRoute('chapters.index', { - locale: version.locale, - storyId: story.id, - }); - } - lastPublished = chapter.updatedAt.toString(); - draft = await Draft.create({ - ...specifier, - bundle: chapter.bundle, + if (props === null) { + return ctx.response.redirect().toRoute('chapters.index', { + locale: version.locale, + storyId: story.id, }); } const indexService = new IndexService(story, cms); await indexService.buildIndex(version); - const data: DraftEditProps = { - draft: draft.meta, - bundle: draft!.bundle, - lastPublished, - providers, - story, - hasEditReview: cms.config.storiesHasEditReview, - }; - - const isTranslation = version.locale !== cms.sourceLocale; - - if (!isTranslation) { - // @ts-expect-error Inertia page name - return ctx.inertia.render('DraftIndex', data); - } - - const source = await Chapter.query() - .where({ - ...specifier, - locale: cms.sourceLocale, - }) - .first(); - // @ts-expect-error Inertia page name - return ctx.inertia.render('TranslationIndex', { - ...data, - source: source?.bundle, - }); + return ctx.inertia.render(draftEditPage(story.template, isTranslation), props); } // Only saving the draft without performing any validation diff --git a/src/backend/stubs/resources/views/preview_course.stub b/src/backend/stubs/resources/views/preview_course.stub new file mode 100644 index 00000000..a8f72f94 --- /dev/null +++ b/src/backend/stubs/resources/views/preview_course.stub @@ -0,0 +1,85 @@ +{{{ + exports({ to: app.makePath('resources/views/preview_course.edge') }) +}}} +
+ @if(bundle.number) +
Number
+
\{\{ bundle.number \}\}
+ @end + + @if(bundle.title) +
Title
+
\{\{ bundle.title \}\}
+ @end + + @if(bundle.description) +
Description
+
\{\{\{ marked.parse(bundle.description) \}\}\}
+ @end + + @if(bundle.coverImage) +
Cover Image
+ \{\{ bundle.title \}\} + @end + + @if(bundle.blocks) + @each(block in bundle.blocks) + @if(!block.visibility || !block.visibility.hidden) +
+ @if(block.blockName) +
\{\{ block.blockName \}\}
+ @end + + @if(block.kind === 'title') + @if(block.title) +
\{\{ block.title \}\}
+ @end + @if(block.subtitle) +
\{\{ block.subtitle \}\}
+ @end + @if(block.coverImage) + \{\{ block.title \}\} + @end + @end + + @if(block.kind === 'scripture') + @if(block.displayName) +
\{\{ block.displayName \}\}
+ @end + @if(block.scripture) + @!component('components/scripture', {passage: block.scripture}) + @end + @end + + @if(!block.kind || block.kind === 'content') + @if(block.displayName) +
\{\{ block.displayName \}\}
+ @end + @if(block.content) +
\{\{\{ marked.parse(block.content) \}\}\}
+ @end + @if(block.items) + @each(item in block.items) + @if(item.kind === 'image' && item.imageUrl) +
+ +
+ @end + @if(item.kind === 'video' && item.video && item.video.url) +
+ @!component('components/video', {url: item.video.url, librayId: librayId}) +
+ @end + @if(item.kind === 'scripture' && item.scripture) +
+ @!component('components/scripture', {passage: item.scripture}) +
+ @end + @endeach + @end + @end +
+ @end + @endeach + @end +
diff --git a/src/backend/stubs/resources/views/preview_devotion.stub b/src/backend/stubs/resources/views/preview_devotion.stub new file mode 100644 index 00000000..ddb0f66c --- /dev/null +++ b/src/backend/stubs/resources/views/preview_devotion.stub @@ -0,0 +1,92 @@ +{{{ + exports({ to: app.makePath('resources/views/preview_devotion.edge') }) +}}} +
+ @if(bundle.number) +
Number
+
\{\{ bundle.number \}\}
+ @end + + @if(bundle.title) +
Title
+
\{\{ bundle.title \}\}
+ @end + + @if(bundle.description) +
Description
+
\{\{\{ marked.parse(bundle.description) \}\}\}
+ @end + + @if(bundle.coverImage) +
Cover Image
+ \{\{ bundle.title \}\} + @end + + @if(bundle.devotionAudio && bundle.devotionAudio.url) +
Devotion Audio
+ + @end + + @if(bundle.blocks) + @each(block in bundle.blocks) + @if(!block.visibility || !block.visibility.hidden) +
+ @if(block.blockName) +
\{\{ block.blockName \}\}
+ @end + + @if(block.kind === 'title') + @if(block.title) +
\{\{ block.title \}\}
+ @end + @if(block.subtitle) +
\{\{ block.subtitle \}\}
+ @end + @if(block.coverImage) + \{\{ block.title \}\} + @end + @end + + @if(block.kind === 'scripture') + @if(block.displayName) +
\{\{ block.displayName \}\}
+ @end + @if(block.scripture) + @!component('components/scripture', {passage: block.scripture}) + @end + @end + + @if(!block.kind || block.kind === 'content') + @if(block.displayName) +
\{\{ block.displayName \}\}
+ @end + @if(block.content) +
\{\{\{ marked.parse(block.content) \}\}\}
+ @end + @if(block.items) + @each(item in block.items) + @if(item.kind === 'image' && item.imageUrl) +
+ +
+ @end + @if(item.kind === 'video' && item.video && item.video.url) +
+ @!component('components/video', {url: item.video.url, librayId: librayId}) +
+ @end + @if(item.kind === 'scripture' && item.scripture) +
+ @!component('components/scripture', {passage: item.scripture}) +
+ @end + @endeach + @end + @end +
+ @end + @endeach + @end +
diff --git a/src/backend/stubs/tests/helpers/cms_mock.stub b/src/backend/stubs/tests/helpers/cms_mock.stub index 6631a3e5..1a5c5264 100644 --- a/src/backend/stubs/tests/helpers/cms_mock.stub +++ b/src/backend/stubs/tests/helpers/cms_mock.stub @@ -15,6 +15,8 @@ export const testCmsConfig: CmsConfig = { hasAppPreview: false, microcopySource: '', videoCollectionId: '', + imageCollectionId: '', + audioCollectionId: '', languages: [ { locale: 'en', diff --git a/src/backend/stubs/validators/story_validator.stub b/src/backend/stubs/validators/story_validator.stub index 8e29cf98..9f1f87d1 100644 --- a/src/backend/stubs/validators/story_validator.stub +++ b/src/backend/stubs/validators/story_validator.stub @@ -2,7 +2,14 @@ exports({ to: app.makePath('app/validators/story_validator.ts') }) }}} import vine, { SimpleMessagesProvider } from '@vinejs/vine'; -import type { StorySpec, ValidatorType } from '@story-cms/kit'; +import { + DevotionDraftValidator, + CourseValidator, + isDevotionTemplate, + isCourseTemplate, + type StorySpec, + type ValidatorType, +} from '@story-cms/kit'; // to save us from setting vine.string().trim().minLength(1) everywhere vine.convertEmptyStringsToNull = true; @@ -41,6 +48,14 @@ export default class Story1 implements ValidatorType { } export const storyValidator = (story: StorySpec): ValidatorType => { + if (isDevotionTemplate(story.template)) { + return new DevotionDraftValidator(); + } + + if (isCourseTemplate(story.template)) { + return new CourseValidator(); + } + switch (story.id) { case 1: return new Story1(); diff --git a/src/backend/validators/chapter_blocks_validator.ts b/src/backend/validators/chapter_blocks_validator.ts new file mode 100644 index 00000000..9480516a --- /dev/null +++ b/src/backend/validators/chapter_blocks_validator.ts @@ -0,0 +1,143 @@ +import vine from '@vinejs/vine'; +import type { FieldContext } from '@vinejs/vine/types'; +import videoRule from './video_rule.js'; + +export const requiredString = () => vine.string().trim().minLength(1); + +const visibilitySchema = vine.object({ + presenter: vine.boolean({ strict: true }), + personal: vine.boolean({ strict: true }), + inNavigation: vine.boolean({ strict: true }), + hidden: vine.boolean({ strict: true }), +}); + +const scriptureSchema = vine.object({ + reference: requiredString(), + verse: requiredString(), +}); + +const imageItemSchema = vine.object({ + id: requiredString(), + kind: vine.literal('image'), + imageUrl: vine.string().trim().url({ require_protocol: true }), +}); + +const videoItemSchema = vine.object({ + id: requiredString(), + kind: vine.literal('video'), + video: vine + .object({ + url: vine.string().nullable(), + }) + .use(videoRule(null)), +}); + +const scriptureItemSchema = vine.object({ + id: requiredString(), + kind: vine.literal('scripture'), + scripture: scriptureSchema, +}); + +const itemSchema = vine.union([ + vine.union.if((value) => value.kind === 'image', imageItemSchema), + vine.union.if((value) => value.kind === 'video', videoItemSchema), + vine.union.if((value) => value.kind === 'scripture', scriptureItemSchema), + vine.union.else( + vine.object({ + id: requiredString(), + kind: vine.enum(['image', 'video', 'scripture'] as const), + }), + ), +]); + +const blockBase = { + id: requiredString(), + blockName: requiredString(), + visibility: visibilitySchema, +}; + +const contentOrItemRule = vine.createRule( + (value: unknown, _options: undefined, field: FieldContext) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return; + + const block = value as Record; + const hasContent = typeof block.content === 'string' && block.content.trim().length > 0; + const hasItems = Array.isArray(block.items) && block.items.length > 0; + if (!hasContent && !hasItems) { + field.report( + 'A content block must have text or at least one media or scripture item', + 'contentOrItem', + field, + ); + } + }, +); + +export const contentBlockSchema = vine + .object({ + ...blockBase, + kind: vine.literal('content'), + displayName: requiredString(), + blockRole: requiredString(), + style: requiredString(), + content: vine.string().optional(), + items: vine.array(itemSchema).optional(), + leadersNotes: vine.string().optional(), + showLeadersNotes: vine.boolean({ strict: true }).optional(), + }) + .bail(false) + .use(contentOrItemRule()); + +export const titleBlockSchema = vine.object({ + ...blockBase, + kind: vine.literal('title'), + title: requiredString(), + subtitle: vine.string().optional(), + coverImage: vine.string().optional(), +}); + +export const scriptureBlockSchema = vine.object({ + ...blockBase, + kind: vine.literal('scripture'), + displayName: requiredString(), + scripture: scriptureSchema, + leadersNotes: vine.string().optional(), + showLeadersNotes: vine.boolean({ strict: true }).optional(), +}); + +export function chapterBlockSchema(options?: { includeScriptureBlock?: boolean }) { + const includeScriptureBlock = options?.includeScriptureBlock ?? true; + + if (!includeScriptureBlock) { + return vine.union([ + vine.union.if((value) => value.kind === 'content', contentBlockSchema), + vine.union.if((value) => value.kind === 'title', titleBlockSchema), + vine.union.else( + vine.object({ + ...blockBase, + kind: vine.enum(['content', 'title'] as const), + }), + ), + ]); + } + + return vine.union([ + vine.union.if((value) => value.kind === 'content', contentBlockSchema), + vine.union.if((value) => value.kind === 'title', titleBlockSchema), + vine.union.if((value) => value.kind === 'scripture', scriptureBlockSchema), + vine.union.else( + vine.object({ + ...blockBase, + kind: vine.enum(['content', 'title', 'scripture'] as const), + }), + ), + ]); +} + +export const chapterBlockErrorMessages = { + 'bundle.blocks.*.id.required': 'Every block must have an ID', + 'bundle.blocks.*.id.minLength': 'Every block must have an ID', + 'bundle.blocks.*.blockName.required': 'Every block must have a name', + 'bundle.blocks.*.blockName.minLength': 'Every block must have a name', + 'bundle.resources.*.uuid': 'Invalid resource', +}; diff --git a/src/backend/validators/course.ts b/src/backend/validators/course.ts index a5fb778d..0150e990 100644 --- a/src/backend/validators/course.ts +++ b/src/backend/validators/course.ts @@ -1,42 +1,33 @@ import vine, { SimpleMessagesProvider } from '@vinejs/vine'; -import videoRule from '../validators/video_rule.js'; -import type { ValidatorType } from '../../types'; +import type { ValidatorType } from '../../types.js'; +import { + chapterBlockErrorMessages, + chapterBlockSchema, + requiredString, +} from './chapter_blocks_validator.js'; -vine.convertEmptyStringsToNull = true; - -const videoSchema = vine - .object({ - url: vine.string(), - }) - .use(videoRule(null)); - -const screenSchema = vine.object({ - screenName: vine.string(), - displayTitle: vine.string().optional(), - heroImage: vine.string().optional(), - sessionVideo: videoSchema.optional(), - bodyText: vine.string().optional(), - screenStyle: vine.string().optional(), +export const courseDraftErrorMessages = new SimpleMessagesProvider({ + 'bundle.number.required': 'The session must have a number', + 'bundle.number.minLength': 'The session must have a number', + 'bundle.title.required': 'The session must have a title', + 'bundle.title.minLength': 'The session must have a title', + 'bundle.blocks.required': 'The session must have at least one block', + 'bundle.blocks.minLength': 'The session must have at least one block', + ...chapterBlockErrorMessages, }); export class CourseValidator implements ValidatorType { validate(data: any): Promise { - vine.messagesProvider = new SimpleMessagesProvider({ - 'bundle.title.required': 'The chapter must have a title', - // TODO(sections): re-enable when spec is ready - // 'bundle.section.required': 'The chapter must have a section', - 'bundle.screens.required': 'The chapter must have at least one screen', - 'bundle.screens.*.screenName.required': 'Each screen must have a name', - 'bundle.screens.*.sessionVideo.videoSchema': 'Please upload a valid video file', - }); + vine.messagesProvider = courseDraftErrorMessages; const schema = vine.create({ bundle: vine.object({ - title: vine.string(), - // TODO(sections): re-enable when spec is ready - // section: vine.string(), - imageUrl: vine.string().optional(), - screens: vine.array(screenSchema).minLength(1), + number: requiredString(), + title: requiredString(), + description: vine.string().optional(), + coverImage: vine.string().optional(), + blocks: vine.array(chapterBlockSchema({ includeScriptureBlock: false })).minLength(1), + resources: vine.array(vine.string().uuid()).optional(), }), }); diff --git a/src/backend/validators/devotion_draft.ts b/src/backend/validators/devotion_draft.ts new file mode 100644 index 00000000..264fe067 --- /dev/null +++ b/src/backend/validators/devotion_draft.ts @@ -0,0 +1,46 @@ +import vine, { SimpleMessagesProvider } from '@vinejs/vine'; +import type { ValidatorType } from '../../types.js'; +import audioRule from './audio_rule.js'; +import { + chapterBlockErrorMessages, + chapterBlockSchema, + requiredString, +} from './chapter_blocks_validator.js'; + +const devotionAudioSchema = vine + .object({ + url: vine.string().nullable(), + length: vine.number().nullable(), + }) + .use(audioRule({ canBeEmpty: true })) + .optional(); + +export const devotionDraftErrorMessages = new SimpleMessagesProvider({ + 'bundle.number.required': 'The devotion must have a number', + 'bundle.number.minLength': 'The devotion must have a number', + 'bundle.title.required': 'The devotion must have a title', + 'bundle.title.minLength': 'The devotion must have a title', + 'bundle.blocks.required': 'The devotion must have at least one block', + 'bundle.blocks.minLength': 'The devotion must have at least one block', + ...chapterBlockErrorMessages, +}); + +export class DevotionDraftValidator implements ValidatorType { + validate(data: any): Promise { + vine.messagesProvider = devotionDraftErrorMessages; + + const schema = vine.create({ + bundle: vine.object({ + number: requiredString(), + title: requiredString(), + description: vine.string().optional(), + coverImage: vine.string().optional(), + devotionAudio: devotionAudioSchema, + blocks: vine.array(chapterBlockSchema()).minLength(1), + resources: vine.array(vine.string().uuid()).optional(), + }), + }); + + return schema.validate(data); + } +} diff --git a/src/frontend/dashboard/action-card.story.vue b/src/frontend/dashboard/action-card.story.vue index fc01894b..62097b65 100644 --- a/src/frontend/dashboard/action-card.story.vue +++ b/src/frontend/dashboard/action-card.story.vue @@ -5,7 +5,7 @@ :icon="Languages" title="New Language" description="Engage your audience morning, noon, and night. Create healthy daily rhythms with content that reaches them throughout the day." - @action="handleAction" + @action="onAction" /> @@ -14,7 +14,7 @@ :icon="BookOpen" title="New Story" description="Engage your audience morning, noon, and night. Create healthy daily rhythms with content that reaches them throughout the day." - @action="handleAction" + @action="onAction" /> @@ -23,7 +23,7 @@ :icon="FileText" title="New Page" description="Engage your audience morning, noon, and night. Create healthy daily rhythms with content that reaches them throughout the day." - @action="handleAction" + @action="onAction" /> @@ -31,8 +31,8 @@ :icon="FileText" title="New Page" description="Engage your audience morning, noon, and night. Create healthy daily rhythms with content that reaches them throughout the day." - @action="handleAction" disabled + @action="onAction" /> @@ -42,7 +42,7 @@ import { BookOpen, FileText, Languages } from '@lucide/vue'; import ActionCard from './action-card.vue'; -const handleAction = () => { +const onAction = () => { console.log('Action card clicked'); }; diff --git a/src/frontend/dashboard/action-grid.story.vue b/src/frontend/dashboard/action-grid.story.vue index fc3bd371..45aa9868 100644 --- a/src/frontend/dashboard/action-grid.story.vue +++ b/src/frontend/dashboard/action-grid.story.vue @@ -1,7 +1,7 @@ @@ -35,7 +35,7 @@ const items: ActionGridItem[] = [ }, ]; -const handleAction = (url: string) => { +const onAction = (url: string) => { console.log('Action grid action:', url); }; diff --git a/src/frontend/dashboard/dashboard-index.vue b/src/frontend/dashboard/dashboard-index.vue index ef873a19..ff035977 100644 --- a/src/frontend/dashboard/dashboard-index.vue +++ b/src/frontend/dashboard/dashboard-index.vue @@ -3,7 +3,7 @@