From d5476cce11f7acfa26fed3e7987e532fa91a73f9 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 4 Aug 2026 16:12:45 +0545 Subject: [PATCH 1/4] feat(OUT-4015): add published status to app install schema Add an AppInstallStatus enum (draft/published) and expose the new `status` field on AppInstallsDataSchema, surfaced by the /installs endpoint via POR-21837. Co-Authored-By: Claude Opus 4.8 --- src/lib/assembly/types.ts | 8 ++++++++ 1 file changed, 8 insertions(+) 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) From 3ce073f9141dd5fda62d88795fb2fd937b90ecea Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 4 Aug 2026 16:12:48 +0545 Subject: [PATCH 2/4] feat(OUT-4015): gate Your Actions on published app installs Only surface installs with status = published in getActionableInstalls, the single choke point feeding both the client "Your Actions" card and the editor Actions toggle list. App-builder drafts no longer appear until published. Co-Authored-By: Claude Opus 4.8 --- .../lib/installed-apps.service.ts | 13 +-- tests/unit/installed-apps.service.test.ts | 83 +++++++++++++++++++ 2 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 tests/unit/installed-apps.service.test.ts diff --git a/src/features/installed-apps/lib/installed-apps.service.ts b/src/features/installed-apps/lib/installed-apps.service.ts index e7b727d0..177e17fb 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 && + // isDraft is the legacy flag; status is the app-builder publish lifecycle. Show only published (OUT-4015). + install.status === AppInstallStatus.PUBLISHED, ), ) diff --git a/tests/unit/installed-apps.service.test.ts b/tests/unit/installed-apps.service.test.ts new file mode 100644 index 00000000..db313541 --- /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('shows only published installs — anything not published is hidden (allowlist)', async () => { + const { service } = buildService([ + createInstall({ id: 'draft-1', appId: 'app-draft', status: AppInstallStatus.DRAFT }), + createInstall({ id: 'unknown-1', appId: 'app-unknown', status: undefined }), + createInstall({ id: 'null-1', appId: 'app-null', status: null }), + ]) + + const result = await service.getActionableInstalls() + + expect(result).toEqual([]) + }) + + 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([]) + }) +}) From c08e0197ba39308a7722531d44560b54c4c02f1d Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 4 Aug 2026 16:23:36 +0545 Subject: [PATCH 3/4] feat(OUT-4015): show non drafts installed apps --- src/features/installed-apps/lib/installed-apps.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/installed-apps/lib/installed-apps.service.ts b/src/features/installed-apps/lib/installed-apps.service.ts index 177e17fb..a78aa4bd 100644 --- a/src/features/installed-apps/lib/installed-apps.service.ts +++ b/src/features/installed-apps/lib/installed-apps.service.ts @@ -44,8 +44,8 @@ export default class InstalledAppsService extends BaseService { !install.disabled && !install.isDraft && !install.isInternalApp && - // isDraft is the legacy flag; status is the app-builder publish lifecycle. Show only published (OUT-4015). - install.status === AppInstallStatus.PUBLISHED, + // show non drafts apps in the action dropdown. + install.status !== AppInstallStatus.DRAFT, ), ) From 8cb8203b68a3592c6cf90c72997fe90ed29ccfe1 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 4 Aug 2026 16:30:16 +0545 Subject: [PATCH 4/4] test(OUT-4015): cover non-draft install filtering Assert getActionableInstalls hides only explicit drafts while missing/null status is treated as non-draft and shown, matching the status != draft gate. Co-Authored-By: Claude Opus 4.8 --- tests/unit/installed-apps.service.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/installed-apps.service.test.ts b/tests/unit/installed-apps.service.test.ts index db313541..dec534ae 100644 --- a/tests/unit/installed-apps.service.test.ts +++ b/tests/unit/installed-apps.service.test.ts @@ -59,16 +59,16 @@ describe('InstalledAppsService#getActionableInstalls', () => { expect(result[0]).toMatchObject({ installId: 'pub-1', appId: 'app-pub', actionLabel: REGISTERED_LABEL }) }) - it('shows only published installs — anything not published is hidden (allowlist)', async () => { + 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: 'unknown-1', appId: 'app-unknown', status: undefined }), + 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).toEqual([]) + 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 () => {