diff --git a/src/features/installed-apps/lib/installed-apps.service.ts b/src/features/installed-apps/lib/installed-apps.service.ts index e7b727d0..a78aa4bd 100644 --- a/src/features/installed-apps/lib/installed-apps.service.ts +++ b/src/features/installed-apps/lib/installed-apps.service.ts @@ -1,5 +1,5 @@ import AssemblyClient from '@assembly/assembly-client' -import { type AppInstallsData, isActionLabelRegistered } from '@assembly/types' +import { AppInstallStatus, 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' @@ -27,9 +27,10 @@ export default class InstalledAppsService extends BaseService { 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. + // Returns installs eligible for "Your Actions": active (not disabled/internal, not an app-builder + // draft) 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() @@ -42,7 +43,9 @@ export default class InstalledAppsService extends BaseService { install.appId !== env.TASKS_APP_ID && !install.disabled && !install.isDraft && - !install.isInternalApp, + !install.isInternalApp && + // show non drafts apps in the action dropdown. + install.status !== AppInstallStatus.DRAFT, ), ) diff --git a/src/lib/assembly/types.ts b/src/lib/assembly/types.ts index bedf4d06..5890e61b 100644 --- a/src/lib/assembly/types.ts +++ b/src/lib/assembly/types.ts @@ -41,6 +41,13 @@ export const WorkspaceResponseSchema = z.object({ }) export type WorkspaceResponse = z.infer +// Lifecycle status of an app install from the app builder. App-builder drafts are hidden from +// "Your Actions" until published (OUT-4015). +export enum AppInstallStatus { + DRAFT = 'draft', + PUBLISHED = 'published', +} + export const AppInstallsDataSchema = z.object({ appId: z.string().optional(), displayName: z.string().optional(), @@ -52,6 +59,7 @@ export const AppInstallsDataSchema = z.object({ disabled: z.boolean().nullish(), isDraft: z.boolean().nullish(), isInternalApp: z.boolean().nullish(), + status: z.enum(AppInstallStatus).nullish(), }) export type AppInstallsData = z.infer export const AppInstallsResponseSchema = z.array(AppInstallsDataSchema) diff --git a/tests/unit/installed-apps.service.test.ts b/tests/unit/installed-apps.service.test.ts new file mode 100644 index 00000000..dec534ae --- /dev/null +++ b/tests/unit/installed-apps.service.test.ts @@ -0,0 +1,83 @@ +import { AppInstallStatus, type AppInstallsData } from '@assembly/types' +import InstalledAppsService from '@installed-apps/lib/installed-apps.service' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createInternalUser } from '../factories' + +vi.mock('server-only', () => ({})) +vi.mock('@assembly/assembly-client', () => ({ default: vi.fn() })) +vi.mock('@/config/env', () => ({ default: { TASKS_APP_ID: 'tasks-app-id' } })) + +// A fully-registered action label — required for an install to become "actionable". +const REGISTERED_LABEL = { verb: 'sign', singularNoun: 'document', pluralNoun: 'documents' } + +const createInstall = (overrides: Partial = {}): AppInstallsData => ({ + id: 'install-1', + appId: 'app-1', + displayName: 'App One', + icon: 'folder', + disabled: false, + isDraft: false, + isInternalApp: false, + status: AppInstallStatus.PUBLISHED, + ...overrides, +}) + +// Builds the service against a stubbed AssemblyClient. `labels` maps installId → the action label +// its notification-settings fetch returns; installs not listed fall back to REGISTERED_LABEL. +const buildService = (installs: AppInstallsData[], labels: Record = {}) => { + const getInstalls = vi.fn().mockResolvedValue(installs) + const getInstallNotificationSettings = vi.fn(async (id: string) => ({ + actionLabel: id in labels ? labels[id] : REGISTERED_LABEL, + })) + const assembly = { getInstalls, getInstallNotificationSettings } + const service = new InstalledAppsService(createInternalUser(), assembly as never) + return { service, getInstalls, getInstallNotificationSettings } +} + +describe('InstalledAppsService#getActionableInstalls', () => { + beforeEach(() => vi.clearAllMocks()) + + it('hides app-builder drafts and never fetches their notification settings', async () => { + const { service, getInstallNotificationSettings } = buildService([ + createInstall({ id: 'draft-1', appId: 'app-draft', status: AppInstallStatus.DRAFT }), + createInstall({ id: 'pub-1', appId: 'app-pub', status: AppInstallStatus.PUBLISHED }), + ]) + + const result = await service.getActionableInstalls() + + expect(result.map((r) => r.installId)).toEqual(['pub-1']) + expect(getInstallNotificationSettings).toHaveBeenCalledTimes(1) + expect(getInstallNotificationSettings).toHaveBeenCalledWith('pub-1') + }) + + it('includes a published install carrying a registered action label', async () => { + const { service } = buildService([createInstall({ id: 'pub-1', appId: 'app-pub' })]) + + const result = await service.getActionableInstalls() + + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ installId: 'pub-1', appId: 'app-pub', actionLabel: REGISTERED_LABEL }) + }) + + it('hides only explicit drafts — missing/null status is treated as non-draft and shown (denylist)', async () => { + const { service } = buildService([ + createInstall({ id: 'draft-1', appId: 'app-draft', status: AppInstallStatus.DRAFT }), + createInstall({ id: 'undefined-1', appId: 'app-undefined', status: undefined }), + createInstall({ id: 'null-1', appId: 'app-null', status: null }), + ]) + + const result = await service.getActionableInstalls() + + expect(result.map((r) => r.installId)).toEqual(['undefined-1', 'null-1']) + }) + + it('excludes a published install that has not registered a complete action label', async () => { + const { service } = buildService([createInstall({ id: 'pub-1', appId: 'app-pub' })], { + 'pub-1': { verb: 'sign', singularNoun: '', pluralNoun: '' }, + }) + + const result = await service.getActionableInstalls() + + expect(result).toEqual([]) + }) +})