From 359b58744e445d9ee185ff9e07f8dc2ffd0fc2b4 Mon Sep 17 00:00:00 2001 From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:55:26 -0400 Subject: [PATCH] feat: Add Zod-based typing for API responses across frontend and backend (#1892) ## Summary Define API response Zod schemas in common-utils for alerts, webhooks, team, installation, and me endpoints. Apply Response typing on backend route handlers with explicit Mongoose-to-JSON serialization (ObjectId, Date, Map). Replace all `any` types and `as Promise` casts in frontend TanStack Query hooks with proper generics. ### How to test locally or on Vercel 1. `yarn dev` 2. Interact with app, ensuring nothing is broken ### References - Linear Issue: HDX-3464 --- .changeset/slow-insects-travel.md | 7 + packages/api/src/routers/api/alerts.ts | 112 +++++--- packages/api/src/routers/api/me.ts | 15 +- packages/api/src/routers/api/root.ts | 4 +- packages/api/src/routers/api/team.ts | 62 +++-- packages/api/src/routers/api/webhooks.ts | 34 ++- packages/app/src/api.ts | 242 ++++++++---------- packages/app/src/components/DBRowTable.tsx | 2 - .../TeamSettings/TeamMembersSection.tsx | 6 +- packages/app/src/hooks/useMetadata.tsx | 2 +- packages/app/src/types.ts | 13 +- packages/common-utils/src/types.ts | 239 +++++++++++++++-- 12 files changed, 498 insertions(+), 240 deletions(-) create mode 100644 .changeset/slow-insects-travel.md diff --git a/.changeset/slow-insects-travel.md b/.changeset/slow-insects-travel.md new file mode 100644 index 00000000..31656774 --- /dev/null +++ b/.changeset/slow-insects-travel.md @@ -0,0 +1,7 @@ +--- +'@hyperdx/common-utils': patch +'@hyperdx/api': patch +'@hyperdx/app': patch +--- + +fix: add explicit api typing to all api routes and frontend hooks diff --git a/packages/api/src/routers/api/alerts.ts b/packages/api/src/routers/api/alerts.ts index 10575b1e..670d6e36 100644 --- a/packages/api/src/routers/api/alerts.ts +++ b/packages/api/src/routers/api/alerts.ts @@ -1,5 +1,5 @@ +import type { AlertsApiResponse } from '@hyperdx/common-utils/dist/types'; import express from 'express'; -import _ from 'lodash'; import { ObjectId } from 'mongodb'; import { z } from 'zod'; import { processRequest, validateRequest } from 'zod-express-middleware'; @@ -17,7 +17,8 @@ import { alertSchema, objectIdSchema } from '@/utils/zod'; const router = express.Router(); -router.get('/', async (req, res, next) => { +type AlertsExpRes = express.Response; +router.get('/', async (req, res: AlertsExpRes, next) => { try { const teamId = req.user?.team; if (teamId == null) { @@ -26,7 +27,7 @@ router.get('/', async (req, res, next) => { const alerts = await getAlertsEnhanced(teamId); - const data = await Promise.all( + const data: AlertsApiResponse['data'] = await Promise.all( alerts.map(async alert => { const history = await getRecentAlertHistories({ alertId: new ObjectId(alert._id), @@ -34,50 +35,79 @@ router.get('/', async (req, res, next) => { }); return { - history, + _id: alert._id.toString(), + interval: alert.interval, + scheduleOffsetMinutes: alert.scheduleOffsetMinutes, + scheduleStartAt: alert.scheduleStartAt?.toISOString() ?? undefined, + threshold: alert.threshold, + thresholdType: alert.thresholdType, + channel: { type: alert.channel.type ?? undefined }, + state: alert.state, + source: alert.source, + tileId: alert.tileId, + name: alert.name, + message: alert.message, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + createdAt: (alert as any).createdAt?.toISOString?.() ?? '', + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + updatedAt: (alert as any).updatedAt?.toISOString?.() ?? '', + history: history.map(h => ({ + counts: h.counts, + createdAt: h.createdAt.toISOString(), + state: h.state, + lastValues: h.lastValues.map(lv => ({ + startTime: lv.startTime.toISOString(), + count: lv.count, + })), + })), silenced: alert.silenced ? { - by: alert.silenced.by?.email, - at: alert.silenced.at, - until: alert.silenced.until, + by: alert.silenced.by?.email ?? '', + at: alert.silenced.at.toISOString(), + until: alert.silenced.until.toISOString(), } : undefined, createdBy: alert.createdBy - ? _.pick(alert.createdBy, ['email', 'name']) + ? { + email: alert.createdBy.email, + name: alert.createdBy.name, + } + : undefined, + dashboardId: alert.dashboard + ? alert.dashboard._id.toString() + : undefined, + savedSearchId: alert.savedSearch + ? alert.savedSearch._id.toString() + : undefined, + dashboard: alert.dashboard + ? { + _id: alert.dashboard._id.toString(), + name: alert.dashboard.name, + updatedAt: + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (alert as any).dashboard?.updatedAt?.toISOString?.() ?? '', + tags: alert.dashboard.tags, + tiles: alert.dashboard.tiles + .filter(tile => tile.id === alert.tileId) + .map(tile => ({ + id: tile.id, + config: { name: tile.config.name }, + })), + } + : undefined, + savedSearch: alert.savedSearch + ? { + _id: alert.savedSearch._id.toString(), + createdAt: + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (alert as any).savedSearch?.createdAt?.toISOString?.() ?? '', + name: alert.savedSearch.name, + updatedAt: + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (alert as any).savedSearch?.updatedAt?.toISOString?.() ?? '', + tags: alert.savedSearch.tags, + } : undefined, - channel: _.pick(alert.channel, ['type']), - ...(alert.dashboard && { - dashboardId: alert.dashboard._id, - dashboard: { - tiles: alert.dashboard.tiles - .filter(tile => tile.id === alert.tileId) - .map(tile => _.pick(tile, ['id', 'config.name'])), - ..._.pick(alert.dashboard, ['_id', 'name', 'updatedAt', 'tags']), - }, - }), - ...(alert.savedSearch && { - savedSearchId: alert.savedSearch._id, - savedSearch: _.pick(alert.savedSearch, [ - '_id', - 'createdAt', - 'name', - 'updatedAt', - 'tags', - ]), - }), - ..._.pick(alert, [ - '_id', - 'interval', - 'scheduleOffsetMinutes', - 'scheduleStartAt', - 'threshold', - 'thresholdType', - 'state', - 'source', - 'tileId', - 'createdAt', - 'updatedAt', - ]), }; }), ); diff --git a/packages/api/src/routers/api/me.ts b/packages/api/src/routers/api/me.ts index 8f2f1df4..58dd0bde 100644 --- a/packages/api/src/routers/api/me.ts +++ b/packages/api/src/routers/api/me.ts @@ -1,3 +1,7 @@ +import { + type MeApiResponse, + MeApiResponseSchema, +} from '@hyperdx/common-utils/dist/types'; import express from 'express'; import { AI_API_KEY, ANTHROPIC_API_KEY, USAGE_STATS_ENABLED } from '@/config'; @@ -6,7 +10,7 @@ import { Api404Error } from '@/utils/errors'; const router = express.Router(); -router.get('/', async (req, res, next) => { +router.get('/', async (req, res: express.Response, next) => { try { if (req.user == null) { throw new Api404Error('Request without user found'); @@ -22,14 +26,17 @@ router.get('/', async (req, res, next) => { } = req.user; const team = await getTeam(teamId); + if (team == null) { + throw new Api404Error(`Team not found for user ${id}`); + } return res.json({ accessKey, - createdAt, + createdAt: createdAt.toISOString(), email, - id, + id: id.toString(), name, - team, + team: MeApiResponseSchema.shape.team.parse(team.toJSON()), usageStatsEnabled: USAGE_STATS_ENABLED, aiAssistantEnabled: !!(AI_API_KEY || ANTHROPIC_API_KEY), }); diff --git a/packages/api/src/routers/api/root.ts b/packages/api/src/routers/api/root.ts index eb7e569e..ce83953f 100644 --- a/packages/api/src/routers/api/root.ts +++ b/packages/api/src/routers/api/root.ts @@ -1,3 +1,4 @@ +import type { InstallationApiResponse } from '@hyperdx/common-utils/dist/types'; import express from 'express'; import { serializeError } from 'serialize-error'; import { z } from 'zod'; @@ -53,7 +54,8 @@ router.get('/health', async (req, res) => { }); }); -router.get('/installation', async (req, res, next) => { +type InstallationEspRes = express.Response; +router.get('/installation', async (_, res: InstallationEspRes, next) => { try { const _isTeamExisting = await isTeamExisting(); return res.json({ diff --git a/packages/api/src/routers/api/team.ts b/packages/api/src/routers/api/team.ts index f3ab8931..addc5404 100644 --- a/packages/api/src/routers/api/team.ts +++ b/packages/api/src/routers/api/team.ts @@ -1,3 +1,11 @@ +import type { + RotateApiKeyApiResponse, + TeamApiResponse, + TeamInvitationsApiResponse, + TeamMembersApiResponse, + TeamTagsApiResponse, + UpdateClickHouseSettingsApiResponse, +} from '@hyperdx/common-utils/dist/types'; import { TeamClickHouseSettingsSchema } from '@hyperdx/common-utils/dist/types'; import crypto from 'crypto'; import express from 'express'; @@ -23,7 +31,8 @@ import { objectIdSchema } from '@/utils/zod'; const router = express.Router(); -router.get('/', async (req, res, next) => { +type TeamApiExpRes = express.Response; +router.get('/', async (req, res: TeamApiExpRes, next) => { try { const teamId = req.user?.team; const userId = req.user?._id; @@ -46,20 +55,37 @@ router.get('/', async (req, res, next) => { throw new Error(`Team ${teamId} not found for user ${userId}`); } - res.json(team.toJSON()); + // createdAt comes from Mongoose timestamps but is not on the ITeam interface + const createdAt = + 'createdAt' in team && team.createdAt instanceof Date + ? team.createdAt.toISOString() + : ''; + + res.json({ + _id: team._id.toString(), + allowedAuthMethods: + 'allowedAuthMethods' in team ? team.allowedAuthMethods : undefined, + apiKey: team.apiKey, + name: team.name, + createdAt, + }); } catch (e) { next(e); } }); -router.patch('/apiKey', async (req, res, next) => { +type RotateApiKeyExpRes = express.Response; +router.patch('/apiKey', async (req, res: RotateApiKeyExpRes, next) => { try { const teamId = req.user?.team; if (teamId == null) { throw new Error(`User ${req.user?._id} not associated with a team`); } const team = await rotateTeamApiKey(teamId); - res.json({ newApiKey: team?.apiKey }); + if (team?.apiKey == null) { + throw new Error(`Failed to rotate API key for team ${teamId}`); + } + res.json({ newApiKey: team.apiKey }); } catch (e) { next(e); } @@ -92,7 +118,11 @@ router.patch( processRequest({ body: TeamClickHouseSettingsSchema, }), - async (req, res, next) => { + async ( + req, + res: express.Response, + next, + ) => { try { const teamId = req.user?.team; if (teamId == null) { @@ -105,14 +135,7 @@ router.patch( const team = await updateTeamClickhouseSettings(teamId, req.body); - res.json( - Object.entries(req.body).reduce((acc, cur) => { - return { - ...acc, - [cur[0]]: team?.[cur[0]], - }; - }, {} as any), - ); + res.json(pick(team, Object.keys(req.body))); } catch (e) { next(e); } @@ -176,7 +199,8 @@ router.post( }, ); -router.get('/invitations', async (req, res, next) => { +type TeamInviteExpressRes = express.Response; +router.get('/invitations', async (req, res: TeamInviteExpressRes, next) => { try { const teamId = req.user?.team; if (teamId == null) { @@ -193,8 +217,8 @@ router.get('/invitations', async (req, res, next) => { ); res.json({ data: teamInvites.map(ti => ({ - _id: ti._id, - createdAt: ti.createdAt, + _id: ti._id.toString(), + createdAt: ti.createdAt.toISOString(), email: ti.email, name: ti.name, url: `${config.FRONTEND_URL}/join-team?token=${ti.token}`, @@ -225,7 +249,8 @@ router.delete( }, ); -router.get('/members', async (req, res, next) => { +type TeamMembersExpRes = express.Response; +router.get('/members', async (req, res: TeamMembersExpRes, next) => { try { const teamId = req.user?.team; const userId = req.user?._id; @@ -281,7 +306,8 @@ router.delete( }, ); -router.get('/tags', async (req, res, next) => { +type TeamTagsExpRes = express.Response; +router.get('/tags', async (req, res: TeamTagsExpRes, next) => { try { const teamId = req.user?.team; if (teamId == null) { diff --git a/packages/api/src/routers/api/webhooks.ts b/packages/api/src/routers/api/webhooks.ts index 0c163092..389dfb4e 100644 --- a/packages/api/src/routers/api/webhooks.ts +++ b/packages/api/src/routers/api/webhooks.ts @@ -1,3 +1,9 @@ +import type { + WebhookCreateApiResponse, + WebhooksApiResponse, + WebhookTestApiResponse, + WebhookUpdateApiResponse, +} from '@hyperdx/common-utils/dist/types'; import express from 'express'; import { ObjectId } from 'mongodb'; import mongoose from 'mongoose'; @@ -23,7 +29,7 @@ router.get( ]), }), }), - async (req, res, next) => { + async (req, res: express.Response, next) => { try { const teamId = req.user?.team; if (teamId == null) { @@ -35,7 +41,7 @@ router.get( { __v: 0, team: 0 }, ); res.json({ - data: webhooks, + data: webhooks.map(w => w.toJSON({ flattenMaps: true })), }); } catch (err) { next(err); @@ -75,7 +81,11 @@ router.post( url: z.string().url(), }), }), - async (req, res, next) => { + async ( + req, + res: express.Response, + next, + ) => { try { const teamId = req.user?.team; if (teamId == null) { @@ -100,7 +110,7 @@ router.post( }); await webhook.save(); res.json({ - data: webhook, + data: webhook.toJSON({ flattenMaps: true }), }); } catch (err) { next(err); @@ -128,7 +138,11 @@ router.put( url: z.string().url(), }), }), - async (req, res, next) => { + async ( + req, + res: express.Response, + next, + ) => { try { const teamId = req.user?.team; if (teamId == null) { @@ -177,8 +191,14 @@ router.put( { new: true, select: { __v: 0, team: 0 } }, ); + if (!updatedWebhook) { + return res.status(404).json({ + message: 'Webhook not found after update', + }); + } + res.json({ - data: updatedWebhook, + data: updatedWebhook.toJSON({ flattenMaps: true }), }); } catch (err) { next(err); @@ -222,7 +242,7 @@ router.post( url: z.string().url(), }), }), - async (req, res, next) => { + async (req, res: express.Response, next) => { try { const teamId = req.user?.team; if (teamId == null) { diff --git a/packages/app/src/api.ts b/packages/app/src/api.ts index 1887d3e8..f146b64f 100644 --- a/packages/app/src/api.ts +++ b/packages/app/src/api.ts @@ -3,9 +3,21 @@ import type { HTTPError, Options, ResponsePromise } from 'ky'; import ky from 'ky-universal'; import type { Alert, + AlertsApiResponse, + InstallationApiResponse, + MeApiResponse, PresetDashboard, PresetDashboardFilter, - Team, + RotateApiKeyApiResponse, + TeamApiResponse, + TeamInvitationsApiResponse, + TeamMembersApiResponse, + TeamTagsApiResponse, + UpdateClickHouseSettingsApiResponse, + WebhookCreateApiResponse, + WebhooksApiResponse, + WebhookTestApiResponse, + WebhookUpdateApiResponse, } from '@hyperdx/common-utils/dist/types'; import type { UseQueryOptions } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query'; @@ -16,8 +28,6 @@ import { fetchLocalDashboards, getLocalDashboardTags, } from './dashboard'; -import type { AlertsPageItem } from './types'; - type ServicesResponse = { data: Record< string, @@ -30,12 +40,6 @@ type ServicesResponse = { >; }; -type AlertsResponse = { - data: AlertsPageItem[]; -}; - -type ApiAlertInput = Alert; - export function loginHook(request: Request, options: any, response: Response) { // marketing pages const WHITELIST_PATHS = [ @@ -76,7 +80,7 @@ export const hdxServer = ( const api = { useCreateAlert() { - return useMutation({ + return useMutation<{ data: Alert }, Error, Alert>({ mutationFn: async alert => server('alerts', { method: 'POST', @@ -85,7 +89,7 @@ const api = { }); }, useUpdateAlert() { - return useMutation({ + return useMutation<{ data: Alert }, Error, { id: string } & Alert>({ mutationFn: async alert => server(`alerts/${alert.id}`, { method: 'PUT', @@ -94,28 +98,31 @@ const api = { }); }, useDeleteAlert() { - return useMutation({ - mutationFn: async (alertId: string) => - server(`alerts/${alertId}`, { + return useMutation({ + mutationFn: async (alertId: string) => { + await server(`alerts/${alertId}`, { method: 'DELETE', - }), + }); + }, }); }, useSilenceAlert() { - return useMutation({ - mutationFn: async ({ alertId, mutedUntil }) => - server(`alerts/${alertId}/silenced`, { + return useMutation({ + mutationFn: async ({ alertId, mutedUntil }) => { + await server(`alerts/${alertId}/silenced`, { method: 'POST', json: { mutedUntil }, - }), + }); + }, }); }, useUnsilenceAlert() { - return useMutation({ - mutationFn: async (alertId: string) => - server(`alerts/${alertId}/silenced`, { + return useMutation({ + mutationFn: async (alertId: string) => { + await server(`alerts/${alertId}/silenced`, { method: 'DELETE', - }), + }); + }, }); }, useDashboards(options?: UseQueryOptions) { @@ -136,14 +143,14 @@ const api = { tags, }: { name: string; - charts: any; - query: any; - tags: any; + charts: Dashboard['tiles']; + query: string; + tags: string[]; }) => hdxServer(`dashboards`, { method: 'POST', json: { name, charts, query, tags }, - }).json(), + }).json(), }); }, useUpdateDashboard() { @@ -157,22 +164,23 @@ const api = { }: { id: string; name: string; - charts: any; - query: any; - tags: any; + charts: Dashboard['tiles']; + query: string; + tags: string[]; }) => hdxServer(`dashboards/${id}`, { method: 'PUT', json: { name, charts, query, tags }, - }).json(), + }).json(), }); }, useDeleteDashboard() { return useMutation({ - mutationFn: async ({ id }: { id: string }) => - hdxServer(`dashboards/${id}`, { + mutationFn: async ({ id }: { id: string }) => { + await hdxServer(`dashboards/${id}`, { method: 'DELETE', - }).json(), + }); + }, }); }, usePresetDashboardFilters( @@ -186,30 +194,34 @@ const api = { hdxServer(`dashboards/preset/${presetDashboard}/filters/`, { method: 'GET', searchParams: { sourceId }, - }).json() as Promise, + }).json(), enabled: !!sourceId && enabled, }); }, useCreatePresetDashboardFilter() { - return useMutation({ + return useMutation({ mutationFn: async (filter: PresetDashboardFilter) => hdxServer(`dashboards/preset/${filter.presetDashboard}/filter`, { method: 'POST', json: { filter }, - }).json(), + }).json(), }); }, useUpdatePresetDashboardFilter() { - return useMutation({ + return useMutation({ mutationFn: async (filter: PresetDashboardFilter) => hdxServer(`dashboards/preset/${filter.presetDashboard}/filter`, { method: 'PUT', json: { filter }, - }).json(), + }).json(), }); }, useDeletePresetDashboardFilter() { - return useMutation({ + return useMutation< + PresetDashboardFilter, + Error, + { id: string; presetDashboard: PresetDashboard } + >({ mutationFn: async ({ id, presetDashboard, @@ -219,13 +231,13 @@ const api = { }) => hdxServer(`dashboards/preset/${presetDashboard}/filter/${id}`, { method: 'DELETE', - }).json(), + }).json(), }); }, useAlerts() { return useQuery({ queryKey: [`alerts`], - queryFn: () => hdxServer(`alerts`).json() as Promise, + queryFn: () => hdxServer(`alerts`).json(), }); }, useServices() { @@ -234,34 +246,39 @@ const api = { queryFn: () => hdxServer(`chart/services`, { method: 'GET', - }).json() as Promise, + }).json(), }); }, useRotateTeamApiKey() { - return useMutation({ + return useMutation({ mutationFn: async () => hdxServer(`team/apiKey`, { method: 'PATCH', - }).json(), + }).json(), }); }, useDeleteTeamMember() { - return useMutation({ + return useMutation< + { message: string }, + Error | HTTPError, + { userId: string } + >({ mutationFn: async ({ userId }: { userId: string }) => hdxServer(`team/member/${userId}`, { method: 'DELETE', - }).json(), + }).json<{ message: string }>(), }); }, useTeamInvitations() { - return useQuery({ + return useQuery({ queryKey: [`team/invitations`], - queryFn: () => hdxServer(`team/invitations`).json(), + queryFn: () => + hdxServer(`team/invitations`).json(), }); }, useSaveTeamInvitation() { return useMutation< - any, + { url: string }, Error | HTTPError, { name?: string; email: string } >({ @@ -272,7 +289,7 @@ const api = { name, email, }, - }).json(), + }).json<{ url: string }>(), }); }, useDeleteTeamInvitation() { @@ -280,28 +297,28 @@ const api = { mutationFn: async ({ id }: { id: string }) => hdxServer(`team/invitation/${id}`, { method: 'DELETE', - }).json(), + }).json<{ message: string }>(), }); }, useInstallation() { - return useQuery({ + return useQuery({ queryKey: [`installation`], queryFn: () => { if (IS_LOCAL_MODE) { return; } - return hdxServer(`installation`).json(); + return hdxServer(`installation`).json(); }, }); }, useMe() { - return useQuery({ + return useQuery({ queryKey: [`me`], queryFn: () => { if (IS_LOCAL_MODE) { return null; } - return hdxServer(`me`).json(); + return hdxServer(`me`).json(); }, }); }, @@ -312,37 +329,29 @@ const api = { if (IS_LOCAL_MODE) { return null; } - - type TeamResponse = Pick< - Team, - 'allowedAuthMethods' | 'apiKey' | 'name' - > & { - createdAt: string; - }; - - return hdxServer(`team`).json(); + return hdxServer(`team`).json(); }, retry: 1, }); }, useTeamMembers() { - return useQuery({ + return useQuery({ queryKey: [`team/members`], - queryFn: () => hdxServer(`team/members`).json(), + queryFn: () => hdxServer(`team/members`).json(), }); }, useSetTeamName() { - return useMutation({ + return useMutation<{ name: string }, HTTPError, { name: string }>({ mutationFn: async ({ name }) => hdxServer(`team/name`, { method: 'PATCH', json: { name }, - }).json(), + }).json<{ name: string }>(), }); }, useUpdateClickhouseSettings() { return useMutation< - any, + UpdateClickHouseSettingsApiResponse, HTTPError, { searchRowLimit?: number; @@ -354,7 +363,7 @@ const api = { hdxServer(`team/clickhouse-settings`, { method: 'PATCH', json: settings, - }).json(), + }).json(), }); }, useTags() { @@ -362,12 +371,12 @@ const api = { queryKey: [`team/tags`], queryFn: IS_LOCAL_MODE ? async () => ({ data: getLocalDashboardTags() }) - : () => hdxServer(`team/tags`).json<{ data: string[] }>(), + : () => hdxServer(`team/tags`).json(), }); }, useSaveWebhook() { return useMutation< - any, + WebhookCreateApiResponse, Error | HTTPError, { service: string; @@ -387,14 +396,6 @@ const api = { queryParams, headers, body, - }: { - service: string; - url: string; - name: string; - description: string; - queryParams?: Record; - headers?: Record; - body?: string; }) => hdxServer(`webhooks`, { method: 'POST', @@ -407,12 +408,12 @@ const api = { headers: headers || {}, body, }, - }).json(), + }).json(), }); }, useUpdateWebhook() { return useMutation< - any, + WebhookUpdateApiResponse, Error | HTTPError, { id: string; @@ -434,15 +435,6 @@ const api = { queryParams, headers, body, - }: { - id: string; - service: string; - url: string; - name: string; - description: string; - queryParams?: Record; - headers?: Record; - body?: string; }) => hdxServer(`webhooks/${id}`, { method: 'PUT', @@ -455,21 +447,25 @@ const api = { headers: headers || {}, body, }, - }).json(), + }).json(), }); }, useWebhooks(services: string[]) { - return useQuery({ + return useQuery({ queryKey: [...services], queryFn: () => hdxServer('webhooks', { method: 'GET', searchParams: [...services.map(service => ['service', service])], - }).json(), + }).json(), }); }, useDeleteWebhook() { - return useMutation({ + return useMutation< + Record, + Error | HTTPError, + { id: string } + >({ mutationFn: async ({ id }: { id: string }) => hdxServer(`webhooks/${id}`, { method: 'DELETE', @@ -478,7 +474,7 @@ const api = { }, useTestWebhook() { return useMutation< - any, + WebhookTestApiResponse, Error | HTTPError, { service: string; @@ -488,19 +484,7 @@ const api = { body?: string; } >({ - mutationFn: async ({ - service, - url, - queryParams, - headers, - body, - }: { - service: string; - url: string; - queryParams?: Record; - headers?: Record; - body?: string; - }) => + mutationFn: async ({ service, url, queryParams, headers, body }) => hdxServer(`webhooks/test`, { method: 'POST', json: { @@ -510,20 +494,16 @@ const api = { headers: headers || {}, body, }, - }).json(), + }).json(), }); }, useRegisterPassword() { - return useMutation({ - mutationFn: async ({ - email, - password, - confirmPassword, - }: { - email: string; - password: string; - confirmPassword: string; - }) => + return useMutation< + { status: string }, + Error, + { email: string; password: string; confirmPassword: string } + >({ + mutationFn: async ({ email, password, confirmPassword }) => hdxServer(`register/password`, { method: 'POST', json: { @@ -531,20 +511,16 @@ const api = { password, confirmPassword, }, - }).json(), + }).json<{ status: string }>(), }); }, useTestConnection() { - return useMutation({ - mutationFn: async ({ - host, - username, - password, - }: { - host: string; - username: string; - password: string; - }) => + return useMutation< + { success: boolean; error?: string }, + Error, + { host: string; username: string; password: string } + >({ + mutationFn: async ({ host, username, password }) => hdxServer(`clickhouse-proxy/test`, { method: 'POST', json: { @@ -552,7 +528,7 @@ const api = { username, password, }, - }).json() as Promise<{ success: boolean; error?: string }>, + }).json<{ success: boolean; error?: string }>(), }); }, }; diff --git a/packages/app/src/components/DBRowTable.tsx b/packages/app/src/components/DBRowTable.tsx index 02d7f7b8..a60bbb9c 100644 --- a/packages/app/src/components/DBRowTable.tsx +++ b/packages/app/src/components/DBRowTable.tsx @@ -899,11 +899,9 @@ export const RawLogTable = memo( isLast={isLast} onRemoveColumn={ onRemoveColumn && - // eslint-disable-next-line @typescript-eslint/no-explicit-any (header.column.columnDef.meta as any)?.column ? () => { onRemoveColumn( - // eslint-disable-next-line @typescript-eslint/no-explicit-any (header.column.columnDef.meta as any) ?.column, ); diff --git a/packages/app/src/components/TeamSettings/TeamMembersSection.tsx b/packages/app/src/components/TeamSettings/TeamMembersSection.tsx index e36b51a9..148aaf7b 100644 --- a/packages/app/src/components/TeamSettings/TeamMembersSection.tsx +++ b/packages/app/src/components/TeamSettings/TeamMembersSection.tsx @@ -228,7 +228,7 @@ export default function TeamMembersSection() { {!isLoadingMembers && Array.isArray(members?.data) && - members?.data.map((member: any) => ( + members?.data.map(member => (
@@ -284,8 +284,8 @@ export default function TeamMembersSection() { ))} {!isLoadingInvitations && - Array.isArray(invitations.data) && - invitations.data.map((invitation: any) => ( + Array.isArray(invitations?.data) && + invitations.data.map(invitation => ( diff --git a/packages/app/src/hooks/useMetadata.tsx b/packages/app/src/hooks/useMetadata.tsx index e707f9bc..cfb613b5 100644 --- a/packages/app/src/hooks/useMetadata.tsx +++ b/packages/app/src/hooks/useMetadata.tsx @@ -59,7 +59,7 @@ export function useMetadataWithSettings() { useEffect(() => { if (me?.team?.metadataMaxRowsToRead && !settingsApplied.current) { metadata.setClickHouseSettings({ - max_rows_to_read: me.team.metadataMaxRowsToRead, + max_rows_to_read: String(me.team.metadataMaxRowsToRead), }); settingsApplied.current = true; } diff --git a/packages/app/src/types.ts b/packages/app/src/types.ts index e77e2416..883fccd1 100644 --- a/packages/app/src/types.ts +++ b/packages/app/src/types.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { Alert, - AlertHistory, + AlertsPageItem as _AlertsPageItem, BuilderChartConfig, DashboardSchema, Filter, @@ -36,16 +36,7 @@ export type LogStreamModel = KeyValuePairs & { trace_id?: string; }; -export type AlertsPageItem = Alert & { - _id: string; - history: AlertHistory[]; - dashboard?: ServerDashboard; - savedSearch?: SavedSearch; - createdBy?: { - email: string; - name?: string; - }; -}; +export type AlertsPageItem = _AlertsPageItem; export type AlertWithCreatedBy = Alert & { createdBy?: { diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 990a968e..ecf41924 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -250,20 +250,22 @@ export enum WebhookService { IncidentIO = 'incidentio', } -// Base webhook interface (matches backend IWebhook but with JSON-serialized types) +// Base webhook schema (matches backend IWebhook but with JSON-serialized types) // When making changes here, consider if they need to be made to the external API schema as well (packages/api/src/utils/zod.ts). -export interface IWebhook { - _id: string; - createdAt: string; - name: string; - service: WebhookService; - updatedAt: string; - url?: string; - description?: string; - queryParams?: Record; - headers?: Record; - body?: string; -} +export const WebhookSchema = z.object({ + _id: z.string(), + createdAt: z.string(), + name: z.string(), + service: z.nativeEnum(WebhookService), + updatedAt: z.string(), + url: z.string().optional(), + description: z.string().optional(), + queryParams: z.record(z.string(), z.string()).optional(), + headers: z.record(z.string(), z.string()).optional(), + body: z.string().optional(), +}); + +export type IWebhook = z.infer; // Webhook API response type (excludes team field for security) export type WebhookApiData = Omit; @@ -434,12 +436,14 @@ export const AlertSchema = z.union([ export type Alert = z.infer; -export type AlertHistory = { - counts: number; - createdAt: string; - lastValues: { startTime: string; count: number }[]; - state: AlertState; -}; +export const AlertHistorySchema = z.object({ + counts: z.number(), + createdAt: z.string(), + lastValues: z.array(z.object({ startTime: z.string(), count: z.number() })), + state: z.nativeEnum(AlertState), +}); + +export type AlertHistory = z.infer; // -------------------------- // FILTERS @@ -1119,3 +1123,200 @@ export const AssistantResponseConfig = z.discriminatedUnion('displayType', [ export type AssistantResponseConfigSchema = z.infer< typeof AssistantResponseConfig >; + +// -------------------------- +// API RESPONSE SCHEMAS +// -------------------------- + +// Alerts +export const AlertsPageItemSchema = z.object({ + _id: z.string(), + interval: AlertIntervalSchema, + scheduleOffsetMinutes: z.number().optional(), + scheduleStartAt: z.union([z.string(), z.date()]).nullish(), + threshold: z.number(), + thresholdType: z.nativeEnum(AlertThresholdType), + channel: z.object({ type: z.string().optional() }), + state: z.nativeEnum(AlertState).optional(), + source: z.nativeEnum(AlertSource).optional(), + dashboardId: z.string().optional(), + savedSearchId: z.string().optional(), + tileId: z.string().optional(), + name: z.string().nullish(), + message: z.string().nullish(), + createdAt: z.string(), + updatedAt: z.string(), + history: z.array(AlertHistorySchema), + dashboard: z + .object({ + _id: z.string(), + name: z.string(), + updatedAt: z.string(), + tags: z.array(z.string()), + tiles: z.array( + z.object({ + id: z.string(), + config: z.object({ name: z.string().optional() }), + }), + ), + }) + .optional(), + savedSearch: z + .object({ + _id: z.string(), + createdAt: z.string(), + name: z.string(), + updatedAt: z.string(), + tags: z.array(z.string()), + }) + .optional(), + createdBy: z + .object({ + email: z.string(), + name: z.string().optional(), + }) + .optional(), + silenced: z + .object({ + by: z.string(), + at: z.string(), + until: z.string(), + }) + .optional(), +}); + +export type AlertsPageItem = z.infer; + +export const AlertsApiResponseSchema = z.object({ + data: z.array(AlertsPageItemSchema), +}); + +export type AlertsApiResponse = z.infer; + +// Webhooks +export const WebhooksApiResponseSchema = z.object({ + data: z.array(WebhookSchema), +}); + +export type WebhooksApiResponse = z.infer; + +export const WebhookCreateApiResponseSchema = z.object({ + data: WebhookSchema, +}); + +export type WebhookCreateApiResponse = z.infer< + typeof WebhookCreateApiResponseSchema +>; + +export const WebhookUpdateApiResponseSchema = z.object({ + data: WebhookSchema, +}); + +export type WebhookUpdateApiResponse = z.infer< + typeof WebhookUpdateApiResponseSchema +>; + +export const WebhookTestApiResponseSchema = z.object({ + message: z.string(), +}); + +export type WebhookTestApiResponse = z.infer< + typeof WebhookTestApiResponseSchema +>; + +// Team +export const TeamApiResponseSchema = z.object({ + _id: z.string(), + allowedAuthMethods: z.array(z.literal('password')).optional(), + apiKey: z.string(), + name: z.string(), + createdAt: z.string(), +}); + +export type TeamApiResponse = z.infer; + +export const TeamMemberSchema = z.object({ + _id: z.string(), + email: z.string(), + name: z.string().optional(), + hasPasswordAuth: z.boolean(), + isCurrentUser: z.boolean(), + groupName: z.string().optional(), +}); + +export type TeamMember = z.infer; + +export const TeamMembersApiResponseSchema = z.object({ + data: z.array(TeamMemberSchema), +}); + +export type TeamMembersApiResponse = z.infer< + typeof TeamMembersApiResponseSchema +>; + +export const TeamInvitationSchema = z.object({ + _id: z.string(), + createdAt: z.string(), + email: z.string(), + name: z.string().optional(), + url: z.string(), +}); + +export type TeamInvitation = z.infer; + +export const TeamInvitationsApiResponseSchema = z.object({ + data: z.array(TeamInvitationSchema), +}); + +export type TeamInvitationsApiResponse = z.infer< + typeof TeamInvitationsApiResponseSchema +>; + +export const TeamTagsApiResponseSchema = z.object({ + data: z.array(z.string()), +}); + +export type TeamTagsApiResponse = z.infer; + +export const UpdateClickHouseSettingsApiResponseSchema = + TeamClickHouseSettingsSchema.partial(); + +export type UpdateClickHouseSettingsApiResponse = z.infer< + typeof UpdateClickHouseSettingsApiResponseSchema +>; + +export const RotateApiKeyApiResponseSchema = z.object({ + newApiKey: z.string(), +}); + +export type RotateApiKeyApiResponse = z.infer< + typeof RotateApiKeyApiResponseSchema +>; + +// Installation +export const InstallationApiResponseSchema = z.object({ + isTeamExisting: z.boolean(), +}); + +export type InstallationApiResponse = z.infer< + typeof InstallationApiResponseSchema +>; + +// Me +export const MeApiResponseSchema = z.object({ + accessKey: z.string(), + createdAt: z.string(), + email: z.string(), + id: z.string(), + name: z.string(), + team: TeamSchema.pick({ + id: true, + name: true, + allowedAuthMethods: true, + apiKey: true, + }).merge(TeamClickHouseSettingsSchema), + usageStatsEnabled: z.boolean(), + aiAssistantEnabled: z.boolean(), +}); + +export type MeApiResponse = z.infer;