Skip to content

Commit 3ce073f

Browse files
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 <noreply@anthropic.com>
1 parent d5476cc commit 3ce073f

2 files changed

Lines changed: 91 additions & 5 deletions

File tree

‎src/features/installed-apps/lib/installed-apps.service.ts‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import AssemblyClient from '@assembly/assembly-client'
2-
import { type AppInstallsData, isActionLabelRegistered } from '@assembly/types'
2+
import { AppInstallStatus, type AppInstallsData, isActionLabelRegistered } from '@assembly/types'
33
import type { User } from '@auth/lib/user.entity'
44
import type { ActionableInstallDto } from '@installed-apps/installed-apps.dto'
55
import env from '@/config/env'
@@ -27,9 +27,10 @@ export default class InstalledAppsService extends BaseService {
2727
return new InstalledAppsService(user, assembly)
2828
}
2929

30-
// Returns installs eligible for "Your Actions": active (not disabled/draft/internal) and carrying a
31-
// complete registered action label. Discovery is a two-step fetch — list installs, then fan out to
32-
// each install's notification settings — because the list endpoint does not inline the action label.
30+
// Returns installs eligible for "Your Actions": active (not disabled/internal, not an app-builder
31+
// draft) and carrying a complete registered action label. Discovery is a two-step fetch — list
32+
// installs, then fan out to each install's notification settings — because the list endpoint does
33+
// not inline the action label.
3334
async getActionableInstalls(): Promise<ActionableInstallDto[]> {
3435
const installs = await this.assembly.getInstalls()
3536

@@ -42,7 +43,9 @@ export default class InstalledAppsService extends BaseService {
4243
install.appId !== env.TASKS_APP_ID &&
4344
!install.disabled &&
4445
!install.isDraft &&
45-
!install.isInternalApp,
46+
!install.isInternalApp &&
47+
// isDraft is the legacy flag; status is the app-builder publish lifecycle. Show only published (OUT-4015).
48+
install.status === AppInstallStatus.PUBLISHED,
4649
),
4750
)
4851

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { AppInstallStatus, type AppInstallsData } from '@assembly/types'
2+
import InstalledAppsService from '@installed-apps/lib/installed-apps.service'
3+
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
import { createInternalUser } from '../factories'
5+
6+
vi.mock('server-only', () => ({}))
7+
vi.mock('@assembly/assembly-client', () => ({ default: vi.fn() }))
8+
vi.mock('@/config/env', () => ({ default: { TASKS_APP_ID: 'tasks-app-id' } }))
9+
10+
// A fully-registered action label — required for an install to become "actionable".
11+
const REGISTERED_LABEL = { verb: 'sign', singularNoun: 'document', pluralNoun: 'documents' }
12+
13+
const createInstall = (overrides: Partial<AppInstallsData> = {}): AppInstallsData => ({
14+
id: 'install-1',
15+
appId: 'app-1',
16+
displayName: 'App One',
17+
icon: 'folder',
18+
disabled: false,
19+
isDraft: false,
20+
isInternalApp: false,
21+
status: AppInstallStatus.PUBLISHED,
22+
...overrides,
23+
})
24+
25+
// Builds the service against a stubbed AssemblyClient. `labels` maps installId → the action label
26+
// its notification-settings fetch returns; installs not listed fall back to REGISTERED_LABEL.
27+
const buildService = (installs: AppInstallsData[], labels: Record<string, unknown> = {}) => {
28+
const getInstalls = vi.fn().mockResolvedValue(installs)
29+
const getInstallNotificationSettings = vi.fn(async (id: string) => ({
30+
actionLabel: id in labels ? labels[id] : REGISTERED_LABEL,
31+
}))
32+
const assembly = { getInstalls, getInstallNotificationSettings }
33+
const service = new InstalledAppsService(createInternalUser(), assembly as never)
34+
return { service, getInstalls, getInstallNotificationSettings }
35+
}
36+
37+
describe('InstalledAppsService#getActionableInstalls', () => {
38+
beforeEach(() => vi.clearAllMocks())
39+
40+
it('hides app-builder drafts and never fetches their notification settings', async () => {
41+
const { service, getInstallNotificationSettings } = buildService([
42+
createInstall({ id: 'draft-1', appId: 'app-draft', status: AppInstallStatus.DRAFT }),
43+
createInstall({ id: 'pub-1', appId: 'app-pub', status: AppInstallStatus.PUBLISHED }),
44+
])
45+
46+
const result = await service.getActionableInstalls()
47+
48+
expect(result.map((r) => r.installId)).toEqual(['pub-1'])
49+
expect(getInstallNotificationSettings).toHaveBeenCalledTimes(1)
50+
expect(getInstallNotificationSettings).toHaveBeenCalledWith('pub-1')
51+
})
52+
53+
it('includes a published install carrying a registered action label', async () => {
54+
const { service } = buildService([createInstall({ id: 'pub-1', appId: 'app-pub' })])
55+
56+
const result = await service.getActionableInstalls()
57+
58+
expect(result).toHaveLength(1)
59+
expect(result[0]).toMatchObject({ installId: 'pub-1', appId: 'app-pub', actionLabel: REGISTERED_LABEL })
60+
})
61+
62+
it('shows only published installs — anything not published is hidden (allowlist)', async () => {
63+
const { service } = buildService([
64+
createInstall({ id: 'draft-1', appId: 'app-draft', status: AppInstallStatus.DRAFT }),
65+
createInstall({ id: 'unknown-1', appId: 'app-unknown', status: undefined }),
66+
createInstall({ id: 'null-1', appId: 'app-null', status: null }),
67+
])
68+
69+
const result = await service.getActionableInstalls()
70+
71+
expect(result).toEqual([])
72+
})
73+
74+
it('excludes a published install that has not registered a complete action label', async () => {
75+
const { service } = buildService([createInstall({ id: 'pub-1', appId: 'app-pub' })], {
76+
'pub-1': { verb: 'sign', singularNoun: '', pluralNoun: '' },
77+
})
78+
79+
const result = await service.getActionableInstalls()
80+
81+
expect(result).toEqual([])
82+
})
83+
})

0 commit comments

Comments
 (0)