diff --git a/src/app/api/installed-apps/route.ts b/src/app/api/installed-apps/route.ts new file mode 100644 index 00000000..69002da5 --- /dev/null +++ b/src/app/api/installed-apps/route.ts @@ -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) diff --git a/src/app/routes.ts b/src/app/routes.ts index d6cb623b..9b9bb963 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -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', }, }) @@ -53,6 +54,7 @@ export const authorizedRoutes: Record = { ROUTES.api.segment, ROUTES.api.segmentConfig, ROUTES.api.segmentStats, + ROUTES.api.installedApps, ], clientUsers: [ ROUTES.api.workspace, @@ -69,5 +71,6 @@ export const authorizedRoutes: Record = { ROUTES.api.notificationCounts, ROUTES.api.bannerImages, ROUTES.api.image, + ROUTES.api.installedApps, ], } diff --git a/src/features/installed-apps/hooks/useInstalledApps.ts b/src/features/installed-apps/hooks/useInstalledApps.ts new file mode 100644 index 00000000..f668d980 --- /dev/null +++ b/src/features/installed-apps/hooks/useInstalledApps.ts @@ -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 => { + const res = await api.get<{ data: ActionableInstallsDto }>(ROUTES.api.installedApps) + return ActionableInstallsDtoSchema.parse(res.data.data) + }, + enabled: Boolean(workspaceId), + }) + + return { + installedApps: data ?? [], + isLoading, + } +} diff --git a/src/features/installed-apps/installed-apps.dto.ts b/src/features/installed-apps/installed-apps.dto.ts new file mode 100644 index 00000000..89275522 --- /dev/null +++ b/src/features/installed-apps/installed-apps.dto.ts @@ -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 + +export const ActionableInstallsDtoSchema = z.array(ActionableInstallDtoSchema) +export type ActionableInstallsDto = z.infer diff --git a/src/features/installed-apps/lib/installed-apps.controller.ts b/src/features/installed-apps/lib/installed-apps.controller.ts new file mode 100644 index 00000000..52cbd748 --- /dev/null +++ b/src/features/installed-apps/lib/installed-apps.controller.ts @@ -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> => { + const user = authenticateHeaders(req.headers) + + const installedAppsService = InstalledAppsService.new(user) + const actionableInstalls = await installedAppsService.getActionableInstalls() + + return NextResponse.json({ data: actionableInstalls }) +} diff --git a/src/features/installed-apps/lib/installed-apps.service.ts b/src/features/installed-apps/lib/installed-apps.service.ts new file mode 100644 index 00000000..0b5ab904 --- /dev/null +++ b/src/features/installed-apps/lib/installed-apps.service.ts @@ -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 { + 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 => { + 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 + } + }), + ) + + return results.filter((result): result is ActionableInstallDto => result !== null) + } +} diff --git a/tsconfig.json b/tsconfig.json index cb4e89af..72008bed 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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/*"] } },