From 2787a6c68fb0e1babbe26c09aba8aa9b6ae701d5 Mon Sep 17 00:00:00 2001 From: fonodi Date: Mon, 11 May 2026 16:31:12 +0200 Subject: [PATCH 1/2] Reapply "feat: Features" This reverts commit 690db8779c051b347bdc97943f3fb2b7e3815009. --- apps/strapi/config/plugins.ts | 21 ++ .../2026.05.11T11:29.seed-features.js | 252 ++++++++++++++++++ .../2026.05.11T11:30.seed-feature-icons.js | 82 ++++++ apps/strapi/database/migrations/features.json | 238 +++++++++++++++++ .../content-types/feature-tag/schema.json | 24 ++ .../feature-tag/controllers/feature-tag.ts | 7 + .../src/api/feature-tag/routes/feature-tag.ts | 7 + .../api/feature-tag/services/feature-tag.ts | 7 + .../feature/content-types/feature/schema.json | 37 +++ .../src/api/feature/controllers/feature.ts | 7 + apps/strapi/src/api/feature/routes/feature.ts | 7 + .../src/api/feature/services/feature.ts | 7 + apps/strapi/types/generated/contentTypes.d.ts | 66 +++++ 13 files changed, 762 insertions(+) create mode 100644 apps/strapi/database/migrations/2026.05.11T11:29.seed-features.js create mode 100644 apps/strapi/database/migrations/2026.05.11T11:30.seed-feature-icons.js create mode 100644 apps/strapi/database/migrations/features.json create mode 100644 apps/strapi/src/api/feature-tag/content-types/feature-tag/schema.json create mode 100644 apps/strapi/src/api/feature-tag/controllers/feature-tag.ts create mode 100644 apps/strapi/src/api/feature-tag/routes/feature-tag.ts create mode 100644 apps/strapi/src/api/feature-tag/services/feature-tag.ts create mode 100644 apps/strapi/src/api/feature/content-types/feature/schema.json create mode 100644 apps/strapi/src/api/feature/controllers/feature.ts create mode 100644 apps/strapi/src/api/feature/routes/feature.ts create mode 100644 apps/strapi/src/api/feature/services/feature.ts diff --git a/apps/strapi/config/plugins.ts b/apps/strapi/config/plugins.ts index f55325d..e110c27 100644 --- a/apps/strapi/config/plugins.ts +++ b/apps/strapi/config/plugins.ts @@ -108,6 +108,27 @@ export default ({ env }) => { ? "blog-posts-production" : "blog-posts-testing", }, + feature: { + indexName: env.bool("MEILISEARCH_PRODUCTION", false) + ? "features-production" + : "features-testing", + entriesQuery: { + populate: { + icon: true, + feature_tag: { fields: ["title"] }, + }, + }, + transformEntry({ entry }) { + return { + ...entry, + feature_tag: entry.feature_tag?.title ?? null, + } + }, + settings: { + filterableAttributes: ["feature_tag"], + searchableAttributes: ["title", "description"], + }, + }, }, }, diff --git a/apps/strapi/database/migrations/2026.05.11T11:29.seed-features.js b/apps/strapi/database/migrations/2026.05.11T11:29.seed-features.js new file mode 100644 index 0000000..ee4bb24 --- /dev/null +++ b/apps/strapi/database/migrations/2026.05.11T11:29.seed-features.js @@ -0,0 +1,252 @@ +/* eslint-disable no-console */ +/** + * Seed features from database/migrations/features.json into the `features` collection type. + * Each card -> Feature row; `label` resolves to a feature_tag relation. + * + * Step 1: extract unique labels, insert draft + published feature_tag rows. + * Step 2: insert draft + published feature rows (with `url`), wire each to its + * tag via features_feature_tag_lnk (draft<->draft, published<->published). + * + * Icons are attached in a separate follow-up migration so the icon source can + * vary per environment without touching this seed. + */ + +"use strict" + +const crypto = require("node:crypto") +const fs = require("node:fs") +const path = require("node:path") + +const FEATURES_JSON = path.join(__dirname, "features.json") +const FEATURE_TAGS_TABLE = "feature_tags" +const FEATURES_TABLE = "features" +const FEATURES_TAG_LNK_TABLE = "features_feature_tag_lnk" + +function loadCards() { + if (!fs.existsSync(FEATURES_JSON)) { + console.log(`[seed-features] ${FEATURES_JSON} not found, skipping`) + + return null + } + + const { cards } = JSON.parse(fs.readFileSync(FEATURES_JSON, "utf8")) + + if (!Array.isArray(cards)) { + console.log(`[seed-features] no cards array in features.json, skipping`) + + return null + } + + return cards +} + +async function loadTagState(knex, titles) { + const rows = await knex(FEATURE_TAGS_TABLE) + .whereIn("title", titles) + .select("title", "document_id", "published_at") + + const map = new Map() + for (const row of rows) { + const s = map.get(row.title) ?? { + documentId: row.document_id, + hasDraft: false, + hasPublished: false, + } + if (row.published_at === null) s.hasDraft = true + else s.hasPublished = true + map.set(row.title, s) + } + + return map +} + +async function seedTags(knex, labels, now) { + const stateByTitle = await loadTagState(knex, labels) + let inserted = 0 + + for (const title of labels) { + const state = stateByTitle.get(title) ?? { + documentId: crypto.randomUUID(), + hasDraft: false, + hasPublished: false, + } + + if (state.hasDraft && state.hasPublished) { + console.log(`[seed-features] tag complete, skipping: ${title}`) + continue + } + + const base = { + document_id: state.documentId, + title, + created_at: now, + updated_at: now, + locale: "en", + } + + if (!state.hasDraft) { + await knex(FEATURE_TAGS_TABLE).insert({ ...base, published_at: null }) + inserted += 1 + console.log(`[seed-features] inserted draft tag: ${title}`) + } + if (!state.hasPublished) { + await knex(FEATURE_TAGS_TABLE).insert({ ...base, published_at: now }) + inserted += 1 + console.log(`[seed-features] inserted published tag: ${title}`) + } + } + + console.log(`[seed-features] feature-tag rows inserted: ${inserted}`) +} + +async function loadTagIdsByLabel(knex, labels) { + const rows = await knex(FEATURE_TAGS_TABLE) + .whereIn("title", labels) + .select("id", "title", "published_at") + + const map = new Map() + for (const row of rows) { + const entry = map.get(row.title) ?? { draftId: null, publishedId: null } + if (row.published_at === null) entry.draftId = row.id + else entry.publishedId = row.id + map.set(row.title, entry) + } + + return map +} + +async function loadFeatureState(knex, titles) { + const rows = await knex(FEATURES_TABLE) + .whereIn("title", titles) + .select("id", "title", "document_id", "published_at") + + const map = new Map() + for (const row of rows) { + const s = map.get(row.title) ?? { + documentId: row.document_id, + draftId: null, + publishedId: null, + } + if (row.published_at === null) s.draftId = row.id + else s.publishedId = row.id + map.set(row.title, s) + } + + return map +} + +async function insertFeatureRow(knex, base, publishedAt) { + const [r] = await knex(FEATURES_TABLE) + .insert({ ...base, published_at: publishedAt }) + .returning("id") + + return typeof r === "object" ? r.id : r +} + +async function linkFeatureTag(knex, featureId, tagId) { + if (!tagId) return false + await knex(FEATURES_TAG_LNK_TABLE).insert({ + feature_id: featureId, + feature_tag_id: tagId, + feature_ord: 1, + }) + + return true +} + +async function seedFeatureCard(knex, card, ctx) { + const { title, description, label, url } = card + const { now, tagIdsByLabel, featureStateByTitle, counters } = ctx + + if (!title) { + console.log(`[seed-features] card missing title, skipping`) + + return + } + + const tagIds = tagIdsByLabel.get(label) + if (!tagIds) { + console.log( + `[seed-features] no tag for label "${label}", skipping feature: ${title}` + ) + + return + } + + const state = featureStateByTitle.get(title) ?? { + documentId: crypto.randomUUID(), + draftId: null, + publishedId: null, + } + + if (state.draftId && state.publishedId) { + console.log(`[seed-features] feature complete, skipping: ${title}`) + + return + } + + const base = { + document_id: state.documentId, + title, + description: description ?? null, + url: url ?? null, + created_at: now, + updated_at: now, + locale: "en", + } + + if (!state.draftId) { + const id = await insertFeatureRow(knex, base, null) + counters.features += 1 + if (await linkFeatureTag(knex, id, tagIds.draftId)) counters.links += 1 + } + + if (!state.publishedId) { + const id = await insertFeatureRow(knex, base, now) + counters.features += 1 + if (await linkFeatureTag(knex, id, tagIds.publishedId)) counters.links += 1 + } + + console.log(`[seed-features] inserted feature: ${title} (${label})`) +} + +async function seedFeatures(knex, cards, labels, now) { + const tagIdsByLabel = await loadTagIdsByLabel(knex, labels) + const featureStateByTitle = await loadFeatureState( + knex, + cards.map((c) => c.title) + ) + + const counters = { features: 0, links: 0 } + const ctx = { now, tagIdsByLabel, featureStateByTitle, counters } + + for (const card of cards) { + await seedFeatureCard(knex, card, ctx) + } + + console.log( + `[seed-features] features inserted: ${counters.features}, links: ${counters.links}` + ) +} + +module.exports = { + async up(knex) { + const cards = loadCards() + if (!cards) return + + console.log(`[seed-features] loaded ${cards.length} cards`) + + const uniqueLabels = [...new Set(cards.map((c) => c.label).filter(Boolean))] + console.log( + `[seed-features] ${uniqueLabels.length} unique labels: ${uniqueLabels.join(", ")}` + ) + + const now = new Date() + await seedTags(knex, uniqueLabels, now) + await seedFeatures(knex, cards, uniqueLabels, now) + }, + + async down() { + throw new Error("Irreversible: feature seed migration has no down step") + }, +} diff --git a/apps/strapi/database/migrations/2026.05.11T11:30.seed-feature-icons.js b/apps/strapi/database/migrations/2026.05.11T11:30.seed-feature-icons.js new file mode 100644 index 0000000..46e3e06 --- /dev/null +++ b/apps/strapi/database/migrations/2026.05.11T11:30.seed-feature-icons.js @@ -0,0 +1,82 @@ +/* eslint-disable no-console */ +/** + * Attach a placeholder icon to every feature row (draft + published). + * + * Looked up by `hash` in the `files` table — the upload-plugin hash is stable + * per environment. Override `PLACEHOLDER_ICON_HASH` (or the env var) before + * running this migration in a new environment where the icon was uploaded with + * a different filename. + * + * Idempotent: skips features that already have an icon morph row. + */ + +"use strict" + +const FILES_TABLE = "files" +const FILES_MORPH_TABLE = "files_related_mph" +const FEATURES_TABLE = "features" +const FEATURE_UID = "api::feature.feature" + +const PLACEHOLDER_ICON_HASH = "Layout_2d5ef707ac" + +async function loadPlaceholderIconId(knex) { + const row = await knex(FILES_TABLE) + .where({ hash: PLACEHOLDER_ICON_HASH }) + .first("id") + + if (!row) { + console.log( + `[seed-feature-icons] placeholder icon (hash ${PLACEHOLDER_ICON_HASH}) not found in ${FILES_TABLE}, skipping` + ) + + return null + } + + return row.id +} + +async function loadFeaturesMissingIcon(knex) { + return knex(FEATURES_TABLE) + .leftJoin(FILES_MORPH_TABLE, function joinIcon() { + this.on(`${FILES_MORPH_TABLE}.related_id`, "=", `${FEATURES_TABLE}.id`) + .andOnVal(`${FILES_MORPH_TABLE}.related_type`, "=", FEATURE_UID) + .andOnVal(`${FILES_MORPH_TABLE}.field`, "=", "icon") + }) + .whereNull(`${FILES_MORPH_TABLE}.id`) + .select(`${FEATURES_TABLE}.id`) +} + +module.exports = { + async up(knex) { + const iconFileId = await loadPlaceholderIconId(knex) + if (!iconFileId) return + + const missing = await loadFeaturesMissingIcon(knex) + + if (missing.length === 0) { + console.log(`[seed-feature-icons] every feature already has an icon`) + + return + } + + const rows = missing.map((f) => ({ + file_id: iconFileId, + related_id: f.id, + related_type: FEATURE_UID, + field: "icon", + order: 1, + })) + + await knex(FILES_MORPH_TABLE).insert(rows) + + console.log( + `[seed-feature-icons] attached placeholder icon to ${rows.length} feature row(s)` + ) + }, + + async down() { + throw new Error( + "Irreversible: placeholder icon morph rows are intentionally left in place" + ) + }, +} diff --git a/apps/strapi/database/migrations/features.json b/apps/strapi/database/migrations/features.json new file mode 100644 index 0000000..38bac1b --- /dev/null +++ b/apps/strapi/database/migrations/features.json @@ -0,0 +1,238 @@ +{ + "cards": [ + { + "label": "Content Management", + "title": "Internationalization (i18n)", + "description": "Manage and publish content in multiple locales, allowing for localized content strategies and broader audience engagement.", + "url": "https://strapi.io/features/internationalization" + }, + { + "label": "Content Management", + "title": "Blocks Editor", + "description": "Drag and drop rich text in a user-friendly WYSIWYG environment, making content creation seamless and straightforward.", + "url": "https://docs.strapi.io/cms/features/content-type-builder" + }, + { + "label": "Content Management", + "title": "Dynamic Zones", + "description": "Editors adjust layouts with dynamic components for flexible page design.", + "url": "https://strapi.io/features/dynamic-zone" + }, + { + "label": "Content Management", + "title": "Content History", + "description": "No more headaches over lost edits or accidental changes. Keep your workflow smooth and stress-free with Content History.", + "url": "https://strapi.io/features/content-history" + }, + { + "label": "Content Management", + "title": "Live Preview", + "description": "Live preview changes before publishing to enable collaboration and confident content validation.", + "url": "https://strapi.io/features/live-preview" + }, + { + "label": "Customization", + "title": "Conditional Fields", + "description": "Give editors a smarter, more intuitive content-editing experience by showing only the fields that matter.", + "url": "https://strapi.io/features/conditional-fields" + }, + { + "label": "Create APIs", + "title": "Content GraphQL API", + "description": "The GraphQL API allows performing queries and mutations to interact with the content-types through Strapi's GraphQL plugin.", + "url": "https://strapi.io/features/customizable-api" + }, + { + "label": "Security", + "title": "API Tokens", + "description": "Manage end-user access to your API securely with customizable tokens, ensuring that data exposure is controlled and intentional.", + "url": "https://docs.strapi.io/user-docs/settings/API-tokens" + }, + { + "label": "Security", + "title": "TypeScript Support", + "description": "Improve coding practices and reduce errors with full TypeScript support, ensuring a more secure and maintainable codebase.", + "url": "https://docs.strapi.io/dev-docs/typescript/development" + }, + { + "label": "Security", + "title": "Audit Logs", + "description": "Keep a detailed record of every action taken within your CMS, allowing for better security monitoring and compliance.", + "url": "https://strapi.io/features/audit-logs" + }, + { + "label": "Security", + "title": "RBAC", + "description": "Control access and permissions within your CMS with advanced role-based access control, enhancing security and compliance.", + "url": "https://strapi.io/features/custom-roles-and-permissions" + }, + { + "label": "Security", + "title": "Single Sign-On (SSO)", + "description": "Simplify user authentication with Single Sign-On from major third-party providers, streamlining access while maintaining security.", + "url": "https://strapi.io/features/single-sign-on-sso" + }, + { + "label": "Hosting", + "title": "Upload Providers", + "description": "Use your preferred CDN for faster content delivery, optimizing user experience across different geographies.", + "url": "https://docs.strapi.io/cloud/advanced/upload" + }, + { + "label": "Hosting", + "title": "PostgreSQL Database", + "description": "Integrate smoothly with your favorite SQL databases, customizing data storage and retrieval to fit your needs.", + "url": "https://docs.strapi.io/cloud/advanced/database" + }, + { + "label": "Hosting", + "title": "Cloud CLI", + "description": "Deploy your Strapi projects directly from the terminal, making the process straightforward and efficient.", + "url": "https://docs.strapi.io/cloud/cli/cloud-cli" + }, + { + "label": "Hosting", + "title": "GitLab Integration", + "description": "Deploy from your GitLab repository, maintaining workflow efficiency and consistency.", + "url": "https://docs.strapi.io/cloud/account/account-settings" + }, + { + "label": "Create APIs", + "title": "Content REST API", + "description": "Integrate dynamic content delivery with a standard RESTful approach, making it easy to fetch and display content as needed.", + "url": "https://strapi.io/features/customizable-api" + }, + { + "label": "Hosting", + "title": "GitHub Integration", + "description": "Deploy code directly from your GitHub repository, facilitating continuous integration and faster updates.", + "url": "https://docs.strapi.io/cloud/account/account-settings" + }, + { + "label": "Hosting", + "title": "Built-in Email Provider", + "description": "Send emails directly through a built-in provider, streamlining communication and marketing efforts.", + "url": "https://docs.strapi.io/cloud/advanced/email" + }, + { + "label": "Hosting", + "title": "Built-in CDN", + "description": "Serve content efficiently with a built-in CDN, reducing load times and improving accessibility.", + "url": "https://docs.strapi.io/cloud/getting-started/caching" + }, + { + "label": "Hosting", + "title": "Backups", + "description": "Secure your CMS data with automatic backups, protecting against data loss and ensuring business continuity.", + "url": "https://docs.strapi.io/cloud/projects/settings" + }, + { + "label": "Hosting", + "title": "Strapi Cloud", + "description": "Host your CMS with our dedicated cloud service, designed for performance and ease of use.", + "url": "https://strapi.io/cloud" + }, + { + "label": "Customization", + "title": "Widget API", + "description": "Build your own widgets to surface the metrics, links, and insights you care about most. Add charts, content summaries, custom workflows, and more, right on your homepage.", + "url": "https://docs.strapi.io/cms/admin-panel-customization/homepage" + }, + { + "label": "Customization", + "title": "Plugin SDK", + "description": "The Plugin SDK is set of commands provided to create a plugin from scratch, link it to an existing project, and publish it.", + "url": "https://docs.strapi.io/dev-docs/plugins/development/plugin-sdk" + }, + { + "label": "Customization", + "title": "Plugin API", + "description": "Extend your CMS with custom plugins, creating tailored solutions for unique project needs.", + "url": "https://docs.strapi.io/dev-docs/plugins" + }, + { + "label": "Customization", + "title": "Design System", + "description": "The Strapi Design System is a collection of standards, foundations, components & hooks to make contributions and plugins more efficient and cohesive.", + "url": "https://design-system.strapi.io/?path=%2Fdocs%2Fgetting-started-welcome--docs" + }, + { + "label": "Customization", + "title": "Document Service API", + "description": "The Document Service API is built on top of the Query Engine API and used to perform CRUD (create, retrieve, update, and delete) operations on documents.", + "url": "https://docs.strapi.io/dev-docs/api/document-service" + }, + { + "label": "Create APIs", + "title": "Relations", + "description": "Design a custom content architecture that links your data in meaningful ways, organizing data in a way that makes sense for your application and your team.", + "url": "https://strapi.io/features/relations" + }, + { + "label": "Customization", + "title": "Custom Layout", + "description": "Personalize the content editing experience with custom layouts, enhancing usability and editor satisfaction.", + "url": "https://docs.strapi.io/dev-docs/plugins/admin-panel-api" + }, + { + "label": "Customization", + "title": "Webhooks", + "description": "Trigger actions automatically in your CMS or other integrated systems, improving operational efficiency.", + "url": "https://docs.strapi.io/dev-docs/backend-customization/webhooks" + }, + { + "label": "Customization", + "title": "Custom Fields", + "description": "Add any type of fields to your project, customizing data structures to suit your specific requirements.", + "url": "https://docs.strapi.io/dev-docs/custom-fields" + }, + { + "label": "Customization", + "title": "Marketplace", + "description": "Benefit from additional features developed by the community, enhancing your CMS with new functionalities.", + "url": "https://market.strapi.io/" + }, + { + "label": "Collaboration", + "title": "RBAC for admins", + "description": "Control access and permissions within your CMS with advanced role-based access control, enhancing security and compliance.", + "url": "https://docs.strapi.io/dev-docs/configurations/guides/rbac" + }, + { + "label": "Collaboration", + "title": "RBAC for end-users", + "description": "Manage the permissions of end-users who consume the content that is created and managed with a Strapi application and displayed on front-end applications.", + "url": "https://docs.strapi.io/dev-docs/configurations/guides/rbac" + }, + { + "label": "Collaboration", + "title": "Review Workflows", + "description": "Set up predefined approval processes that ensure every piece of content meets your standards before it's published.", + "url": "https://strapi.io/features/review-workflow" + }, + { + "label": "Collaboration", + "title": "Releases", + "description": "Group and manage the publication of your content, allowing for better control over when and how content goes live.", + "url": "https://strapi.io/features/releases" + }, + { + "label": "Content Management", + "title": "Cron Jobs", + "description": "Automate publishing and other repetitive tasks, optimizing workflow efficiency and ensuring timely content updates.", + "url": "https://docs.strapi.io/dev-docs/configurations/cron" + }, + { + "label": "Content Management", + "title": "Media Library", + "description": "Efficiently organize, store, and access media files, streamlining content creation and management processes.", + "url": "https://strapi.io/features/media-library" + }, + { + "label": "Create APIs", + "title": "Content-Type Builder", + "description": "Create and manage content models through a user-friendly interface, simplifying the development process.", + "url": "https://strapi.io/features/content-types-builder" + } + ] +} diff --git a/apps/strapi/src/api/feature-tag/content-types/feature-tag/schema.json b/apps/strapi/src/api/feature-tag/content-types/feature-tag/schema.json new file mode 100644 index 0000000..4941efe --- /dev/null +++ b/apps/strapi/src/api/feature-tag/content-types/feature-tag/schema.json @@ -0,0 +1,24 @@ +{ + "kind": "collectionType", + "collectionName": "feature_tags", + "info": { + "singularName": "feature-tag", + "pluralName": "feature-tags", + "displayName": "Feature tag" + }, + "options": { + "draftAndPublish": true + }, + "pluginOptions": {}, + "attributes": { + "title": { + "type": "string" + }, + "features": { + "type": "relation", + "relation": "oneToMany", + "target": "api::feature.feature", + "mappedBy": "feature_tag" + } + } +} diff --git a/apps/strapi/src/api/feature-tag/controllers/feature-tag.ts b/apps/strapi/src/api/feature-tag/controllers/feature-tag.ts new file mode 100644 index 0000000..0ade4b5 --- /dev/null +++ b/apps/strapi/src/api/feature-tag/controllers/feature-tag.ts @@ -0,0 +1,7 @@ +/** + * feature-tag controller + */ + +import { factories } from "@strapi/strapi" + +export default factories.createCoreController("api::feature-tag.feature-tag") diff --git a/apps/strapi/src/api/feature-tag/routes/feature-tag.ts b/apps/strapi/src/api/feature-tag/routes/feature-tag.ts new file mode 100644 index 0000000..1dc0522 --- /dev/null +++ b/apps/strapi/src/api/feature-tag/routes/feature-tag.ts @@ -0,0 +1,7 @@ +/** + * feature-tag router + */ + +import { factories } from "@strapi/strapi" + +export default factories.createCoreRouter("api::feature-tag.feature-tag") diff --git a/apps/strapi/src/api/feature-tag/services/feature-tag.ts b/apps/strapi/src/api/feature-tag/services/feature-tag.ts new file mode 100644 index 0000000..c0f58a6 --- /dev/null +++ b/apps/strapi/src/api/feature-tag/services/feature-tag.ts @@ -0,0 +1,7 @@ +/** + * feature-tag service + */ + +import { factories } from "@strapi/strapi" + +export default factories.createCoreService("api::feature-tag.feature-tag") diff --git a/apps/strapi/src/api/feature/content-types/feature/schema.json b/apps/strapi/src/api/feature/content-types/feature/schema.json new file mode 100644 index 0000000..0b38748 --- /dev/null +++ b/apps/strapi/src/api/feature/content-types/feature/schema.json @@ -0,0 +1,37 @@ +{ + "kind": "collectionType", + "collectionName": "features", + "info": { + "singularName": "feature", + "pluralName": "features", + "displayName": "Feature" + }, + "options": { + "draftAndPublish": true + }, + "pluginOptions": {}, + "attributes": { + "title": { + "type": "string" + }, + "description": { + "type": "text" + }, + "icon": { + "type": "media", + "multiple": false, + "allowedTypes": [ + "images" + ] + }, + "feature_tag": { + "type": "relation", + "relation": "manyToOne", + "target": "api::feature-tag.feature-tag", + "inversedBy": "features" + }, + "url": { + "type": "string" + } + } +} diff --git a/apps/strapi/src/api/feature/controllers/feature.ts b/apps/strapi/src/api/feature/controllers/feature.ts new file mode 100644 index 0000000..2c95e09 --- /dev/null +++ b/apps/strapi/src/api/feature/controllers/feature.ts @@ -0,0 +1,7 @@ +/** + * feature controller + */ + +import { factories } from "@strapi/strapi" + +export default factories.createCoreController("api::feature.feature") diff --git a/apps/strapi/src/api/feature/routes/feature.ts b/apps/strapi/src/api/feature/routes/feature.ts new file mode 100644 index 0000000..55af06a --- /dev/null +++ b/apps/strapi/src/api/feature/routes/feature.ts @@ -0,0 +1,7 @@ +/** + * feature router + */ + +import { factories } from "@strapi/strapi" + +export default factories.createCoreRouter("api::feature.feature") diff --git a/apps/strapi/src/api/feature/services/feature.ts b/apps/strapi/src/api/feature/services/feature.ts new file mode 100644 index 0000000..18df8b5 --- /dev/null +++ b/apps/strapi/src/api/feature/services/feature.ts @@ -0,0 +1,7 @@ +/** + * feature service + */ + +import { factories } from "@strapi/strapi" + +export default factories.createCoreService("api::feature.feature") diff --git a/apps/strapi/types/generated/contentTypes.d.ts b/apps/strapi/types/generated/contentTypes.d.ts index d5740d4..7d32888 100644 --- a/apps/strapi/types/generated/contentTypes.d.ts +++ b/apps/strapi/types/generated/contentTypes.d.ts @@ -951,6 +951,70 @@ export interface ApiCountryCountry extends Struct.CollectionTypeSchema { } } +export interface ApiFeatureTagFeatureTag extends Struct.CollectionTypeSchema { + collectionName: "feature_tags" + info: { + displayName: "Feature tag" + pluralName: "feature-tags" + singularName: "feature-tag" + } + options: { + draftAndPublish: true + } + attributes: { + createdAt: Schema.Attribute.DateTime + createdBy: Schema.Attribute.Relation<"oneToOne", "admin::user"> & + Schema.Attribute.Private + features: Schema.Attribute.Relation<"oneToMany", "api::feature.feature"> + locale: Schema.Attribute.String & Schema.Attribute.Private + localizations: Schema.Attribute.Relation< + "oneToMany", + "api::feature-tag.feature-tag" + > & + Schema.Attribute.Private + publishedAt: Schema.Attribute.DateTime + title: Schema.Attribute.String + updatedAt: Schema.Attribute.DateTime + updatedBy: Schema.Attribute.Relation<"oneToOne", "admin::user"> & + Schema.Attribute.Private + } +} + +export interface ApiFeatureFeature extends Struct.CollectionTypeSchema { + collectionName: "features" + info: { + displayName: "Feature" + pluralName: "features" + singularName: "feature" + } + options: { + draftAndPublish: true + } + attributes: { + createdAt: Schema.Attribute.DateTime + createdBy: Schema.Attribute.Relation<"oneToOne", "admin::user"> & + Schema.Attribute.Private + description: Schema.Attribute.Text + feature_tag: Schema.Attribute.Relation< + "manyToOne", + "api::feature-tag.feature-tag" + > + icon: Schema.Attribute.Media<"images"> + locale: Schema.Attribute.String & Schema.Attribute.Private + localizations: Schema.Attribute.Relation< + "oneToMany", + "api::feature.feature" + > & + Schema.Attribute.Private + publishedAt: Schema.Attribute.DateTime + title: Schema.Attribute.String + updatedAt: Schema.Attribute.DateTime + updatedBy: Schema.Attribute.Relation<"oneToOne", "admin::user"> & + Schema.Attribute.Private + url: Schema.Attribute.String + } +} + export interface ApiFooterFooter extends Struct.SingleTypeSchema { collectionName: "footers" info: { @@ -2133,6 +2197,8 @@ declare module "@strapi/strapi" { "api::cms-comparison.cms-comparison": ApiCmsComparisonCmsComparison "api::cms.cms": ApiCmsCms "api::country.country": ApiCountryCountry + "api::feature-tag.feature-tag": ApiFeatureTagFeatureTag + "api::feature.feature": ApiFeatureFeature "api::footer.footer": ApiFooterFooter "api::global.global": ApiGlobalGlobal "api::header.header": ApiHeaderHeader From 2dccba92710ae9b6bf0e34e782de13712ecc8874 Mon Sep 17 00:00:00 2001 From: fonodi Date: Mon, 11 May 2026 16:43:15 +0200 Subject: [PATCH 2/2] chore(strapi): remove duplicate seed migrations from database/migrations/ Reapply commit re-added these alongside the moved versions at the apps/strapi root. Keep the moved versions only. Co-Authored-By: Claude Opus 4.7 --- .../2026.05.11T11:29.seed-features.js | 252 ------------------ .../2026.05.11T11:30.seed-feature-icons.js | 82 ------ 2 files changed, 334 deletions(-) delete mode 100644 apps/strapi/database/migrations/2026.05.11T11:29.seed-features.js delete mode 100644 apps/strapi/database/migrations/2026.05.11T11:30.seed-feature-icons.js diff --git a/apps/strapi/database/migrations/2026.05.11T11:29.seed-features.js b/apps/strapi/database/migrations/2026.05.11T11:29.seed-features.js deleted file mode 100644 index ee4bb24..0000000 --- a/apps/strapi/database/migrations/2026.05.11T11:29.seed-features.js +++ /dev/null @@ -1,252 +0,0 @@ -/* eslint-disable no-console */ -/** - * Seed features from database/migrations/features.json into the `features` collection type. - * Each card -> Feature row; `label` resolves to a feature_tag relation. - * - * Step 1: extract unique labels, insert draft + published feature_tag rows. - * Step 2: insert draft + published feature rows (with `url`), wire each to its - * tag via features_feature_tag_lnk (draft<->draft, published<->published). - * - * Icons are attached in a separate follow-up migration so the icon source can - * vary per environment without touching this seed. - */ - -"use strict" - -const crypto = require("node:crypto") -const fs = require("node:fs") -const path = require("node:path") - -const FEATURES_JSON = path.join(__dirname, "features.json") -const FEATURE_TAGS_TABLE = "feature_tags" -const FEATURES_TABLE = "features" -const FEATURES_TAG_LNK_TABLE = "features_feature_tag_lnk" - -function loadCards() { - if (!fs.existsSync(FEATURES_JSON)) { - console.log(`[seed-features] ${FEATURES_JSON} not found, skipping`) - - return null - } - - const { cards } = JSON.parse(fs.readFileSync(FEATURES_JSON, "utf8")) - - if (!Array.isArray(cards)) { - console.log(`[seed-features] no cards array in features.json, skipping`) - - return null - } - - return cards -} - -async function loadTagState(knex, titles) { - const rows = await knex(FEATURE_TAGS_TABLE) - .whereIn("title", titles) - .select("title", "document_id", "published_at") - - const map = new Map() - for (const row of rows) { - const s = map.get(row.title) ?? { - documentId: row.document_id, - hasDraft: false, - hasPublished: false, - } - if (row.published_at === null) s.hasDraft = true - else s.hasPublished = true - map.set(row.title, s) - } - - return map -} - -async function seedTags(knex, labels, now) { - const stateByTitle = await loadTagState(knex, labels) - let inserted = 0 - - for (const title of labels) { - const state = stateByTitle.get(title) ?? { - documentId: crypto.randomUUID(), - hasDraft: false, - hasPublished: false, - } - - if (state.hasDraft && state.hasPublished) { - console.log(`[seed-features] tag complete, skipping: ${title}`) - continue - } - - const base = { - document_id: state.documentId, - title, - created_at: now, - updated_at: now, - locale: "en", - } - - if (!state.hasDraft) { - await knex(FEATURE_TAGS_TABLE).insert({ ...base, published_at: null }) - inserted += 1 - console.log(`[seed-features] inserted draft tag: ${title}`) - } - if (!state.hasPublished) { - await knex(FEATURE_TAGS_TABLE).insert({ ...base, published_at: now }) - inserted += 1 - console.log(`[seed-features] inserted published tag: ${title}`) - } - } - - console.log(`[seed-features] feature-tag rows inserted: ${inserted}`) -} - -async function loadTagIdsByLabel(knex, labels) { - const rows = await knex(FEATURE_TAGS_TABLE) - .whereIn("title", labels) - .select("id", "title", "published_at") - - const map = new Map() - for (const row of rows) { - const entry = map.get(row.title) ?? { draftId: null, publishedId: null } - if (row.published_at === null) entry.draftId = row.id - else entry.publishedId = row.id - map.set(row.title, entry) - } - - return map -} - -async function loadFeatureState(knex, titles) { - const rows = await knex(FEATURES_TABLE) - .whereIn("title", titles) - .select("id", "title", "document_id", "published_at") - - const map = new Map() - for (const row of rows) { - const s = map.get(row.title) ?? { - documentId: row.document_id, - draftId: null, - publishedId: null, - } - if (row.published_at === null) s.draftId = row.id - else s.publishedId = row.id - map.set(row.title, s) - } - - return map -} - -async function insertFeatureRow(knex, base, publishedAt) { - const [r] = await knex(FEATURES_TABLE) - .insert({ ...base, published_at: publishedAt }) - .returning("id") - - return typeof r === "object" ? r.id : r -} - -async function linkFeatureTag(knex, featureId, tagId) { - if (!tagId) return false - await knex(FEATURES_TAG_LNK_TABLE).insert({ - feature_id: featureId, - feature_tag_id: tagId, - feature_ord: 1, - }) - - return true -} - -async function seedFeatureCard(knex, card, ctx) { - const { title, description, label, url } = card - const { now, tagIdsByLabel, featureStateByTitle, counters } = ctx - - if (!title) { - console.log(`[seed-features] card missing title, skipping`) - - return - } - - const tagIds = tagIdsByLabel.get(label) - if (!tagIds) { - console.log( - `[seed-features] no tag for label "${label}", skipping feature: ${title}` - ) - - return - } - - const state = featureStateByTitle.get(title) ?? { - documentId: crypto.randomUUID(), - draftId: null, - publishedId: null, - } - - if (state.draftId && state.publishedId) { - console.log(`[seed-features] feature complete, skipping: ${title}`) - - return - } - - const base = { - document_id: state.documentId, - title, - description: description ?? null, - url: url ?? null, - created_at: now, - updated_at: now, - locale: "en", - } - - if (!state.draftId) { - const id = await insertFeatureRow(knex, base, null) - counters.features += 1 - if (await linkFeatureTag(knex, id, tagIds.draftId)) counters.links += 1 - } - - if (!state.publishedId) { - const id = await insertFeatureRow(knex, base, now) - counters.features += 1 - if (await linkFeatureTag(knex, id, tagIds.publishedId)) counters.links += 1 - } - - console.log(`[seed-features] inserted feature: ${title} (${label})`) -} - -async function seedFeatures(knex, cards, labels, now) { - const tagIdsByLabel = await loadTagIdsByLabel(knex, labels) - const featureStateByTitle = await loadFeatureState( - knex, - cards.map((c) => c.title) - ) - - const counters = { features: 0, links: 0 } - const ctx = { now, tagIdsByLabel, featureStateByTitle, counters } - - for (const card of cards) { - await seedFeatureCard(knex, card, ctx) - } - - console.log( - `[seed-features] features inserted: ${counters.features}, links: ${counters.links}` - ) -} - -module.exports = { - async up(knex) { - const cards = loadCards() - if (!cards) return - - console.log(`[seed-features] loaded ${cards.length} cards`) - - const uniqueLabels = [...new Set(cards.map((c) => c.label).filter(Boolean))] - console.log( - `[seed-features] ${uniqueLabels.length} unique labels: ${uniqueLabels.join(", ")}` - ) - - const now = new Date() - await seedTags(knex, uniqueLabels, now) - await seedFeatures(knex, cards, uniqueLabels, now) - }, - - async down() { - throw new Error("Irreversible: feature seed migration has no down step") - }, -} diff --git a/apps/strapi/database/migrations/2026.05.11T11:30.seed-feature-icons.js b/apps/strapi/database/migrations/2026.05.11T11:30.seed-feature-icons.js deleted file mode 100644 index 46e3e06..0000000 --- a/apps/strapi/database/migrations/2026.05.11T11:30.seed-feature-icons.js +++ /dev/null @@ -1,82 +0,0 @@ -/* eslint-disable no-console */ -/** - * Attach a placeholder icon to every feature row (draft + published). - * - * Looked up by `hash` in the `files` table — the upload-plugin hash is stable - * per environment. Override `PLACEHOLDER_ICON_HASH` (or the env var) before - * running this migration in a new environment where the icon was uploaded with - * a different filename. - * - * Idempotent: skips features that already have an icon morph row. - */ - -"use strict" - -const FILES_TABLE = "files" -const FILES_MORPH_TABLE = "files_related_mph" -const FEATURES_TABLE = "features" -const FEATURE_UID = "api::feature.feature" - -const PLACEHOLDER_ICON_HASH = "Layout_2d5ef707ac" - -async function loadPlaceholderIconId(knex) { - const row = await knex(FILES_TABLE) - .where({ hash: PLACEHOLDER_ICON_HASH }) - .first("id") - - if (!row) { - console.log( - `[seed-feature-icons] placeholder icon (hash ${PLACEHOLDER_ICON_HASH}) not found in ${FILES_TABLE}, skipping` - ) - - return null - } - - return row.id -} - -async function loadFeaturesMissingIcon(knex) { - return knex(FEATURES_TABLE) - .leftJoin(FILES_MORPH_TABLE, function joinIcon() { - this.on(`${FILES_MORPH_TABLE}.related_id`, "=", `${FEATURES_TABLE}.id`) - .andOnVal(`${FILES_MORPH_TABLE}.related_type`, "=", FEATURE_UID) - .andOnVal(`${FILES_MORPH_TABLE}.field`, "=", "icon") - }) - .whereNull(`${FILES_MORPH_TABLE}.id`) - .select(`${FEATURES_TABLE}.id`) -} - -module.exports = { - async up(knex) { - const iconFileId = await loadPlaceholderIconId(knex) - if (!iconFileId) return - - const missing = await loadFeaturesMissingIcon(knex) - - if (missing.length === 0) { - console.log(`[seed-feature-icons] every feature already has an icon`) - - return - } - - const rows = missing.map((f) => ({ - file_id: iconFileId, - related_id: f.id, - related_type: FEATURE_UID, - field: "icon", - order: 1, - })) - - await knex(FILES_MORPH_TABLE).insert(rows) - - console.log( - `[seed-feature-icons] attached placeholder icon to ${rows.length} feature row(s)` - ) - }, - - async down() { - throw new Error( - "Irreversible: placeholder icon morph rows are intentionally left in place" - ) - }, -}