diff --git a/src/app/api/installed-apps/route.ts b/src/app/api/installed-apps/route.ts new file mode 100644 index 00000000..69002da5 --- /dev/null +++ b/src/app/api/installed-apps/route.ts @@ -0,0 +1,4 @@ +import { getActionableInstalls } from '@installed-apps/lib/installed-apps.controller' +import { withErrorHandler } from '@/lib/with-error-handler' + +export const GET = withErrorHandler(getActionableInstalls) diff --git a/src/app/routes.ts b/src/app/routes.ts index d6cb623b..9b9bb963 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -19,6 +19,7 @@ export const ROUTES = Object.freeze({ segment: '/api/segments/:segmentId', segmentConfig: '/api/segments/config', segmentStats: '/api/segments/stats', + installedApps: '/api/installed-apps', }, }) @@ -53,6 +54,7 @@ export const authorizedRoutes: Record = { ROUTES.api.segment, ROUTES.api.segmentConfig, ROUTES.api.segmentStats, + ROUTES.api.installedApps, ], clientUsers: [ ROUTES.api.workspace, @@ -69,5 +71,6 @@ export const authorizedRoutes: Record = { ROUTES.api.notificationCounts, ROUTES.api.bannerImages, ROUTES.api.image, + ROUTES.api.installedApps, ], } diff --git a/src/features/action-items/components/action-item.tsx b/src/features/action-items/components/action-item.tsx index 8ab35f9a..d0b274ce 100644 --- a/src/features/action-items/components/action-item.tsx +++ b/src/features/action-items/components/action-item.tsx @@ -1,12 +1,12 @@ import { Icon } from '@assembly-js/design-system' import { useAuthStore } from '@auth/providers/auth.provider' -import type { ActionDefinition } from '@editor/components/Sidebar/Actions/constant' import { useViewStore, ViewMode } from '@editor/stores/viewStore' +import { isDynamicAction, type RenderableAction } from '@installed-apps/lib/dynamic-action' import { HandleBarTemplate } from '@/features/handlebar-template/components/handle-bar-template' import { cn } from '@/utils/tailwind' interface ActionItemProps { - action: ActionDefinition + action: RenderableAction isLoading?: boolean mode: ViewMode className?: string @@ -31,7 +31,9 @@ export const ActionItem = ({ action, isLoading, mode, className, count }: Action const handleClick = () => { if (!clientId) return - if (action.key === 'tasks' && tasksAppId) { + if (isDynamicAction(action)) { + window.parent.postMessage({ type: 'history.push', id: action.installId, route: 'apps' }, '*') + } else if (action.key === 'tasks' && tasksAppId) { window.parent.postMessage({ type: 'history.push', id: tasksAppId, route: 'apps' }, '*') } else { window.parent.postMessage({ type: 'history.push', route: action.key }, '*') @@ -62,7 +64,23 @@ export const ActionItem = ({ action, isLoading, mode, className, count }: Action {action.verb} - + {isDynamicAction(action) ? ( + // Dynamic rows have no handlebar template. In the editor we show the same + // `{{N}}` placeholder chip as built-ins (the count is not yet resolved); in + // preview the resolved unread count is the whole value. + mode === ViewMode.EDITOR ? ( + + ) : ( + {count ?? 0} + ) + ) : ( + + )} {noun} diff --git a/src/features/action-items/components/actions-card.tsx b/src/features/action-items/components/actions-card.tsx index 6eb2cea2..400726b6 100644 --- a/src/features/action-items/components/actions-card.tsx +++ b/src/features/action-items/components/actions-card.tsx @@ -1,4 +1,5 @@ import { useViewStore, ViewMode } from '@editor/stores/viewStore' +import { isDynamicAction, type RenderableAction } from '@installed-apps/lib/dynamic-action' import { useNotificationCounts } from '@notification-counts/hooks/useNotificationCounts' import type { NotificationCountKey } from '@notification-counts/notification-counts.dto' import { useEnabledActions } from '@settings/hooks/useEnabledActions' @@ -17,12 +18,14 @@ export const ActionsCard = ({ readonly }: ActionCardProps) => { const isPreviewMode = readonly || viewMode === ViewMode.PREVIEW - // Client/preview only surfaces actions that actually have pending items. In the - // editor, counts are placeholders, so we keep every enabled action visible so the - // admin can still configure them. - const visibleActions = isPreviewMode - ? enabledActions.filter((action) => (counts?.[action.key as NotificationCountKey] ?? 0) > 0) - : enabledActions + // Dynamic app rows read their count from the per-app map; built-ins from the named fields. + const getCount = (action: RenderableAction): number | undefined => + isDynamicAction(action) ? counts?.apps[action.appId] : counts?.[action.key as NotificationCountKey] + + // Client/preview surfaces only rows with pending items; the editor keeps every enabled + // action visible (counts are placeholders) so the admin can see and configure them. + // This holds for both built-in and dynamic Studio-app rows. + const visibleActions = enabledActions.filter((action) => (isPreviewMode ? (getCount(action) ?? 0) > 0 : true)) const visibleCount = visibleActions.length @@ -52,12 +55,12 @@ export const ActionsCard = ({ readonly }: ActionCardProps) => { > {visibleActions.map((action) => ( ))} diff --git a/src/features/editor/components/Sidebar/Actions/ActionItem.tsx b/src/features/editor/components/Sidebar/Actions/ActionItem.tsx index b4f2681c..e1612b79 100644 --- a/src/features/editor/components/Sidebar/Actions/ActionItem.tsx +++ b/src/features/editor/components/Sidebar/Actions/ActionItem.tsx @@ -1,11 +1,10 @@ -import { Icon, Toggle } from '@assembly-js/design-system' +import { Icon, type IconType, Toggle } from '@assembly-js/design-system' import { useSortable } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' -import type { ActionItemIcon } from '@/features/editor/components/Sidebar/Actions/type' type ActionItemProps = { id: string - icon: ActionItemIcon + icon: IconType label: string checked: boolean onChange: () => void diff --git a/src/features/editor/components/Sidebar/Actions/constant.ts b/src/features/editor/components/Sidebar/Actions/constant.ts index 1f2e4a3e..60995d53 100644 --- a/src/features/editor/components/Sidebar/Actions/constant.ts +++ b/src/features/editor/components/Sidebar/Actions/constant.ts @@ -1,4 +1,5 @@ -import type { ActionItemIcon, ActionItemLabelType } from '@editor/components/Sidebar/Actions/type' +import type { IconType } from '@assembly-js/design-system' +import type { ActionItemLabelType } from '@editor/components/Sidebar/Actions/type' import type { SettingsUpdateDto } from '@settings/lib/settings-actions.dto' import type { TemplateString } from '@/features/handlebar-template/types/hande-bar-template.type' @@ -16,7 +17,7 @@ export type ActionDefinition = { label: string /** Imperative verb that prefixes the action sentence, e.g. "Pay 2 invoices". */ verb: string - icon: ActionItemIcon + icon: IconType template: TemplateString key: ActionKey singularLabel?: string diff --git a/src/features/editor/components/Sidebar/Actions/type.ts b/src/features/editor/components/Sidebar/Actions/type.ts index f2e14e02..1e7836ff 100644 --- a/src/features/editor/components/Sidebar/Actions/type.ts +++ b/src/features/editor/components/Sidebar/Actions/type.ts @@ -1,6 +1,3 @@ -import type { IconType } from '@assembly-js/design-system' import type { ActionItemLabel } from '@/features/editor/components/Sidebar/Actions/constant' -export type ActionItemIcon = Extract - export type ActionItemLabelType = (typeof ActionItemLabel)[keyof typeof ActionItemLabel] diff --git a/src/features/editor/components/Sidebar/Actions/useActions.tsx b/src/features/editor/components/Sidebar/Actions/useActions.tsx index 21e48fc0..fea78b0e 100644 --- a/src/features/editor/components/Sidebar/Actions/useActions.tsx +++ b/src/features/editor/components/Sidebar/Actions/useActions.tsx @@ -1,29 +1,63 @@ +import type { IconType } from '@assembly-js/design-system' import { ActionDefinitions } from '@editor/components/Sidebar/Actions/constant' +import { useInstalledApps } from '@installed-apps/hooks/useInstalledApps' +import { isIconType } from '@installed-apps/lib/icon-names' import { useSettingsStore } from '@settings/providers/settings.provider' +// Stands in when an install's `icon` is absent or not a design-system IconType. +const FALLBACK_ICON: IconType = 'CustomApps' + +type ActionToggleItem = { + key: string + label: string + icon: IconType + checked: boolean + onChange: () => void +} + export const useActions = () => { const actions = useSettingsStore((s) => s.actions) const setActions = useSettingsStore((s) => s.setActions) + const { installedApps } = useInstalledApps() const order = actions?.order ?? [] + const hiddenAppIds = actions?.hiddenAppIds ?? [] + + // Built-in actions are keyed by their ActionKey and toggled via named boolean fields. + const builtInItems: ActionToggleItem[] = Object.values(ActionDefinitions).map((item) => ({ + key: item.key, + label: item.label, + icon: item.icon, + checked: actions?.[item.key] ?? false, + onChange: () => { + setActions({ [item.key]: !actions[item.key] }) + }, + })) + + // Dynamic Studio-app actions are keyed by appId and toggled via the hiddenAppIds + // deny-list (checked = not hidden = shown; default on). Tasks is filtered out + // server-side so it never collides with the built-in Tasks row. + const appItems: ActionToggleItem[] = installedApps.map((install) => ({ + key: install.appId, + label: install.displayName, + icon: isIconType(install.icon) ? install.icon : FALLBACK_ICON, + checked: !hiddenAppIds.includes(install.appId), + onChange: () => { + const nextHidden = hiddenAppIds.includes(install.appId) + ? hiddenAppIds.filter((id) => id !== install.appId) + : [...hiddenAppIds, install.appId] + setActions({ hiddenAppIds: nextHidden }) + }, + })) - const actionItems = Object.values(ActionDefinitions) - .sort((a, b) => { - const aIndex = order.indexOf(a.key) - const bIndex = order.indexOf(b.key) - return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex) - }) - .map((item) => { - return { - key: item.key, - label: item.label, - icon: item.icon, - checked: actions?.[item.key] ?? false, - onChange: () => { - setActions({ [item.key]: !actions[item.key] }) - }, - } - }) + // Built-ins and Studio apps share a single drag-orderable list. `order` holds a mix + // of ActionKeys and appIds; items absent from it (e.g. a freshly installed app) fall + // to the end until the user positions them. + const actionItems = [...builtInItems, ...appItems].sort((a, b) => { + const aIndex = order.indexOf(a.key) + const bIndex = order.indexOf(b.key) + return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex) + }) const onReorder = (newOrder: string[]) => { setActions({ order: newOrder }) diff --git a/src/features/installed-apps/hooks/useInstalledApps.ts b/src/features/installed-apps/hooks/useInstalledApps.ts new file mode 100644 index 00000000..f668d980 --- /dev/null +++ b/src/features/installed-apps/hooks/useInstalledApps.ts @@ -0,0 +1,27 @@ +'use client' + +import { useAuthStore } from '@auth/providers/auth.provider' +import { type ActionableInstallsDto, ActionableInstallsDtoSchema } from '@installed-apps/installed-apps.dto' +import { useQuery } from '@tanstack/react-query' +import { ROUTES } from '@/app/routes' +import { api } from '@/lib/core/axios.instance' + +export const INSTALLED_APPS_QUERY_KEY = 'installed-apps' + +export const useInstalledApps = () => { + const workspaceId = useAuthStore((s) => s.workspaceId) + + const { data, isLoading } = useQuery({ + queryKey: [INSTALLED_APPS_QUERY_KEY, workspaceId], + queryFn: async (): Promise => { + const res = await api.get<{ data: ActionableInstallsDto }>(ROUTES.api.installedApps) + return ActionableInstallsDtoSchema.parse(res.data.data) + }, + enabled: Boolean(workspaceId), + }) + + return { + installedApps: data ?? [], + isLoading, + } +} diff --git a/src/features/installed-apps/installed-apps.dto.ts b/src/features/installed-apps/installed-apps.dto.ts new file mode 100644 index 00000000..89275522 --- /dev/null +++ b/src/features/installed-apps/installed-apps.dto.ts @@ -0,0 +1,15 @@ +import { RegisteredActionLabelSchema } from '@assembly/types' +import z from 'zod' + +// A Studio app install eligible for "Your Actions": active and with a complete registered action label. +export const ActionableInstallDtoSchema = z.object({ + installId: z.string(), + appId: z.string(), + displayName: z.string(), + icon: z.string().nullable(), + actionLabel: RegisteredActionLabelSchema, +}) +export type ActionableInstallDto = z.infer + +export const ActionableInstallsDtoSchema = z.array(ActionableInstallDtoSchema) +export type ActionableInstallsDto = z.infer diff --git a/src/features/installed-apps/lib/dynamic-action.ts b/src/features/installed-apps/lib/dynamic-action.ts new file mode 100644 index 00000000..76e11a80 --- /dev/null +++ b/src/features/installed-apps/lib/dynamic-action.ts @@ -0,0 +1,35 @@ +import type { IconType } from '@assembly-js/design-system' +import type { ActionDefinition } from '@editor/components/Sidebar/Actions/constant' +import type { ActionableInstallDto } from '@installed-apps/installed-apps.dto' +import { isIconType } from '@installed-apps/lib/icon-names' + +// Stands in when an install's `icon` is absent or not a design-system IconType. +const FALLBACK_ICON: IconType = 'CustomApps' + +// A "Your Actions" row synthesized at runtime from an actionable Studio app install. +// Mirrors ActionDefinition's presentational fields so ActionItem renders it identically, +// but is identified by its install/app rather than a built-in ActionKey. +export type DynamicActionDefinition = { + verb: string + /** Plural noun, e.g. "documents". */ + label: string + /** Singular noun, e.g. "document". */ + singularLabel: string + icon: IconType + installId: string + appId: string +} + +// The union ActionItem / ActionsCard render: built-in rows and dynamic Studio-app rows. +export type RenderableAction = ActionDefinition | DynamicActionDefinition + +export const isDynamicAction = (action: RenderableAction): action is DynamicActionDefinition => 'installId' in action + +export const toDynamicActionDefinition = (install: ActionableInstallDto): DynamicActionDefinition => ({ + verb: install.actionLabel.verb, + label: install.actionLabel.pluralNoun, + singularLabel: install.actionLabel.singularNoun, + icon: isIconType(install.icon) ? install.icon : FALLBACK_ICON, + installId: install.installId, + appId: install.appId, +}) diff --git a/src/features/installed-apps/lib/icon-names.ts b/src/features/installed-apps/lib/icon-names.ts new file mode 100644 index 00000000..163ca60b --- /dev/null +++ b/src/features/installed-apps/lib/icon-names.ts @@ -0,0 +1,403 @@ +import type { IconType } from '@assembly-js/design-system' + +// Runtime allow-list of valid design-system `IconType` names, generated from +// @assembly-js/design-system's icon exports. The package does not expose the icon +// registry at runtime (its `exports` map blocks the subpath), so we snapshot the +// names here to validate arbitrary install `icon` strings before rendering. +// Regenerate when the design-system icon set changes. +const ICON_NAMES = new Set([ + 'Accessibility', + 'AcuityScheduling', + 'AI', + 'Airtable', + 'AlarmClock', + 'Aperture', + 'API', + 'AppleWhole', + 'AppSetup', + 'Archive', + 'ArrowDownSolid', + 'ArrowLeft', + 'ArrowNE', + 'ArrowRight', + 'ArrowTrendDown', + 'ArrowTrendUp', + 'ArrowUpSolid', + 'AssemblyLogo', + 'Asterisk', + 'At', + 'Attachment', + 'AuditLog', + 'Authentication', + 'Automation', + 'Autoresponder', + 'AwardSimple', + 'BagShoppingMinus', + 'BagShoppingPlus', + 'Balloon', + 'Bandage', + 'Bank', + 'BarcodeRead', + 'Baseball', + 'Basketball', + 'BasketShoppingSimple', + 'BedFront', + 'BeerMug', + 'Billing', + 'Bold', + 'BoltSlash', + 'Book', + 'Bookmark', + 'BowlingBall', + 'BracketsCurly', + 'Briefcase', + 'BriefcaseMedical', + 'Brightness', + 'Bug', + 'Building', + 'Buildings', + 'Bullhorn', + 'BullseyeArrow', + 'BurgerFries', + 'Bus', + 'CalCom', + 'Calculator', + 'Calendar', + 'CalendarDays', + 'Calendly', + 'Callout', + 'Camera', + 'CameraRotate', + 'Cancel', + 'Canva', + 'Car', + 'CaretDown', + 'CaretRight', + 'CaretUp', + 'CartShopping', + 'Chart', + 'ChartSimpleHorizontal', + 'Check', + 'Checklist', + 'ChevronDown', + 'ChevronLeft', + 'ChevronRight', + 'ChevronUp', + 'ClickUp', + 'Close', + 'Cloud', + 'CloudBolt', + 'CloudMoon', + 'CloudSun', + 'Code', + 'CodeBranch', + 'CodeCommit', + 'CodeCompare', + 'CodeFork', + 'CodeMerge', + 'CodePullRequest', + 'Coins', + 'Comment', + 'CommentDots', + 'Compass', + 'Compose', + 'Contract', + 'Copy', + 'CSV', + 'CustomApps', + 'Customization', + 'Dash', + 'Databox', + 'Desktop', + 'Dialpad', + 'DiceFive', + 'Disconnect', + 'Doc', + 'Dollar', + 'Dot', + 'Download', + 'DragDrop', + 'Dropbox', + 'Droplet', + 'Dumbbell', + 'Duplicate', + 'Ear', + 'EarthAmericas', + 'Edit', + 'EditSolid', + 'Egg', + 'Ellipsis', + 'Email', + 'EmailRead', + 'EmailUnread', + 'EmbedsLinks', + 'Excel', + 'Export', + 'Exporter', + 'Eye', + 'EyeDropper', + 'EyeHidden', + 'Facebook', + 'FaceFrownSlight', + 'Failed', + 'FailedSolid', + 'Figma', + 'File', + 'FileLines', + 'Files', + 'Fill', + 'Film', + 'Filter', + 'Fingerprint', + 'Fire', + 'FireSmoke', + 'FishFins', + 'FitToWidth', + 'Flag', + 'Flashlight', + 'Flask', + 'FloppyDisk', + 'Flower', + 'FolderLocked', + 'FolderMove', + 'FolderOpen', + 'FontCase', + 'Football', + 'ForkKnife', + 'Form', + 'GamepadModern', + 'Gauge', + 'GIF', + 'Gift', + 'Glass', + 'Glasses', + 'GolfFlagHole', + 'GoogleCalendar', + 'GoogleDocs', + 'GoogleDrive', + 'GoogleSheets', + 'GoogleSlides', + 'GraduationCap', + 'GraphBarSolid', + 'GridFourSquares', + 'H1', + 'H2', + 'H3', + 'Hammer', + 'Hand', + 'Headphones', + 'Heart', + 'HeartHalfStroke', + 'HeartPulse', + 'Helpdesk', + 'Home', + 'HourglassHalf', + 'HubspotMeetings', + 'IceCream', + 'IdBadge', + 'Image', + 'ImageMissing', + 'ImageNotFilled', + 'Images', + 'ImageStack', + 'Inbox', + 'Inboxes', + 'InboxFull', + 'Infinity', + 'Info', + 'InfoSolid', + 'InProgress', + 'Insert', + 'Instagram', + 'Invite', + 'Invoice', + 'InvoicePaid', + 'Italicize', + 'Jotform', + 'JPG', + 'Key', + 'KeyboardBrightness', + 'Language', + 'Laptop', + 'LayerGroup', + 'Lead', + 'Leaf', + 'LifeRing', + 'Lightbulb', + 'Link', + 'LinkedIn', + 'List', + 'Location', + 'LocationArrow', + 'LocationCrosshairs', + 'LockFilled', + 'LogOut', + 'Loom', + 'Magnet', + 'Make', + 'Map', + 'Marketing', + 'Mars', + 'MarsAndVenus', + 'MartiniGlassEmpty', + 'MassFileShare', + 'Medal', + 'Mention', + 'Menu', + 'Message', + 'MessageDots', + 'Messages', + 'Microchip', + 'Minus', + 'Miro', + 'Mobile', + 'MobileNumber', + 'Monday', + 'MoneyBills', + 'Moon', + 'MoreVertical', + 'MOV', + 'Movie', + 'MP3', + 'MP4', + 'Mug', + 'Music', + 'MusicNote', + 'New', + 'Newspaper', + 'Note', + 'Notification', + 'Notion', + 'Number', + 'NumberedList', + 'OneDrive', + 'PaintbrushFine', + 'Palette', + 'Pause', + 'Paw', + 'PDF', + 'PersonBiking', + 'PersonDress', + 'PersonSimple', + 'PersonWalking', + 'Pin', + 'PizzaSlice', + 'Plane', + 'PlanetRinged', + 'PlansPayments', + 'Play', + 'Plus', + 'PNG', + 'PowerBi', + 'PresentationScreen', + 'Print', + 'Profile', + 'Puzzle', + 'Qrcode', + 'Question', + 'QuestionMark', + 'QuickBook', + 'RecordVinyl', + 'RectangleWide', + 'Repeat', + 'Reply', + 'Reposition', + 'ResetZoom', + 'Reverse', + 'Rocket', + 'Scale', + 'Scissors', + 'ScrewdriverWrench', + 'Search', + 'Send', + 'SendFilled', + 'Settings', + 'Share', + 'ShareNodes', + 'Shield', + 'ShieldCheck', + 'ShieldHalf', + 'Ship', + 'Shirt', + 'ShoePrints', + 'ShoppingBag', + 'Sidebar', + 'SidebarFilled', + 'SignalBars', + 'SignalStream', + 'SignPost', + 'Slack', + 'Smile', + 'Snowflake', + 'Soccer', + 'SolarSystem', + 'Spinner', + 'SquareOutline', + 'SquareQuestion', + 'SquareSolid', + 'Star', + 'StarHalf', + 'Stopwatch', + 'Store', + 'Strikethrough', + 'Subscription', + 'Subtask', + 'Success', + 'SuccessSolid', + 'SVG', + 'Table', + 'Tag', + 'Tags', + 'Tasks', + 'Teams', + 'Telescope', + 'TemperatureThreeQuarters', + 'Templates', + 'TennisBall', + 'Text', + 'ThumbsDown', + 'ThumbsUp', + 'Ticket', + 'Time', + 'Timer', + 'ToDo', + 'ToggleOn', + 'TokenInspector', + 'Train', + 'Transgender', + 'Trash', + 'Trello', + 'Triangle', + 'Trophy', + 'Tv', + 'Typeform', + 'Umbrella', + 'Unarchive', + 'Underline', + 'Unlock', + 'UnorderedList', + 'UnPin', + 'Upload', + 'Venus', + 'Video', + 'Voicemail', + 'Wallet', + 'WandMagicSparkles', + 'Warning', + 'WarningSolid', + 'WatchApple', + 'WavePulse', + 'Web', + 'WeightScale', + 'WindowMaximize', + 'Wrench', + 'X', + 'Xero', + 'YouTube', + 'Zapier', + 'ZIP', + 'Zoom', +]) + +export const isIconType = (value: string | null | undefined): value is IconType => + value != null && ICON_NAMES.has(value) diff --git a/src/features/installed-apps/lib/installed-apps.controller.ts b/src/features/installed-apps/lib/installed-apps.controller.ts new file mode 100644 index 00000000..52cbd748 --- /dev/null +++ b/src/features/installed-apps/lib/installed-apps.controller.ts @@ -0,0 +1,13 @@ +import { authenticateHeaders } from '@auth/lib/authenticate' +import InstalledAppsService from '@installed-apps/lib/installed-apps.service' +import { type NextRequest, NextResponse } from 'next/server' +import type { APIResponse } from '@/app/types' + +export const getActionableInstalls = async (req: NextRequest): Promise> => { + const user = authenticateHeaders(req.headers) + + const installedAppsService = InstalledAppsService.new(user) + const actionableInstalls = await installedAppsService.getActionableInstalls() + + return NextResponse.json({ data: actionableInstalls }) +} diff --git a/src/features/installed-apps/lib/installed-apps.service.ts b/src/features/installed-apps/lib/installed-apps.service.ts new file mode 100644 index 00000000..e7b727d0 --- /dev/null +++ b/src/features/installed-apps/lib/installed-apps.service.ts @@ -0,0 +1,78 @@ +import AssemblyClient from '@assembly/assembly-client' +import { type AppInstallsData, isActionLabelRegistered } from '@assembly/types' +import type { User } from '@auth/lib/user.entity' +import type { ActionableInstallDto } from '@installed-apps/installed-apps.dto' +import env from '@/config/env' +import BaseService from '@/lib/core/base.service' +import logger from '@/lib/logger' +import { mapWithConcurrency } from '@/utils/array' + +type ActiveInstall = AppInstallsData & { id: string; appId: string } + +// Cap on simultaneous notification-settings fetches. A single call is fast, but firing +// one per install at once opens a burst of TLS connections that can trip undici's connect +// timeout; a small pool keeps discovery well within the platform's per-connection budget. +const NOTIFICATION_SETTINGS_FETCH_CONCURRENCY = 5 + +export default class InstalledAppsService extends BaseService { + constructor( + readonly user: User, + readonly assembly: AssemblyClient, + ) { + super(user, assembly) + } + + static new(user: User) { + const assembly = new AssemblyClient(user.token) + return new InstalledAppsService(user, assembly) + } + + // Returns installs eligible for "Your Actions": active (not disabled/draft/internal) and carrying a + // complete registered action label. Discovery is a two-step fetch — list installs, then fan out to + // each install's notification settings — because the list endpoint does not inline the action label. + async getActionableInstalls(): Promise { + const installs = await this.assembly.getInstalls() + + const activeInstalls = installs.filter((install): install is ActiveInstall => + Boolean( + install.id && + install.appId && + // Tasks already renders as a built-in "Your Actions" row; exclude its install + // so we don't surface a duplicate dynamic row for the same app. + install.appId !== env.TASKS_APP_ID && + !install.disabled && + !install.isDraft && + !install.isInternalApp, + ), + ) + + const results = await mapWithConcurrency( + activeInstalls, + NOTIFICATION_SETTINGS_FETCH_CONCURRENCY, + async (install): Promise => { + try { + const { actionLabel } = await this.assembly.getInstallNotificationSettings(install.id) + // Skip installs that have not registered a complete action label. + if (!isActionLabelRegistered(actionLabel)) return null + + return { + installId: install.id, + appId: install.appId, + displayName: install.displayName ?? '', + icon: install.icon ?? null, + actionLabel, + } + } catch (error) { + // One failing sub-resource fetch must not fail the whole endpoint — skip this install. + logger.error( + `InstalledAppsService#getActionableInstalls | notification-settings fetch failed for install ${install.id}`, + error, + ) + return null + } + }, + ) + + return results.filter((result): result is ActionableInstallDto => result !== null) + } +} diff --git a/src/features/settings/hooks/useEnabledActions.ts b/src/features/settings/hooks/useEnabledActions.ts index 6805b599..b4e214cf 100644 --- a/src/features/settings/hooks/useEnabledActions.ts +++ b/src/features/settings/hooks/useEnabledActions.ts @@ -1,21 +1,37 @@ import { ActionDefinitions } from '@editor/components/Sidebar/Actions/constant' +import { useInstalledApps } from '@installed-apps/hooks/useInstalledApps' +import { isDynamicAction, type RenderableAction, toDynamicActionDefinition } from '@installed-apps/lib/dynamic-action' import { useSettingsStore } from '@settings/providers/settings.provider' import { useMemo } from 'react' +// Built-in rows are keyed by ActionKey, dynamic rows by appId — both live in the same +// `order` array, so ordering uses whichever identifies the row. +const orderKeyOf = (action: RenderableAction): string => (isDynamicAction(action) ? action.appId : action.key) + export function useEnabledActions() { const actions = useSettingsStore((s) => s.actions) + const { installedApps } = useInstalledApps() - const enabledActions = useMemo(() => { + const enabledActions = useMemo(() => { const order = actions.order ?? [] // TODO:- remove type casting once files has been added in settings table - return Object.values(ActionDefinitions) - .filter((definition) => !!actions[definition.key as unknown as keyof typeof actions]) - .sort((a, b) => { - const aIndex = order.indexOf(a.key) - const bIndex = order.indexOf(b.key) - return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex) - }) - }, [actions]) + const builtInActions = Object.values(ActionDefinitions).filter( + (definition) => !!actions[definition.key as unknown as keyof typeof actions], + ) + + // Deny-list by appId; absence means shown. Stale entries are inert. + const hiddenAppIds = actions.hiddenAppIds ?? [] + const dynamicActions = installedApps + .filter((install) => !hiddenAppIds.includes(install.appId)) + .map(toDynamicActionDefinition) + + // Built-ins and dynamic Studio-app rows share one drag-orderable sequence. + return [...builtInActions, ...dynamicActions].sort((a, b) => { + const aIndex = order.indexOf(orderKeyOf(a)) + const bIndex = order.indexOf(orderKeyOf(b)) + return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex) + }) + }, [actions, installedApps]) return { enabledActions } } diff --git a/src/utils/array.ts b/src/utils/array.ts index d3a53f18..d44ea8d7 100644 --- a/src/utils/array.ts +++ b/src/utils/array.ts @@ -3,6 +3,33 @@ export const clampIndex = (index: number, length: number) => { return ((index % length) + length) % length } +/** + * Maps `items` through the async `mapper`, running at most `limit` calls at once. + * Results preserve input order. Use this instead of `Promise.all(items.map(...))` + * when the mapper opens network connections — a large simultaneous burst can + * exhaust connection pools / trip connect timeouts (e.g. undici's 10s default). + */ +export const mapWithConcurrency = async ( + items: readonly T[], + limit: number, + mapper: (item: T, index: number) => Promise, +): Promise => { + const results = new Array(items.length) + let cursor = 0 + + const worker = async () => { + while (cursor < items.length) { + const index = cursor++ + results[index] = await mapper(items[index], index) + } + } + + const workers = Array.from({ length: Math.min(limit, items.length) }, worker) + await Promise.all(workers) + + return results +} + export const getArraySymmetricDifference = (a: T[], b: T[]): T[] => { const setA = new Set(a) const setB = new Set(b) diff --git a/tsconfig.json b/tsconfig.json index cb4e89af..72008bed 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,6 +31,7 @@ "@settings/*": ["./src/features/settings/*"], "@users/*": ["./src/features/users/*"], "@notification-counts/*": ["./src/features/notification-counts/*"], + "@installed-apps/*": ["./src/features/installed-apps/*"], "@/*": ["./src/*"] } },