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 15312e6..15fe1e1 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 @@ -77,8 +77,22 @@ export default defineHook(({ filter }, hookContext) => { }` ) + // Get the new payload with slug + const newPayload = await getPayloadWithSlug(futureItem, { metadata, payload }) + + // For podcasts: save old slug to history if the podcast is published and slug is changing + if ( + metadata.collection === 'podcasts' && + item.status === 'published' && + item.slug && + newPayload.slug && + item.slug !== newPayload.slug + ) { + await saveSlugToHistory(item.id, item.slug, context) + } + // Return payload with "slug" - return await getPayloadWithSlug(futureItem, { metadata, payload }) + return newPayload } } @@ -93,7 +107,44 @@ export default defineHook(({ filter }, hookContext) => { return payload } - - - + /** + * Saves the old slug to the podcast_slug_history collection. + * This allows old URLs to redirect to the new slug. + * + * @param podcastId The ID of the podcast + * @param oldSlug The old slug being replaced + * @param context The hook context + */ + async function saveSlugToHistory(podcastId: string, oldSlug: string, context: { accountability: any; schema: any }) { + try { + const slugHistoryService = new ItemsService('podcast_slug_history', { + accountability: context.accountability, + schema: context.schema, + }) + + // Check if this slug already exists in history for this podcast + const existingEntries = await slugHistoryService.readByQuery({ + filter: { + podcast: { _eq: podcastId }, + old_slug: { _eq: oldSlug }, + }, + limit: 1, + }) + + // Only create a new entry if this slug isn't already in history + if (!existingEntries || existingEntries.length === 0) { + await slugHistoryService.createOne({ + podcast: podcastId, + old_slug: oldSlug, + }) + + logger.info(`${HOOK_NAME} hook: Saved old slug "${oldSlug}" to history for podcast "${podcastId}"`) + } else { + logger.info(`${HOOK_NAME} hook: Slug "${oldSlug}" already exists in history for podcast "${podcastId}"`) + } + } catch (error: any) { + // Log but don't fail the entire operation if slug history save fails + logger.error(`${HOOK_NAME} hook: Failed to save slug to history: ${error.message}`) + } + } }) diff --git a/directus-cms/schema.json b/directus-cms/schema.json index 5ef2cc1..f245723 100644 --- a/directus-cms/schema.json +++ b/directus-cms/schema.json @@ -1915,6 +1915,34 @@ "schema": { "name": "ticket_settings" } + }, + { + "collection": "podcast_slug_history", + "meta": { + "accountability": "all", + "archive_app_filter": true, + "archive_field": null, + "archive_value": null, + "collapse": "open", + "collection": "podcast_slug_history", + "color": null, + "display_template": "{{old_slug}}", + "group": "Podcasts", + "hidden": false, + "icon": "history", + "item_duplication_fields": null, + "note": "History of old podcast slugs for redirect purposes", + "preview_url": null, + "singleton": false, + "sort": 2, + "sort_field": null, + "translations": null, + "unarchive_value": null, + "versioning": false + }, + "schema": { + "name": "podcast_slug_history" + } } ], "fields": [ @@ -34398,6 +34426,194 @@ "schema": { "is_indexed": true } + }, + { + "collection": "podcast_slug_history", + "field": "id", + "type": "uuid", + "meta": { + "collection": "podcast_slug_history", + "conditions": null, + "display": null, + "display_options": null, + "field": "id", + "group": null, + "hidden": true, + "interface": "input", + "note": null, + "options": null, + "readonly": true, + "required": false, + "searchable": true, + "sort": 1, + "special": [ + "uuid" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "id", + "table": "podcast_slug_history", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": true, + "is_indexed": false, + "is_primary_key": true, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } + }, + { + "collection": "podcast_slug_history", + "field": "podcast", + "type": "uuid", + "meta": { + "collection": "podcast_slug_history", + "conditions": null, + "display": "related-values", + "display_options": { + "template": "{{title}}" + }, + "field": "podcast", + "group": null, + "hidden": false, + "interface": "select-dropdown-m2o", + "note": "The podcast this slug belonged to", + "options": { + "template": "{{title}}" + }, + "readonly": false, + "required": true, + "searchable": true, + "sort": 2, + "special": [ + "m2o" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "podcast", + "table": "podcast_slug_history", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": true, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "podcasts", + "foreign_key_column": "id" + } + }, + { + "collection": "podcast_slug_history", + "field": "old_slug", + "type": "string", + "meta": { + "collection": "podcast_slug_history", + "conditions": null, + "display": null, + "display_options": null, + "field": "old_slug", + "group": null, + "hidden": false, + "interface": "input", + "note": "The previous slug that should redirect to the current one", + "options": null, + "readonly": false, + "required": true, + "searchable": true, + "sort": 3, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "old_slug", + "table": "podcast_slug_history", + "data_type": "varchar", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": true, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } + }, + { + "collection": "podcast_slug_history", + "field": "date_created", + "type": "timestamp", + "meta": { + "collection": "podcast_slug_history", + "conditions": null, + "display": "datetime", + "display_options": { + "relative": true + }, + "field": "date_created", + "group": null, + "hidden": false, + "interface": "datetime", + "note": null, + "options": null, + "readonly": true, + "required": false, + "searchable": true, + "sort": 4, + "special": [ + "date-created" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "date_created", + "table": "podcast_slug_history", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } } ], "relations": [ @@ -37891,6 +38107,31 @@ "on_delete": "CASCADE", "constraint_name": null } + }, + { + "collection": "podcast_slug_history", + "field": "podcast", + "related_collection": "podcasts", + "meta": { + "junction_field": null, + "many_collection": "podcast_slug_history", + "many_field": "podcast", + "one_allowed_collections": null, + "one_collection": "podcasts", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "podcast_slug_history", + "column": "podcast", + "foreign_key_table": "podcasts", + "foreign_key_column": "id", + "on_update": "NO ACTION", + "on_delete": "CASCADE", + "constraint_name": null + } } ] } \ No newline at end of file diff --git a/nuxt-app/composables/useDirectus.ts b/nuxt-app/composables/useDirectus.ts index 97c60f9..f316f72 100644 --- a/nuxt-app/composables/useDirectus.ts +++ b/nuxt-app/composables/useDirectus.ts @@ -876,6 +876,36 @@ export function useDirectus() { } } + /** + * Get all podcast slug history entries for building redirects. + * Returns a list of old slugs mapped to their current podcast slugs. + */ + async function getPodcastSlugHistory(): Promise<{ oldSlug: string; currentSlug: string }[]> { + interface SlugHistoryEntry { + old_slug: string + podcast: { slug: string } | null + } + + return await directus + .request( + readItems('podcast_slug_history', { + fields: [ + 'old_slug', + 'podcast.slug', + ], + limit: -1, + }) + ) + .then((result) => + (result as SlugHistoryEntry[]) + .filter((entry) => entry.podcast?.slug && entry.old_slug !== entry.podcast.slug) + .map((entry) => ({ + oldSlug: entry.old_slug, + currentSlug: entry.podcast!.slug, + })) + ) + } + return { getHomepage, getPodcastPage, @@ -918,5 +948,6 @@ export function useDirectus() { getTestimonials, createRating, getTicketSettings, + getPodcastSlugHistory, } } diff --git a/nuxt-app/nuxt.config.ts b/nuxt-app/nuxt.config.ts index 2a45d62..3e14b0c 100644 --- a/nuxt-app/nuxt.config.ts +++ b/nuxt-app/nuxt.config.ts @@ -93,6 +93,30 @@ export default defineNuxtConfig({ const speakers = await directus.getSpeakers() routes.push(...speakers.map((speaker) => `/hall-of-fame/${speaker.slug}`)) + // Fetch podcast slug history and generate redirect rules + try { + const slugHistory = await directus.getPodcastSlugHistory() + if (slugHistory && slugHistory.length > 0) { + nitroConfig.routeRules = nitroConfig.routeRules || {} + let redirectCount = 0 + for (const entry of slugHistory) { + const oldPath = `/podcast/${entry.oldSlug}` + const newPath = `/podcast/${entry.currentSlug}` + if (nitroConfig.routeRules[oldPath]) { + console.warn(`Podcast slug redirect conflict: "${oldPath}" already has a route rule, skipping`) + continue + } + nitroConfig.routeRules[oldPath] = { + redirect: { to: newPath, statusCode: 301 }, + } + redirectCount++ + } + console.log(`Generated ${redirectCount} podcast slug redirect(s)`) + } + } catch (error) { + console.warn('Failed to fetch podcast slug history for redirects:', error) + } + // ..Async logic.. nitroConfig.prerender?.routes?.push(...routes) },