diff --git a/apps/activitypub/package.json b/apps/activitypub/package.json index cd41e1103f0..a8c5509bf3b 100644 --- a/apps/activitypub/package.json +++ b/apps/activitypub/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/activitypub", - "version": "3.1.32", + "version": "3.1.33", "license": "MIT", "repository": { "type": "git", diff --git a/apps/activitypub/src/views/notifications/notifications.tsx b/apps/activitypub/src/views/notifications/notifications.tsx index 0a3b0957649..acdebf11587 100644 --- a/apps/activitypub/src/views/notifications/notifications.tsx +++ b/apps/activitypub/src/views/notifications/notifications.tsx @@ -17,7 +17,7 @@ import {Notification, isApiError} from '@src/api/activitypub'; import {handleProfileClick} from '@utils/handle-profile-click'; import {renderFeedAttachment} from '@components/feed/feed-item'; import {renderTimestamp} from '@src/utils/render-timestamp'; -import {stripHtml} from '@src/utils/content-formatters'; +import {sanitizeHtml, stripHtml} from '@src/utils/content-formatters'; import {useNavigateWithBasePath} from '@src/hooks/use-navigate-with-base-path'; import {useNotificationsForUser} from '@hooks/use-activity-pub-queries'; @@ -192,7 +192,7 @@ const ProfileLinkedContent: React.FC<{ return (
@@ -436,7 +436,7 @@ const Notifications: React.FC = () => { (group.type !== 'reply' && group.type !== 'mention' ?
{group.post?.type === 'article' && group.post?.title && <>{group.post.title} — } - +
: <>
diff --git a/apps/activitypub/test/acceptance/dom-validation.test.ts b/apps/activitypub/test/acceptance/dom-validation.test.ts index f96e83d0a1e..15ee970f21a 100644 --- a/apps/activitypub/test/acceptance/dom-validation.test.ts +++ b/apps/activitypub/test/acceptance/dom-validation.test.ts @@ -232,4 +232,75 @@ test.describe('DOM validation for rendered AP content', async () => { const excerptText = await excerptElement.textContent(); expect(excerptText).toContain('">Data + VBScript + Encoded JavaScript + `); + + const links = renderHtml(result).querySelectorAll('a'); + + expect(links).toHaveLength(5); + links.forEach((link) => { + expect(link.getAttribute('href')).toBeNull(); + }); + }); + + it('strips unsupported tags while keeping anchor text readable', function () { + const result = sanitizeStrippedHtml(` +

Hello bold safe link

+ + Image + + + `); + + const div = renderHtml(result); + + expect(div.querySelector('p')).toBeNull(); + expect(div.querySelector('strong')).toBeNull(); + expect(div.querySelector('script')).toBeNull(); + expect(div.querySelector('img')).toBeNull(); + expect(div.querySelector('svg')).toBeNull(); + expect(div.querySelector('iframe')).toBeNull(); + expect(div.querySelector('a')?.textContent).toBe('safe link'); + expect(div.textContent).toContain('Hello bold safe link'); + }); + + it('returns plain text when stripHtml has no exclusions', function () { + const result = sanitizeHtml(stripHtml(` +

Hello safe link

+
+ Bold + `)); + + const div = renderHtml(result); + + expect(div.querySelector('a')).toBeNull(); + expect(div.querySelector('br')).toBeNull(); + expect(div.textContent).toBe('Hello safe link Bold'); + }); }); }); diff --git a/apps/posts/src/hooks/filter-sources/use-tier-value-source.ts b/apps/posts/src/hooks/filter-sources/use-tier-value-source.ts index 3081ab0c08c..23b0effe487 100644 --- a/apps/posts/src/hooks/filter-sources/use-tier-value-source.ts +++ b/apps/posts/src/hooks/filter-sources/use-tier-value-source.ts @@ -1,15 +1,54 @@ -import {FilterOption, ValueSource} from '@tryghost/shade/patterns'; import {createLocalValueSource} from './create-local-value-source'; +import {getActiveTiers, getArchivedTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; +import {useMemo} from 'react'; +import type {FilterOption, ValueSource} from '@tryghost/shade/patterns'; +import type {Tier} from '@tryghost/admin-x-framework/api/tiers'; + +interface TierValueSource { + valueSource: ValueSource; + // True once more than one paid tier (active or archived) exists, i.e. when + // filtering members by tier is meaningful. Stays false until tiers load. + hasMultipleTiers: boolean; +} + +function toTierFilterOption(tier: Tier): FilterOption { + return { + value: tier.id, + label: tier.active ? tier.name : `${tier.name} (archived)`, + detail: tier.slug + }; +} + +// Active tiers first, then archived; each group keeps the order returned by the API. +function buildTierFilterOptions(tiers: Tier[]): FilterOption[] { + return [ + ...getActiveTiers(tiers).map(toTierFilterOption), + ...getArchivedTiers(tiers).map(toTierFilterOption) + ]; +} + +export function useTierValueSource(): TierValueSource { + // The tiers endpoint returns every match in a single response, so no paging or + // limit is needed; `type:paid` keeps free/complimentary tiers out of the filter. + const {data: tiersData, isLoading} = useBrowseTiers({ + searchParams: {filter: 'type:paid'} + }); + + const tiers = useMemo(() => tiersData?.tiers ?? [], [tiersData?.tiers]); + const options = useMemo(() => buildTierFilterOptions(tiers), [tiers]); + const hasMultipleTiers = tiers.length > 1; -export function useTierValueSource(options: FilterOption[] = []): ValueSource { const useLocalTierValueSource = createLocalValueSource, string>({ id: 'posts.tiers.local', useItems: () => ({ data: options, - isLoading: false + isLoading }), toOption: option => option }); - return useLocalTierValueSource(); + return { + valueSource: useLocalTierValueSource(), + hasMultipleTiers + }; } diff --git a/apps/posts/src/views/Automations/components/add-step-edge.tsx b/apps/posts/src/views/Automations/components/add-step-edge.tsx index de167fdc507..e5890f77ce1 100644 --- a/apps/posts/src/views/Automations/components/add-step-edge.tsx +++ b/apps/posts/src/views/Automations/components/add-step-edge.tsx @@ -12,9 +12,8 @@ export type AddStepEdgeData = { onPick: (type: StepPickerType, anchor: {sourceId: string; targetId: string}) => void; }; -const INSERT_BUTTON_CLASSES = 'border-transparent bg-green-500 text-white shadow-sm hover:bg-green-600'; +const INSERT_BUTTON_CLASSES = 'border-dashed border-border-default bg-surface-page text-text-secondary shadow-sm hover:border-border-strong'; const DEFAULT_EDGE_STROKE = 'var(--xy-edge-stroke)'; -const HOVERED_EDGE_STROKE = 'var(--color-green-500)'; const AddStepEdge: React.FC = ({ id, @@ -27,7 +26,8 @@ const AddStepEdge: React.FC = ({ data }) => { const [open, setOpen] = useState(false); - const [hovered, setHovered] = useState(false); + const [edgeHovered, setEdgeHovered] = useState(false); + const [labelHovered, setLabelHovered] = useState(false); const edgeData = data as AddStepEdgeData | undefined; const [path, labelX, labelY] = getSmoothStepPath({ @@ -39,10 +39,6 @@ const AddStepEdge: React.FC = ({ targetPosition }); - const edgeStyle: React.CSSProperties = { - stroke: hovered || open ? HOVERED_EDGE_STROKE : DEFAULT_EDGE_STROKE - }; - if (!edgeData) { return ; } @@ -52,12 +48,12 @@ const AddStepEdge: React.FC = ({ edgeData.onPick(type, {sourceId: edgeData.sourceId, targetId: edgeData.targetId}); }; - const visible = open || hovered; + const visible = open || edgeHovered || labelHovered; const button = ( ); @@ -86,7 +82,7 @@ const AddStepEdge: React.FC = ({ control = ( {button} - + @@ -95,18 +91,18 @@ const AddStepEdge: React.FC = ({ return ( setHovered(true)} - onMouseLeave={() => setHovered(false)} + onMouseEnter={() => setEdgeHovered(true)} + onMouseLeave={() => setEdgeHovered(false)} > - +
setHovered(true)} - onMouseLeave={() => setHovered(false)} + onMouseEnter={() => setLabelHovered(true)} + onMouseLeave={() => setLabelHovered(false)} > {/* Wider hit zone so the + becomes visible when the cursor is near the edge midpoint. */}
diff --git a/apps/posts/src/views/Automations/components/automation-canvas.tsx b/apps/posts/src/views/Automations/components/automation-canvas.tsx index 7dcd33e0329..9cfa5bdfa3a 100644 --- a/apps/posts/src/views/Automations/components/automation-canvas.tsx +++ b/apps/posts/src/views/Automations/components/automation-canvas.tsx @@ -4,9 +4,10 @@ import EmailContentModal from './email-modal/email-content-modal'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import StepPicker, {type StepPickerType} from './step-picker'; import {AutomationAction, AutomationDetail, AutomationSendEmailAction, AutomationWaitAction, InsertActionAnchor, MAX_AUTOMATION_ACTIONS, insertSendEmailAction, insertWaitAction, removeAction, updateSendEmailAction, updateWaitAction} from '@tryghost/admin-x-framework/api/automations'; -import {Background, BackgroundVariant, Edge, Handle, Node, NodeProps, Position, ReactFlow} from '@xyflow/react'; -import {Banner, Button, Checkbox, Input, Label, LoadingIndicator, Popover, PopoverContent, PopoverTrigger, Select, SelectTrigger, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@tryghost/shade/components'; +import {Background, BackgroundVariant, Controls, Edge, Handle, Node, NodeProps, Position, ReactFlow, useReactFlow, useViewport} from '@xyflow/react'; +import {Banner, Button, Checkbox, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger, Field, FieldError, FieldLabel, Input, InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText, Label, LoadingIndicator, Popover, PopoverContent, PopoverTrigger, Select, SelectTrigger, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@tryghost/shade/components'; import {LucideIcon, cn, formatNumber} from '@tryghost/shade/utils'; +import type {EmailModalMode} from './types'; const MAX_WAIT_DAYS = 30; const WHOLE_NUMBER_PATTERN = /^\d+$/; @@ -16,8 +17,11 @@ const NODE_WIDTH = 256; const NODE_COLUMN_CENTER_X = NODE_X + (NODE_WIDTH / 2); const NODE_GAP_Y = 180; const INITIAL_VIEWPORT_Y = 40; +const VIEWPORT_ANIMATION_DURATION = 180; +const NODE_ENTER_ANIMATION_DURATION = 250; const DISABLED_REASON = `Limit of ${formatNumber(MAX_AUTOMATION_ACTIONS)} steps reached`; const DEFAULT_EDGE_STROKE = 'var(--xy-edge-stroke)'; +const ZOOM_PRESETS = [1.5, 1, 0.75, 0.5, 0.25]; // React Flow node IDs for the trigger and tail nodes. The canvas builds the visual graph using // these; they are not action IDs and never reach the API. @@ -39,7 +43,24 @@ type StepNodeDisplayData = { value?: string; }; +type NodeContextMenuItem = { + icon?: React.ElementType; + label: string; + onSelect: () => void; + type?: 'item'; + variant?: 'default' | 'destructive'; +}; + +type NodeContextMenuSeparator = { + id: string; + type: 'separator'; +}; + +type NodeContextMenuEntry = NodeContextMenuItem | NodeContextMenuSeparator; + type StepNodeData = StepNodeDisplayData & { + contextMenuItems: NodeContextMenuEntry[]; + isNew: boolean; selected: boolean; onSelect: () => void; }; @@ -56,32 +77,83 @@ type TailFlowNode = Node; type AutomationFlowNode = StepFlowNode | TailFlowNode; const HIDDEN_HANDLE_STYLE: React.CSSProperties = { + background: 'transparent', + border: 'none', + height: 0, + minHeight: 0, + minWidth: 0, opacity: 0, pointerEvents: 'none', - background: 'transparent', - border: 'none' + width: 0 }; const HiddenHandle: React.FC<{type: 'source' | 'target'; position: Position}> = ({type, position}) => ( ); -const NodeShell: React.FC> = ({children, className, data}) => ( - -); +const NodeShell: React.FC> = ({children, className, data}) => { + const ignoreNextClickRef = useRef(false); + + return ( + { + if (!open) { + ignoreNextClickRef.current = false; + } + }}> + + + + event.stopPropagation()} + onPointerDown={event => event.stopPropagation()} + > + {data.contextMenuItems.map((item) => { + if (item.type === 'separator') { + return ; + } + const Icon = item.icon; + return ( + + {Icon && } + {item.label} + + ); + })} + + + ); +}; const StepNodeContent: React.FC<{data: StepNodeData}> = ({data}) => { const Icon = data.icon; @@ -161,7 +233,7 @@ const TailNode: React.FC> = ({data}) => { - + @@ -178,6 +250,90 @@ const edgeTypes = { 'add-step-edge': AddStepEdge }; +const AutomationCanvasControls: React.FC = () => { + const [open, setOpen] = useState(false); + const {fitView, zoomIn, zoomOut, zoomTo} = useReactFlow(); + const {zoom} = useViewport(); + const animationOptions = {duration: VIEWPORT_ANIMATION_DURATION}; + const zoomPercent = Math.round(zoom * 100); + + const handleZoomTo = (nextZoom: number) => { + setOpen(false); + void zoomTo(nextZoom, animationOptions); + }; + + const handleFitView = () => { + setOpen(false); + void fitView(animationOptions); + }; + + return ( + + + + + + + + {ZOOM_PRESETS.map((preset) => { + const presetPercent = Math.round(preset * 100); + const isSelected = Math.abs(zoom - preset) < 0.01; + return ( + handleZoomTo(preset)}> + {formatNumber(presetPercent)}% + {isSelected && ( + + + + )} + + ); + })} + + + Fit to view + + + + + + ); +}; + export const formatWait = (hours: number): string => { if (hours <= 0) { throw new Error('Wait time must be a positive number of hours.'); @@ -202,6 +358,60 @@ const buildActionData = (action: AutomationAction): StepNodeDisplayData => { } }; +const buildNodeContextMenuItems = ({ + canDelete = false, + canEditEmailBody = false, + onDelete, + onEditEmailBody, + onPreviewEmail, + onSelectStep, + stepId +}: { + canDelete?: boolean; + canEditEmailBody?: boolean; + onDelete?: (deleteStepId: string) => void; + onEditEmailBody?: (editEmailBodyStepId: string, mode?: EmailModalMode) => void; + onPreviewEmail?: (previewEmailStepId: string) => void; + onSelectStep: (nextStepId: string) => void; + stepId: string; +}): NodeContextMenuEntry[] => { + const items: NodeContextMenuEntry[] = [{ + icon: LucideIcon.Settings2, + label: 'Edit settings', + onSelect: () => onSelectStep(stepId) + }]; + + if (canEditEmailBody && onEditEmailBody) { + items.push({ + icon: LucideIcon.Pencil, + label: 'Edit email body', + onSelect: () => onEditEmailBody(stepId) + }); + } + + if (canEditEmailBody && onPreviewEmail) { + items.push({ + icon: LucideIcon.Eye, + label: 'Preview', + onSelect: () => onPreviewEmail(stepId) + }); + } + + if (canDelete && onDelete) { + if (canEditEmailBody) { + items.push({id: 'before-delete', type: 'separator'}); + } + items.push({ + icon: LucideIcon.Trash2, + label: 'Delete', + onSelect: () => onDelete(stepId), + variant: 'destructive' + }); + } + + return items; +}; + // Returns the actions of `automation` ordered along the chain from the head. Throws on malformed // data (cycle, branch, or disconnected nodes). The canvas wraps its render tree in an Error // Boundary that catches these and renders the same "Couldn't load automation" banner. @@ -246,12 +456,16 @@ const getInitialActionOrder = (automation: AutomationDetail): AutomationAction[] type BuildGraphParams = { automation: AutomationDetail; disabled: boolean; + onDelete: (stepId: string) => void; + onEditEmailBody: (stepId: string, mode?: EmailModalMode) => void; + onPreviewEmail: (stepId: string) => void; onPick: (type: StepPickerType, anchor: CanvasAnchor) => void; onSelectStep: (stepId: string) => void; + newStepId: string | null; selectedStepId: string | null; } -const buildGraph = ({automation, disabled, onPick, onSelectStep, selectedStepId}: BuildGraphParams): {nodes: AutomationFlowNode[]; edges: Edge[]} => { +const buildGraph = ({automation, disabled, onDelete, onEditEmailBody, onPick, onPreviewEmail, onSelectStep, newStepId, selectedStepId}: BuildGraphParams): {nodes: AutomationFlowNode[]; edges: Edge[]} => { const ordered = getInitialActionOrder(automation); const baseNodeProps = { draggable: false, @@ -273,7 +487,12 @@ const buildGraph = ({automation, disabled, onPick, onSelectStep, selectedStepId} type: 'trigger', position: {x: NODE_X, y: 0}, data: { + contextMenuItems: buildNodeContextMenuItems({ + onSelectStep, + stepId: TRIGGER_CANVAS_ID + }), icon: LucideIcon.Zap, + isNew: false, label: 'Trigger', value: 'Member signs up', selected: selectedStepId === TRIGGER_CANVAS_ID, @@ -290,6 +509,16 @@ const buildGraph = ({automation, disabled, onPick, onSelectStep, selectedStepId} position: {x: NODE_X, y: NODE_GAP_Y * (index + 1)}, data: { ...buildActionData(action), + contextMenuItems: buildNodeContextMenuItems({ + canDelete: true, + canEditEmailBody: action.type === 'send_email', + onDelete, + onEditEmailBody, + onPreviewEmail, + onSelectStep, + stepId: action.id + }), + isNew: newStepId === action.id, selected: selectedStepId === action.id, onSelect: () => onSelectStep(action.id) }, @@ -441,11 +670,13 @@ const getStepSidebarDetail = ({automation, stepId, onDelete, onUpdateWait, onUpd } }; -const SidebarField: React.FC<{label: string; children: React.ReactNode}> = ({label, children}) => ( - + ); const ReadOnlySelect: React.FC<{value: string}> = ({value}) => ( @@ -508,8 +739,6 @@ const WaitSidebarBody: React.FC<{ const days = Number(daysText); const isValid = getValidWaitDays(daysText) !== null; - const unitLabel = days === 1 ? 'Day' : 'Days'; - const updateWaitDays = (nextDays: number) => { const nextHours = nextDays * 24; if (nextHours !== action.data.wait_hours) { @@ -517,6 +746,17 @@ const WaitSidebarBody: React.FC<{ } }; + const stepWaitDays = (direction: -1 | 1) => { + const currentDays = getValidWaitDays(daysText); + if (currentDays === null) { + return; + } + + const nextDays = Math.min(MAX_WAIT_DAYS, Math.max(1, currentDays + direction)); + setDaysText(String(nextDays)); + updateWaitDays(nextDays); + }; + const handleChange = (event: React.ChangeEvent) => { const nextDaysText = event.target.value; setDaysText(nextDaysText); @@ -530,21 +770,47 @@ const WaitSidebarBody: React.FC<{ return (
- -
- + + - -
+ {days === 1 ? 'day' : 'days'} + + stepWaitDays(-1)} + > + + + = MAX_WAIT_DAYS} + size='icon-xs' + title='Increase wait by one day' + onClick={() => stepWaitDays(1)} + > + + + + {!isValid && ( - + Enter a whole number between 1 and {formatNumber(MAX_WAIT_DAYS)} days. - + )}
@@ -561,8 +827,9 @@ const SendEmailSidebarBody: React.FC<{ onDelete: () => void; }> = ({action, onUpdateSubject, onEditEmail, onDelete}) => (
- + onUpdateSubject(e.target.value)} @@ -610,7 +877,7 @@ const StepSidebarContent: React.FC<{detail: StepSidebarDetail}> = ({detail}) =>
{detail.label} -

{detail.title}

+

{detail.title}

@@ -666,7 +933,6 @@ type AutomationCanvasProps = { type SelectedStep = { id: string; - isEditingEmail: boolean; }; const insertActionByType = { @@ -675,6 +941,9 @@ const insertActionByType = { }; const AutomationCanvas: React.FC = ({automation, isLoading, isError, onChange}) => { + const [newStepId, setNewStepId] = useState(null); + const [emailModalMode, setEmailModalMode] = useState('edit'); + const [emailModalStepId, setEmailModalStepId] = useState(null); const [selectedStep, setSelectedStep] = useState(null); const selectedStepId = selectedStep?.id ?? null; @@ -688,14 +957,30 @@ const AutomationCanvas: React.FC = ({automation, isLoadin const apiAnchor = toApiAnchor(anchor); const insertAction = insertActionByType[type]; const next = insertAction({detail: automation, anchor: apiAnchor}); + const insertedAction = next.actions.find(action => !automation.actions.some(existingAction => existingAction.id === action.id)); + setNewStepId(insertedAction?.id ?? null); + if (insertedAction) { + setSelectedStep({id: insertedAction.id}); + } onChange(next); }, [automation, onChange]); + useEffect(() => { + if (!newStepId) { + return; + } + const timeout = window.setTimeout(() => { + setNewStepId(null); + }, NODE_ENTER_ANIMATION_DURATION); + return () => window.clearTimeout(timeout); + }, [newStepId]); + const handleDelete = useCallback((actionId: string) => { if (!automation) { return; } const next = removeAction({detail: automation, actionId}); + setEmailModalStepId(currentId => (currentId === actionId ? null : currentId)); setSelectedStep(null); onChange(next); }, [automation, onChange]); @@ -718,12 +1003,23 @@ const AutomationCanvas: React.FC = ({automation, isLoadin onChange(updateSendEmailAction({detail: automation, actionId, emailSubject: subject, emailLexical: action.data.email_lexical})); }, [automation, onChange]); - const handleEditEmail = (actionId: string) => { - setSelectedStep({id: actionId, isEditingEmail: true}); - }; + const handleEditEmail = useCallback((actionId: string, mode: EmailModalMode = 'edit') => { + setEmailModalMode(mode); + setEmailModalStepId(actionId); + }, []); - const emailModalAction = selectedStep?.isEditingEmail && automation - ? automation.actions.find((action): action is AutomationSendEmailAction => action.id === selectedStep.id && action.type === 'send_email') + const handleContextMenuEditEmail = useCallback((actionId: string, mode: EmailModalMode = 'edit') => { + setSelectedStep(null); + setEmailModalMode(mode); + setEmailModalStepId(actionId); + }, []); + + const handleContextMenuPreviewEmail = useCallback((actionId: string) => { + handleContextMenuEditEmail(actionId, 'preview'); + }, [handleContextMenuEditEmail]); + + const emailModalAction = emailModalStepId && automation + ? automation.actions.find((action): action is AutomationSendEmailAction => action.id === emailModalStepId && action.type === 'send_email') : undefined; const initialViewport = useRef(getInitialViewport(window.innerWidth)); @@ -735,11 +1031,15 @@ const AutomationCanvas: React.FC = ({automation, isLoadin return buildGraph({ automation, disabled: automation.actions.length >= MAX_AUTOMATION_ACTIONS, + onDelete: handleDelete, + onEditEmailBody: handleContextMenuEditEmail, onPick: handlePick, - onSelectStep: id => setSelectedStep({id, isEditingEmail: false}), + onPreviewEmail: handleContextMenuPreviewEmail, + onSelectStep: id => setSelectedStep({id}), + newStepId, selectedStepId }); - }, [automation, handlePick, selectedStepId]); + }, [automation, handleContextMenuEditEmail, handleContextMenuPreviewEmail, handleDelete, handlePick, newStepId, selectedStepId]); const sidebarDetail = automation ? getStepSidebarDetail({ automation, @@ -754,11 +1054,20 @@ const AutomationCanvas: React.FC = ({automation, isLoadin }, []); const closeEmailModal = () => { - if (!emailModalAction) { + setEmailModalStepId(null); + setEmailModalMode('edit'); + }; + + const handleNodeDoubleClick = useCallback((event: React.MouseEvent, node: AutomationFlowNode) => { + event.stopPropagation(); + if (!automation || node.id === TAIL_CANVAS_ID || node.id === TRIGGER_CANVAS_ID) { return; } - setSelectedStep({id: emailModalAction.id, isEditingEmail: false}); - }; + const action = automation.actions.find(item => item.id === node.id); + if (action?.type === 'send_email') { + handleEditEmail(action.id); + } + }, [automation, handleEditEmail]); if (isLoading) { return ( @@ -787,7 +1096,7 @@ const AutomationCanvas: React.FC = ({automation, isLoadin return (
= ({automation, isLoadin nodesFocusable={false} nodeTypes={nodeTypes} proOptions={{hideAttribution: true}} + zoomOnDoubleClick={false} zoomOnScroll={false} panOnScroll - onNodeClick={(_, node) => { + onNodeClick={(event, node) => { + if (event.button !== 0) { + return; + } if (node.id !== TAIL_CANVAS_ID) { - setSelectedStep({id: node.id, isEditingEmail: false}); + setSelectedStep({id: node.id}); } }} + onNodeDoubleClick={handleNodeDoubleClick} onPaneClick={clearDetail} > + {emailModalAction && automation && ( { onChange(updateSendEmailAction({detail: automation, actionId: emailModalAction.id, emailSubject: subject, emailLexical: lexical})); - setSelectedStep({id: emailModalAction.id, isEditingEmail: false}); + closeEmailModal(); }} /> )} diff --git a/apps/posts/src/views/Automations/components/email-modal/email-content-modal.tsx b/apps/posts/src/views/Automations/components/email-modal/email-content-modal.tsx index 4ffbb0ef695..66324831f83 100644 --- a/apps/posts/src/views/Automations/components/email-modal/email-content-modal.tsx +++ b/apps/posts/src/views/Automations/components/email-modal/email-content-modal.tsx @@ -10,6 +10,7 @@ import {useBrowseAutomatedEmails, usePreviewWelcomeEmail} from '@tryghost/admin- import {useEmailPreview} from './use-email-preview'; import {useEmailSenderDetails} from './use-sender-details'; import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; +import type {EmailModalMode} from '../types'; interface EmailPreviewModalContentProps { title: string; @@ -80,21 +81,21 @@ const EmailPreviewBody: React.FC = ({children, className} ); export interface EmailContentModalProps { - initialSubject: string; initialLexical: string; + initialMode?: EmailModalMode; + initialSubject: string; onClose: () => void; onSave: (data: {subject: string; lexical: string}) => void; } -type PreviewMode = 'edit' | 'preview'; - -const EmailContentModal: React.FC = ({initialSubject, initialLexical, onClose, onSave}) => { +const EmailContentModal: React.FC = ({initialMode = 'edit', initialSubject, initialLexical, onClose, onSave}) => { const {mutateAsync: previewWelcomeEmail} = usePreviewWelcomeEmail(); const {data: automatedEmailsData} = useBrowseAutomatedEmails(); const [showTestDropdown, setShowTestDropdown] = useState(false); - const [mode, setMode] = useState('edit'); + const [mode, setMode] = useState(initialMode); const [previewSubjectOverride, setPreviewSubjectOverride] = useState(null); const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false); + const hasEnteredInitialPreview = useRef(false); const dropdownRef = useRef(null); const normalizedLexical = useRef(initialLexical || ''); const hasEditorBeenFocused = useRef(false); @@ -129,6 +130,14 @@ const EmailContentModal: React.FC = ({initialSubject, in setErrors }); + useEffect(() => { + if (initialMode !== 'preview' || hasEnteredInitialPreview.current) { + return; + } + hasEnteredInitialPreview.current = true; + enterPreview(formState); + }, [enterPreview, formState, initialMode]); + const isDirty = saveState === 'unsaved'; // Single close funnel: Esc, overlay click, and the Close button all route here. @@ -183,7 +192,7 @@ const EmailContentModal: React.FC = ({initialSubject, in }; }, []); - const handleModeChange = useCallback((nextMode: PreviewMode) => { + const handleModeChange = useCallback((nextMode: EmailModalMode) => { setMode(nextMode); if (nextMode === 'preview') { @@ -241,7 +250,7 @@ const EmailContentModal: React.FC = ({initialSubject, in data-testid='email-mode-toggle' value={mode} variant='segmented-sm' - onValueChange={value => value && handleModeChange(value as PreviewMode)} + onValueChange={value => value && handleModeChange(value as EmailModalMode)} > Email content diff --git a/apps/posts/src/views/Automations/components/step-picker.tsx b/apps/posts/src/views/Automations/components/step-picker.tsx index 47360d510c8..99ea95614dc 100644 --- a/apps/posts/src/views/Automations/components/step-picker.tsx +++ b/apps/posts/src/views/Automations/components/step-picker.tsx @@ -16,7 +16,7 @@ interface PickerOptionProps { const PickerOption: React.FC = ({icon: Icon, label, description, onClick}) => ( @@ -30,6 +32,13 @@ const mockEditMutation = { isLoading: false, variables: undefined as {id: string; status: 'active' | 'inactive'} | undefined }; +const mockReactFlow = { + fitView: vi.fn(), + zoomIn: vi.fn(), + zoomOut: vi.fn(), + zoomTo: vi.fn() +}; +let mockViewportZoom = 1; vi.mock('@tryghost/admin-x-framework/api/automations', async () => { const actual = await vi.importActual( @@ -48,11 +57,14 @@ type StubEdge = {id: string; source: string; target: string; type?: string; data type StubReactFlowProps = { nodes: StubNode[]; edges?: StubEdge[]; + children?: React.ReactNode; className?: string; nodeTypes?: Record>; edgeTypes?: Record>; onNodeClick?: (event: React.MouseEvent, node: StubNode) => void; + onNodeDoubleClick?: (event: React.MouseEvent, node: StubNode) => void; onPaneClick?: (event: React.MouseEvent) => void; + zoomOnDoubleClick?: boolean; }; type NodeRenderProps = {id: string; data: Record; type: string}; type EdgeRenderProps = {id: string; data: Record; sourceX: number; sourceY: number; targetX: number; targetY: number; sourcePosition: string; targetPosition: string}; @@ -61,8 +73,8 @@ vi.mock('@xyflow/react', async () => { const actual = await vi.importActual('@xyflow/react'); return { ...actual, - ReactFlow: ({nodes, edges, className, nodeTypes, edgeTypes, onNodeClick, onPaneClick}: StubReactFlowProps) => ( -
+ ReactFlow: ({nodes, edges, children, className, nodeTypes, edgeTypes, onNodeClick, onNodeDoubleClick, onPaneClick, zoomOnDoubleClick}: StubReactFlowProps) => ( +
{nodes.map((node) => { const nodeType = node.type ?? 'default'; const Custom = nodeTypes?.[nodeType]; @@ -75,6 +87,10 @@ vi.mock('@xyflow/react', async () => { event.stopPropagation(); onNodeClick?.(event, node); }} + onDoubleClick={(event) => { + event.stopPropagation(); + onNodeDoubleClick?.(event, node); + }} > {Custom ? : null}
@@ -101,13 +117,28 @@ vi.mock('@xyflow/react', async () => { ); })} + {children}
), Background: () => null, + Controls: ({children, className, showFitView, showInteractive, showZoom, style}: {children?: React.ReactNode; className?: string; showFitView?: boolean; showInteractive?: boolean; showZoom?: boolean; style?: React.CSSProperties}) => ( +
+ {children} +
+ ), Handle: () => null, BaseEdge: () => null, EdgeLabelRenderer: ({children}: {children: React.ReactNode}) => <>{children}, - getSmoothStepPath: () => ['M 0 0', 0, 0] + getSmoothStepPath: () => ['M 0 0', 0, 0], + useReactFlow: () => mockReactFlow, + useViewport: () => ({x: 0, y: 0, zoom: mockViewportZoom}) }; }); @@ -160,6 +191,11 @@ describe('AutomationEditor', () => { beforeEach(() => { mockUseReadAutomation.mockReset(); mockEditMutation.mutate.mockReset(); + mockReactFlow.fitView.mockReset(); + mockReactFlow.zoomIn.mockReset(); + mockReactFlow.zoomOut.mockReset(); + mockReactFlow.zoomTo.mockReset(); + mockViewportZoom = 1; mockEditMutation.isLoading = false; mockEditMutation.variables = undefined; }); @@ -220,6 +256,84 @@ describe('AutomationEditor', () => { ]); }); + it('renders styled canvas zoom controls without the interaction toggle', () => { + mockUseReadAutomation.mockReturnValue({ + data: {automations: [automationDetail]}, + isLoading: false, + isError: false + }); + + renderEditor(); + + expect(screen.getByTestId('react-flow-mock')).toHaveAttribute('data-zoom-on-double-click', 'false'); + const controls = screen.getByTestId('react-flow-controls'); + expect(controls).toHaveAttribute('data-show-interactive', 'false'); + expect(controls).toHaveAttribute('data-show-fit-view', 'false'); + expect(controls).toHaveAttribute('data-show-zoom', 'false'); + expect(controls).toHaveStyle({bottom: '24px', left: '24px'}); + expect(controls).toHaveClass('overflow-hidden', 'rounded-md'); + expect(screen.getByRole('button', {name: 'Zoom out'})).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Zoom level 100%'})).toHaveTextContent('100%'); + expect(screen.getByRole('button', {name: 'Zoom in'})).toBeInTheDocument(); + }); + + it('animates viewport changes from the custom canvas controls', () => { + mockUseReadAutomation.mockReturnValue({ + data: {automations: [automationDetail]}, + isLoading: false, + isError: false + }); + + renderEditor(); + + fireEvent.click(screen.getByRole('button', {name: 'Zoom in'})); + fireEvent.click(screen.getByRole('button', {name: 'Zoom out'})); + + expect(mockReactFlow.zoomIn).toHaveBeenCalledWith({duration: 180}); + expect(mockReactFlow.zoomOut).toHaveBeenCalledWith({duration: 180}); + }); + + it('opens a zoom preset menu from the canvas controls', () => { + mockViewportZoom = 0.75; + mockUseReadAutomation.mockReturnValue({ + data: {automations: [automationDetail]}, + isLoading: false, + isError: false + }); + + renderEditor(); + + fireEvent.pointerDown(screen.getByRole('button', {name: 'Zoom level 75%'}), {button: 0, ctrlKey: false}); + + expect(screen.getByRole('menuitem', {name: '150%'})).toBeInTheDocument(); + expect(screen.getByRole('menuitem', {name: '100%'})).toBeInTheDocument(); + expect(screen.getByRole('menuitem', {name: '75%'})).toBeInTheDocument(); + expect(screen.getByRole('menuitem', {name: '50%'})).toBeInTheDocument(); + expect(screen.getByRole('menuitem', {name: '25%'})).toBeInTheDocument(); + expect(screen.getByRole('menuitem', {name: 'Fit to view'})).toBeInTheDocument(); + expect(screen.getByRole('menuitem', {name: '75%'}).querySelector('svg')).toBeInTheDocument(); + }); + + it('animates zoom preset and fit view menu selections', () => { + mockUseReadAutomation.mockReturnValue({ + data: {automations: [automationDetail]}, + isLoading: false, + isError: false + }); + + renderEditor(); + + fireEvent.pointerDown(screen.getByRole('button', {name: 'Zoom level 100%'}), {button: 0, ctrlKey: false}); + fireEvent.click(screen.getByRole('menuitem', {name: '150%'})); + + expect(mockReactFlow.zoomTo).toHaveBeenCalledWith(1.5, {duration: 180}); + + fireEvent.pointerDown(screen.getByRole('button', {name: 'Zoom level 100%'}), {button: 0, ctrlKey: false}); + fireEvent.click(screen.getByRole('menuitem', {name: 'Fit to view'})); + + expect(mockReactFlow.fitView).toHaveBeenCalledWith({duration: 180}); + }); + it('opens a read-only sidebar for the trigger step', () => { mockUseReadAutomation.mockReturnValue({ data: {automations: [automationDetail]}, @@ -242,6 +356,129 @@ describe('AutomationEditor', () => { expect(within(sidebar).queryByRole('button', {name: /Edit/})).not.toBeInTheDocument(); }); + it('opens step properties from the node right-click menu', async () => { + mockUseReadAutomation.mockReturnValue({ + data: {automations: [automationDetail]}, + isLoading: false, + isError: false + }); + + renderEditor(); + + const waitStep = screen.getByRole('button', {name: 'Wait: 1 day'}); + fireEvent.contextMenu(waitStep, {clientX: 12, clientY: 12}); + fireEvent.click(await screen.findByRole('menuitem', {name: 'Edit settings'})); + + expect(waitStep).toHaveAttribute('aria-pressed', 'true'); + const sidebar = screen.getByRole('complementary', {name: 'Step details'}); + expect(within(sidebar).getByRole('heading', {name: '1 day'})).toBeInTheDocument(); + expect(within(sidebar).getByText('Wait for')).toBeInTheDocument(); + }); + + it('selects a node after its context menu is dismissed', async () => { + mockUseReadAutomation.mockReturnValue({ + data: {automations: [automationDetail]}, + isLoading: false, + isError: false + }); + + renderEditor(); + + const waitStep = screen.getByRole('button', {name: 'Wait: 1 day'}); + fireEvent.contextMenu(waitStep); + expect(await screen.findByRole('menuitem', {name: 'Edit settings'})).toBeInTheDocument(); + + fireEvent.keyDown(document, {key: 'Escape'}); + await waitFor(() => { + expect(screen.queryByRole('menuitem', {name: 'Edit settings'})).not.toBeInTheDocument(); + }); + + fireEvent.click(waitStep); + + expect(waitStep).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('complementary', {name: 'Step details'})).toHaveAttribute('data-state', 'open'); + }); + + it('shows delete in action node menus but not the trigger node menu', async () => { + mockUseReadAutomation.mockReturnValue({ + data: {automations: [automationDetail]}, + isLoading: false, + isError: false + }); + + renderEditor(); + + fireEvent.contextMenu(screen.getByRole('button', {name: 'Trigger: Member signs up'})); + expect(await screen.findByRole('menuitem', {name: 'Edit settings'})).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', {name: 'Delete'})).not.toBeInTheDocument(); + fireEvent.keyDown(document, {key: 'Escape'}); + + const waitStep = screen.getByRole('button', {name: 'Wait: 1 day'}); + fireEvent.contextMenu(waitStep); + fireEvent.click(await screen.findByRole('menuitem', {name: 'Delete'})); + + expect(screen.queryByRole('button', {name: 'Wait: 1 day'})).not.toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Send email: Welcome to The Blueprint'})).toBeInTheDocument(); + }); + + it('opens the email editor from the send email node right-click menu', async () => { + mockUseReadAutomation.mockReturnValue({ + data: {automations: [automationDetail]}, + isLoading: false, + isError: false + }); + + renderEditor(); + + const emailStep = screen.getByRole('button', {name: 'Send email: Welcome to The Blueprint'}); + fireEvent.contextMenu(emailStep); + fireEvent.click(await screen.findByRole('menuitem', {name: 'Edit email body'})); + + expect(await screen.findByTestId('email-content-modal')).toBeInTheDocument(); + expect(screen.queryByRole('complementary', {name: 'Step details'})).not.toBeInTheDocument(); + expect(emailStep).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByTestId('modal-initial-mode')).toHaveTextContent('edit'); + expect(screen.getByTestId('modal-initial-subject')).toHaveTextContent('Welcome to The Blueprint'); + expect(screen.getByTestId('modal-initial-lexical')).toHaveTextContent('{"root":{"children":[]}}'); + }); + + it('opens the email editor preview from the send email node right-click menu', async () => { + mockUseReadAutomation.mockReturnValue({ + data: {automations: [automationDetail]}, + isLoading: false, + isError: false + }); + + renderEditor(); + + const emailStep = screen.getByRole('button', {name: 'Send email: Welcome to The Blueprint'}); + fireEvent.contextMenu(emailStep); + fireEvent.click(await screen.findByRole('menuitem', {name: 'Preview'})); + + expect(await screen.findByTestId('email-content-modal')).toBeInTheDocument(); + expect(screen.queryByRole('complementary', {name: 'Step details'})).not.toBeInTheDocument(); + expect(emailStep).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByTestId('modal-initial-mode')).toHaveTextContent('preview'); + }); + + it('opens the email editor from an email node double-click without opening the sidebar', async () => { + mockUseReadAutomation.mockReturnValue({ + data: {automations: [automationDetail]}, + isLoading: false, + isError: false + }); + + renderEditor(); + + const emailStep = screen.getByRole('button', {name: 'Send email: Welcome to The Blueprint'}); + fireEvent.doubleClick(emailStep); + + expect(await screen.findByTestId('email-content-modal')).toBeInTheDocument(); + expect(screen.queryByRole('complementary', {name: 'Step details'})).not.toBeInTheDocument(); + expect(emailStep).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByTestId('modal-initial-mode')).toHaveTextContent('edit'); + }); + it('shows paid member eligibility for the paid welcome automation trigger', () => { mockUseReadAutomation.mockReturnValue({ data: { @@ -459,6 +696,34 @@ describe('AutomationEditor', () => { expect(mutateCall.actions).toContainEqual({id: 'action-wait', type: 'wait', data: {wait_hours: 72}}); }); + it('increments and decrements the wait step from the day input group buttons', () => { + mockUseReadAutomation.mockReturnValue({ + data: {automations: [automationDetail]}, + isLoading: false, + isError: false + }); + + renderEditor(); + + fireEvent.click(screen.getByRole('button', {name: 'Wait: 1 day'})); + let sidebar = screen.getByRole('complementary', {name: 'Step details'}); + expect(within(sidebar).getByLabelText('Wait for')).toHaveValue('1'); + expect(within(sidebar).getByRole('button', {name: 'Decrease wait by one day'})).toBeDisabled(); + + fireEvent.click(within(sidebar).getByRole('button', {name: 'Increase wait by one day'})); + + expect(screen.getByRole('button', {name: 'Wait: 2 days'})).toBeInTheDocument(); + sidebar = screen.getByRole('complementary', {name: 'Step details'}); + expect(within(sidebar).getByDisplayValue('2')).toBeInTheDocument(); + + fireEvent.click(within(sidebar).getByRole('button', {name: 'Decrease wait by one day'})); + + expect(screen.getByRole('button', {name: 'Wait: 1 day'})).toBeInTheDocument(); + sidebar = screen.getByRole('complementary', {name: 'Step details'}); + expect(within(sidebar).getByDisplayValue('1')).toBeInTheDocument(); + expect(within(sidebar).getByRole('button', {name: 'Decrease wait by one day'})).toBeDisabled(); + }); + it('rejects non-decimal wait editor values', () => { mockUseReadAutomation.mockReturnValue({ data: {automations: [automationDetail]}, @@ -476,6 +741,7 @@ describe('AutomationEditor', () => { fireEvent.change(waitInput, {target: {value}}); expect(waitInput).toHaveAttribute('aria-invalid', 'true'); + expect(waitInput).toHaveAttribute('aria-describedby', 'automation-wait-days-error'); expect(within(sidebar).getByText('Enter a whole number between 1 and 30 days.')).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Published'})).toBeDisabled(); } @@ -881,7 +1147,9 @@ describe('AutomationEditor', () => { fireEvent.click(within(picker).getByText('Wait')); // The new step renders with the default 24h wait ("1 day") at the end of the chain. - expect(screen.getByText('1 day')).toBeInTheDocument(); + const insertedNode = screen.getByRole('button', {name: 'Wait: 1 day'}); + expect(insertedNode).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getAllByText('1 day')).toHaveLength(2); // Adding a step flips the editor into a dirty state. expect(screen.getByRole('button', {name: 'Publish changes'})).toBeEnabled(); }); @@ -900,7 +1168,11 @@ describe('AutomationEditor', () => { fireEvent.click(within(picker).getByText('Email')); // The new send_email step renders with the placeholder subject. - expect(screen.getByText('Untitled email')).toBeInTheDocument(); + const insertedNode = screen.getByRole('button', {name: 'Send email: Untitled email'}); + expect(screen.getAllByText('Untitled email')).toHaveLength(2); + expect(insertedNode).toHaveClass('animate-in'); + expect(insertedNode).toHaveClass('zoom-in-90'); + expect(insertedNode).toHaveAttribute('aria-pressed', 'true'); expect(screen.getByRole('button', {name: 'Publish changes'})).toBeEnabled(); }); @@ -932,6 +1204,34 @@ describe('AutomationEditor', () => { expect(edgePairs).toContainEqual([insertedId, 'action-email']); }); + it('keeps the in-edge + button visible after leaving the button while still hovering the edge', () => { + mockUseReadAutomation.mockReturnValue({ + data: {automations: [automationDetail]}, + isLoading: false, + isError: false + }); + + renderEditor(); + + const edge = screen.getByTestId('react-flow-mock-edges').querySelector('[data-edge-id="e-action-wait-action-email"]'); + const edgeGroup = edge?.querySelector('g'); + const button = screen.getByTestId('add-step-button-action-wait-action-email'); + const labelHitZone = button.closest('.pointer-events-auto'); + + expect(edgeGroup).toBeInTheDocument(); + expect(labelHitZone).toBeInTheDocument(); + + fireEvent.mouseEnter(edgeGroup!); + expect(button).toHaveClass('opacity-100'); + + fireEvent.mouseEnter(labelHitZone!); + fireEvent.mouseLeave(labelHitZone!, {relatedTarget: edgeGroup}); + expect(button).toHaveClass('opacity-100'); + + fireEvent.mouseLeave(edgeGroup!); + expect(button).toHaveClass('opacity-0'); + }); + it('deletes a wait step and reconnects the chain', () => { mockUseReadAutomation.mockReturnValue({ data: {automations: [automationDetail]}, diff --git a/apps/shade/package.json b/apps/shade/package.json index b120dd6efa2..184dc5ce723 100644 --- a/apps/shade/package.json +++ b/apps/shade/package.json @@ -89,6 +89,7 @@ "@testing-library/react": "catalog:", "@types/node": "catalog:", "@types/react-world-flags": "1.6.0", + "@typescript-eslint/eslint-plugin": "catalog:", "@typescript-eslint/parser": "catalog:", "@vitejs/plugin-react": "catalog:", "@vitest/coverage-v8": "catalog:", @@ -119,6 +120,7 @@ "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-avatar": "catalog:", "@radix-ui/react-checkbox": "catalog:", + "@radix-ui/react-context-menu": "catalog:", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-hover-card": "1.1.15", diff --git a/apps/shade/src/components.ts b/apps/shade/src/components.ts index 07e7548e196..826f5021fa5 100644 --- a/apps/shade/src/components.ts +++ b/apps/shade/src/components.ts @@ -11,6 +11,7 @@ export * from './components/ui/card'; export * from './components/ui/chart'; export * from './components/ui/checkbox'; export * from './components/ui/command'; +export * from './components/ui/context-menu'; export * from './components/ui/data-list'; export * from './components/ui/dialog'; export * from './components/ui/dropdown-menu'; @@ -50,6 +51,7 @@ export * from './components/ui/tooltip'; export * from './components/ui/trend-badge'; export type {DropdownMenuCheckboxItemProps as DropdownMenuCheckboxItemProps} from '@radix-ui/react-dropdown-menu'; +export type {ContextMenuCheckboxItemProps as ContextMenuCheckboxItemProps} from '@radix-ui/react-context-menu'; export {IconComponents as Icon} from './components/ui/icon'; diff --git a/apps/shade/src/components/ui/context-menu.stories.tsx b/apps/shade/src/components/ui/context-menu.stories.tsx new file mode 100644 index 00000000000..98a90a455f8 --- /dev/null +++ b/apps/shade/src/components/ui/context-menu.stories.tsx @@ -0,0 +1,86 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {Archive, Copy, Pencil, Share, Trash} from 'lucide-react'; +import {ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuPortal, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger} from './context-menu'; + +const meta = { + title: 'Components / Context menu', + component: ContextMenu, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: 'Right-click menu for object-specific actions. Use when commands are secondary to the main surface and need pointer or long-press access.' + } + } + } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const TriggerBox = () => ( + + Right click or long press + +); + +export const Default: Story = { + parameters: { + docs: { + description: { + story: 'Includes groups, separators, submenus, disabled items, checkbox and radio states, destructive styling, icons, and keyboard shortcuts.' + } + } + }, + args: { + children: [ + , + + Post actions + + + + + Edit + E + + + + Duplicate + D + + + + Share + + + + + Show in list + Pin to top + + + + + + Move to + + + + + Drafts + Archive + + + + + Locked action + + + + Delete + + + ] + } +}; diff --git a/apps/shade/src/components/ui/context-menu.tsx b/apps/shade/src/components/ui/context-menu.tsx new file mode 100644 index 00000000000..b6756d42a06 --- /dev/null +++ b/apps/shade/src/components/ui/context-menu.tsx @@ -0,0 +1,202 @@ +import * as React from 'react'; +import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'; +import {Check, ChevronRight, Circle} from 'lucide-react'; + +import {cn} from '@/lib/utils'; +import {SHADE_APP_NAMESPACES} from '@/shade-app'; + +const ContextMenu = ContextMenuPrimitive.Root; + +const ContextMenuTrigger = ContextMenuPrimitive.Trigger; + +const ContextMenuGroup = ContextMenuPrimitive.Group; + +const ContextMenuPortal = ContextMenuPrimitive.Portal; + +const ContextMenuSub = ContextMenuPrimitive.Sub; + +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; + +const ContextMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({className, inset, children, ...props}, ref) => ( + + {children} + + +)); +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; + +const ContextMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({className, ...props}, ref) => ( +
+ +
+)); +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({className, ...props}, ref) => ( + +
+ +
+
+)); +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName; + +const ContextMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean, + variant?: 'default' | 'destructive' + } +>(({className, inset, variant = 'default', ...props}, ref) => ( + svg]:size-4 [&>svg]:shrink-0', + variant === 'destructive' && 'text-destructive focus:bg-destructive/10 focus:text-destructive', + inset && 'pl-8', + className + )} + {...props} + /> +)); +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; + +const ContextMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({className, children, checked, ...props}, ref) => ( + + + + + + + {children} + +)); +ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName; + +const ContextMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({className, children, ...props}, ref) => ( + + + + + + + {children} + +)); +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName; + +const ContextMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({className, inset, ...props}, ref) => ( + +)); +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName; + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({className, ...props}, ref) => ( + +)); +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName; + +const ContextMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +ContextMenuShortcut.displayName = 'ContextMenuShortcut'; + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup +}; diff --git a/ghost/core/core/frontend/services/llms/service.js b/ghost/core/core/frontend/services/llms/service.js index d20f433259f..daebdc7b773 100644 --- a/ghost/core/core/frontend/services/llms/service.js +++ b/ghost/core/core/frontend/services/llms/service.js @@ -199,8 +199,7 @@ function createLlmsService({settingsCache, labs, config, urlServiceFacade, urlUt limit: 'all', order: type === 'post' ? 'published_at desc' : 'id asc', filter: `status:published+visibility:public+type:${type}`, - columns: ['id', 'title', 'slug', 'custom_excerpt', 'plaintext', 'published_at', 'type'], - withRelated: ['tags', 'authors'] + columns: ['id', 'title', 'slug', 'custom_excerpt', 'plaintext', 'published_at', 'type'] }); const entries = page.data.map((model) => { @@ -222,8 +221,7 @@ function createLlmsService({settingsCache, labs, config, urlServiceFacade, urlUt page: pageNum, order: type === 'post' ? 'published_at desc' : 'id asc', filter: `status:published+visibility:public+type:${type}`, - columns: ['id', 'title', 'slug', 'html', 'plaintext', 'custom_excerpt', 'updated_at', 'published_at', 'created_at', 'type'], - withRelated: ['tags', 'authors'] + columns: ['id', 'title', 'slug', 'html', 'plaintext', 'custom_excerpt', 'updated_at', 'published_at', 'created_at', 'type'] }); const entries = result.data.map((model) => { diff --git a/ghost/core/core/server/services/automations/automations-api.ts b/ghost/core/core/server/services/automations/automations-api.ts index 61e555cc347..a87499031ae 100644 --- a/ghost/core/core/server/services/automations/automations-api.ts +++ b/ghost/core/core/server/services/automations/automations-api.ts @@ -6,10 +6,12 @@ import type {DatabaseSync} from 'node:sqlite'; import {z} from 'zod'; import {createFakeDatabaseAutomationsRepository} from './fake-database-automations-repository'; import type { + AutomationsRepository, EditAutomationData } from './automations-repository'; const domainEvents = require('@tryghost/domain-events'); +const labs = require('../../../shared/labs'); const StartAutomationsPollEvent = require('./events/start-automations-poll-event'); const temporaryFakeAutomationsDatabase = require('./temporary-fake-database'); @@ -238,6 +240,30 @@ export function requestPoll() { domainEvents.dispatch(StartAutomationsPollEvent.create()); } +type TriggerOptions = Parameters[0] & { + event: 'member_sign_up'; +}; +export async function trigger(options: TriggerOptions) { + if (options.event !== 'member_sign_up') { + throw new errors.IncorrectUsageError({ + message: 'Member signup is the only supported event right now. More may be added later' + }); + } + + const isAllowedEnvironment = ( + process.env.NODE_ENV === 'development' || + process.env.NODE_ENV?.startsWith('testing') + ); + const shouldTrigger = isAllowedEnvironment && labs.isSet('automations'); + if (!shouldTrigger) { + return; + } + + await repository.trigger(options); + + requestPoll(); +} + export function _resetTestDatabase() { if (process.env.NODE_ENV?.startsWith('testing')) { testDatabase = null; diff --git a/ghost/core/core/server/services/automations/automations-repository.ts b/ghost/core/core/server/services/automations/automations-repository.ts index 6e5b16e64d4..bc9e3a184b9 100644 --- a/ghost/core/core/server/services/automations/automations-repository.ts +++ b/ghost/core/core/server/services/automations/automations-repository.ts @@ -66,4 +66,9 @@ export interface AutomationsRepository { browse(): Promise>; getById(id: string): Promise; edit(id: string, data: EditAutomationData): Promise; + trigger(options: { + memberEmail: string; + memberId: string; + memberStatus: 'free' | 'paid'; + }): Promise; } diff --git a/ghost/core/core/server/services/automations/fake-database-automations-repository.ts b/ghost/core/core/server/services/automations/fake-database-automations-repository.ts index d4b2e7dc996..9f2d9a42114 100644 --- a/ghost/core/core/server/services/automations/fake-database-automations-repository.ts +++ b/ghost/core/core/server/services/automations/fake-database-automations-repository.ts @@ -2,6 +2,7 @@ import errors from '@tryghost/errors'; import tpl from '@tryghost/tpl'; import ObjectId from 'bson-objectid'; import type {DatabaseSync} from 'node:sqlite'; +import {MEMBER_WELCOME_EMAIL_SLUGS} from '../member-welcome-emails/constants'; import type { Automation, AutomationAction, @@ -12,6 +13,8 @@ import type { Page } from './automations-repository'; +const HOUR_MS = 60 * 60 * 1000; + const messages = { invalidAutomationActionRevision: 'Automation action "{actionId}" of type "{actionType}" is missing required revision field "{field}".', conflictingAutomationActionId: 'Automation action "{actionId}" already exists and cannot be inserted.', @@ -44,6 +47,14 @@ interface EdgeRow { target_action_id: string; } +type NextActionRevisionRow = { + automation_id: string; + action_id: string; + automation_action_revision_id: string; + type: 'wait' | 'send_email'; + wait_hours: number | null; +}; + export function createFakeDatabaseAutomationsRepository({ getDatabase }: { @@ -98,6 +109,16 @@ export function createFakeDatabaseAutomationsRepository({ return buildAutomation(database, updatedAutomation); }); + }, + + async trigger(options: { + memberEmail: string; + memberId: string; + memberStatus: 'free' | 'paid'; + }): Promise { + const database = getDatabase(); + + return withTransaction(database, () => trigger(database, options)); } }; } @@ -115,6 +136,112 @@ function withTransaction(database: DatabaseSync, operation: () => T): T { } } +function trigger(database: DatabaseSync, { + memberEmail, + memberId, + memberStatus +}: Readonly<{ + memberEmail: string; + memberId: string; + memberStatus: 'free' | 'paid'; +}>): void { + const firstAction = findFirstActionRevision(database, memberStatus); + if (!firstAction) { + return; + } + + const now = new Date(); + const nowString = now.toISOString(); + + const readyAt = getReadyAtForAction(firstAction, now); + + const run = { + id: ObjectId().toHexString(), + created_at: nowString, + updated_at: nowString, + automation_id: firstAction.automation_id, + member_id: memberId, + member_email: memberEmail + }; + + database.prepare(` + INSERT INTO automation_runs + (id, created_at, updated_at, automation_id, member_id, member_email) VALUES + (:id, :created_at, :updated_at, :automation_id, :member_id, :member_email) + `).run(run); + database.prepare(` + INSERT INTO automation_run_steps + (id, created_at, updated_at, automation_run_id, automation_action_revision_id, ready_at) VALUES + (:id, :created_at, :updated_at, :automation_run_id, :automation_action_revision_id, :ready_at) + `).run({ + id: ObjectId().toHexString(), + created_at: nowString, + updated_at: nowString, + automation_run_id: run.id, + automation_action_revision_id: firstAction.automation_action_revision_id, + ready_at: readyAt.toISOString() + }); +} + +function findFirstActionRevision(database: DatabaseSync, memberStatus: 'free' | 'paid'): NextActionRevisionRow | null { + const automationSlug: NonNullable = MEMBER_WELCOME_EMAIL_SLUGS[memberStatus]; + + const row = database.prepare(` + SELECT + automation.id AS automation_id, + actions.id AS action_id, + revisions.id AS automation_action_revision_id, + actions.type AS type, + revisions.wait_hours AS wait_hours + FROM automations automation + INNER JOIN automation_actions actions ON actions.automation_id = automation.id + INNER JOIN automation_action_revisions revisions ON revisions.action_id = actions.id + WHERE automation.slug = ? + AND automation.status = 'active' + AND actions.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM automation_action_edges edge + INNER JOIN automation_actions source_actions ON source_actions.id = edge.source_action_id + AND source_actions.deleted_at IS NULL + WHERE edge.target_action_id = actions.id + ) + AND revisions.created_at = ( + SELECT MAX(created_at) + FROM automation_action_revisions + WHERE action_id = actions.id + ) + ORDER BY actions.created_at, actions.id + LIMIT 1 + `).get(automationSlug) as NextActionRevisionRow | undefined; + + return row ?? null; +} + +function getReadyAtForAction( + action: Pick, + now: Readonly +): Date { + switch (action.type) { + case 'wait': { + const waitHours = requireValue({ + ...action, + id: action.action_id + }, 'wait_hours'); + const waitMs = waitHours * HOUR_MS; + return new Date(now.getTime() + waitMs); + } + case 'send_email': + return now; + default: { + const _exhaustive: never = action.type; + throw new errors.IncorrectUsageError({ + message: `Unexpected action type ${_exhaustive}` + }); + } + } +} + function loadAutomation(database: DatabaseSync, automationId: string): AutomationRow | null { const automation = database.prepare(` SELECT id, slug, name, status, created_at, updated_at diff --git a/ghost/core/core/server/services/automations/temporary-fake-database.js b/ghost/core/core/server/services/automations/temporary-fake-database.ts similarity index 92% rename from ghost/core/core/server/services/automations/temporary-fake-database.js rename to ghost/core/core/server/services/automations/temporary-fake-database.ts index 0d09019a6ba..b1c3f96b3c1 100644 --- a/ghost/core/core/server/services/automations/temporary-fake-database.js +++ b/ghost/core/core/server/services/automations/temporary-fake-database.ts @@ -10,16 +10,16 @@ * migration once we're sure this schema is correct. */ -const errors = require('@tryghost/errors'); -const ObjectId = require('bson-objectid').default; +import * as errors from '@tryghost/errors'; +import ObjectId from 'bson-objectid'; +import type {DatabaseSync} from 'node:sqlite'; -/** - * @returns {import('node:sqlite').DatabaseSync} - */ -function createTemporaryFakeAutomationsDatabase() { - const {DatabaseSync} = require('node:sqlite'); +export function createTemporaryFakeAutomationsDatabase(): DatabaseSync { + // We want to do this import dynamically. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const sqlite = require('node:sqlite'); - const database = new DatabaseSync(':memory:'); + const database = new sqlite.DatabaseSync(':memory:'); database.exec('PRAGMA foreign_keys = ON;'); const id = () => ObjectId().toHexString(); @@ -98,10 +98,10 @@ CREATE TABLE automation_run_steps ( automation_run_id TEXT NOT NULL REFERENCES automation_runs(id), automation_action_revision_id TEXT NOT NULL REFERENCES automation_action_revisions(id), ready_at TEXT NOT NULL, - step_attempts INTEGER NOT NULL, + step_attempts INTEGER NOT NULL DEFAULT 0, started_at TEXT, finished_at TEXT, - status TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', locked_by TEXT, locked_at TEXT ) STRICT; @@ -312,13 +312,9 @@ CREATE TABLE automation_run_steps ( return database; } -/** @type {null | import('node:sqlite').DatabaseSync} */ -let cachedDatabase = null; +let cachedDatabase: DatabaseSync | null = null; -/** - * @returns {import('node:sqlite').DatabaseSync} - */ -function getTemporaryFakeAutomationsDatabase() { +export function getTemporaryFakeAutomationsDatabase(): DatabaseSync { if (process.env.NODE_ENV !== 'development') { throw new errors.IncorrectUsageError({ message: 'Fake automations database should only be used in development' @@ -326,7 +322,4 @@ function getTemporaryFakeAutomationsDatabase() { } cachedDatabase ??= createTemporaryFakeAutomationsDatabase(); return cachedDatabase; -} - -exports.createTemporaryFakeAutomationsDatabase = createTemporaryFakeAutomationsDatabase; -exports.getTemporaryFakeAutomationsDatabase = getTemporaryFakeAutomationsDatabase; +} \ No newline at end of file diff --git a/ghost/core/core/server/services/gifts/gift-service.ts b/ghost/core/core/server/services/gifts/gift-service.ts index 8e04794cdfb..df225553609 100644 --- a/ghost/core/core/server/services/gifts/gift-service.ts +++ b/ghost/core/core/server/services/gifts/gift-service.ts @@ -6,7 +6,6 @@ import type {GiftRepository} from './gift-repository'; import type {GiftReminderScheduler} from './gift-reminder-scheduler'; 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; @@ -38,7 +37,12 @@ interface MemberModel { interface MemberRepository { get(filter: Record, options?: Record): Promise; update(data: Record, options?: Record): Promise; - enqueueWelcomeEmailRun(memberId: string, slug: string, options?: Record): Promise; + triggerMemberSignupAutomation( + memberId: string, + memberEmail: string, + memberStatus: 'free' | 'paid', + options?: Record + ): Promise; } type Tier = { @@ -306,7 +310,12 @@ 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}); + await this.deps.memberRepository.triggerMemberSignupAutomation( + memberId, + member.get('email'), + 'paid', + {transacting} + ); return {redeemed, member}; }; diff --git a/ghost/core/core/server/services/members/members-api/members-api.js b/ghost/core/core/server/services/members/members-api/members-api.js index ff5085e0100..d2c5d3c6558 100644 --- a/ghost/core/core/server/services/members/members-api/members-api.js +++ b/ghost/core/core/server/services/members/members-api/members-api.js @@ -19,6 +19,7 @@ const WellKnownController = require('./controllers/well-known-controller'); const {EmailSuppressedEvent} = require('../../email-suppression-list/email-suppression-list'); const MagicLink = require('../../lib/magic-link/magic-link'); const DomainEvents = require('@tryghost/domain-events'); +const automationsApi = require('../../automations/automations-api'); module.exports = function MembersAPI({ tokenConfig: { @@ -104,6 +105,7 @@ module.exports = function MembersAPI({ tokenService, newslettersService, productRepository, + automationsApi, Automation, WelcomeEmailAutomationRun, Member, 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 a3dfbdfa419..57eda19fc45 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 @@ -11,6 +11,8 @@ const crypto = require('crypto'); const hasActiveOffer = require('../utils/has-active-offer'); const StartAutomationsPollEvent = require('../../../automations/events/start-automations-poll-event'); const {MEMBER_WELCOME_EMAIL_SLUGS} = require('../../../member-welcome-emails/constants'); +/** @import {Knex} from 'knex' */ +/** @import * as automationsApi from '../../../automations/automations-api' */ const messages = { noStripeConnection: 'Cannot {action} without a Stripe Connection', @@ -66,6 +68,7 @@ module.exports = class MemberRepository { * @param {any} deps.offersAPI * @param {ITokenService} deps.tokenService * @param {any} deps.newslettersService + * @param {Pick} deps.automationsApi * @param {any} deps.Automation * @param {any} deps.WelcomeEmailAutomationRun */ @@ -87,6 +90,7 @@ module.exports = class MemberRepository { offersAPI, tokenService, newslettersService, + automationsApi, Automation, WelcomeEmailAutomationRun }) { @@ -107,6 +111,7 @@ module.exports = class MemberRepository { this._offersAPI = offersAPI; this.tokenService = tokenService; this._newslettersService = newslettersService; + this._automationsApi = automationsApi; this._Automation = Automation; this._WelcomeEmailAutomationRun = WelcomeEmailAutomationRun; @@ -173,28 +178,40 @@ module.exports = class MemberRepository { return nickname && nickname.toLowerCase() === 'complimentary'; } + /** + * @param {string} memberId + * @param {string} memberEmail + * @param {'free' | 'paid'} memberStatus + * @returns {Promise} + */ + async #triggerMemberSignupAutomation(memberId, memberEmail, memberStatus) { + // TODO(NY-1311) When moving to real tables, we should insert the new + // rows in a new transaction. + await this._automationsApi.trigger({ + event: 'member_sign_up', + memberId, + memberEmail, + memberStatus + }); + } + /** * 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. + * so the poll picks it up. * * @param {string} memberId - * @param {string} slug automation slug, see MEMBER_WELCOME_EMAIL_SLUGS - * @param {object} [options] bookshelf options (transacting, context, etc.) + * @param {'free' | 'paid'} memberStatus + * @param {object} options + * @returns {Promise} */ - async enqueueWelcomeEmailRun(memberId, slug, options = {}) { + async #triggerMemberSignupLegacyAutomation(memberId, memberStatus, options) { if (!this._Automation || !this._WelcomeEmailAutomationRun) { - return null; + return; } const automation = await this._Automation.findOne( - {slug}, + {slug: MEMBER_WELCOME_EMAIL_SLUGS[memberStatus]}, {...options, withRelated: ['welcomeEmailAutomatedEmail']} ); const email = automation?.related('welcomeEmailAutomatedEmail'); @@ -206,10 +223,10 @@ module.exports = class MemberRepository { ); if (!isActive) { - return null; + return; } - const run = await this._WelcomeEmailAutomationRun.add({ + await this._WelcomeEmailAutomationRun.add({ welcome_email_automation_id: automation.id, member_id: memberId, next_welcome_email_automated_email_id: email.id, @@ -220,8 +237,25 @@ module.exports = class MemberRepository { }, options); this.dispatchEvent(StartAutomationsPollEvent.create(), options); + } - return run; + /** + * Trigger an automation for member signup. + * + * Callers are responsible for any eligibility gating (member status, source, etc.) + * before calling this. + * + * @param {string} memberId + * @param {string} memberEmail + * @param {'free' | 'paid'} memberStatus + * @param {object} bookshelfOptions + * @returns {Promise} + */ + async triggerMemberSignupAutomation(memberId, memberEmail, memberStatus, bookshelfOptions) { + await Promise.all([ + this.#triggerMemberSignupAutomation(memberId, memberEmail, memberStatus), + this.#triggerMemberSignupLegacyAutomation(memberId, memberStatus, bookshelfOptions) + ]); } /** @@ -437,7 +471,12 @@ module.exports = class MemberRepository { labels }, {...memberAddOptions, transacting}); - await this.enqueueWelcomeEmailRun(newMember.id, MEMBER_WELCOME_EMAIL_SLUGS.free, {transacting}); + await this.triggerMemberSignupAutomation( + newMember.id, + newMember.get('email'), + 'free', + {transacting} + ); return newMember; }; @@ -1021,7 +1060,8 @@ module.exports = class MemberRepository { * @param {Object} data.subscription * @param {string | null} [data.offerId] * @param {import('../../../member-attribution/attribution-builder').AttributionResource} [data.attribution] - * @param {*} options + * @param {object} [options] + * @param {Knex.Transaction} [options.transacting] * @returns */ async linkSubscription(data, options = {}) { @@ -1527,8 +1567,8 @@ module.exports = class MemberRepository { const context = options?.context || {}; const source = this._resolveContextSource(context); - // Enqueue paid welcome email if: - // 1. The source is allowed to send welcome emails + // Enqueue automation if: + // 1. The source is allowed to trigger automations // 2. The member status changed to 'paid' // 3. The previous status wasn't 'gift', as gift members already received the paid welcome email on redemption if ( @@ -1536,7 +1576,12 @@ module.exports = class MemberRepository { updatedMember.get('status') === 'paid' && updatedMember._previousAttributes.status !== 'gift' ) { - await this.enqueueWelcomeEmailRun(memberModel.id, MEMBER_WELCOME_EMAIL_SLUGS.paid, options); + await this.triggerMemberSignupAutomation( + memberModel.id, + memberModel.get('email'), + 'paid', + options + ); } } } diff --git a/ghost/core/core/server/services/settings-helpers/settings-helpers.js b/ghost/core/core/server/services/settings-helpers/settings-helpers.js index 76476c88fa5..e963fd06435 100644 --- a/ghost/core/core/server/services/settings-helpers/settings-helpers.js +++ b/ghost/core/core/server/services/settings-helpers/settings-helpers.js @@ -40,6 +40,7 @@ class SettingsHelpers { throw new errors.IncorrectUsageError({message: tpl(messages.incorrectKeyType)}); } + // secretlint-disable-next-line @secretlint/secretlint-rule-pattern const secretKey = this.settingsCache.get(`stripe_${type === 'connect' ? 'connect_' : ''}secret_key`); const publicKey = this.settingsCache.get(`stripe_${type === 'connect' ? 'connect_' : ''}publishable_key`); diff --git a/ghost/core/test/unit/frontend/services/llms/service.test.js b/ghost/core/test/unit/frontend/services/llms/service.test.js index 4de44e23bf9..a9322b5ea42 100644 --- a/ghost/core/test/unit/frontend/services/llms/service.test.js +++ b/ghost/core/test/unit/frontend/services/llms/service.test.js @@ -230,6 +230,46 @@ describe('Unit: frontend/services/llms/service', function () { assert.ok(callCount >= 4, `Expected at least 4 DB calls (2 per getLlmsTxt), got ${callCount}`); }); + it('does not load post relations for llms.txt index entries', async function () { + const calls = []; + const models = { + Post: { + findPage: async function (options) { + calls.push(options); + return {data: []}; + } + } + }; + + const service = createService({models, urlMap: {}}); + + await service.getLlmsTxt(); + + assert.equal(calls.length, 2); + assert.equal(calls[0].withRelated, undefined); + assert.equal(calls[1].withRelated, undefined); + }); + + it('does not load post relations for llms-full.txt entries', async function () { + const calls = []; + const models = { + Post: { + findPage: async function (options) { + calls.push(options); + return {data: []}; + } + } + }; + + const service = createService({models, urlMap: {}}); + + await service.getLlmsFullTxt(); + + assert.equal(calls.length, 2); + assert.equal(calls[0].withRelated, undefined); + assert.equal(calls[1].withRelated, undefined); + }); + describe('fetchPublicEntry', function () { it('calls the correct controller for pages vs posts', async function () { const calls = []; diff --git a/ghost/core/test/unit/server/services/automations/automations-repository.test.ts b/ghost/core/test/unit/server/services/automations/automations-repository.test.ts new file mode 100644 index 00000000000..1c2e5312398 --- /dev/null +++ b/ghost/core/test/unit/server/services/automations/automations-repository.test.ts @@ -0,0 +1,205 @@ +import assert from 'node:assert/strict'; +import {AutomationsRepository} from '../../../../../core/server/services/automations/automations-repository'; +import {createTemporaryFakeAutomationsDatabase} from '../../../../../core/server/services/automations/temporary-fake-database'; +import {createFakeDatabaseAutomationsRepository} from '../../../../../core/server/services/automations/fake-database-automations-repository'; +import type {DatabaseSync, SQLInputValue} from 'node:sqlite'; + +const addHours = (dateCol: unknown, hours: number): Date => { + assert(typeof dateCol === 'string', 'Expected date column to be a string'); + const start = new Date(dateCol).valueOf(); + const delta = hours * 60 * 60 * 1000; + return new Date(start + delta); +}; + +// These tests are partly coupled to the *fake* repository. We should be able to +// modify it once we have the real repository. +describe('automations repository', function () { + let database: DatabaseSync; + let repo: AutomationsRepository; + + const getRunByMemberEmail = (email: string) => ( + database!.prepare(` + SELECT + automation_runs.*, + automations.slug AS automation_slug + FROM automation_runs + INNER JOIN automations ON automations.id = automation_runs.automation_id + WHERE automation_runs.member_email = ? + `).get(email) + ); + + const getStepByRunId = (runId: SQLInputValue) => ( + database!.prepare(` + SELECT + automation_run_steps.*, + automation_actions.id AS action_id, + automation_actions.type AS action_type, + automation_action_revisions.wait_hours AS wait_hours, + automation_action_revisions.email_subject AS email_subject + FROM automation_run_steps + INNER JOIN automation_action_revisions ON automation_action_revisions.id = automation_run_steps.automation_action_revision_id + INNER JOIN automation_actions ON automation_actions.id = automation_action_revisions.action_id + WHERE automation_run_steps.automation_run_id = ? + `).get(runId) + ); + + const getAutomationBySlug = async (slug: string) => { + const automationSummaries = await repo.browse(); + const automationSummary = automationSummaries.data.find(automation => automation.slug === slug); + assert(automationSummary); + const automation = await repo.getById(automationSummary.id); + assert(automation); + return automation; + }; + + const getRunCountByAutomationId = (automationId: SQLInputValue) => { + const result = database!.prepare(` + SELECT COUNT(*) AS count + FROM automation_runs + WHERE automation_id = ? + `).get(automationId); + return result?.count; + }; + + beforeEach(function () { + database = createTemporaryFakeAutomationsDatabase(); + repo = createFakeDatabaseAutomationsRepository({ + getDatabase: () => database + }); + }); + + afterEach(function () { + database.close(); + }); + + describe('trigger', function () { + it('can trigger an automation for a free member', async function () { + await repo.trigger({ + memberEmail: 'free@example.com', + memberId: 'member_123', + memberStatus: 'free' + }); + + const run = getRunByMemberEmail('free@example.com'); + assert(run); + assert.equal(run.member_email, 'free@example.com'); + assert.equal(run.member_id, 'member_123'); + assert.equal(run.automation_slug, 'member-welcome-email-free'); + assert.equal(run.created_at, run.updated_at); + + const step = getStepByRunId(run.id); + assert(step); + assert.equal(step.automation_run_id, run.id); + assert.equal(step.action_type, 'wait'); + assert.equal(step.wait_hours, 48); + assert.equal(step.created_at, run.created_at); + assert.equal(step.updated_at, run.updated_at); + assert.equal(step.ready_at, addHours(run.created_at, 48).toISOString()); + assert.equal(step.step_attempts, 0); + assert.equal(step.started_at, null); + assert.equal(step.finished_at, null); + assert.equal(step.status, 'pending'); + assert.equal(step.locked_by, null); + assert.equal(step.locked_at, null); + }); + + it('can trigger an automation for a paid member', async function () { + await repo.trigger({ + memberEmail: 'paid@example.com', + memberId: 'member_123', + memberStatus: 'paid' + }); + + const run = getRunByMemberEmail('paid@example.com'); + assert(run); + assert.equal(run.automation_slug, 'member-welcome-email-paid'); + + const step = getStepByRunId(run.id); + assert(step); + assert.equal(step.automation_run_id, run.id); + assert.equal(step.action_type, 'wait'); + }); + + it('inserts the first non-deleted step', async function () { + const automation = await getAutomationBySlug('member-welcome-email-free'); + await repo.edit(automation.id, { + status: 'active', + actions: [ + { + id: 'wait-action-to-delete', + type: 'wait', + data: {wait_hours: 72} + }, + { + id: 'main-wait-action', + type: 'wait', + data: {wait_hours: 24} + } + ], + edges: [{ + source_action_id: 'wait-action-to-delete', + target_action_id: 'main-wait-action' + }] + }); + await repo.edit(automation.id, { + status: 'active', + actions: [ + { + id: 'main-wait-action', + type: 'wait', + data: {wait_hours: 24} + } + ], + edges: [] + }); + + await repo.trigger({ + memberEmail: 'free@example.com', + memberId: 'member_123', + memberStatus: 'free' + }); + + const run = getRunByMemberEmail('free@example.com'); + assert(run); + + const step = getStepByRunId(run.id); + assert(step); + assert.equal(step.action_id, 'main-wait-action'); + }); + + it('does not trigger an automation for an inactive automation', async function () { + const freeAutomation = await getAutomationBySlug('member-welcome-email-free'); + await repo.edit(freeAutomation.id, { + ...freeAutomation, + status: 'inactive' + }); + + await repo.trigger({ + memberEmail: 'inactive-free@example.com', + memberId: 'member_123', + memberStatus: 'free' + }); + + assert.equal(getRunByMemberEmail('inactive-free@example.com'), undefined); + assert.equal(getRunCountByAutomationId(freeAutomation.id), 0); + }); + + it('does not trigger an automation for an automation with no actions', async function () { + const freeAutomation = await getAutomationBySlug('member-welcome-email-free'); + await repo.edit(freeAutomation.id, { + status: 'active', + actions: [], + edges: [] + }); + + await repo.trigger({ + memberEmail: 'free-no-actions@example.com', + memberId: 'member_123', + memberStatus: 'free' + }); + + assert.equal(getRunByMemberEmail('free-no-actions@example.com'), undefined); + assert.equal(getRunCountByAutomationId(freeAutomation.id), 0); + }); + }); +}); 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 fc41f3a64bf..352352bedb9 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 @@ -48,7 +48,7 @@ describe('GiftService', function () { let memberRepository: { get: sinon.SinonStub; update: sinon.SinonStub; - enqueueWelcomeEmailRun: sinon.SinonStub; + triggerMemberSignupAutomation: sinon.SinonStub; }; let staffServiceEmails: { notifyGiftPurchased: sinon.SinonStub; @@ -101,7 +101,7 @@ describe('GiftService', function () { return Promise.resolve({id: 'member_1', get: memberGet}); }), update: sinon.stub().resolves(undefined), - enqueueWelcomeEmailRun: sinon.stub().resolves(undefined) + triggerMemberSignupAutomation: sinon.stub().resolves(undefined) }; staffServiceEmails = { notifyGiftPurchased: sinon.stub(), @@ -1265,7 +1265,7 @@ describe('GiftService', function () { sinon.assert.notCalled(staffServiceEmails.notifyGiftSubscriptionStarted); }); - it('enqueues the paid welcome email run for a new gift signup', async function () { + it('triggers the paid member signup automation for a new gift signup', async function () { const gift = buildGift(); const memberGet = sinon.stub(); memberGet.withArgs('status').returns('gift'); @@ -1279,14 +1279,15 @@ describe('GiftService', function () { await service.redeem('gift-token', 'member_1', {newMember: true}); sinon.assert.calledOnceWithExactly( - memberRepository.enqueueWelcomeEmailRun, + memberRepository.triggerMemberSignupAutomation, 'member_1', - 'member-welcome-email-paid', + 'member@example.com', + 'paid', {transacting: 'trx'} ); }); - it('enqueues the paid welcome email run when an existing free member redeems a gift', async function () { + it('triggers the paid member signup automation when an existing free member redeems a gift', async function () { const gift = buildGift(); const memberGet = sinon.stub(); memberGet.withArgs('status').returns('free'); @@ -1300,14 +1301,15 @@ describe('GiftService', function () { await service.redeem('gift-token', 'member_1'); sinon.assert.calledOnceWithExactly( - memberRepository.enqueueWelcomeEmailRun, + memberRepository.triggerMemberSignupAutomation, 'member_1', - 'member-welcome-email-paid', + 'member@example.com', + 'paid', {transacting: 'trx'} ); }); - it('passes the external transaction through to the welcome email enqueue', async function () { + it('passes the external transaction through to the member signup automation trigger', async function () { const gift = buildGift(); const memberGet = sinon.stub(); memberGet.withArgs('status').returns('free'); @@ -1322,9 +1324,10 @@ describe('GiftService', function () { await service.redeem('gift-token', 'member_1', {transacting: externalTrx}); sinon.assert.calledOnceWithExactly( - memberRepository.enqueueWelcomeEmailRun, + memberRepository.triggerMemberSignupAutomation, 'member_1', - 'member-welcome-email-paid', + 'member@example.com', + 'paid', {transacting: externalTrx} ); }); 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 23832589119..96383dbaf6c 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 @@ -22,6 +22,7 @@ describe('MemberRepository', function () { let WelcomeEmailAutomationRun; let newslettersService; let offersAPI; + let automationsApi; let productRepository; let stripeAPIService; let tokenService; @@ -47,6 +48,7 @@ describe('MemberRepository', function () { WelcomeEmailAutomationRun, newslettersService, offersAPI, + automationsApi, productRepository, stripeAPIService, tokenService, @@ -164,6 +166,10 @@ describe('MemberRepository', function () { }) }; + automationsApi = { + trigger: sinon.stub().resolves() + }; + productRepository = { get: sinon.stub().resolves({ get: sinon.stub().returns(), @@ -1704,150 +1710,164 @@ describe('MemberRepository', function () { }); }); - describe('create - automation run integration', function () { - it('creates automation run for free member signup (free welcome email)', async function () { - const repo = buildRepo({ - Member, - Outbox, - WelcomeEmailAutomationRun, - MemberStatusEvent, - MemberSubscribeEventModel: MemberSubscribeEvent, - newslettersService, - Automation, - OfferRedemption: mockOfferRedemption - }); - + describe('create - automation integration', function () { + it('triggers an automation event for free signup', async function () { + const repo = buildRepo(); await repo.create({email: 'test@example.com', name: 'Test Member'}, {}); - sinon.assert.calledOnce(WelcomeEmailAutomationRun.add); - const runCall = WelcomeEmailAutomationRun.add.firstCall.args[0]; - assert.equal(runCall.welcome_email_automation_id, 'automation_id_free'); - assert.equal(runCall.member_id, 'member_id_123'); - assert.equal(runCall.next_welcome_email_automated_email_id, 'automated_email_id_free'); - assert.ok(runCall.ready_at); - assert.equal(runCall.step_started_at, null); - assert.equal(runCall.step_attempts, 0); - assert.equal(runCall.exit_reason, null); + sinon.assert.calledOnceWithExactly(automationsApi.trigger, { + event: 'member_sign_up', + memberId: 'member_id_123', + memberEmail: 'test@example.com', + memberStatus: 'free' + }); }); - it('does not create automation run for disallowed sources', async function () { - const repo = buildRepo({ - Member, - Outbox, - WelcomeEmailAutomationRun, - MemberStatusEvent, - MemberSubscribeEventModel: MemberSubscribeEvent, - newslettersService, - Automation, - OfferRedemption: mockOfferRedemption - }); + describe('legacy automations', function () { + it('creates automation run for free member signup (free welcome email)', async function () { + const repo = buildRepo({ + Member, + Outbox, + WelcomeEmailAutomationRun, + MemberStatusEvent, + MemberSubscribeEventModel: MemberSubscribeEvent, + newslettersService, + Automation, + OfferRedemption: mockOfferRedemption + }); - const disallowedSources = [ - {name: 'import', context: {import: true}}, - {name: 'admin', context: {user: true}}, - {name: 'api', context: {api_key: true}} - ]; + await repo.create({email: 'test@example.com', name: 'Test Member'}, {}); + + sinon.assert.calledOnce(WelcomeEmailAutomationRun.add); + const runCall = WelcomeEmailAutomationRun.add.firstCall.args[0]; + assert.equal(runCall.welcome_email_automation_id, 'automation_id_free'); + assert.equal(runCall.member_id, 'member_id_123'); + assert.equal(runCall.next_welcome_email_automated_email_id, 'automated_email_id_free'); + assert.ok(runCall.ready_at); + assert.equal(runCall.step_started_at, null); + assert.equal(runCall.step_attempts, 0); + assert.equal(runCall.exit_reason, null); + }); + + it('does not create automation run for disallowed sources', async function () { + const repo = buildRepo({ + Member, + Outbox, + WelcomeEmailAutomationRun, + MemberStatusEvent, + MemberSubscribeEventModel: MemberSubscribeEvent, + newslettersService, + Automation, + OfferRedemption: mockOfferRedemption + }); - for (const source of disallowedSources) { - WelcomeEmailAutomationRun.add.resetHistory(); - await repo.create({email: 'test@example.com', name: 'Test Member'}, {context: source.context}); - sinon.assert.notCalled(WelcomeEmailAutomationRun.add); - } - }); + const disallowedSources = [ + {name: 'import', context: {import: true}}, + {name: 'admin', context: {user: true}}, + {name: 'api', context: {api_key: true}} + ]; - it('passes transaction to automation run creation', async function () { - const repo = buildRepo({ - Member, - Outbox, - WelcomeEmailAutomationRun, - MemberStatusEvent, - MemberSubscribeEventModel: MemberSubscribeEvent, - newslettersService, - Automation, - OfferRedemption: mockOfferRedemption + for (const source of disallowedSources) { + WelcomeEmailAutomationRun.add.resetHistory(); + await repo.create({email: 'test@example.com', name: 'Test Member'}, {context: source.context}); + sinon.assert.notCalled(WelcomeEmailAutomationRun.add); + } }); - await repo.create({email: 'test@example.com', name: 'Test Member'}, {}); + it('passes transaction to automation run creation', async function () { + const repo = buildRepo({ + Member, + Outbox, + WelcomeEmailAutomationRun, + MemberStatusEvent, + MemberSubscribeEventModel: MemberSubscribeEvent, + newslettersService, + Automation, + OfferRedemption: mockOfferRedemption + }); - const runOptions = WelcomeEmailAutomationRun.add.firstCall.args[1]; - assert.ok(runOptions.transacting); - }); + await repo.create({email: 'test@example.com', name: 'Test Member'}, {}); - it('does NOT create automation run when welcome email is inactive', async function () { - Automation.findOne.resolves({ - get: sinon.stub().callsFake((key) => { - const data = {status: 'inactive'}; - return data[key]; - }), - related: sinon.stub().callsFake((relation) => { - assert.equal(relation, 'welcomeEmailAutomatedEmail'); - return { - get: sinon.stub().callsFake((key) => { - const data = {lexical: '{"root":{}}'}; - return data[key]; - }) - }; - }) + const runOptions = WelcomeEmailAutomationRun.add.firstCall.args[1]; + assert.ok(runOptions.transacting); }); - const repo = buildRepo({ - Member, - Outbox, - WelcomeEmailAutomationRun, - MemberStatusEvent, - MemberSubscribeEventModel: MemberSubscribeEvent, - newslettersService, - Automation, - OfferRedemption: mockOfferRedemption - }); + it('does NOT create automation run when welcome email is inactive', async function () { + Automation.findOne.resolves({ + get: sinon.stub().callsFake((key) => { + const data = {status: 'inactive'}; + return data[key]; + }), + related: sinon.stub().callsFake((relation) => { + assert.equal(relation, 'welcomeEmailAutomatedEmail'); + return { + get: sinon.stub().callsFake((key) => { + const data = {lexical: '{"root":{}}'}; + return data[key]; + }) + }; + }) + }); - await repo.create({email: 'test@example.com', name: 'Test Member'}, {}); + const repo = buildRepo({ + Member, + Outbox, + WelcomeEmailAutomationRun, + MemberStatusEvent, + MemberSubscribeEventModel: MemberSubscribeEvent, + newslettersService, + Automation, + OfferRedemption: mockOfferRedemption + }); - sinon.assert.notCalled(WelcomeEmailAutomationRun.add); - }); + await repo.create({email: 'test@example.com', name: 'Test Member'}, {}); - it('does NOT create automation run when member is signing up for a paid subscription (stripeCustomer is present)', async function () { - const repo = buildRepo({ - Member, - Outbox, - WelcomeEmailAutomationRun, - MemberStatusEvent, - MemberSubscribeEventModel: MemberSubscribeEvent, - newslettersService, - Automation, - OfferRedemption: mockOfferRedemption + sinon.assert.notCalled(WelcomeEmailAutomationRun.add); }); - // Stub linkSubscription to avoid needing all the stripe-related mocks - sinon.stub(repo, 'linkSubscription').resolves(); - sinon.stub(repo, 'upsertCustomer').resolves(); + it('does NOT create automation run when member is signing up for a paid subscription (stripeCustomer is present)', async function () { + const repo = buildRepo({ + Member, + Outbox, + WelcomeEmailAutomationRun, + MemberStatusEvent, + MemberSubscribeEventModel: MemberSubscribeEvent, + newslettersService, + Automation, + OfferRedemption: mockOfferRedemption + }); - // Create a member with a stripeCustomer (i.e., signing up for paid subscription) - await repo.create({ - email: 'test@example.com', - name: 'Test Member', - stripeCustomer: { - id: 'cus_123', - name: 'Test Member', + // Stub linkSubscription to avoid needing all the stripe-related mocks + sinon.stub(repo, 'linkSubscription').resolves(); + sinon.stub(repo, 'upsertCustomer').resolves(); + + // Create a member with a stripeCustomer (i.e., signing up for paid subscription) + await repo.create({ email: 'test@example.com', - subscriptions: { - data: [{ - id: 'sub_123', - customer: 'cus_123', - status: 'active' - }] + name: 'Test Member', + stripeCustomer: { + id: 'cus_123', + name: 'Test Member', + email: 'test@example.com', + subscriptions: { + data: [{ + id: 'sub_123', + customer: 'cus_123', + status: 'active' + }] + } } - } - }, {}); + }, {}); - // The free welcome email should NOT be sent when stripeCustomer is present - sinon.assert.notCalled(WelcomeEmailAutomationRun.add); - sinon.assert.notCalled(Automation.findOne); - sinon.assert.notCalled(Member.transaction); + // The free welcome email should NOT be sent when stripeCustomer is present + sinon.assert.notCalled(WelcomeEmailAutomationRun.add); + sinon.assert.notCalled(Automation.findOne); + sinon.assert.notCalled(Member.transaction); + }); }); }); - describe('linkSubscription - automation run integration', function () { + describe('linkSubscription - automation integration', function () { let subscriptionData; beforeEach(function () { @@ -1998,7 +2018,7 @@ describe('MemberRepository', function () { sinon.restore(); }); - it('creates automation run when member status changes to paid', async function () { + it('triggers an automation event for paid signup', async function () { Member.edit.resolves({ attributes: {status: 'paid'}, _previousAttributes: {status: 'free'}, @@ -2008,20 +2028,7 @@ describe('MemberRepository', function () { }) }); - const repo = buildRepo({ - Member, - Outbox, - WelcomeEmailAutomationRun, - MemberPaidSubscriptionEvent, - StripeCustomerSubscription, - MemberProductEvent, - MemberStatusEvent, - stripeAPIService, - productRepository, - Automation, - OfferRedemption: mockOfferRedemption - }); - + const repo = buildRepo(); sinon.stub(repo, 'getSubscriptionByStripeID').resolves(null); await repo.linkSubscription({ @@ -2034,51 +2041,41 @@ describe('MemberRepository', function () { context: {} }); - 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'); - assert.ok(runCall.ready_at); - assert.equal(runCall.step_started_at, null); - assert.equal(runCall.step_attempts, 0); - assert.equal(runCall.exit_reason, null); - }); - - it('does NOT create automation run for disallowed sources', async function () { - Member.edit.resolves({ - attributes: {status: 'paid'}, - _previousAttributes: {status: 'free'}, - get: sinon.stub().callsFake((key) => { - const data = {status: 'paid'}; - return data[key]; - }) + sinon.assert.calledOnceWithExactly(automationsApi.trigger, { + event: 'member_sign_up', + memberId: 'member_id_123', + memberEmail: 'test@example.com', + memberStatus: 'paid' }); + }); - const repo = buildRepo({ - Member, - Outbox, - WelcomeEmailAutomationRun, - MemberPaidSubscriptionEvent, - StripeCustomerSubscription, - MemberProductEvent, - MemberStatusEvent, - stripeAPIService, - productRepository, - Automation, - OfferRedemption: mockOfferRedemption - }); + describe('legacy automations', function () { + it('creates automation run when member status changes to paid', async function () { + Member.edit.resolves({ + attributes: {status: 'paid'}, + _previousAttributes: {status: 'free'}, + get: sinon.stub().callsFake((key) => { + const data = {status: 'paid'}; + return data[key]; + }) + }); - sinon.stub(repo, 'getSubscriptionByStripeID').resolves(null); + const repo = buildRepo({ + Member, + Outbox, + WelcomeEmailAutomationRun, + MemberPaidSubscriptionEvent, + StripeCustomerSubscription, + MemberProductEvent, + MemberStatusEvent, + stripeAPIService, + productRepository, + Automation, + OfferRedemption: mockOfferRedemption + }); - const disallowedSources = [ - {name: 'import', context: {import: true}}, - {name: 'admin', context: {user: true}}, - {name: 'api', context: {api_key: true}} - ]; + sinon.stub(repo, 'getSubscriptionByStripeID').resolves(null); - for (const source of disallowedSources) { - WelcomeEmailAutomationRun.add.resetHistory(); await repo.linkSubscription({ id: 'member_id_123', subscription: subscriptionData @@ -2086,106 +2083,162 @@ describe('MemberRepository', function () { transacting: { executionPromise: Promise.resolve() }, - context: source.context + context: {} }); - sinon.assert.notCalled(WelcomeEmailAutomationRun.add); - } - }); - it('does NOT create automation run when paid welcome email is inactive', async function () { - Member.edit.resolves({ - attributes: {status: 'paid'}, - _previousAttributes: {status: 'free'}, - get: sinon.stub().callsFake((key) => { - const data = {status: 'paid'}; - return data[key]; - }) - }); + 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'); + assert.ok(runCall.ready_at); + assert.equal(runCall.step_started_at, null); + assert.equal(runCall.step_attempts, 0); + assert.equal(runCall.exit_reason, null); + }); + + it('does NOT create automation run for disallowed sources', async function () { + Member.edit.resolves({ + attributes: {status: 'paid'}, + _previousAttributes: {status: 'free'}, + get: sinon.stub().callsFake((key) => { + const data = {status: 'paid'}; + return data[key]; + }) + }); - Automation.findOne.resolves({ - id: 'automation_id_paid', - get: sinon.stub().callsFake((key) => { - const data = {status: 'inactive'}; - 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 = buildRepo({ + Member, + Outbox, + WelcomeEmailAutomationRun, + MemberPaidSubscriptionEvent, + StripeCustomerSubscription, + MemberProductEvent, + MemberStatusEvent, + stripeAPIService, + productRepository, + Automation, + OfferRedemption: mockOfferRedemption + }); - const repo = buildRepo({ - Member, - Outbox, - WelcomeEmailAutomationRun, - MemberPaidSubscriptionEvent, - StripeCustomerSubscription, - MemberProductEvent, - MemberStatusEvent, - stripeAPIService, - productRepository, - Automation, - OfferRedemption: mockOfferRedemption + sinon.stub(repo, 'getSubscriptionByStripeID').resolves(null); + + const disallowedSources = [ + {name: 'import', context: {import: true}}, + {name: 'admin', context: {user: true}}, + {name: 'api', context: {api_key: true}} + ]; + + for (const source of disallowedSources) { + WelcomeEmailAutomationRun.add.resetHistory(); + await repo.linkSubscription({ + id: 'member_id_123', + subscription: subscriptionData + }, { + transacting: { + executionPromise: Promise.resolve() + }, + context: source.context + }); + sinon.assert.notCalled(WelcomeEmailAutomationRun.add); + } }); - sinon.stub(repo, 'getSubscriptionByStripeID').resolves(null); + it('does NOT create automation run when paid welcome email is inactive', async function () { + Member.edit.resolves({ + attributes: {status: 'paid'}, + _previousAttributes: {status: 'free'}, + get: sinon.stub().callsFake((key) => { + const data = {status: 'paid'}; + return data[key]; + }) + }); - await repo.linkSubscription({ - id: 'member_id_123', - subscription: subscriptionData - }, { - transacting: { - executionPromise: Promise.resolve() - }, - context: {} - }); + Automation.findOne.resolves({ + id: 'automation_id_paid', + get: sinon.stub().callsFake((key) => { + const data = {status: 'inactive'}; + 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]; + }) + }; + }) + }); - sinon.assert.notCalled(WelcomeEmailAutomationRun.add); - }); + const repo = buildRepo({ + Member, + Outbox, + WelcomeEmailAutomationRun, + MemberPaidSubscriptionEvent, + StripeCustomerSubscription, + MemberProductEvent, + MemberStatusEvent, + stripeAPIService, + productRepository, + Automation, + OfferRedemption: mockOfferRedemption + }); - it('does NOT create automation run when previous status was "gift" (already received paid welcome at redemption)', async function () { - Member.edit.resolves({ - attributes: {status: 'paid'}, - _previousAttributes: {status: 'gift'}, - get: sinon.stub().callsFake((key) => { - const data = {status: 'paid'}; - return data[key]; - }) - }); + sinon.stub(repo, 'getSubscriptionByStripeID').resolves(null); - const repo = buildRepo({ - Member, - Outbox, - WelcomeEmailAutomationRun, - MemberPaidSubscriptionEvent, - StripeCustomerSubscription, - MemberProductEvent, - MemberStatusEvent, - stripeAPIService, - productRepository, - Automation, - OfferRedemption: mockOfferRedemption + await repo.linkSubscription({ + id: 'member_id_123', + subscription: subscriptionData + }, { + transacting: { + executionPromise: Promise.resolve() + }, + context: {} + }); + + sinon.assert.notCalled(WelcomeEmailAutomationRun.add); }); - sinon.stub(repo, 'getSubscriptionByStripeID').resolves(null); + it('does NOT create automation run when previous status was "gift" (already received paid welcome at redemption)', async function () { + Member.edit.resolves({ + attributes: {status: 'paid'}, + _previousAttributes: {status: 'gift'}, + get: sinon.stub().callsFake((key) => { + const data = {status: 'paid'}; + return data[key]; + }) + }); - await repo.linkSubscription({ - id: 'member_id_123', - subscription: subscriptionData - }, { - transacting: { - executionPromise: Promise.resolve() - }, - context: {} - }); + const repo = buildRepo({ + Member, + Outbox, + WelcomeEmailAutomationRun, + MemberPaidSubscriptionEvent, + StripeCustomerSubscription, + MemberProductEvent, + MemberStatusEvent, + stripeAPIService, + productRepository, + Automation, + OfferRedemption: mockOfferRedemption + }); - sinon.assert.notCalled(WelcomeEmailAutomationRun.add); + sinon.stub(repo, 'getSubscriptionByStripeID').resolves(null); + + await repo.linkSubscription({ + id: 'member_id_123', + subscription: subscriptionData + }, { + transacting: { + executionPromise: Promise.resolve() + }, + context: {} + }); + + sinon.assert.notCalled(WelcomeEmailAutomationRun.add); + }); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f8889520ab..acf4ff0875f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ catalogs: '@radix-ui/react-checkbox': specifier: 1.3.3 version: 1.3.3 + '@radix-ui/react-context-menu': + specifier: 2.2.16 + version: 2.2.16 '@radix-ui/react-form': specifier: 0.1.8 version: 0.1.8 @@ -129,6 +132,9 @@ catalogs: '@types/validator': specifier: 13.15.10 version: 13.15.10 + '@typescript-eslint/eslint-plugin': + specifier: 8.49.0 + version: 8.49.0 '@typescript-eslint/parser': specifier: 8.49.0 version: 8.49.0 @@ -1425,6 +1431,9 @@ importers: '@radix-ui/react-checkbox': specifier: 'catalog:' version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-context-menu': + specifier: 'catalog:' + version: 2.2.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dialog': specifier: 1.1.15 version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1549,6 +1558,9 @@ importers: '@types/react-world-flags': specifier: 1.6.0 version: 1.6.0 + '@typescript-eslint/eslint-plugin': + specifier: 'catalog:' + version: 8.49.0(@typescript-eslint/parser@8.49.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) '@typescript-eslint/parser': specifier: 'catalog:' version: 8.49.0(eslint@8.57.1)(typescript@5.9.3) @@ -6241,6 +6253,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-context-menu@2.2.16': + resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-context@1.1.2': resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} peerDependencies: @@ -25858,6 +25883,20 @@ snapshots: optionalDependencies: '@types/react': 18.3.29 + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + '@radix-ui/react-context@1.1.2(@types/react@18.3.29)(react@18.3.1)': dependencies: react: 18.3.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5c25ce92910..bd4bfa2bff7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -31,6 +31,7 @@ catalog: '@playwright/test': 1.60.0 '@radix-ui/react-avatar': 1.1.11 '@radix-ui/react-checkbox': 1.3.3 + '@radix-ui/react-context-menu': 2.2.16 '@radix-ui/react-form': 0.1.8 '@radix-ui/react-popover': 1.1.15 '@radix-ui/react-separator': 1.1.8 @@ -67,6 +68,7 @@ catalog: '@types/react': 18.3.29 '@types/react-dom': 18.3.7 '@types/validator': 13.15.10 + '@typescript-eslint/eslint-plugin': 8.49.0 '@typescript-eslint/parser': 8.49.0 '@vitejs/plugin-react': 4.7.0 '@vitest/coverage-v8': 4.1.7