From 7b1143a26899c1f9351c2939805ee433a9fd8d73 Mon Sep 17 00:00:00 2001 From: Boaz Poolman Date: Wed, 21 Oct 2026 15:00:46 +0200 Subject: [PATCH 1/4] feat: partner portal --- .../content-types/partner-program/schema.json | 21 + .../controllers/partner-program.ts | 5 + .../partner-program/routes/partner-program.ts | 5 + .../services/partner-program.ts | 5 + .../partner-resource/schema.json | 63 +++ .../controllers/partner-resource.ts | 5 + .../routes/partner-resource.ts | 5 + .../services/partner-resource.ts | 5 + apps/cms/src/components/partner/benefit.json | 23 ++ .../better-auth/controllers/organization.ts | 49 +++ .../better-auth/routes/organization.ts | 42 ++ .../better-auth/services/organization.ts | 158 +++++++- .../better-auth/utils/partner-levels.ts | 32 ++ apps/cms/src/index.ts | 4 + apps/cms/src/seed/partner-program.ts | 49 +++ apps/cms/src/seed/partner-resources.ts | 167 ++++++++ apps/cms/types/generated/components.d.ts | 17 + apps/cms/types/generated/contentTypes.d.ts | 81 ++++ .../app/partner/[slug]/[[...path]]/page.tsx | 86 ++++ .../auth-navigation/auth-navigation.tsx | 26 +- .../organization-view/organization-view.tsx | 32 +- .../components/organization-mark.tsx | 41 ++ .../components/partner-portal-view/index.ts | 1 + .../partner-portal-view.tsx | 378 ++++++++++++++++++ .../sections/coming-soon-section.tsx | 29 ++ .../components/sections/locked-section.tsx | 28 ++ .../sections/program-info-section.tsx | 167 ++++++++ .../components/sections/resource-detail.tsx | 101 +++++ .../components/sections/resource-section.tsx | 174 ++++++++ .../sections/reusable-content-section.tsx | 92 +++++ .../components/sections/shared.tsx | 46 +++ .../src/features/partner-portal/lib/levels.ts | 31 ++ .../lib/partner-organizations.ts | 72 ++++ .../partner-portal/lib/partner-portal.ts | 113 ++++++ 34 files changed, 2130 insertions(+), 23 deletions(-) create mode 100644 apps/cms/src/api/partner-program/content-types/partner-program/schema.json create mode 100644 apps/cms/src/api/partner-program/controllers/partner-program.ts create mode 100644 apps/cms/src/api/partner-program/routes/partner-program.ts create mode 100644 apps/cms/src/api/partner-program/services/partner-program.ts create mode 100644 apps/cms/src/api/partner-resource/content-types/partner-resource/schema.json create mode 100644 apps/cms/src/api/partner-resource/controllers/partner-resource.ts create mode 100644 apps/cms/src/api/partner-resource/routes/partner-resource.ts create mode 100644 apps/cms/src/api/partner-resource/services/partner-resource.ts create mode 100644 apps/cms/src/components/partner/benefit.json create mode 100644 apps/cms/src/extensions/better-auth/utils/partner-levels.ts create mode 100644 apps/cms/src/seed/partner-program.ts create mode 100644 apps/cms/src/seed/partner-resources.ts create mode 100644 apps/web/src/app/partner/[slug]/[[...path]]/page.tsx create mode 100644 apps/web/src/features/partner-portal/components/organization-mark.tsx create mode 100644 apps/web/src/features/partner-portal/components/partner-portal-view/index.ts create mode 100644 apps/web/src/features/partner-portal/components/partner-portal-view/partner-portal-view.tsx create mode 100644 apps/web/src/features/partner-portal/components/sections/coming-soon-section.tsx create mode 100644 apps/web/src/features/partner-portal/components/sections/locked-section.tsx create mode 100644 apps/web/src/features/partner-portal/components/sections/program-info-section.tsx create mode 100644 apps/web/src/features/partner-portal/components/sections/resource-detail.tsx create mode 100644 apps/web/src/features/partner-portal/components/sections/resource-section.tsx create mode 100644 apps/web/src/features/partner-portal/components/sections/reusable-content-section.tsx create mode 100644 apps/web/src/features/partner-portal/components/sections/shared.tsx create mode 100644 apps/web/src/features/partner-portal/lib/levels.ts create mode 100644 apps/web/src/features/partner-portal/lib/partner-organizations.ts create mode 100644 apps/web/src/features/partner-portal/lib/partner-portal.ts diff --git a/apps/cms/src/api/partner-program/content-types/partner-program/schema.json b/apps/cms/src/api/partner-program/content-types/partner-program/schema.json new file mode 100644 index 00000000..3ed358fc --- /dev/null +++ b/apps/cms/src/api/partner-program/content-types/partner-program/schema.json @@ -0,0 +1,21 @@ +{ + "kind": "singleType", + "collectionName": "partner_programs", + "info": { + "singularName": "partner-program", + "pluralName": "partner-programs", + "displayName": "Partner Program", + "description": "Partner portal settings, like the benefits listed per partner level." + }, + "options": { + "draftAndPublish": false + }, + "pluginOptions": {}, + "attributes": { + "benefits": { + "type": "component", + "component": "partner.benefit", + "repeatable": true + } + } +} diff --git a/apps/cms/src/api/partner-program/controllers/partner-program.ts b/apps/cms/src/api/partner-program/controllers/partner-program.ts new file mode 100644 index 00000000..39152ffb --- /dev/null +++ b/apps/cms/src/api/partner-program/controllers/partner-program.ts @@ -0,0 +1,5 @@ +import { factories } from "@strapi/strapi"; + +export default factories.createCoreController( + "api::partner-program.partner-program", +); diff --git a/apps/cms/src/api/partner-program/routes/partner-program.ts b/apps/cms/src/api/partner-program/routes/partner-program.ts new file mode 100644 index 00000000..72c25310 --- /dev/null +++ b/apps/cms/src/api/partner-program/routes/partner-program.ts @@ -0,0 +1,5 @@ +import { factories } from "@strapi/strapi"; + +export default factories.createCoreRouter( + "api::partner-program.partner-program", +); diff --git a/apps/cms/src/api/partner-program/services/partner-program.ts b/apps/cms/src/api/partner-program/services/partner-program.ts new file mode 100644 index 00000000..e33b8328 --- /dev/null +++ b/apps/cms/src/api/partner-program/services/partner-program.ts @@ -0,0 +1,5 @@ +import { factories } from "@strapi/strapi"; + +export default factories.createCoreService( + "api::partner-program.partner-program", +); diff --git a/apps/cms/src/api/partner-resource/content-types/partner-resource/schema.json b/apps/cms/src/api/partner-resource/content-types/partner-resource/schema.json new file mode 100644 index 00000000..d95941d2 --- /dev/null +++ b/apps/cms/src/api/partner-resource/content-types/partner-resource/schema.json @@ -0,0 +1,63 @@ +{ + "kind": "collectionType", + "collectionName": "partner_resources", + "info": { + "singularName": "partner-resource", + "pluralName": "partner-resources", + "displayName": "Partner Resources", + "description": "Gated content for the partner portal's Sales enablement, Tech enablement and Reusable content sections." + }, + "options": { + "draftAndPublish": true + }, + "pluginOptions": {}, + "attributes": { + "title": { + "type": "string", + "required": true + }, + "slug": { + "type": "uid", + "targetField": "title", + "required": true + }, + "description": { + "type": "text" + }, + "section": { + "type": "enumeration", + "required": true, + "enum": ["sales_enablement", "tech_enablement", "reusable_content"] + }, + "minimum_level": { + "type": "enumeration", + "required": true, + "default": "Community", + "enum": ["Unlisted", "Community", "Business", "Enterprise"] + }, + "category": { + "type": "string" + }, + "label": { + "type": "string" + }, + "icon": { + "type": "customField", + "customField": "plugin::strapi-lucide-icons.icon" + }, + "content": { + "type": "richtext" + }, + "file": { + "type": "media", + "multiple": false + }, + "link": { + "type": "string" + }, + "sort_order": { + "type": "integer", + "default": 0 + } + } +} diff --git a/apps/cms/src/api/partner-resource/controllers/partner-resource.ts b/apps/cms/src/api/partner-resource/controllers/partner-resource.ts new file mode 100644 index 00000000..3b6fbf88 --- /dev/null +++ b/apps/cms/src/api/partner-resource/controllers/partner-resource.ts @@ -0,0 +1,5 @@ +import { factories } from "@strapi/strapi"; + +export default factories.createCoreController( + "api::partner-resource.partner-resource", +); diff --git a/apps/cms/src/api/partner-resource/routes/partner-resource.ts b/apps/cms/src/api/partner-resource/routes/partner-resource.ts new file mode 100644 index 00000000..f32af916 --- /dev/null +++ b/apps/cms/src/api/partner-resource/routes/partner-resource.ts @@ -0,0 +1,5 @@ +import { factories } from "@strapi/strapi"; + +export default factories.createCoreRouter( + "api::partner-resource.partner-resource", +); diff --git a/apps/cms/src/api/partner-resource/services/partner-resource.ts b/apps/cms/src/api/partner-resource/services/partner-resource.ts new file mode 100644 index 00000000..c516ca14 --- /dev/null +++ b/apps/cms/src/api/partner-resource/services/partner-resource.ts @@ -0,0 +1,5 @@ +import { factories } from "@strapi/strapi"; + +export default factories.createCoreService( + "api::partner-resource.partner-resource", +); diff --git a/apps/cms/src/components/partner/benefit.json b/apps/cms/src/components/partner/benefit.json new file mode 100644 index 00000000..be7b2490 --- /dev/null +++ b/apps/cms/src/components/partner/benefit.json @@ -0,0 +1,23 @@ +{ + "collectionName": "components_partner_benefits", + "info": { + "displayName": "Benefit", + "description": "A benefit shown to partners of the given level." + }, + "options": {}, + "attributes": { + "level": { + "type": "enumeration", + "required": true, + "enum": ["Unlisted", "Community", "Business", "Enterprise"] + }, + "title": { + "type": "string", + "required": true + }, + "description": { + "type": "text" + } + }, + "config": {} +} diff --git a/apps/cms/src/extensions/better-auth/controllers/organization.ts b/apps/cms/src/extensions/better-auth/controllers/organization.ts index fd7b0619..ea203175 100644 --- a/apps/cms/src/extensions/better-auth/controllers/organization.ts +++ b/apps/cms/src/extensions/better-auth/controllers/organization.ts @@ -54,6 +54,55 @@ export default factories.createCoreController( ctx.body = organizations; }, + /** + * GET /organizations/partners — partner organizations the caller is + * an owner/admin of, i.e. whose partner portal they can open. Scoped + * to the caller like `mine`, so only `is-authenticated` applies. + */ + async partners(ctx) { + const session = ctx.state.betterAuthSession; + + const organizations = await getPluginService( + "organization", + ).getPartnerPortals({ + userId: session.user.id, + }); + + ctx.body = organizations; + }, + + /** + * GET /organizations/:id/partner-portal — the partner portal's gated + * content, filtered by the organization's partner level. Gated by + * the `is-authenticated` and `is-organization-owner` policies. + */ + async partnerPortal(ctx) { + const { id } = ctx.params; + + const portal = await getPluginService("organization").getPartnerPortal({ + organizationId: id, + }); + if (!portal) return ctx.notFound(); + + ctx.body = portal; + }, + + /** + * GET /organizations/:id/partner-portal/resources/:slug — a single + * partner resource, including its body. Same policies as + * `partnerPortal`. + */ + async partnerResource(ctx) { + const { id, slug } = ctx.params; + + const resource = await getPluginService( + "organization", + ).getPartnerResource({ organizationId: id, slug }); + if (!resource) return ctx.notFound(); + + ctx.body = resource; + }, + async members(ctx) { const { id } = ctx.params; diff --git a/apps/cms/src/extensions/better-auth/routes/organization.ts b/apps/cms/src/extensions/better-auth/routes/organization.ts index 72e3da58..3affbdec 100644 --- a/apps/cms/src/extensions/better-auth/routes/organization.ts +++ b/apps/cms/src/extensions/better-auth/routes/organization.ts @@ -24,6 +24,19 @@ export default { prefix: "", }, }, + { + method: "GET", + path: "/organizations/partners", + handler: "organization.partners", + config: { + // Same as `/organizations/mine` — must stay registered before + // `/organizations/:id`. + auth: false, + policies: ["plugin::better-auth.is-authenticated"], + middlewares: [], + prefix: "", + }, + }, { method: "GET", path: "/organizations/:id", @@ -45,6 +58,35 @@ export default { handler: "organization.members", config: { policies: [], middlewares: [], prefix: "" }, }, + { + method: "GET", + path: "/organizations/:id/partner-portal", + handler: "organization.partnerPortal", + config: { + // Owners/admins only, same as the web app's own portal gating. + auth: false, + policies: [ + "plugin::better-auth.is-authenticated", + "plugin::better-auth.is-organization-owner", + ], + middlewares: [], + prefix: "", + }, + }, + { + method: "GET", + path: "/organizations/:id/partner-portal/resources/:slug", + handler: "organization.partnerResource", + config: { + auth: false, + policies: [ + "plugin::better-auth.is-authenticated", + "plugin::better-auth.is-organization-owner", + ], + middlewares: [], + prefix: "", + }, + }, { method: "PUT", path: "/organizations/:id/profile", diff --git a/apps/cms/src/extensions/better-auth/services/organization.ts b/apps/cms/src/extensions/better-auth/services/organization.ts index 0f4174f0..36bb90f2 100644 --- a/apps/cms/src/extensions/better-auth/services/organization.ts +++ b/apps/cms/src/extensions/better-auth/services/organization.ts @@ -8,6 +8,57 @@ import { findOwnedContentIds, uploadOwnedFile, } from "../utils"; +import { + levelsUpTo, + type PartnerLevel, + type PartnerSection, + SECTION_MINIMUM_LEVEL, +} from "../utils/partner-levels"; + +/** IDs of the organizations `userId` is an owner/admin of. */ +async function findManageableOrganizationIds(userId: string | number) { + const memberships = await strapi + .documents("plugin::better-auth.member") + .findMany({ + filters: { + userId, + role: { $in: ["admin", "owner"] }, + }, + fields: ["organizationId"], + }); + + return memberships.map((membership) => membership.organizationId); +} + +/** The organization's partner level, or `null` if it isn't a partner. */ +async function findPartnerLevel( + organizationId: string, +): Promise { + const [organization] = await strapi + .documents("plugin::better-auth.organization") + .findMany({ + filters: { id: organizationId, partner: { $eq: true } }, + fields: ["partner_level"], + }); + + if (!organization) return null; + return organization.partner_level ?? "Unlisted"; +} + +/** Partner resources unlocked at `level` — by their own and their section's minimum level. */ +function partnerResourceFilters(level: PartnerLevel) { + const unlocked = levelsUpTo(level); + const sections = ( + Object.entries(SECTION_MINIMUM_LEVEL) as [PartnerSection, PartnerLevel][] + ) + .filter(([, minimum]) => unlocked.includes(minimum)) + .map(([section]) => section); + + return { + minimum_level: { $in: unlocked }, + section: { $in: sections }, + }; +} export default factories.createCoreService( "plugin::better-auth.organization", @@ -83,29 +134,104 @@ export default factories.createCoreService( * by the plugin/template submission forms' "Submit as" selector. */ async getManageable({ userId }: { userId: string | number }) { - const memberships = await strapi - .documents("plugin::better-auth.member") - .findMany({ - filters: { - userId, - role: { $in: ["admin", "owner"] }, - }, - fields: ["organizationId"], - }); - - if (memberships.length === 0) return []; + const organizationIds = await findManageableOrganizationIds(userId); + if (organizationIds.length === 0) return []; return strapi.documents("plugin::better-auth.organization").findMany({ - filters: { - id: { - $in: memberships.map((membership) => membership.organizationId), - }, - }, + filters: { id: { $in: organizationIds } }, fields: ["id", "name", "slug", "logo"], sort: { name: "asc" }, }); }, + /** + * Partner organizations `userId` is an owner/admin of — i.e. whose + * partner portal they may access. Backs `GET /organizations/partners`, + * used by the web app's user menu and the partner portal itself. + */ + async getPartnerPortals({ userId }: { userId: string | number }) { + const organizationIds = await findManageableOrganizationIds(userId); + if (organizationIds.length === 0) return []; + + return strapi.documents("plugin::better-auth.organization").findMany({ + filters: { id: { $in: organizationIds }, partner: { $eq: true } }, + fields: ["id", "name", "slug", "logo", "partner_level", "createdAt"], + populate: { profile: { fields: ["website", "location"] } }, + sort: { name: "asc" }, + }); + }, + + /** + * The partner portal's gated content for a partner organization: + * its level, the benefits for that level, and every published + * resource its level unlocks (both the resource's own + * `minimum_level` and its section's). Resource bodies are left out — + * see `getPartnerResource`. Returns `null` for non-partner + * organizations. + */ + async getPartnerPortal({ organizationId }: { organizationId: string }) { + const level = await findPartnerLevel(organizationId); + if (!level) return null; + + const [program, resources] = await Promise.all([ + strapi.documents("api::partner-program.partner-program").findFirst({ + populate: { benefits: true }, + }), + strapi.documents("api::partner-resource.partner-resource").findMany({ + status: "published", + filters: partnerResourceFilters(level), + fields: [ + "title", + "slug", + "description", + "section", + "minimum_level", + "category", + "label", + "icon", + "link", + "content", + ], + populate: { file: { fields: ["url", "name", "ext", "size"] } }, + sort: [{ sort_order: "asc" }, { title: "asc" }], + }), + ]); + + return { + level, + benefits: (program?.benefits ?? []).filter( + (benefit) => benefit.level === level, + ), + resources: resources.map(({ content, ...resource }) => ({ + ...resource, + has_content: !!content?.trim(), + })), + }; + }, + + /** + * A single partner resource, including its body — or `null` if it + * doesn't exist or the organization's level doesn't unlock it. + */ + async getPartnerResource({ + organizationId, + slug, + }: { + organizationId: string; + slug: string; + }) { + const level = await findPartnerLevel(organizationId); + if (!level) return null; + + return strapi + .documents("api::partner-resource.partner-resource") + .findFirst({ + status: "published", + filters: { ...partnerResourceFilters(level), slug: { $eq: slug } }, + populate: { file: { fields: ["url", "name", "ext", "size"] } }, + }); + }, + async getMembers({ organizationId, query, diff --git a/apps/cms/src/extensions/better-auth/utils/partner-levels.ts b/apps/cms/src/extensions/better-auth/utils/partner-levels.ts new file mode 100644 index 00000000..48dac44c --- /dev/null +++ b/apps/cms/src/extensions/better-auth/utils/partner-levels.ts @@ -0,0 +1,32 @@ +/** + * Partner levels, lowest first. An organization's `partner_level` is one + * of the last three; an empty level means "Unlisted". + * + * Mirrored in apps/web/src/features/partner-portal/lib/levels.ts — keep + * the two in sync. + */ +export const PARTNER_LEVELS = [ + "Unlisted", + "Community", + "Business", + "Enterprise", +] as const; + +export type PartnerLevel = (typeof PARTNER_LEVELS)[number]; + +export type PartnerSection = + | "sales_enablement" + | "tech_enablement" + | "reusable_content"; + +/** The lowest level that can see a section at all, on top of each resource's own `minimum_level`. */ +export const SECTION_MINIMUM_LEVEL: Record = { + sales_enablement: "Community", + tech_enablement: "Community", + reusable_content: "Unlisted", +}; + +/** Every level at or below `level` — i.e. the minimum levels it satisfies. */ +export function levelsUpTo(level: PartnerLevel): PartnerLevel[] { + return PARTNER_LEVELS.slice(0, PARTNER_LEVELS.indexOf(level) + 1); +} diff --git a/apps/cms/src/index.ts b/apps/cms/src/index.ts index 90b50abe..90ff8266 100644 --- a/apps/cms/src/index.ts +++ b/apps/cms/src/index.ts @@ -3,12 +3,16 @@ import { migrateReadmeAutoSync } from "./migration/readme-auto-sync"; import { migrateUserProfilePictures } from "./migration/user-profile-pictures"; import { migrateUserSlugs } from "./migration/user-slugs"; import { seedEmailTemplates } from "./seed/email-templates"; +import { seedPartnerProgram } from "./seed/partner-program"; +import { seedPartnerResources } from "./seed/partner-resources"; export default { register(/* { strapi }: { strapi: Core.Strapi } */) {}, async bootstrap({ strapi }: { strapi: Core.Strapi }) { await seedEmailTemplates(strapi); + await seedPartnerProgram(strapi); + await seedPartnerResources(strapi); if (process.env.ENABLE_MIGRATION !== "true") { return; diff --git a/apps/cms/src/seed/partner-program.ts b/apps/cms/src/seed/partner-program.ts new file mode 100644 index 00000000..abb32318 --- /dev/null +++ b/apps/cms/src/seed/partner-program.ts @@ -0,0 +1,49 @@ +import type { Core } from "@strapi/strapi"; + +type Benefit = { + level: "Unlisted" | "Community" | "Business" | "Enterprise"; + title: string; + description?: string; +}; + +const enablement: Omit[] = [ + { + title: "Sales enablement", + description: "Playbooks, pricing and battlecards to help you win pitches.", + }, + { + title: "Tech enablement", + description: "Training, certification and resources for your team.", + }, +]; + +const benefits: Benefit[] = [ + ...enablement.map((benefit) => ({ ...benefit, level: "Community" as const })), + ...enablement.map((benefit) => ({ ...benefit, level: "Business" as const })), + { level: "Business", title: "Lead sharing" }, + { level: "Business", title: "5% discount" }, + ...enablement.map((benefit) => ({ + ...benefit, + level: "Enterprise" as const, + })), + { level: "Enterprise", title: "Lead sharing" }, + { level: "Enterprise", title: "10% discount" }, +]; + +/** + * Creates the partner program single type with its initial per-level + * benefits, once — after that it's edited in the admin panel. + */ +export async function seedPartnerProgram(strapi: Core.Strapi): Promise { + const existing = await strapi + .documents("api::partner-program.partner-program") + .findFirst(); + + if (existing) return; + + await strapi + .documents("api::partner-program.partner-program") + .create({ data: { benefits } }); + + strapi.log.info("[seed] Created partner program"); +} diff --git a/apps/cms/src/seed/partner-resources.ts b/apps/cms/src/seed/partner-resources.ts new file mode 100644 index 00000000..4495180c --- /dev/null +++ b/apps/cms/src/seed/partner-resources.ts @@ -0,0 +1,167 @@ +import type { Core } from "@strapi/strapi"; + +type Seed = { + title: string; + description: string; + section: "sales_enablement" | "tech_enablement" | "reusable_content"; + label?: string; + category?: string; + /** A lucide icon name, as stored by the `strapi-lucide-icons` field. */ + icon: string; +}; + +// Initial partner portal content, from the partner portal design. No +// bodies, files or links yet — those are added by the partner team. +const seeds: Seed[] = [ + { + section: "sales_enablement", + title: "Partner sales playbook", + description: + "Discovery questions, qualification and positioning against other CMSs.", + label: "Guide", + icon: "book-marked", + }, + { + section: "sales_enablement", + title: "Pricing & packaging", + description: + "Current Strapi Cloud and Enterprise pricing for client proposals.", + label: "PDF", + icon: "tag", + }, + { + section: "sales_enablement", + title: "Competitive battlecards", + description: "How Strapi compares with Contentful, Sanity and WordPress.", + label: "PDF", + icon: "swords", + }, + { + section: "sales_enablement", + title: "Deal registration guide", + description: "How registration works and what protection it gives you.", + label: "Guide", + icon: "handshake", + }, + { + section: "sales_enablement", + title: "Sales training sessions", + description: "Recorded and upcoming sessions with the Strapi sales team.", + label: "Video", + icon: "monitor-play", + }, + { + section: "sales_enablement", + title: "Request a joint call", + description: "Bring a Strapi account executive into a client conversation.", + label: "Booking", + icon: "phone-call", + }, + { + section: "tech_enablement", + title: "Strapi certification", + description: "Certify your developers on Strapi 5 and earn tier credit.", + label: "Course", + icon: "scroll-text", + }, + { + section: "tech_enablement", + title: "Architecture guides", + description: + "Reference architectures for enterprise and multi-site projects.", + label: "Docs", + icon: "network", + }, + { + section: "tech_enablement", + title: "Partner sandbox", + description: + "Enterprise and Cloud licences for demos and internal projects.", + label: "Access", + icon: "flask-conical", + }, + { + section: "tech_enablement", + title: "Technical office hours", + description: "Book time with Strapi solutions engineers.", + label: "Booking", + icon: "calendar-check", + }, + { + section: "tech_enablement", + title: "Migration toolkit", + description: "Scripts and guides for moving clients from other CMSs.", + label: "Toolkit", + icon: "repeat", + }, + { + section: "tech_enablement", + title: "Release briefings", + description: "Early walkthroughs of upcoming Strapi releases.", + label: "Webinar", + icon: "rocket", + }, + { + section: "reusable_content", + title: "Strapi logo pack", + description: "The Strapi logo in every format and colorway.", + category: "Logos & brand kit", + label: "SVG, PNG", + icon: "shapes", + }, + { + section: "reusable_content", + title: "Partner badge — all tiers", + description: "Show your partner level on your website and proposals.", + category: "Logos & brand kit", + label: "SVG", + icon: "badge-check", + }, + { + section: "reusable_content", + title: "Co-branded one-pager", + description: "A one-page Strapi + partner overview for client pitches.", + category: "Co-branded templates", + label: "Figma, PDF", + icon: "file-text", + }, + { + section: "reusable_content", + title: "Joint webinar slides", + description: "A slide template for webinars run together with Strapi.", + category: "Co-branded templates", + label: "Google Slides", + icon: "presentation", + }, +]; + +const slugify = (title: string) => + title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + +/** + * Creates the initial partner resources (published), but only while the + * collection is still empty — once the partner team starts editing, it + * never touches it again. + */ +export async function seedPartnerResources(strapi: Core.Strapi): Promise { + const documents = strapi.documents("api::partner-resource.partner-resource"); + + if ((await documents.count({})) > 0) return; + + for (const [index, seed] of seeds.entries()) { + await documents.create({ + data: { + ...seed, + slug: slugify(seed.title), + minimum_level: "Community", + sort_order: index, + }, + status: "published", + }); + } + + strapi.log.info(`[seed] Created ${seeds.length} partner resources`); +} diff --git a/apps/cms/types/generated/components.d.ts b/apps/cms/types/generated/components.d.ts index 5c0db1f7..7063941b 100644 --- a/apps/cms/types/generated/components.d.ts +++ b/apps/cms/types/generated/components.d.ts @@ -1,5 +1,21 @@ import type { Schema, Struct } from '@strapi/strapi'; +export interface PartnerBenefit extends Struct.ComponentSchema { + collectionName: 'components_partner_benefits'; + info: { + description: 'A benefit shown to partners of the given level.'; + displayName: 'Benefit'; + }; + attributes: { + description: Schema.Attribute.Text; + level: Schema.Attribute.Enumeration< + ['Unlisted', 'Community', 'Business', 'Enterprise'] + > & + Schema.Attribute.Required; + title: Schema.Attribute.String & Schema.Attribute.Required; + }; +} + export interface SectionsCardGrid extends Struct.ComponentSchema { collectionName: 'components_sections_card_grids'; info: { @@ -223,6 +239,7 @@ export interface SharedVersionInfo extends Struct.ComponentSchema { declare module '@strapi/strapi' { export namespace Public { export interface ComponentSchemas { + 'partner.benefit': PartnerBenefit; 'sections.card-grid': SectionsCardGrid; 'sections.card-grid-item': SectionsCardGridItem; 'sections.cta': SectionsCta; diff --git a/apps/cms/types/generated/contentTypes.d.ts b/apps/cms/types/generated/contentTypes.d.ts index 9f1e787a..da97a200 100644 --- a/apps/cms/types/generated/contentTypes.d.ts +++ b/apps/cms/types/generated/contentTypes.d.ts @@ -1012,6 +1012,85 @@ export interface ApiPackagePackage extends Struct.CollectionTypeSchema { }; } +export interface ApiPartnerProgramPartnerProgram + extends Struct.SingleTypeSchema { + collectionName: 'partner_programs'; + info: { + description: 'Partner portal settings, like the benefits listed per partner level.'; + displayName: 'Partner Program'; + pluralName: 'partner-programs'; + singularName: 'partner-program'; + }; + options: { + draftAndPublish: false; + }; + attributes: { + benefits: Schema.Attribute.Component<'partner.benefit', true>; + createdAt: Schema.Attribute.DateTime; + createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & + Schema.Attribute.Private; + locale: Schema.Attribute.String & Schema.Attribute.Private; + localizations: Schema.Attribute.Relation< + 'oneToMany', + 'api::partner-program.partner-program' + > & + Schema.Attribute.Private; + publishedAt: Schema.Attribute.DateTime; + updatedAt: Schema.Attribute.DateTime; + updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & + Schema.Attribute.Private; + }; +} + +export interface ApiPartnerResourcePartnerResource + extends Struct.CollectionTypeSchema { + collectionName: 'partner_resources'; + info: { + description: "Gated content for the partner portal's Sales enablement, Tech enablement and Reusable content sections."; + displayName: 'Partner Resources'; + pluralName: 'partner-resources'; + singularName: 'partner-resource'; + }; + options: { + draftAndPublish: true; + }; + attributes: { + category: Schema.Attribute.String; + content: Schema.Attribute.RichText; + createdAt: Schema.Attribute.DateTime; + createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & + Schema.Attribute.Private; + description: Schema.Attribute.Text; + file: Schema.Attribute.Media; + icon: Schema.Attribute.String & + Schema.Attribute.CustomField<'plugin::strapi-lucide-icons.icon'>; + label: Schema.Attribute.String; + link: Schema.Attribute.String; + locale: Schema.Attribute.String & Schema.Attribute.Private; + localizations: Schema.Attribute.Relation< + 'oneToMany', + 'api::partner-resource.partner-resource' + > & + Schema.Attribute.Private; + minimum_level: Schema.Attribute.Enumeration< + ['Unlisted', 'Community', 'Business', 'Enterprise'] + > & + Schema.Attribute.Required & + Schema.Attribute.DefaultTo<'Community'>; + publishedAt: Schema.Attribute.DateTime; + section: Schema.Attribute.Enumeration< + ['sales_enablement', 'tech_enablement', 'reusable_content'] + > & + Schema.Attribute.Required; + slug: Schema.Attribute.UID<'title'> & Schema.Attribute.Required; + sort_order: Schema.Attribute.Integer & Schema.Attribute.DefaultTo<0>; + title: Schema.Attribute.String & Schema.Attribute.Required; + updatedAt: Schema.Attribute.DateTime; + updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & + Schema.Attribute.Private; + }; +} + export interface ApiProfileProfile extends Struct.CollectionTypeSchema { collectionName: 'profiles'; info: { @@ -2630,6 +2709,8 @@ declare module '@strapi/strapi' { 'api::overview-page.overview-page': ApiOverviewPageOverviewPage; 'api::package-category.package-category': ApiPackageCategoryPackageCategory; 'api::package.package': ApiPackagePackage; + 'api::partner-program.partner-program': ApiPartnerProgramPartnerProgram; + 'api::partner-resource.partner-resource': ApiPartnerResourcePartnerResource; 'api::profile.profile': ApiProfileProfile; 'api::recipe.recipe': ApiRecipeRecipe; 'api::service.service': ApiServiceService; diff --git a/apps/web/src/app/partner/[slug]/[[...path]]/page.tsx b/apps/web/src/app/partner/[slug]/[[...path]]/page.tsx new file mode 100644 index 00000000..1069922c --- /dev/null +++ b/apps/web/src/app/partner/[slug]/[[...path]]/page.tsx @@ -0,0 +1,86 @@ +import type { GetQueryParams } from "@repo/strapi-client"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { Navigation } from "@/components/layout/navigation"; +import { isAuthEnabled } from "@/features/auth/lib/is-enabled"; +import { cmsClient } from "@/features/cms/lib/strapi"; +import { PartnerPortalView } from "@/features/partner-portal/components/partner-portal-view"; + +const contentType = "plugin::better-auth.organization" as const; + +type Props = { + params: Promise<{ slug: string; path?: string[] }>; +}; + +// Keyed by partner-portal-view.tsx's `partnerViewPaths` segments. Not +// imported directly — that file is a "use client" module, so its exports +// can't be pulled into this server-only generateMetadata. +const TITLES: Record = { + "sales-enablement": "Sales enablement", + "tech-enablement": "Tech enablement", + "reusable-content": "Reusable content", + submissions: "Submissions", + "arr-tracker": "ARR tracker", +}; + +// Sections with a child page per CMS resource (`
/`). +const RESOURCE_SECTIONS = new Set([ + "sales-enablement", + "tech-enablement", + "reusable-content", +]); + +function isValidPath(path?: string[]) { + if (!path) return true; + const [section = "", resource, ...rest] = path; + if (!TITLES[section] || rest.length > 0) return false; + return !resource || RESOURCE_SECTIONS.has(section); +} + +/** The organization, only if it's flagged as a partner in the CMS. */ +async function findPartnerOrganization(slug: string) { + const { data } = await cmsClient.collection(contentType).find({ + filters: { slug: { $eq: slug }, partner: { $eq: true } }, + fields: ["name"], + } satisfies GetQueryParams); + + return data[0]; +} + +export async function generateMetadata({ params }: Props): Promise { + const { slug, path } = await params; + const label = (path?.[0] && TITLES[path[0]]) || "Partner portal"; + + const organization = await findPartnerOrganization(slug); + + return { + title: organization ? `${label} · ${organization.name}` : label, + robots: { index: false }, + }; +} + +export default async function PartnerPortalPage({ params }: Props) { + if (!isAuthEnabled) notFound(); + + const { slug, path } = await params; + if (!isValidPath(path)) notFound(); + + // Server-side, this only proves the organization is a partner — whether + // the signed-in user is one of its owners/admins is checked client-side + // by `PartnerPortalView` (the better-auth session cookie lives on the + // CMS's origin, not this one). + if (!(await findPartnerOrganization(slug))) notFound(); + + const pathname = path + ? `/partner/${slug}/${path.join("/")}` + : `/partner/${slug}`; + + return ( + <> + +
+ +
+ + ); +} diff --git a/apps/web/src/features/auth/components/auth-navigation/auth-navigation.tsx b/apps/web/src/features/auth/components/auth-navigation/auth-navigation.tsx index 04e3d177..231f088f 100644 --- a/apps/web/src/features/auth/components/auth-navigation/auth-navigation.tsx +++ b/apps/web/src/features/auth/components/auth-navigation/auth-navigation.tsx @@ -2,9 +2,10 @@ import { SignedIn, SignedOut, UserButton } from "@daveyplate/better-auth-ui"; import { Button } from "@repo/strapi-ui"; -import { UserIcon } from "lucide-react"; +import { HandshakeIcon, UserIcon } from "lucide-react"; import Link from "next/link"; import { authClient } from "@/features/auth/lib/client"; +import { usePartnerOrganizations } from "@/features/partner-portal/lib/partner-organizations"; type Props = { theme: "light" | "dark"; @@ -19,6 +20,20 @@ export function AuthNavigation({ theme }: Props) { const { data: session } = authClient.useSession(); const slug = (session?.user as { slug?: string } | undefined)?.slug; + // One entry per partner organization the user can manage — normally + // just one, but the portal is organization-scoped, so name the org when + // there's more than one to tell them apart. + const { organizations: partnerOrganizations } = usePartnerOrganizations(); + const partnerLinks = partnerOrganizations.map((organization) => ({ + href: `/partner/${organization.slug}`, + icon: , + label: + partnerOrganizations.length > 1 + ? `Partner portal · ${organization.name}` + : "Partner portal", + signedIn: true, + })); + return ( <> @@ -35,8 +50,8 @@ export function AuthNavigation({ theme }: Props) { diff --git a/apps/web/src/features/auth/components/organization-view/organization-view.tsx b/apps/web/src/features/auth/components/organization-view/organization-view.tsx index a7743423..44c460de 100644 --- a/apps/web/src/features/auth/components/organization-view/organization-view.tsx +++ b/apps/web/src/features/auth/components/organization-view/organization-view.tsx @@ -13,8 +13,9 @@ import { useAuthenticate, useCurrentOrganization, } from "@daveyplate/better-auth-ui"; -import { ArrowUpRightIcon, MenuIcon, XIcon } from "lucide-react"; +import { ArrowUpRightIcon, HandshakeIcon, MenuIcon, XIcon } from "lucide-react"; import { useContext, useEffect, useMemo, useState } from "react"; +import { usePartnerOrganizations } from "@/features/partner-portal/lib/partner-organizations"; import { SubmissionsView } from "@/features/submissions/components/submissions-view"; import type { SubmissionCategory, @@ -110,6 +111,27 @@ export function OrganizationView({ isRefetching: organizationRefetching, } = useCurrentOrganization({ slug }); + // Only owners/admins of a partner organization get a partner portal — + // the same list (and gating) the user menu's "Partner portal" links use. + const { organizations: partnerOrganizations } = usePartnerOrganizations(); + const hasPartnerPortal = partnerOrganizations.some( + (partnerOrganization) => partnerOrganization.slug === slug, + ); + + const partnerPortalLink = (className?: string) => + hasPartnerPortal && ( + + + Partner portal + + ); + const navItems = useMemo(() => { const items: { view: View; label: string }[] = [ { view: "SETTINGS", label: "Settings" }, @@ -212,10 +234,16 @@ export function OrganizationView({ + {partnerPortalLink("md:hidden")} +
{identity("lg")} - +
+ {partnerPortalLink()} + + +
diff --git a/apps/web/src/features/partner-portal/components/organization-mark.tsx b/apps/web/src/features/partner-portal/components/organization-mark.tsx new file mode 100644 index 00000000..e7835172 --- /dev/null +++ b/apps/web/src/features/partner-portal/components/organization-mark.tsx @@ -0,0 +1,41 @@ +import Image from "next/image"; +import { cmsImageUrl } from "@/features/cms/lib/image-url"; +import { cn } from "@/lib/utils"; +import type { PartnerOrganization } from "../lib/partner-organizations"; + +/** The organization's logo, falling back to its initials. */ +export function OrganizationMark({ + organization, + size = "default", +}: { + organization: Pick; + size?: "default" | "lg"; +}) { + const className = cn( + "relative flex shrink-0 items-center justify-center overflow-hidden rounded-md bg-(--color-primary100) font-semibold text-(--color-primary600)", + size === "lg" ? "size-12 text-base" : "size-10 text-sm", + ); + + if (organization.logo) { + return ( + + + + ); + } + + const initials = organization.name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((word) => word[0]?.toUpperCase()) + .join(""); + + return {initials}; +} diff --git a/apps/web/src/features/partner-portal/components/partner-portal-view/index.ts b/apps/web/src/features/partner-portal/components/partner-portal-view/index.ts new file mode 100644 index 00000000..080ad81a --- /dev/null +++ b/apps/web/src/features/partner-portal/components/partner-portal-view/index.ts @@ -0,0 +1 @@ +export * from "./partner-portal-view"; diff --git a/apps/web/src/features/partner-portal/components/partner-portal-view/partner-portal-view.tsx b/apps/web/src/features/partner-portal/components/partner-portal-view/partner-portal-view.tsx new file mode 100644 index 00000000..e2d41d5a --- /dev/null +++ b/apps/web/src/features/partner-portal/components/partner-portal-view/partner-portal-view.tsx @@ -0,0 +1,378 @@ +"use client"; + +import { useAuthenticate } from "@daveyplate/better-auth-ui"; +import { + BadgeCheckIcon, + ChartLineIcon, + CodeXmlIcon, + FolderIcon, + IdCardIcon, + LockIcon, + type LucideIcon, + MenuIcon, + PresentationIcon, + SendIcon, + SettingsIcon, + XIcon, +} from "lucide-react"; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { cn } from "@/lib/utils"; +import { + meetsLevel, + type PartnerLevel, + type PartnerSection, + SECTION_MINIMUM_LEVEL, +} from "../../lib/levels"; +import { + type PartnerOrganization, + usePartnerOrganizations, +} from "../../lib/partner-organizations"; +import { + type PartnerResourceSummary, + usePartnerPortal, +} from "../../lib/partner-portal"; +import { OrganizationMark } from "../organization-mark"; +import { ComingSoonSection } from "../sections/coming-soon-section"; +import { LockedSection } from "../sections/locked-section"; +import { ProgramInfoSection } from "../sections/program-info-section"; +import { ResourceDetail } from "../sections/resource-detail"; +import { ResourceSection } from "../sections/resource-section"; +import { ReusableContentSection } from "../sections/reusable-content-section"; + +type View = + | "PROGRAM" + | "SALES" + | "TECH" + | "CONTENT" + | "SUBMISSIONS" + | "ARR_TRACKER"; + +/** + * URL segment per view (`/partner//`); `PROGRAM` is the + * index. Resource sections also have a child page per resource + * (`/partner///`). + */ +export const partnerViewPaths: Record = { + PROGRAM: "", + SALES: "sales-enablement", + TECH: "tech-enablement", + CONTENT: "reusable-content", + SUBMISSIONS: "submissions", + ARR_TRACKER: "arr-tracker", +}; + +const NAV_ITEMS: { view: View; label: string; icon: LucideIcon }[] = [ + { view: "PROGRAM", label: "Program information", icon: IdCardIcon }, + { view: "SALES", label: "Sales enablement", icon: PresentationIcon }, + { view: "TECH", label: "Tech enablement", icon: CodeXmlIcon }, + { view: "CONTENT", label: "Reusable content", icon: FolderIcon }, + { view: "SUBMISSIONS", label: "Submissions", icon: SendIcon }, + { view: "ARR_TRACKER", label: "ARR tracker", icon: ChartLineIcon }, +]; + +/** The views backed by CMS `partner-resource` entries, by their `section`. */ +const RESOURCE_SECTIONS: Partial< + Record +> = { + SALES: { + section: "sales_enablement", + description: + "Everything you need to position and sell Strapi to your clients.", + }, + TECH: { + section: "tech_enablement", + description: + "Training, certification and technical resources for your delivery team.", + }, + CONTENT: { + section: "reusable_content", + description: + "Approved assets to use in your own marketing and client pitches.", + }, +}; + +/** Views planned for a later phase, shown as placeholders for now. */ +const LATER_SECTIONS: Partial< + Record +> = { + SUBMISSIONS: { + description: "Register deals and send requests to the partner team.", + badge: "Coming later", + }, + ARR_TRACKER: { + description: + "Track the annual recurring revenue you generate with Strapi, by client and over time.", + badge: "Not before Q2 2027", + }, +}; + +type Props = { + slug: string; + pathname: string; +}; + +/** + * An organization-scoped partner portal — same sidebar + content layout as + * `OrganizationView`/`AccountView`, but only for owners/admins of an + * organization flagged as `partner` in the CMS. The route itself already + * 404s for non-partner organizations; this additionally checks the + * signed-in user's own membership via `GET /organizations/partners`, and + * loads the gated content (filtered by partner level in the CMS) via + * `GET /organizations/:id/partner-portal`. + */ +export function PartnerPortalView({ slug, pathname }: Props) { + useAuthenticate(); + + const { organizations, isPending } = usePartnerOrganizations(); + const organization = organizations.find((org) => org.slug === slug); + + const [mobileNavOpen, setMobileNavOpen] = useState(false); + + const [segment = "", resourceSlug] = pathname + .split("/") + .filter(Boolean) + .slice(2); + const view = + (Object.entries(partnerViewPaths).find( + ([, path]) => path === segment, + )?.[0] as View | undefined) ?? "PROGRAM"; + + // Close the mobile nav once navigating to a tab actually lands on it. + useEffect(() => { + if (view) setMobileNavOpen(false); + }, [view]); + + if (isPending) { + return ( +
+
+
+
+ ); + } + + if (!organization) { + return ( +
+

+ No access to this partner portal +

+

+ The partner portal is available to owners and admins of partner + organizations. +

+ + Back to your account + +
+ ); + } + + const level: PartnerLevel = organization.partner_level ?? "Unlisted"; + + const navHref = (item: { view: View }) => + [`/partner/${slug}`, partnerViewPaths[item.view]].filter(Boolean).join("/"); + + const isLocked = (item: { view: View }) => { + const section = RESOURCE_SECTIONS[item.view]?.section; + return !!section && !meetsLevel(level, SECTION_MINIMUM_LEVEL[section]); + }; + + const navItemClassName = (item: { view: View }) => + cn( + "flex w-full items-center gap-2 rounded-md px-4 py-2 text-left text-sm font-medium transition-colors [&_svg]:size-4 [&_svg]:shrink-0", + view === item.view + ? "bg-(--color-neutral100) font-semibold text-(--color-neutral900)" + : "text-(--color-neutral700) hover:bg-(--color-neutral100)", + ); + + const navLinks = NAV_ITEMS.map((item) => ( + + + {item.label} + {isLocked(item) && ( + + )} + {LATER_SECTIONS[item.view] && ( + + Later + + )} + + )); + + const currentItem = NAV_ITEMS.find((item) => item.view === view); + + return ( +
+
+
+ +
+ + Partner portal + + + {organization.name} + +
+
+ +
+ + + {level} partner + + + + Organization settings + +
+
+ +
+ {/* Mobile: current view label + toggle for the nav list below it. */} +
+ {currentItem?.label} + + +
+ + {mobileNavOpen && ( + + )} + + {/* Desktop: a persistent sidebar, like the settings pages. */} + + +
+ {currentItem && LATER_SECTIONS[view] ? ( + + ) : isLocked({ view }) ? ( + + ) : ( + + )} +
+
+
+ ); +} + +/** The views rendered from the CMS's gated partner content. */ +function PortalContent({ + organization, + level, + view, + title, + sectionHref, + resourceSlug, +}: { + organization: PartnerOrganization; + level: PartnerLevel; + view: View; + title: string; + sectionHref: string; + resourceSlug?: string; +}) { + const { data: portal, isPending } = usePartnerPortal(organization.id); + const resourceSection = RESOURCE_SECTIONS[view]; + + if (resourceSection && resourceSlug) { + return ( + + ); + } + + if (isPending) { + return ( +
+ ); + } + + if (!portal) { + return ( +

+ The partner portal couldn't be loaded. Please try again later. +

+ ); + } + + if (!resourceSection) { + return ( + + ); + } + + const resources = portal.resources.filter( + (resource) => resource.section === resourceSection.section, + ); + const hrefFor = (resource: PartnerResourceSummary) => + `${sectionHref}/${resource.slug}`; + + if (view === "CONTENT") { + return ; + } + + return ( + + ); +} diff --git a/apps/web/src/features/partner-portal/components/sections/coming-soon-section.tsx b/apps/web/src/features/partner-portal/components/sections/coming-soon-section.tsx new file mode 100644 index 00000000..ade6964a --- /dev/null +++ b/apps/web/src/features/partner-portal/components/sections/coming-soon-section.tsx @@ -0,0 +1,29 @@ +import type { LucideIcon } from "lucide-react"; +import { Eyebrow } from "./shared"; + +export function ComingSoonSection({ + title, + description, + badge, + icon: Icon, +}: { + title: string; + description: string; + badge: string; + icon: LucideIcon; +}) { + return ( +
+ + + + {badge} +

+ {title} +

+

+ {description} +

+
+ ); +} diff --git a/apps/web/src/features/partner-portal/components/sections/locked-section.tsx b/apps/web/src/features/partner-portal/components/sections/locked-section.tsx new file mode 100644 index 00000000..c0bba96d --- /dev/null +++ b/apps/web/src/features/partner-portal/components/sections/locked-section.tsx @@ -0,0 +1,28 @@ +import { LockIcon } from "lucide-react"; +import type { PartnerLevel } from "../../lib/levels"; +import { Eyebrow } from "./shared"; + +export function LockedSection({ + title, + description, + minimumLevel, +}: { + title: string; + description: string; + minimumLevel: PartnerLevel; +}) { + return ( +
+ + + + Available from {minimumLevel} level +

+ {title} +

+

+ {description} +

+
+ ); +} diff --git a/apps/web/src/features/partner-portal/components/sections/program-info-section.tsx b/apps/web/src/features/partner-portal/components/sections/program-info-section.tsx new file mode 100644 index 00000000..171c403a --- /dev/null +++ b/apps/web/src/features/partner-portal/components/sections/program-info-section.tsx @@ -0,0 +1,167 @@ +import { CheckIcon, LayoutGridIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { PARTNER_LEVELS, type PartnerLevel } from "../../lib/levels"; +import type { PartnerOrganization } from "../../lib/partner-organizations"; +import type { PartnerBenefit } from "../../lib/partner-portal"; +import { OrganizationMark } from "../organization-mark"; +import { cardClassName, Eyebrow, SectionHeader } from "./shared"; + +function DetailRow({ + label, + value, +}: { + label: string; + value: React.ReactNode; +}) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +export function ProgramInfoSection({ + organization, + level, + benefits, +}: { + organization: PartnerOrganization; + level: PartnerLevel; + benefits: PartnerBenefit[]; +}) { + const levelIndex = PARTNER_LEVELS.indexOf(level); + const website = organization.profile?.website; + const memberSince = new Date(organization.createdAt).toLocaleDateString( + "en-US", + { month: "long", year: "numeric" }, + ); + + return ( +
+ + +
+
+
+ +
+

+ {organization.name} +

+

+ On Strapi since {memberSince} +

+
+
+ +
+ + {organization.profile?.location && ( + + )} + {website && ( + + {website.replace(/^https?:\/\//, "").replace(/\/$/, "")} + + } + /> + )} + +
+
+ +
+
+ Your partner level +
    + {PARTNER_LEVELS.map((step, index) => ( +
  1. + + + {step} + +
  2. + ))} +
+
+ +
+ Your benefits + {benefits.length === 0 ? ( +

+ {level === "Unlisted" + ? "Reach Community level to unlock sales and tech enablement." + : "No benefits listed for this level yet."} +

+ ) : ( +
    + {benefits.map((benefit) => ( +
  • + + + + {benefit.title} + + {benefit.description && ( + + {benefit.description} + + )} + +
  • + ))} +
+ )} +
+
+
+ +
+
+ +
+

+ Project tracker +

+

+ Track active Strapi client projects. Planned for a later phase. +

+
+
+ Later +
+
+ ); +} diff --git a/apps/web/src/features/partner-portal/components/sections/resource-detail.tsx b/apps/web/src/features/partner-portal/components/sections/resource-detail.tsx new file mode 100644 index 00000000..4f30ab07 --- /dev/null +++ b/apps/web/src/features/partner-portal/components/sections/resource-detail.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { ArrowLeftIcon } from "lucide-react"; +import Link from "next/link"; +import { Markdown } from "@/components/content/markdown"; +import { cn } from "@/lib/utils"; +import { usePartnerResource } from "../../lib/partner-portal"; +import { + ResourceIcon, + ResourceTargetLabel, + resourceTarget, +} from "./resource-section"; +import { cardClassName, Eyebrow } from "./shared"; + +/** A partner resource's own page — the "child" of its section. */ +export function ResourceDetail({ + organizationId, + slug, + backHref, + backLabel, +}: { + organizationId: number; + slug: string; + backHref: string; + backLabel: string; +}) { + const { data: resource, isPending } = usePartnerResource( + organizationId, + slug, + ); + + const back = ( + + + {backLabel} + + ); + + if (isPending) { + return ( +
+ {back} +
+
+ ); + } + + if (!resource) { + return ( +
+ {back} +

+ This resource doesn't exist or isn't available at your partner level. +

+
+ ); + } + + // Already on the resource's page — only its file or link is left to offer. + const target = resourceTarget({ ...resource, has_content: false }, ""); + + return ( +
+ {back} + +
+
+ + + +
+ {resource.label && {resource.label}} +

+ {resource.title} +

+ {resource.description && ( +

+ {resource.description} +

+ )} +
+ {target && ( + + + + )} +
+ + {resource.content && } +
+
+ ); +} diff --git a/apps/web/src/features/partner-portal/components/sections/resource-section.tsx b/apps/web/src/features/partner-portal/components/sections/resource-section.tsx new file mode 100644 index 00000000..3a7ec441 --- /dev/null +++ b/apps/web/src/features/partner-portal/components/sections/resource-section.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { ArrowUpRightIcon, DownloadIcon, FileTextIcon } from "lucide-react"; +import { DynamicIcon, type IconName } from "lucide-react/dynamic"; +import Link from "next/link"; +import { cmsImageUrl } from "@/features/cms/lib/image-url"; +import { cn } from "@/lib/utils"; +import type { PartnerResourceSummary } from "../../lib/partner-portal"; +import { cardClassName, Eyebrow, SectionHeader } from "./shared"; + +/** The resource's icon, as picked with the CMS's lucide icon field. */ +export function ResourceIcon({ + name, + className, +}: { + name: string | null; + className?: string; +}) { + if (!name) return ; + + return ( + } + /> + ); +} + +/** + * Where a resource card leads: its own page when it has a body, + * otherwise straight to its file or external link. + */ +export function resourceTarget( + resource: PartnerResourceSummary, + pageHref: string, +) { + if (resource.has_content) return { href: pageHref, kind: "page" as const }; + if (resource.file) { + return { href: cmsImageUrl(resource.file.url), kind: "file" as const }; + } + if (resource.link) return { href: resource.link, kind: "link" as const }; + return null; +} + +export function ResourceTargetLabel({ + kind, +}: { + kind: "page" | "file" | "link"; +}) { + if (kind === "file") { + return ( + <> + + Download + + ); + } + + return ( + <> + {kind === "page" ? "Read more" : "Open"} + + + ); +} + +function ResourceCard({ + resource, + href, +}: { + resource: PartnerResourceSummary; + href: string; +}) { + const target = resourceTarget(resource, href); + + const body = ( + <> + + + +

+ {resource.title} +

+ {resource.description && ( +

+ {resource.description} +

+ )} +
+ {resource.label ? {resource.label} : } + {target && ( + + + + )} +
+ + ); + + const className = cn( + cardClassName, + "flex flex-col gap-3 transition-colors", + target && "hover:border-(--color-primary200)", + ); + + if (!target) return
{body}
; + + if (target.kind === "page") { + return ( + + {body} + + ); + } + + return ( + + {body} + + ); +} + +export function ResourceGrid({ + resources, + hrefFor, +}: { + resources: PartnerResourceSummary[]; + hrefFor: (resource: PartnerResourceSummary) => string; +}) { + if (resources.length === 0) { + return ( +

+ Nothing here yet — check back soon. +

+ ); + } + + return ( +
+ {resources.map((resource) => ( + + ))} +
+ ); +} + +export function ResourceSection({ + title, + description, + resources, + hrefFor, +}: { + title: string; + description: string; + resources: PartnerResourceSummary[]; + hrefFor: (resource: PartnerResourceSummary) => string; +}) { + return ( +
+ + +
+ ); +} diff --git a/apps/web/src/features/partner-portal/components/sections/reusable-content-section.tsx b/apps/web/src/features/partner-portal/components/sections/reusable-content-section.tsx new file mode 100644 index 00000000..4bf7b67b --- /dev/null +++ b/apps/web/src/features/partner-portal/components/sections/reusable-content-section.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { SearchIcon } from "lucide-react"; +import { useState } from "react"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import type { PartnerResourceSummary } from "../../lib/partner-portal"; +import { ResourceGrid } from "./resource-section"; +import { SectionHeader } from "./shared"; + +const ALL = "All"; + +export function ReusableContentSection({ + resources, + hrefFor, +}: { + resources: PartnerResourceSummary[]; + hrefFor: (resource: PartnerResourceSummary) => string; +}) { + const [filter, setFilter] = useState(ALL); + const [query, setQuery] = useState(""); + + // Filter chips come from the categories editors actually use. + const categories = [ + ...new Set( + resources + .map((resource) => resource.category) + .filter((category): category is string => !!category), + ), + ]; + + const normalizedQuery = query.trim().toLowerCase(); + const filtered = resources.filter( + (resource) => + (filter === ALL || resource.category === filter) && + (!normalizedQuery || + resource.title.toLowerCase().includes(normalizedQuery) || + resource.description?.toLowerCase().includes(normalizedQuery)), + ); + + return ( +
+ + {resources.length > 0 && ( +
+ + setQuery(event.target.value)} + placeholder="Search assets" + aria-label="Search assets" + className="pl-9" + /> +
+ )} +
+ + {categories.length > 1 && ( +
+ {[ALL, ...categories].map((category) => ( + + ))} +
+ )} + + {resources.length > 0 && filtered.length === 0 ? ( +

+ No assets match your search. +

+ ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/features/partner-portal/components/sections/shared.tsx b/apps/web/src/features/partner-portal/components/sections/shared.tsx new file mode 100644 index 00000000..7129afff --- /dev/null +++ b/apps/web/src/features/partner-portal/components/sections/shared.tsx @@ -0,0 +1,46 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +export const cardClassName = + "w-full min-w-0 rounded-md border border-(--color-neutral150) bg-white p-6 shadow-sm"; + +export function SectionHeader({ + title, + description, + children, +}: { + title: string; + description: string; + children?: ReactNode; +}) { + return ( +
+
+

+ {title} +

+

{description}

+
+ {children} +
+ ); +} + +export function Eyebrow({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} diff --git a/apps/web/src/features/partner-portal/lib/levels.ts b/apps/web/src/features/partner-portal/lib/levels.ts new file mode 100644 index 00000000..27ceb70c --- /dev/null +++ b/apps/web/src/features/partner-portal/lib/levels.ts @@ -0,0 +1,31 @@ +/** + * Partner levels, lowest first. An organization's `partner_level` is one + * of the last three; an empty level means "Unlisted". + * + * Mirrored from apps/cms/src/extensions/better-auth/utils/partner-levels.ts, + * which is what actually filters the gated content — the rules here only + * decide which locked states to show. Keep the two in sync. + */ +export const PARTNER_LEVELS = [ + "Unlisted", + "Community", + "Business", + "Enterprise", +] as const; + +export type PartnerLevel = (typeof PARTNER_LEVELS)[number]; + +export type PartnerSection = + | "sales_enablement" + | "tech_enablement" + | "reusable_content"; + +export const SECTION_MINIMUM_LEVEL: Record = { + sales_enablement: "Community", + tech_enablement: "Community", + reusable_content: "Unlisted", +}; + +export function meetsLevel(level: PartnerLevel, minimum: PartnerLevel) { + return PARTNER_LEVELS.indexOf(level) >= PARTNER_LEVELS.indexOf(minimum); +} diff --git a/apps/web/src/features/partner-portal/lib/partner-organizations.ts b/apps/web/src/features/partner-portal/lib/partner-organizations.ts new file mode 100644 index 00000000..baee5efe --- /dev/null +++ b/apps/web/src/features/partner-portal/lib/partner-organizations.ts @@ -0,0 +1,72 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { authClient } from "@/features/auth/lib/client"; +import type { PartnerLevel } from "./levels"; + +const CMS_URL = process.env.NEXT_PUBLIC_CMS_URL; + +export type PartnerOrganization = { + id: number; + name: string; + slug: string; + logo: string | null; + /** Empty means "Unlisted". */ + partner_level: Exclude | null; + createdAt: string; + profile: { website?: string | null; location?: string | null } | null; +}; + +/** + * GET /api/organizations/partners — partner organizations the signed-in + * user is an owner/admin of, i.e. whose partner portal they can open. + * Called with the browser's better-auth session cookie, same pattern as + * `features/submit/lib/get-owner-options.ts`. + */ +export async function getPartnerOrganizations(): Promise< + PartnerOrganization[] +> { + try { + const res = await fetch(`${CMS_URL}/api/organizations/partners`, { + credentials: "include", + }); + if (!res.ok) return []; + return (await res.json()) as PartnerOrganization[]; + } catch { + return []; + } +} + +/** + * The signed-in user's partner organizations, refetched whenever the + * session's user changes (sign-in, sign-out, account switch). + */ +export function usePartnerOrganizations() { + const { data: session, isPending: sessionPending } = authClient.useSession(); + const userId = session?.user?.id; + + const [state, setState] = useState<{ + userId?: string; + organizations: PartnerOrganization[]; + }>({ organizations: [] }); + + useEffect(() => { + if (!userId) return; + + let cancelled = false; + getPartnerOrganizations().then((organizations) => { + if (!cancelled) setState({ userId, organizations }); + }); + + return () => { + cancelled = true; + }; + }, [userId]); + + const isCurrent = !!userId && state.userId === userId; + + return { + organizations: isCurrent ? state.organizations : [], + isPending: sessionPending || (!!userId && !isCurrent), + }; +} diff --git a/apps/web/src/features/partner-portal/lib/partner-portal.ts b/apps/web/src/features/partner-portal/lib/partner-portal.ts new file mode 100644 index 00000000..0b0e4573 --- /dev/null +++ b/apps/web/src/features/partner-portal/lib/partner-portal.ts @@ -0,0 +1,113 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { PartnerLevel, PartnerSection } from "./levels"; + +const CMS_URL = process.env.NEXT_PUBLIC_CMS_URL; + +export type PartnerBenefit = { + id: number; + level: PartnerLevel; + title: string; + description: string | null; +}; + +export type PartnerResource = { + documentId: string; + title: string; + slug: string; + description: string | null; + section: PartnerSection; + minimum_level: PartnerLevel; + category: string | null; + label: string | null; + icon: string | null; + link: string | null; + file: { url: string; name: string; ext: string; size: number } | null; +}; + +export type PartnerResourceSummary = PartnerResource & { + has_content: boolean; +}; + +export type PartnerPortalContent = { + level: PartnerLevel; + benefits: PartnerBenefit[]; + resources: PartnerResourceSummary[]; +}; + +/** + * GET /api/organizations/:id/partner-portal — the portal's gated content, + * already filtered by the organization's partner level. Owners/admins + * only, called with the browser's better-auth session cookie. + */ +async function getPartnerPortal( + organizationId: number, +): Promise { + try { + const res = await fetch( + `${CMS_URL}/api/organizations/${organizationId}/partner-portal`, + { credentials: "include" }, + ); + if (!res.ok) return null; + return (await res.json()) as PartnerPortalContent; + } catch { + return null; + } +} + +/** GET /api/organizations/:id/partner-portal/resources/:slug — one resource, with its body. */ +async function getPartnerResource( + organizationId: number, + slug: string, +): Promise<(PartnerResource & { content: string | null }) | null> { + try { + const res = await fetch( + `${CMS_URL}/api/organizations/${organizationId}/partner-portal/resources/${encodeURIComponent(slug)}`, + { credentials: "include" }, + ); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + +/** Loads `load()` whenever `key` changes, dropping stale results. */ +function useLoader(key: string | undefined, load: () => Promise) { + const [state, setState] = useState<{ key?: string; data?: T }>({}); + + // biome-ignore lint/correctness/useExhaustiveDependencies: `key` identifies `load`'s inputs. + useEffect(() => { + if (!key) return; + + let cancelled = false; + load().then((data) => { + if (!cancelled) setState({ key, data }); + }); + + return () => { + cancelled = true; + }; + }, [key]); + + const isCurrent = !!key && state.key === key; + + return { + data: isCurrent ? state.data : undefined, + isPending: !isCurrent, + }; +} + +export function usePartnerPortal(organizationId?: number) { + return useLoader( + organizationId != null ? String(organizationId) : undefined, + () => getPartnerPortal(organizationId as number), + ); +} + +export function usePartnerResource(organizationId: number, slug: string) { + return useLoader(`${organizationId}/${slug}`, () => + getPartnerResource(organizationId, slug), + ); +} From df7995e8bd6940904117015cdb84cbd6ffc0a9f8 Mon Sep 17 00:00:00 2001 From: Boaz Poolman Date: Thu, 22 Oct 2026 13:31:03 +0200 Subject: [PATCH 2/4] chore: remove all credential accounts --- apps/cms/src/index.ts | 2 + apps/cms/src/migration/credential-accounts.ts | 40 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 apps/cms/src/migration/credential-accounts.ts diff --git a/apps/cms/src/index.ts b/apps/cms/src/index.ts index ff9d104c..bf7ea973 100644 --- a/apps/cms/src/index.ts +++ b/apps/cms/src/index.ts @@ -1,5 +1,6 @@ import type { Core } from "@strapi/strapi"; import { migrateAccountAccessEmail } from "./migration/account-access-email"; +import { migrateCredentialAccounts } from "./migration/credential-accounts"; import { migrateReadmeAutoSync } from "./migration/readme-auto-sync"; import { migrateUserProfilePictures } from "./migration/user-profile-pictures"; import { migrateUserSlugs } from "./migration/user-slugs"; @@ -18,6 +19,7 @@ export default { await migrateUserProfilePictures(); await migrateUserSlugs(); await migrateReadmeAutoSync(); + await migrateCredentialAccounts(); // await migrateAccountAccessEmail(); }, }; diff --git a/apps/cms/src/migration/credential-accounts.ts b/apps/cms/src/migration/credential-accounts.ts new file mode 100644 index 00000000..5aa7b4b9 --- /dev/null +++ b/apps/cms/src/migration/credential-accounts.ts @@ -0,0 +1,40 @@ +/** + * Removes every email/password ("credential") account from every user, so + * nobody can sign in with a password anymore. Accounts linked to other + * providers (GitHub, etc.) are left untouched, as are the users themselves — + * they can still sign in through magic link or a linked social provider. + * + * Idempotent: once deleted, there are no credential accounts left to find, + * so re-running it on the next boot is a no-op. + */ +export const migrateCredentialAccounts = async () => { + strapi.log.info("Starting credential accounts migration..."); + let deleted = 0; + let failed = 0; + + const accounts = await strapi + .documents("plugin::better-auth.account") + .findMany({ + fields: ["documentId", "userId"], + filters: { providerId: "credential" }, + }); + + for (const account of accounts) { + try { + await strapi + .documents("plugin::better-auth.account") + .delete({ documentId: account.documentId }); + deleted++; + } catch (error) { + failed++; + strapi.log.error( + `Error deleting credential account for user ${account.userId}:`, + error, + ); + } + } + + strapi.log.info( + `Credential accounts migration finished. Deleted: ${deleted}, Failed: ${failed}`, + ); +}; From 7ea1eb70d64a53af19589b4311bbc5a435f5531a Mon Sep 17 00:00:00 2001 From: Boaz Poolman Date: Thu, 22 Oct 2026 13:39:33 +0200 Subject: [PATCH 3/4] chore: enable account access emails --- apps/cms/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cms/src/index.ts b/apps/cms/src/index.ts index bf7ea973..eefe61b7 100644 --- a/apps/cms/src/index.ts +++ b/apps/cms/src/index.ts @@ -20,6 +20,6 @@ export default { await migrateUserSlugs(); await migrateReadmeAutoSync(); await migrateCredentialAccounts(); - // await migrateAccountAccessEmail(); + await migrateAccountAccessEmail(); }, }; From b8ec5c8d356c2934de4e39b611cf3689ee5a53d7 Mon Sep 17 00:00:00 2001 From: Boaz Poolman Date: Thu, 22 Oct 2026 13:46:50 +0200 Subject: [PATCH 4/4] chore: remove old migration scripts --- apps/cms/src/index.ts | 8 --- .../cms/src/migration/account-access-email.ts | 56 ----------------- apps/cms/src/migration/credential-accounts.ts | 40 ------------- apps/cms/src/migration/readme-auto-sync.ts | 49 --------------- apps/cms/src/migration/user-slugs.ts | 60 ------------------- 5 files changed, 213 deletions(-) delete mode 100644 apps/cms/src/migration/account-access-email.ts delete mode 100644 apps/cms/src/migration/credential-accounts.ts delete mode 100644 apps/cms/src/migration/readme-auto-sync.ts delete mode 100644 apps/cms/src/migration/user-slugs.ts diff --git a/apps/cms/src/index.ts b/apps/cms/src/index.ts index eefe61b7..e17cf3ad 100644 --- a/apps/cms/src/index.ts +++ b/apps/cms/src/index.ts @@ -1,9 +1,5 @@ import type { Core } from "@strapi/strapi"; -import { migrateAccountAccessEmail } from "./migration/account-access-email"; -import { migrateCredentialAccounts } from "./migration/credential-accounts"; -import { migrateReadmeAutoSync } from "./migration/readme-auto-sync"; import { migrateUserProfilePictures } from "./migration/user-profile-pictures"; -import { migrateUserSlugs } from "./migration/user-slugs"; import { seedEmailTemplates } from "./seed/email-templates"; export default { @@ -17,9 +13,5 @@ export default { } await migrateUserProfilePictures(); - await migrateUserSlugs(); - await migrateReadmeAutoSync(); - await migrateCredentialAccounts(); - await migrateAccountAccessEmail(); }, }; diff --git a/apps/cms/src/migration/account-access-email.ts b/apps/cms/src/migration/account-access-email.ts deleted file mode 100644 index 1c470e25..00000000 --- a/apps/cms/src/migration/account-access-email.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { sendAccountAccessEmail } from "../lib/email"; - -const STORE_KEY = "account-access-email-sent"; - -/** - * Sends the one-off "you have an account" email (see - * `src/lib/account-access-email.tsx`) to every existing user, pointing them - * to the magic-link page so they can sign in to an account that was created - * for them by a past submission. - * - * Migrations run on every boot while ENABLE_MIGRATION=true, so the ids of - * users that were already emailed are persisted in the core store after each - * send. Re-running the migration (or resuming after a crash) only emails the - * users that haven't received it yet. - */ -export const migrateAccountAccessEmail = async () => { - strapi.log.info("Starting account access email migration..."); - let sent = 0; - let skipped = 0; - let failed = 0; - - const store = strapi.store({ type: "core", name: "migration" }); - const sentIds = new Set( - ((await store.get({ key: STORE_KEY })) as string[] | null) ?? [], - ); - const url = `${process.env.WEBSITE_URL}/auth/magic-link`; - - const users = await strapi.documents("plugin::better-auth.user").findMany({ - fields: ["documentId", "email"], - filters: { email: { $notNull: true } }, - }); - - for (const user of users) { - if (sentIds.has(user.documentId)) { - skipped++; - continue; - } - - try { - await sendAccountAccessEmail(user.email, url); - sentIds.add(user.documentId); - await store.set({ key: STORE_KEY, value: [...sentIds] }); - sent++; - } catch (error) { - failed++; - strapi.log.error( - `Error sending account access email to user ${user.documentId}:`, - error, - ); - } - } - - strapi.log.info( - `Account access email migration finished. Sent: ${sent}, Skipped (already sent): ${skipped}, Failed: ${failed}`, - ); -}; diff --git a/apps/cms/src/migration/credential-accounts.ts b/apps/cms/src/migration/credential-accounts.ts deleted file mode 100644 index 5aa7b4b9..00000000 --- a/apps/cms/src/migration/credential-accounts.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Removes every email/password ("credential") account from every user, so - * nobody can sign in with a password anymore. Accounts linked to other - * providers (GitHub, etc.) are left untouched, as are the users themselves — - * they can still sign in through magic link or a linked social provider. - * - * Idempotent: once deleted, there are no credential accounts left to find, - * so re-running it on the next boot is a no-op. - */ -export const migrateCredentialAccounts = async () => { - strapi.log.info("Starting credential accounts migration..."); - let deleted = 0; - let failed = 0; - - const accounts = await strapi - .documents("plugin::better-auth.account") - .findMany({ - fields: ["documentId", "userId"], - filters: { providerId: "credential" }, - }); - - for (const account of accounts) { - try { - await strapi - .documents("plugin::better-auth.account") - .delete({ documentId: account.documentId }); - deleted++; - } catch (error) { - failed++; - strapi.log.error( - `Error deleting credential account for user ${account.userId}:`, - error, - ); - } - } - - strapi.log.info( - `Credential accounts migration finished. Deleted: ${deleted}, Failed: ${failed}`, - ); -}; diff --git a/apps/cms/src/migration/readme-auto-sync.ts b/apps/cms/src/migration/readme-auto-sync.ts deleted file mode 100644 index e967e42f..00000000 --- a/apps/cms/src/migration/readme-auto-sync.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Backfills `readme_auto_sync` for every existing package/template created - * before that field existed — their column is `null` in the database (the - * schema's `default: true` only applies to rows created after the field - * was added), which several read paths (the readme-sync crons, the - * "Submissions" edit form) already treat as `true` defensively, but a - * `null` in the database is still surprising to find later. Sets it to - * `true` (syncing enabled — the intended default) for every draft and - * published row that hasn't been touched yet. - */ -export const migrateReadmeAutoSync = async () => { - strapi.log.info("Starting readme_auto_sync migration..."); - let migrated = 0; - let failed = 0; - - for (const uid of [ - "api::package.package", - "api::template.template", - ] as const) { - for (const status of ["draft", "published"] as const) { - const entries = await strapi.documents(uid).findMany({ - status, - fields: ["documentId", "name"], - filters: { readme_auto_sync: { $null: true } }, - }); - - for (const entry of entries) { - try { - await strapi.documents(uid).update({ - status, - documentId: entry.documentId, - data: { readme_auto_sync: true }, - }); - migrated++; - } catch (error) { - failed++; - strapi.log.error( - `Error migrating readme_auto_sync for ${uid} "${entry.name}":`, - error, - ); - } - } - } - } - - strapi.log.info( - `readme_auto_sync migration finished. Migrated: ${migrated}, Failed: ${failed}`, - ); -}; diff --git a/apps/cms/src/migration/user-slugs.ts b/apps/cms/src/migration/user-slugs.ts deleted file mode 100644 index b1d47509..00000000 --- a/apps/cms/src/migration/user-slugs.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { generateUniqueUserSlug } from "../utils/slugify"; - -/** - * Backfills `slug` for every existing user that doesn't have one yet. - * - * The slug is copied verbatim from the user's *current, live* - * `plugin::webtools.url-alias.url_path` (minus the leading `/`) rather than - * freshly re-slugified from `name` — that guarantees a byte-for-byte match - * with the URL the user already has, including any historical `-0`/`-1` - * collision suffix webtools assigned, or a manually-overridden alias that - * no longer matches `slugify(name)` at all. Only users with no alias at - * all (shouldn't normally happen, since webtools generates one on create) - * fall back to generating a fresh unique slug from `name`. - * - * This is a one-off step in the migration to repoint the `plugin::better- - * auth.user` URL pattern from `/[name]` to `/[slug]` without moving anyone's - * existing profile URL — see the "personal profile settings" plan for the - * full rollout order. - */ -export const migrateUserSlugs = async () => { - strapi.log.info("Starting user slugs migration..."); - let migrated = 0; - let fromAlias = 0; - let generated = 0; - let failed = 0; - - const users = await strapi.documents("plugin::better-auth.user").findMany({ - fields: ["documentId", "name"], - filters: { slug: { $null: true } }, - populate: { url_alias: { fields: ["url_path"] } }, - }); - - for (const user of users) { - try { - const existingPath = user.url_alias?.[0]?.url_path; - const slug = existingPath - ? existingPath.replace(/^\//, "") - : await generateUniqueUserSlug(user.name); - - if (existingPath) fromAlias++; - else generated++; - - await strapi.documents("plugin::better-auth.user").update({ - documentId: user.documentId, - data: { slug }, - }); - migrated++; - } catch (error) { - failed++; - strapi.log.error( - `Error migrating slug for user ${user.documentId}:`, - error, - ); - } - } - - strapi.log.info( - `User slugs migration finished. Migrated: ${migrated} (${fromAlias} from existing URL, ${generated} generated from name), Failed: ${failed}`, - ); -};