From bd97dc2d534602f9f624df87acc5997720bd5ca4 Mon Sep 17 00:00:00 2001 From: Boaz Poolman Date: Mon, 7 Sep 2026 14:25:16 +0200 Subject: [PATCH] feat: add migration for missing profile pictures --- apps/cms/src/index.ts | 10 +- apps/cms/src/migration/integrations.ts | 162 ------------------ apps/cms/src/migration/partners.ts | 86 ---------- apps/cms/src/migration/plugins.ts | 90 ---------- apps/cms/src/migration/providers.ts | 90 ---------- .../src/migration/user-profile-pictures.ts | 81 +++++++++ apps/cms/src/migration/users-organizations.ts | 45 ----- apps/cms/src/migration/utils.ts | 77 --------- 8 files changed, 83 insertions(+), 558 deletions(-) delete mode 100644 apps/cms/src/migration/integrations.ts delete mode 100644 apps/cms/src/migration/partners.ts delete mode 100644 apps/cms/src/migration/plugins.ts delete mode 100644 apps/cms/src/migration/providers.ts create mode 100644 apps/cms/src/migration/user-profile-pictures.ts delete mode 100644 apps/cms/src/migration/users-organizations.ts diff --git a/apps/cms/src/index.ts b/apps/cms/src/index.ts index 3dd0190..e17cf3a 100644 --- a/apps/cms/src/index.ts +++ b/apps/cms/src/index.ts @@ -1,8 +1,5 @@ import type { Core } from "@strapi/strapi"; -import { migrateIntegrations } from "./migration/integrations"; -import { migratePartners } from "./migration/partners"; -import { migratePlugins } from "./migration/plugins"; -import { migrateProviders } from "./migration/providers"; +import { migrateUserProfilePictures } from "./migration/user-profile-pictures"; import { seedEmailTemplates } from "./seed/email-templates"; export default { @@ -15,9 +12,6 @@ export default { return; } - await migratePartners(); - await migrateIntegrations(); - await migratePlugins(); - await migrateProviders(); + await migrateUserProfilePictures(); }, }; diff --git a/apps/cms/src/migration/integrations.ts b/apps/cms/src/migration/integrations.ts deleted file mode 100644 index a4dff93..0000000 --- a/apps/cms/src/migration/integrations.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { createCategories, uploadFromUrl, uploadMarkdownImages } from "./utils"; - -const FAQ_POPULATE = "populate[slices][populate][categories][populate]=*"; - -const extractFaqQuestions = (slices) => { - if (!Array.isArray(slices)) return []; - return slices - .filter((s) => s.__component === "slices.faq") - .flatMap((slice) => - (slice.categories || []).flatMap((cat) => - (cat.questions || []).filter((q) => q.question && q.answer), - ), - ); -}; - -const getFaqSectionTitle = (slices) => { - if (!Array.isArray(slices)) return "Frequently Asked Questions"; - const faqSlice = slices.find((s) => s.__component === "slices.faq"); - return faqSlice?.categories?.[0]?.name || "Frequently Asked Questions"; -}; - -const createFaqItems = async (slices) => { - const questions = extractFaqQuestions(slices); - const documentIds: string[] = []; - for (const q of questions) { - const item = await strapi.documents("api::faq-item.faq-item").create({ - data: { question: q.question, answer: q.answer }, - }); - documentIds.push(item.documentId); - } - return documentIds; -}; - -export const migrateIntegrations = async () => { - strapi.log.info("Starting integrations migration..."); - let migrated = 0; - let updated = 0; - let skipped = 0; - let failed = 0; - - const firstPage = (await fetch( - `https://api-prod.strapi.io/api/integrations?pagination[pageSize]=100&${FAQ_POPULATE}`, - ).then((res) => res.json())) as any; - - const allIntegrations = [...firstPage.data]; - - for (let page = 2; page <= firstPage.meta.pagination.pageCount; page++) { - const nextPage = (await fetch( - `https://api-prod.strapi.io/api/integrations?pagination[pageSize]=100&pagination[page]=${page}&${FAQ_POPULATE}`, - ).then((res) => res.json())) as any; - allIntegrations.push(...nextPage.data); - } - - for (const integration of allIntegrations) { - try { - const existing = await strapi - .documents("api::integration.integration") - .findFirst({ - filters: { slug: integration.attributes.slug }, - populate: { faq_items_section: { populate: { items: true } } }, - }); - - const slices = integration.attributes.slices || []; - - if (existing) { - // Already migrated — update FAQ items if missing - const hasFaqItems = existing.faq_items_section?.items?.length > 0; - - if (hasFaqItems) { - skipped++; - continue; - } - - const faqItemIds = await createFaqItems(slices); - if (faqItemIds.length > 0) { - await strapi.documents("api::integration.integration").update({ - documentId: existing.documentId, - status: "published", - data: { - faq_items_section: { - title: getFaqSectionTitle(slices), - items: faqItemIds, - }, - }, - }); - updated++; - } else { - skipped++; - } - continue; - } - - let icon = null; - let image = null; - - if (integration.attributes.logo?.media?.data?.attributes?.url) { - icon = await uploadFromUrl( - integration.attributes.logo.media.data.attributes.url, - integration.attributes.logo.media.data.attributes.name, - ); - } - - if (integration.attributes.image?.media?.data?.attributes?.url) { - image = await uploadFromUrl( - integration.attributes.image.media.data.attributes.url, - integration.attributes.image.media.data.attributes.name, - ); - } - - const categories = await createCategories( - "api::integration-category.integration-category", - integration.attributes.integration_tags?.data || [], - ); - - const markdown = await uploadMarkdownImages( - integration.attributes.content || "", - ); - - const faqItemIds = await createFaqItems(slices); - - await strapi.documents("api::integration.integration").create({ - status: "published", - data: { - name: integration.attributes.title, - slug: integration.attributes.slug, - description: integration.attributes.description, - content: markdown, - logo: icon?.id, - image: image?.id, - categories: categories, - seo: { - metaTitle: - integration.attributes.seo?.metaTitle || - integration.attributes.title, - metaDescription: - integration.attributes.seo?.metaDescription || - integration.attributes.description, - keywords: integration.attributes.seo?.keywords, - metaImage: image?.id, - }, - ...(faqItemIds.length > 0 && { - faq_items_section: { - title: getFaqSectionTitle(slices), - items: faqItemIds, - }, - }), - }, - }); - migrated++; - } catch (error) { - failed++; - strapi.log.error( - `Error migrating integration ${integration.attributes.slug}:`, - error, - ); - } - } - - strapi.log.info( - `Integrations migration finished. Migrated: ${migrated}, Updated (FAQ): ${updated}, Skipped: ${skipped}, Failed: ${failed}`, - ); -}; diff --git a/apps/cms/src/migration/partners.ts b/apps/cms/src/migration/partners.ts deleted file mode 100644 index a772d3a..0000000 --- a/apps/cms/src/migration/partners.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { createCategories, uploadFromUrl } from "./utils"; - -export const migratePartners = async () => { - strapi.log.info("Starting partners migration..."); - let migrated = 0; - let skipped = 0; - let failed = 0; - - const partners = (await fetch( - "https://api-prod.strapi.io/api/partners?pagination[pageSize]=100", - ).then((res) => res.json())) as any; - - for (const partner of partners.data) { - try { - let logo = null; - - const existingOrg = await strapi - .documents("plugin::better-auth.organization") - .findFirst({ - filters: { slug: partner.attributes.slug }, - }); - - if (existingOrg) { - skipped++; - continue; - } - - if (partner.attributes.logo?.media?.data?.attributes?.url) { - logo = await uploadFromUrl( - partner.attributes.logo.media.data.attributes.url, - partner.attributes.logo.media.data.attributes.name, - ); - } - - const countries = await createCategories( - "api::country.country", - partner.attributes.location?.countries?.data || [], - ); - - const services = await createCategories( - "api::service.service", - partner.attributes.services?.data || [], - ); - const techStacks = await createCategories( - "api::tech-stack.tech-stack", - partner.attributes.techStacks?.data || [], - ); - - const profile = await strapi.documents("api::profile.profile").create({ - data: { - bio: partner.attributes.seo.metaDescription, - readme: partner.attributes.intro.text, - countries: countries, - services: services, - tech_stacks: techStacks, - subtitle: partner.attributes.intro.label, - website: partner.attributes.intro.button?.[0]?.link?.href, - }, - }); - - await strapi.documents("plugin::better-auth.organization").create({ - status: "published", - data: { - createdAt: partner.attributes.createdAt, - name: partner.attributes.intro.title, - profile: profile.id, - slug: partner.attributes.slug, - partner: true, - partner_level: partner.attributes.level, - logo: logo?.formats?.small?.url || logo?.url, - }, - }); - migrated++; - } catch (error) { - failed++; - strapi.log.error( - `Error migrating partner ${partner.attributes.slug}:`, - error, - ); - } - } - - strapi.log.info( - `Partners migration finished. Migrated: ${migrated}, Skipped: ${skipped}, Failed: ${failed}`, - ); -}; diff --git a/apps/cms/src/migration/plugins.ts b/apps/cms/src/migration/plugins.ts deleted file mode 100644 index 13d405b..0000000 --- a/apps/cms/src/migration/plugins.ts +++ /dev/null @@ -1,90 +0,0 @@ -import Airtable from "airtable"; -import { findOrCreateAuthor } from "./users-organizations"; -import { uploadFromUrl } from "./utils"; - -const airtableInstance = new Airtable({ - apiKey: process.env.AIRTABLE_API_KEY, -}); -const base = airtableInstance.base(process.env.AIRTABLE_MARKET_BASE_ID); - -const PLUGINS_TABLE = "Plugins"; -const PLUGINS_VIEW = "PROXY_API"; - -export const getPlugins = async () => { - const baseTable = base.table(PLUGINS_TABLE); - const results = await baseTable - .select({ - view: PLUGINS_VIEW, - }) - .all(); - return results; -}; - -export const migratePlugins = async () => { - strapi.log.info("Starting plugins migration..."); - let migrated = 0; - let skipped = 0; - let failed = 0; - const plugins = await getPlugins(); - for (const plugin of plugins) { - try { - const existingPackage = await strapi - .documents("api::package.package") - .findFirst({ - fields: [], - filters: { - slug: plugin.fields["Slug"] as string, - }, - }); - if (existingPackage) { - skipped++; - continue; - } - const author = await findOrCreateAuthor( - plugin.fields["Developer email"], - plugin.fields["Developer name"], - plugin.fields["Repository URL"], - plugin.fields["Submission date"], - ); - const logoUrl = plugin.fields["Logo URL"]?.[0]; - const logoKey = plugin.fields["Logo Key"]?.[0]; - let icon = null; - if (logoUrl && logoKey) { - icon = await uploadFromUrl( - logoUrl, - logoKey.split("/").pop() || logoKey, - ); - } - await strapi.documents("api::package.package").create({ - status: "published", - data: { - name: plugin.fields["Name"] as string, - createdAt: plugin.fields["Submission date"] as string, - description: plugin.fields["Description"] as string, - type: plugin.fields["Type"] as "plugin", - package_location: plugin.fields["npm package URL"] as string, - git_repository: plugin.fields["Repository URL"] as string, - stars: plugin.fields["GitHub stars"] as number, - icon: icon?.id, - monthly_downloads: plugin.fields["npm downloads"] as number, - maintainers: [author.author.documentId], - owner: { - __type: author.type, - id: author.author.id, - }, - slug: plugin.fields["Slug"] as string, - }, - }); - migrated++; - } catch (error) { - failed++; - strapi.log.error( - `Error migrating plugin ${plugin.fields["Name"]}:`, - error, - ); - } - } - strapi.log.info( - `Plugins migration finished. Migrated: ${migrated}, Skipped: ${skipped}, Failed: ${failed}`, - ); -}; diff --git a/apps/cms/src/migration/providers.ts b/apps/cms/src/migration/providers.ts deleted file mode 100644 index 843ebc8..0000000 --- a/apps/cms/src/migration/providers.ts +++ /dev/null @@ -1,90 +0,0 @@ -import Airtable from "airtable"; -import { findOrCreateAuthor } from "./users-organizations"; -import { uploadFromUrl } from "./utils"; - -const airtableInstance = new Airtable({ - apiKey: process.env.AIRTABLE_API_KEY, -}); -const base = airtableInstance.base(process.env.AIRTABLE_MARKET_BASE_ID); - -const PLUGINS_TABLE = "Providers"; -const PLUGINS_VIEW = "PROXY_API"; - -export const getProviders = async () => { - const baseTable = base.table(PLUGINS_TABLE); - const results = await baseTable - .select({ - view: PLUGINS_VIEW, - }) - .all(); - return results; -}; - -export const migrateProviders = async () => { - strapi.log.info("Starting providers migration..."); - let migrated = 0; - let skipped = 0; - let failed = 0; - const providers = await getProviders(); - for (const provider of providers) { - try { - const existingPackage = await strapi - .documents("api::package.package") - .findFirst({ - fields: [], - filters: { - slug: provider.fields["Slug"] as string, - }, - }); - if (existingPackage) { - skipped++; - continue; - } - const author = await findOrCreateAuthor( - provider.fields["Developer email"], - provider.fields["Developer name"], - provider.fields["Repository URL"], - provider.fields["Submission date"], - ); - const logoUrl = provider.fields["Logo URL"]?.[0]; - const logoKey = provider.fields["Logo Key"]?.[0]; - let icon = null; - if (logoUrl && logoKey) { - icon = await uploadFromUrl( - logoUrl, - logoKey.split("/").pop() || logoKey, - ); - } - await strapi.documents("api::package.package").create({ - status: "published", - data: { - name: provider.fields["Name"] as string, - createdAt: provider.fields["Submission date"] as string, - description: provider.fields["Description"] as string, - type: provider.fields["Type"] as "provider", - package_location: provider.fields["npm package URL"] as string, - git_repository: provider.fields["Repository URL"] as string, - stars: provider.fields["GitHub stars"] as number, - icon: icon?.id, - monthly_downloads: provider.fields["npm downloads"] as number, - maintainers: [author.author.documentId], - owner: { - __type: author.type, - id: author.author.id, - }, - slug: provider.fields["Slug"] as string, - }, - }); - migrated++; - } catch (error) { - failed++; - strapi.log.error( - `Error migrating provider ${provider.fields["Name"]}:`, - error, - ); - } - } - strapi.log.info( - `Providers migration finished. Migrated: ${migrated}, Skipped: ${skipped}, Failed: ${failed}`, - ); -}; diff --git a/apps/cms/src/migration/user-profile-pictures.ts b/apps/cms/src/migration/user-profile-pictures.ts new file mode 100644 index 0000000..fb060b6 --- /dev/null +++ b/apps/cms/src/migration/user-profile-pictures.ts @@ -0,0 +1,81 @@ +import { getGithubOwnerAvatarUrl, uploadFromUrl } from "./utils"; + +const PACKAGE_UID = "api::package.package"; +const TEMPLATE_UID = "api::template.template"; + +const findLatestPackage = (userDocumentId: string) => + strapi.documents(PACKAGE_UID).findFirst({ + fields: ["git_repository", "createdAt"], + filters: { + maintainers: { documentId: { $eq: userDocumentId } }, + }, + sort: { createdAt: "desc" }, + }); + +const findLatestTemplate = (userDocumentId: string) => + strapi.documents(TEMPLATE_UID).findFirst({ + fields: ["git_repository", "createdAt"], + filters: { + maintainers: { documentId: { $eq: userDocumentId } }, + }, + sort: { createdAt: "desc" }, + }); + +export const migrateUserProfilePictures = async () => { + strapi.log.info("Starting user profile pictures migration..."); + let migrated = 0; + let skipped = 0; + let failed = 0; + + const users = await strapi.documents("plugin::better-auth.user").findMany({ + fields: ["documentId", "image"], + filters: { + $or: [{ image: { $null: true } }, { image: { $eq: "" } }], + }, + }); + + for (const user of users) { + try { + const [latestPackage, latestTemplate] = await Promise.all([ + findLatestPackage(user.documentId), + findLatestTemplate(user.documentId), + ]); + + const latestSubmission = [latestPackage, latestTemplate] + .filter(Boolean) + .sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + )[0]; + + const avatar = latestSubmission?.git_repository + ? getGithubOwnerAvatarUrl(latestSubmission.git_repository) + : null; + + if (!avatar) { + skipped++; + continue; + } + + const uploadedAvatar = await uploadFromUrl(avatar.url, avatar.name); + + await strapi.documents("plugin::better-auth.user").update({ + documentId: user.documentId, + data: { + image: uploadedAvatar?.formats?.small?.url || uploadedAvatar?.url, + }, + }); + migrated++; + } catch (error) { + failed++; + strapi.log.error( + `Error migrating profile picture for user ${user.documentId}:`, + error, + ); + } + } + + strapi.log.info( + `User profile pictures migration finished. Migrated: ${migrated}, Skipped: ${skipped}, Failed: ${failed}`, + ); +}; diff --git a/apps/cms/src/migration/users-organizations.ts b/apps/cms/src/migration/users-organizations.ts deleted file mode 100644 index 743fdbe..0000000 --- a/apps/cms/src/migration/users-organizations.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { auth } from "../lib/auth"; -import { - generatePassword, - getGithubOwnerAvatarUrl, - uploadFromUrl, -} from "./utils"; - -export const findOrCreateAuthor = async (email, name, githubUrl, createdAt) => { - let author = await strapi.documents("plugin::better-auth.user").findFirst({ - fields: ["id"], - filters: { - email: email?.toLowerCase(), - }, - }); - if (!author) { - const profilePic = getGithubOwnerAvatarUrl(githubUrl); - const uploadedAvatar = profilePic - ? await uploadFromUrl(profilePic.url, profilePic.name) - : null; - await auth.api.signUpEmail({ - body: { - name: name, - email: email?.toLowerCase(), - password: generatePassword(), - image: uploadedAvatar?.formats?.small?.url || uploadedAvatar?.url, - }, - }); - author = await strapi.documents("plugin::better-auth.user").findFirst({ - fields: ["id"], - filters: { - email: email?.toLowerCase(), - }, - }); - await strapi.documents("plugin::better-auth.user").update({ - documentId: author.documentId, - data: { - createdAt: createdAt, - }, - }); - } - return { - author, - type: "plugin::better-auth.user", - }; -}; diff --git a/apps/cms/src/migration/utils.ts b/apps/cms/src/migration/utils.ts index 5acd127..5572358 100644 --- a/apps/cms/src/migration/utils.ts +++ b/apps/cms/src/migration/utils.ts @@ -63,80 +63,3 @@ export function getGithubOwnerAvatarUrl(repoUrl) { name: match[1].toLocaleLowerCase(), }; } - -export async function uploadMarkdownImages(markdown: string): Promise { - const markdownImageRegex = /!\[([^\]]*)\]\((https?:\/\/[^)\s"]+)[^)]*\)/g; - const htmlImageRegex = /]+src="(https?:\/\/[^"]+)"[^>]*>/g; - - const urls = new Set(); - for (const match of markdown.matchAll(markdownImageRegex)) { - urls.add(match[2]); - } - for (const match of markdown.matchAll(htmlImageRegex)) { - urls.add(match[1]); - } - - const urlMap = new Map(); - for (const url of urls) { - try { - const fileName = path.basename(new URL(url).pathname) || "image"; - const uploaded = await uploadFromUrl(url, fileName); - if (uploaded?.url) { - urlMap.set(url, uploaded.url); - } - } catch (e) { - strapi.log.error(`Failed to upload markdown image: ${url}`, e); - } - } - - let result = markdown; - for (const [oldUrl, newUrl] of urlMap) { - result = result.replaceAll(oldUrl, newUrl); - } - - return result; -} - -export const generatePassword = () => { - let length = 20, - charset = - "@#$&*0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ@#$&*0123456789abcdefghijklmnopqrstuvwxyz", - password = ""; - for (let i = 0, n = charset.length; i < length; ++i) { - password += charset.charAt(Math.floor(Math.random() * n)); - } - return password; -}; - -type CategoryTag = string | { attributes: { name: string } }; - -export const createCategories = async (uid, data: CategoryTag[]) => { - const categories = []; - - if (!data || data.length === 0) { - return categories; - } - - await Promise.all( - data.map(async (tag) => { - const name = typeof tag === "string" ? tag : tag.attributes.name; - - const existingCategory = await strapi.documents(uid).findFirst({ - filters: { name }, - }); - - if (existingCategory) { - categories.push(existingCategory.id); - return; - } - - const newCategory = await strapi.documents(uid).create({ - data: { name }, - }); - - categories.push(newCategory.id); - }), - ); - - return categories; -};