Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions src/features/installed-apps/lib/installed-apps.service.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<ActionableInstallDto[]> {
const installs = await this.assembly.getInstalls()

Expand All @@ -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,
),
)

Expand Down
8 changes: 8 additions & 0 deletions src/lib/assembly/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ export const WorkspaceResponseSchema = z.object({
})
export type WorkspaceResponse = z.infer<typeof WorkspaceResponseSchema>

// 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(),
Expand All @@ -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<typeof AppInstallsDataSchema>
export const AppInstallsResponseSchema = z.array(AppInstallsDataSchema)
Expand Down
83 changes: 83 additions & 0 deletions tests/unit/installed-apps.service.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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<string, unknown> = {}) => {
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([])
})
})
Loading