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
4 changes: 4 additions & 0 deletions src/app/api/installed-apps/route.ts
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 3 additions & 0 deletions src/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
})

Expand Down Expand Up @@ -53,6 +54,7 @@ export const authorizedRoutes: Record<string, RouteRule[]> = {
ROUTES.api.segment,
ROUTES.api.segmentConfig,
ROUTES.api.segmentStats,
ROUTES.api.installedApps,
],
clientUsers: [
ROUTES.api.workspace,
Expand All @@ -69,5 +71,6 @@ export const authorizedRoutes: Record<string, RouteRule[]> = {
ROUTES.api.notificationCounts,
ROUTES.api.bannerImages,
ROUTES.api.image,
ROUTES.api.installedApps,
],
}
27 changes: 27 additions & 0 deletions src/features/installed-apps/hooks/useInstalledApps.ts
Original file line number Diff line number Diff line change
@@ -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<ActionableInstallsDto> => {
const res = await api.get<{ data: ActionableInstallsDto }>(ROUTES.api.installedApps)
return ActionableInstallsDtoSchema.parse(res.data.data)
},
enabled: Boolean(workspaceId),
})

return {
installedApps: data ?? [],
isLoading,
}
}
15 changes: 15 additions & 0 deletions src/features/installed-apps/installed-apps.dto.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ActionableInstallDtoSchema>

export const ActionableInstallsDtoSchema = z.array(ActionableInstallDtoSchema)
export type ActionableInstallsDto = z.infer<typeof ActionableInstallsDtoSchema>
13 changes: 13 additions & 0 deletions src/features/installed-apps/lib/installed-apps.controller.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse<APIResponse>> => {
const user = authenticateHeaders(req.headers)

const installedAppsService = InstalledAppsService.new(user)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder why we think this is better than calling new InstalledAppService. I find myself doing similiar thing. But most of the time makes no sense unless it has to be async.

const actionableInstalls = await installedAppsService.getActionableInstalls()

return NextResponse.json({ data: actionableInstalls })
}
60 changes: 60 additions & 0 deletions src/features/installed-apps/lib/installed-apps.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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 BaseService from '@/lib/core/base.service'
import logger from '@/lib/logger'

type ActiveInstall = AppInstallsData & { id: string; appId: string }

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<ActionableInstallDto[]> {
const installs = await this.assembly.getInstalls()

const activeInstalls = installs.filter((install): install is ActiveInstall =>
Boolean(install.id && install.appId && !install.disabled && !install.isDraft && !install.isInternalApp),
)

const results = await Promise.all(
activeInstalls.map(async (install): Promise<ActionableInstallDto | null> => {
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
}
Comment on lines +47 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Systemic Settings Failures Become Empty Lists

When the notification-settings endpoint fails for every active install, this catch path drops all installs and the API returns an empty array. A workspace with eligible apps can then look identical to a workspace with no actionable apps, which hides auth, permission, or platform outages from callers.

}),
)

return results.filter((result): result is ActionableInstallDto => result !== null)
}
}
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/*"]
}
},
Expand Down
Loading