-
Notifications
You must be signed in to change notification settings - Fork 0
OUT-3979 | API: actionable installs discovery endpoint #221
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) |
| 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, | ||
| } | ||
| } |
| 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> |
| 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) | ||
| const actionableInstalls = await installedAppsService.getActionableInstalls() | ||
|
|
||
| return NextResponse.json({ data: actionableInstalls }) | ||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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.