diff --git a/apps/admin/src/hooks/user-preferences.test.tsx b/apps/admin/src/hooks/user-preferences.test.tsx index 6aeca134e6e..6d5e4f8d152 100644 --- a/apps/admin/src/hooks/user-preferences.test.tsx +++ b/apps/admin/src/hooks/user-preferences.test.tsx @@ -1,7 +1,7 @@ import { test as baseTest, describe, expect } from "vitest"; import { renderHook, waitFor, act } from "@testing-library/react"; import type { QueryClient } from "@tanstack/react-query"; -import { useUserPreferences, useEditUserPreferences, DEFAULT_NAVIGATION_PREFERENCES } from "./user-preferences"; +import { useUserPreferences, useEditUserPreferences, DEFAULT_NAVIGATION_PREFERENCES, DEFAULT_ONBOARDING_PREFERENCES } from "./user-preferences"; import { HttpResponse, http } from "msw"; import { mockUser } from "@test-utils/factories"; import { waitForQuerySettled } from "@test-utils/test-helpers"; @@ -30,6 +30,7 @@ const fixtures = { }, defaults: { navigation: DEFAULT_NAVIGATION_PREFERENCES, + onboarding: DEFAULT_ONBOARDING_PREFERENCES, } }; @@ -209,6 +210,11 @@ describe("useUserPreferences", () => { queryTest("gracefully handles invalid schema values", async ({ setup }) => { const result = await setup({ accessibility: JSON.stringify({ + onboarding: { + checklistState: "unknown", + completedSteps: "customize-design", + startedAt: "not-a-valid-datetime", + }, whatsNew: { lastSeenDate: "not-a-valid-datetime", }, @@ -225,6 +231,24 @@ describe("useUserPreferences", () => { }); }); + queryTest("parses onboarding startedAt into a date", async ({ setup }) => { + const result = await setup({ + accessibility: JSON.stringify({ + onboarding: { + completedSteps: ["customize-design"], + checklistState: "started", + startedAt: "2026-04-30T10:00:00.000Z", + }, + }), + }); + + expect(result.current.data?.onboarding).toEqual({ + completedSteps: ["customize-design"], + checklistState: "started", + startedAt: new Date("2026-04-30T10:00:00.000Z"), + }); + }); + queryTest("returns undefined when user is not loaded", async ({ server, wrapper }) => { server.use( http.get(USERS_API_URL, () => { @@ -322,6 +346,7 @@ describe("useUserPreferences", () => { await waitFor(() => { expect(result.current.query.data).toEqual({ navigation: DEFAULT_NAVIGATION_PREFERENCES, + onboarding: DEFAULT_ONBOARDING_PREFERENCES, whatsNew: { lastSeenDate: new Date("2025-01-01T00:00:00.000Z"), }, @@ -349,6 +374,7 @@ describe("useUserPreferences", () => { posts: false, }, }, + onboarding: DEFAULT_ONBOARDING_PREFERENCES, whatsNew: { lastSeenDate: new Date("2025-01-01T00:00:00.000Z"), }, @@ -423,6 +449,10 @@ describe("useEditUserPreferences", () => { expanded: { posts: false, members: false }, menu: { visible: true }, }, + onboarding: { + completedSteps: ["customize-design"], + checklistState: "started", + }, nightShift: true, }), }); @@ -430,6 +460,7 @@ describe("useEditUserPreferences", () => { await act(async () => { await mutation.current.mutateAsync({ navigation: { expanded: { posts: true } }, + onboarding: { completedSteps: ["customize-design", "first-post"] }, }); }); @@ -439,6 +470,10 @@ describe("useEditUserPreferences", () => { expanded: { posts: true, members: false }, menu: { visible: true }, // Preserved }, + onboarding: { + completedSteps: ["customize-design", "first-post"], + checklistState: "started", + }, nightShift: true, // Preserved }); }); diff --git a/apps/admin/src/hooks/user-preferences.ts b/apps/admin/src/hooks/user-preferences.ts index 28a66b77d9c..d8d1781dd8f 100644 --- a/apps/admin/src/hooks/user-preferences.ts +++ b/apps/admin/src/hooks/user-preferences.ts @@ -10,6 +10,18 @@ const WhatsNewPreferencesSchema = z.looseObject({ lastSeenDate: isoDatetimeToDate.optional().catch(undefined), }); +export const DEFAULT_ONBOARDING_PREFERENCES = { + completedSteps: [] as string[], + checklistState: "pending" as const, + startedAt: undefined as Date | undefined, +}; + +export const OnboardingPreferencesSchema = z.looseObject({ + completedSteps: z.array(z.string()).default(DEFAULT_ONBOARDING_PREFERENCES.completedSteps).catch(DEFAULT_ONBOARDING_PREFERENCES.completedSteps), + checklistState: z.enum(["pending", "started", "completed", "dismissed"]).default(DEFAULT_ONBOARDING_PREFERENCES.checklistState).catch(DEFAULT_ONBOARDING_PREFERENCES.checklistState), + startedAt: isoDatetimeToDate.optional().catch(DEFAULT_ONBOARDING_PREFERENCES.startedAt), +}); + export const DEFAULT_NAVIGATION_PREFERENCES = { expanded: { posts: true, members: true }, menu: { visible: true }, @@ -28,11 +40,13 @@ export const NavigationPreferencesSchema = z.looseObject({ const PreferencesSchema = z.looseObject({ whatsNew: WhatsNewPreferencesSchema.optional().catch(undefined), nightShift: z.boolean().optional(), + onboarding: OnboardingPreferencesSchema.default(DEFAULT_ONBOARDING_PREFERENCES).catch(DEFAULT_ONBOARDING_PREFERENCES), navigation: NavigationPreferencesSchema.default(DEFAULT_NAVIGATION_PREFERENCES).catch(DEFAULT_NAVIGATION_PREFERENCES), }); export type Preferences = z.infer; export type WhatsNewPreferences = z.infer; +export type OnboardingPreferences = z.infer; export type NavigationPreferences = z.infer; const userPreferencesQueryKey = (user: User | undefined) => ["userPreferences", user?.id, user?.accessibility] as const; @@ -80,7 +94,6 @@ export const useEditUserPreferences = (): UseMutationResult(userPreferencesQueryKey(user)) ?? PreferencesSchema.parse({}); - // TODO: use zod to validate? const newPreferences = deepMerge(currentPreferences, updatedPreferences); const encodedForStorage = PreferencesSchema.encode(newPreferences); diff --git a/apps/admin/src/onboarding/components/onboarding-checklist.tsx b/apps/admin/src/onboarding/components/onboarding-checklist.tsx new file mode 100644 index 00000000000..1476f970be1 --- /dev/null +++ b/apps/admin/src/onboarding/components/onboarding-checklist.tsx @@ -0,0 +1,102 @@ +import {Button} from "@tryghost/shade/components"; +import {LucideIcon} from "@tryghost/shade/utils"; +import {ONBOARDING_STEPS, type OnboardingStep} from "@/onboarding/constants"; +import {OnboardingLogoVideo} from "@/onboarding/components/onboarding-logo-video"; +import {OnboardingStepItem} from "@/onboarding/components/onboarding-step-item"; + +interface OnboardingChecklistProps { + allStepsCompleted: boolean; + completedSteps: string[]; + nextStep: OnboardingStep | undefined; + onComplete: () => void; + onDismiss: () => void; + onStepClick: (step: OnboardingStep) => void; + siteTitle: string; +} + +export function OnboardingChecklist({ + allStepsCompleted, + completedSteps, + nextStep, + onComplete, + onDismiss, + onStepClick, + siteTitle, +}: OnboardingChecklistProps) { + const completedStepSet = new Set(completedSteps); + + return ( +
+
+
+ + {allStepsCompleted ? +

You're all set.

+ : + <> +

Let's get started!

+

Welcome! It's time to set up {siteTitle}.

+ + } +
+ +
+
+ + + Start a new Ghost publication + + + + +
+ + {ONBOARDING_STEPS.map((step, index) => ( + onStepClick(step.id)} + /> + ))} +
+ + {allStepsCompleted && + + } + +

+ More questions? Check out our{" "} + . +

+ + {!allStepsCompleted && + + } +
+
+ ); +} diff --git a/apps/admin/src/onboarding/components/onboarding-logo-video.tsx b/apps/admin/src/onboarding/components/onboarding-logo-video.tsx new file mode 100644 index 00000000000..ab245b2aa9c --- /dev/null +++ b/apps/admin/src/onboarding/components/onboarding-logo-video.tsx @@ -0,0 +1,40 @@ +import logoLoaderDarkUrl from "@/assets/videos/logo-loader-dark.mp4"; +import logoLoaderUrl from "@/assets/videos/logo-loader.mp4"; + +export function OnboardingLogoVideo() { + return ( +
+ + +
+
+ ); +} diff --git a/apps/admin/src/onboarding/components/onboarding-step-item.tsx b/apps/admin/src/onboarding/components/onboarding-step-item.tsx new file mode 100644 index 00000000000..0f9a30e02ed --- /dev/null +++ b/apps/admin/src/onboarding/components/onboarding-step-item.tsx @@ -0,0 +1,51 @@ +import {LucideIcon} from "@tryghost/shade/utils"; +import {type OnboardingStepDefinition} from "@/onboarding/constants"; + +interface OnboardingStepItemProps { + complete: boolean; + id: string; + isBeforeNext: boolean; + isLast: boolean; + isNext: boolean; + onClick: () => void; + step: OnboardingStepDefinition; +} + +export function OnboardingStepItem({ + complete, + id, + isBeforeNext, + isLast, + isNext, + onClick, + step, +}: OnboardingStepItemProps) { + const Icon = step.icon; + const hideBorder = isLast || isBeforeNext || isNext; + const rowClassName = isNext + ? `relative z-10 -mx-8 flex w-[calc(100%+64px)] items-center justify-between rounded-md bg-background px-8 py-6 text-left shadow-[0_1px_0_rgba(17,17,26,0.05),0_0_8px_rgba(17,17,26,0.10)] transition-none dark:ring-1 dark:ring-border ${isLast ? "-mb-[18px]" : "mb-1.5"}` + : `relative flex w-full items-center justify-between bg-transparent py-6 text-left ${hideBorder ? "" : "after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-border after:content-['']"}`; + + return ( + + ); +} diff --git a/apps/admin/src/onboarding/components/share-publication-dialog.tsx b/apps/admin/src/onboarding/components/share-publication-dialog.tsx new file mode 100644 index 00000000000..72e51efc54c --- /dev/null +++ b/apps/admin/src/onboarding/components/share-publication-dialog.tsx @@ -0,0 +1,83 @@ +import {Button} from "@tryghost/shade/components"; +import {ShareModal, type ShareModalSocialLink} from "@tryghost/shade/patterns"; + +interface SharePublicationDialogProps { + description: string; + imageUrl: string; + onOpenChange: (open: boolean) => void; + open: boolean; + siteTitle: string; + siteUrl: string; +} + +export function SharePublicationDialog({ + description, + imageUrl, + onOpenChange, + open, + siteTitle, + siteUrl, +}: SharePublicationDialogProps) { + const encodedUrl = encodeURIComponent(siteUrl); + const socialLinks: ShareModalSocialLink[] = [ + { + href: `https://twitter.com/intent/tweet?url=${encodedUrl}`, + id: "ob-share-on-x", + label: "Share your publication on X", + service: "x", + }, + { + href: `https://threads.net/intent/post?text=${encodedUrl}`, + id: "ob-share-on-threads", + label: "Share your publication on Threads", + service: "threads", + }, + { + href: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, + id: "ob-share-on-fb", + label: "Share your publication on Facebook", + service: "facebook", + }, + { + href: `https://www.linkedin.com/feed/?shareActive=true&text=${encodedUrl}`, + id: "ob-share-on-li", + label: "Share your publication on LinkedIn", + service: "linkedin", + }, + ]; + + return ( + + Set your publication's cover image and description in{" "} + . +

+ )} + open={open} + preview={{ + description, + imageURL: imageUrl, + title: siteTitle, + url: siteUrl, + }} + socialLinks={socialLinks} + title="Share your publication" + variant="publication" + onClose={() => onOpenChange(false)} + onOpenChange={onOpenChange} + /> + ); +} diff --git a/apps/admin/src/onboarding/constants.ts b/apps/admin/src/onboarding/constants.ts new file mode 100644 index 00000000000..b324c505a3b --- /dev/null +++ b/apps/admin/src/onboarding/constants.ts @@ -0,0 +1,44 @@ +import type React from "react"; +import {LucideIcon} from "@tryghost/shade/utils"; + +type OnboardingStepDefinitionShape = { + description: string; + icon: React.ComponentType<{className?: string}>; + id: string; + route?: string; + title: string; +}; + +export const ONBOARDING_STEPS = [ + { + description: "Craft a look that reflects your brand and style.", + icon: LucideIcon.Brush, + id: "customize-design", + route: "/settings/design/edit?ref=setup", + title: "Customize your design", + }, + { + description: "Get to know a writing experience you'll love.", + icon: LucideIcon.PenLine, + id: "first-post", + route: "/editor/post", + title: "Explore the editor", + }, + { + description: "Add members and grow your readership.", + icon: LucideIcon.UserPlus, + id: "build-audience", + route: "/members", + title: "Build your audience", + }, + { + description: "Expand your reach on social media.", + icon: LucideIcon.Megaphone, + id: "share-publication", + route: undefined, + title: "Share your publication", + }, +] as const satisfies readonly OnboardingStepDefinitionShape[]; + +export type OnboardingStepDefinition = typeof ONBOARDING_STEPS[number]; +export type OnboardingStep = OnboardingStepDefinition["id"]; diff --git a/apps/admin/src/onboarding/hooks/use-onboarding.test.tsx b/apps/admin/src/onboarding/hooks/use-onboarding.test.tsx new file mode 100644 index 00000000000..a0de978195c --- /dev/null +++ b/apps/admin/src/onboarding/hooks/use-onboarding.test.tsx @@ -0,0 +1,209 @@ +import {act, renderHook, waitFor} from "@testing-library/react"; +import {describe, expect, test as baseTest} from "vitest"; +import {HttpResponse, http} from "msw"; +import {mockUser} from "@test-utils/factories"; +import {queryClientFixtures, type TestWrapperComponent} from "@test-utils/fixtures/query-client"; +import {serverFixture} from "@test-utils/fixtures/msw"; +import {useOnboarding} from "./use-onboarding"; +import type {QueryClient} from "@tanstack/react-query"; +import type {SetupServer} from "msw/node"; +import type {UpdateUserRequestBody, UsersResponseType, User} from "@tryghost/admin-x-framework/api/users"; + +const USERS_API_URL = "/ghost/api/admin/users/me/"; +const USER_UPDATE_API_URL = "/ghost/api/admin/users/:id/"; + +const ownerRole = { + id: "owner-role", + name: "Owner", + description: "Owner", + created_at: "2024-01-01T00:00:00.000Z", + updated_at: "2024-01-01T00:00:00.000Z", +} as const; + +async function setupOnboarding( + server: SetupServer, + wrapper: TestWrapperComponent, + userOverrides: Partial = {} +) { + server.use( + http.get(USERS_API_URL, () => { + return HttpResponse.json({ + users: [{ + ...mockUser, + roles: [ownerRole], + ...userOverrides, + }], + }); + }), + http.put<{ id: string }, UpdateUserRequestBody, UsersResponseType>( + USER_UPDATE_API_URL, + async ({request}) => { + const body = await request.json(); + return HttpResponse.json({ + users: [{ + ...mockUser, + roles: [ownerRole], + accessibility: body.users[0]?.accessibility ?? "", + }], + }); + } + ) + ); + + const {result} = renderHook(() => useOnboarding(), {wrapper}); + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + return result; +} + +const onboardingTest = baseTest.extend<{ + server: SetupServer; + queryClient: QueryClient; + wrapper: TestWrapperComponent; + setup: (userOverrides?: Partial) => ReturnType; +}>({ + ...serverFixture, + ...queryClientFixtures, + setup: async ({server, wrapper}, provide) => { + await provide((userOverrides) => setupOnboarding(server, wrapper, userOverrides)); + }, +}); + +describe("useOnboarding", () => { + onboardingTest("shows checklist for owners when onboarding is started", async ({setup}) => { + const result = await setup({ + accessibility: JSON.stringify({ + onboarding: { + completedSteps: ["customize-design"], + checklistState: "started", + startedAt: "2026-04-30T10:00:00.000Z", + }, + }), + }); + + expect(result.current.shouldShowChecklist).toBe(true); + expect(result.current.nextStep).toBe("first-post"); + expect(result.current.allStepsCompleted).toBe(false); + }); + + onboardingTest("does not show checklist for non-owner users", async ({setup}) => { + const result = await setup({ + accessibility: JSON.stringify({ + onboarding: { + completedSteps: [], + checklistState: "started", + startedAt: "2026-04-30T10:00:00.000Z", + }, + }), + roles: [{ + id: "admin-role", + name: "Administrator", + description: "Admin", + created_at: "2024-01-01T00:00:00.000Z", + updated_at: "2024-01-01T00:00:00.000Z", + }], + }); + + expect(result.current.shouldShowChecklist).toBe(false); + }); + + onboardingTest("updates completed steps without duplicating existing steps", async ({setup}) => { + const result = await setup({ + accessibility: JSON.stringify({ + onboarding: { + completedSteps: ["customize-design"], + checklistState: "started", + startedAt: "2026-04-30T10:00:00.000Z", + }, + }), + }); + + await act(async () => { + await result.current.markStepCompleted("customize-design"); + await result.current.markStepCompleted("first-post"); + }); + + await waitFor(() => { + expect(result.current.completedSteps).toEqual(["customize-design", "first-post"]); + }); + }); + + onboardingTest("updates checklist state", async ({setup}) => { + const result = await setup({ + accessibility: JSON.stringify({ + onboarding: { + completedSteps: [], + checklistState: "started", + startedAt: "2026-04-30T10:00:00.000Z", + }, + }), + }); + + await act(async () => { + await result.current.dismissChecklist(); + }); + + await waitFor(() => { + expect(result.current.checklistState).toBe("dismissed"); + expect(result.current.shouldShowChecklist).toBe(false); + }); + }); + + onboardingTest("starts checklist with a start date", async ({setup}) => { + const result = await setup({ + accessibility: JSON.stringify({ + onboarding: { + completedSteps: ["customize-design"], + checklistState: "pending", + }, + }), + }); + + await act(async () => { + await result.current.startChecklist(); + }); + + await waitFor(() => { + expect(result.current.checklistState).toBe("started"); + expect(result.current.completedSteps).toEqual([]); + expect(result.current.hasActiveStartedAt).toBe(true); + expect(result.current.shouldShowChecklist).toBe(true); + }); + }); + + onboardingTest("dismisses started checklist when startedAt is missing", async ({setup}) => { + const result = await setup({ + accessibility: JSON.stringify({ + onboarding: { + completedSteps: [], + checklistState: "started", + }, + }), + }); + + expect(result.current.shouldShowChecklist).toBe(false); + + await waitFor(() => { + expect(result.current.checklistState).toBe("dismissed"); + }); + }); + + onboardingTest("dismisses started checklist when startedAt is before the cutoff", async ({setup}) => { + const result = await setup({ + accessibility: JSON.stringify({ + onboarding: { + completedSteps: [], + checklistState: "started", + startedAt: "2026-04-29T23:59:59.999Z", + }, + }), + }); + + expect(result.current.shouldShowChecklist).toBe(false); + + await waitFor(() => { + expect(result.current.checklistState).toBe("dismissed"); + }); + }); +}); diff --git a/apps/admin/src/onboarding/hooks/use-onboarding.ts b/apps/admin/src/onboarding/hooks/use-onboarding.ts new file mode 100644 index 00000000000..e9d25a8307b --- /dev/null +++ b/apps/admin/src/onboarding/hooks/use-onboarding.ts @@ -0,0 +1,96 @@ +import {useCallback, useEffect, useMemo, useRef} from "react"; +import {useCurrentUser} from "@tryghost/admin-x-framework/api/current-user"; +import {isOwnerUser} from "@tryghost/admin-x-framework/api/users"; +import {useEditUserPreferences, useUserPreferences} from "@/hooks/user-preferences"; +import type {OnboardingPreferences} from "@/hooks/user-preferences"; +import {ONBOARDING_STEPS, type OnboardingStep} from "@/onboarding/constants"; + +const ONBOARDING_STARTED_AT_CUTOFF = new Date("2026-04-30T00:00:00.000Z"); + +function isAfterOnboardingStartedAtCutoff(date: Date | undefined) { + if (!date) { + return false; + } + + return date >= ONBOARDING_STARTED_AT_CUTOFF; +} + +export function useOnboarding() { + const {data: currentUser, isLoading: isUserLoading} = useCurrentUser(); + const {data: preferences, isLoading: isPreferencesLoading} = useUserPreferences(); + const {mutateAsync: editPreferences} = useEditUserPreferences(); + const hasAttemptedInvalidStartedStateDismissalRef = useRef(false); + + const completedSteps = useMemo(() => preferences?.onboarding.completedSteps || [], [preferences?.onboarding.completedSteps]); + const completedStepSet = useMemo(() => new Set(completedSteps), [completedSteps]); + const checklistState = preferences?.onboarding.checklistState || "pending"; + const startedAt = preferences?.onboarding.startedAt; + const hasActiveStartedAt = isAfterOnboardingStartedAtCutoff(startedAt); + const isOwner = currentUser ? isOwnerUser(currentUser) : false; + const shouldShowChecklist = isOwner && checklistState === "started" && hasActiveStartedAt; + const nextStep = ONBOARDING_STEPS.find(step => !completedStepSet.has(step.id))?.id; + const allStepsCompleted = ONBOARDING_STEPS.every(step => completedStepSet.has(step.id)); + + const updateOnboarding = useCallback((updates: { + completedSteps?: string[]; + checklistState?: OnboardingPreferences["checklistState"]; + startedAt?: Date; + }) => { + return editPreferences({ + onboarding: updates, + }); + }, [editPreferences]); + + const markStepCompleted = useCallback(async (step: OnboardingStep) => { + if (completedStepSet.has(step)) { + return; + } + + await updateOnboarding({ + completedSteps: [...completedSteps, step], + }); + }, [completedStepSet, completedSteps, updateOnboarding]); + + const dismissChecklist = useCallback(() => { + return updateOnboarding({checklistState: "dismissed"}); + }, [updateOnboarding]); + + useEffect(() => { + if (isUserLoading || isPreferencesLoading || !isOwner || checklistState !== "started" || hasActiveStartedAt || hasAttemptedInvalidStartedStateDismissalRef.current) { + return; + } + + hasAttemptedInvalidStartedStateDismissalRef.current = true; + void dismissChecklist().catch((error) => { + hasAttemptedInvalidStartedStateDismissalRef.current = false; + console.error(error); + }); + }, [checklistState, dismissChecklist, hasActiveStartedAt, isOwner, isPreferencesLoading, isUserLoading]); + + const startChecklist = useCallback(() => { + return updateOnboarding({ + completedSteps: [], + checklistState: "started", + startedAt: new Date(), + }); + }, [updateOnboarding]); + + const completeChecklist = useCallback(() => { + return updateOnboarding({checklistState: "completed"}); + }, [updateOnboarding]); + + return { + allStepsCompleted, + checklistState, + completeChecklist, + completedSteps, + dismissChecklist, + hasActiveStartedAt, + isOwner, + shouldShowChecklist, + isLoading: isUserLoading || isPreferencesLoading, + markStepCompleted, + nextStep, + startChecklist, + }; +} diff --git a/apps/admin/src/onboarding/onboarding-redirect.tsx b/apps/admin/src/onboarding/onboarding-redirect.tsx new file mode 100644 index 00000000000..21f288772db --- /dev/null +++ b/apps/admin/src/onboarding/onboarding-redirect.tsx @@ -0,0 +1,23 @@ +import type React from "react"; +import {Navigate, useLocation} from "@tryghost/admin-x-framework"; +import {useOnboarding} from "@/onboarding/hooks/use-onboarding"; + +interface OnboardingRedirectProps { + children: React.ReactNode; +} + +export function OnboardingRedirect({children}: OnboardingRedirectProps) { + const location = useLocation(); + const onboarding = useOnboarding(); + + if (onboarding.isLoading) { + return null; + } + + if (onboarding.shouldShowChecklist) { + const returnTo = `${location.pathname}${location.search}`; + return ; + } + + return children; +} diff --git a/apps/admin/src/onboarding/onboarding-route.tsx b/apps/admin/src/onboarding/onboarding-route.tsx new file mode 100644 index 00000000000..dd441811fb9 --- /dev/null +++ b/apps/admin/src/onboarding/onboarding-route.tsx @@ -0,0 +1,105 @@ +import {Navigate, useNavigate, useSearchParams} from "@tryghost/admin-x-framework"; +import {getSettingValue, useBrowseSettings} from "@tryghost/admin-x-framework/api/settings"; +import {useBrowseSite} from "@tryghost/admin-x-framework/api/site"; +import {useRef, useState} from "react"; +import {OnboardingChecklist} from "@/onboarding/components/onboarding-checklist"; +import {SharePublicationDialog} from "@/onboarding/components/share-publication-dialog"; +import {useOnboarding} from "@/onboarding/hooks/use-onboarding"; +import {ONBOARDING_STEPS, type OnboardingStep} from "./constants"; + +function getSafeReturnTo(value: string | null) { + return value && /^\/analytics(?:\/|\?|$)/.test(value) ? value : "/analytics"; +} + +export default function OnboardingRoute() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const returnTo = getSafeReturnTo(searchParams.get("returnTo")); + const onboarding = useOnboarding(); + const settings = useBrowseSettings(); + const site = useBrowseSite(); + const isLeavingRef = useRef(false); + const [isLeaving, setIsLeaving] = useState(false); + const [shareDialogOpen, setShareDialogOpen] = useState(false); + + const siteTitle = String(getSettingValue(settings.data?.settings, "title") || site.data?.site.title || "your publication"); + const description = String(getSettingValue(settings.data?.settings, "description") || site.data?.site.description || ""); + const imageUrl = String(getSettingValue(settings.data?.settings, "cover_image") || ""); + const siteUrl = site.data?.site.url || "/"; + + const { + allStepsCompleted, + completeChecklist, + completedSteps, + dismissChecklist, + shouldShowChecklist, + isLoading, + markStepCompleted, + nextStep, + } = onboarding; + + if (isLoading || site.isLoading || isLeaving || isLeavingRef.current) { + return null; + } + + if (!shouldShowChecklist) { + return ; + } + + const navigateAfterUpdate = async (update: () => Promise) => { + isLeavingRef.current = true; + setIsLeaving(true); + try { + await update(); + navigate(returnTo, {crossApp: true, replace: true}); + } catch (error) { + isLeavingRef.current = false; + setIsLeaving(false); + console.error(error); + } + }; + + const handleStepClick = async (step: OnboardingStep) => { + if (step === "share-publication") { + await markStepCompleted(step); + setShareDialogOpen(true); + return; + } + + await markStepCompleted(step); + + const stepRoute = ONBOARDING_STEPS.find(({id}) => id === step)?.route; + if (stepRoute) { + navigate(stepRoute, {crossApp: true}); + } + }; + + return ( + <> + { + void navigateAfterUpdate(completeChecklist); + }} + onDismiss={() => { + void navigateAfterUpdate(dismissChecklist); + }} + onStepClick={(step) => { + void handleStepClick(step); + }} + /> + + + + ); +} diff --git a/apps/admin/src/routes.tsx b/apps/admin/src/routes.tsx index c3dd3ef440a..17b8893affe 100644 --- a/apps/admin/src/routes.tsx +++ b/apps/admin/src/routes.tsx @@ -14,6 +14,7 @@ import MyProfileRedirect from "./my-profile-redirect"; import { EmberFallback, ForceUpgradeGuard } from "./ember-bridge"; import type { RouteHandle } from "./ember-bridge"; import { MembersRoute } from "./members-route"; +import { OnboardingRedirect } from "./onboarding/onboarding-redirect"; import { NotFound } from "./not-found"; @@ -98,12 +99,18 @@ export const routes: RouteObject[] = [ }, { element: ( - - - + + + + + ), children: statsRoutes, }, + { + path: "setup/onboarding", + lazy: lazyComponent(() => import("./onboarding/onboarding-route")), + }, { path: `network`, loader: () => redirect("/activitypub"), diff --git a/apps/admin/src/utils/deep-merge.ts b/apps/admin/src/utils/deep-merge.ts index 6141c33a8af..531ada9acc9 100644 --- a/apps/admin/src/utils/deep-merge.ts +++ b/apps/admin/src/utils/deep-merge.ts @@ -1,7 +1,7 @@ /** * Deep partial type that makes all properties optional recursively. */ -export type DeepPartial = T extends object ? { +export type DeepPartial = T extends Array ? Array> : T extends object ? { [P in keyof T]?: DeepPartial; } : T; diff --git a/apps/posts/src/views/members/member-fields.ts b/apps/posts/src/views/members/member-fields.ts index e6127d04de0..f7ccdea8729 100644 --- a/apps/posts/src/views/members/member-fields.ts +++ b/apps/posts/src/views/members/member-fields.ts @@ -389,8 +389,7 @@ export const memberFields = defineFields({ ui: { label: 'Emails sent (all time)', type: 'number', - defaultOperator: 'is', - defaultValue: 0, + defaultOperator: 'is-greater', min: 0, className: 'w-24' }, @@ -401,8 +400,7 @@ export const memberFields = defineFields({ ui: { label: 'Emails opened (all time)', type: 'number', - defaultOperator: 'is', - defaultValue: 0, + defaultOperator: 'is-greater', min: 0, className: 'w-24' }, @@ -413,8 +411,7 @@ export const memberFields = defineFields({ ui: { label: 'Open rate (all time)', type: 'number', - defaultOperator: 'is', - defaultValue: 0, + defaultOperator: 'is-greater', min: 0, max: 100, suffix: '%', diff --git a/apps/shade/.storybook/preview.tsx b/apps/shade/.storybook/preview.tsx index 27be90dfa23..fae5019a4d4 100644 --- a/apps/shade/.storybook/preview.tsx +++ b/apps/shade/.storybook/preview.tsx @@ -45,6 +45,35 @@ const customViewports = { }, }; +const StorybookSchemeDecorator = ({Story, scheme}: {Story: React.ComponentType; scheme: string}) => { + React.useEffect(() => { + const isDark = scheme === 'dark'; + + document.documentElement.classList.toggle('dark', isDark); + document.body.classList.toggle('dark', isDark); + + return () => { + document.documentElement.classList.remove('dark'); + document.body.classList.remove('dark'); + }; + }, [scheme]); + + return ( +
+ {/* 👇 Decorators in Storybook also accept a function. Replace with Story() to enable it */} + + + +
+ ); +}; + const preview: Preview = { parameters: { actions: { argTypesRegex: "^on[A-Z].*" }, @@ -88,19 +117,7 @@ const preview: Preview = { (Story, context) => { let {scheme} = context.globals; - return ( -
- {/* 👇 Decorators in Storybook also accept a function. Replace with Story() to enable it */} - - - -
); + return ; }, ], globalTypes: { diff --git a/apps/shade/src/components/features/post-share-modal/post-share-modal.tsx b/apps/shade/src/components/features/post-share-modal/post-share-modal.tsx index 1e1a5fb826a..d6167b17198 100644 --- a/apps/shade/src/components/features/post-share-modal/post-share-modal.tsx +++ b/apps/shade/src/components/features/post-share-modal/post-share-modal.tsx @@ -1,137 +1,101 @@ -import {H3} from '@/components/layout/heading'; +import ShareModal, {type ShareModalSocialLink} from '@/components/features/share-modal/share-modal'; import {Button} from '@/components/ui/button'; -import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger} from '@/components/ui/dialog'; import * as DialogPrimitive from '@radix-ui/react-dialog'; -import {Check, Link, X} from 'lucide-react'; -import React, {useState} from 'react'; +import React from 'react'; interface PostShareModalProps extends React.ComponentPropsWithoutRef { + author?: string; + children?: React.ReactNode; + description?: React.ReactNode; emailOnly?: boolean; + faviconURL?: string; + featureImageURL?: string; + onClose?: () => void; + postExcerpt?: string; + postTitle?: string; postURL?: string; primaryTitle?: string; secondaryTitle?: string; - description?: React.ReactNode; - featureImageURL?: string; - postTitle?: string; - postExcerpt?: string; - faviconURL?: string; siteTitle?: string; - author?: string; - onClose?: () => void; - children?: React.ReactNode; } -const PostShareModal: React.FC = ( - {emailOnly = false, - postURL = '', - primaryTitle = 'Your post is published.', - secondaryTitle = 'Spread the word!', - description = '', - featureImageURL = '', - postTitle = '', - postExcerpt = '', - faviconURL = '', - siteTitle = '', - author = '', - onClose = () => {}, - children, - ...props}) => { - const [isCopied, setIsCopied] = useState(false); - - const handleCopyLink = async () => { - try { - await navigator.clipboard.writeText(postURL); - setIsCopied(true); - // Reset the copied state after 2 seconds - setTimeout(() => setIsCopied(false), 2000); - } catch { - // Could add toast notification for copy failure - } - }; - +const PostShareModal: React.FC = ({ + author = '', + children, + description = '', + emailOnly = false, + faviconURL = '', + featureImageURL = '', + onClose = () => {}, + postExcerpt = '', + postTitle = '', + postURL = '', + primaryTitle = 'Your post is published.', + secondaryTitle = 'Spread the word!', + siteTitle = '', + ...props +}) => { const encodedPostTitle = encodeURIComponent(postTitle); const encodedPostURL = encodeURIComponent(postURL); const encodedPostURLTitle = encodeURIComponent(`${postTitle} ${postURL}`); + const socialLinks: ShareModalSocialLink[] = emailOnly ? [] : [ + { + href: `https://twitter.com/intent/tweet?text=${encodedPostTitle}%0A${encodedPostURL}`, + label: 'Share on X', + service: 'x' + }, + { + href: `https://threads.net/intent/post?text=${encodedPostURLTitle}`, + label: 'Share on Threads', + service: 'threads' + }, + { + href: `https://www.facebook.com/sharer/sharer.php?u=${encodedPostURL}`, + label: 'Share on Facebook', + service: 'facebook' + }, + { + href: `https://www.linkedin.com/shareArticle?mini=true&title=${encodedPostTitle}&url=${encodedPostURL}`, + label: 'Share on LinkedIn', + service: 'linkedin' + } + ]; return ( - - - {children} - - -
- -
- - - {primaryTitle}
- {secondaryTitle} -
- {description && - - {description} - - } -
- - {featureImageURL && -
- } -
+ ), + title: postTitle, + url: postURL + }} + primaryTitle={primaryTitle} + secondaryTitle={secondaryTitle} + socialLinks={socialLinks} + variant="post" + onClose={onClose} + {...props} + > + {children} +
); }; diff --git a/apps/shade/src/components/features/share-modal/index.ts b/apps/shade/src/components/features/share-modal/index.ts new file mode 100644 index 00000000000..f2e2a3d2f94 --- /dev/null +++ b/apps/shade/src/components/features/share-modal/index.ts @@ -0,0 +1,2 @@ +export {default} from './share-modal'; +export type {ShareModalSocialLink} from './share-modal'; diff --git a/apps/shade/src/components/features/share-modal/share-modal.stories.tsx b/apps/shade/src/components/features/share-modal/share-modal.stories.tsx new file mode 100644 index 00000000000..5233e7d0b23 --- /dev/null +++ b/apps/shade/src/components/features/share-modal/share-modal.stories.tsx @@ -0,0 +1,300 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {useState} from 'react'; +import {Button} from '@/components/ui/button'; +import ShareModal, {type ShareModalSocialLink} from './share-modal'; + +const meta = { + title: 'Features / Share Modal', + component: ShareModal, + tags: ['autodocs'], + argTypes: { + children: { + table: { + disable: true + } + } + } +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const postUrl = 'https://example.com/copy-clipboard-react-guide'; +const encodedPostTitle = encodeURIComponent('Copy to Clipboard in React: Complete Guide'); +const encodedPostUrl = encodeURIComponent(postUrl); +const encodedPostTitleAndUrl = encodeURIComponent(`Copy to Clipboard in React: Complete Guide ${postUrl}`); + +const publicationUrl = 'https://ghost.org'; +const encodedPublicationUrl = encodeURIComponent(publicationUrl); + +const postSocialLinks: ShareModalSocialLink[] = [ + { + href: `https://twitter.com/intent/tweet?text=${encodedPostTitle}%0A${encodedPostUrl}`, + label: 'Share on X', + service: 'x' + }, + { + href: `https://threads.net/intent/post?text=${encodedPostTitleAndUrl}`, + label: 'Share on Threads', + service: 'threads' + }, + { + href: `https://www.facebook.com/sharer/sharer.php?u=${encodedPostUrl}`, + label: 'Share on Facebook', + service: 'facebook' + }, + { + href: `https://www.linkedin.com/shareArticle?mini=true&title=${encodedPostTitle}&url=${encodedPostUrl}`, + label: 'Share on LinkedIn', + service: 'linkedin' + } +]; + +const publicationSocialLinks: ShareModalSocialLink[] = [ + { + href: `https://twitter.com/intent/tweet?url=${encodedPublicationUrl}`, + label: 'Share your publication on X', + service: 'x' + }, + { + href: `https://threads.net/intent/post?text=${encodedPublicationUrl}`, + label: 'Share your publication on Threads', + service: 'threads' + }, + { + href: `https://www.facebook.com/sharer/sharer.php?u=${encodedPublicationUrl}`, + label: 'Share your publication on Facebook', + service: 'facebook' + }, + { + href: `https://www.linkedin.com/feed/?shareActive=true&text=${encodedPublicationUrl}`, + label: 'Share your publication on LinkedIn', + service: 'linkedin' + } +]; + +const postArgs = { + copyURL: postUrl, + description: <> + Your post was published on your site and sent to 3 subscribers of Ghost Blog, on June 13th at 12:02. + , + preview: { + description: 'A comprehensive guide to implementing copy-to-clipboard functionality in React applications with proper error handling and user feedback.', + imageURL: 'https://picsum.photos/800/600?random=1', + meta: ( +
+
+
+ Ghost Blog + + Jane Smith +
+
+ ), + title: 'Copy to Clipboard in React: Complete Guide', + url: postUrl + }, + primaryTitle: 'Your post is published.', + secondaryTitle: 'Spread the word!', + socialLinks: postSocialLinks, + variant: 'post' as const +}; + +const publicationArgs = { + actionsLayout: 'footer' as const, + copyURL: publicationUrl, + guidance: ( +

+ Set your publication's cover image and description in . +

+ ), + preview: { + description: 'Thoughts, stories and ideas.', + imageURL: 'https://picsum.photos/800/600?random=2', + title: 'Ghostbusters', + url: publicationUrl + }, + socialLinks: publicationSocialLinks, + title: 'Share your publication', + variant: 'publication' as const +}; + +const postSource = `const [isOpen, setIsOpen] = useState(false); + +const postUrl = 'https://example.com/copy-clipboard-react-guide'; +const encodedPostTitle = encodeURIComponent('Copy to Clipboard in React: Complete Guide'); +const encodedPostUrl = encodeURIComponent(postUrl); + +Your post was published on your site and sent to 3 subscribers.} + open={isOpen} + preview={{ + description: 'A comprehensive guide to implementing copy-to-clipboard functionality in React applications.', + imageURL: 'https://picsum.photos/800/600?random=1', + meta: ( +
+
+
+ Ghost Blog + + Jane Smith +
+
+ ), + title: 'Copy to Clipboard in React: Complete Guide', + url: postUrl + }} + primaryTitle="Your post is published." + secondaryTitle="Spread the word!" + socialLinks={[ + { + href: \`https://twitter.com/intent/tweet?text=\${encodedPostTitle}%0A\${encodedPostUrl}\`, + label: 'Share on X', + service: 'x' + } + ]} + variant="post" + onClose={() => setIsOpen(false)} + onOpenChange={setIsOpen} +> + +`; + +const publicationSource = `const [isOpen, setIsOpen] = useState(false); + +const publicationUrl = 'https://ghost.org'; +const encodedPublicationUrl = encodeURIComponent(publicationUrl); + + + Set your publication's cover image and description in{' '} + . +

+ )} + open={isOpen} + preview={{ + description: 'Thoughts, stories and ideas.', + imageURL: 'https://picsum.photos/800/600?random=2', + title: 'Ghostbusters', + url: publicationUrl + }} + socialLinks={[ + { + href: \`https://threads.net/intent/post?text=\${encodedPublicationUrl}\`, + label: 'Share your publication on Threads', + service: 'threads' + } + ]} + title="Share your publication" + variant="publication" + onClose={() => setIsOpen(false)} + onOpenChange={setIsOpen} +> + +
`; + +export const Post: Story = { + args: { + ...postArgs + }, + render: (args) => { + const PostExample = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( + setIsOpen(false)} + onOpenChange={setIsOpen} + > + + + ); + }; + + return ; + }, + parameters: { + docs: { + source: { + code: postSource + } + } + } +}; + +export const Publication: Story = { + args: { + ...publicationArgs + }, + render: (args) => { + const PublicationExample = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( + setIsOpen(false)} + onOpenChange={setIsOpen} + > + + + ); + }; + + return ; + }, + parameters: { + docs: { + source: { + code: publicationSource + } + } + } +}; + +export const ControlledPublication: Story = { + args: { + copyURL: publicationUrl, + preview: { + title: 'Ghostbusters', + url: publicationUrl + } + }, + parameters: { + docs: { + source: { + code: publicationSource.replace('Share publication', 'Open publication share modal') + } + } + }, + render: () => { + const ControlledExample = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( + setIsOpen(false)} + onOpenChange={setIsOpen} + > + + + ); + }; + + return ; + } +}; diff --git a/apps/shade/src/components/features/share-modal/share-modal.tsx b/apps/shade/src/components/features/share-modal/share-modal.tsx new file mode 100644 index 00000000000..3f099026f6d --- /dev/null +++ b/apps/shade/src/components/features/share-modal/share-modal.tsx @@ -0,0 +1,269 @@ +import {H3} from '@/components/layout/heading'; +import {Button} from '@/components/ui/button'; +import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger} from '@/components/ui/dialog'; +import {cn} from '@/lib/utils'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import {Check, Copy, Image as ImageIcon, Link, X} from 'lucide-react'; +import React, {useState} from 'react'; + +type ShareService = 'x' | 'threads' | 'facebook' | 'linkedin'; + +export type ShareModalSocialLink = { + href: string; + id?: string; + label: string; + service: ShareService; + title?: string; +}; + +interface ShareModalPreview { + description?: React.ReactNode; + imageURL?: string; + meta?: React.ReactNode; + title: React.ReactNode; + url: string; +} + +interface ShareModalProps extends React.ComponentPropsWithoutRef { + actionsLayout?: 'footer' | 'stacked'; + children?: React.ReactNode; + closeButtonId?: string; + copyButtonId?: string; + copyButtonTestId?: string; + copyLabel?: string; + copySuccessLabel?: string; + copyURL: string; + contentProps?: React.ComponentPropsWithoutRef & Record<`data-${string}`, string | undefined>; + description?: React.ReactNode; + footerAction?: React.ReactNode; + guidance?: React.ReactNode; + onClose?: () => void; + preview: ShareModalPreview; + primaryTitle?: React.ReactNode; + secondaryTitle?: React.ReactNode; + socialLinks?: ShareModalSocialLink[]; + title?: React.ReactNode; + variant?: 'post' | 'publication'; +} + +async function copyTextToClipboard(text: string) { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + // Fall back for browser contexts where the async clipboard API is blocked. + } + } + + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '0'; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); +} + +function SocialIcon({service}: {service: ShareService}) { + if (service === 'threads') { + return ( + + ); + } + + if (service === 'facebook') { + return ( + + ); + } + + if (service === 'linkedin') { + return ( + + ); + } + + return ( + + ); +} + +function SocialLinks({layout, links}: {layout: 'footer' | 'stacked'; links: ShareModalSocialLink[]}) { + if (layout === 'stacked') { + return ( +
+ {links.map(link => ( + + ))} +
+ ); + } + + return ( +
+ {links.map(link => ( + + + + ))} +
+ ); +} + +const ShareModal: React.FC = ({ + actionsLayout = 'footer', + children, + closeButtonId, + copyButtonId, + copyButtonTestId, + copyLabel = 'Copy link', + copySuccessLabel = 'Copied!', + copyURL, + contentProps, + description, + footerAction, + guidance, + onClose = () => {}, + preview, + primaryTitle, + secondaryTitle, + socialLinks = [], + title, + variant = 'post', + ...props +}) => { + const [isCopied, setIsCopied] = useState(false); + + const handleCopyLink = async () => { + await copyTextToClipboard(copyURL); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + }; + + const showPostHeader = variant === 'post'; + const {className: contentClassName, ...dialogContentProps} = contentProps || {}; + const content = ( + + {showPostHeader && ( +
+ +
+ )} + + {showPostHeader ? + + {primaryTitle && {primaryTitle}} + {primaryTitle && secondaryTitle &&
} + {secondaryTitle && {secondaryTitle}} +
+ : + {title} + } + {!showPostHeader && ( + + )} + {description && + + {description} + + } +
+ + + {preview.imageURL ? +
+ : + !showPostHeader && ( +
+ +
+ ) + } +
+ {showPostHeader ? +

{preview.title}

+ : +
{preview.title}
+ } + {preview.description && ( +

{preview.description}

+ )} + {preview.meta} +
+
+ + {guidance} + + {actionsLayout === 'stacked' ? + <> +
+ {copyURL} + +
+ + + : + + {footerAction || ( + <> + + + + )} + + } +
+ ); + + return ( + + {children ? + + {children} + + : + null + } + {content} + + ); +}; + +export default ShareModal; diff --git a/apps/shade/src/patterns.ts b/apps/shade/src/patterns.ts index db148e08146..22d8ea3f8be 100644 --- a/apps/shade/src/patterns.ts +++ b/apps/shade/src/patterns.ts @@ -3,6 +3,8 @@ export * from './components/features/filters/filters'; export {default as ColorPicker} from './components/features/color-picker/color-picker'; export type {ColorPickerProps} from './components/features/color-picker/color-picker'; export {default as PostShareModal} from './components/features/post-share-modal'; +export {default as ShareModal} from './components/features/share-modal'; +export type {ShareModalSocialLink} from './components/features/share-modal'; export * from './components/features/table-filter-tabs/table-filter-tabs'; export * from './components/features/utm-campaign-tabs/utm-campaign-tabs'; export type {CampaignType, TabType} from './components/features/utm-campaign-tabs/utm-campaign-tabs'; diff --git a/e2e/helpers/pages/admin/index.ts b/e2e/helpers/pages/admin/index.ts index 2e5a433a799..b8a837912e1 100644 --- a/e2e/helpers/pages/admin/index.ts +++ b/e2e/helpers/pages/admin/index.ts @@ -7,6 +7,7 @@ export * from './login-verify-page'; export * from './settings'; export * from './whats-new'; export * from './analytics'; +export * from './onboarding'; export * from './posts'; export * from './tags'; export * from './sidebar'; diff --git a/e2e/helpers/pages/admin/onboarding/index.ts b/e2e/helpers/pages/admin/onboarding/index.ts new file mode 100644 index 00000000000..f7449e6951c --- /dev/null +++ b/e2e/helpers/pages/admin/onboarding/index.ts @@ -0,0 +1 @@ +export * from './onboarding-page'; diff --git a/e2e/helpers/pages/admin/onboarding/onboarding-page.ts b/e2e/helpers/pages/admin/onboarding/onboarding-page.ts new file mode 100644 index 00000000000..994b550d3da --- /dev/null +++ b/e2e/helpers/pages/admin/onboarding/onboarding-page.ts @@ -0,0 +1,25 @@ +import {AdminPage} from '@/admin-pages'; +import {Locator, Page} from '@playwright/test'; + +export class OnboardingPage extends AdminPage { + public readonly checklist: Locator; + public readonly completeButton: Locator; + public readonly copyLinkButton: Locator; + public readonly shareModal: Locator; + public readonly skipButton: Locator; + + constructor(page: Page) { + super(page); + + this.pageUrl = '/ghost/#/setup/onboarding'; + this.checklist = page.getByTestId('onboarding-checklist'); + this.completeButton = page.getByTestId('onboarding-complete'); + this.copyLinkButton = page.getByTestId('onboarding-copy-link'); + this.shareModal = page.getByTestId('onboarding-share-modal'); + this.skipButton = page.getByTestId('onboarding-skip'); + } + + step(stepId: string) { + return this.page.getByTestId(`onboarding-step-${stepId}`); + } +} diff --git a/e2e/tests/admin/onboarding.test.ts b/e2e/tests/admin/onboarding.test.ts new file mode 100644 index 00000000000..3249c152983 --- /dev/null +++ b/e2e/tests/admin/onboarding.test.ts @@ -0,0 +1,208 @@ +import {AnalyticsOverviewPage, OnboardingPage} from '@/admin-pages'; +import {expect, test} from '@/helpers/playwright'; +import type {Page} from '@playwright/test'; + +type ChecklistState = 'pending' | 'started' | 'completed' | 'dismissed'; + +const allSteps = ['customize-design', 'first-post', 'build-audience', 'share-publication']; +const activeStartedAt = '2026-05-01T00:00:00.000Z'; +const navigationSteps: Array<[string, RegExp]> = [ + ['customize-design', /\/ghost\/#\/settings\/design\/edit\?ref=setup/], + ['first-post', /\/ghost\/#\/editor\/post/], + ['build-audience', /\/ghost\/#\/members/] +]; + +test.use({isolation: 'per-test'}); + +async function getCurrentUser(page: Page) { + const response = await page.request.get('/ghost/api/admin/users/me/?include=roles'); + expect(response.ok()).toBe(true); + + const body = await response.json(); + return body.users[0]; +} + +async function setOnboardingState(page: Page, checklistState: ChecklistState, completedSteps: string[] = [], startedAt: string | null | undefined = checklistState === 'started' ? activeStartedAt : undefined) { + const user = await getCurrentUser(page); + const preferences = user.accessibility ? JSON.parse(user.accessibility) : {}; + + preferences.onboarding = { + completedSteps, + checklistState + }; + + if (startedAt) { + preferences.onboarding.startedAt = startedAt; + } + + const response = await page.request.put(`/ghost/api/admin/users/${user.id}/?include=roles`, { + data: { + users: [{ + ...user, + accessibility: JSON.stringify(preferences) + }] + } + }); + expect(response.ok()).toBe(true); + + await page.reload({waitUntil: 'load'}); +} + +async function getOnboardingPreferences(page: Page) { + const user = await getCurrentUser(page); + const preferences = user.accessibility ? JSON.parse(user.accessibility) : {}; + + return preferences.onboarding; +} + +async function expectOnboardingRoute(page: Page, {returnTo = '/analytics'}: {returnTo?: string} = {}) { + await expect(page).toHaveURL((url) => { + const hashUrl = new URL(url.hash.slice(1), 'http://ghost.local'); + + return hashUrl.pathname === '/setup/onboarding' && hashUrl.searchParams.get('returnTo') === returnTo; + }); +} + +async function startOnboarding(page: Page) { + await setOnboardingState(page, 'started'); + await page.goto(`/ghost/?onboardingTest=${Date.now()}#/setup/onboarding?returnTo=%2Fanalytics`); + + const onboardingPage = new OnboardingPage(page); + await expect(onboardingPage.checklist).toBeVisible(); +} + +test.describe('Ghost Admin - Onboarding Checklist', () => { + test('new owner setup flow lands on onboarding', async ({page}) => { + await setOnboardingState(page, 'pending', ['customize-design']); + + await page.goto('/ghost/#/setup/done'); + + const onboardingPage = new OnboardingPage(page); + await expect(onboardingPage.checklist).toBeVisible(); + await expectOnboardingRoute(page); + + const preferences = await getOnboardingPreferences(page); + expect(preferences).toMatchObject({ + checklistState: 'started', + completedSteps: [] + }); + expect(typeof preferences.startedAt).toBe('string'); + }); + + test('analytics routes redirect to onboarding while active', async ({page}) => { + await startOnboarding(page); + + await page.goto('/ghost/#/analytics'); + + let onboardingPage = new OnboardingPage(page); + await expect(onboardingPage.checklist).toBeVisible(); + await expectOnboardingRoute(page); + + await page.goto('/ghost/#/analytics/web'); + + onboardingPage = new OnboardingPage(page); + await expect(onboardingPage.checklist).toBeVisible(); + await expectOnboardingRoute(page, {returnTo: '/analytics/web'}); + }); + + test('completed and dismissed users reach Analytics normally', async ({page}) => { + const analyticsPage = new AnalyticsOverviewPage(page); + + await setOnboardingState(page, 'completed', allSteps); + await analyticsPage.goto(); + await expect(analyticsPage.header).toBeVisible(); + + await setOnboardingState(page, 'dismissed'); + await analyticsPage.goto(); + await expect(analyticsPage.header).toBeVisible(); + }); + + test('pending users reach Analytics normally and are not started by the React route', async ({page}) => { + const analyticsPage = new AnalyticsOverviewPage(page); + + await setOnboardingState(page, 'pending', ['customize-design']); + await analyticsPage.goto(); + await expect(analyticsPage.header).toBeVisible(); + + await page.goto('/ghost/#/setup/onboarding?returnTo=%2Fanalytics%3Fsource%3Dweb'); + await expect(page).toHaveURL(/\/ghost\/#\/analytics\?source=web$/); + + const preferences = await getOnboardingPreferences(page); + expect(preferences).toMatchObject({ + checklistState: 'pending', + completedSteps: ['customize-design'] + }); + }); + + test('legacy started users without startedAt reach Analytics and are dismissed', async ({page}) => { + const analyticsPage = new AnalyticsOverviewPage(page); + + await setOnboardingState(page, 'started', [], null); + await analyticsPage.goto(); + + await expect(analyticsPage.header).toBeVisible(); + + await expect.poll(async () => { + return (await getOnboardingPreferences(page))?.checklistState; + }).toBe('dismissed'); + + await expect.poll(async () => { + return (await getOnboardingPreferences(page))?.completedSteps; + }).toEqual([]); + }); + + navigationSteps.forEach(([step, expectedUrl]) => { + test(`${step} step marks complete and navigates`, async ({page}) => { + await startOnboarding(page); + + const onboardingPage = new OnboardingPage(page); + await onboardingPage.step(step).click(); + + await expect(page).toHaveURL(expectedUrl); + + const preferences = await getOnboardingPreferences(page); + expect(preferences.completedSteps).toContain(step); + }); + }); + + test('share step opens the dialog and marks the step complete', async ({page}) => { + await startOnboarding(page); + + const onboardingPage = new OnboardingPage(page); + await onboardingPage.step('share-publication').click(); + + await expect(onboardingPage.shareModal).toBeVisible(); + + const preferences = await getOnboardingPreferences(page); + expect(preferences.completedSteps).toContain('share-publication'); + }); + + test('skip returns to the preserved analytics URL', async ({page}) => { + await startOnboarding(page); + await page.goto('/ghost/#/analytics?source=web'); + + const onboardingPage = new OnboardingPage(page); + await expectOnboardingRoute(page, {returnTo: '/analytics?source=web'}); + await onboardingPage.skipButton.click(); + + await expect(page).toHaveURL(/\/ghost\/#\/analytics\?source=web$/); + + const preferences = await getOnboardingPreferences(page); + expect(preferences.checklistState).toBe('dismissed'); + }); + + test('completing all steps returns to the preserved analytics URL', async ({page}) => { + await startOnboarding(page); + await setOnboardingState(page, 'started', allSteps); + await page.goto('/ghost/#/setup/onboarding?returnTo=%2Fanalytics%3Fsource%3Dweb'); + + const onboardingPage = new OnboardingPage(page); + await expectOnboardingRoute(page, {returnTo: '/analytics?source=web'}); + await onboardingPage.completeButton.click(); + + await expect(page).toHaveURL(/\/ghost\/#\/analytics\?source=web$/); + + const preferences = await getOnboardingPreferences(page); + expect(preferences.checklistState).toBe('completed'); + }); +}); diff --git a/ghost/admin/app/helpers/parse-member-event.js b/ghost/admin/app/helpers/parse-member-event.js index 11407c6e9fd..91a9ddcc22f 100644 --- a/ghost/admin/app/helpers/parse-member-event.js +++ b/ghost/admin/app/helpers/parse-member-event.js @@ -148,6 +148,10 @@ export default class ParseMemberEventHelper extends Helper { icon = 'gift'; } + if (event.type === 'gift_ended_event') { + icon = 'subscriptions'; + } + if (event.type === 'email_change_event') { icon = 'email-changed'; } @@ -279,6 +283,10 @@ export default class ParseMemberEventHelper extends Helper { if (event.type === 'gift_redemption_event') { return 'started paid subscription via gift'; } + + if (event.type === 'gift_ended_event') { + return 'ended paid subscription'; + } } /** diff --git a/ghost/admin/app/routes/setup/done.js b/ghost/admin/app/routes/setup/done.js index edeaa025a36..0b4fce120f7 100644 --- a/ghost/admin/app/routes/setup/done.js +++ b/ghost/admin/app/routes/setup/done.js @@ -6,19 +6,19 @@ export default class SetupFinishingTouchesRoute extends AuthenticatedRoute { @inject config; @service feature; @service onboarding; - @service router; @service session; @service settings; - beforeModel() { - super.beforeModel(...arguments); + async beforeModel() { + await super.beforeModel(...arguments); if (this.session.user.isOwnerOnly) { - this.onboarding.startChecklist(); + await this.onboarding.startChecklist(); } if (this.session.user?.isAdmin) { - return this.router.transitionTo('/analytics'); + // The React admin app owns /setup/onboarding, so hand off via hash navigation. + window.location.hash = '/setup/onboarding?returnTo=/analytics'; } } } diff --git a/ghost/admin/app/services/onboarding.js b/ghost/admin/app/services/onboarding.js index 26c8338d82a..951fee10d95 100644 --- a/ghost/admin/app/services/onboarding.js +++ b/ghost/admin/app/services/onboarding.js @@ -3,7 +3,8 @@ import {action} from '@ember/object'; const EMPTY_SETTINGS = { completedSteps: [], - checklistState: 'pending' // pending, started, completed, dismissed + checklistState: 'pending', // pending, started, completed, dismissed + startedAt: undefined }; export default class OnboardingService extends Service { @@ -65,6 +66,7 @@ export default class OnboardingService extends Service { settings.completedSteps = []; settings.checklistState = 'started'; + settings.startedAt = new Date().toISOString(); await this._saveSettings(settings); } diff --git a/ghost/admin/app/utils/member-event-types.js b/ghost/admin/app/utils/member-event-types.js index 1f20b32a200..94db184f3f6 100644 --- a/ghost/admin/app/utils/member-event-types.js +++ b/ghost/admin/app/utils/member-event-types.js @@ -37,9 +37,11 @@ export function toggleEventType(eventType, currentExcludedEvents = []) { if (excludedEvents.has('subscription_event')) { excludedEvents.delete('subscription_event'); excludedEvents.delete('gift_redemption_event'); + excludedEvents.delete('gift_ended_event'); } else { excludedEvents.add('subscription_event'); excludedEvents.add('gift_redemption_event'); + excludedEvents.add('gift_ended_event'); } } else if (eventType === 'payment_event') { if (excludedEvents.has('payment_event')) { diff --git a/ghost/admin/mirage/config/authentication.js b/ghost/admin/mirage/config/authentication.js index c178278474b..61b7a055f06 100644 --- a/ghost/admin/mirage/config/authentication.js +++ b/ghost/admin/mirage/config/authentication.js @@ -60,13 +60,13 @@ export default function mockAuthentication(server) { /* Setup ---------------------------------------------------------------- */ - server.post('/authentication/setup', function ({roles, users}, request) { - let attrs = JSON.parse(request.requestBody).setup; - let role = roles.findBy({name: 'Owner'}); + server.post('/authentication/setup', function (schema, request) { + let [attrs] = JSON.parse(request.requestBody).setup; + let role = schema.roles.findBy({name: 'Owner'}); // create owner role unless already exists if (!role) { - role = roles.create({name: 'Owner'}); + role = schema.roles.create({name: 'Owner'}); } attrs.roles = [role]; @@ -74,8 +74,7 @@ export default function mockAuthentication(server) { attrs.slug = dasherize(attrs.email.split('@')[0]); } - // NOTE: server does not use the user factory to fill in blank fields - return users.create(attrs); + return schema.create('user', attrs); }); server.get('/authentication/setup/', function () { diff --git a/ghost/admin/tests/acceptance/onboarding-test.js b/ghost/admin/tests/acceptance/onboarding-test.js index 0958b9ce2a6..b0d9f87c057 100644 --- a/ghost/admin/tests/acceptance/onboarding-test.js +++ b/ghost/admin/tests/acceptance/onboarding-test.js @@ -1,6 +1,6 @@ import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support'; import {cleanupMockAnalyticsApps, mockAnalyticsApps} from '../helpers/mock-analytics-apps'; -import {currentURL, find, visit} from '@ember/test-helpers'; +import {currentURL, find, visit, waitUntil} from '@ember/test-helpers'; import {describe, it} from 'mocha'; import {enableMembers} from '../helpers/members'; import {expect} from 'chai'; @@ -45,6 +45,19 @@ describe('Acceptance: Onboarding', function () { // Onboarding checklist tests removed — checklist is now rendered by // the React analytics app, not Ember. + + it('setup/done starts onboarding and redirects to the React onboarding route', async function () { + await visit('/setup/done'); + + await waitUntil(() => window.location.hash === '#/setup/onboarding?returnTo=/analytics'); + expect(window.location.hash).to.equal('#/setup/onboarding?returnTo=/analytics'); + + let user = this.server.schema.users.first(); + let preferences = JSON.parse(user.accessibility); + expect(preferences.onboarding.completedSteps).to.deep.equal([]); + expect(preferences.onboarding.checklistState).to.equal('started'); + expect(preferences.onboarding.startedAt).to.match(/^\d{4}-\d{2}-\d{2}T/); + }); }); describe('checklist (non-owner)', function () { @@ -61,6 +74,16 @@ describe('Acceptance: Onboarding', function () { // onboarding isn't shown expect(checklist()).to.not.exist; }); + + it('setup/done redirects to the React onboarding route without starting onboarding', async function () { + await visit('/setup/done'); + + await waitUntil(() => window.location.hash === '#/setup/onboarding?returnTo=/analytics'); + expect(window.location.hash).to.equal('#/setup/onboarding?returnTo=/analytics'); + + let user = this.server.schema.users.first(); + expect(user.accessibility).to.be.null; + }); }); describe('unauthenticated', function () { diff --git a/ghost/admin/tests/acceptance/setup-test.js b/ghost/admin/tests/acceptance/setup-test.js index e8be65a1ed1..4b72f12ee6b 100644 --- a/ghost/admin/tests/acceptance/setup-test.js +++ b/ghost/admin/tests/acceptance/setup-test.js @@ -2,7 +2,7 @@ import {Response} from 'miragejs'; import {afterEach, beforeEach, describe, it} from 'mocha'; import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support'; import {cleanupMockAnalyticsApps, mockAnalyticsApps} from '../helpers/mock-analytics-apps'; -import {click, currentURL, fillIn, find, findAll} from '@ember/test-helpers'; +import {click, currentURL, fillIn, find, findAll, waitUntil} from '@ember/test-helpers'; import {expect} from 'chai'; import {setupApplicationTest} from 'ember-mocha'; import {setupMirage} from 'ember-cli-mirage/test-support'; @@ -32,6 +32,9 @@ describe('Acceptance: Setup', function () { if (!server.schema.settings.all().length) { server.loadFixtures('settings'); } + if (!server.schema.themes.all().length) { + server.loadFixtures('themes'); + } // mimick a new blog server.get('/authentication/setup/', function () { @@ -120,9 +123,10 @@ describe('Acceptance: Setup', function () { await fillIn('[data-test-blog-title-input]', 'Blog Title'); await click('[data-test-button="setup"]'); - // it redirects to the dashboard - expect(currentURL(), 'url after submitting account details') - .to.equal('/analytics'); + // it starts onboarding and hands off to the React onboarding route + await waitUntil(() => window.location.hash === '#/setup/onboarding?returnTo=/analytics'); + expect(window.location.hash, 'url after submitting account details') + .to.equal('#/setup/onboarding?returnTo=/analytics'); }); it('handles validation errors in setup', async function () { @@ -214,15 +218,20 @@ describe('Acceptance: Setup', function () { describe('?firstStart=true', function () { beforeEach(async function () { + this.server.loadFixtures('configs'); + this.server.loadFixtures('settings'); + this.server.loadFixtures('themes'); + let role = this.server.create('role', {name: 'Owner'}); this.server.create('user', {roles: [role], slug: 'owner'}); await authenticateSession(); }); - it('transitions to dashboard', async function () { + it('transitions to onboarding', async function () { await visit('/?firstStart=true'); - expect(currentURL()).to.equal('/analytics'); + await waitUntil(() => window.location.hash === '#/setup/onboarding?returnTo=/analytics'); + expect(window.location.hash).to.equal('#/setup/onboarding?returnTo=/analytics'); }); }); }); diff --git a/ghost/admin/tests/unit/helpers/parse-member-event-test.js b/ghost/admin/tests/unit/helpers/parse-member-event-test.js index dd32e73b645..cef76c440b3 100644 --- a/ghost/admin/tests/unit/helpers/parse-member-event-test.js +++ b/ghost/admin/tests/unit/helpers/parse-member-event-test.js @@ -113,4 +113,18 @@ describe('Unit: Helper: parse-member-event', function () { expect(result.info).to.equal('Free'); }); }); + + describe('gift_ended_event', function () { + it('returns "ended paid subscription" action', function () { + const event = buildEvent({type: 'gift_ended_event'}); + const result = helper.compute([event]); + expect(result.action).to.equal('ended paid subscription'); + }); + + it('returns "event-subscriptions" icon', function () { + const event = buildEvent({type: 'gift_ended_event'}); + const result = helper.compute([event]); + expect(result.icon).to.equal('event-subscriptions'); + }); + }); }); diff --git a/ghost/admin/tests/unit/utils/member-event-types-test.js b/ghost/admin/tests/unit/utils/member-event-types-test.js index 41b4a151df5..7f6f04e196b 100644 --- a/ghost/admin/tests/unit/utils/member-event-types-test.js +++ b/ghost/admin/tests/unit/utils/member-event-types-test.js @@ -29,14 +29,14 @@ describe('Unit | Utility | event-type-utils', function () { expect(newExcludedEvents).to.equal(''); }); - it('should toggle subscription_event together with gift_redemption_event', function () { + it('should toggle subscription_event together with gift_redemption_event and gift_ended_event', function () { const newExcludedEvents = toggleEventType('subscription_event', []); - expect(newExcludedEvents).to.equal('subscription_event,gift_redemption_event'); + expect(newExcludedEvents).to.equal('subscription_event,gift_redemption_event,gift_ended_event'); }); it('should toggle subscription_event group off when toggling subscription_event off', function () { - const newExcludedEvents = toggleEventType('subscription_event', ['subscription_event', 'gift_redemption_event']); + const newExcludedEvents = toggleEventType('subscription_event', ['subscription_event', 'gift_redemption_event', 'gift_ended_event']); expect(newExcludedEvents).to.equal(''); }); @@ -44,19 +44,19 @@ describe('Unit | Utility | event-type-utils', function () { it('should preserve previously-excluded payment group when toggling subscription_event', function () { const newExcludedEvents = toggleEventType('subscription_event', ['payment_event', 'donation_event', 'gift_purchase_event']); - expect(newExcludedEvents).to.equal('payment_event,donation_event,gift_purchase_event,subscription_event,gift_redemption_event'); + expect(newExcludedEvents).to.equal('payment_event,donation_event,gift_purchase_event,subscription_event,gift_redemption_event,gift_ended_event'); }); it('should preserve previously-excluded subscription group when toggling payment_event', function () { - const newExcludedEvents = toggleEventType('payment_event', ['subscription_event', 'gift_redemption_event']); + const newExcludedEvents = toggleEventType('payment_event', ['subscription_event', 'gift_redemption_event', 'gift_ended_event']); - expect(newExcludedEvents).to.equal('subscription_event,gift_redemption_event,payment_event,donation_event,gift_purchase_event'); + expect(newExcludedEvents).to.equal('subscription_event,gift_redemption_event,gift_ended_event,payment_event,donation_event,gift_purchase_event'); }); it('should accept a comma-separated string for currentExcludedEvents', function () { const newExcludedEvents = toggleEventType('subscription_event', 'payment_event,donation_event,gift_purchase_event'); - expect(newExcludedEvents).to.equal('payment_event,donation_event,gift_purchase_event,subscription_event,gift_redemption_event'); + expect(newExcludedEvents).to.equal('payment_event,donation_event,gift_purchase_event,subscription_event,gift_redemption_event,gift_ended_event'); }); it('should return correct divider need based on event groups', function () { diff --git a/ghost/core/core/server/adapters/storage/S3Storage.ts b/ghost/core/core/server/adapters/storage/S3Storage.ts index 433c1b9fce0..aec0fe146c0 100644 --- a/ghost/core/core/server/adapters/storage/S3Storage.ts +++ b/ghost/core/core/server/adapters/storage/S3Storage.ts @@ -395,7 +395,7 @@ export default class S3Storage extends StorageBase { }); } - const pathWithStorage = path.posix.join(this.storagePath, relativePath); + const pathWithStorage = path.posix.join(this.storagePath, this.toCanonicalRelativePath(relativePath)); if (!pathWithStorage.startsWith(this.storagePath + '/') && pathWithStorage !== this.storagePath) { throw new errors.IncorrectUsageError({ @@ -410,6 +410,42 @@ export default class S3Storage extends StorageBase { return `${this.tenantPrefix}/${pathWithStorage}`; } + private toCanonicalRelativePath(input: string): string { + return this.fromAbsoluteFilesystemPath(input) + ?? this.fromStoragePathPrefixed(input) + ?? this.fromLeadingSlashPath(input) + ?? input; + } + + private fromAbsoluteFilesystemPath(input: string): string | null { + if (!path.posix.isAbsolute(input)) { + return null; + } + const marker = `/${this.storagePath}/`; + const idx = input.lastIndexOf(marker); + if (idx !== -1) { + return input.slice(idx + marker.length); + } + if (input.endsWith(`/${this.storagePath}`)) { + return ''; + } + return null; + } + + private fromStoragePathPrefixed(input: string): string | null { + if (input === this.storagePath || input.startsWith(`${this.storagePath}/`)) { + return path.posix.relative(this.storagePath, input); + } + return null; + } + + private fromLeadingSlashPath(input: string): string | null { + if (!path.posix.isAbsolute(input)) { + return null; + } + return input.replace(/^\/+/, ''); + } + private isNotFound(error: unknown): boolean { return error instanceof NotFound || error instanceof NoSuchKey; } diff --git a/ghost/core/core/server/services/gifts/gift-service.ts b/ghost/core/core/server/services/gifts/gift-service.ts index 5e261b873f4..7130e774926 100644 --- a/ghost/core/core/server/services/gifts/gift-service.ts +++ b/ghost/core/core/server/services/gifts/gift-service.ts @@ -5,6 +5,7 @@ import {Gift} from './gift'; import type {GiftRepository} from './gift-repository'; import tpl from '@tryghost/tpl'; import {GIFT_REMINDER_FLOOR_DAYS, GIFT_REMINDER_LEAD_DAYS} from './constants'; +import {MEMBER_WELCOME_EMAIL_SLUGS} from '../member-welcome-emails/constants'; const MS_PER_DAY = 24 * 60 * 60 * 1000; const GIFT_REMINDER_LEAD_MS = GIFT_REMINDER_LEAD_DAYS * MS_PER_DAY; @@ -37,6 +38,7 @@ interface MemberModel { interface MemberRepository { get(filter: Record, options?: Record): Promise; update(data: Record, options?: Record): Promise; + enqueueWelcomeEmailRun(memberId: string, slug: string, options?: Record): Promise; } type Tier = { @@ -325,6 +327,9 @@ export class GiftService { await this.deps.giftRepository.update(redeemed, {transacting}); + // Gift members receive the paid welcome email, as they receive access to paid content + await this.deps.memberRepository.enqueueWelcomeEmailRun(memberId, MEMBER_WELCOME_EMAIL_SLUGS.paid, {transacting}); + return {redeemed, member}; }; diff --git a/ghost/core/core/server/services/members/members-api/repositories/event-repository.js b/ghost/core/core/server/services/members/members-api/repositories/event-repository.js index ba9c67cbc15..2f8f8ec4b5b 100644 --- a/ghost/core/core/server/services/members/members-api/repositories/event-repository.js +++ b/ghost/core/core/server/services/members/members-api/repositories/event-repository.js @@ -86,7 +86,8 @@ module.exports = class EventRepository { {type: 'payment_event', action: 'getPaymentEvents'}, {type: 'email_change_event', action: 'getEmailChangeEvent'}, {type: 'gift_purchase_event', action: 'getGiftPurchaseEvents'}, - {type: 'gift_redemption_event', action: 'getGiftRedemptionEvents'} + {type: 'gift_redemption_event', action: 'getGiftRedemptionEvents'}, + {type: 'gift_ended_event', action: 'getGiftEndedEvents'} ); if (this._AutomatedEmailRecipient) { @@ -136,6 +137,7 @@ module.exports = class EventRepository { login_event: 0, subscription_event: 1, gift_redemption_event: 1, + gift_ended_event: 1, newsletter_event: 2, signup_event: 3 }; @@ -253,10 +255,6 @@ module.exports = class EventRepository { // Prevent toJSON on stripeSubscription (we don't have everything loaded) delete model.relations.stripeSubscription; - // paidStatusEvent is a helper relation only used to derive previous_status above - if (subscriptionCreatedEvent && subscriptionCreatedEvent.id) { - delete subscriptionCreatedEvent.relations.paidStatusEvent; - } const d = { ...model.toJSON(options), attribution: model.get('type') === 'created' && subscriptionCreatedEvent && subscriptionCreatedEvent.id ? this._memberAttributionService.getEventAttribution(subscriptionCreatedEvent) : null, @@ -265,6 +263,7 @@ module.exports = class EventRepository { tierName }; delete d.stripeSubscription; + delete d.subscriptionCreatedEvent?.paidStatusEvent; return { type: 'subscription_event', data: d @@ -554,6 +553,46 @@ module.exports = class EventRepository { }; } + async getGiftEndedEvents(options = {}, filter) { + options = { + ...options, + withRelated: ['member'], + filter: 'from_status:gift+to_status:free+custom:true', + useBasicCount: true, + mongoTransformer: chainTransformers( + // First set the filter manually + replaceCustomFilterTransformer(filter), + + // Map the used keys in that filter + ...mapKeys({ + 'data.created_at': 'created_at', + 'data.member_id': 'member_id' + }) + ) + }; + + const {data: models, meta} = await this._MemberStatusEvent.findPage(options); + + const data = models.map((model) => { + const json = model.toJSON(options); + + return { + type: 'gift_ended_event', + data: { + id: json.id, + member: json.member || null, + member_id: json.member_id, + created_at: json.created_at + } + }; + }); + + return { + data, + meta + }; + } + async getCommentEvents(options = {}, filter) { options = { ...options, diff --git a/ghost/core/core/server/services/members/members-api/repositories/member-repository.js b/ghost/core/core/server/services/members/members-api/repositories/member-repository.js index 9551df91055..1388400decf 100644 --- a/ghost/core/core/server/services/members/members-api/repositories/member-repository.js +++ b/ghost/core/core/server/services/members/members-api/repositories/member-repository.js @@ -173,6 +173,57 @@ module.exports = class MemberRepository { return nickname && nickname.toLowerCase() === 'complimentary'; } + /** + * Looks up the active welcome email automation for the given slug and enqueues a + * `WelcomeEmailAutomationRun` for the member. Dispatches `StartAutomationsPollEvent` + * so the poll picks it up. Returns the created run, or null if there is no active + * automation/email for that slug. + * + * Callers are responsible for any eligibility gating (member status, source, etc.) + * before calling this — this helper just looks up + inserts + dispatches. Pass + * `options.transacting` to run the insert inside an existing transaction; the + * dispatch is automatically deferred until that transaction commits. + * + * @param {string} memberId + * @param {string} slug automation slug, see MEMBER_WELCOME_EMAIL_SLUGS + * @param {object} [options] bookshelf options (transacting, context, etc.) + */ + async enqueueWelcomeEmailRun(memberId, slug, options = {}) { + if (!this._WelcomeEmailAutomation || !this._WelcomeEmailAutomationRun) { + return null; + } + + const automation = await this._WelcomeEmailAutomation.findOne( + {slug}, + {...options, withRelated: ['welcomeEmailAutomatedEmail']} + ); + const email = automation?.related('welcomeEmailAutomatedEmail'); + const isActive = Boolean( + automation && + email && + email.get('lexical') && + automation.get('status') === 'active' + ); + + if (!isActive) { + return null; + } + + const run = await this._WelcomeEmailAutomationRun.add({ + welcome_email_automation_id: automation.id, + member_id: memberId, + next_welcome_email_automated_email_id: email.id, + ready_at: new Date(), + step_started_at: null, + step_attempts: 0, + exit_reason: null + }, options); + + this.dispatchEvent(StartAutomationsPollEvent.create(), options); + + return run; + } + /** * Maps the framework context to members_*.source table record value * @param {Object} context instance of ghost framework context object @@ -382,50 +433,15 @@ module.exports = class MemberRepository { let member; const isFreeSignup = !stripeCustomer && memberData.status === 'free'; - const isGiftSignup = !stripeCustomer && memberData.status === 'gift'; - let welcomeEmailToEnqueue = null; - - if (this._WelcomeEmailAutomation && WELCOME_EMAIL_SOURCES.includes(source)) { - const getActiveWelcomeEmailToEnqueue = async (slug) => { - const automation = await this._WelcomeEmailAutomation.findOne( - {slug}, - {...options, withRelated: ['welcomeEmailAutomatedEmail']} - ); - const email = automation?.related('welcomeEmailAutomatedEmail'); - const isActive = Boolean( - automation && - email && - email.get('lexical') && - automation.get('status') === 'active' - ); - - return isActive ? {automation, email} : null; - }; - - if (isFreeSignup) { - welcomeEmailToEnqueue = await getActiveWelcomeEmailToEnqueue(MEMBER_WELCOME_EMAIL_SLUGS.free); - } else if (isGiftSignup) { - // As gift members get access to a paid tier, they receive the paid welcome email - welcomeEmailToEnqueue = await getActiveWelcomeEmailToEnqueue(MEMBER_WELCOME_EMAIL_SLUGS.paid); - } - } - if (welcomeEmailToEnqueue) { + if (isFreeSignup && WELCOME_EMAIL_SOURCES.includes(source)) { const runMemberCreation = async (transacting) => { const newMember = await this._Member.add({ ...memberData, labels }, {...memberAddOptions, transacting}); - await this._WelcomeEmailAutomationRun.add({ - welcome_email_automation_id: welcomeEmailToEnqueue.automation.id, - member_id: newMember.id, - next_welcome_email_automated_email_id: welcomeEmailToEnqueue.email.id, - ready_at: new Date(), - step_started_at: null, - step_attempts: 0, - exit_reason: null - }, {transacting}); + await this.enqueueWelcomeEmailRun(newMember.id, MEMBER_WELCOME_EMAIL_SLUGS.free, {transacting}); return newMember; }; @@ -435,8 +451,6 @@ module.exports = class MemberRepository { } else { member = await this._Member.transaction(runMemberCreation); } - - this.dispatchEvent(StartAutomationsPollEvent.create(), memberAddOptions); } else { member = await this._Member.add({ ...memberData, @@ -1516,38 +1530,17 @@ module.exports = class MemberRepository { const context = options?.context || {}; const source = this._resolveContextSource(context); - const shouldSendPaidWelcomeEmail = WELCOME_EMAIL_SOURCES.includes(source); - let isPaidWelcomeEmailActive = false; - let paidWelcomeAutomation = null; - let paidWelcomeEmail = null; - if (shouldSendPaidWelcomeEmail && this._WelcomeEmailAutomation) { - paidWelcomeAutomation = await this._WelcomeEmailAutomation.findOne( - {slug: MEMBER_WELCOME_EMAIL_SLUGS.paid}, - {...options, withRelated: ['welcomeEmailAutomatedEmail']} - ); - paidWelcomeEmail = paidWelcomeAutomation?.related('welcomeEmailAutomatedEmail'); - isPaidWelcomeEmailActive = Boolean( - paidWelcomeAutomation && - paidWelcomeEmail && - paidWelcomeEmail.get('lexical') && - paidWelcomeAutomation.get('status') === 'active' - ); - } - // Send paid welcome email if: - // 1. The paid welcome email is active + + // Enqueue paid welcome email if: + // 1. The source is allowed to send welcome emails // 2. The member status changed to 'paid' - // 3. The previous status wasn't 'gift', as gift members already received the paid welcome email on signup - if (updatedMember.get('status') === 'paid' && updatedMember._previousAttributes.status !== 'gift' && isPaidWelcomeEmailActive) { - await this._WelcomeEmailAutomationRun.add({ - welcome_email_automation_id: paidWelcomeAutomation.id, - member_id: memberModel.id, - next_welcome_email_automated_email_id: paidWelcomeEmail.id, - ready_at: new Date(), - step_started_at: null, - step_attempts: 0, - exit_reason: null - }, options); - this.dispatchEvent(StartAutomationsPollEvent.create(), options); + // 3. The previous status wasn't 'gift', as gift members already received the paid welcome email on redemption + if ( + WELCOME_EMAIL_SOURCES.includes(source) && + updatedMember.get('status') === 'paid' && + updatedMember._previousAttributes.status !== 'gift' + ) { + await this.enqueueWelcomeEmailRun(memberModel.id, MEMBER_WELCOME_EMAIL_SLUGS.paid, options); } } } diff --git a/ghost/core/core/shared/config/overrides.json b/ghost/core/core/shared/config/overrides.json index fdc3d03fe9c..cd23b412f40 100644 --- a/ghost/core/core/shared/config/overrides.json +++ b/ghost/core/core/shared/config/overrides.json @@ -44,53 +44,105 @@ }, "files": { "extensions": [ - ".pdf", - ".json", - ".jsonld", - ".ods", - ".odt", - ".pptx", - ".rtf", - ".txt", - ".xls", - ".xlsx", - ".xml", + ".7z", + ".aac", + ".ai", + ".aif", + ".aiff", ".apkg", + ".arw", + ".avif", + ".avi", + ".blend", + ".bmp", + ".bz2", + ".cr2", + ".cr3", ".css", ".csv", + ".dng", ".doc", ".docx", + ".eot", ".epub", + ".eps", + ".fb2", + ".fbx", + ".flac", + ".geojson", ".gif", + ".glb", + ".gltf", ".gpx", + ".gz", + ".heic", + ".heif", ".html", ".ics", ".ipynb", ".jpeg", ".jpg", ".js", + ".json", + ".jsonld", ".key", ".kml", ".m4a", + ".m4v", ".md", + ".mid", + ".midi", + ".mkv", ".mobi", ".mov", ".mp3", ".mp4", + ".nef", + ".np3", + ".numbers", + ".obj", + ".odp", + ".ods", + ".odt", + ".ogv", + ".opus", + ".orf", ".otf", ".pages", ".paprikarecipes", + ".pdf", ".png", + ".ppt", + ".pptx", ".psd", ".py", + ".raf", + ".rar", + ".rtf", + ".rw2", ".skp", + ".stl", ".svg", + ".tar", + ".tgz", + ".tif", + ".tiff", + ".toml", + ".tsv", + ".ttf", + ".txt", + ".vcf", ".wav", + ".webm", ".webp", ".woff", ".woff2", + ".xcf", + ".xls", ".xlsb", ".xlsm", + ".xlsx", + ".xml", ".yaml", ".zip" ], diff --git a/ghost/core/test/e2e-api/members/gift-subscriptions.test.js b/ghost/core/test/e2e-api/members/gift-subscriptions.test.js index c62b43163c0..cd6c9cae188 100644 --- a/ghost/core/test/e2e-api/members/gift-subscriptions.test.js +++ b/ghost/core/test/e2e-api/members/gift-subscriptions.test.js @@ -873,7 +873,7 @@ describe('Gift Subscriptions', function () { const email = 'gift-existing-member@test.com'; // Create member first - await models.Member.add({email, name: 'Existing Member', email_disabled: false}); + const existingMember = await models.Member.add({email, name: 'Existing Member', email_disabled: false}); await DomainEvents.allSettled(); const gift = await createGift(); @@ -881,13 +881,54 @@ describe('Gift Subscriptions', function () { const redirectUrl = new URL(urlUtils.getSiteUrl()); redirectUrl.hash = '#/portal/account?giftRedemption=true'; + let freeWelcomeAutomation; + let paidWelcomeAutomation; + try { + // Set up both free and paid welcome email automations to verify gift + // redemption picks the paid welcome email (not the free one) — same as + // the new-member case above. + const emailDesignSetting = await models.EmailDesignSetting.findOne( + {slug: 'default-automated-email'}, + {require: true} + ); + freeWelcomeAutomation = await models.WelcomeEmailAutomation.add({ + name: 'Free welcome email', + slug: 'member-welcome-email-free', + status: 'active' + }); + await models.WelcomeEmailAutomatedEmail.add({ + welcome_email_automation_id: freeWelcomeAutomation.id, + delay_days: 0, + subject: 'Welcome to the site!', + lexical: JSON.stringify({root: {children: [{type: 'paragraph', children: [{text: 'Welcome'}]}]}}), + email_design_setting_id: emailDesignSetting.id + }); + paidWelcomeAutomation = await models.WelcomeEmailAutomation.add({ + name: 'Paid welcome email', + slug: 'member-welcome-email-paid', + status: 'active' + }); + await models.WelcomeEmailAutomatedEmail.add({ + welcome_email_automation_id: paidWelcomeAutomation.id, + delay_days: 0, + subject: 'Welcome to the paid tier!', + lexical: JSON.stringify({root: {children: [{type: 'paragraph', children: [{text: 'Welcome paid'}]}]}}), + email_design_setting_id: emailDesignSetting.id + }); + await models.Product.edit({ welcome_page_url: '' }, { id: paidProduct.id }); + // The existing free member shouldn't have any welcome runs yet + const runsBefore = await models.WelcomeEmailAutomationRun.findAll({ + filter: `member_id:'${existingMember.id}'` + }); + assert.equal(runsBefore.length, 0, 'Existing free member should have no welcome email runs before redemption'); + const magicLink = await membersService.api.getMagicLink(email, 'subscribe', { giftToken: gift.get('token') }); @@ -916,6 +957,19 @@ describe('Gift Subscriptions', function () { assert.ok(gift.get('redeemed_at')); assert.ok(gift.get('consumes_at')); + // Verify the paid welcome automation enqueued a run for this member, + // and that the free welcome automation did NOT (gift redemption + // delivers the paid welcome email regardless of pre-redemption status). + const welcomeRuns = await models.WelcomeEmailAutomationRun.findAll({ + filter: `member_id:'${member.id}'` + }); + assert.equal(welcomeRuns.length, 1, 'Should enqueue exactly one welcome email automation run for an existing free member redeeming a gift'); + assert.equal( + welcomeRuns.models[0].get('welcome_email_automation_id'), + paidWelcomeAutomation.id, + 'Should enqueue the paid welcome email automation, not the free one' + ); + // Verify gift subscription started staff notification was sent mockManager.assert.sentEmail({ subject: /paid subscription started/i, @@ -933,6 +987,19 @@ describe('Gift Subscriptions', function () { }, { id: paidProduct.id }); + + for (const automation of [freeWelcomeAutomation, paidWelcomeAutomation]) { + if (!automation) { + continue; + } + const runs = await models.WelcomeEmailAutomationRun.findAll({ + filter: `welcome_email_automation_id:'${automation.id}'` + }); + for (const run of runs.models) { + await models.WelcomeEmailAutomationRun.destroy({id: run.id}); + } + await models.WelcomeEmailAutomation.destroy({id: automation.id}); + } } }); }); diff --git a/ghost/core/test/unit/server/adapters/storage/s3-storage.test.ts b/ghost/core/test/unit/server/adapters/storage/s3-storage.test.ts index 50711405c6d..25d386bac83 100644 --- a/ghost/core/test/unit/server/adapters/storage/s3-storage.test.ts +++ b/ghost/core/test/unit/server/adapters/storage/s3-storage.test.ts @@ -494,6 +494,102 @@ describe('S3Storage', function () { ); }); + describe('absolute and pre-prefixed paths are normalised before building keys', function () { + // Some legacy callers build their target by joining `getContentPath(...)` + // with an extra segment, producing an absolute filesystem path. Without + // normalisation the absolute prefix gets concatenated into the bucket + // key. These tests pin the behaviour so the keys produced for an + // absolute or pre-prefixed input match the keys for the equivalent + // relative input, mirroring `LocalStorageBase`. + + it('strips an absolute filesystem prefix that contains the storagePath segment', function () { + const {storage} = createStorage(); + + const fromAbsolute = (storage as any).buildKey('/var/lib/ghost/content/files/2024/06/image.jpg'); + const fromRelative = (storage as any).buildKey('2024/06/image.jpg'); + + assert.equal(fromAbsolute, fromRelative); + assert.equal(fromAbsolute, 'configurable/prefix/content/files/2024/06/image.jpg'); + }); + + it('strips a leading / when callers pass it via `getTargetDir(storagePath)`', function () { + const {storage} = createStorage(); + + // External-media-inliner shape: targetDir = `storagePath/year/month`, + // joined with a filename and handed to exists()/save(). + const fromPrefixed = (storage as any).buildKey('content/files/2024/06/image.jpg'); + const fromRelative = (storage as any).buildKey('2024/06/image.jpg'); + + assert.equal(fromPrefixed, fromRelative); + }); + + it('preserves the historical leading-slash-as-decoration behaviour when the path has no storagePath segment', function () { + // handle-image-sizes calls exists(req.url) with values like + // `/size/w1200/...`. These are conceptually relative to the storage + // root and must continue to map to the same key as before. + const {storage} = createStorage(); + + const key = (storage as any).buildKey('/size/w1200/2024/06/photo.jpg'); + + assert.equal(key, 'configurable/prefix/content/files/size/w1200/2024/06/photo.jpg'); + }); + + it('exists() probes the same key for absolute, pre-prefixed and relative inputs', async function () { + const {storage, sendStub} = createStorage(); + const {HeadObjectCommand: HeadObjectCmd} = await import('@aws-sdk/client-s3'); + + sendStub.rejects(createNotFoundError()); + + await storage.exists('image.jpg', '/var/lib/ghost/content/files/2024/06'); + await storage.exists('image.jpg', 'content/files/2024/06'); + await storage.exists('image.jpg', '2024/06'); + + const keys = sendStub.getCalls().map(call => (call.args[0] as InstanceType).input.Key); + assert.equal(keys.length, 3); + assert.ok(keys.every(k => k === 'configurable/prefix/content/files/2024/06/image.jpg'), + `expected all three exists() probes to target the same key, got: ${JSON.stringify(keys)}`); + }); + + it('save() writes to the same key whether targetDir is absolute, pre-prefixed or relative', async function () { + const {storage, sendStub} = createStorage(); + + sinon.stub(storage, 'exists').resolves(false); + sinon.stub(fs.promises, 'stat').resolves({size: 256} as fs.Stats); + sinon.stub(fs.promises, 'readFile').resolves(Buffer.from('image-bytes')); + + const targetDirs = [ + '/var/lib/ghost/content/files/2024/06', + 'content/files/2024/06', + '2024/06' + ]; + + const keys: (string | undefined)[] = []; + for (const targetDir of targetDirs) { + sendStub.resetHistory(); + await storage.save({path: '/tmp/image.jpg', name: 'image.jpg'}, targetDir); + const command = sendStub.firstCall.args[0] as PutObjectCommand; + keys.push(command.input.Key); + } + + assert.ok(keys.every(k => k === 'configurable/prefix/content/files/2024/06/image.jpg'), + `expected all save()s to land at the same key, got: ${JSON.stringify(keys)}`); + }); + + it('still rejects path traversal that would escape the storage root', function () { + const {storage} = createStorage(); + + // Belt and braces: the existing `..` protection still fires when + // the input passes through normalisation. + assert.throws(() => { + (storage as any).buildKey('/var/lib/ghost/content/files/../../../etc/passwd'); + }, /not a valid URL/); + + assert.throws(() => { + (storage as any).buildKey('content/files/../../etc/passwd'); + }, /not a valid URL/); + }); + }); + describe('Multipart Upload', function () { function createMockReadStream(fileContent: Buffer) { return Readable.from(fileContent); diff --git a/ghost/core/test/unit/server/data/importer/index.test.js b/ghost/core/test/unit/server/data/importer/index.test.js index 76c444946e9..b61d82c7a13 100644 --- a/ghost/core/test/unit/server/data/importer/index.test.js +++ b/ghost/core/test/unit/server/data/importer/index.test.js @@ -42,7 +42,7 @@ describe('Importer', function () { it('gets the correct extensions', function () { assert(Array.isArray(ImportManager.getExtensions())); - assert.equal(ImportManager.getExtensions().length, 55); + assert.equal(ImportManager.getExtensions().length, 105); assert(ImportManager.getExtensions().includes('.csv')); assert(ImportManager.getExtensions().includes('.json')); assert(ImportManager.getExtensions().includes('.zip')); @@ -108,7 +108,7 @@ describe('Importer', function () { }); it('globs extensions correctly', function () { - const extGlob = '+(.jpg|.jpeg|.gif|.png|.svg|.svgz|.ico|.webp|.mp4|.webm|.ogv|.mp3|.wav|.ogg|.m4a|.pdf|.json|.jsonld|.ods|.odt|.pptx|.rtf|.txt|.xls|.xlsx|.xml|.apkg|.css|.csv|.doc|.docx|.epub|.gpx|.html|.ics|.ipynb|.js|.key|.kml|.md|.mobi|.mov|.otf|.pages|.paprikarecipes|.psd|.py|.skp|.woff|.woff2|.xlsb|.xlsm|.yaml|.zip|.markdown)'; + const extGlob = '+(.jpg|.jpeg|.gif|.png|.svg|.svgz|.ico|.webp|.mp4|.webm|.ogv|.mp3|.wav|.ogg|.m4a|.7z|.aac|.ai|.aif|.aiff|.apkg|.arw|.avif|.avi|.blend|.bmp|.bz2|.cr2|.cr3|.css|.csv|.dng|.doc|.docx|.eot|.epub|.eps|.fb2|.fbx|.flac|.geojson|.glb|.gltf|.gpx|.gz|.heic|.heif|.html|.ics|.ipynb|.js|.json|.jsonld|.key|.kml|.m4v|.md|.mid|.midi|.mkv|.mobi|.mov|.nef|.np3|.numbers|.obj|.odp|.ods|.odt|.opus|.orf|.otf|.pages|.paprikarecipes|.pdf|.ppt|.pptx|.psd|.py|.raf|.rar|.rtf|.rw2|.skp|.stl|.tar|.tgz|.tif|.tiff|.toml|.tsv|.ttf|.txt|.vcf|.woff|.woff2|.xcf|.xls|.xlsb|.xlsm|.xlsx|.xml|.yaml|.zip|.markdown)'; assert.equal(ImportManager.getGlobPattern(ImportManager.getExtensions()), extGlob); assert.equal(ImportManager.getGlobPattern(ImportManager.getDirectories()), '+(images|content|media|files)'); assert.equal(ImportManager.getGlobPattern(JSONHandler.extensions), '+(.json)'); diff --git a/ghost/core/test/unit/server/services/gifts/gift-service.test.ts b/ghost/core/test/unit/server/services/gifts/gift-service.test.ts index fb06ab874d0..f3b53505e07 100644 --- a/ghost/core/test/unit/server/services/gifts/gift-service.test.ts +++ b/ghost/core/test/unit/server/services/gifts/gift-service.test.ts @@ -47,6 +47,7 @@ describe('GiftService', function () { let memberRepository: { get: sinon.SinonStub; update: sinon.SinonStub; + enqueueWelcomeEmailRun: sinon.SinonStub; }; let staffServiceEmails: { notifyGiftReceived: sinon.SinonStub; @@ -97,7 +98,8 @@ describe('GiftService', function () { memberGet.withArgs('status').returns('free'); return Promise.resolve({id: 'member_1', get: memberGet}); }), - update: sinon.stub().resolves(undefined) + update: sinon.stub().resolves(undefined), + enqueueWelcomeEmailRun: sinon.stub().resolves(undefined) }; staffServiceEmails = { notifyGiftReceived: sinon.stub(), @@ -1289,6 +1291,70 @@ describe('GiftService', function () { sinon.assert.notCalled(giftRepository.update); sinon.assert.notCalled(staffServiceEmails.notifyGiftSubscriptionStarted); }); + + it('enqueues the paid welcome email run for a new gift signup', async function () { + const gift = buildGift(); + const memberGet = sinon.stub(); + memberGet.withArgs('status').returns('gift'); + memberGet.withArgs('name').returns('Member Name'); + memberGet.withArgs('email').returns('member@example.com'); + + giftRepository.getByToken.resolves(gift); + memberRepository.get.resolves({id: 'member_1', get: memberGet}); + + const service = createService(); + await service.redeem('gift-token', 'member_1', {newMember: true}); + + sinon.assert.calledOnceWithExactly( + memberRepository.enqueueWelcomeEmailRun, + 'member_1', + 'member-welcome-email-paid', + {transacting: 'trx'} + ); + }); + + it('enqueues the paid welcome email run when an existing free member redeems a gift', async function () { + const gift = buildGift(); + const memberGet = sinon.stub(); + memberGet.withArgs('status').returns('free'); + memberGet.withArgs('name').returns('Member Name'); + memberGet.withArgs('email').returns('member@example.com'); + + giftRepository.getByToken.resolves(gift); + memberRepository.get.resolves({id: 'member_1', get: memberGet}); + + const service = createService(); + await service.redeem('gift-token', 'member_1'); + + sinon.assert.calledOnceWithExactly( + memberRepository.enqueueWelcomeEmailRun, + 'member_1', + 'member-welcome-email-paid', + {transacting: 'trx'} + ); + }); + + it('passes the external transaction through to the welcome email enqueue', async function () { + const gift = buildGift(); + const memberGet = sinon.stub(); + memberGet.withArgs('status').returns('free'); + memberGet.withArgs('name').returns('Member Name'); + memberGet.withArgs('email').returns('member@example.com'); + + giftRepository.getByToken.resolves(gift); + memberRepository.get.resolves({id: 'member_1', get: memberGet}); + + const service = createService(); + const externalTrx = {executionPromise: Promise.resolve()}; + await service.redeem('gift-token', 'member_1', {transacting: externalTrx}); + + sinon.assert.calledOnceWithExactly( + memberRepository.enqueueWelcomeEmailRun, + 'member_1', + 'member-welcome-email-paid', + {transacting: externalTrx} + ); + }); }); describe('scheduleReminder (via redeem)', function () { diff --git a/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js b/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js index 6242378d910..12b53e352c2 100644 --- a/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js @@ -658,4 +658,216 @@ describe('EventRepository', function () { assert.equal(event.data.member_id, null); }); }); + + describe('getGiftEndedEvents', function () { + let eventRepository; + let fake; + + before(function () { + fake = sinon.fake.returns({data: [{ + toJSON: () => ({ + id: 'status-event-1', + member_id: 'member-abc', + member: {id: 'member-abc', name: 'Test Member', email: 'member@example.com'}, + from_status: 'gift', + to_status: 'free', + created_at: '2024-10-15T08:00:00.000Z' + }) + }]}); + eventRepository = new EventRepository({ + EmailRecipient: null, + MemberSubscribeEvent: null, + MemberPaymentEvent: null, + MemberStatusEvent: { + findPage: fake + }, + MemberLoginEvent: null, + MemberPaidSubscriptionEvent: null, + labsService: null + }); + }); + + afterEach(function () { + fake.resetHistory(); + }); + + it('queries with correct options', async function () { + await eventRepository.getGiftEndedEvents({ + filter: 'not used', + order: 'created_at desc, id desc' + }, { + type: 'unused' + }); + + sinon.assert.calledOnceWithMatch(fake, { + withRelated: ['member'], + filter: 'from_status:gift+to_status:free+custom:true', + order: 'created_at desc, id desc' + }); + }); + + it('returns correctly formatted gift_ended_event', async function () { + const result = await eventRepository.getGiftEndedEvents({ + order: 'created_at desc, id desc' + }, {}); + + assert.equal(result.data.length, 1); + + const event = result.data[0]; + + assert.equal(event.type, 'gift_ended_event'); + assert.equal(event.data.id, 'status-event-1'); + assert.equal(event.data.member_id, 'member-abc'); + assert.equal(event.data.created_at, '2024-10-15T08:00:00.000Z'); + assert.deepEqual(event.data.member, { + id: 'member-abc', + name: 'Test Member', + email: 'member@example.com' + }); + }); + + it('sets member to null when member relation is not present', async function () { + const nullMemberFake = sinon.fake.returns({data: [{ + toJSON: () => ({ + id: 'status-event-2', + member_id: 'member-xyz', + member: null, + from_status: 'gift', + to_status: 'free', + created_at: '2024-11-01T12:00:00.000Z' + }) + }]}); + const repo = new EventRepository({ + EmailRecipient: null, + MemberSubscribeEvent: null, + MemberPaymentEvent: null, + MemberStatusEvent: { + findPage: nullMemberFake + }, + MemberLoginEvent: null, + MemberPaidSubscriptionEvent: null, + labsService: null + }); + + const result = await repo.getGiftEndedEvents({}, {}); + const event = result.data[0]; + + assert.equal(event.data.member, null); + assert.equal(event.data.member_id, 'member-xyz'); + }); + }); + + describe('getSubscriptionEvents', function () { + // Builds a Bookshelf-shaped mock that mirrors how the model is used inside + // getSubscriptionEvents, including the eager-load behaviour where multiple + // MemberPaidSubscriptionEvent rows that share a subscription_id receive the + // SAME SubscriptionCreatedEvent instance via .related(). + function buildModels({sharedSubscriptionCreatedEvent}) { + function makeRelated(map) { + return name => map[name] ?? {id: undefined, related: () => ({id: undefined})}; + } + + const sharedRelated = makeRelated({ + subscriptionCreatedEvent: sharedSubscriptionCreatedEvent, + stripeSubscription: {related: () => ({related: () => ({related: () => null})})} + }); + + const buildModel = (attrs) => { + const relations = { + subscriptionCreatedEvent: sharedSubscriptionCreatedEvent, + stripeSubscription: {related: () => ({related: () => ({related: () => null})})} + }; + return { + id: attrs.id, + relations, + related: name => relations[name] ?? sharedRelated(name), + get: key => attrs[key], + toJSON: () => { + const paidStatusEvent = sharedSubscriptionCreatedEvent.related('paidStatusEvent'); + + return { + ...attrs, + subscriptionCreatedEvent: { + id: sharedSubscriptionCreatedEvent.id, + paidStatusEvent: paidStatusEvent && paidStatusEvent.id ? { + id: paidStatusEvent.id, + from_status: paidStatusEvent.get('from_status'), + to_status: paidStatusEvent.get('to_status') + } : undefined + } + }; + } + }; + }; + + return [ + // Order matches findPage(order: 'created_at desc, id desc'): + // the newer "updated" row comes first, the original "created" row second. + buildModel({ + id: 'mpse-updated', + type: 'updated', + member_id: 'member1', + subscription_id: 'sub1', + created_at: '2026-05-05T18:21:31.000Z' + }), + buildModel({ + id: 'mpse-created', + type: 'created', + member_id: 'member1', + subscription_id: 'sub1', + created_at: '2026-05-05T15:49:44.000Z' + }) + ]; + } + + it('preserves previous_status on every row when multiple events share a subscription_id', async function () { + // One SubscriptionCreatedEvent shared across both paid-subscription rows + // (this is what Bookshelf's belongsTo eager-load gives us when the foreign + // key is duplicated). The paidStatusEvent on it represents the gift-to-paid + // transition. + const paidStatusEvent = { + id: 'mse-gift-to-paid', + get: key => ({from_status: 'gift', to_status: 'paid'}[key]) + }; + const sharedSubscriptionCreatedEvent = { + id: 'sce1', + relations: {paidStatusEvent, memberCreatedEvent: {id: undefined}}, + related(name) { + return this.relations[name] ?? {id: undefined}; + } + }; + + const models = buildModels({sharedSubscriptionCreatedEvent}); + const findPage = sinon.fake.resolves({ + data: models, + meta: {pagination: {total: models.length}} + }); + + const eventRepository = new EventRepository({ + EmailRecipient: null, + MemberSubscribeEvent: null, + MemberPaymentEvent: null, + MemberStatusEvent: null, + MemberLoginEvent: null, + MemberPaidSubscriptionEvent: {findPage}, + memberAttributionService: {getEventAttribution: () => null}, + labsService: null + }); + + const result = await eventRepository.getSubscriptionEvents({}, ''); + + assert.equal(result.data.length, 2); + const created = result.data.find(e => e.data.type === 'created'); + const updated = result.data.find(e => e.data.type === 'updated'); + + // The original `created` row should still report previous_status='gift' + // even when iterated AFTER another row that shares its SubscriptionCreatedEvent. + assert.equal(created.data.previous_status, 'gift'); + + // The helper relation should be removed from the serialized payload + assert.equal(created.data.subscriptionCreatedEvent.paidStatusEvent, undefined); + assert.equal(updated.data.subscriptionCreatedEvent.paidStatusEvent, undefined); + assert.equal(sharedSubscriptionCreatedEvent.related('paidStatusEvent').get('from_status'), 'gift'); + }); + }); }); diff --git a/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js b/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js index edc9be182ae..531e4d1c855 100644 --- a/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js @@ -1563,83 +1563,6 @@ describe('MemberRepository', function () { assert.equal(runCall.exit_reason, null); }); - it('creates automation run for gift member signup (paid welcome email)', async function () { - // Override stub with paid welcome email - WelcomeEmailAutomation.findOne = sinon.stub().resolves({ - id: 'automation_id_paid', - get: sinon.stub().callsFake((key) => { - const data = {status: 'active'}; - return data[key]; - }), - related: sinon.stub().callsFake((relation) => { - assert.equal(relation, 'welcomeEmailAutomatedEmail'); - return { - id: 'automated_email_id_paid', - get: sinon.stub().callsFake((key) => { - const data = {lexical: '{"root":{}}'}; - return data[key]; - }) - }; - }) - }); - - const repo = new MemberRepository({ - Member, - Outbox, - WelcomeEmailAutomationRun, - MemberStatusEvent, - MemberSubscribeEventModel: MemberSubscribeEvent, - newslettersService, - WelcomeEmailAutomation, - OfferRedemption: mockOfferRedemption - }); - - await repo.create({email: 'test@example.com', name: 'Test Member', status: 'gift'}, {}); - - sinon.assert.calledOnce(WelcomeEmailAutomation.findOne); - assert.equal(WelcomeEmailAutomation.findOne.firstCall.args[0].slug, 'member-welcome-email-paid'); - - sinon.assert.calledOnce(WelcomeEmailAutomationRun.add); - const runCall = WelcomeEmailAutomationRun.add.firstCall.args[0]; - assert.equal(runCall.welcome_email_automation_id, 'automation_id_paid'); - assert.equal(runCall.member_id, 'member_id_123'); - assert.equal(runCall.next_welcome_email_automated_email_id, 'automated_email_id_paid'); - }); - - it('does NOT create automation run for a gift signup when the paid welcome email is inactive', async function () { - // Override stub with inactive paid welcome email - WelcomeEmailAutomation.findOne = sinon.stub().resolves({ - id: 'automation_id_paid', - get: sinon.stub().callsFake((key) => { - const data = {status: 'inactive'}; - return data[key]; - }), - related: sinon.stub().callsFake(() => ({ - id: 'automated_email_id_paid', - get: sinon.stub().callsFake((key) => { - const data = {lexical: '{"root":{}}'}; - return data[key]; - }) - })) - }); - - const repo = new MemberRepository({ - Member, - Outbox, - WelcomeEmailAutomationRun, - MemberStatusEvent, - MemberSubscribeEventModel: MemberSubscribeEvent, - newslettersService, - WelcomeEmailAutomation, - OfferRedemption: mockOfferRedemption - }); - - await repo.create({email: 'test@example.com', name: 'Test Member', status: 'gift'}, {}); - - sinon.assert.notCalled(WelcomeEmailAutomationRun.add); - sinon.assert.notCalled(Member.transaction); - }); - it('does not create automation run for disallowed sources', async function () { const repo = new MemberRepository({ Member,