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
112 changes: 112 additions & 0 deletions app/models/integration.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DeviceIntegrationSummary> {
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<T extends { _id: string }>(
devices: T[],
): Promise<Array<T & { integrations: DeviceIntegrationSummary }>> {
return Promise.all(
devices.map(async (device) => ({
...device,
integrations: await getDeviceIntegrations(device._id),
})),
)
}
48 changes: 26 additions & 22 deletions app/routes/api.users.me.boxes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
24 changes: 21 additions & 3 deletions tests/routes/api.users.me.boxes.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -17,8 +18,6 @@ const TEST_BOX = {
latitude: 0,
longitude: 0,
model: 'luftdaten.info',
mqttEnabled: false,
ttnEnabled: false,
}

describe('openSenseMap API Routes: /users', () => {
Expand All @@ -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,
Expand Down
Loading