diff --git a/app/models/integration.server.ts b/app/models/integration.server.ts index 66bf414e..480182ea 100644 --- a/app/models/integration.server.ts +++ b/app/models/integration.server.ts @@ -67,4 +67,116 @@ export async function reconcileDeviceIntegrations({ console.error(`Error reconciling ${intg.slug} integration`, error) } } +} + +export type DeviceIntegrationStatus = + | 'configured' + | 'not_configured' + | 'unreachable' + | 'misconfigured' + +export type DeviceIntegrationSummary = Record< + string, + { + enabled: boolean + status: DeviceIntegrationStatus + } +> + +export async function getDeviceIntegrations( + deviceId: string, +): Promise { + const integrations = await getIntegrations() + + const entries = await Promise.all( + integrations.map(async (intg): Promise<[string, DeviceIntegrationSummary[string]]> => { + const serviceKey = process.env[intg.serviceKey] + + if (!serviceKey) { + console.warn(`Service key '${intg.serviceKey}' not configured`) + return [ + intg.slug, + { + enabled: false, + status: 'misconfigured', + }, + ] + } + + try { + const res = await fetch(`${intg.serviceUrl}/integrations/${deviceId}`, { + method: 'GET', + headers: { + 'x-service-key': serviceKey, + }, + signal: AbortSignal.timeout(2000), + }) + + if (res.status === 404) { + return [ + intg.slug, + { + enabled: false, + status: 'not_configured', + }, + ] + } + + if (!res.ok) { + const text = await res.text() + console.error( + `Failed to fetch ${intg.slug} integration for device ${deviceId}`, + { + status: res.status, + body: text, + }, + ) + + return [ + intg.slug, + { + enabled: false, + status: 'unreachable', + }, + ] + } + + const data = await res.json() + + return [ + intg.slug, + { + enabled: Boolean(data?.enabled), + status: 'configured', + }, + ] + } catch (error) { + console.error( + `Error fetching ${intg.slug} integration for device ${deviceId}`, + error, + ) + + return [ + intg.slug, + { + enabled: false, + status: 'unreachable', + }, + ] + } + }), + ) + + return Object.fromEntries(entries) +} + +export async function enrichDevicesWithIntegrations( + devices: T[], +): Promise> { + return Promise.all( + devices.map(async (device) => ({ + ...device, + integrations: await getDeviceIntegrations(device._id), + })), + ) } \ No newline at end of file diff --git a/app/routes/api.users.me.boxes.ts b/app/routes/api.users.me.boxes.ts index 9bbf2778..0e32fe23 100644 --- a/app/routes/api.users.me.boxes.ts +++ b/app/routes/api.users.me.boxes.ts @@ -2,32 +2,36 @@ import { type LoaderFunction, type LoaderFunctionArgs } from 'react-router' import { transformDeviceToApiFormat } from '~/lib/device-transform' import { getUserFromJwt } from '~/lib/jwt' import { getUserDevices } from '~/models/device.server' +import { enrichDevicesWithIntegrations } from '~/models/integration.server' import { StandardResponse } from '~/utils/response-utils' export const loader: LoaderFunction = async ({ - request, + request, }: LoaderFunctionArgs) => { - try { - const jwtResponse = await getUserFromJwt(request) + try { + const jwtResponse = await getUserFromJwt(request) - if (typeof jwtResponse === 'string') - return StandardResponse.forbidden( - 'Invalid JWT authorization. Please sign in to obtain new JWT.', - ) + if (typeof jwtResponse === 'string') { + return StandardResponse.forbidden( + 'Invalid JWT authorization. Please sign in to obtain new JWT.', + ) + } - const userBoxes = await getUserDevices(jwtResponse.id) - const cleanedBoxes = userBoxes.map((box) => transformDeviceToApiFormat(box)) + const userBoxes = await getUserDevices(jwtResponse.id) + const transformedBoxes = userBoxes.map((box) => transformDeviceToApiFormat(box)) + const boxesWithIntegrations = + await enrichDevicesWithIntegrations(transformedBoxes) - return StandardResponse.ok({ - code: 'Ok', - data: { - boxes: cleanedBoxes, - boxes_count: cleanedBoxes.length, - sharedBoxes: [], - }, - }) - } catch (err) { - console.warn(err) - return StandardResponse.internalServerError() - } -} + return StandardResponse.ok({ + code: 'Ok', + data: { + boxes: boxesWithIntegrations, + boxes_count: boxesWithIntegrations.length, + sharedBoxes: [], + }, + }) + } catch (err) { + console.warn(err) + return StandardResponse.internalServerError() + } +} \ No newline at end of file diff --git a/tests/routes/api.users.me.boxes.spec.ts b/tests/routes/api.users.me.boxes.spec.ts index 75469b25..727225dd 100644 --- a/tests/routes/api.users.me.boxes.spec.ts +++ b/tests/routes/api.users.me.boxes.spec.ts @@ -1,12 +1,13 @@ import { type LoaderFunctionArgs } from 'react-router' import { generateTestUserCredentials } from 'tests/data/generate_test_user' import { BASE_URL } from 'vitest.setup' +import { drizzleClient } from '~/db.server' import { createToken } from '~/lib/jwt' import { registerUser } from '~/lib/user-service.server' import { createDevice, deleteDevice } from '~/models/device.server' import { deleteUserByEmail } from '~/models/user.server' import { loader } from '~/routes/api.users.me.boxes' -import { type User } from '~/schema' +import { integration, type User } from '~/schema' const BOXES_TEST_USER = generateTestUserCredentials() const TEST_BOX = { @@ -17,8 +18,6 @@ const TEST_BOX = { latitude: 0, longitude: 0, model: 'luftdaten.info', - mqttEnabled: false, - ttnEnabled: false, } describe('openSenseMap API Routes: /users', () => { @@ -28,6 +27,25 @@ describe('openSenseMap API Routes: /users', () => { describe('/me/boxes', () => { describe('GET', async () => { beforeAll(async () => { + // seed integrations if they dont exist + await drizzleClient.insert(integration).values([ + { + name: 'MQTT', + slug: 'mqtt', + serviceUrl: 'http://mqtt-test-service', + serviceKey: 'MQTT_SERVICE_KEY', + icon: 'message-square-text', + order: 1, + }, + { + name: 'The Things Network', + slug: 'ttn', + serviceUrl: 'http://ttn-test-service', + serviceKey: 'TTN_SERVICE_KEY', + icon: 'antenna', + order: 2, + }, + ]).onConflictDoNothing() const registration = await registerUser( BOXES_TEST_USER.name, BOXES_TEST_USER.email,