From e11aefeb2a28f28a8be1a28f3a5d86d09bc20fb4 Mon Sep 17 00:00:00 2001 From: Jan Gregor Emge-Triebel Date: Wed, 5 Aug 2026 21:39:17 +0200 Subject: [PATCH 1/4] Fix the extension bundle's Prettier config before reformatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections to the formatter's own configuration, kept ahead of the reformat so that sweep runs under the final config rather than a config that changes underneath it. `jsxBracketSameLine` was removed in Prettier 3 and printed a deprecation warning on every single run. Verified it changed no output: exactly the same 64 files are unformatted before and after removing it, so the reformat in the next commit is byte-identical either way. The config now matches nuxt-app/.prettierrc exactly, minus the Tailwind plugin. Added a .prettierignore for `.claude`. `prettier . --write` walks the whole tree and was rewriting contributors' untracked `.claude/settings.local.json` — local tool state that nobody reviews. Verified with a control: a deliberately unformatted file inside `.claude/` is not flagged, while the same content outside it is. `node_modules` and `dist` are deliberately not repeated in that file; Prettier reads `.gitignore` as well, and the bundle's already covers both. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BMa3aosYRWyivC4DtPbSNr --- .../.prettierignore | 8 ++++++++ .../directus-extension-programmierbar-bundle/.prettierrc | 1 - 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 directus-cms/extensions/directus-extension-programmierbar-bundle/.prettierignore diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/.prettierignore b/directus-cms/extensions/directus-extension-programmierbar-bundle/.prettierignore new file mode 100644 index 00000000..98ba0b5c --- /dev/null +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/.prettierignore @@ -0,0 +1,8 @@ +# Local editor/agent tool state. `prettier . --write` walks the whole tree, and without this it +# rewrites contributors' untracked `.claude/settings.local.json` — a file the formatter has no +# business touching and that nobody reviews. +# +# `node_modules` and `dist` are not listed here on purpose: Prettier reads `.gitignore` in addition +# to this file, and the bundle's `.gitignore` already covers both. Duplicating them would mean two +# places to keep in step. +.claude diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/.prettierrc b/directus-cms/extensions/directus-extension-programmierbar-bundle/.prettierrc index 0cf2d5fb..65b7c8e0 100755 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/.prettierrc +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/.prettierrc @@ -9,7 +9,6 @@ "jsxSingleQuote": false, "trailingComma": "es5", "bracketSpacing": true, - "jsxBracketSameLine": false, "arrowParens": "always", "endOfLine": "lf" } From f61eee1449d9794f6aeecea526b114cfe963a9c5 Mon Sep 17 00:00:00 2001 From: Jan Gregor Emge-Triebel Date: Wed, 5 Aug 2026 21:39:58 +0200 Subject: [PATCH 2/4] Reformat the extension bundle with Prettier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical `prettier . --write` output, no hand edits. Formatting was never enforced in this tree, so 64 files had drifted from the committed .prettierrc — one of them the untracked local settings file excluded in the previous commit, leaving 63 here. The gate that stops this recurring arrives in the next commit. Beyond whitespace, this strips semicolons (`semi: false`) and sorts imports via @ianvs/prettier-plugin-sort-imports, so the diff is not whitespace-only. Import reordering is the one way a sweep like this can genuinely break something, so it was checked first: the only load-time side-effect imports are `import 'dotenv/config'` in the two Algolia CLI scripts, the plugin keeps them in the same slot (after third-party, before local), and the only process.env reads in those files sit below the entire import block. Verified against the artefact Directus actually loads, since lint, test and build are all blind to a formatter by construction and 17 of 26 entries have no tests at all. Built dist/ before and after: - app.js: all 43 string literals identical; after normalising every identifier, exactly one structural difference, which is the unused watch parameters removed in the next commit. - api.js: same 18 module specifiers in the same order; 10,554 of 10,583 literals byte-identical. The 29 that differ are `entities` lazy-init blobs varying only in Rollup's internal module variable names, a knock-on of modules being emitted in a different order. Static analysis could not push api.js past "almost certainly equivalent", so the decisive check was to import both builds in Node and compare the registry handed to Directus: deep-equal, 23 hooks in the same order, 2 endpoints, 0 operations. That compares the registry's shape, not the behaviour of the 23 handlers, which cannot be invoked without a Directus host. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BMa3aosYRWyivC4DtPbSNr --- .../jest.config.ts | 41 +- .../src/algolia-index/cli/rebuild-index.ts | 94 +-- .../src/algolia-index/cli/repair-index.ts | 210 +++---- .../src/algolia-index/handlers/ItemHandler.ts | 37 +- .../algolia-index/handlers/MeetupHandler.ts | 72 ++- .../handlers/PickOfTheDayHandler.ts | 43 +- .../algolia-index/handlers/PodcastHandler.ts | 40 +- .../algolia-index/handlers/SpeakerHandler.ts | 40 +- .../handlers/TranscriptHandler.ts | 73 +-- .../src/algolia-index/handlers/index.ts | 14 +- .../src/algolia-index/index.ts | 209 ++++--- .../src/algolia-index/util/pagination.ts | 44 +- .../src/algolia-index/util/sanitizer.ts | 32 +- .../src/asset-generation/generateAssets.ts | 17 +- .../src/asset-generation/index.ts | 214 +++---- .../src/buzzsprout/handlers/buzzsprout.ts | 16 +- .../handlers/handlePickOfTheDayAction.ts | 2 +- .../handlers/handlePodcastAction.ts | 11 +- .../buzzsprout/handlers/handleTagAction.ts | 2 +- .../src/buzzsprout/handlers/podcastData.ts | 2 +- .../src/buzzsprout/index.ts | 13 +- .../cascade-publish/__tests__/index.test.ts | 26 +- .../src/cascade-publish/index.ts | 4 +- .../src/conference/index.ts | 58 +- .../src/content-approval/index.ts | 24 +- .../src/create-news/__tests__/index.test.ts | 10 +- .../src/create-news/index.ts | 40 +- .../util/__tests__/newsTarget.test.ts | 5 +- .../src/create-profile/index.ts | 22 +- .../src/deploy-website/index.ts | 40 +- .../fetch-open-graph/__tests__/index.test.ts | 60 +- .../util/__tests__/openGraph.test.ts | 5 +- .../util/__tests__/urlSafety.test.ts | 4 +- .../__tests__/matchMembers.test.ts | 2 +- .../src/member-matching/index.ts | 2 +- .../src/member-matching/matchMembers.ts | 14 +- .../generateTranscriptItem.ts | 49 +- .../src/podcast-transcript/index.ts | 27 +- .../processTranscriptItem.ts | 68 +-- .../src/post-to-discord/index.ts | 2 +- .../src/process-guard/index.ts | 4 +- .../__tests__/index.test.ts | 5 +- .../src/screenshot/index.ts | 18 +- .../src/set-slug/__tests__/README.md | 14 +- .../__tests__/getPayloadWithSlug.test.ts | 181 +++--- .../src/set-slug/index.ts | 13 +- .../src/set-slug/util/getPayloadWithSlug.ts | 45 +- .../shared/__tests__/isPublishable.test.ts | 35 +- .../src/shared/__tests__/podcasts_fields.json | 93 +-- .../src/shared/__tests__/safeHook.test.ts | 2 +- .../src/shared/__tests__/settings.test.ts | 6 +- .../shared/__tests__/test-wallet-passes.ts | 40 +- .../src/shared/email-service.ts | 5 +- .../src/shared/gemini.ts | 4 +- .../src/shared/invoice-generator.ts | 27 +- .../src/shared/isPublishable.ts | 59 +- .../src/shared/wallet-pass-generator.ts | 5 +- .../src/social-media-publish/index.ts | 155 ++--- .../src/speaker-portal-notifications/index.ts | 232 +++---- .../src/speaker-token/index.ts | 2 +- .../src/ticket-order-processing/index.ts | 566 +++++++++--------- .../src/ticket-profile-completion/index.ts | 281 ++++----- .../src/ticket-wallet/index.ts | 5 +- 63 files changed, 1773 insertions(+), 1712 deletions(-) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/jest.config.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/jest.config.ts index 8685849b..892de59f 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/jest.config.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/jest.config.ts @@ -1,22 +1,25 @@ -import type { Config } from 'jest'; +import type { Config } from 'jest' const config: Config = { - preset: 'ts-jest/presets/js-with-ts-esm', - testEnvironment: 'node', - moduleFileExtensions: ['ts', 'js', 'json'], - extensionsToTreatAsEsm: ['.ts'], - transform: { - '^.+\\.ts$': ['ts-jest', { - useESM: true, - }], - }, - testMatch: ['**/__tests__/**/*.test.ts'], - moduleNameMapper: { - // Handle module aliases (if needed) - '^../../../../../shared-code/(.*)$': '/../../../shared-code/$1', - }, - // Setup files if needed - // setupFilesAfterEnv: ['/jest.setup.ts'], -}; + preset: 'ts-jest/presets/js-with-ts-esm', + testEnvironment: 'node', + moduleFileExtensions: ['ts', 'js', 'json'], + extensionsToTreatAsEsm: ['.ts'], + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + }, + ], + }, + testMatch: ['**/__tests__/**/*.test.ts'], + moduleNameMapper: { + // Handle module aliases (if needed) + '^../../../../../shared-code/(.*)$': '/../../../shared-code/$1', + }, + // Setup files if needed + // setupFilesAfterEnv: ['/jest.setup.ts'], +} -export default config; +export default config diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/cli/rebuild-index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/cli/rebuild-index.ts index 8ef83893..de41bb8b 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/cli/rebuild-index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/cli/rebuild-index.ts @@ -1,15 +1,14 @@ #!/usr/bin/env node - -import meow from 'meow'; -import { createDirectus, rest } from '@directus/sdk'; -import { createFetchRequester } from '@algolia/requester-fetch'; -import { searchClient } from '@algolia/client-search'; +import { searchClient } from '@algolia/client-search' +import { createFetchRequester } from '@algolia/requester-fetch' +import { createDirectus, rest } from '@directus/sdk' +import meow from 'meow' import 'dotenv/config' +import { getHandlers } from './../handlers/index.ts' +import { streamItems } from './../util/pagination.ts' -import { getHandlers } from './../handlers/index.ts'; -import { streamItems } from './../util/pagination.ts'; - -const cli = meow(` +const cli = meow( + ` Usage $ npm run algolia:rebuild-index @@ -18,28 +17,31 @@ const cli = meow(` `, { importMeta: import.meta, - }); + } +) -const PUBLIC_URL = process.env.PUBLIC_URL; -const ALGOLIA_INDEX = process.env.ALGOLIA_INDEX; -const ALGOLIA_APP_ID = process.env.ALGOLIA_APP_ID; -const ALGOLIA_API_KEY = process.env.ALGOLIA_API_KEY; +const PUBLIC_URL = process.env.PUBLIC_URL +const ALGOLIA_INDEX = process.env.ALGOLIA_INDEX +const ALGOLIA_APP_ID = process.env.ALGOLIA_APP_ID +const ALGOLIA_API_KEY = process.env.ALGOLIA_API_KEY if (!PUBLIC_URL || !ALGOLIA_INDEX || !ALGOLIA_APP_ID || !ALGOLIA_API_KEY) { - throw new Error('Missing environment variables'); + throw new Error('Missing environment variables') } if (cli.input.length === 0) { - throw new Error('No collection specified.'); + throw new Error('No collection specified.') } +const algoliaClient = searchClient(ALGOLIA_APP_ID, ALGOLIA_API_KEY, { requester: createFetchRequester() }) +const directusClient = createDirectus(PUBLIC_URL).with(rest()) -const algoliaClient = searchClient(ALGOLIA_APP_ID, ALGOLIA_API_KEY, { requester: createFetchRequester() }); -const directusClient = createDirectus(PUBLIC_URL).with(rest()); - -const itemHandlers = getHandlers({ - PUBLIC_URL: PUBLIC_URL, -}, {}); +const itemHandlers = getHandlers( + { + PUBLIC_URL: PUBLIC_URL, + }, + {} +) // The fields to fetch are taken from each handler's `indexFields` (the single source of truth shared // with the live hook), so the CLI can never drift out of sync with what the handler actually reads. @@ -53,22 +55,22 @@ const configuration = [ for (const configurationItem of configuration) { if (cli.input.lastIndexOf(configurationItem.collection) !== -1) { - console.log('Rebuilding index for collection: ' + configurationItem.collection); + console.log('Rebuilding index for collection: ' + configurationItem.collection) // Stream the collection page by page (page size decided by the handler) instead of loading // it all at once. Transcripts in particular cannot be read with `limit: -1` — each row holds // a full hour of audio transcription. Streaming also keeps memory bounded: we process and // push one item before fetching the next. - let counter = 0; + let counter = 0 for await (const item of streamItems(directusClient, configurationItem.collection, configurationItem.handler)) { - counter++; + counter++ const payloads = configurationItem.handler.buildAttributes(item).map((payload) => { return { ...payload, distinct: configurationItem.handler.buildDistinctKey(item), _directus_reference: configurationItem.handler.buildDirectusReference(item), } - }); + }) // Transcripts (and any handler that fans an item out into a VARIABLE number of objects) // must delete the item's existing entries first: when the chunk count shrinks, the @@ -79,19 +81,17 @@ for (const configurationItem of configuration) { const results = await algoliaClient.browseObjects({ indexName: ALGOLIA_INDEX, query: '', - attributesToRetrieve: [ - 'objectID', - ], + attributesToRetrieve: ['objectID'], browseParams: { - filters: configurationItem.handler.buildDeletionFilter(item), - } - }); + filters: configurationItem.handler.buildDeletionFilter(item), + }, + }) - const IdsForDeletion = results.hits.map((hit: any) => hit.objectID); + const IdsForDeletion = results.hits.map((hit: any) => hit.objectID) await algoliaClient.deleteObjects({ indexName: ALGOLIA_INDEX, objectIDs: IdsForDeletion, - }); + }) } // Write each payload as a FULL-RECORD REPLACE (addOrUpdateObject = PUT), NOT a partial @@ -102,19 +102,21 @@ for (const configurationItem of configuration) { // write was rejected citing the EXISTING record's size. A full replace overwrites the whole // record (dropping any stale attributes too) and is accepted as long as the NEW payload // fits — which the handlers' size guards ensure. - await Promise.all(payloads.map(async (payload, index) => { - await algoliaClient.addOrUpdateObject({ - indexName: ALGOLIA_INDEX, - objectID: `${item.id}_${index}`, - body: payload, - }); - })); - - console.log(`Processed ${configurationItem.collection.slice(0, -1)} (${counter}): ${item.id}`); - console.log(payloads); - console.log('-----'); + await Promise.all( + payloads.map(async (payload, index) => { + await algoliaClient.addOrUpdateObject({ + indexName: ALGOLIA_INDEX, + objectID: `${item.id}_${index}`, + body: payload, + }) + }) + ) + + console.log(`Processed ${configurationItem.collection.slice(0, -1)} (${counter}): ${item.id}`) + console.log(payloads) + console.log('-----') } - console.log('Rebuilt index for collection: ' + configurationItem.collection); + console.log('Rebuilt index for collection: ' + configurationItem.collection) } } diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/cli/repair-index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/cli/repair-index.ts index 8bcd8f58..028ffe5f 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/cli/repair-index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/cli/repair-index.ts @@ -1,15 +1,14 @@ #!/usr/bin/env node - -import meow from 'meow'; -import { createDirectus, rest } from '@directus/sdk'; -import { createFetchRequester } from '@algolia/requester-fetch'; -import { searchClient } from '@algolia/client-search'; +import { searchClient } from '@algolia/client-search' +import { createFetchRequester } from '@algolia/requester-fetch' +import { createDirectus, rest } from '@directus/sdk' +import meow from 'meow' import 'dotenv/config' +import { getHandlers } from './../handlers/index.ts' +import { collectItems } from './../util/pagination.ts' -import { getHandlers } from './../handlers/index.ts'; -import { collectItems } from './../util/pagination.ts'; - -const cli = meow(` +const cli = meow( + ` Usage $ npm run algolia-index-repair @@ -22,28 +21,31 @@ const cli = meow(` `, { importMeta: import.meta, - }); + } +) -const PUBLIC_URL = process.env.PUBLIC_URL; -const ALGOLIA_INDEX = process.env.ALGOLIA_INDEX; -const ALGOLIA_APP_ID = process.env.ALGOLIA_APP_ID; -const ALGOLIA_API_KEY = process.env.ALGOLIA_API_KEY; +const PUBLIC_URL = process.env.PUBLIC_URL +const ALGOLIA_INDEX = process.env.ALGOLIA_INDEX +const ALGOLIA_APP_ID = process.env.ALGOLIA_APP_ID +const ALGOLIA_API_KEY = process.env.ALGOLIA_API_KEY if (!PUBLIC_URL || !ALGOLIA_INDEX || !ALGOLIA_APP_ID || !ALGOLIA_API_KEY) { - throw new Error('Missing environment variables'); + throw new Error('Missing environment variables') } if (cli.input.length === 0) { - throw new Error('No collection specified.'); + throw new Error('No collection specified.') } +const algoliaClient = searchClient(ALGOLIA_APP_ID, ALGOLIA_API_KEY, { requester: createFetchRequester() }) +const directusClient = createDirectus(PUBLIC_URL).with(rest()) -const algoliaClient = searchClient(ALGOLIA_APP_ID, ALGOLIA_API_KEY, { requester: createFetchRequester() }); -const directusClient = createDirectus(PUBLIC_URL).with(rest()); - -const itemHandlers = getHandlers({ - PUBLIC_URL: PUBLIC_URL, -}, {}); +const itemHandlers = getHandlers( + { + PUBLIC_URL: PUBLIC_URL, + }, + {} +) // The fields to fetch are taken from each handler's `indexFields` (the single source of truth shared // with the live hook), so the CLI can never drift out of sync with what the handler actually reads. @@ -53,19 +55,19 @@ const configuration = [ { collection: 'speakers', handler: itemHandlers.speakerHandler }, { collection: 'picks_of_the_day', handler: itemHandlers.pickOfTheDayHandler }, { collection: 'transcripts', handler: itemHandlers.transcriptHandler }, -]; +] interface RepairStats { - collection: string; - totalDbItems: number; - totalIndexItems: number; - missing: number; - stale: number; - orphaned: number; - repaired: number; + collection: string + totalDbItems: number + totalIndexItems: number + missing: number + stale: number + orphaned: number + repaired: number } -async function repairCollection(configItem: typeof configuration[0]): Promise { +async function repairCollection(configItem: (typeof configuration)[0]): Promise { const stats: RepairStats = { collection: configItem.collection, totalDbItems: 0, @@ -74,17 +76,17 @@ async function repairCollection(configItem: typeof configuration[0]): Promise [String(item.id), item])); - const indexItemsMap = new Map(); + const dbItemsMap = new Map(dbItems.map((item) => [String(item.id), item])) + const indexItemsMap = new Map() // Group index items by their directus reference for (const hit of indexItems.hits) { - const ref = String(hit._directus_reference || ''); + const ref = String(hit._directus_reference || '') if (ref && !indexItemsMap.has(ref)) { - indexItemsMap.set(ref, []); + indexItemsMap.set(ref, []) } if (ref) { - indexItemsMap.get(ref).push(hit); + indexItemsMap.get(ref).push(hit) } } - console.log('\n🔍 Identifying issues...'); + console.log('\n🔍 Identifying issues...') // Find missing items (in DB but not in index) - const missingItems = []; + const missingItems = [] for (const [itemId, dbItem] of dbItemsMap) { if (!indexItemsMap.has(itemId)) { - missingItems.push(dbItem); - stats.missing++; + missingItems.push(dbItem) + stats.missing++ } } // Find orphaned items (in index but not in DB) - const orphanedItems = []; + const orphanedItems = [] for (const [ref, hits] of indexItemsMap) { if (!dbItemsMap.has(ref)) { - orphanedItems.push(...hits); - stats.orphaned += hits.length; + orphanedItems.push(...hits) + stats.orphaned += hits.length } } @@ -140,133 +142,135 @@ async function repairCollection(configItem: typeof configuration[0]): Promise` instead — it re-pushes every field for every item. - const staleItems = []; + const staleItems = [] for (const [itemId, dbItem] of dbItemsMap) { - const indexHits = indexItemsMap.get(itemId); + const indexHits = indexItemsMap.get(itemId) if (indexHits) { // Check if we need to update (simplified check) if (configItem.handler.updateRequired(dbItem)) { - const expectedPayloads = configItem.handler.buildAttributes(dbItem); + const expectedPayloads = configItem.handler.buildAttributes(dbItem) if (expectedPayloads.length !== indexHits.length) { - staleItems.push(dbItem); - stats.stale++; + staleItems.push(dbItem) + stats.stale++ } } } } - console.log(`❌ Missing in index: ${stats.missing}`); - console.log(`🗑️ Orphaned in index: ${stats.orphaned}`); - console.log(`⚠️ Potentially stale: ${stats.stale}`); + console.log(`❌ Missing in index: ${stats.missing}`) + console.log(`🗑️ Orphaned in index: ${stats.orphaned}`) + console.log(`⚠️ Potentially stale: ${stats.stale}`) // Repair missing items if (missingItems.length > 0) { - console.log('\n🔧 Adding missing items to index...'); + console.log('\n🔧 Adding missing items to index...') for (const item of missingItems) { - await addItemToIndex(item, configItem); - stats.repaired++; - console.log(`✅ Added ${configItem.collection} item ${item.id}`); + await addItemToIndex(item, configItem) + stats.repaired++ + console.log(`✅ Added ${configItem.collection} item ${item.id}`) } } // Remove orphaned items if (orphanedItems.length > 0) { - console.log('\n🧹 Removing orphaned items from index...'); - const orphanedIds = orphanedItems.map(hit => hit.objectID); + console.log('\n🧹 Removing orphaned items from index...') + const orphanedIds = orphanedItems.map((hit) => hit.objectID) await algoliaClient.deleteObjects({ indexName: ALGOLIA_INDEX, objectIDs: orphanedIds, - }); - stats.repaired += orphanedItems.length; - console.log(`✅ Removed ${orphanedItems.length} orphaned items`); + }) + stats.repaired += orphanedItems.length + console.log(`✅ Removed ${orphanedItems.length} orphaned items`) } // Repair stale items if (staleItems.length > 0) { - console.log('\n🔄 Updating stale items in index...'); + console.log('\n🔄 Updating stale items in index...') for (const item of staleItems) { - await updateItemInIndex(item, configItem); - stats.repaired++; - console.log(`✅ Updated ${configItem.collection} item ${item.id}`); + await updateItemInIndex(item, configItem) + stats.repaired++ + console.log(`✅ Updated ${configItem.collection} item ${item.id}`) } } - return stats; + return stats } -async function addItemToIndex(item: any, configItem: typeof configuration[0]) { +async function addItemToIndex(item: any, configItem: (typeof configuration)[0]) { const payloads = configItem.handler.buildAttributes(item).map((payload) => { return { ...payload, distinct: configItem.handler.buildDistinctKey(item), _directus_reference: configItem.handler.buildDirectusReference(item), } - }); + }) - await Promise.all(payloads.map(async (payload, index) => { - await algoliaClient.partialUpdateObject({ - indexName: ALGOLIA_INDEX, - objectID: `${item.id}_${index}`, - attributesToUpdate: payload, - createIfNotExists: true, - }); - })); + await Promise.all( + payloads.map(async (payload, index) => { + await algoliaClient.partialUpdateObject({ + indexName: ALGOLIA_INDEX, + objectID: `${item.id}_${index}`, + attributesToUpdate: payload, + createIfNotExists: true, + }) + }) + ) } -async function updateItemInIndex(item: any, configItem: typeof configuration[0]) { +async function updateItemInIndex(item: any, configItem: (typeof configuration)[0]) { // First remove existing entries if required if (configItem.handler.requiresDistinctDeletionBeforeUpdate()) { const results = await algoliaClient.browseObjects({ indexName: ALGOLIA_INDEX, browseParams: { filters: configItem.handler.buildDeletionFilter(item), - } - }); + }, + }) - const idsForDeletion = results.hits.map((hit: any) => hit.objectID); + const idsForDeletion = results.hits.map((hit: any) => hit.objectID) if (idsForDeletion.length > 0) { await algoliaClient.deleteObjects({ indexName: ALGOLIA_INDEX, objectIDs: idsForDeletion, - }); + }) } } // Then add the updated item - await addItemToIndex(item, configItem); + await addItemToIndex(item, configItem) } // Main execution async function main() { - const requestedCollection = cli.input[0]; - const allStats: RepairStats[] = []; + const requestedCollection = cli.input[0] + const allStats: RepairStats[] = [] for (const configItem of configuration) { if (requestedCollection === 'all' || configItem.collection === requestedCollection) { try { - const stats = await repairCollection(configItem); - allStats.push(stats); + const stats = await repairCollection(configItem) + allStats.push(stats) } catch (error) { - console.error(`❌ Failed to repair ${configItem.collection}:`, error); + console.error(`❌ Failed to repair ${configItem.collection}:`, error) } } } // Print summary - console.log('\n📊 REPAIR SUMMARY'); - console.log('================'); + console.log('\n📊 REPAIR SUMMARY') + console.log('================') - let totalRepaired = 0; + let totalRepaired = 0 for (const stats of allStats) { - console.log(`${stats.collection}: ${stats.repaired} items repaired`); - totalRepaired += stats.repaired; + console.log(`${stats.collection}: ${stats.repaired} items repaired`) + totalRepaired += stats.repaired } - console.log(`\n🎉 Total items repaired: ${totalRepaired}`); + console.log(`\n🎉 Total items repaired: ${totalRepaired}`) if (totalRepaired === 0) { - console.log('✨ Search index is already in sync!'); + console.log('✨ Search index is already in sync!') } } -main().catch(console.error); +main().catch(console.error) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/ItemHandler.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/ItemHandler.ts index f84d320f..b6ec12d5 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/ItemHandler.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/ItemHandler.ts @@ -1,11 +1,11 @@ export interface ItemHandler { - collectionName: string; + collectionName: string /** * The `_type` value this handler writes onto every index entry (see `buildAttributes`). It is the * authoritative source for the type string, so consumers that need to query the index by type * (e.g. the repair CLI) MUST read it from here rather than deriving it from `collectionName`. */ - type: string; + type: string /** * The Directus fields required to build this handler's index entries — the single source of * truth shared by the live hook (which re-reads the full item) and the rebuild/repair CLIs @@ -13,7 +13,7 @@ export interface ItemHandler { * read, including relational fields with their nested paths (e.g. `podcast.*`). If a field is * missing here it will silently be absent from the index. */ - indexFields: string[]; + indexFields: string[] /** * Page size for the rebuild/repair CLIs' bulk reads from Directus. Most collections are small * enough (in both row count AND per-row size) to fetch in a single `limit: -1` request, so the @@ -23,39 +23,40 @@ export interface ItemHandler { * transcripts, which embed a full hour of audio transcription per row. There a single bulk read * overruns Directus (and would pin hundreds of MB in memory), so they must be paged through. */ - pageSize: number; - updateRequired(item: any): boolean; - buildAttributes(item: any): Record[]; - requiresDistinctDeletionBeforeUpdate(): boolean; - buildDistinctKey(item: any): string; - buildDeletionFilter(item: any): string; - buildDirectusReference(item: any): string; + pageSize: number + updateRequired(item: any): boolean + buildAttributes(item: any): Record[] + requiresDistinctDeletionBeforeUpdate(): boolean + buildDistinctKey(item: any): string + buildDeletionFilter(item: any): string + buildDirectusReference(item: any): string } export abstract class AbstractItemHandler { - - constructor(protected env, private logger) { - } + constructor( + protected env, + private logger + ) {} // No pagination by default: the whole collection is fetched in one request. Handlers with large // rows (e.g. transcripts) override this with a small positive page size. See ItemHandler.pageSize. get pageSize(): number { - return -1; + return -1 } requiresDistinctDeletionBeforeUpdate(): boolean { - return false; + return false } buildDistinctKey(item: any): string { - return `${item.id}`; + return `${item.id}` } buildDirectusReference(item: any): string { - return `${item.id}`; + return `${item.id}` } buildDeletionFilter(item: any): string { - return `_directus_reference:${this.buildDirectusReference(item)}`; + return `_directus_reference:${this.buildDirectusReference(item)}` } } diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/MeetupHandler.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/MeetupHandler.ts index 6c32cde9..a0153055 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/MeetupHandler.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/MeetupHandler.ts @@ -1,10 +1,10 @@ -import { AbstractItemHandler } from './ItemHandler.ts'; -import { sanitize, sanitizeFull, truncateToByteLimit } from '../util/sanitizer.ts'; +import { sanitize, sanitizeFull, truncateToByteLimit } from '../util/sanitizer.ts' +import { AbstractItemHandler } from './ItemHandler.ts' // Keep meetup records comfortably below Algolia's per-record size limit. Mirrors the podcast handler: // once the sanitized text grows past this, we fall back to fully stripped (tag-less) text. -const MAX_TALK_TEXT_LENGTH = 2500; -const MAX_DESCRIPTION_LENGTH = 2500; +const MAX_TALK_TEXT_LENGTH = 2500 +const MAX_DESCRIPTION_LENGTH = 2500 // Algolia rejects any record over a hard 10 KB (10000-byte) limit, and a rejected record means the // meetup vanishes from search entirely. We target this slightly lower ceiling for the payload this @@ -12,20 +12,19 @@ const MAX_DESCRIPTION_LENGTH = 2500; // that the rebuild/repair CLIs and the live hook append to every payload after buildAttributes() // returns (two UUIDs, ~110 bytes). Algolia counts the SERIALIZED record (UTF-8 bytes, including JSON // escaping), so the fitting logic below measures JSON.stringify() rather than guessing field sizes. -const MAX_PAYLOAD_BYTES = 9700; +const MAX_PAYLOAD_BYTES = 9700 // Talks take at most this share of the budget; the description (the snippet shown in search results) // then gets whatever is left, so a talk-less meetup can still use almost the entire allowance for its // text rather than being cut short by a fixed, smaller cap. -const MAX_TALK_TEXT_BYTES = 4000; - -export class MeetupHandler extends AbstractItemHandler{ +const MAX_TALK_TEXT_BYTES = 4000 +export class MeetupHandler extends AbstractItemHandler { get collectionName(): string { - return 'meetups'; + return 'meetups' } get type(): string { - return 'meetup'; + return 'meetup' } // Every field read by updateRequired() and buildAttributes(). `status` is added by the hook. @@ -40,9 +39,16 @@ export class MeetupHandler extends AbstractItemHandler{ // `talks.items.update` handler. get indexFields(): string[] { return [ - 'id', 'title', 'slug', 'intro', 'description', 'published_on', 'cover_image', - 'talks.talk.title', 'talks.talk.abstract', - ]; + 'id', + 'title', + 'slug', + 'intro', + 'description', + 'published_on', + 'cover_image', + 'talks.talk.title', + 'talks.talk.abstract', + ] } updateRequired(item: any): boolean { @@ -65,25 +71,25 @@ export class MeetupHandler extends AbstractItemHandler{ // markup that both pollutes search and blows past Algolia's record-size limit. We strip it the // same way the podcast handler does — sanitize() keeps a little structure, sanitizeFull() // removes everything — falling back to the harder strip once the text gets long. - let description = this.buildDescription(item, sanitize); + let description = this.buildDescription(item, sanitize) if (description.length > MAX_DESCRIPTION_LENGTH) { - description = this.buildDescription(item, sanitizeFull); + description = this.buildDescription(item, sanitizeFull) } // Talk text lives in its own searchable `talks` attribute rather than in `description`: the // search result card displays `description`, and we don't want to bury the meetup summary // under the concatenated talk abstracts. The index defines no explicit searchableAttributes, // so every attribute — including this one — is searchable by default. - let talks = this.buildTalkText(item, sanitize); + let talks = this.buildTalkText(item, sanitize) if (talks.length > MAX_TALK_TEXT_LENGTH) { - talks = this.buildTalkText(item, sanitizeFull); + talks = this.buildTalkText(item, sanitizeFull) } // Talks get a bounded share of the budget; the description takes whatever is left. - talks = truncateToByteLimit(talks, MAX_TALK_TEXT_BYTES); + talks = truncateToByteLimit(talks, MAX_TALK_TEXT_BYTES) const payload = { - _type : this.type, + _type: this.type, title: item.title, // Always send a string (empty when there's no content), never `undefined`. The hook and // rebuild push via partialUpdateObject, which drops `undefined` properties from the @@ -95,7 +101,7 @@ export class MeetupHandler extends AbstractItemHandler{ published_on: item.published_on, image: item.cover_image ? `${this.env.PUBLIC_URL}assets/${item.cover_image}` : undefined, slug: item.slug, - }; + } // Final size guard against Algolia's 10 KB hard limit. We measure the SERIALIZED payload and // trim the description (the only remaining unbounded field) until it fits. Measuring the real @@ -103,9 +109,9 @@ export class MeetupHandler extends AbstractItemHandler{ // cases: long titles/slugs (the schema allows 255 chars each) eat into the same budget, and // JSON escaping (every `\n` in a conference agenda becomes `\\n`) makes the serialized size // larger than the raw byte count. - this.fitPayloadToByteLimit(payload); + this.fitPayloadToByteLimit(payload) - return [payload]; + return [payload] } // Trims `payload.description` until the serialized payload is within MAX_PAYLOAD_BYTES. Loops @@ -116,9 +122,12 @@ export class MeetupHandler extends AbstractItemHandler{ payload.description.length > 0 && Buffer.byteLength(JSON.stringify(payload), 'utf8') > MAX_PAYLOAD_BYTES ) { - const overflowBytes = Buffer.byteLength(JSON.stringify(payload), 'utf8') - MAX_PAYLOAD_BYTES; - const descriptionBytes = Buffer.byteLength(payload.description, 'utf8'); - payload.description = truncateToByteLimit(payload.description, Math.max(0, descriptionBytes - overflowBytes)); + const overflowBytes = Buffer.byteLength(JSON.stringify(payload), 'utf8') - MAX_PAYLOAD_BYTES + const descriptionBytes = Buffer.byteLength(payload.description, 'utf8') + payload.description = truncateToByteLimit( + payload.description, + Math.max(0, descriptionBytes - overflowBytes) + ) } } @@ -129,24 +138,23 @@ export class MeetupHandler extends AbstractItemHandler{ .filter(Boolean) .map((text: string) => sanitizer(text).trim()) .filter(Boolean) - .join(' '); + .join(' ') } // Concatenates every linked talk's title + abstract into one searchable string. Each `item.talks` // entry is a `meetups_talks` junction row of shape `{ talk: { title, abstract }, ... }`. private buildTalkText(item: any, sanitizer: (input: string) => string): string { if (!Array.isArray(item.talks)) { - return ''; + return '' } return item.talks .map((entry: any) => entry?.talk) .filter(Boolean) - .map((talk: any) => [talk.title, talk.abstract ? sanitizer(talk.abstract) : ''] - .filter(Boolean) - .join(' ') - .trim()) + .map((talk: any) => + [talk.title, talk.abstract ? sanitizer(talk.abstract) : ''].filter(Boolean).join(' ').trim() + ) .filter(Boolean) - .join(' '); + .join(' ') } } diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/PickOfTheDayHandler.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/PickOfTheDayHandler.ts index f840081a..73bbce51 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/PickOfTheDayHandler.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/PickOfTheDayHandler.ts @@ -1,42 +1,37 @@ -import { AbstractItemHandler } from './ItemHandler.ts'; +import { AbstractItemHandler } from './ItemHandler.ts' export class PickOfTheDayHandler extends AbstractItemHandler { - get collectionName(): string { - return 'picks_of_the_day'; + return 'picks_of_the_day' } get type(): string { - return 'pick_of_the_day'; + return 'pick_of_the_day' } // Every field read by updateRequired() and buildAttributes(). `status` is added by the hook. get indexFields(): string[] { - return ['id', 'name', 'website_url', 'description', 'published_on', 'image']; + return ['id', 'name', 'website_url', 'description', 'published_on', 'image'] } updateRequired(item: any): boolean { - return ( - item.name || - item.description || - item.website_url || - item.published_on || - item.image - ) + return item.name || item.description || item.website_url || item.published_on || item.image } buildAttributes(item: any): Record[] { - return [{ - _type : this.type, - name: item.name, - // Also expose the title under `title`, the searchable attribute the other title-bearing - // types (podcasts, meetups, transcripts) use, so picks become searchable. `name` is kept - // for the frontend card heading (SearchResultCard.vue). - title: item.name, - description: item.description, - website_url: item.website_url, - published_on: item.published_on, - image: item.image ? `${this.env.PUBLIC_URL}assets/${item.image}` : undefined, - }] + return [ + { + _type: this.type, + name: item.name, + // Also expose the title under `title`, the searchable attribute the other title-bearing + // types (podcasts, meetups, transcripts) use, so picks become searchable. `name` is kept + // for the frontend card heading (SearchResultCard.vue). + title: item.name, + description: item.description, + website_url: item.website_url, + published_on: item.published_on, + image: item.image ? `${this.env.PUBLIC_URL}assets/${item.image}` : undefined, + }, + ] } } diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/PodcastHandler.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/PodcastHandler.ts index f454feff..9433897f 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/PodcastHandler.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/PodcastHandler.ts @@ -1,23 +1,22 @@ -import { AbstractItemHandler } from './ItemHandler.ts'; -import { sanitize, sanitizeFull } from '../util/sanitizer.ts'; +import { sanitize, sanitizeFull } from '../util/sanitizer.ts' +import { AbstractItemHandler } from './ItemHandler.ts' export class PodcastHandler extends AbstractItemHandler { - get collectionName(): string { - return 'podcasts'; + return 'podcasts' } get type(): string { - return 'podcast'; + return 'podcast' } // Every field read by updateRequired() and buildAttributes(). `status` is added by the hook. get indexFields(): string[] { - return ['id', 'title', 'slug', 'description', 'number', 'type', 'published_on', 'cover_image']; + return ['id', 'title', 'slug', 'description', 'number', 'type', 'published_on', 'cover_image'] } buildDistinctKey(item: any): string { - return `podcast-${item.id}`; + return `podcast-${item.id}` } updateRequired(item: any): boolean { @@ -33,23 +32,24 @@ export class PodcastHandler extends AbstractItemHandler { } buildAttributes(item: any): Record[] { - // This is a simple workaround for the algolia size-limit per index-entry // Ideally, we would split this out into multiple index entries later - let description = sanitize(item.description); + let description = sanitize(item.description) if (description.length > 2500) { - description = sanitizeFull(item.description); + description = sanitizeFull(item.description) } - return [{ - _type : this.type, - title: item.title, - number: item.number, - description: description, - type: item.type, - published_on: item.published_on, - image: item.cover_image ? `${this.env.PUBLIC_URL}assets/${item.cover_image}` : undefined, - slug: item.slug, - }] + return [ + { + _type: this.type, + title: item.title, + number: item.number, + description: description, + type: item.type, + published_on: item.published_on, + image: item.cover_image ? `${this.env.PUBLIC_URL}assets/${item.cover_image}` : undefined, + slug: item.slug, + }, + ] } } diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/SpeakerHandler.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/SpeakerHandler.ts index a4b62915..f08ec718 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/SpeakerHandler.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/SpeakerHandler.ts @@ -1,18 +1,26 @@ -import { AbstractItemHandler } from './ItemHandler.ts'; +import { AbstractItemHandler } from './ItemHandler.ts' export class SpeakerHandler extends AbstractItemHandler { - get collectionName(): string { - return 'speakers'; + return 'speakers' } get type(): string { - return 'speaker'; + return 'speaker' } // Every field read by updateRequired() and buildAttributes(). `status` is added by the hook. get indexFields(): string[] { - return ['id', 'first_name', 'last_name', 'academic_title', 'description', 'published_on', 'slug', 'profile_image']; + return [ + 'id', + 'first_name', + 'last_name', + 'academic_title', + 'description', + 'published_on', + 'slug', + 'profile_image', + ] } updateRequired(item: any): boolean { @@ -28,15 +36,17 @@ export class SpeakerHandler extends AbstractItemHandler { } buildAttributes(item: any): Record[] { - return [{ - _type : this.type, - first_name: item.first_name, - last_name: item.last_name, - academic_title: item.academic_title, - description: item.description, - published_on: item.published_on, - slug: item.slug, - image: item.profile_image ? `${this.env.PUBLIC_URL}assets/${item.profile_image}` : undefined, - }] + return [ + { + _type: this.type, + first_name: item.first_name, + last_name: item.last_name, + academic_title: item.academic_title, + description: item.description, + published_on: item.published_on, + slug: item.slug, + image: item.profile_image ? `${this.env.PUBLIC_URL}assets/${item.profile_image}` : undefined, + }, + ] } } diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/TranscriptHandler.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/TranscriptHandler.ts index 7f520cd8..21b65f0e 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/TranscriptHandler.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/TranscriptHandler.ts @@ -1,15 +1,14 @@ -import { AbstractItemHandler } from './ItemHandler.ts'; +import { AbstractItemHandler } from './ItemHandler.ts' export class TranscriptHandler extends AbstractItemHandler { - - private MAX_TEXT_LENGTH = 2500; + private MAX_TEXT_LENGTH = 2500 get collectionName(): string { - return 'transcripts'; + return 'transcripts' } get type(): string { - return 'transcript'; + return 'transcript' } // Transcripts are the one collection that MUST be paged through. Each row embeds a full hour of @@ -17,7 +16,7 @@ export class TranscriptHandler extends AbstractItemHandler { // `limit: -1` overruns Directus' response and pins hundreds of MB in memory. A small page keeps // each bulk read — and the rebuild's memory footprint — manageable. get pageSize(): number { - return 10; + return 10 } // Every field read by updateRequired(), buildAttributes() and buildDistinctKey(). `status` is @@ -25,33 +24,27 @@ export class TranscriptHandler extends AbstractItemHandler { // entry's title/number/date/cover/slug AND its id for the distinct key. Without it the hook // builds metadata-less entries and crashes on `item.podcast.id`. get indexFields(): string[] { - return ['id', 'podcast.*', 'speakers.*', 'service', 'supported_features', 'raw_response']; + return ['id', 'podcast.*', 'speakers.*', 'service', 'supported_features', 'raw_response'] } updateRequired(item: any): boolean { - return ( - item.podcast || - item.speakers || - item.service || - item.supported_features || - item.raw_response - ) + return item.podcast || item.speakers || item.service || item.supported_features || item.raw_response } requiresDistinctDeletionBeforeUpdate(): boolean { - return true; + return true } buildDistinctKey(item: any): string { // Depends on `podcast` being loaded (see indexFields). The hook and CLIs always read // `podcast.*`, so this is safe; it would throw for a transcript with no podcast linked, which // is not a valid state for an indexable transcript. - return `podcast-${item.podcast.id}`; + return `podcast-${item.podcast.id}` } buildAttributes(item: any): Record[] { - const podcast = item.podcast; - let podcastAttributes = {}; + const podcast = item.podcast + let podcastAttributes = {} if (podcast) { podcastAttributes = { title: podcast.title, @@ -63,17 +56,17 @@ export class TranscriptHandler extends AbstractItemHandler { } } - let transcriptText = ""; - if (item.service === "deepgram") { - if (item.raw_response?.results?.channels?.[0]?.alternatives?.[0]?.transcript) { - transcriptText = item.raw_response?.results?.channels?.[0]?.alternatives?.[0]?.transcript; - } + let transcriptText = '' + if (item.service === 'deepgram') { + if (item.raw_response?.results?.channels?.[0]?.alternatives?.[0]?.transcript) { + transcriptText = item.raw_response?.results?.channels?.[0]?.alternatives?.[0]?.transcript + } } - const transcriptChunks = this.splitTranscriptText(transcriptText); + const transcriptChunks = this.splitTranscriptText(transcriptText) - return transcriptChunks.map(chunk => { + return transcriptChunks.map((chunk) => { return { - _type : this.type, + _type: this.type, transcript: chunk, ...podcastAttributes, } @@ -81,37 +74,37 @@ export class TranscriptHandler extends AbstractItemHandler { } private splitTranscriptText(transcriptText: string): string[] { - const chunks: string[] = []; + const chunks: string[] = [] - let currentIndex = 0; + let currentIndex = 0 while (currentIndex < transcriptText.length) { // Find the next possible chunk within the remaining text - let endIndex = Math.min(currentIndex + this.MAX_TEXT_LENGTH, transcriptText.length); - const substring = transcriptText.substring(currentIndex, endIndex); + let endIndex = Math.min(currentIndex + this.MAX_TEXT_LENGTH, transcriptText.length) + const substring = transcriptText.substring(currentIndex, endIndex) if (substring.length < this.MAX_TEXT_LENGTH) { // If the substring fits entirely, add it as the last chunk - chunks.push(substring.trim()); - break; + chunks.push(substring.trim()) + break } // Find the last sentence-ending punctuation within the substring - let lastSentenceEnd = substring.lastIndexOf(".", this.MAX_TEXT_LENGTH - 1); - lastSentenceEnd = Math.max(lastSentenceEnd, substring.lastIndexOf("?", this.MAX_TEXT_LENGTH - 1)); - lastSentenceEnd = Math.max(lastSentenceEnd, substring.lastIndexOf("!", this.MAX_TEXT_LENGTH - 1)); + let lastSentenceEnd = substring.lastIndexOf('.', this.MAX_TEXT_LENGTH - 1) + lastSentenceEnd = Math.max(lastSentenceEnd, substring.lastIndexOf('?', this.MAX_TEXT_LENGTH - 1)) + lastSentenceEnd = Math.max(lastSentenceEnd, substring.lastIndexOf('!', this.MAX_TEXT_LENGTH - 1)) if (lastSentenceEnd === -1) { // If no sentence-ending punctuation is found, break at MAX_TEXT_LENGTH - chunks.push(substring.trim()); - currentIndex += this.MAX_TEXT_LENGTH; + chunks.push(substring.trim()) + currentIndex += this.MAX_TEXT_LENGTH } else { // Split at the sentence-ending punctuation - chunks.push(substring.substring(0, lastSentenceEnd + 1).trim()); - currentIndex += lastSentenceEnd + 1; // Move past the punctuation + chunks.push(substring.substring(0, lastSentenceEnd + 1).trim()) + currentIndex += lastSentenceEnd + 1 // Move past the punctuation } } - return chunks; + return chunks } } diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/index.ts index 25bb5ba7..4811d3c4 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/handlers/index.ts @@ -1,11 +1,11 @@ -import { PodcastHandler } from "./PodcastHandler.ts" -import type { ItemHandler } from "./ItemHandler.ts" -import { MeetupHandler } from './MeetupHandler.ts'; -import { SpeakerHandler } from './SpeakerHandler.ts'; -import { PickOfTheDayHandler } from './PickOfTheDayHandler.ts'; -import { TranscriptHandler } from './TranscriptHandler.js'; +import type { ItemHandler } from './ItemHandler.ts' +import { MeetupHandler } from './MeetupHandler.ts' +import { PickOfTheDayHandler } from './PickOfTheDayHandler.ts' +import { PodcastHandler } from './PodcastHandler.ts' +import { SpeakerHandler } from './SpeakerHandler.ts' +import { TranscriptHandler } from './TranscriptHandler.js' -type knownHandlers = "podcastHandler" | "meetupHandler" | "speakerHandler" | "pickOfTheDayHandler" | "transcriptHandler"; +type knownHandlers = 'podcastHandler' | 'meetupHandler' | 'speakerHandler' | 'pickOfTheDayHandler' | 'transcriptHandler' export function getHandlers(env, logger): Record { return { diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/index.ts index 57bc1b08..2a535dee 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/index.ts @@ -1,27 +1,29 @@ -import { defineHook } from '@directus/extensions-sdk'; -import { createFetchRequester } from '@algolia/requester-fetch'; -import { searchClient } from '@algolia/client-search'; - import type { ItemHandler } from './handlers/ItemHandler.ts'; +import { searchClient } from '@algolia/client-search' +import { createFetchRequester } from '@algolia/requester-fetch' +import { defineHook } from '@directus/extensions-sdk' +import type { SearchClient } from 'algoliasearch' +import type { ItemsService as ItemsServiceType } from '../buzzsprout/handlers/types.js' +import { safeAction } from '../shared/safeHook.ts' import { getHandlers } from './handlers/index.ts' -import type { SearchClient } from 'algoliasearch'; -import type { ItemsService as ItemsServiceType } from '../buzzsprout/handlers/types.js'; -import { safeAction } from '../shared/safeHook.ts'; +import type { ItemHandler } from './handlers/ItemHandler.ts' + const HOOK_NAME = 'algolia-index' export default defineHook(({ action }, hookContext) => { + const logger = hookContext.logger + const env = hookContext.env + const ItemsService = hookContext.services.ItemsService satisfies ItemsServiceType - const logger = hookContext.logger; - const env = hookContext.env; - const ItemsService = hookContext.services.ItemsService satisfies ItemsServiceType; - - const handlers = getHandlers(env, logger); + const handlers = getHandlers(env, logger) if (!(env.ALGOLIA_APP_ID && env.ALGOLIA_API_KEY && env.ALGOLIA_INDEX)) { - logger.warn(`${HOOK_NAME} hook: Did not set ALGOLIA_APP_ID && ALGOLIA_API_KEY && ALGOLIA_INDEX. Algolia extension will not be active.`) + logger.warn( + `${HOOK_NAME} hook: Did not set ALGOLIA_APP_ID && ALGOLIA_API_KEY && ALGOLIA_INDEX. Algolia extension will not be active.` + ) return } - const client = searchClient(env.ALGOLIA_APP_ID, env.ALGOLIA_API_KEY, { requester: createFetchRequester() }); + const client = searchClient(env.ALGOLIA_APP_ID, env.ALGOLIA_API_KEY, { requester: createFetchRequester() }) // safeAction wraps each callback so a thrown error / rejected promise is caught and logged // instead of becoming an unhandled rejection that crashes the CMS. The callbacks RETURN the @@ -46,15 +48,18 @@ export default defineHook(({ action }, hookContext) => { // Talks are their own collection embedded into meetups (see MeetupHandler). Editing a talk's // title/abstract must refresh the meetup entries that embed it — see handleRelatedTalkChange for // why only `update` is wired up (create/delete are covered by the accompanying meetup update). - action('talks.items.update', safeAction(HOOK_NAME, logger, (metadata, eventContext) => - handleRelatedTalkChange(metadata, eventContext, { - meetupHandler: handlers.meetupHandler, - client, - ItemsService, - logger, - env - }) - )) + action( + 'talks.items.update', + safeAction(HOOK_NAME, logger, (metadata, eventContext) => + handleRelatedTalkChange(metadata, eventContext, { + meetupHandler: handlers.meetupHandler, + client, + ItemsService, + logger, + env, + }) + ) + ) action('speakers.items.create', onUpdate(handlers.speakerHandler)) action('speakers.items.update', onUpdate(handlers.speakerHandler)) @@ -67,17 +72,20 @@ export default defineHook(({ action }, hookContext) => { action('transcripts.items.create', onUpdate(handlers.transcriptHandler)) action('transcripts.items.update', onUpdate(handlers.transcriptHandler)) action('transcripts.items.delete', onDelete(handlers.transcriptHandler)) -}); - -async function handleUpdateAction(metadata, eventContext, dependencies: { - handler: ItemHandler, - client: SearchClient, - ItemsService, - logger, - env -}){ - - const {handler, ItemsService, client, logger, env} = dependencies; +}) + +async function handleUpdateAction( + metadata, + eventContext, + dependencies: { + handler: ItemHandler + client: SearchClient + ItemsService + logger + env + } +) { + const { handler, ItemsService, client, logger, env } = dependencies // Directus passes `key` for single-item operations and `keys[]` for batch operations. const itemKey = metadata.key || (metadata.keys && metadata.keys[0]) @@ -122,37 +130,38 @@ async function handleUpdateAction(metadata, eventContext, dependencies: { * omit it, which forces a rebuild. */ async function reindexByKey(dependencies: { - handler: ItemHandler, - collection: string, - itemKey: any, - eventContext: any, - ItemsService, - client: SearchClient, - logger, - env, - changedPayload?: any, + handler: ItemHandler + collection: string + itemKey: any + eventContext: any + ItemsService + client: SearchClient + logger + env + changedPayload?: any }): Promise { - const { handler, collection, itemKey, eventContext, ItemsService, client, logger, env, changedPayload } = dependencies; + const { handler, collection, itemKey, eventContext, ItemsService, client, logger, env, changedPayload } = + dependencies const { fields: collectionFields } = eventContext.schema.collections[collection] const itemsService = new ItemsService(collection, { accountability: eventContext.accountability, schema: eventContext.schema, - }) as ItemsServiceType; + }) as ItemsServiceType // `status` is requested only when the collection actually has the field — asking Directus for a // non-existent field throws. - const fieldsToRead = collectionFields.status - ? [...handler.indexFields, 'status'] - : [...handler.indexFields] + const fieldsToRead = collectionFields.status ? [...handler.indexFields, 'status'] : [...handler.indexFields] const item = await itemsService.readOne(itemKey, { fields: fieldsToRead }) // Only published items belong in the index. Action hooks run *after* the write, so the item we // just read already reflects the new status; there is no need to also inspect the diff. if (collectionFields.status && item.status !== 'published') { - logger.info(`${HOOK_NAME} hook: Item "${itemKey}" is not published (status: "${item.status}"). Ensuring it is absent from the search index.`) + logger.info( + `${HOOK_NAME} hook: Item "${itemKey}" is not published (status: "${item.status}"). Ensuring it is absent from the search index.` + ) // Always issue the delete (it is idempotent): this covers depublishing as well as items that // were never indexed. We delete by the handler's deletion filter, NOT by a single objectID — // entries are stored as `_0`, `_1`, … (transcripts produce many chunks), so deleting @@ -176,7 +185,7 @@ async function reindexByKey(dependencies: { distinct: handler.buildDistinctKey(item), _directus_reference: handler.buildDirectusReference(item), } - }); + }) // Some handlers (transcripts) expand one item into many chunk entries, and the number of chunks // can change between saves, so we delete the previous entries before re-creating them. Building @@ -188,21 +197,27 @@ async function reindexByKey(dependencies: { } try { - await Promise.all(payloads.map(async (payload, index) => { - await client.partialUpdateObject({ - indexName: env.ALGOLIA_INDEX, - objectID: `${itemKey}_${index}`, - attributesToUpdate: payload, - createIfNotExists: true, - }); - })); + await Promise.all( + payloads.map(async (payload, index) => { + await client.partialUpdateObject({ + indexName: env.ALGOLIA_INDEX, + objectID: `${itemKey}_${index}`, + attributesToUpdate: payload, + createIfNotExists: true, + }) + }) + ) } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logger.error(`${HOOK_NAME} hook: Failed to update search index for "${handler.collectionName}" item "${itemKey}": ${errorMessage}`); - throw error; + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error( + `${HOOK_NAME} hook: Failed to update search index for "${handler.collectionName}" item "${itemKey}": ${errorMessage}` + ) + throw error } - logger.info(`${HOOK_NAME} hook: Updated search index "${env.ALGOLIA_INDEX}" for "${handler.collectionName}" item "${itemKey}"`) + logger.info( + `${HOOK_NAME} hook: Updated search index "${env.ALGOLIA_INDEX}" for "${handler.collectionName}" item "${itemKey}"` + ) } /** @@ -219,14 +234,18 @@ async function reindexByKey(dependencies: { * - delete: the talk can no longer be read to find its meetups; Directus removes the junction rows, * which updates the meetups' `talks` field and triggers `meetups.items.update` to refresh them. */ -async function handleRelatedTalkChange(metadata, eventContext, dependencies: { - meetupHandler: ItemHandler, - client: SearchClient, - ItemsService, - logger, - env -}) { - const { meetupHandler, client, ItemsService, logger, env } = dependencies; +async function handleRelatedTalkChange( + metadata, + eventContext, + dependencies: { + meetupHandler: ItemHandler + client: SearchClient + ItemsService + logger + env + } +) { + const { meetupHandler, client, ItemsService, logger, env } = dependencies const talkKey = metadata.key || (metadata.keys && metadata.keys[0]) if (!talkKey) { @@ -245,14 +264,12 @@ async function handleRelatedTalkChange(metadata, eventContext, dependencies: { const talksService = new ItemsService('talks', { accountability: eventContext.accountability, schema: eventContext.schema, - }) as ItemsServiceType; + }) as ItemsServiceType // `meetups` is the talk's reverse M2M alias → rows of the `meetups_talks` junction, each with a // `meetup` id. const talk = await talksService.readOne(talkKey, { fields: ['meetups.meetup'] }) - const meetupIds = (talk?.meetups ?? []) - .map((row: any) => row?.meetup) - .filter(Boolean) + const meetupIds = (talk?.meetups ?? []).map((row: any) => row?.meetup).filter(Boolean) if (meetupIds.length === 0) { logger.info(`${HOOK_NAME} hook: Talk "${talkKey}" is not linked to any meetup; nothing to reindex.`) @@ -276,13 +293,17 @@ async function handleRelatedTalkChange(metadata, eventContext, dependencies: { } } -async function handleDeleteAction(metadata, eventContext, dependencies: { - handler: ItemHandler, - client: SearchClient, - logger, - env -}) { - const {handler, client, logger, env} = dependencies; +async function handleDeleteAction( + metadata, + eventContext, + dependencies: { + handler: ItemHandler + client: SearchClient + logger + env + } +) { + const { handler, client, logger, env } = dependencies const itemKey = metadata.key || (metadata.keys && metadata.keys[0]) if (!itemKey) { @@ -292,7 +313,9 @@ async function handleDeleteAction(metadata, eventContext, dependencies: { const deletedIds = await deleteFromIndex({ handler, client, env, itemKey }) - logger.info(`${HOOK_NAME} hook: Removed item(s) "${JSON.stringify(deletedIds)}" from search index via filter ${handler.buildDeletionFilter({ id: itemKey })}`) + logger.info( + `${HOOK_NAME} hook: Removed item(s) "${JSON.stringify(deletedIds)}" from search index via filter ${handler.buildDeletionFilter({ id: itemKey })}` + ) } /** @@ -305,12 +328,12 @@ async function handleDeleteAction(metadata, eventContext, dependencies: { * delete paths so deletion behaves identically everywhere. */ async function deleteFromIndex(dependencies: { - handler: ItemHandler, - client: SearchClient, - env, - itemKey, + handler: ItemHandler + client: SearchClient + env + itemKey }): Promise { - const { handler, client, env, itemKey } = dependencies; + const { handler, client, env, itemKey } = dependencies const results = await client.browseObjects({ indexName: env.ALGOLIA_INDEX, @@ -318,18 +341,18 @@ async function deleteFromIndex(dependencies: { attributesToRetrieve: ['objectID'], browseParams: { filters: handler.buildDeletionFilter({ id: itemKey }), - } - }); + }, + }) - const idsForDeletion = results.hits.map((hit: any) => hit.objectID); + const idsForDeletion = results.hits.map((hit: any) => hit.objectID) if (idsForDeletion.length === 0) { - return []; + return [] } await client.deleteObjects({ indexName: env.ALGOLIA_INDEX, objectIDs: idsForDeletion, - }); + }) - return idsForDeletion; + return idsForDeletion } diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/util/pagination.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/util/pagination.ts index 10e99d2f..e768b4e6 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/util/pagination.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/util/pagination.ts @@ -1,5 +1,5 @@ -import { readItems } from '@directus/sdk'; -import type { ItemHandler } from '../handlers/ItemHandler.ts'; +import { readItems } from '@directus/sdk' +import type { ItemHandler } from '../handlers/ItemHandler.ts' // Shared bulk-read helpers for the rebuild/repair CLIs. // @@ -17,12 +17,8 @@ import type { ItemHandler } from '../handlers/ItemHandler.ts'; * on), because it never holds more than a single page in memory. This is what makes rebuilding the * transcript index — where each row carries a full hour of audio transcription — feasible. */ -export async function* streamItems( - client: any, - collection: string, - handler: ItemHandler, -): AsyncGenerator { - const pageSize = handler.pageSize; +export async function* streamItems(client: any, collection: string, handler: ItemHandler): AsyncGenerator { + const pageSize = handler.pageSize // No pagination: fetch the whole collection in one request. if (pageSize <= 0) { @@ -30,29 +26,29 @@ export async function* streamItems( readItems(collection, { fields: handler.indexFields, limit: -1, - }), - ); - yield* items; - return; + }) + ) + yield* items + return } // Paged read. Directus pages are 1-based; a short (or empty) page means we've reached the end. - let page = 1; + let page = 1 while (true) { const batch = await client.request( readItems(collection, { fields: handler.indexFields, limit: pageSize, page, - }), - ); + }) + ) - yield* batch; + yield* batch if (batch.length < pageSize) { - break; + break } - page++; + page++ } } @@ -61,14 +57,10 @@ export async function* streamItems( * whole collection in memory at once — e.g. the repair CLI, which diffs the database against the * index. Otherwise prefer streamItems() to keep the memory footprint bounded. */ -export async function collectItems( - client: any, - collection: string, - handler: ItemHandler, -): Promise { - const items: any[] = []; +export async function collectItems(client: any, collection: string, handler: ItemHandler): Promise { + const items: any[] = [] for await (const item of streamItems(client, collection, handler)) { - items.push(item); + items.push(item) } - return items; + return items } diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/util/sanitizer.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/util/sanitizer.ts index cde91f35..e1463eaa 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/util/sanitizer.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/algolia-index/util/sanitizer.ts @@ -1,19 +1,19 @@ -import sanitizeHtml from 'sanitize-html'; +import sanitizeHtml from 'sanitize-html' export function sanitize(input: string): string { return sanitizeHtml(input, { - allowedTags: [ 'a', 'p', 'ul', 'li' ], + allowedTags: ['a', 'p', 'ul', 'li'], allowedAttributes: { - 'a': [ 'href' ] - } - }); + a: ['href'], + }, + }) } export function sanitizeFull(input: string): string { return sanitizeHtml(input, { - allowedTags: [ ], - allowedAttributes: { } - }); + allowedTags: [], + allowedAttributes: {}, + }) } // Hard-trims a string so its UTF-8 byte length never exceeds `maxBytes`. Algolia measures records in @@ -23,23 +23,23 @@ export function sanitizeFull(input: string): string { // Trims on a word boundary where one is reasonably close, to avoid cutting mid-word. export function truncateToByteLimit(input: string, maxBytes: number): string { if (Buffer.byteLength(input, 'utf8') <= maxBytes) { - return input; + return input } // Converge from the overshoot: each pass drops roughly the number of surplus bytes, so we reach // the budget in a handful of iterations regardless of how multi-byte the text is. - let truncated = input; + let truncated = input while (Buffer.byteLength(truncated, 'utf8') > maxBytes && truncated.length > 0) { - const overshootBytes = Buffer.byteLength(truncated, 'utf8') - maxBytes; - const charsToDrop = Math.max(1, Math.ceil(overshootBytes / 2)); - truncated = truncated.slice(0, truncated.length - charsToDrop); + const overshootBytes = Buffer.byteLength(truncated, 'utf8') - maxBytes + const charsToDrop = Math.max(1, Math.ceil(overshootBytes / 2)) + truncated = truncated.slice(0, truncated.length - charsToDrop) } // Prefer ending at the last word boundary, but only if we don't throw away too much. - const lastSpace = truncated.lastIndexOf(' '); + const lastSpace = truncated.lastIndexOf(' ') if (lastSpace > truncated.length * 0.8) { - truncated = truncated.slice(0, lastSpace); + truncated = truncated.slice(0, lastSpace) } - return truncated.trimEnd(); + return truncated.trimEnd() } diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/asset-generation/generateAssets.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/asset-generation/generateAssets.ts index 28c8d5d3..0111b7c0 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/asset-generation/generateAssets.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/asset-generation/generateAssets.ts @@ -142,7 +142,11 @@ function buildTemplateVariables( /** * Generate assets for a single podcast */ -export async function generateAssetsForPodcast(hookName: string, podcastId: number, services: HookServices): Promise { +export async function generateAssetsForPodcast( + hookName: string, + podcastId: number, + services: HookServices +): Promise { const { logger, ItemsService, FilesService, AssetsService, getSchema, env, accountability } = services const geminiApiKey = env.GEMINI_API_KEY @@ -248,7 +252,9 @@ export async function generateAssetsForPodcast(hookName: string, podcastId: numb const haystack = [podcast.title, podcast.number].filter(Boolean).join(' ').toLowerCase() const matches = haystack.includes(t.title_contains.toLowerCase()) if (!matches) { - logger.info(`${hookName}: Skipping template "${t.name}" - title/number doesn't contain "${t.title_contains}"`) + logger.info( + `${hookName}: Skipping template "${t.name}" - title/number doesn't contain "${t.title_contains}"` + ) } return matches }) @@ -328,7 +334,12 @@ export async function generateAssetsForPodcast(hookName: string, podcastId: numb // Add speaker profile image if required and available if (template.requires_speaker_image && speakerProfileImageId) { - const speakerImageData = await getFileAsBase64(speakerProfileImageId, filesService, assetsService, logger) + const speakerImageData = await getFileAsBase64( + speakerProfileImageId, + filesService, + assetsService, + logger + ) if (speakerImageData) { inputImages.push(speakerImageData) } diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/asset-generation/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/asset-generation/index.ts index 0a740c13..75b33ede 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/asset-generation/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/asset-generation/index.ts @@ -1,7 +1,7 @@ import { defineHook } from '@directus/extensions-sdk' -import { generateAssetsForPodcast, regenerateAssets } from './generateAssets.ts' import { postSlackMessage } from '../shared/postSlackMessage.ts' import { safeAction } from '../shared/safeHook.ts' +import { generateAssetsForPodcast, regenerateAssets } from './generateAssets.ts' const HOOK_NAME = 'asset-generation' @@ -17,52 +17,118 @@ export default defineHook(({ action }, hookContext) => { * Trigger asset generation when a speaker's portal submission is approved. * This indicates the speaker has submitted their info and images, and an admin approved them. */ - action('speakers.items.update', safeAction(HOOK_NAME, logger, async ({ payload, keys }, context) => { - // Only trigger when portal_submission_status changes to 'approved' - if (payload.portal_submission_status !== 'approved') { - return - } - - const speakerId = keys?.[0] - if (!speakerId) { - logger.warn(`${HOOK_NAME}: No speaker ID found in update`) - return - } - - logger.info(`${HOOK_NAME}: Speaker ${speakerId} submission approved, finding linked podcasts`) - - try { - const schema = await getSchema() - - // Find podcasts that have this speaker by querying the speaker's podcasts relation - const speakersService = new ItemsService('speakers', { - schema, - accountability: context.accountability, - }) - - const speaker = await speakersService.readOne(speakerId, { - fields: ['id', 'podcasts.podcast'], - }) - - if (!speaker?.podcasts || speaker.podcasts.length === 0) { - logger.info(`${HOOK_NAME}: No podcasts linked to speaker ${speakerId}`) + action( + 'speakers.items.update', + safeAction(HOOK_NAME, logger, async ({ payload, keys }, context) => { + // Only trigger when portal_submission_status changes to 'approved' + if (payload.portal_submission_status !== 'approved') { + return + } + + const speakerId = keys?.[0] + if (!speakerId) { + logger.warn(`${HOOK_NAME}: No speaker ID found in update`) return } - const podcastIds = speaker.podcasts - .map((p: { podcast: number | string }) => p.podcast) - .filter(Boolean) + logger.info(`${HOOK_NAME}: Speaker ${speakerId} submission approved, finding linked podcasts`) + + try { + const schema = await getSchema() + + // Find podcasts that have this speaker by querying the speaker's podcasts relation + const speakersService = new ItemsService('speakers', { + schema, + accountability: context.accountability, + }) + + const speaker = await speakersService.readOne(speakerId, { + fields: ['id', 'podcasts.podcast'], + }) - if (podcastIds.length === 0) { - logger.info(`${HOOK_NAME}: No valid podcast IDs found for speaker ${speakerId}`) + if (!speaker?.podcasts || speaker.podcasts.length === 0) { + logger.info(`${HOOK_NAME}: No podcasts linked to speaker ${speakerId}`) + return + } + + const podcastIds = speaker.podcasts.map((p: { podcast: number | string }) => p.podcast).filter(Boolean) + + if (podcastIds.length === 0) { + logger.info(`${HOOK_NAME}: No valid podcast IDs found for speaker ${speakerId}`) + return + } + + logger.info(`${HOOK_NAME}: Found ${podcastIds.length} podcast(s) linked to speaker ${speakerId}`) + + // Generate assets for each podcast (async, non-blocking) + for (const podcastId of podcastIds) { + generateAssetsForPodcast(HOOK_NAME, podcastId, { + logger, + ItemsService, + FilesService, + AssetsService, + getSchema, + env, + accountability: context.accountability, + }).catch(async (err) => { + logger.error(`${HOOK_NAME}: Asset generation failed for podcast ${podcastId}: ${err.message}`) + + try { + const podcastUrl = `${env.PUBLIC_URL}admin/content/podcasts/${podcastId}` + await postSlackMessage( + `:warning: *${HOOK_NAME}*: Asset generation failed for podcast ${podcastId}.\n` + + `Error: ${err.message}\n` + + `Podcast: ${podcastUrl}` + ) + } catch (slackErr: any) { + logger.error(`${HOOK_NAME}: Failed to send Slack notification: ${slackErr.message}`) + } + }) + } + } catch (err: any) { + logger.error(`${HOOK_NAME}: Error processing speaker approval: ${err.message}`) + } + }) + ) + + /** + * Trigger asset regeneration when regenerate_assets is set to true on a podcast. + */ + action( + 'podcasts.items.update', + safeAction(HOOK_NAME, logger, async ({ payload, keys }, context) => { + // Only trigger when regenerate_assets is set to true + if (payload.regenerate_assets !== true) { return } - logger.info(`${HOOK_NAME}: Found ${podcastIds.length} podcast(s) linked to speaker ${speakerId}`) + if (!keys || keys.length === 0) { + logger.warn(`${HOOK_NAME}: No podcast IDs found in update`) + return + } + + logger.info(`${HOOK_NAME}: Regenerate assets requested for ${keys.length} podcast(s)`) + + // Reset the flag immediately for all podcasts + try { + const schema = await getSchema() + const podcastsService = new ItemsService('podcasts', { + schema, + accountability: context.accountability, + }) + + for (const podcastId of keys) { + await podcastsService.updateOne(podcastId, { + regenerate_assets: false, + }) + } + } catch (err: any) { + logger.error(`${HOOK_NAME}: Failed to reset regenerate_assets flag: ${err.message}`) + } - // Generate assets for each podcast (async, non-blocking) - for (const podcastId of podcastIds) { - generateAssetsForPodcast(HOOK_NAME, podcastId, { + // Regenerate assets for each podcast (async, non-blocking) + for (const podcastId of keys) { + regenerateAssets(HOOK_NAME, podcastId, { logger, ItemsService, FilesService, @@ -71,12 +137,12 @@ export default defineHook(({ action }, hookContext) => { env, accountability: context.accountability, }).catch(async (err) => { - logger.error(`${HOOK_NAME}: Asset generation failed for podcast ${podcastId}: ${err.message}`) + logger.error(`${HOOK_NAME}: Asset regeneration failed for podcast ${podcastId}: ${err.message}`) try { const podcastUrl = `${env.PUBLIC_URL}admin/content/podcasts/${podcastId}` await postSlackMessage( - `:warning: *${HOOK_NAME}*: Asset generation failed for podcast ${podcastId}.\n` + + `:warning: *${HOOK_NAME}*: Asset regeneration failed for podcast ${podcastId}.\n` + `Error: ${err.message}\n` + `Podcast: ${podcastUrl}` ) @@ -85,68 +151,6 @@ export default defineHook(({ action }, hookContext) => { } }) } - } catch (err: any) { - logger.error(`${HOOK_NAME}: Error processing speaker approval: ${err.message}`) - } - })) - - /** - * Trigger asset regeneration when regenerate_assets is set to true on a podcast. - */ - action('podcasts.items.update', safeAction(HOOK_NAME, logger, async ({ payload, keys }, context) => { - // Only trigger when regenerate_assets is set to true - if (payload.regenerate_assets !== true) { - return - } - - if (!keys || keys.length === 0) { - logger.warn(`${HOOK_NAME}: No podcast IDs found in update`) - return - } - - logger.info(`${HOOK_NAME}: Regenerate assets requested for ${keys.length} podcast(s)`) - - // Reset the flag immediately for all podcasts - try { - const schema = await getSchema() - const podcastsService = new ItemsService('podcasts', { - schema, - accountability: context.accountability, - }) - - for (const podcastId of keys) { - await podcastsService.updateOne(podcastId, { - regenerate_assets: false, - }) - } - } catch (err: any) { - logger.error(`${HOOK_NAME}: Failed to reset regenerate_assets flag: ${err.message}`) - } - - // Regenerate assets for each podcast (async, non-blocking) - for (const podcastId of keys) { - regenerateAssets(HOOK_NAME, podcastId, { - logger, - ItemsService, - FilesService, - AssetsService, - getSchema, - env, - accountability: context.accountability, - }).catch(async (err) => { - logger.error(`${HOOK_NAME}: Asset regeneration failed for podcast ${podcastId}: ${err.message}`) - - try { - const podcastUrl = `${env.PUBLIC_URL}admin/content/podcasts/${podcastId}` - await postSlackMessage( - `:warning: *${HOOK_NAME}*: Asset regeneration failed for podcast ${podcastId}.\n` + - `Error: ${err.message}\n` + - `Podcast: ${podcastUrl}` - ) - } catch (slackErr: any) { - logger.error(`${HOOK_NAME}: Failed to send Slack notification: ${slackErr.message}`) - } - }) - } - })) + }) + ) }) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/buzzsprout/handlers/buzzsprout.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/buzzsprout/handlers/buzzsprout.ts index d884ad00..4a9a00e9 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/buzzsprout/handlers/buzzsprout.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/buzzsprout/handlers/buzzsprout.ts @@ -1,5 +1,5 @@ -import type { AxiosRequestConfig} from 'axios'; -import { default as axios } from 'axios'; +import type { AxiosRequestConfig } from 'axios' +import { default as axios } from 'axios' // @ts-ignore import { getFullPodcastTitle, getUrlSlug } from '../../../../../../shared-code/index.ts' import { postSlackMessage } from '../../shared/postSlackMessage.ts' @@ -196,7 +196,9 @@ export async function handleBuzzsprout( const buzzsproutResponse = await axios(requestConfig) - logger.info(`${HOOK_NAME} hook: Received response (${buzzsproutResponse.status} / ${buzzsproutResponse.statusText}) from buzzsprout: ${JSON.stringify(buzzsproutResponse.data)}`) + logger.info( + `${HOOK_NAME} hook: Received response (${buzzsproutResponse.status} / ${buzzsproutResponse.statusText}) from buzzsprout: ${JSON.stringify(buzzsproutResponse.data)}` + ) // Throw error if the request was not successful if (buzzsproutResponse.status !== 200 && buzzsproutResponse.status !== 201) { @@ -210,12 +212,12 @@ export async function handleBuzzsprout( // If an error occurs, log it and inform team via Slack } catch (error: any) { - if ( error['message']) { + if (error['message']) { logger.error(`${HOOK_NAME} hook: Error message: "${error.message}"`) - } else if ( typeof error['toString'] === 'function') { - logger.error(`${HOOK_NAME} hook: "${typeof error}" Error toString: "${error.toString()}"`) + } else if (typeof error['toString'] === 'function') { + logger.error(`${HOOK_NAME} hook: "${typeof error}" Error toString: "${error.toString()}"`) } - if ( error['response']) { + if (error['response']) { logger.error(`${HOOK_NAME} hook: Error response payload: "${JSON.stringify(error.response.data)}"`) } try { diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/buzzsprout/handlers/handlePickOfTheDayAction.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/buzzsprout/handlers/handlePickOfTheDayAction.ts index f8a439e8..1e1f5b66 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/buzzsprout/handlers/handlePickOfTheDayAction.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/buzzsprout/handlers/handlePickOfTheDayAction.ts @@ -1,5 +1,5 @@ +import { createHookErrorConstructor } from '../../shared/errors.ts' import { handleBuzzsprout } from './buzzsprout.ts' -import { createHookErrorConstructor } from '../../shared/errors.ts'; import { getPodcastData } from './podcastData.ts' import type { ActionData, Dependencies, Payload, PickOfTheDayPayload } from './types.ts' diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/buzzsprout/handlers/handlePodcastAction.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/buzzsprout/handlers/handlePodcastAction.ts index f912b56c..37c533f5 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/buzzsprout/handlers/handlePodcastAction.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/buzzsprout/handlers/handlePodcastAction.ts @@ -1,7 +1,7 @@ +import { createHookErrorConstructor } from '../../shared/errors.ts' import { handleBuzzsprout } from './buzzsprout.ts' import { getPodcastData } from './podcastData.ts' import type { ActionData, BuzzsproutData, Dependencies, PodcastData } from './types.ts' -import { createHookErrorConstructor } from '../../shared/errors.ts'; /** * It handles the podcast action and creates or updates @@ -62,8 +62,7 @@ export async function handlePodcastAction( (payload.status || payload.published_on || payload.type || - (podcastItem.number || podcastItem.type === 'other') && - payload.title || + ((podcastItem.number || podcastItem.type === 'other') && payload.title) || payload.description || payload.cover_image || payload.audio_file) @@ -84,7 +83,7 @@ export async function handlePodcastAction( if (!buzzsproutData) { logger.error(`${HOOK_NAME} hook: No data returned from handleBuzzsprout`) - throw new Error("Did not receive Buzzsprout data.") + throw new Error('Did not receive Buzzsprout data.') } // Create update data object @@ -109,7 +108,9 @@ export async function handlePodcastAction( return } - logger.info(`${HOOK_NAME} hook: Updating podcast item with id "${itemKey}" and data: ${JSON.stringify(updateData)}`) + logger.info( + `${HOOK_NAME} hook: Updating podcast item with id "${itemKey}" and data: ${JSON.stringify(updateData)}` + ) // If update data contains something, update podcast item await podcastItemsService.updateOne(itemKey, updateData) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/buzzsprout/handlers/handleTagAction.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/buzzsprout/handlers/handleTagAction.ts index 5ef8d62a..2365831a 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/buzzsprout/handlers/handleTagAction.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/buzzsprout/handlers/handleTagAction.ts @@ -1,7 +1,7 @@ +import { createHookErrorConstructor } from '../../shared/errors.ts' import { handleBuzzsprout } from './buzzsprout.ts' import { getPodcastData } from './podcastData.ts' import type { ActionData, Dependencies, Payload, PodcastData } from './types.ts' -import { createHookErrorConstructor } from '../../shared/errors.ts'; /** * It handles the tag action and updates podcast diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/buzzsprout/handlers/podcastData.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/buzzsprout/handlers/podcastData.ts index 1778c201..ed38a74b 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/buzzsprout/handlers/podcastData.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/buzzsprout/handlers/podcastData.ts @@ -18,7 +18,7 @@ export async function getPodcastData( ): Promise { const { logger, ItemsService } = dependencies - let pickOfTheDayItems: PickOfTheDay[] = []; + let pickOfTheDayItems: PickOfTheDay[] = [] // !!!! // This will currently fail due to permission issues, most likely diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/buzzsprout/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/buzzsprout/index.ts index 2f0f6468..89746283 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/buzzsprout/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/buzzsprout/index.ts @@ -1,18 +1,20 @@ import { defineHook } from '@directus/extensions-sdk' +import { safeAction } from '../shared/safeHook.ts' import { handlePickOfTheDayAction } from './handlers/handlePickOfTheDayAction.ts' import { handlePodcastAction } from './handlers/handlePodcastAction.ts' import { handleTagAction } from './handlers/handleTagAction.ts' -import { safeAction } from '../shared/safeHook.ts' const HOOK_NAME = 'buzzsprout' export default defineHook(({ action }, hookContext) => { - const logger = hookContext.logger; - const env = hookContext.env; - const ItemsService = hookContext.services.ItemsService; + const logger = hookContext.logger + const env = hookContext.env + const ItemsService = hookContext.services.ItemsService if (!(env.BUZZSPROUT_API_URL && env.BUZZSPROUT_API_TOKEN)) { - logger.warn(`${HOOK_NAME} hook: Did not set BUZZSPROUT_API_URL && BUZZSPROUT_API_TOKEN. Buzzsprout extension will not be active.`) + logger.warn( + `${HOOK_NAME} hook: Did not set BUZZSPROUT_API_URL && BUZZSPROUT_API_TOKEN. Buzzsprout extension will not be active.` + ) return } @@ -61,4 +63,3 @@ export default defineHook(({ action }, hookContext) => { action('tags.items.create', safeAction(HOOK_NAME, logger, tagHandler)) action('tags.items.update', safeAction(HOOK_NAME, logger, tagHandler)) }) - diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/cascade-publish/__tests__/index.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/cascade-publish/__tests__/index.test.ts index 3383cafc..af0647b6 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/cascade-publish/__tests__/index.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/cascade-publish/__tests__/index.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, jest, test } from '@jest/globals' +import { postSlackMessage } from './../../shared/postSlackMessage.ts' +import registerHook from './../index.ts' // The extensions SDK ships as ESM and is not transformed under Jest's CJS mode, // so stub it. The real `defineHook` simply returns its callback. @@ -12,9 +14,6 @@ jest.mock('./../../shared/postSlackMessage.ts', () => ({ postSlackMessage: jest.fn(), })) -import { postSlackMessage } from './../../shared/postSlackMessage.ts' -import registerHook from './../index.ts' - const postSlackMessageMock = jest.mocked(postSlackMessage) // Handlers are registered through `safeAction`, which detaches the work into its @@ -24,7 +23,11 @@ const flush = () => new Promise((resolve) => setImmediate(resolve)) type Handler = (metadata: Record, eventContext: Record) => void -async function invoke(handler: Handler, metadata: Record, eventContext: Record = { accountability: {} }) { +async function invoke( + handler: Handler, + metadata: Record, + eventContext: Record = { accountability: {} } +) { handler(metadata, eventContext) await flush() } @@ -210,10 +213,7 @@ describe('cascade-publish hook', () => { test('does not change archived related items', async () => { const { handlers, updateOneCalls } = setup({ parentItem: { - speakers: [ - { speaker: { id: 'sp1', status: 'archived' } }, - { speaker: { id: 'sp2', status: 'draft' } }, - ], + speakers: [{ speaker: { id: 'sp1', status: 'archived' } }, { speaker: { id: 'sp2', status: 'draft' } }], picks_of_the_day: [{ id: 'pick1', status: 'archived' }], }, }) @@ -254,7 +254,10 @@ describe('cascade-publish hook', () => { parentItem: { speakers: [{ speaker: { id: 'sp1', status: 'draft' } }], picks_of_the_day: [] }, }) - await invoke(handlers.get('podcasts.items.update')!, { keys: ['pod1', 'pod2'], payload: { status: 'published' } }) + await invoke(handlers.get('podcasts.items.update')!, { + keys: ['pod1', 'pod2'], + payload: { status: 'published' }, + }) expect(updateOneCalls).toEqual([ { collection: 'speakers', id: 'sp1', data: { status: 'published' } }, @@ -282,10 +285,7 @@ describe('cascade-publish hook', () => { test('publishes complete children while skipping incomplete ones in the same relation', async () => { const { handlers, updateOneCalls } = setup({ parentItem: { - speakers: [ - { speaker: { id: 'sp1', status: 'draft' } }, - { speaker: { id: 'sp2', status: 'draft' } }, - ], + speakers: [{ speaker: { id: 'sp1', status: 'draft' } }, { speaker: { id: 'sp2', status: 'draft' } }], picks_of_the_day: [], }, fieldsByCollection: { speakers: [requiredField('first_name')] }, diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/cascade-publish/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/cascade-publish/index.ts index 8688879e..3f3ff24e 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/cascade-publish/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/cascade-publish/index.ts @@ -147,7 +147,9 @@ export default defineHook(({ action }, hookContext) => { if (skipped.length > 0) { await notifySlack( `:warning: *${HOOK_NAME}*: Folgende mit ${parentCollection} ${parentKey} verknüpfte Einträge konnten nicht automatisch veröffentlicht werden, da Pflichtfelder fehlen. Bitte manuell prüfen und veröffentlichen:\n` + - skipped.map((item) => `${env.PUBLIC_URL}admin/content/${item.collection}/${item.id}`).join('\n') + skipped + .map((item) => `${env.PUBLIC_URL}admin/content/${item.collection}/${item.id}`) + .join('\n') ) } } catch (error: any) { diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/conference/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/conference/index.ts index 9dbeebd1..847ddc4a 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/conference/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/conference/index.ts @@ -1,18 +1,16 @@ /// -import { defineEndpoint } from '@directus/extensions-sdk'; - -import type { SandboxEndpointRouter } from 'directus:api'; +import { defineEndpoint } from '@directus/extensions-sdk' +import type { SandboxEndpointRouter } from 'directus:api' export default defineEndpoint(async (router: SandboxEndpointRouter, context) => { - const logger = context.logger const ItemsService = context.services.ItemsService router.get('/:identifier', async (req, res) => { - const conferenceIdentifier = req.params.identifier; - let conference = null; - let conferenceItemsService = null; - let talkItemService = null; + const conferenceIdentifier = req.params.identifier + let conference = null + let conferenceItemsService = null + let talkItemService = null try { conferenceItemsService = new ItemsService('conferences', { @@ -26,34 +24,37 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => knex: context.database, }) } catch (error: any) { - logger.error('Could not initialize item services: ' + error.message + '.'); + logger.error('Could not initialize item services: ' + error.message + '.') res.status(500).send({}) - return; + return } try { - const conferences = await conferenceItemsService.readByQuery({filter: {'_or': [{id: {'_eq': conferenceIdentifier}}, {slug: {'_eq': conferenceIdentifier}}]}, limit: 1}); + const conferences = await conferenceItemsService.readByQuery({ + filter: { _or: [{ id: { _eq: conferenceIdentifier } }, { slug: { _eq: conferenceIdentifier } }] }, + limit: 1, + }) if (conferences.length > 0) { - conference = conferences[0]; + conference = conferences[0] } else { res.status(404).send({}) - return; + return } } catch (error: any) { - logger.error('Could not fetch conference item: ' + error.message + '.'); + logger.error('Could not fetch conference item: ' + error.message + '.') res.status(500).send({}) - return; + return } if (!conference) { - logger.warn('Requested unknown conference: ' + conferenceIdentifier + '.'); - res.status(404).send({}); - return; + logger.warn('Requested unknown conference: ' + conferenceIdentifier + '.') + res.status(404).send({}) + return } conference.agenda = await Promise.all( - conference.agenda.map(async (agenda_item: {talk_identifier: null|string}) => { - let talk_object = null; + conference.agenda.map(async (agenda_item: { talk_identifier: null | string }) => { + let talk_object = null if (agenda_item.talk_identifier) { talk_object = await talkItemService.readOne(agenda_item.talk_identifier, { fields: [ @@ -64,25 +65,24 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => 'members.*', 'members.member', 'members.member.*', - ] - }); + ], + }) if (talk_object.speakers) { - talk_object.speakers = talk_object.speakers.map(speaker => speaker.speaker) + talk_object.speakers = talk_object.speakers.map((speaker) => speaker.speaker) } if (talk_object.members) { - talk_object.members = talk_object.members.map(member => member.member) + talk_object.members = talk_object.members.map((member) => member.member) } - } return { ...agenda_item, - talk_object + talk_object, } }) ) - res.send({conference: conference}); - }); -}); + res.send({ conference: conference }) + }) +}) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/content-approval/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/content-approval/index.ts index 821edc57..16c15d4e 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/content-approval/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/content-approval/index.ts @@ -9,15 +9,18 @@ export default defineHook(({ action }, hookContext) => { const getSchema = hookContext.getSchema // Listen for updates to podcast_generated_content - action('podcast_generated_content.items.update', safeAction(HOOK_NAME, logger, async function (metadata, eventContext) { - const { payload, keys } = metadata - - if (payload.status === 'approved') { - await handleApproval(keys, eventContext) - } else if (payload.status && payload.status !== 'approved') { - await handleUnapproval(keys, eventContext) - } - })) + action( + 'podcast_generated_content.items.update', + safeAction(HOOK_NAME, logger, async function (metadata, eventContext) { + const { payload, keys } = metadata + + if (payload.status === 'approved') { + await handleApproval(keys, eventContext) + } else if (payload.status && payload.status !== 'approved') { + await handleUnapproval(keys, eventContext) + } + }) + ) async function handleApproval(keys: string[], eventContext: any) { try { @@ -64,8 +67,7 @@ export default defineHook(({ action }, hookContext) => { fields: ['id', 'status'], }) - const allApproved = - allContent.length > 0 && allContent.every((c: any) => c.status === 'approved') + const allApproved = allContent.length > 0 && allContent.every((c: any) => c.status === 'approved') if (allApproved) { logger.info( diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/create-news/__tests__/index.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/create-news/__tests__/index.test.ts index 0e70f3fa..aafeb7bb 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/create-news/__tests__/index.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/create-news/__tests__/index.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, jest, test } from '@jest/globals' +import { postSlackMessage } from './../../shared/postSlackMessage.ts' +import registerHook from './../index.ts' // The extensions SDK ships as ESM and is not transformed under Jest's CJS mode, // so stub it. The real `defineHook` simply returns its callback. @@ -24,9 +26,6 @@ jest.mock('./../../shared/errors.ts', () => ({ }, })) -import { postSlackMessage } from './../../shared/postSlackMessage.ts' -import registerHook from './../index.ts' - const postSlackMessageMock = jest.mocked(postSlackMessage) // Action handlers run through `safeAction`, which detaches the work into its own @@ -199,7 +198,10 @@ describe('create-news hook', () => { await invokeAction(handler, { key: 'link-2', payload: { title: 'React 19 Released' } }) - expect(recorded.createOne[0]).toEqual({ collection: 'news', data: { status: 'draft', slug: 'react-19-released-2' } }) + expect(recorded.createOne[0]).toEqual({ + collection: 'news', + data: { status: 'draft', slug: 'react-19-released-2' }, + }) }) test('create: creates the news without a slug when the title is empty', async () => { diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/create-news/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/create-news/index.ts index 901deac2..c77fcfe1 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/create-news/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/create-news/index.ts @@ -124,7 +124,9 @@ export default defineHook(({ action, filter }, hookContext) => { limit: 1, }) if (existing.length > 0) { - logger.info(`${HOOK_NAME}: ${SOURCE_COLLECTION} ${newsLinkId} is already linked to a news item, skipping`) + logger.info( + `${HOOK_NAME}: ${SOURCE_COLLECTION} ${newsLinkId} is already linked to a news item, skipping` + ) continue } @@ -159,9 +161,13 @@ export default defineHook(({ action, filter }, hookContext) => { throw junctionError } - logger.info(`${HOOK_NAME}: Created news ${newsId} for ${SOURCE_COLLECTION} ${newsLinkId} (slug: ${slug ?? 'none'})`) + logger.info( + `${HOOK_NAME}: Created news ${newsId} for ${SOURCE_COLLECTION} ${newsLinkId} (slug: ${slug ?? 'none'})` + ) } catch (error: any) { - logger.error(`${HOOK_NAME}: Failed to create news item for ${SOURCE_COLLECTION} ${newsLinkId}: ${error.message}`) + logger.error( + `${HOOK_NAME}: Failed to create news item for ${SOURCE_COLLECTION} ${newsLinkId}: ${error.message}` + ) await notifySlack( `:warning: *${HOOK_NAME}*: Für den News-Link ${newsLinkId} konnte kein verknüpfter News-Eintrag erstellt werden. Der Link ist dadurch nicht über die News-Sammlung abrufbar. Bitte manuell prüfen:\n` + `Fehler: ${error.message}\n` + @@ -215,9 +221,13 @@ export default defineHook(({ action, filter }, hookContext) => { } await newsService.updateOne(newsId, { slug }) - logger.info(`${HOOK_NAME}: Updated slug of news ${newsId} to "${slug}" from ${SOURCE_COLLECTION} ${newsLinkId}`) + logger.info( + `${HOOK_NAME}: Updated slug of news ${newsId} to "${slug}" from ${SOURCE_COLLECTION} ${newsLinkId}` + ) } catch (error: any) { - logger.error(`${HOOK_NAME}: Failed to sync slug for ${SOURCE_COLLECTION} ${newsLinkId}: ${error.message}`) + logger.error( + `${HOOK_NAME}: Failed to sync slug for ${SOURCE_COLLECTION} ${newsLinkId}: ${error.message}` + ) await notifySlack( `:warning: *${HOOK_NAME}*: Der Slug des News-Eintrags für den Link ${newsLinkId} konnte nicht aktualisiert werden.\n` + `Fehler: ${error.message}` @@ -270,7 +280,9 @@ export default defineHook(({ action, filter }, hookContext) => { `${HOOK_NAME}: Removed ${junctionRows.length} junction row(s) and ${newsIds.length} news item(s) for deleted ${SOURCE_COLLECTION} ${newsLinkId}` ) } catch (error: any) { - logger.error(`${HOOK_NAME}: Failed to clean up news item for deleted ${SOURCE_COLLECTION} ${newsLinkId}: ${error.message}`) + logger.error( + `${HOOK_NAME}: Failed to clean up news item for deleted ${SOURCE_COLLECTION} ${newsLinkId}: ${error.message}` + ) await notifySlack( `:warning: *${HOOK_NAME}*: Nach dem Löschen des News-Links ${newsLinkId} konnte der verknüpfte News-Eintrag nicht aufgeräumt werden. Es könnte ein verwaister Eintrag zurückbleiben.\n` + `Fehler: ${error.message}` @@ -331,7 +343,9 @@ export default defineHook(({ action, filter }, hookContext) => { } } } catch (error: any) { - logger.error(`${HOOK_NAME}: Publish guard could not verify source links, allowing publish: ${error.message}`) + logger.error( + `${HOOK_NAME}: Publish guard could not verify source links, allowing publish: ${error.message}` + ) await notifySlack( `:warning: *${HOOK_NAME}*: Die Pflichtfeld-Prüfung vor dem Veröffentlichen von News konnte nicht ausgeführt werden. Die Veröffentlichung wurde trotzdem zugelassen.\n` + `Fehler: ${error.message}` @@ -411,7 +425,9 @@ export default defineHook(({ action, filter }, hookContext) => { } if (linkIds.length > 0) { - logger.info(`${HOOK_NAME}: Mirrored status "${newStatus}" from news ${newsId} to ${linkIds.length} link(s)`) + logger.info( + `${HOOK_NAME}: Mirrored status "${newStatus}" from news ${newsId} to ${linkIds.length} link(s)` + ) } } catch (error: any) { logger.error(`${HOOK_NAME}: Failed to mirror status for news ${newsId}: ${error.message}`) @@ -458,10 +474,14 @@ export default defineHook(({ action, filter }, hookContext) => { await junctionService.deleteOne(row.id) } if (rows.length > 0) { - logger.info(`${HOOK_NAME}: Archived ${rows.length} link(s) and dropped junction row(s) for deleted news ${newsId}`) + logger.info( + `${HOOK_NAME}: Archived ${rows.length} link(s) and dropped junction row(s) for deleted news ${newsId}` + ) } } catch (error: any) { - logger.error(`${HOOK_NAME}: Failed to archive source links for deleted news ${newsId}: ${error.message}`) + logger.error( + `${HOOK_NAME}: Failed to archive source links for deleted news ${newsId}: ${error.message}` + ) await notifySlack( `:warning: *${HOOK_NAME}*: Beim Löschen des News-Eintrags ${newsId} konnte der verknüpfte News-Link nicht archiviert werden. Bitte manuell prüfen.\n` + `Fehler: ${error.message}` diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/create-news/util/__tests__/newsTarget.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/create-news/util/__tests__/newsTarget.test.ts index 8b538e9d..cc8b1033 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/create-news/util/__tests__/newsTarget.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/create-news/util/__tests__/newsTarget.test.ts @@ -57,7 +57,10 @@ describe('buildUniqueNewsSlug', () => { }) test('appends the next free numeric suffix on collision', async () => { - const rows = [{ id: 'a', slug: 'react-19-released' }, { id: 'b', slug: 'react-19-released-2' }] + const rows = [ + { id: 'a', slug: 'react-19-released' }, + { id: 'b', slug: 'react-19-released-2' }, + ] await expect(buildUniqueNewsSlug(service(rows), 'React 19 Released')).resolves.toBe('react-19-released-3') }) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/create-profile/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/create-profile/index.ts index 970da028..372227d7 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/create-profile/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/create-profile/index.ts @@ -1,6 +1,6 @@ import { defineHook } from '@directus/extensions-sdk' +import type { FilterHandler } from '@directus/types' import { createHookErrorConstructor } from '../shared/errors.ts' -import type { FilterHandler} from '@directus/types' const HOOK_NAME = 'create-profile' @@ -9,12 +9,18 @@ export default defineHook(({ filter }, hookContext) => { const ItemsService = hookContext.services.ItemsService type UserPayloadType = { - profiles: { - profiles_id: string, - }[] | undefined, + profiles: + | { + profiles_id: string + }[] + | undefined } - const handler: FilterHandler = async function(payload, _metadata, context): Promise { + const handler: FilterHandler = async function ( + payload, + _metadata, + context + ): Promise { try { logger.info(`${HOOK_NAME} hook: Start filter function`) @@ -32,7 +38,7 @@ export default defineHook(({ filter }, hookContext) => { knex: context.database, }) - const newProfileId = await profilesItemsService.createOne({}); + const newProfileId = await profilesItemsService.createOne({}) logger.info(`${HOOK_NAME} hook: Created profile ${newProfileId} for newly created user.`) @@ -43,10 +49,10 @@ export default defineHook(({ filter }, hookContext) => { { profiles_id: newProfileId as string, }, - ] + ], } - // Handle unknown errors + // Handle unknown errors } catch (error: any) { logger.error(`${HOOK_NAME} hook: Error: ${error.message}`) const hookError = createHookErrorConstructor(HOOK_NAME, error.message) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/deploy-website/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/deploy-website/index.ts index 49873bb0..c43d505d 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/deploy-website/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/deploy-website/index.ts @@ -1,8 +1,8 @@ import { defineHook } from '@directus/extensions-sdk' import axios from 'axios' import { createHookErrorConstructor } from '../shared/errors.ts' -import { postSlackMessage } from './../shared/postSlackMessage.ts' import { safeAction } from '../shared/safeHook.ts' +import { postSlackMessage } from './../shared/postSlackMessage.ts' const HOOK_NAME = 'deploy-website' @@ -12,23 +12,31 @@ export default defineHook(({ action }, hookContext) => { const ItemsService = hookContext.services.ItemsService if (!env.VERCEL_DEPLOY_WEBHOOK_URL) { - logger.warn(`${HOOK_NAME} hook: Did not set VERCEL_DEPLOY_WEBHOOK_URL. Vercel deployment extension will not be active.`) + logger.warn( + `${HOOK_NAME} hook: Did not set VERCEL_DEPLOY_WEBHOOK_URL. Vercel deployment extension will not be active.` + ) return } /** * It deploys our website on created items, if necessary. */ - action('items.create', safeAction(HOOK_NAME, logger, ({ payload, ...metadata }, context) => - handleAction('create', { payload, metadata, context }) - )) + action( + 'items.create', + safeAction(HOOK_NAME, logger, ({ payload, ...metadata }, context) => + handleAction('create', { payload, metadata, context }) + ) + ) /** * It deploys our website on updated items, if necessary. */ - action('items.update', safeAction(HOOK_NAME, logger, ({ payload, ...metadata }, context) => - handleAction('update', { payload, metadata, context }) - )) + action( + 'items.update', + safeAction(HOOK_NAME, logger, ({ payload, ...metadata }, context) => + handleAction('update', { payload, metadata, context }) + ) + ) async function handleAction( type: string, @@ -49,9 +57,9 @@ export default defineHook(({ action }, hookContext) => { if (['profiles', 'ratings', 'ratings_target'].includes(metadata.collection)) { logger.info( `${HOOK_NAME} hook: Updated item was in "${metadata.collection}" collection. ` + - `Exiting hook early.` - ); - return; + `Exiting hook early.` + ) + return } // Get fields of collection @@ -59,10 +67,7 @@ export default defineHook(({ action }, hookContext) => { // Deploy website only if status field exists if (!fields.status) { - logger.info( - `${HOOK_NAME} hook: Item has not status field.` + - `Exiting hook early.` - ); + logger.info(`${HOOK_NAME} hook: Item has not status field.` + `Exiting hook early.`) return } @@ -80,10 +85,7 @@ export default defineHook(({ action }, hookContext) => { const contentUpdateRelevant = item.status === 'published' || (type === 'update' && payload.status) if (!contentUpdateRelevant) { - logger.info( - `${HOOK_NAME} hook: Item update not relevant` + - `Exiting hook early.` - ); + logger.info(`${HOOK_NAME} hook: Item update not relevant` + `Exiting hook early.`) return } diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/fetch-open-graph/__tests__/index.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/fetch-open-graph/__tests__/index.test.ts index e3caa5ea..67ecdabe 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/fetch-open-graph/__tests__/index.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/fetch-open-graph/__tests__/index.test.ts @@ -1,4 +1,8 @@ import { beforeEach, describe, expect, jest, test } from '@jest/globals' +import { default as axios } from 'axios' +import { postSlackMessage } from './../../shared/postSlackMessage.ts' +import registerHook from './../index.ts' +import { assertPublicUrl } from './../util/urlSafety.ts' // The extensions SDK ships as ESM and is not transformed under Jest's CJS mode, // so stub it. The real `defineHook` simply returns its callback. @@ -23,11 +27,6 @@ jest.mock('./../util/urlSafety.ts', () => ({ assertPublicUrl: jest.fn(), })) -import { default as axios } from 'axios' -import { postSlackMessage } from './../../shared/postSlackMessage.ts' -import { assertPublicUrl } from './../util/urlSafety.ts' -import registerHook from './../index.ts' - const axiosGet = (axios as any).get as jest.Mock const postSlackMessageMock = jest.mocked(postSlackMessage) const assertPublicUrlMock = jest.mocked(assertPublicUrl) @@ -38,7 +37,11 @@ const flush = () => new Promise((resolve) => setImmediate(resolve)) type Handler = (metadata: Record, context: Record) => void -async function invoke(handler: Handler, meta: Record, context: Record = { accountability: {}, schema: {} }) { +async function invoke( + handler: Handler, + meta: Record, + context: Record = { accountability: {}, schema: {} } +) { handler(meta, context) await flush() } @@ -97,9 +100,15 @@ describe('fetch-open-graph hook', () => { axiosGet.mockResolvedValue({ status: 200, headers: { 'content-type': 'text/html' }, data: OG_HTML }) const { handlers, updateOneCalls } = setup() - await invoke(handlers.get('news_links.items.create')!, { key: 'link-1', payload: { link: 'https://example.com/a' } }) + await invoke(handlers.get('news_links.items.create')!, { + key: 'link-1', + payload: { link: 'https://example.com/a' }, + }) - expect(axiosGet).toHaveBeenCalledWith('https://example.com/a', expect.objectContaining({ timeout: expect.any(Number) })) + expect(axiosGet).toHaveBeenCalledWith( + 'https://example.com/a', + expect.objectContaining({ timeout: expect.any(Number) }) + ) expect(updateOneCalls).toHaveLength(1) expect(updateOneCalls[0].id).toBe('link-1') expect(updateOneCalls[0].data.open_graph.title).toBe('OG Title') @@ -110,7 +119,10 @@ describe('fetch-open-graph hook', () => { test('does nothing when the payload has no link (loop avoidance)', async () => { const { handlers, updateOneCalls } = setup() - await invoke(handlers.get('news_links.items.update')!, { key: 'link-2', payload: { open_graph: { title: 'x' } } }) + await invoke(handlers.get('news_links.items.update')!, { + key: 'link-2', + payload: { open_graph: { title: 'x' } }, + }) expect(axiosGet).not.toHaveBeenCalled() expect(updateOneCalls).toEqual([]) @@ -120,7 +132,10 @@ describe('fetch-open-graph hook', () => { axiosGet.mockRejectedValue(new Error('ECONNREFUSED')) const { handlers, updateOneCalls, logger } = setup() - await invoke(handlers.get('news_links.items.create')!, { key: 'link-3', payload: { link: 'https://bad.example' } }) + await invoke(handlers.get('news_links.items.create')!, { + key: 'link-3', + payload: { link: 'https://bad.example' }, + }) // Still writes an empty object so stale data isn't retained for the new link. expect(updateOneCalls).toEqual([{ id: 'link-3', data: { open_graph: {} } }]) @@ -135,7 +150,10 @@ describe('fetch-open-graph hook', () => { axiosGet.mockResolvedValue({ status: 404, headers: { 'content-type': 'text/html' }, data: '' }) const { handlers, updateOneCalls } = setup() - await invoke(handlers.get('news_links.items.create')!, { key: 'link-4', payload: { link: 'https://example.com/missing' } }) + await invoke(handlers.get('news_links.items.create')!, { + key: 'link-4', + payload: { link: 'https://example.com/missing' }, + }) expect(updateOneCalls).toEqual([{ id: 'link-4', data: { open_graph: {} } }]) expect(postSlackMessageMock).toHaveBeenCalledTimes(1) @@ -145,7 +163,10 @@ describe('fetch-open-graph hook', () => { axiosGet.mockResolvedValue({ status: 200, headers: { 'content-type': 'application/pdf' }, data: '%PDF' }) const { handlers, updateOneCalls } = setup() - await invoke(handlers.get('news_links.items.create')!, { key: 'link-5', payload: { link: 'https://example.com/file.pdf' } }) + await invoke(handlers.get('news_links.items.create')!, { + key: 'link-5', + payload: { link: 'https://example.com/file.pdf' }, + }) // Not an error, so no Slack — but still write {} to avoid stale data. expect(updateOneCalls).toEqual([{ id: 'link-5', data: { open_graph: {} } }]) @@ -156,7 +177,10 @@ describe('fetch-open-graph hook', () => { assertPublicUrlMock.mockRejectedValue(new Error('Refusing to fetch internal host: localhost')) const { handlers, updateOneCalls } = setup() - await invoke(handlers.get('news_links.items.create')!, { key: 'link-6', payload: { link: 'http://localhost/admin' } }) + await invoke(handlers.get('news_links.items.create')!, { + key: 'link-6', + payload: { link: 'http://localhost/admin' }, + }) expect(axiosGet).not.toHaveBeenCalled() expect(updateOneCalls).toEqual([{ id: 'link-6', data: { open_graph: {} } }]) @@ -172,7 +196,10 @@ describe('fetch-open-graph hook', () => { }) const { handlers, updateOneCalls } = setup() - await invoke(handlers.get('news_links.items.create')!, { key: 'link-7', payload: { link: 'https://example.com/start' } }) + await invoke(handlers.get('news_links.items.create')!, { + key: 'link-7', + payload: { link: 'https://example.com/start' }, + }) expect(updateOneCalls[0].data.open_graph.image).toBe('https://redirected.example.com/img/pic.jpg') }) @@ -181,7 +208,10 @@ describe('fetch-open-graph hook', () => { axiosGet.mockResolvedValue({ status: 200, headers: { 'content-type': 'text/html' }, data: OG_HTML }) const { handlers, updateOneCalls } = setup() - await invoke(handlers.get('news_links.items.update')!, { keys: ['a', 'b'], payload: { link: 'https://example.com/shared' } }) + await invoke(handlers.get('news_links.items.update')!, { + keys: ['a', 'b'], + payload: { link: 'https://example.com/shared' }, + }) expect(axiosGet).toHaveBeenCalledTimes(1) expect(updateOneCalls.map((c) => c.id)).toEqual(['a', 'b']) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/fetch-open-graph/util/__tests__/openGraph.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/fetch-open-graph/util/__tests__/openGraph.test.ts index e909e831..8096a639 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/fetch-open-graph/util/__tests__/openGraph.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/fetch-open-graph/util/__tests__/openGraph.test.ts @@ -76,7 +76,10 @@ describe('normalizeOpenGraph', () => { }) test('resolves a relative og:image against the page URL', () => { - const result = normalizeOpenGraph(parseMetaTags(''), PAGE_URL) + const result = normalizeOpenGraph( + parseMetaTags(''), + PAGE_URL + ) expect(result.image).toBe('https://example.com/media/pic.jpg') }) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/fetch-open-graph/util/__tests__/urlSafety.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/fetch-open-graph/util/__tests__/urlSafety.test.ts index 452acfc8..234e349f 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/fetch-open-graph/util/__tests__/urlSafety.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/fetch-open-graph/util/__tests__/urlSafety.test.ts @@ -50,7 +50,9 @@ describe('assertPublicUrl', () => { }) test('rejects a public hostname that resolves to a private address', async () => { - await expect(assertPublicUrl('http://sneaky.example.com', privateResolver)).rejects.toThrow(/non-public address/) + await expect(assertPublicUrl('http://sneaky.example.com', privateResolver)).rejects.toThrow( + /non-public address/ + ) }) test('rejects an unparseable URL', async () => { diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/member-matching/__tests__/matchMembers.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/member-matching/__tests__/matchMembers.test.ts index 283a07ba..66f4ab5c 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/member-matching/__tests__/matchMembers.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/member-matching/__tests__/matchMembers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from '@jest/globals' -import type { MemberData } from './../matchMembers.ts'; +import type { MemberData } from './../matchMembers.ts' import { extractSpeakerNames, findMatchingMembers } from './../matchMembers.ts' describe('extractSpeakerNames', () => { diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/member-matching/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/member-matching/index.ts index 42060e3b..4950196a 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/member-matching/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/member-matching/index.ts @@ -1,7 +1,7 @@ import { defineHook } from '@directus/extensions-sdk' -import { matchMembersFromTranscript } from './matchMembers.js' import { postSlackMessage } from '../shared/postSlackMessage.ts' import { safeAction } from '../shared/safeHook.ts' +import { matchMembersFromTranscript } from './matchMembers.js' const HOOK_NAME = 'member-matching' diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/member-matching/matchMembers.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/member-matching/matchMembers.ts index 9c39e9a0..3b56dff6 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/member-matching/matchMembers.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/member-matching/matchMembers.ts @@ -28,7 +28,8 @@ export function extractSpeakerNames(transcriptText: string): string[] { // Pattern 1: Name followed by timestamp in parentheses // Examples: "Jan Gregor Emge-Triebel (00:12.534)", "Fabi Fink (00:35.735)" // Use [ \t]+ instead of \s+ to avoid matching across newlines - const timestampPattern = /^([A-ZÄÖÜa-zäöüß][A-ZÄÖÜa-zäöüß-]+(?:[ \t]+[A-ZÄÖÜa-zäöüß][A-ZÄÖÜa-zäöüß-]+)*)[ \t]+\(\d{2}:\d{2}\.\d+\)/gm + const timestampPattern = + /^([A-ZÄÖÜa-zäöüß][A-ZÄÖÜa-zäöüß-]+(?:[ \t]+[A-ZÄÖÜa-zäöüß][A-ZÄÖÜa-zäöüß-]+)*)[ \t]+\(\d{2}:\d{2}\.\d+\)/gm let match while ((match = timestampPattern.exec(transcriptText)) !== null) { @@ -40,7 +41,8 @@ export function extractSpeakerNames(transcriptText: string): string[] { // Pattern 2: Name followed by colon (fallback for other transcript formats) // Examples: "Dennis:", "Dennis Becker:", "**Jojo**:" - const colonPattern = /^(?:\*\*)?([A-ZÄÖÜa-zäöüß][A-ZÄÖÜa-zäöüß-]+(?:\s+[A-ZÄÖÜa-zäöüß][A-ZÄÖÜa-zäöüß-]+)?)(?:\*\*)?:/gm + const colonPattern = + /^(?:\*\*)?([A-ZÄÖÜa-zäöüß][A-ZÄÖÜa-zäöüß-]+(?:\s+[A-ZÄÖÜa-zäöüß][A-ZÄÖÜa-zäöüß-]+)?)(?:\*\*)?:/gm while ((match = colonPattern.exec(transcriptText)) !== null) { const name = match[1].trim() @@ -167,7 +169,9 @@ export async function matchMembersFromTranscript( ) if (matchedMembers.length === 0) { - logger.warn(`${hookName}: No members matched for podcast ${podcastId} (speakers: [${speakerNames.join(', ')}])`) + logger.warn( + `${hookName}: No members matched for podcast ${podcastId} (speakers: [${speakerNames.join(', ')}])` + ) try { await postSlackMessage( `:warning: *${hookName} hook*: Keine Members für Podcast ${podcastId} zugeordnet (Sprecher: ${speakerNames.join(', ')}). Bitte manuell zuweisen: https://admin.programmier.bar/admin/content/podcasts/${podcastId}` @@ -184,7 +188,9 @@ export async function matchMembersFromTranscript( }) if (currentPodcast.members && currentPodcast.members.length > 0) { - logger.info(`${hookName}: Podcast already has ${currentPodcast.members.length} members assigned, skipping auto-assignment`) + logger.info( + `${hookName}: Podcast already has ${currentPodcast.members.length} members assigned, skipping auto-assignment` + ) return } diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/podcast-transcript/generateTranscriptItem.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/podcast-transcript/generateTranscriptItem.ts index b363aa1a..67972e9c 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/podcast-transcript/generateTranscriptItem.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/podcast-transcript/generateTranscriptItem.ts @@ -1,7 +1,7 @@ -import type { Dependencies } from '../buzzsprout/handlers/types.js'; -import { createHookErrorConstructor } from './../shared/errors.ts'; -import { postSlackMessage } from './../shared/postSlackMessage.ts'; import type { Query } from '@directus/types/dist/query.js' +import type { Dependencies } from '../buzzsprout/handlers/types.js' +import { createHookErrorConstructor } from './../shared/errors.ts' +import { postSlackMessage } from './../shared/postSlackMessage.ts' async function generateTranscriptItem( HOOK_NAME: string, @@ -12,10 +12,8 @@ async function generateTranscriptItem( payload: any metadata: Record context: any - }, { - logger, - ItemsService, - }: Dependencies, + }, + { logger, ItemsService }: Dependencies ) { try { logger.info(`${HOOK_NAME} hook: Start "${metadata.collection}" action function`) @@ -33,33 +31,34 @@ async function generateTranscriptItem( const item = await podcastItemsService.readOne(metadata.key || metadata.keys[0]) if (!item.audio_file) { - logger.info( - `${HOOK_NAME} hook: Updated podcast has no audio file set. ` + - `Exiting hook early.` - ); - return; + logger.info(`${HOOK_NAME} hook: Updated podcast has no audio file set. ` + `Exiting hook early.`) + return } const query: Query = { filter: { - podcast: {_eq: item.id}, - podcast_audio_file: {_eq: item.audio_file}, - } - }; + podcast: { _eq: item.id }, + podcast_audio_file: { _eq: item.audio_file }, + }, + } - const existingTranscripts = await transcriptItemsService.readByQuery(query); + const existingTranscripts = await transcriptItemsService.readByQuery(query) if (existingTranscripts.length > 0) { - logger.info(`${HOOK_NAME} hook: Found ${existingTranscripts.length} existing transcripts for podcast: "${item.id}". ` + - `Exiting hook early.`); - return; + logger.info( + `${HOOK_NAME} hook: Found ${existingTranscripts.length} existing transcripts for podcast: "${item.id}". ` + + `Exiting hook early.` + ) + return } - const newTranscript = await transcriptItemsService.createOne({podcast: item.id, podcast_audio_file: item.audio_file, service: 'deepgram'}); + const newTranscript = await transcriptItemsService.createOne({ + podcast: item.id, + podcast_audio_file: item.audio_file, + service: 'deepgram', + }) - logger.info( - `${HOOK_NAME} hook: Generated a transcript "${newTranscript.id}" for podcast: "${item.id}".` - ); + logger.info(`${HOOK_NAME} hook: Generated a transcript "${newTranscript.id}" for podcast: "${item.id}".`) } catch (error: any) { try { await postSlackMessage( @@ -75,4 +74,4 @@ async function generateTranscriptItem( } } -export default generateTranscriptItem; +export default generateTranscriptItem diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/podcast-transcript/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/podcast-transcript/index.ts index a0183117..151772c4 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/podcast-transcript/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/podcast-transcript/index.ts @@ -1,8 +1,7 @@ import { defineHook } from '@directus/extensions-sdk' - -import generateTranscriptItem from './generateTranscriptItem.js'; -import processTranscriptItem from './processTranscriptItem.js'; import { safeAction } from '../shared/safeHook.ts' +import generateTranscriptItem from './generateTranscriptItem.js' +import processTranscriptItem from './processTranscriptItem.js' const HOOK_NAME = 'podcast-transcript-create' @@ -12,15 +11,19 @@ export default defineHook(({ action, schedule }, hookContext) => { const getSchema = hookContext.getSchema const env = hookContext.env - action('podcasts.items.create', safeAction(HOOK_NAME, logger, ({ payload, ...metadata }, context) => - generateTranscriptItem(HOOK_NAME, { payload, metadata, context }, {logger, ItemsService}) - )) - - action('podcasts.items.update', safeAction(HOOK_NAME, logger, ({ payload, ...metadata }, context) => - generateTranscriptItem(HOOK_NAME, { payload, metadata, context }, {logger, ItemsService}) - )) + action( + 'podcasts.items.create', + safeAction(HOOK_NAME, logger, ({ payload, ...metadata }, context) => + generateTranscriptItem(HOOK_NAME, { payload, metadata, context }, { logger, ItemsService }) + ) + ) - schedule('*/5 * * * *', - processTranscriptItem(HOOK_NAME, {logger, ItemsService, getSchema, env}) + action( + 'podcasts.items.update', + safeAction(HOOK_NAME, logger, ({ payload, ...metadata }, context) => + generateTranscriptItem(HOOK_NAME, { payload, metadata, context }, { logger, ItemsService }) + ) ) + + schedule('*/5 * * * *', processTranscriptItem(HOOK_NAME, { logger, ItemsService, getSchema, env })) }) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/podcast-transcript/processTranscriptItem.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/podcast-transcript/processTranscriptItem.ts index 3fc2d25c..f1fd3099 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/podcast-transcript/processTranscriptItem.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/podcast-transcript/processTranscriptItem.ts @@ -1,16 +1,8 @@ import axios from 'axios' +import type { Dependencies } from './../buzzsprout/handlers/types.js' import { postSlackMessage } from './../shared/postSlackMessage.ts' -import type { Dependencies } from './../buzzsprout/handlers/types.js'; -function processTranscriptItem( - HOOK_NAME: string, - { - logger, - ItemsService, - getSchema, - env - }: Dependencies, -) { +function processTranscriptItem(HOOK_NAME: string, { logger, ItemsService, getSchema, env }: Dependencies) { return async () => { logger.info(`${HOOK_NAME} hook: Start schedule function`) @@ -20,34 +12,36 @@ function processTranscriptItem( schema: globalSchema, }) - const existingTranscripts = await transcriptItemsService.readByQuery({filter: {raw_response: {'_null': true}}, sort: ['-date_created'], limit: 1}); + const existingTranscripts = await transcriptItemsService.readByQuery({ + filter: { raw_response: { _null: true } }, + sort: ['-date_created'], + limit: 1, + }) if (existingTranscripts.length === 0) { - logger.info(`${HOOK_NAME} hook: Found no waiting transcripts. ` + - `Exiting hook early.`); - return; + logger.info(`${HOOK_NAME} hook: Found no waiting transcripts. ` + `Exiting hook early.`) + return } - const existingTranscript = existingTranscripts.pop(); + const existingTranscript = existingTranscripts.pop() - logger.info(`${HOOK_NAME} hook: Processing transcript "${existingTranscript.id}".`); + logger.info(`${HOOK_NAME} hook: Processing transcript "${existingTranscript.id}".`) if (!env.DEEPGRAM_API_URL) { - logger.info(`${HOOK_NAME} hook: DEEPGRAM_API_URL env variable not set. ` + - `Exiting hook early.`); - return; + logger.info(`${HOOK_NAME} hook: DEEPGRAM_API_URL env variable not set. ` + `Exiting hook early.`) + return } /* * Currently, we only support deepgram * In the future this will need to depend on `existingTranscript.service` (currently hard-coded to "deepgram") */ - const url = new URL(env.DEEPGRAM_API_URL); - url.searchParams.append('model', 'nova-2'); - url.searchParams.append('smart_format', 'true'); - url.searchParams.append('diarize', 'true'); - url.searchParams.append('paragraphs', 'true'); - url.searchParams.append('utterances', 'true'); - url.searchParams.append('punctuate', 'true'); - url.searchParams.append('language', 'de'); + const url = new URL(env.DEEPGRAM_API_URL) + url.searchParams.append('model', 'nova-2') + url.searchParams.append('smart_format', 'true') + url.searchParams.append('diarize', 'true') + url.searchParams.append('paragraphs', 'true') + url.searchParams.append('utterances', 'true') + url.searchParams.append('punctuate', 'true') + url.searchParams.append('language', 'de') try { const response = await axios({ @@ -55,27 +49,24 @@ function processTranscriptItem( url: url.toString(), headers: { 'Content-Type': 'application/json', - 'Authorization': `Token ${env.DEEPGRAM_API_KEY}`, + Authorization: `Token ${env.DEEPGRAM_API_KEY}`, }, data: { url: `${env.PUBLIC_URL}assets/${existingTranscript.podcast_audio_file}`, - } + }, }) - logger.info(`${HOOK_NAME} hook: Received transcription response.`); + logger.info(`${HOOK_NAME} hook: Received transcription response.`) - existingTranscript.raw_response = JSON.stringify(response.data); - existingTranscript.supported_features = [ - "timestamps", - "diarization" - ]; + existingTranscript.raw_response = JSON.stringify(response.data) + existingTranscript.supported_features = ['timestamps', 'diarization'] await transcriptItemsService.updateOne(existingTranscript.id, { raw_response: existingTranscript.raw_response, supported_features: existingTranscript.supported_features, - }); + }) - logger.info(`${HOOK_NAME} hook: Transcription persisted.`); + logger.info(`${HOOK_NAME} hook: Transcription persisted.`) try { await postSlackMessage( `:info: *${HOOK_NAME} hook*: Transcript wurde erzeugt und kann veröffentlicht werden: https://admin.programmier.bar/admin/content/transcripts/${existingTranscript.id}` @@ -83,7 +74,6 @@ function processTranscriptItem( } catch (slackError: any) { logger.error(`${HOOK_NAME} hook: Error: Could not post message to Slack: ${slackError.message}`) } - } catch (error: any) { try { await postSlackMessage( @@ -96,4 +86,4 @@ function processTranscriptItem( } } -export default processTranscriptItem; +export default processTranscriptItem diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/post-to-discord/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/post-to-discord/index.ts index aa6eeaa6..b5cca0ab 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/post-to-discord/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/post-to-discord/index.ts @@ -6,9 +6,9 @@ import { readJunctionRowsByNewsId, SOURCE_COLLECTION, } from '../create-news/util/newsTarget.ts' -import { getRequiredSetting } from '../shared/settings.js' import { postSlackMessage } from '../shared/postSlackMessage.ts' import { safeAction } from '../shared/safeHook.ts' +import { getRequiredSetting } from '../shared/settings.js' import { buildNewsEmbed, postToDiscord } from './discord.ts' const HOOK_NAME = 'post-to-discord' diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/process-guard/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/process-guard/index.ts index 78112d01..db724d42 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/process-guard/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/process-guard/index.ts @@ -31,9 +31,7 @@ export default defineHook((_, { logger }) => { process.on('uncaughtException', (error: any) => { logger.error(`${HOOK_NAME}: uncaughtException: ${error?.stack ?? error}`) - postSlackMessage( - `:rotating_light: *${HOOK_NAME}*: uncaughtException, restarting: ${error?.message ?? error}` - ) + postSlackMessage(`:rotating_light: *${HOOK_NAME}*: uncaughtException, restarting: ${error?.message ?? error}`) .catch(() => {}) .finally(() => setTimeout(() => process.exit(1), 1000)) }) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/schedule-publication/__tests__/index.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/schedule-publication/__tests__/index.test.ts index 65c43a30..185190f9 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/schedule-publication/__tests__/index.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/schedule-publication/__tests__/index.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, jest, test } from '@jest/globals' +import { postSlackMessage } from './../../shared/postSlackMessage.ts' +import registerHook from './../index.ts' // The extensions SDK ships as ESM and is not transformed under Jest's CJS mode, // so stub it. The real `defineHook` simply returns its callback. @@ -21,9 +23,6 @@ jest.mock('./../../shared/errors.ts', () => ({ }, })) -import { postSlackMessage } from './../../shared/postSlackMessage.ts' -import registerHook from './../index.ts' - const postSlackMessageMock = jest.mocked(postSlackMessage) interface UpdateCall { diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/screenshot/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/screenshot/index.ts index 0460a040..4d18d1de 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/screenshot/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/screenshot/index.ts @@ -27,17 +27,23 @@ export default defineHook(({ action }, hookContext) => { * It sets the "image" field on newly created * pick of the day items, if necessary. */ - action('picks_of_the_day.items.create', safeAction(HOOK_NAME, logger, ({ payload, ...metadata }, context) => - handleAction({ payload, metadata, context }) - )) + action( + 'picks_of_the_day.items.create', + safeAction(HOOK_NAME, logger, ({ payload, ...metadata }, context) => + handleAction({ payload, metadata, context }) + ) + ) /** * It sets the "image" field on updated pick * of the day items, if necessary. */ - action('picks_of_the_day.items.update', safeAction(HOOK_NAME, logger, ({ payload, ...metadata }, context) => - handleAction({ payload, metadata, context }) - )) + action( + 'picks_of_the_day.items.update', + safeAction(HOOK_NAME, logger, ({ payload, ...metadata }, context) => + handleAction({ payload, metadata, context }) + ) + ) /** * It handles the action logic that sets the "image" field on diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/set-slug/__tests__/README.md b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/set-slug/__tests__/README.md index c9f665e8..2053a014 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/set-slug/__tests__/README.md +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/set-slug/__tests__/README.md @@ -21,10 +21,10 @@ The tests cover the following scenarios: 4. **Conferences**: Generating slugs for conferences with titles. 5. **Profiles**: Generating slugs for profiles with first names and last names. 6. **Edge Cases**: - - Not updating slugs for profiles when `update_slug` is set to `false`. - - Handling unsupported collections. - - Handling missing required fields. - - Handling newly created items without keys. + - Not updating slugs for profiles when `update_slug` is set to `false`. + - Handling unsupported collections. + - Handling missing required fields. + - Handling newly created items without keys. ## Running the Tests @@ -40,6 +40,6 @@ To add more tests: 1. Add new test cases to the `getPayloadWithSlug.test.ts` file. 2. Follow the existing pattern of Arrange-Act-Assert: - - **Arrange**: Set up the test data (futureItem, payload, metadata). - - **Act**: Call the function being tested. - - **Assert**: Verify the results using Jest's expect functions. + - **Arrange**: Set up the test data (futureItem, payload, metadata). + - **Act**: Call the function being tested. + - **Assert**: Verify the results using Jest's expect functions. diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/set-slug/__tests__/getPayloadWithSlug.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/set-slug/__tests__/getPayloadWithSlug.test.ts index cc4c6108..5efc35c8 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/set-slug/__tests__/getPayloadWithSlug.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/set-slug/__tests__/getPayloadWithSlug.test.ts @@ -1,11 +1,11 @@ -import { describe, expect, test, jest, beforeEach } from '@jest/globals'; -import { getPayloadWithSlug } from './../util/getPayloadWithSlug.ts'; +import { beforeEach, describe, expect, jest, test } from '@jest/globals' +import { getPayloadWithSlug } from './../util/getPayloadWithSlug.ts' describe('getPayloadWithSlug', () => { beforeEach(() => { // Clear all mocks before each test - jest.clearAllMocks(); - }); + jest.clearAllMocks() + }) test('should generate slug for speakers', async () => { // Arrange @@ -13,19 +13,19 @@ describe('getPayloadWithSlug', () => { academic_title: 'Dr.', first_name: 'John', last_name: 'Doe', - }; - const payload = { name: 'John Doe' }; - const metadata = { collection: 'speakers', keys: ['123'] }; + } + const payload = { name: 'John Doe' } + const metadata = { collection: 'speakers', keys: ['123'] } // Act - const result = await getPayloadWithSlug(futureItem, { payload, metadata }); + const result = await getPayloadWithSlug(futureItem, { payload, metadata }) // Assert expect(result).toEqual({ ...payload, slug: 'dr-john-doe', - }); - }); + }) + }) test('should not generate slug for incomplete podcasts', async () => { // Arrange @@ -33,16 +33,16 @@ describe('getPayloadWithSlug', () => { type: 'news', number: null, title: 'Topic A // Topic B // Topic C', - }; - const payload = { title: 'Topic A // Topic B // Topic C' }; - const metadata = { collection: 'podcasts', keys: ['456'] }; + } + const payload = { title: 'Topic A // Topic B // Topic C' } + const metadata = { collection: 'podcasts', keys: ['456'] } // Act - const result = await getPayloadWithSlug(futureItem, { payload, metadata }); + const result = await getPayloadWithSlug(futureItem, { payload, metadata }) // Assert - expect(result).toEqual(payload); - }); + expect(result).toEqual(payload) + }) test('should generate slug for podcasts - deep dive', async () => { // Arrange @@ -50,19 +50,19 @@ describe('getPayloadWithSlug', () => { type: 'deep_dive', number: '42', title: 'Understanding Jest', - }; - const payload = { title: 'Understanding Jest' }; - const metadata = { collection: 'podcasts', keys: ['456'] }; + } + const payload = { title: 'Understanding Jest' } + const metadata = { collection: 'podcasts', keys: ['456'] } // Act - const result = await getPayloadWithSlug(futureItem, { payload, metadata }); + const result = await getPayloadWithSlug(futureItem, { payload, metadata }) // Assert expect(result).toEqual({ ...payload, slug: 'deep-dive-42-understanding-jest', - }); - }); + }) + }) test('should generate slug for podcasts - news', async () => { // Arrange @@ -70,19 +70,19 @@ describe('getPayloadWithSlug', () => { type: 'news', number: '01/23', title: 'Topic A // Topic B // Topic C', - }; - const payload = { title: 'Topic A // Topic B // Topic C' }; - const metadata = { collection: 'podcasts', keys: ['456'] }; + } + const payload = { title: 'Topic A // Topic B // Topic C' } + const metadata = { collection: 'podcasts', keys: ['456'] } // Act - const result = await getPayloadWithSlug(futureItem, { payload, metadata }); + const result = await getPayloadWithSlug(futureItem, { payload, metadata }) // Assert expect(result).toEqual({ ...payload, slug: 'news-01-23-topic-a-topic-b-topic-c', - }); - }); + }) + }) test('should generate slug for podcasts - cto special', async () => { // Arrange @@ -90,19 +90,19 @@ describe('getPayloadWithSlug', () => { type: 'cto_special', number: '123', title: 'John Doe', - }; - const payload = { title: 'John Doe' }; - const metadata = { collection: 'podcasts', keys: ['456'] }; + } + const payload = { title: 'John Doe' } + const metadata = { collection: 'podcasts', keys: ['456'] } // Act - const result = await getPayloadWithSlug(futureItem, { payload, metadata }); + const result = await getPayloadWithSlug(futureItem, { payload, metadata }) // Assert expect(result).toEqual({ ...payload, slug: 'cto-special-123-john-doe', - }); - }); + }) + }) test('should generate slug for podcasts - other with number', async () => { // Arrange @@ -110,19 +110,19 @@ describe('getPayloadWithSlug', () => { type: 'other', number: '123', title: 'Something happened!', - }; - const payload = { title: 'Something happened!' }; - const metadata = { collection: 'podcasts', keys: ['456'] }; + } + const payload = { title: 'Something happened!' } + const metadata = { collection: 'podcasts', keys: ['456'] } // Act - const result = await getPayloadWithSlug(futureItem, { payload, metadata }); + const result = await getPayloadWithSlug(futureItem, { payload, metadata }) // Assert expect(result).toEqual({ ...payload, slug: 'spezialfolge-123-something-happened', - }); - }); + }) + }) test('should generate slug for podcasts - other without number', async () => { // Arrange @@ -130,55 +130,55 @@ describe('getPayloadWithSlug', () => { type: 'other', number: null, title: 'Something happened!', - }; - const payload = { title: 'Something happened!' }; - const metadata = { collection: 'podcasts', keys: ['456'] }; + } + const payload = { title: 'Something happened!' } + const metadata = { collection: 'podcasts', keys: ['456'] } // Act - const result = await getPayloadWithSlug(futureItem, { payload, metadata }); + const result = await getPayloadWithSlug(futureItem, { payload, metadata }) // Assert expect(result).toEqual({ ...payload, slug: 'spezialfolge-something-happened', - }); - }); + }) + }) test('should generate slug for meetups', async () => { // Arrange const futureItem = { title: 'JavaScript Meetup 2025', - }; - const payload = { title: 'JavaScript Meetup 2025' }; - const metadata = { collection: 'meetups', keys: ['789'] }; + } + const payload = { title: 'JavaScript Meetup 2025' } + const metadata = { collection: 'meetups', keys: ['789'] } // Act - const result = await getPayloadWithSlug(futureItem, { payload, metadata }); + const result = await getPayloadWithSlug(futureItem, { payload, metadata }) // Assert expect(result).toEqual({ ...payload, slug: 'javascript-meetup-2025', - }); - }); + }) + }) test('should generate slug for conferences', async () => { // Arrange const futureItem = { title: 'TypeScript Conference 2025', - }; - const payload = { title: 'TypeScript Conference 2025' }; - const metadata = { collection: 'conferences', keys: ['101'] }; + } + const payload = { title: 'TypeScript Conference 2025' } + const metadata = { collection: 'conferences', keys: ['101'] } // Act - const result = await getPayloadWithSlug(futureItem, { payload, metadata }); + const result = await getPayloadWithSlug(futureItem, { payload, metadata }) // Assert expect(result).toEqual({ ...payload, slug: 'typescript-conference-2025', - }); - }); + }) + }) test('should generate slug for profiles with first_name and last_name', async () => { // Arrange @@ -186,16 +186,16 @@ describe('getPayloadWithSlug', () => { first_name: 'Jane', last_name: 'Smith', update_slug: true, - }; - const payload = { name: 'Jane Smith' }; - const metadata = { collection: 'profiles', keys: ['202'] }; + } + const payload = { name: 'Jane Smith' } + const metadata = { collection: 'profiles', keys: ['202'] } // Act - const result = await getPayloadWithSlug(futureItem, { payload, metadata }); + const result = await getPayloadWithSlug(futureItem, { payload, metadata }) // Assert - expect(result.slug).toMatch(/^jane-smith-[a-z0-9]{4}$/); - }); + expect(result.slug).toMatch(/^jane-smith-[a-z0-9]{4}$/) + }) test('should not update slug for profiles when update_slug is false', async () => { // Arrange @@ -203,65 +203,64 @@ describe('getPayloadWithSlug', () => { first_name: 'Jane', last_name: 'Smith', update_slug: false, - }; - const payload = { name: 'Jane Smith' }; - const metadata = { collection: 'profiles', keys: ['202'] }; + } + const payload = { name: 'Jane Smith' } + const metadata = { collection: 'profiles', keys: ['202'] } // Act - const result = await getPayloadWithSlug(futureItem, { payload, metadata }); + const result = await getPayloadWithSlug(futureItem, { payload, metadata }) // Assert - expect(result).toEqual(payload); - }); + expect(result).toEqual(payload) + }) test('should return original payload for unsupported collection', async () => { // Arrange const futureItem = { title: 'Some Title', - }; - const payload = { title: 'Some Title' }; - const metadata = { collection: 'unsupported', keys: ['303'] }; + } + const payload = { title: 'Some Title' } + const metadata = { collection: 'unsupported', keys: ['303'] } // Act - const result = await getPayloadWithSlug(futureItem, { payload, metadata }); + const result = await getPayloadWithSlug(futureItem, { payload, metadata }) // Assert - expect(result).toEqual(payload); - }); + expect(result).toEqual(payload) + }) test('should return original payload when required fields are missing', async () => { // Arrange const futureItem = { // Missing first_name and last_name - }; - const payload = { name: 'Incomplete' }; - const metadata = { collection: 'speakers', keys: ['404'] }; + } + const payload = { name: 'Incomplete' } + const metadata = { collection: 'speakers', keys: ['404'] } // Act - const result = await getPayloadWithSlug(futureItem, { payload, metadata }); + const result = await getPayloadWithSlug(futureItem, { payload, metadata }) // Assert - expect(result).toEqual(payload); - }); + expect(result).toEqual(payload) + }) test('should handle newly created items without keys', async () => { // Arrange const futureItem = { title: 'New Meetup', - }; - const payload = { title: 'New Meetup' }; - const metadata = { collection: 'meetups' }; // No keys for new items + } + const payload = { title: 'New Meetup' } + const metadata = { collection: 'meetups' } // No keys for new items // Act - const result = await getPayloadWithSlug(futureItem, { payload, metadata }); + const result = await getPayloadWithSlug(futureItem, { payload, metadata }) // Assert expect(result).toEqual({ ...payload, slug: 'new-meetup', - }); - }); + }) + }) - afterAll(() => { - }); -}); + afterAll(() => {}) +}) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/set-slug/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/set-slug/index.ts index 15312e62..a146aa7b 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/set-slug/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/set-slug/index.ts @@ -1,6 +1,6 @@ import { defineHook } from '@directus/extensions-sdk' import { createHookErrorConstructor } from '../shared/errors.ts' -import { getPayloadWithSlug } from './util/getPayloadWithSlug.js'; +import { getPayloadWithSlug } from './util/getPayloadWithSlug.js' const HOOK_NAME = 'set-slug' @@ -49,13 +49,12 @@ export default defineHook(({ filter }, hookContext) => { // create future item and return payload with "slug" if ( type === 'update' && - ( - (metadata.collection === 'speakers' && (payload.academic_title || payload.first_name || payload.last_name)) || + ((metadata.collection === 'speakers' && + (payload.academic_title || payload.first_name || payload.last_name)) || (metadata.collection === 'podcasts' && (payload.type || payload.number || payload.title)) || (metadata.collection === 'meetups' && payload.title) || (metadata.collection === 'conferences' && payload.title) || - (metadata.collection === 'profiles' && (payload.first_name || payload.last_name)) - ) + (metadata.collection === 'profiles' && (payload.first_name || payload.last_name))) ) { // Create items service instance const itemsService = new ItemsService(metadata.collection, { @@ -92,8 +91,4 @@ export default defineHook(({ filter }, hookContext) => { // Otherwise just return payload return payload } - - - - }) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/set-slug/util/getPayloadWithSlug.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/set-slug/util/getPayloadWithSlug.ts index 27a5015f..44e895a0 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/set-slug/util/getPayloadWithSlug.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/set-slug/util/getPayloadWithSlug.ts @@ -11,9 +11,8 @@ import { getFullPodcastTitle, getFullSpeakerName, getUrlSlug } from './../../../ */ export async function getPayloadWithSlug( futureItem: any, - { payload, metadata }: { payload: any; metadata: Record} + { payload, metadata }: { payload: any; metadata: Record } ) { - // If collection name is "speakers" and "academic_title", "first_name" and // "last_name" ist set, return payload with speaker slug if (metadata.collection === 'speakers' && futureItem.first_name && futureItem.last_name) { @@ -25,12 +24,13 @@ export async function getPayloadWithSlug( // If collection name is "podcasts" and "type", "number" and "title" is set, // return payload with podcast slug - if (metadata.collection === 'podcasts' && ( - (futureItem.type === 'deep_dive' && futureItem.number && futureItem.title) || - (futureItem.type === 'cto_special' && futureItem.number && futureItem.title) || - (futureItem.type === 'news' && futureItem.number && futureItem.title) || - (futureItem.type === 'other' && futureItem.title) - )) { + if ( + metadata.collection === 'podcasts' && + ((futureItem.type === 'deep_dive' && futureItem.number && futureItem.title) || + (futureItem.type === 'cto_special' && futureItem.number && futureItem.title) || + (futureItem.type === 'news' && futureItem.number && futureItem.title) || + (futureItem.type === 'other' && futureItem.title)) + ) { return { ...payload, slug: getUrlSlug(getFullPodcastTitle(futureItem)), @@ -59,11 +59,11 @@ export async function getPayloadWithSlug( // return payload with profile slug if (metadata.collection === 'profiles' && futureItem.first_name && futureItem.last_name) { if (futureItem.update_slug === false) { - return payload; + return payload } const result = { - ...payload + ...payload, } // Set suffix if empty or null @@ -71,15 +71,15 @@ export async function getPayloadWithSlug( result.slug_suffix = await getUniqueIdentifier() } - let suffix = ''; + let suffix = '' if (futureItem.slug_suffix) { - suffix = futureItem.slug_suffix; + suffix = futureItem.slug_suffix } else { - suffix = result.slug_suffix; + suffix = result.slug_suffix } - result.slug = getUrlSlug(`${futureItem.first_name}-${futureItem.last_name}-${suffix}`); + result.slug = getUrlSlug(`${futureItem.first_name}-${futureItem.last_name}-${suffix}`) - return result; + return result } // Otherwise just return payload @@ -88,22 +88,21 @@ export async function getPayloadWithSlug( // We use this approach to generate a unique part for the slug that remains stable over the lifetime of an item async function getUniqueIdentifier(input?: string): Promise { - if (!input) { - input = crypto.randomUUID(); + input = crypto.randomUUID() } // Convert the string to an ArrayBuffer - const encoder = new TextEncoder(); - const data = encoder.encode(input); + const encoder = new TextEncoder() + const data = encoder.encode(input) // Generate the SHA-256 hash - const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashBuffer = await crypto.subtle.digest('SHA-256', data) // Convert the hash to a hex string - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') // Get the first 4 characters of the hex string - return hashHex.slice(0, 4); + return hashHex.slice(0, 4) } diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/isPublishable.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/isPublishable.test.ts index 3a036352..7bc16ea1 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/isPublishable.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/isPublishable.test.ts @@ -1,13 +1,13 @@ -import { describe, expect, test, jest, beforeEach } from '@jest/globals'; -import { isPublishable } from './../isPublishable.ts'; +import { beforeEach, describe, expect, jest, test } from '@jest/globals' +import { isPublishable } from './../isPublishable.ts' // This configuration is acquired from the log output of interface extension running in directus import PodcastFields from './podcasts_fields.json' describe('isPublishable', () => { beforeEach(() => { // Clear all mocks before each test - jest.clearAllMocks(); - }); + jest.clearAllMocks() + }) test('Episode with full details should be publishable', async () => { const item = { @@ -21,8 +21,8 @@ describe('isPublishable', () => { } const publishableResult = isPublishable(item, PodcastFields) - expect(publishableResult).toEqual(true); - }); + expect(publishableResult).toEqual(true) + }) test.each([ [ @@ -103,12 +103,10 @@ describe('isPublishable', () => { false, 'missing audio_file', ], - ])('Episode %s should be publishable: %s', async (item, expected, _reason - ) => { - const result = isPublishable(item, PodcastFields); - expect(result).toBe(expected); - }); - + ])('Episode %s should be publishable: %s', async (item, expected, _reason) => { + const result = isPublishable(item, PodcastFields) + expect(result).toBe(expected) + }) test.each([ [ @@ -163,11 +161,8 @@ describe('isPublishable', () => { true, 'Other episodes do not require a number', ], - - ])('Number is optional for some episodes %s to be publishable: %s', async (item, expected, _reason - ) => { - const result = isPublishable(item, PodcastFields); - expect(result).toBe(expected); - }); - -}); + ])('Number is optional for some episodes %s to be publishable: %s', async (item, expected, _reason) => { + const result = isPublishable(item, PodcastFields) + expect(result).toBe(expected) + }) +}) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/podcasts_fields.json b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/podcasts_fields.json index b203321a..67704c5e 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/podcasts_fields.json +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/podcasts_fields.json @@ -25,9 +25,7 @@ "id": 82, "collection": "podcasts", "field": "id", - "special": [ - "uuid" - ], + "special": ["uuid"], "interface": "input", "options": null, "display": null, @@ -190,9 +188,7 @@ "id": 85, "collection": "podcasts", "field": "created_by", - "special": [ - "user-created" - ], + "special": ["user-created"], "interface": "select-dropdown-m2o", "options": { "template": "{{avatar.$thumbnail}} {{first_name}} {{last_name}}" @@ -238,9 +234,7 @@ "id": 86, "collection": "podcasts", "field": "created_on", - "special": [ - "date-created" - ], + "special": ["date-created"], "interface": "datetime", "options": null, "display": "datetime", @@ -286,9 +280,7 @@ "id": 87, "collection": "podcasts", "field": "updated_by", - "special": [ - "user-updated" - ], + "special": ["user-updated"], "interface": "select-dropdown-m2o", "options": { "template": "{{avatar.$thumbnail}} {{first_name}} {{last_name}}" @@ -334,9 +326,7 @@ "id": 88, "collection": "podcasts", "field": "updated_on", - "special": [ - "date-updated" - ], + "special": ["date-updated"], "interface": "datetime", "options": null, "display": "datetime", @@ -616,11 +606,7 @@ }, { "type": { - "_in": [ - "deep_dive", - "cto_special", - "news" - ] + "_in": ["deep_dive", "cto_special", "news"] } } ] @@ -715,9 +701,7 @@ "id": 94, "collection": "podcasts", "field": "cover_image", - "special": [ - "file" - ], + "special": ["file"], "interface": "file-image", "options": { "folder": null @@ -773,9 +757,7 @@ "id": 95, "collection": "podcasts", "field": "banner_image", - "special": [ - "file" - ], + "special": ["file"], "interface": "file-image", "options": null, "display": null, @@ -890,9 +872,7 @@ "id": 97, "collection": "podcasts", "field": "audio_file", - "special": [ - "file" - ], + "special": ["file"], "interface": "file", "options": { "folder": "94b9b20c-30e7-4de0-81ee-2a1201f6c673" @@ -1371,9 +1351,7 @@ "id": 117, "collection": "podcasts", "field": "transcription_done", - "special": [ - "cast-boolean" - ], + "special": ["cast-boolean"], "interface": "boolean", "options": null, "display": null, @@ -1400,9 +1378,7 @@ "id": 103, "collection": "podcasts", "field": "picks_of_the_day", - "special": [ - "o2m" - ], + "special": ["o2m"], "interface": "list-o2m", "options": { "template": "{{name}}" @@ -1431,10 +1407,7 @@ "id": 104, "collection": "podcasts", "field": "divider-hidden", - "special": [ - "alias", - "no-data" - ], + "special": ["alias", "no-data"], "interface": "presentation-divider", "options": null, "display": null, @@ -1461,10 +1434,7 @@ "id": 105, "collection": "podcasts", "field": "divider-metadata", - "special": [ - "alias", - "no-data" - ], + "special": ["alias", "no-data"], "interface": "presentation-divider", "options": { "title": "Metadata" @@ -1493,10 +1463,7 @@ "id": 106, "collection": "podcasts", "field": "divider-podcast", - "special": [ - "alias", - "no-data" - ], + "special": ["alias", "no-data"], "interface": "presentation-divider", "options": { "title": "Podcast" @@ -1525,10 +1492,7 @@ "id": 107, "collection": "podcasts", "field": "notice-publish", - "special": [ - "alias", - "no-data" - ], + "special": ["alias", "no-data"], "interface": "presentation-notice", "options": { "text": "Wähle bei \"Published On\" ein Datum in der Zukunft, um die Podcastfolge zu diesem Zeitpunk automatisch zu veröffentlichen." @@ -1567,10 +1531,7 @@ "id": 108, "collection": "podcasts", "field": "divider-relations", - "special": [ - "alias", - "no-data" - ], + "special": ["alias", "no-data"], "interface": "presentation-divider", "options": { "title": "Relations" @@ -1599,10 +1560,7 @@ "id": 110, "collection": "podcasts", "field": "notice-buzzsprout", - "special": [ - "alias", - "no-data" - ], + "special": ["alias", "no-data"], "interface": "presentation-notice", "options": { "text": "Die Podcastfolge wurde zu Buzzsprout hinzugefügt und alle weitere Änderungen, die du hier vornimmst, werden automatisch mit Buzzsprout synchronisiert." @@ -1641,9 +1599,7 @@ "id": 111, "collection": "podcasts", "field": "members", - "special": [ - "m2m" - ], + "special": ["m2m"], "interface": "list-m2m", "options": { "template": "{{member.first_name}} {{member.last_name}}" @@ -1672,9 +1628,7 @@ "id": 112, "collection": "podcasts", "field": "speakers", - "special": [ - "m2m" - ], + "special": ["m2m"], "interface": "list-m2m", "options": { "template": "{{speaker.first_name}} {{speaker.last_name}}" @@ -1703,9 +1657,7 @@ "id": 113, "collection": "podcasts", "field": "tags", - "special": [ - "m2m" - ], + "special": ["m2m"], "interface": "list-m2m", "options": { "template": "{{tag.name}}" @@ -1734,10 +1686,7 @@ "id": 118, "collection": "podcasts", "field": "publishable", - "special": [ - "alias", - "no-data" - ], + "special": ["alias", "no-data"], "interface": "publishable", "options": null, "display": null, diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/safeHook.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/safeHook.test.ts index cb4563de..1d40f833 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/safeHook.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/safeHook.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, jest, beforeEach } from '@jest/globals' +import { beforeEach, describe, expect, jest, test } from '@jest/globals' import { safeAction } from '../safeHook.ts' function createLogger() { diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/settings.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/settings.test.ts index bd0d5ddc..ab7af459 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/settings.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/settings.test.ts @@ -21,9 +21,9 @@ function buildContext(rows: Array<{ value: any }>) { describe('getRequiredSetting', () => { test('returns the value when the setting is present', async () => { - await expect(getRequiredSetting('website_url', buildContext([{ value: 'https://staging.example' }]))).resolves.toBe( - 'https://staging.example' - ) + await expect( + getRequiredSetting('website_url', buildContext([{ value: 'https://staging.example' }])) + ).resolves.toBe('https://staging.example') }) test('throws when the setting is missing', async () => { diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/test-wallet-passes.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/test-wallet-passes.ts index de69c5da..1cfa737f 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/test-wallet-passes.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/test-wallet-passes.ts @@ -58,7 +58,7 @@ async function getAccessToken(serviceAccountEmail: string, privateKey: string): headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${jwt}`, }) - const data = await res.json() as any + const data = (await res.json()) as any if (!res.ok) { throw new Error(`OAuth failed: ${JSON.stringify(data)}`) } @@ -124,17 +124,14 @@ async function upsertGoogleWalletClass(env: Record): Promise): Promise { +export async function sendTemplatedEmail(options: SendEmailOptions, context: EmailServiceContext): Promise { const { logger, services, getSchema } = context const { MailService } = services diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/gemini.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/gemini.ts index 11a81441..16d0c579 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/gemini.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/gemini.ts @@ -102,9 +102,7 @@ export async function generateImageWithGemini( } // Check for image data in the response - const imagePart = candidate.content?.parts?.find( - (part: any) => part.inlineData?.mimeType?.startsWith('image/') - ) + const imagePart = candidate.content?.parts?.find((part: any) => part.inlineData?.mimeType?.startsWith('image/')) if (imagePart?.inlineData) { return { diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-generator.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-generator.ts index fd07d935..5e859aa7 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-generator.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-generator.ts @@ -1,7 +1,7 @@ -import PDFDocument from 'pdfkit' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' +import PDFDocument from 'pdfkit' // Seller info (hardcoded) const SELLER = { @@ -154,7 +154,10 @@ export function generateInvoicePdf(data: InvoiceData): Promise { // --- Separator --- y += 20 - doc.moveTo(50, y).lineTo(50 + pageWidth, y).strokeColor('#ccc').stroke() + doc.moveTo(50, y) + .lineTo(50 + pageWidth, y) + .strokeColor('#ccc') + .stroke() // --- Customer info --- y += 12 @@ -196,7 +199,10 @@ export function generateInvoicePdf(data: InvoiceData): Promise { // --- Separator --- y += 10 - doc.moveTo(50, y).lineTo(50 + pageWidth, y).strokeColor('#ccc').stroke() + doc.moveTo(50, y) + .lineTo(50 + pageWidth, y) + .strokeColor('#ccc') + .stroke() // --- Line items table --- y += 14 @@ -229,7 +235,10 @@ export function generateInvoicePdf(data: InvoiceData): Promise { y += 35 // --- Separator --- - doc.moveTo(50, y).lineTo(50 + pageWidth, y).strokeColor('#ccc').stroke() + doc.moveTo(50, y) + .lineTo(50 + pageWidth, y) + .strokeColor('#ccc') + .stroke() y += 14 // --- Totals --- @@ -260,7 +269,10 @@ export function generateInvoicePdf(data: InvoiceData): Promise { doc.text(formatEur(data.vatAmountCents), valueX, y, { width: valueW, align: 'right' }) y += 24 - doc.moveTo(280, y).lineTo(50 + pageWidth, y).strokeColor('#ccc').stroke() + doc.moveTo(280, y) + .lineTo(50 + pageWidth, y) + .strokeColor('#ccc') + .stroke() y += 14 doc.fontSize(10).fillColor('#000') @@ -285,10 +297,7 @@ export function generateInvoicePdf(data: InvoiceData): Promise { * Generate the next invoice number for a conference. * Pattern: PB-CON{YY}-{NNN} */ -export async function generateInvoiceNumber( - ordersService: any, - conferenceYear: number -): Promise { +export async function generateInvoiceNumber(ordersService: any, conferenceYear: number): Promise { const yy = String(conferenceYear).slice(-2) const prefix = `PB-CON${yy}-` diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/isPublishable.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/isPublishable.ts index d42d0df7..4a6f6217 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/isPublishable.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/isPublishable.ts @@ -11,7 +11,7 @@ interface Field { { status: { _eq: string - }, + } type: { _in: Array } @@ -23,22 +23,20 @@ interface Field { type LoggerFunction = (message: string) => void -const isRuleForPublished = function(rule: any): boolean { - return rule.status && rule.status._eq && rule.status._eq === 'published'; +const isRuleForPublished = function (rule: any): boolean { + return rule.status && rule.status._eq && rule.status._eq === 'published' } -const isRuleApplicable = function(rule: any, item: Record): boolean { - +const isRuleApplicable = function (rule: any, item: Record): boolean { if (rule.type && rule.type._in && item.type && rule.type._in.includes(item.type)) { - return true; + return true } - return false; + return false } - export function isPublishable(item: Record, fields: Field[], logger?: LoggerFunction) { const requiredFieldsAreSet = fields.every((field) => { - (() => logger?.('Controlling field ' + field.field))() + ;(() => logger?.('Controlling field ' + field.field))() const hasValue = Boolean(item[field.field]) const isRequiredInSchema = field.schema && field.schema.required @@ -54,37 +52,24 @@ export function isPublishable(item: Record, fields: Field[], logger return ( condition.required && condition.rule && - ( - // Either the rule(s) are a single rule only - isRuleForPublished(condition.rule) || - ( - // or they are combined with an AND - condition.rule._and && - ( - // but that AND might only contain a single rule - // then only the one needs to apply - (condition.rule._and.length === 1 && condition.rule._and.some( - (rule) => isRuleForPublished(rule) - )) || - ( - // or it might contain multiple rules, then each needs to apply - condition.rule._and.some( - (rule) => isRuleForPublished(rule) - ) && - condition.rule._and.some( - (rule) => isRuleApplicable(rule, item) - ) - ) - ) - ) - ) + // Either the rule(s) are a single rule only + (isRuleForPublished(condition.rule) || + // or they are combined with an AND + (condition.rule._and && + // but that AND might only contain a single rule + // then only the one needs to apply + ((condition.rule._and.length === 1 && + condition.rule._and.some((rule) => isRuleForPublished(rule))) || + // or it might contain multiple rules, then each needs to apply + (condition.rule._and.some((rule) => isRuleForPublished(rule)) && + condition.rule._and.some((rule) => isRuleApplicable(rule, item)))))) ) - }); - (() => logger?.('is required on published ' + isRequiredOnPublished))() + }) + ;(() => logger?.('is required on published ' + isRequiredOnPublished))() } - const isOptional = !isRequiredInSchema && !isRequiredOnPublished; + const isOptional = !isRequiredInSchema && !isRequiredOnPublished - (() => logger?.('Is set: ' + (hasValue || isOptional)))() + ;(() => logger?.('Is set: ' + (hasValue || isOptional)))() return hasValue || isOptional }) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/wallet-pass-generator.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/wallet-pass-generator.ts index e2fbb9a2..0a49a93d 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/wallet-pass-generator.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/wallet-pass-generator.ts @@ -215,10 +215,7 @@ function signJwt(payload: object, privateKey: string): string { return `${encodedHeader}.${encodedPayload}.${signature}` } -export function generateGoogleWalletUrl( - input: WalletPassInput, - env: Record -): string | null { +export function generateGoogleWalletUrl(input: WalletPassInput, env: Record): string | null { const config = loadGoogleConfig(env) if (!config) return null diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/social-media-publish/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/social-media-publish/index.ts index a42ece3b..5a9c06a6 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/social-media-publish/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/social-media-publish/index.ts @@ -1,7 +1,7 @@ import { defineHook } from '@directus/extensions-sdk' +import { safeAction } from '../shared/safeHook.ts' import { publishToBluesky } from './bluesky.js' import { publishToMastodon } from './mastodon.js' -import { safeAction } from '../shared/safeHook.ts' const HOOK_NAME = 'social-media-publish' @@ -20,92 +20,99 @@ export default defineHook(({ action }, hookContext) => { return } - logger.info(`${HOOK_NAME}: Initialized with platforms: ${[hasBluesky && 'Bluesky', hasMastodon && 'Mastodon'].filter(Boolean).join(', ')}`) + logger.info( + `${HOOK_NAME}: Initialized with platforms: ${[hasBluesky && 'Bluesky', hasMastodon && 'Mastodon'].filter(Boolean).join(', ')}` + ) // Trigger when a social_media_posts item is updated to 'scheduled' and scheduled_for is now or in the past // OR when manually triggered by setting status to 'publishing' - action('social_media_posts.items.update', safeAction(HOOK_NAME, logger, async function (metadata, eventContext) { - const { payload, keys } = metadata - - // Only proceed if status is being set to 'publishing' - if (payload.status !== 'publishing') { - return - } - - const schema = await getSchema() - const postsService = new ItemsService('social_media_posts', { - schema, - accountability: eventContext.accountability, - }) - - for (const postId of keys) { - try { - const post = await postsService.readOne(postId, { - fields: ['id', 'platform', 'post_text', 'tags', 'podcast_id'], - }) - - if (!post) { - logger.warn(`${HOOK_NAME}: Post ${postId} not found`) - continue - } + action( + 'social_media_posts.items.update', + safeAction(HOOK_NAME, logger, async function (metadata, eventContext) { + const { payload, keys } = metadata + + // Only proceed if status is being set to 'publishing' + if (payload.status !== 'publishing') { + return + } - logger.info(`${HOOK_NAME}: Publishing post ${postId} to ${post.platform}`) + const schema = await getSchema() + const postsService = new ItemsService('social_media_posts', { + schema, + accountability: eventContext.accountability, + }) - let result: { postId: string; postUrl: string } | null = null + for (const postId of keys) { + try { + const post = await postsService.readOne(postId, { + fields: ['id', 'platform', 'post_text', 'tags', 'podcast_id'], + }) - switch (post.platform) { - case 'bluesky': - if (!hasBluesky) { - throw new Error('Bluesky credentials not configured') - } - result = await publishToBluesky(post.post_text, { - handle: env.BLUESKY_HANDLE, - appPassword: env.BLUESKY_APP_PASSWORD, - logger, - }) - break - - case 'mastodon': - if (!hasMastodon) { - throw new Error('Mastodon credentials not configured') - } - result = await publishToMastodon(post.post_text, { - instanceUrl: env.MASTODON_INSTANCE_URL, - accessToken: env.MASTODON_ACCESS_TOKEN, - logger, + if (!post) { + logger.warn(`${HOOK_NAME}: Post ${postId} not found`) + continue + } + + logger.info(`${HOOK_NAME}: Publishing post ${postId} to ${post.platform}`) + + let result: { postId: string; postUrl: string } | null = null + + switch (post.platform) { + case 'bluesky': + if (!hasBluesky) { + throw new Error('Bluesky credentials not configured') + } + result = await publishToBluesky(post.post_text, { + handle: env.BLUESKY_HANDLE, + appPassword: env.BLUESKY_APP_PASSWORD, + logger, + }) + break + + case 'mastodon': + if (!hasMastodon) { + throw new Error('Mastodon credentials not configured') + } + result = await publishToMastodon(post.post_text, { + instanceUrl: env.MASTODON_INSTANCE_URL, + accessToken: env.MASTODON_ACCESS_TOKEN, + logger, + }) + break + + case 'linkedin': + case 'instagram': + // Not implemented yet + throw new Error(`${post.platform} publishing not yet implemented`) + + default: + throw new Error(`Unknown platform: ${post.platform}`) + } + + if (result) { + await postsService.updateOne(postId, { + status: 'published', + platform_post_id: result.postId, + platform_post_url: result.postUrl, + published_at: new Date().toISOString(), + error_message: null, }) - break - case 'linkedin': - case 'instagram': - // Not implemented yet - throw new Error(`${post.platform} publishing not yet implemented`) - - default: - throw new Error(`Unknown platform: ${post.platform}`) - } + logger.info( + `${HOOK_NAME}: Successfully published post ${postId} to ${post.platform}: ${result.postUrl}` + ) + } + } catch (err: any) { + logger.error(`${HOOK_NAME}: Failed to publish post ${postId}: ${err?.message || err}`) - if (result) { await postsService.updateOne(postId, { - status: 'published', - platform_post_id: result.postId, - platform_post_url: result.postUrl, - published_at: new Date().toISOString(), - error_message: null, + status: 'failed', + error_message: err?.message || String(err), }) - - logger.info(`${HOOK_NAME}: Successfully published post ${postId} to ${post.platform}: ${result.postUrl}`) } - } catch (err: any) { - logger.error(`${HOOK_NAME}: Failed to publish post ${postId}: ${err?.message || err}`) - - await postsService.updateOne(postId, { - status: 'failed', - error_message: err?.message || String(err), - }) } - } - })) + }) + ) logger.info(`${HOOK_NAME} hook registered`) }) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/speaker-portal-notifications/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/speaker-portal-notifications/index.ts index 36799f53..82f84853 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/speaker-portal-notifications/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/speaker-portal-notifications/index.ts @@ -1,8 +1,8 @@ import { defineHook } from '@directus/extensions-sdk' -import { sendTemplatedEmail, formatDateGerman, type EmailServiceContext } from '../shared/email-service.js' -import { getSetting, getSettings } from '../shared/settings.js' +import { formatDateGerman, sendTemplatedEmail, type EmailServiceContext } from '../shared/email-service.js' import { postSlackMessage } from '../shared/postSlackMessage.js' import { safeAction } from '../shared/safeHook.ts' +import { getSetting, getSettings } from '../shared/settings.js' const HOOK_NAME = 'speaker-portal-notifications' @@ -127,135 +127,146 @@ export default defineHook(({ action, schedule }, hookContext) => { /** * Send invitation email when a new speaker is created (token is auto-generated). */ - action('speakers.items.create', safeAction(HOOK_NAME, logger, async function (metadata, eventContext) { - const { key } = metadata - - try { - await sendInvitationForSpeaker(key, eventContext.accountability) - } catch (err: any) { - logger.error(`${HOOK_NAME}: Error sending invitation email on create: ${err?.message || err}`) - } - })) + action( + 'speakers.items.create', + safeAction(HOOK_NAME, logger, async function (metadata, eventContext) { + const { key } = metadata + + try { + await sendInvitationForSpeaker(key, eventContext.accountability) + } catch (err: any) { + logger.error(`${HOOK_NAME}: Error sending invitation email on create: ${err?.message || err}`) + } + }) + ) /** * Send invitation email when a speaker's portal token is regenerated. */ - action('speakers.items.update', safeAction(HOOK_NAME, logger, async function (metadata, eventContext) { - const { payload, keys } = metadata - - // Only proceed if portal_token was just set - if (!payload.portal_token) { - return - } + action( + 'speakers.items.update', + safeAction(HOOK_NAME, logger, async function (metadata, eventContext) { + const { payload, keys } = metadata + + // Only proceed if portal_token was just set + if (!payload.portal_token) { + return + } - try { - for (const speakerId of keys) { - await sendInvitationForSpeaker(speakerId, eventContext.accountability) + try { + for (const speakerId of keys) { + await sendInvitationForSpeaker(speakerId, eventContext.accountability) + } + } catch (err: any) { + logger.error(`${HOOK_NAME}: Error sending invitation email on update: ${err?.message || err}`) } - } catch (err: any) { - logger.error(`${HOOK_NAME}: Error sending invitation email on update: ${err?.message || err}`) - } - })) + }) + ) /** * Send confirmation when a speaker submits their information. */ - action('speakers.items.update', safeAction(HOOK_NAME, logger, async function (metadata, eventContext) { - const { payload, keys } = metadata - - // Only proceed if status is being set to 'submitted' - if (payload.portal_submission_status !== 'submitted') { - return - } - - const context: EmailServiceContext = { - logger, - services, - getSchema, - accountability: eventContext.accountability, - } - - try { - const schema = await getSchema() + action( + 'speakers.items.update', + safeAction(HOOK_NAME, logger, async function (metadata, eventContext) { + const { payload, keys } = metadata + + // Only proceed if status is being set to 'submitted' + if (payload.portal_submission_status !== 'submitted') { + return + } - const speakersService = new ItemsService('speakers', { - schema, + const context: EmailServiceContext = { + logger, + services, + getSchema, accountability: eventContext.accountability, - }) + } - for (const speakerId of keys) { - const speaker = await speakersService.readOne(speakerId, { - fields: ['id', 'first_name', 'last_name', 'email'], - }) + try { + const schema = await getSchema() - if (!speaker?.email) { - continue - } + const speakersService = new ItemsService('speakers', { + schema, + accountability: eventContext.accountability, + }) - // Find related podcast for context - let podcastTitle: string | undefined - try { - const podcastSpeakersService = new ItemsService('podcasts_speakers', { - schema, - accountability: eventContext.accountability, - }) - const podcastsService = new ItemsService('podcasts', { - schema, - accountability: eventContext.accountability, + for (const speakerId of keys) { + const speaker = await speakersService.readOne(speakerId, { + fields: ['id', 'first_name', 'last_name', 'email'], }) - const relations = await podcastSpeakersService.readByQuery({ - filter: { speaker: { _eq: speakerId } }, - fields: ['podcast'], - limit: 1, - sort: ['-id'], - }) + if (!speaker?.email) { + continue + } - if (relations && relations.length > 0 && relations[0].podcast) { - const podcast = await podcastsService.readOne(relations[0].podcast, { - fields: ['title'], + // Find related podcast for context + let podcastTitle: string | undefined + try { + const podcastSpeakersService = new ItemsService('podcasts_speakers', { + schema, + accountability: eventContext.accountability, }) - podcastTitle = podcast?.title + const podcastsService = new ItemsService('podcasts', { + schema, + accountability: eventContext.accountability, + }) + + const relations = await podcastSpeakersService.readByQuery({ + filter: { speaker: { _eq: speakerId } }, + fields: ['podcast'], + limit: 1, + sort: ['-id'], + }) + + if (relations && relations.length > 0 && relations[0].podcast) { + const podcast = await podcastsService.readOne(relations[0].podcast, { + fields: ['title'], + }) + podcastTitle = podcast?.title + } + } catch { + // Optional } - } catch { - // Optional - } - const emailData = { - first_name: speaker.first_name, - last_name: speaker.last_name, - podcast_title: podcastTitle, - } + const emailData = { + first_name: speaker.first_name, + last_name: speaker.last_name, + podcast_title: podcastTitle, + } + + // Send confirmation to speaker + logger.info(`${HOOK_NAME}: Sending submission confirmation to ${speaker.email}`) + + await sendTemplatedEmail( + { + templateKey: 'speaker_submission_confirmation', + to: speaker.email, + data: emailData, + }, + context + ) + + // Notify admin via Slack + const speakerName = `${speaker.first_name} ${speaker.last_name}` + const podcastInfo = podcastTitle ? ` für "${podcastTitle}"` : '' + try { + await postSlackMessage( + `:white_check_mark: *Speaker Portal*: ${speakerName} hat die Informationen${podcastInfo} eingereicht. ${env.PUBLIC_URL}admin/content/speakers/${speakerId}` + ) + } catch (slackError: any) { + logger.error(`${HOOK_NAME}: Failed to send Slack notification: ${slackError?.message}`) + } - // Send confirmation to speaker - logger.info(`${HOOK_NAME}: Sending submission confirmation to ${speaker.email}`) - - await sendTemplatedEmail( - { - templateKey: 'speaker_submission_confirmation', - to: speaker.email, - data: emailData, - }, - context - ) - - // Notify admin via Slack - const speakerName = `${speaker.first_name} ${speaker.last_name}` - const podcastInfo = podcastTitle ? ` für "${podcastTitle}"` : '' - try { - await postSlackMessage( - `:white_check_mark: *Speaker Portal*: ${speakerName} hat die Informationen${podcastInfo} eingereicht. ${env.PUBLIC_URL}admin/content/speakers/${speakerId}` + logger.info( + `${HOOK_NAME}: Submission confirmation sent for ${speaker.first_name} ${speaker.last_name}` ) - } catch (slackError: any) { - logger.error(`${HOOK_NAME}: Failed to send Slack notification: ${slackError?.message}`) } - - logger.info(`${HOOK_NAME}: Submission confirmation sent for ${speaker.first_name} ${speaker.last_name}`) + } catch (err: any) { + logger.error(`${HOOK_NAME}: Error sending submission confirmation: ${err?.message || err}`) } - } catch (err: any) { - logger.error(`${HOOK_NAME}: Error sending submission confirmation: ${err?.message || err}`) - } - })) + }) + ) /** * Scheduled task to send deadline reminders. @@ -295,14 +306,7 @@ export default defineHook(({ action, schedule }, hookContext) => { { portal_submission_deadline: { _nnull: true } }, ], }, - fields: [ - 'id', - 'first_name', - 'last_name', - 'email', - 'portal_token', - 'portal_submission_deadline', - ], + fields: ['id', 'first_name', 'last_name', 'email', 'portal_token', 'portal_submission_deadline'], }) logger.info(`${HOOK_NAME}: Found ${pendingSpeakers.length} pending speakers to check`) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/speaker-token/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/speaker-token/index.ts index 2a36d7e7..78a31466 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/speaker-token/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/speaker-token/index.ts @@ -1,5 +1,5 @@ -import { defineHook } from '@directus/extensions-sdk' import { randomUUID } from 'node:crypto' +import { defineHook } from '@directus/extensions-sdk' const HOOK_NAME = 'speaker-token' diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/index.ts index 0fea3586..156f2cd1 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/index.ts @@ -1,16 +1,16 @@ -import { defineHook } from '@directus/extensions-sdk' import { randomUUID } from 'node:crypto' import { Readable } from 'node:stream' +import { defineHook } from '@directus/extensions-sdk' import { sendTemplatedEmail, type EmailServiceContext } from '../shared/email-service.js' -import { getSetting } from '../shared/settings.js' -import { generateUniqueTicketCode, formatPrice } from '../shared/ticket-utils.js' import { - generateInvoicePdf, generateInvoiceNumber, + generateInvoicePdf, ticketTypeLabel, type InvoiceData, } from '../shared/invoice-generator.js' import { safeAction } from '../shared/safeHook.ts' +import { getSetting } from '../shared/settings.js' +import { formatPrice, generateUniqueTicketCode } from '../shared/ticket-utils.js' const HOOK_NAME = 'ticket-order-processing' @@ -33,309 +33,317 @@ export default defineHook(({ action }, hookContext) => { * Creates tickets with profile tokens, generates invoice, and sends emails * (QR codes are generated later when the attendee completes their profile) */ - action('ticket_orders.items.update', safeAction(HOOK_NAME, logger, async function (metadata, eventContext) { - const { payload, keys } = metadata - - // Only proceed if status is being set to 'paid' - if (payload.status !== 'paid') { - return - } - - const context: EmailServiceContext = { - logger, - services, - getSchema, - accountability: eventContext.accountability, - } - - try { - const schema = await getSchema() - - const ordersService = new ItemsService('ticket_orders', { - schema, - accountability: { admin: true }, - }) - - const ticketsService = new ItemsService('tickets', { - schema, - accountability: { admin: true }, - }) - - const conferencesService = new ItemsService('conferences', { - schema, - accountability: { admin: true }, - }) - - const websiteUrl = (await getSetting('website_url', context)) || 'https://www.programmier.bar' - - for (const orderId of keys) { - logger.info(`${HOOK_NAME}: Processing paid order ${orderId}`) - - // Get order details (including billing fields for invoice) - const order = await ordersService.readOne(orderId, { - fields: [ - 'id', - 'order_number', - 'conference', - 'purchase_type', - 'purchaser_first_name', - 'purchaser_last_name', - 'purchaser_email', - 'company_name', - 'company_vat_id', - 'billing_address_line1', - 'billing_address_line2', - 'billing_city', - 'billing_postal_code', - 'billing_country', - 'billing_email', - 'subtotal_cents', - 'discount_amount_cents', - 'total_cents', - 'total_gross_cents', - 'vat_amount_cents', - 'attendees_json', - 'ticket_type', - 'is_internal', - ], - }) + action( + 'ticket_orders.items.update', + safeAction(HOOK_NAME, logger, async function (metadata, eventContext) { + const { payload, keys } = metadata + + // Only proceed if status is being set to 'paid' + if (payload.status !== 'paid') { + return + } - if (!order) { - logger.error(`${HOOK_NAME}: Order ${orderId} not found`) - continue - } + const context: EmailServiceContext = { + logger, + services, + getSchema, + accountability: eventContext.accountability, + } - // Get conference details (title + start_on for invoice year + ticket limit) - const conference = await conferencesService.readOne(order.conference, { - fields: ['title', 'start_on', 'ticket_max_quantity'], - }) + try { + const schema = await getSchema() - if (!conference) { - logger.error(`${HOOK_NAME}: Conference ${order.conference} not found`) - continue - } + const ordersService = new ItemsService('ticket_orders', { + schema, + accountability: { admin: true }, + }) - // Get attendees from order (Directus may already parse JSON fields) - let attendees: Array<{ firstName: string; lastName: string; email: string }> = [] - try { - if (order.attendees_json) { - // Handle both cases: already parsed (object/array) or string - if (typeof order.attendees_json === 'string') { - attendees = JSON.parse(order.attendees_json) - } else if (Array.isArray(order.attendees_json)) { - attendees = order.attendees_json - } - } - } catch (e) { - logger.error(`${HOOK_NAME}: Failed to parse attendees_json for order ${orderId}: ${e}`) - continue - } + const ticketsService = new ItemsService('tickets', { + schema, + accountability: { admin: true }, + }) - if (attendees.length === 0) { - logger.error(`${HOOK_NAME}: No attendees found for order ${orderId}`) - continue - } + const conferencesService = new ItemsService('conferences', { + schema, + accountability: { admin: true }, + }) - const isInternal = order.is_internal === true - - // Hard limit check: verify ticket limit before creating tickets (internal tickets don't count) - if (!isInternal && conference.ticket_max_quantity !== null && conference.ticket_max_quantity !== undefined) { - const existingTickets = await ticketsService.readByQuery({ - filter: { - conference: { _eq: order.conference }, - status: { _neq: 'cancelled' }, - is_internal: { _neq: true }, - }, - aggregate: { count: ['id'] }, + const websiteUrl = (await getSetting('website_url', context)) || 'https://www.programmier.bar' + + for (const orderId of keys) { + logger.info(`${HOOK_NAME}: Processing paid order ${orderId}`) + + // Get order details (including billing fields for invoice) + const order = await ordersService.readOne(orderId, { + fields: [ + 'id', + 'order_number', + 'conference', + 'purchase_type', + 'purchaser_first_name', + 'purchaser_last_name', + 'purchaser_email', + 'company_name', + 'company_vat_id', + 'billing_address_line1', + 'billing_address_line2', + 'billing_city', + 'billing_postal_code', + 'billing_country', + 'billing_email', + 'subtotal_cents', + 'discount_amount_cents', + 'total_cents', + 'total_gross_cents', + 'vat_amount_cents', + 'attendees_json', + 'ticket_type', + 'is_internal', + ], }) - const currentCount = Number(existingTickets?.[0]?.count?.id ?? 0) - if (currentCount + attendees.length > conference.ticket_max_quantity) { - logger.error( - `${HOOK_NAME}: Ticket limit exceeded for conference ${order.conference}. ` + - `Current: ${currentCount}, requested: ${attendees.length}, limit: ${conference.ticket_max_quantity}. ` + - `Marking order ${orderId} as cancelled.` - ) - await ordersService.updateOne(orderId, { status: 'cancelled' }) + + if (!order) { + logger.error(`${HOOK_NAME}: Order ${orderId} not found`) continue } - } - - const pricePerTicket = Math.round((order.total_cents || 0) / attendees.length) - const purchaserName = `${order.purchaser_first_name} ${order.purchaser_last_name}` - - // --- Generate invoice (skipped for internal employee orders) --- - let invoiceNumber: string | null = null - let invoiceFileName: string | null = null - let pdfBuffer: Buffer | null = null - - if (!isInternal) { - const conferenceYear = new Date(conference.start_on).getFullYear() - invoiceNumber = await generateInvoiceNumber(ordersService, conferenceYear) - - const now = new Date() - const invoiceDate = now.toLocaleDateString('de-DE', { - day: '2-digit', - month: '2-digit', - year: 'numeric', + // Get conference details (title + start_on for invoice year + ticket limit) + const conference = await conferencesService.readOne(order.conference, { + fields: ['title', 'start_on', 'ticket_max_quantity'], }) - // Derive per-ticket price from the pre-discount subtotal so the line item reconciles with Zwischensumme; the Rabatt line takes us down to the actual total. - const subtotalCents = order.subtotal_cents || order.total_cents || 0 - const baseUnitNetCents = Math.round(subtotalCents / attendees.length) - const grossPerTicket = Math.round(baseUnitNetCents * 1.19) - - const invoiceData: InvoiceData = { - invoiceNumber, - invoiceDate, - purchaserName, - purchaserEmail: order.purchaser_email, - companyName: order.company_name, - companyVatId: order.company_vat_id, - billingAddressLine1: order.billing_address_line1, - billingAddressLine2: order.billing_address_line2, - billingCity: order.billing_city, - billingPostalCode: order.billing_postal_code, - billingCountry: order.billing_country, - conferenceTitle: conference.title, - ticketType: ticketTypeLabel(order.ticket_type), - ticketCount: attendees.length, - unitPriceGrossCents: grossPerTicket, - subtotalCents: order.subtotal_cents || order.total_cents, - discountAmountCents: order.discount_amount_cents || 0, - vatAmountCents: order.vat_amount_cents || 0, - totalGrossCents: order.total_gross_cents || order.total_cents, + if (!conference) { + logger.error(`${HOOK_NAME}: Conference ${order.conference} not found`) + continue } - logger.info(`${HOOK_NAME}: Generating invoice ${invoiceNumber} for order ${order.order_number}`) - pdfBuffer = await generateInvoicePdf(invoiceData) - - // Upload PDF to Directus files - const filesService = new FilesService({ - accountability: { admin: true }, - schema, - }) + // Get attendees from order (Directus may already parse JSON fields) + let attendees: Array<{ firstName: string; lastName: string; email: string }> = [] + try { + if (order.attendees_json) { + // Handle both cases: already parsed (object/array) or string + if (typeof order.attendees_json === 'string') { + attendees = JSON.parse(order.attendees_json) + } else if (Array.isArray(order.attendees_json)) { + attendees = order.attendees_json + } + } + } catch (e) { + logger.error(`${HOOK_NAME}: Failed to parse attendees_json for order ${orderId}: ${e}`) + continue + } - invoiceFileName = `Rechnung-${invoiceNumber}.pdf` - const storageLocation = env.STORAGE_LOCATIONS?.split(',')[0] + if (attendees.length === 0) { + logger.error(`${HOOK_NAME}: No attendees found for order ${orderId}`) + continue + } - const pdfStream = Readable.from([pdfBuffer]) - const fileId = await filesService.uploadOne(pdfStream, { - type: 'application/pdf', - filename_download: invoiceFileName, - title: `Rechnung ${invoiceNumber}`, - ...(storageLocation && { storage: storageLocation }), - }) + const isInternal = order.is_internal === true + + // Hard limit check: verify ticket limit before creating tickets (internal tickets don't count) + if ( + !isInternal && + conference.ticket_max_quantity !== null && + conference.ticket_max_quantity !== undefined + ) { + const existingTickets = await ticketsService.readByQuery({ + filter: { + conference: { _eq: order.conference }, + status: { _neq: 'cancelled' }, + is_internal: { _neq: true }, + }, + aggregate: { count: ['id'] }, + }) + const currentCount = Number(existingTickets?.[0]?.count?.id ?? 0) + if (currentCount + attendees.length > conference.ticket_max_quantity) { + logger.error( + `${HOOK_NAME}: Ticket limit exceeded for conference ${order.conference}. ` + + `Current: ${currentCount}, requested: ${attendees.length}, limit: ${conference.ticket_max_quantity}. ` + + `Marking order ${orderId} as cancelled.` + ) + await ordersService.updateOne(orderId, { status: 'cancelled' }) + continue + } + } - // Update order with invoice number and file reference - await ordersService.updateOne(orderId, { - invoice_number: invoiceNumber, - invoice_file: fileId, - }) + const pricePerTicket = Math.round((order.total_cents || 0) / attendees.length) + const purchaserName = `${order.purchaser_first_name} ${order.purchaser_last_name}` + + // --- Generate invoice (skipped for internal employee orders) --- + let invoiceNumber: string | null = null + let invoiceFileName: string | null = null + let pdfBuffer: Buffer | null = null + + if (!isInternal) { + const conferenceYear = new Date(conference.start_on).getFullYear() + invoiceNumber = await generateInvoiceNumber(ordersService, conferenceYear) + + const now = new Date() + const invoiceDate = now.toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }) + + // Derive per-ticket price from the pre-discount subtotal so the line item reconciles with Zwischensumme; the Rabatt line takes us down to the actual total. + const subtotalCents = order.subtotal_cents || order.total_cents || 0 + const baseUnitNetCents = Math.round(subtotalCents / attendees.length) + const grossPerTicket = Math.round(baseUnitNetCents * 1.19) + + const invoiceData: InvoiceData = { + invoiceNumber, + invoiceDate, + purchaserName, + purchaserEmail: order.purchaser_email, + companyName: order.company_name, + companyVatId: order.company_vat_id, + billingAddressLine1: order.billing_address_line1, + billingAddressLine2: order.billing_address_line2, + billingCity: order.billing_city, + billingPostalCode: order.billing_postal_code, + billingCountry: order.billing_country, + conferenceTitle: conference.title, + ticketType: ticketTypeLabel(order.ticket_type), + ticketCount: attendees.length, + unitPriceGrossCents: grossPerTicket, + subtotalCents: order.subtotal_cents || order.total_cents, + discountAmountCents: order.discount_amount_cents || 0, + vatAmountCents: order.vat_amount_cents || 0, + totalGrossCents: order.total_gross_cents || order.total_cents, + } - logger.info(`${HOOK_NAME}: Invoice ${invoiceNumber} generated and stored (file: ${fileId})`) - } else { - logger.info(`${HOOK_NAME}: Skipping invoice for internal order ${order.order_number}`) - } + logger.info(`${HOOK_NAME}: Generating invoice ${invoiceNumber} for order ${order.order_number}`) + pdfBuffer = await generateInvoicePdf(invoiceData) + + // Upload PDF to Directus files + const filesService = new FilesService({ + accountability: { admin: true }, + schema, + }) + + invoiceFileName = `Rechnung-${invoiceNumber}.pdf` + const storageLocation = env.STORAGE_LOCATIONS?.split(',')[0] + + const pdfStream = Readable.from([pdfBuffer]) + const fileId = await filesService.uploadOne(pdfStream, { + type: 'application/pdf', + filename_download: invoiceFileName, + title: `Rechnung ${invoiceNumber}`, + ...(storageLocation && { storage: storageLocation }), + }) + + // Update order with invoice number and file reference + await ordersService.updateOne(orderId, { + invoice_number: invoiceNumber, + invoice_file: fileId, + }) + + logger.info(`${HOOK_NAME}: Invoice ${invoiceNumber} generated and stored (file: ${fileId})`) + } else { + logger.info(`${HOOK_NAME}: Skipping invoice for internal order ${order.order_number}`) + } - // --- Create individual tickets with profile tokens (no QR codes yet) --- - const ticketRecords: Array<{ - attendeeName: string - attendeeEmail: string - ticketCode: string - profileToken: string - }> = [] - - for (const attendee of attendees) { - const ticketCode = await generateUniqueTicketCode(ticketsService) - const profileToken = randomUUID() - - // Create ticket in database with pending profile - await ticketsService.createOne({ - ticket_code: ticketCode, - order: orderId, - conference: order.conference, - attendee_first_name: attendee.firstName, - attendee_last_name: attendee.lastName, - attendee_email: attendee.email, - ticket_type: order.ticket_type, - price_cents: pricePerTicket, - status: 'valid', - profile_token: profileToken, - profile_status: 'pending', - is_internal: isInternal, - }) + // --- Create individual tickets with profile tokens (no QR codes yet) --- + const ticketRecords: Array<{ + attendeeName: string + attendeeEmail: string + ticketCode: string + profileToken: string + }> = [] + + for (const attendee of attendees) { + const ticketCode = await generateUniqueTicketCode(ticketsService) + const profileToken = randomUUID() + + // Create ticket in database with pending profile + await ticketsService.createOne({ + ticket_code: ticketCode, + order: orderId, + conference: order.conference, + attendee_first_name: attendee.firstName, + attendee_last_name: attendee.lastName, + attendee_email: attendee.email, + ticket_type: order.ticket_type, + price_cents: pricePerTicket, + status: 'valid', + profile_token: profileToken, + profile_status: 'pending', + is_internal: isInternal, + }) + + ticketRecords.push({ + attendeeName: `${attendee.firstName} ${attendee.lastName}`, + attendeeEmail: attendee.email, + ticketCode, + profileToken, + }) + + logger.info( + `${HOOK_NAME}: Created ticket ${ticketCode} for ${attendee.email} (profile pending)` + ) + } - ticketRecords.push({ - attendeeName: `${attendee.firstName} ${attendee.lastName}`, - attendeeEmail: attendee.email, - ticketCode, - profileToken, - }) + // --- Send purchaser confirmation email with invoice PDF (skipped for internal orders) --- + if (!isInternal && pdfBuffer && invoiceFileName && invoiceNumber) { + const totalAmount = formatPrice(order.total_gross_cents || order.total_cents) + + await sendTemplatedEmail( + { + templateKey: 'ticket_order_confirmation', + to: order.purchaser_email, + cc: order.billing_email || undefined, + data: { + purchaser_name: purchaserName, + conference_title: conference.title, + order_number: order.order_number, + total_amount: totalAmount, + ticket_count: ticketRecords.length, + invoice_number: invoiceNumber, + }, + attachments: [ + { + filename: invoiceFileName, + content: pdfBuffer, + contentType: 'application/pdf', + }, + ], + }, + context + ) - logger.info(`${HOOK_NAME}: Created ticket ${ticketCode} for ${attendee.email} (profile pending)`) - } + logger.info(`${HOOK_NAME}: Sent confirmation email with invoice to ${order.purchaser_email}`) + } - // --- Send purchaser confirmation email with invoice PDF (skipped for internal orders) --- - if (!isInternal && pdfBuffer && invoiceFileName && invoiceNumber) { - const totalAmount = formatPrice(order.total_gross_cents || order.total_cents) - - await sendTemplatedEmail( - { - templateKey: 'ticket_order_confirmation', - to: order.purchaser_email, - cc: order.billing_email || undefined, - data: { - purchaser_name: purchaserName, - conference_title: conference.title, - order_number: order.order_number, - total_amount: totalAmount, - ticket_count: ticketRecords.length, - invoice_number: invoiceNumber, - }, - attachments: [ - { - filename: invoiceFileName, - content: pdfBuffer, - contentType: 'application/pdf', + // --- Send profile invitation email to all attendees --- + for (const ticket of ticketRecords) { + const portalUrl = `${websiteUrl}/ticket-portal?token=${encodeURIComponent(ticket.profileToken)}` + + await sendTemplatedEmail( + { + templateKey: 'ticket_profile_invitation', + to: ticket.attendeeEmail, + data: { + attendee_name: ticket.attendeeName, + conference_title: conference.title, + portal_url: portalUrl, + purchaser_name: purchaserName, }, - ], - }, - context - ) + }, + context + ) - logger.info(`${HOOK_NAME}: Sent confirmation email with invoice to ${order.purchaser_email}`) - } + logger.info(`${HOOK_NAME}: Sent profile invitation to ${ticket.attendeeEmail}`) + } - // --- Send profile invitation email to all attendees --- - for (const ticket of ticketRecords) { - const portalUrl = `${websiteUrl}/ticket-portal?token=${encodeURIComponent(ticket.profileToken)}` - - await sendTemplatedEmail( - { - templateKey: 'ticket_profile_invitation', - to: ticket.attendeeEmail, - data: { - attendee_name: ticket.attendeeName, - conference_title: conference.title, - portal_url: portalUrl, - purchaser_name: purchaserName, - }, - }, - context + logger.info( + `${HOOK_NAME}: Order ${order.order_number} processed with ${ticketRecords.length} tickets${invoiceNumber ? `, invoice ${invoiceNumber}` : ' (internal, no invoice)'}` ) - - logger.info(`${HOOK_NAME}: Sent profile invitation to ${ticket.attendeeEmail}`) } - - logger.info( - `${HOOK_NAME}: Order ${order.order_number} processed with ${ticketRecords.length} tickets${invoiceNumber ? `, invoice ${invoiceNumber}` : ' (internal, no invoice)'}` - ) + } catch (err: any) { + logger.error(`${HOOK_NAME}: Error processing order: ${err?.message || err}`) } - } catch (err: any) { - logger.error(`${HOOK_NAME}: Error processing order: ${err?.message || err}`) - } - })) + }) + ) logger.info(`${HOOK_NAME} hook registered`) }) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-profile-completion/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-profile-completion/index.ts index 414b0fd4..81bd2eba 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-profile-completion/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-profile-completion/index.ts @@ -1,5 +1,6 @@ import { defineHook } from '@directus/extensions-sdk' import { sendTemplatedEmail, type EmailServiceContext } from '../shared/email-service.js' +import { safeAction } from '../shared/safeHook.ts' import { getSetting } from '../shared/settings.js' import { generateQRCodeBuffer } from '../shared/ticket-utils.js' import { @@ -7,7 +8,6 @@ import { generateGoogleWalletUrl, type WalletPassInput, } from '../shared/wallet-pass-generator.js' -import { safeAction } from '../shared/safeHook.ts' const HOOK_NAME = 'ticket-profile-completion' @@ -26,156 +26,159 @@ export default defineHook(({ action }, hookContext) => { /** * Send final ticket email with QR code and wallet passes when profile_status changes to 'completed' */ - action('tickets.items.update', safeAction(HOOK_NAME, logger, async function (metadata, eventContext) { - const { payload, keys } = metadata - - // Only proceed if profile_status is being set to 'completed' - if (payload.profile_status !== 'completed') { - return - } - - const context: EmailServiceContext = { - logger, - services, - getSchema, - accountability: eventContext.accountability, - } - - try { - const schema = await getSchema() - - const ticketsService = new ItemsService('tickets', { - schema, - accountability: { admin: true }, - }) - - const conferencesService = new ItemsService('conferences', { - schema, - accountability: { admin: true }, - }) - - const websiteUrl = (await getSetting('website_url', context)) || 'https://www.programmier.bar' - const venueName = await getSetting('conference_venue_name', context) - const venueAddress = await getSetting('conference_venue_address', context) - - for (const ticketId of keys) { - logger.info(`${HOOK_NAME}: Processing completed profile for ticket ${ticketId}`) - - const ticket = await ticketsService.readOne(ticketId, { - fields: [ - 'id', - 'ticket_code', - 'conference', - 'attendee_first_name', - 'attendee_last_name', - 'attendee_email', - 'profile_token', - ], - }) + action( + 'tickets.items.update', + safeAction(HOOK_NAME, logger, async function (metadata, eventContext) { + const { payload, keys } = metadata + + // Only proceed if profile_status is being set to 'completed' + if (payload.profile_status !== 'completed') { + return + } - if (!ticket) { - logger.error(`${HOOK_NAME}: Ticket ${ticketId} not found`) - continue - } + const context: EmailServiceContext = { + logger, + services, + getSchema, + accountability: eventContext.accountability, + } - const conference = await conferencesService.readOne(ticket.conference, { - fields: ['title', 'start_on', 'end_on'], + try { + const schema = await getSchema() + + const ticketsService = new ItemsService('tickets', { + schema, + accountability: { admin: true }, }) - if (!conference) { - logger.error(`${HOOK_NAME}: Conference ${ticket.conference} not found`) - continue - } + const conferencesService = new ItemsService('conferences', { + schema, + accountability: { admin: true }, + }) - // Generate QR code as buffer for CID embedding - const qrCodeBuffer = await generateQRCodeBuffer(ticket.ticket_code, websiteUrl) - const attendeeName = `${ticket.attendee_first_name} ${ticket.attendee_last_name}` - const qrCid = `qrcode-${ticket.ticket_code}@programmier.bar` - - // Generate wallet passes - const walletInput: WalletPassInput = { - ticketCode: ticket.ticket_code, - attendeeName, - attendeeEmail: ticket.attendee_email, - conferenceTitle: conference.title, - conferenceDate: conference.start_on, - conferenceEndDate: conference.end_on, - venueName: venueName || undefined, - venueAddress: venueAddress || undefined, - websiteUrl, - } + const websiteUrl = (await getSetting('website_url', context)) || 'https://www.programmier.bar' + const venueName = await getSetting('conference_venue_name', context) + const venueAddress = await getSetting('conference_venue_address', context) + + for (const ticketId of keys) { + logger.info(`${HOOK_NAME}: Processing completed profile for ticket ${ticketId}`) + + const ticket = await ticketsService.readOne(ticketId, { + fields: [ + 'id', + 'ticket_code', + 'conference', + 'attendee_first_name', + 'attendee_last_name', + 'attendee_email', + 'profile_token', + ], + }) + + if (!ticket) { + logger.error(`${HOOK_NAME}: Ticket ${ticketId} not found`) + continue + } - const attachments = [ - { - filename: `qrcode-${ticket.ticket_code}.png`, - content: qrCodeBuffer, - contentType: 'image/png', - cid: qrCid, - }, - ] - - // Apple Wallet: attach .pkpass to email + provide download URL - let appleWalletUrl: string | null = null - try { - const applePassBuffer = await generateAppleWalletPass(walletInput, env) - if (applePassBuffer) { - attachments.push({ - filename: `${ticket.ticket_code}.pkpass`, - content: applePassBuffer, - contentType: 'application/vnd.apple.pkpass', - cid: '', - }) - // Only emit a re-download link when we actually have a token to - // authenticate it with; otherwise the URL would carry "token=null". - if (ticket.profile_token) { - const directusUrl = (env.PUBLIC_URL || '').replace(/\/+$/, '') - const tokenParam = encodeURIComponent(ticket.profile_token) - appleWalletUrl = `${directusUrl}/ticket-wallet/apple/${ticket.ticket_code}?token=${tokenParam}` - } else { - logger.warn( - `${HOOK_NAME}: Ticket ${ticket.ticket_code} has no profile_token; skipping Apple Wallet re-download link` - ) + const conference = await conferencesService.readOne(ticket.conference, { + fields: ['title', 'start_on', 'end_on'], + }) + + if (!conference) { + logger.error(`${HOOK_NAME}: Conference ${ticket.conference} not found`) + continue + } + + // Generate QR code as buffer for CID embedding + const qrCodeBuffer = await generateQRCodeBuffer(ticket.ticket_code, websiteUrl) + const attendeeName = `${ticket.attendee_first_name} ${ticket.attendee_last_name}` + const qrCid = `qrcode-${ticket.ticket_code}@programmier.bar` + + // Generate wallet passes + const walletInput: WalletPassInput = { + ticketCode: ticket.ticket_code, + attendeeName, + attendeeEmail: ticket.attendee_email, + conferenceTitle: conference.title, + conferenceDate: conference.start_on, + conferenceEndDate: conference.end_on, + venueName: venueName || undefined, + venueAddress: venueAddress || undefined, + websiteUrl, + } + + const attachments = [ + { + filename: `qrcode-${ticket.ticket_code}.png`, + content: qrCodeBuffer, + contentType: 'image/png', + cid: qrCid, + }, + ] + + // Apple Wallet: attach .pkpass to email + provide download URL + let appleWalletUrl: string | null = null + try { + const applePassBuffer = await generateAppleWalletPass(walletInput, env) + if (applePassBuffer) { + attachments.push({ + filename: `${ticket.ticket_code}.pkpass`, + content: applePassBuffer, + contentType: 'application/vnd.apple.pkpass', + cid: '', + }) + // Only emit a re-download link when we actually have a token to + // authenticate it with; otherwise the URL would carry "token=null". + if (ticket.profile_token) { + const directusUrl = (env.PUBLIC_URL || '').replace(/\/+$/, '') + const tokenParam = encodeURIComponent(ticket.profile_token) + appleWalletUrl = `${directusUrl}/ticket-wallet/apple/${ticket.ticket_code}?token=${tokenParam}` + } else { + logger.warn( + `${HOOK_NAME}: Ticket ${ticket.ticket_code} has no profile_token; skipping Apple Wallet re-download link` + ) + } } + } catch (err: any) { + logger.warn(`${HOOK_NAME}: Apple Wallet pass generation failed: ${err?.message || err}`) } - } catch (err: any) { - logger.warn(`${HOOK_NAME}: Apple Wallet pass generation failed: ${err?.message || err}`) - } - // Google Wallet: generate "Add to Wallet" URL - let googleWalletUrl: string | null = null - try { - googleWalletUrl = generateGoogleWalletUrl(walletInput, env) - } catch (err: any) { - logger.warn(`${HOOK_NAME}: Google Wallet URL generation failed: ${err?.message || err}`) - } + // Google Wallet: generate "Add to Wallet" URL + let googleWalletUrl: string | null = null + try { + googleWalletUrl = generateGoogleWalletUrl(walletInput, env) + } catch (err: any) { + logger.warn(`${HOOK_NAME}: Google Wallet URL generation failed: ${err?.message || err}`) + } - // Send final ticket email with QR code and wallet links - await sendTemplatedEmail( - { - templateKey: 'ticket_profile_completed', - to: ticket.attendee_email, - data: { - attendee_name: attendeeName, - conference_title: conference.title, - ticket_code: ticket.ticket_code, - qr_code_cid: qrCid, - apple_wallet_url: appleWalletUrl || '', - google_wallet_url: googleWalletUrl || '', + // Send final ticket email with QR code and wallet links + await sendTemplatedEmail( + { + templateKey: 'ticket_profile_completed', + to: ticket.attendee_email, + data: { + attendee_name: attendeeName, + conference_title: conference.title, + ticket_code: ticket.ticket_code, + qr_code_cid: qrCid, + apple_wallet_url: appleWalletUrl || '', + google_wallet_url: googleWalletUrl || '', + }, + attachments, }, - attachments, - }, - context - ) - - logger.info( - `${HOOK_NAME}: Sent final ticket email to ${ticket.attendee_email} (${ticket.ticket_code})` + - `${appleWalletUrl ? ' +Apple' : ''}${googleWalletUrl ? ' +Google' : ''}` - ) + context + ) + + logger.info( + `${HOOK_NAME}: Sent final ticket email to ${ticket.attendee_email} (${ticket.ticket_code})` + + `${appleWalletUrl ? ' +Apple' : ''}${googleWalletUrl ? ' +Google' : ''}` + ) + } + } catch (err: any) { + logger.error(`${HOOK_NAME}: Error processing profile completion: ${err?.message || err}`) } - } catch (err: any) { - logger.error(`${HOOK_NAME}: Error processing profile completion: ${err?.message || err}`) - } - })) + }) + ) logger.info(`${HOOK_NAME} hook registered`) }) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-wallet/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-wallet/index.ts index f096c074..0a38afa3 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-wallet/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-wallet/index.ts @@ -1,9 +1,8 @@ /// import { defineEndpoint } from '@directus/extensions-sdk' -import { generateAppleWalletPass, type WalletPassInput } from '../shared/wallet-pass-generator.js' -import { getRequiredSetting } from '../shared/settings.js' - import type { SandboxEndpointRouter } from 'directus:api' +import { getRequiredSetting } from '../shared/settings.js' +import { generateAppleWalletPass, type WalletPassInput } from '../shared/wallet-pass-generator.js' export default defineEndpoint(async (router: SandboxEndpointRouter, context) => { const logger = context.logger From d0e128d352ae6b0e526b86f3964027a871e4c7d8 Mon Sep 17 00:00:00 2001 From: Jan Gregor Emge-Triebel Date: Wed, 5 Aug 2026 21:40:20 +0200 Subject: [PATCH 3/4] Gate the extension bundle on format, lint and build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundle's CI job ran npm ci, ESLint and Jest. It did not check formatting, did not typecheck, and never confirmed the artefact Directus loads still compiles. This adds the two gates that need no source changes, plus the config corrections behind them. Phase 0 of docs/directus-extension-tooling-plan.md; no dependency versions change. CI (bundle job now: format, lint, test, build) - `prettier:check`, not `prettier` — the latter writes, so it would pass by mutating. - A build step. `directus-extension build` has its own Rollup/esbuild pipeline that neither ESLint nor Jest exercises, so a change that broke the loadable bundle could merge green. - `node-version-file:` reading a new .nvmrc instead of a hardcoded 22, so CI, the Docker image and local development have one file to change rather than three. Local dev had already drifted to 24.19.0. - `shared-code/**` added to the paths filter. The bundle imports from there, so those changes were skipping the gate entirely. Corrected the comment claiming this tree is frozen by the Directus licence block. It is not: every @directus/* package the bundle uses is MIT, and only the `directus` server package is blocked. The real reason to stay on 22 is Dockerfile.directus — Node 24 brings npm 11, which gates the install scripts this tree's native dependencies need. Hermetic image build Both `npm install` calls in Dockerfile.directus become `npm ci`, so the image can no longer resolve versions the lockfile does not record. Verified both lockfiles are in sync first (`npm ci --dry-run` exits 0 in directus-cms and in the bundle), since otherwise this would land a broken image build. ESLint now covers the one Vue SFC `files: ['**/*.ts']` had left presentation-publishable.vue as the only unlinted source file. Adding eslint-plugin-vue surfaced 2 errors and 20 warnings, the warnings all being formatting rules fighting Prettier — because eslint-config-prettier was already a dependency but the config had never applied it. Harmless while the config was TypeScript-only and enabled no stylistic rules; load-bearing the moment such a plugin arrives. Applying it last takes the 20 to 0. That left two genuine errors, unused watch callback parameters, removed here. The gate was confirmed to actually fire by injecting an unused variable and watching ESLint fail. Also removed a dead ignore entry for `**/podcast-transcription/**`; the directory is `podcast-transcript`, so it had never matched anything. Docs TESTING.md claimed exactly one function was tested; there are 15 files and 205 assertions across 9 of 26 entries. AGENTS.md said formatting was enforced in nuxt-app only, and pointed at nuxt-app's Prettier config as if it were the only one. Verified: prettier:check, lint, test (205/205) and build all green; npm audit unchanged at 19, which Phase 3 addresses. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BMa3aosYRWyivC4DtPbSNr --- .github/workflows/run_tests.yml | 30 +- AGENTS.md | 23 +- Dockerfile.directus | 15 +- .../.nvmrc | 1 + .../TESTING.md | 102 ++-- .../eslint.config.js | 25 +- .../package-lock.json | 87 ++- .../package.json | 9 +- .../publishable/presentation-publishable.vue | 2 +- docs/directus-extension-tooling-plan.md | 546 ++++++++++++++++++ 10 files changed, 772 insertions(+), 68 deletions(-) create mode 100644 directus-cms/extensions/directus-extension-programmierbar-bundle/.nvmrc create mode 100644 docs/directus-extension-tooling-plan.md diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 56a3177b..8b252381 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -5,6 +5,9 @@ on: paths: - 'directus-cms/extensions/directus-extension-programmierbar-bundle/**' - 'nuxt-app/**' + # The extension bundle imports from shared-code/, so a change there can break its build or + # tests. Without this line those changes skip the gate entirely. + - 'shared-code/**' - '.github/workflows/run_tests.yml' jobs: @@ -17,13 +20,18 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - # Deliberately still 22 while nuxt-app moves to 24: this tree has its own lockfile, its own - # Jest setup, and is frozen pending the Directus licence clarification. Moving its runtime - # would be an untested change to a tree nobody is allowed to upgrade. - - name: Use Node.js 22.x + # Reads the bundle's own .nvmrc rather than naming a version here, so CI, the Docker image and + # local development have one file to change instead of three. + # + # Deliberately still 22 while nuxt-app is on 24 — but *not* because of the Directus licence + # block, which an earlier version of this comment claimed. Every @directus/* package this + # bundle uses is MIT; only the `directus` server package is licence-blocked, and it lives in + # directus-cms/package.json. The real reason is Dockerfile.directus: Node 24 brings npm 11, + # which gates the install scripts this tree's native dependencies need. + - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: 22 + node-version-file: directus-cms/extensions/directus-extension-programmierbar-bundle/.nvmrc cache: 'npm' cache-dependency-path: 'directus-cms/extensions/directus-extension-programmierbar-bundle/package-lock.json' @@ -31,12 +39,24 @@ jobs: - name: Install dependencies run: npm ci + # Keep `prettier:check`, not `prettier` — the latter writes, so it would pass by mutating. + # Without this step formatting is voluntary, which is how 64 files drifted here unnoticed. + - name: Check formatting + run: npm run prettier:check + + # Keep `lint`, not `eslint` — the latter runs with --fix, so it mutates instead of failing. - name: Run ESLint run: npm run lint - name: Run tests run: npm test + # Nothing above confirms the bundle still compiles: `directus-extension build` has its own + # Rollup/esbuild pipeline that neither ESLint nor Jest exercises, so a change that breaks the + # actual artefact Directus loads could otherwise merge green. + - name: Build + run: npm run build + nuxt-app-test: runs-on: ubuntu-latest defaults: diff --git a/AGENTS.md b/AGENTS.md index 215d91c6..c21b1d97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,9 +66,15 @@ npm run migrate:db # Database migrations ```bash # In directus-cms/extensions/directus-extension-programmierbar-bundle/ -npm test # Run Jest tests +npm run prettier:check # Formatting — fails, never rewrites +npm run lint # ESLint over .ts and the one .vue file +npm test # Jest +npm run build # directus-extension build ``` +That is the full CI gate, in order. There is deliberately no typecheck step yet — `tsc` cannot +currently run on that tree at all. See [the tooling plan](docs/directus-extension-tooling-plan.md). + ## Code Principles ### Consolidation & DRY @@ -114,16 +120,19 @@ Additional hints can be found in: ### Formatting is enforced, not requested -CI runs `npm run prettier:check` in `nuxt-app`, so unformatted code fails the build. Nobody should be -expected to remember the formatter — turn on **format on save** and it never comes up: +CI runs `npm run prettier:check` in **both** `nuxt-app` and the Directus extension bundle, so +unformatted code fails the build. Nobody should be expected to remember the formatter — turn on +**format on save** and it never comes up: - **WebStorm**: Settings → Languages & Frameworks → JavaScript → Prettier → *On save* - **VS Code**: the Prettier extension, plus `"editor.formatOnSave": true` -Both read `nuxt-app/.prettierrc` on their own, and `nuxt-app/.editorconfig` covers indentation and line -endings before that is set up. Keep those two in step — they overlap, and an editor that indents to a -different width than Prettier produces a diff on every save. If a PR fails the check, `npm run prettier` -fixes it — never hand-edit to satisfy it. +Editors pick up whichever `.prettierrc` is nearest the file, and each gated tree ships one alongside an +`.editorconfig` that covers indentation and line endings before the formatter is set up. Keep each pair +in step — they overlap, and an editor that indents to a different width than Prettier produces a diff on +every save. The two configs are identical apart from `nuxt-app`'s Tailwind class-sorting plugin, so the +same editor setup works in both. If a PR fails the check, `npm run prettier` fixes it — never hand-edit +to satisfy it. Note that `npm run lint` (ESLint) needs `nuxt prepare` to have run first, since `eslint.config.mjs` extends the generated `.nuxt/eslint.config.mjs`. `npm ci` does this via `postinstall`. diff --git a/Dockerfile.directus b/Dockerfile.directus index 45d413e2..6d1268c9 100644 --- a/Dockerfile.directus +++ b/Dockerfile.directus @@ -1,4 +1,10 @@ # Choose a base image +# +# Keep this in step with `directus-cms/extensions/directus-extension-programmierbar-bundle/.nvmrc`, +# which CI reads — the image and CI must build the extension on the same Node, or CI stops being +# evidence about production. Moving to 24 is deliberately deferred: Node 24 ships npm 11, which +# gates dependency install scripts, and this tree needs them to run (sharp, sqlite3, isolated-vm, +# esbuild all build native code on install). See docs/directus-extension-tooling-plan.md. FROM node:22 # Set working directory @@ -9,7 +15,10 @@ COPY directus-cms/package.json . COPY directus-cms/package-lock.json . # Install dependencies -RUN npm install +# +# `npm ci`, not `npm install`: the latter is free to resolve versions the lockfile does not record, +# so the image could ship dependencies CI never tested. `npm ci` fails instead, which is the point. +RUN npm ci # Copy the shared code that lives outside of directus dir COPY shared-code ../shared-code @@ -20,8 +29,8 @@ COPY directus-cms . # Set working directory to interface extension WORKDIR /usr/src/app/directus-cms/extensions/directus-extension-programmierbar-bundle -# Install publishable interface extension dependencies -RUN npm install +# Install extension bundle dependencies (its own lockfile, copied in by `COPY directus-cms .` above) +RUN npm ci # Build the extension RUN npm run build diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/.nvmrc b/directus-cms/extensions/directus-extension-programmierbar-bundle/.nvmrc new file mode 100644 index 00000000..2bd5a0a9 --- /dev/null +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/TESTING.md b/directus-cms/extensions/directus-extension-programmierbar-bundle/TESTING.md index cd6f48ce..f64ae723 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/TESTING.md +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/TESTING.md @@ -1,64 +1,76 @@ -# Automated Testing Setup for directus-extension-programmierbar-bundle +# Testing `directus-extension-programmierbar-bundle` -This document provides an overview of the automated testing setup for the `directus-extension-programmierbar-bundle` project. +## Running the suite -## Testing Framework - -The project uses Jest as the testing framework, with the following configuration: - -- **TypeScript Support**: Using ts-jest for TypeScript support -- **Test Files**: Located in `__tests__` directories with `.test.ts` extension -- **Configuration**: Jest configuration in `jest.config.ts` - -## Test Structure - -The tests are organized as follows: - -- Each extension has its own `__tests__` directory -- Test files are named after the function or component they test -- Test utilities are stored in a `utils` directory within the `__tests__` directory - -## Running Tests +```bash +npm test # run once +npm run test:watch +``` -To run the tests, use the following command: +The full CI gate, in the order `.github/workflows/run_tests.yml` runs it: ```bash -npm test +npm run prettier:check # formatting — fails, never rewrites +npm run lint # ESLint over .ts and the one .vue file +npm test # Jest +npm run build # directus-extension build ``` -## Current Test Coverage +There is deliberately no typecheck step yet. `tsc` cannot currently run on this tree at all — see +[the tooling plan](../../../docs/directus-extension-tooling-plan.md), Phase 1. + +## Framework -The following components have automated tests: +Jest with `ts-jest`. Tests live in `__tests__/` directories beside the code they cover and are named +`*.test.ts`; `jest.config.ts` matches `**/__tests__/**/*.test.ts` and nothing else. -- `getPayloadWithSlug` function in the `set-slug` hook +**The suite runs as CommonJS, not ESM** — despite `jest.config.ts` asking for ESM. This is not a +detail you can ignore when writing tests, and +[ADR 0001](../../../_ADRs/0001-jest-runs-in-cjs-mode.md) explains why it is that way and what it +costs. In short: `jest.unstable_mockModule` and top-level `await` do not work, and an ESM-only +dependency anywhere in a tested import chain has to be stubbed before it can be imported. -## Adding More Tests +## How to write a test here -To add tests for other components: +**Prefer extracting pure functions.** The established pattern is to move business logic into a +`util/` module with no framework imports and unit-test that directly — no mocks, no module-format +problems. Five extensions already do this (`set-slug`, `member-matching`, `cascade-publish`, +`create-news`, `fetch-open-graph`), and it is the approach that will survive the move off Jest. -1. Create a `__tests__` directory in the component's directory -2. Create a test file with the `.test.ts` extension -3. Write tests using Jest's testing functions -4. Run the tests to verify they work +**When a hook's entry file must be tested directly**, use hoisted `jest.mock(...)` and stub the +ESM-only framework dependencies: + +```ts +// The real `defineHook` just returns its callback, so this stub exercises the real hook logic +// without loading the untranspiled package. +jest.mock('@directus/extensions-sdk', () => ({ + defineHook: (callback: unknown) => callback, +})) +``` -## Test Documentation +See `cascade-publish/__tests__/index.test.ts` for the full pattern. Also mock anything that reaches +the network — `postSlackMessage`, `email-service`, `axios` — so tests stay offline. -Each `__tests__` directory contains a README.md file that explains: +**Two things worth testing in every hook**, both from +[the Directus conventions](../../../.claude/rules/directus-conventions.md): -- The testing approach for that component -- The test cases covered -- How to run the tests -- How to add more tests -- The mocking strategy used +1. Does it behave correctly when an item is **created** already in the triggering state, not just + when one is updated into it? +2. Is there a guard that stops it firing repeatedly — a status field or equivalent? -## Dependencies +## Current coverage -The testing setup uses the following dependencies: +**205 assertions across 15 files, covering 9 of the bundle's 26 entries.** -- jest: The testing framework -- ts-jest: TypeScript support for Jest -- @types/jest: TypeScript type definitions for Jest -- @jest/globals: Global functions and types for Jest -- ts-node: For running TypeScript files directly +| Covered | Test files | +| ---------------------------------------------------------------------------------------------------- | ---------- | +| `shared` (`isPublishable`, `safeHook`, `settings`) | 3 | +| `fetch-open-graph` (incl. `openGraph`, `urlSafety`) | 3 | +| `cascade-publish` | 2 | +| `create-news` (incl. `newsTarget`) | 2 | +| `member-matching`, `newsletter-double-opt-in`, `post-to-discord`, `schedule-publication`, `set-slug` | 1 each | -These dependencies are listed in the `package.json` file. +The other 17 entries have no tests, including the four largest modules in the bundle +(`algolia-index` at 1333 LOC, `buzzsprout`, `asset-generation`, `social-media-publish`). Closing that +gap is Phase 5 of [the tooling plan](../../../docs/directus-extension-tooling-plan.md), which ranks +them by size and blast radius. diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/eslint.config.js b/directus-cms/extensions/directus-extension-programmierbar-bundle/eslint.config.js index 226c6252..05ca958a 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/eslint.config.js +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/eslint.config.js @@ -1,9 +1,11 @@ import eslint from '@eslint/js' +import prettierConfig from 'eslint-config-prettier' +import pluginVue from 'eslint-plugin-vue' import tseslint from 'typescript-eslint' export default tseslint.config( { - ignores: ['**/dist/**', '**/podcast-transcription/**', '**/assets/**', 'eslint.config.js', 'jest.config.ts'], + ignores: ['**/dist/**', '**/assets/**', 'eslint.config.js', 'jest.config.ts'], }, eslint.configs.recommended, { @@ -41,5 +43,24 @@ export default tseslint.config( '@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: false }], }, files: ['**/*.ts'], - } + }, + // The bundle's one Vue SFC (`publishable/presentation-publishable.vue`) was previously the only + // unlinted source file in the tree, because the block above matches `**/*.ts` only. + ...pluginVue.configs['flat/recommended'], + { + files: ['**/*.vue'], + // `vue-eslint-parser` handles the SFC itself and delegates `