Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/strapi/config/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
},
},
},
},

Expand Down
252 changes: 252 additions & 0 deletions apps/strapi/database/migrations/2026.05.11T11:29.seed-features.js
Original file line number Diff line number Diff line change
@@ -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")
},
}
Original file line number Diff line number Diff line change
@@ -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"
)
},
}
Loading
Loading