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
24 changes: 17 additions & 7 deletions app/lib/devices-service.server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod'
import {
ArchivedDeviceError,
createDevice as createDeviceInDb,
deleteDevice as deleteDeviceById,
updateDevice as updateDeviceById,
Expand Down Expand Up @@ -244,6 +245,14 @@ export const BoxesQuerySchema = z.object({

export type BoxesQueryParams = z.infer<typeof BoxesQuerySchema>

export function assertDeviceIsWritable(
device: Pick<Device, 'id' | 'archivedAt'>,
): void {
if (device.archivedAt) {
throw new ArchivedDeviceError(device.id)
}
}


/**
* Updates a device after verifying the user is entitled (device owner).
Expand All @@ -254,14 +263,15 @@ export type BoxesQueryParams = z.infer<typeof BoxesQuerySchema>
* @returns The updated device, or "unauthorized" if not entitled
*/
export const updateDevice = async (
userId: User['id'],
device: Device,
args: UpdateDeviceArgs,
): Promise<Device | 'unauthorized'> => {
if (device.userId !== userId) return 'unauthorized'
return updateDeviceById(device.id, args)
}
userId: User['id'],
device: Device,
args: UpdateDeviceArgs,
): Promise<Device | 'unauthorized' | 'archived'> => {
if (device.userId !== userId) return 'unauthorized'
assertDeviceIsWritable(device)

return updateDeviceById(device.id, args)
}
/**
* Deletes a device after verifiying that the user is entitled by checking
* the password.
Expand Down
5 changes: 5 additions & 0 deletions app/lib/measurement-service.server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type BoxesDataColumn } from './api-schemas/boxes-data-query-schema'
import { assertDeviceIsWritable } from './devices-service.server'
import { validLngLat } from './location'
import { decodeMeasurements, hasDecoder } from '~/lib/decoding-service.server'
import {
Expand Down Expand Up @@ -136,6 +137,8 @@ export const postNewMeasurements = async (
throw new Error('NotFoundError: Device not found')
}

assertDeviceIsWritable(device)

if (device.useAuth && !isTrustedService) {
if (device.apiKey !== authorization) {
const error = new Error('Device access token not valid!')
Expand Down Expand Up @@ -183,6 +186,8 @@ export const postSingleMeasurement = async (
throw error
}

assertDeviceIsWritable(device)

const sensor = device.sensors?.find((s: any) => s.id === sensorId)
if (!sensor) {
const error = new Error('Sensor not found on device')
Expand Down
130 changes: 79 additions & 51 deletions app/models/device.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,23 @@ export class DeviceUpdateError extends Error {
}
}

export class ArchivedDeviceError extends Error {
constructor(deviceId?: string) {
super(
deviceId
? `Device ${deviceId} is archived and read-only`
: 'Archived devices are read-only',
)
this.name = 'ArchivedDeviceError'
}
}

export function assertDeviceIsMutable(device: Pick<Device, 'id' | 'archivedAt'>) {
if (device.archivedAt) {
throw new ArchivedDeviceError(device.id)
}
}

export function getDevice({ id }: Pick<Device, 'id'>) {
return drizzleClient.query.device.findFirst({
where: (device, { eq }) => eq(device.id, id),
Expand Down Expand Up @@ -192,14 +209,26 @@ export type DeviceWithoutSensors = Awaited<
ReturnType<typeof getDeviceWithoutSensors>
>

export function updateDeviceLocation({
export async function updateDeviceLocation({
id,
latitude,
longitude,
}: Pick<Device, 'id' | 'latitude' | 'longitude'>) {
const [existingDevice] = await drizzleClient
.select()
.from(device)
.where(eq(device.id, id))
.limit(1)

if (!existingDevice) {
throw new DeviceUpdateError(`Device ${id} not found`, 404)
}

assertDeviceIsMutable(existingDevice)

return drizzleClient
.update(device)
.set({ latitude: latitude, longitude: longitude })
.set({ latitude, longitude, updatedAt: sql`NOW()` })
.where(eq(device.id, id))
}

Expand Down Expand Up @@ -232,48 +261,56 @@ export async function updateDevice(
deviceId: string,
args: UpdateDeviceArgs,
): Promise<Device> {
const setColumns: Record<string, any> = {}
const updatableFields: (keyof UpdateDeviceArgs)[] = [
'name',
'exposure',
'description',
'website',
'image',
'model',
'useAuth',
'link',
]

for (const field of updatableFields) {
if (args[field] !== undefined) {
// Handle empty string -> null for specific fields (backwards compatibility)
if (
(field === 'description' || field === 'link' || field === 'image') &&
args[field] === ''
) {
setColumns[field] = null
} else {
setColumns[field] = args[field]
const result = await drizzleClient.transaction(async (tx) => {
const [existingDevice] = await tx
.select()
.from(device)
.where(eq(device.id, deviceId))
.limit(1)

if (!existingDevice) {
throw new DeviceUpdateError(`Device ${deviceId} not found`, 404)
}

assertDeviceIsMutable(existingDevice)

const setColumns: Record<string, any> = {}
const updatableFields: (keyof UpdateDeviceArgs)[] = [
'name',
'exposure',
'description',
'website',
'image',
'model',
'useAuth',
'link',
]

for (const field of updatableFields) {
if (args[field] !== undefined) {
if (
(field === 'description' || field === 'link' || field === 'image') &&
args[field] === ''
) {
setColumns[field] = null
} else {
setColumns[field] = args[field]
}
}
}
}

if ('grouptag' in args) {
if (Array.isArray(args.grouptag)) {
// Empty array -> null for backwards compatibility
setColumns['tags'] = args.grouptag.length === 0 ? null : args.grouptag
} else if (args.grouptag != null) {
// Empty string -> null
setColumns['tags'] = args.grouptag === '' ? null : [args.grouptag]
} else {
setColumns['tags'] = null
if ('grouptag' in args) {
if (Array.isArray(args.grouptag)) {
setColumns['tags'] = args.grouptag.length === 0 ? null : args.grouptag
} else if (args.grouptag != null) {
setColumns['tags'] = args.grouptag === '' ? null : [args.grouptag]
} else {
setColumns['tags'] = null
}
}
}

const result = await drizzleClient.transaction(async (tx) => {
if (args.location) {
const { lat, lng } = args.location

const pointWKT = `POINT(${lng} ${lat})`

const [existingLocation] = await tx
Expand Down Expand Up @@ -314,23 +351,15 @@ export async function updateDevice(
setColumns['longitude'] = lng
}

let updatedDevice
let updatedDevice = existingDevice

if (Object.keys(setColumns).length > 0) {
;[updatedDevice] = await tx
.update(device)
.set({ ...setColumns, updatedAt: sql`NOW()` })
.where(eq(device.id, deviceId))
.returning()

if (!updatedDevice) {
throw new DeviceUpdateError(`Device ${deviceId} not found`, 404)
}
} else {
;[updatedDevice] = await tx
.select()
.from(device)
.where(eq(device.id, deviceId))

if (!updatedDevice) {
throw new DeviceUpdateError(`Device ${deviceId} not found`, 404)
}
Expand Down Expand Up @@ -359,9 +388,7 @@ export async function updateDevice(
const hasEdited = 'edited' in s
const hasNew = 'new' in s

if (!hasDeleted && !hasEdited && !hasNew) {
continue
}
if (!hasDeleted && !hasEdited && !hasNew) continue

if (hasDeleted) {
if (!s._id) {
Expand Down Expand Up @@ -424,8 +451,9 @@ export async function updateDevice(
}
}

if (args.useAuth === true && !updatedDevice.apiKey)
if (args.useAuth === true && !updatedDevice.apiKey) {
await addOrReplaceDeviceApiKey(updatedDevice, tx)
}

return updatedDevice
})
Expand Down
45 changes: 28 additions & 17 deletions app/models/measurement.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
measurements1hourView,
measurements1monthView,
measurements1yearView,
device
} from '~/schema'
import {
type MinimalDevice,
Expand All @@ -19,6 +20,7 @@
insertMeasurementsWithLocation,
updateLastMeasurements,
} from '~/utils/measurement-server-helper'
import { ArchivedDeviceError } from './device.server'

Check warning on line 23 in app/models/measurement.server.ts

View workflow job for this annotation

GitHub Actions / ⬣ Lint

`./device.server` import should occur before import of `~/db.server`

// This function retrieves measurements from the database based on the provided parameters.
export function getMeasurement(
Expand Down Expand Up @@ -175,16 +177,15 @@
}

export async function saveMeasurements(
device: MinimalDevice,
minimalDevice: MinimalDevice,
measurements: MeasurementWithLocation[],
): Promise<void> {
if (!device) throw new Error('No device given!')
if (!Array.isArray(measurements)) throw new Error('Array expected')

const sensorIds = device.sensors.map((s: any) => s.id)
const sensorIds = minimalDevice.sensors.map((s: any) => s.id)
const lastMeasurements: Record<string, NonNullable<LastMeasurement>> = {}

// Validate and prepare measurements
for (let i = measurements.length - 1; i >= 0; i--) {
const m = measurements[i]

Expand All @@ -197,9 +198,9 @@
}

const now = new Date()
const maxFutureTime = 30 * 1000 // 30 seconds

const maxFutureTime = 30 * 1000
const measurementTime = new Date(m.createdAt || Date.now())

if (measurementTime.getTime() > now.getTime() + maxFutureTime) {
const error = new Error(
`Measurement timestamp is too far in the future: ${measurementTime.toISOString()}`,
Expand All @@ -221,21 +222,31 @@
}
}

// Track measurements that update device location (those with explicit locations)
const deviceLocationUpdates = getLocationUpdates(measurements)
const locations = await findOrCreateLocations(deviceLocationUpdates)

// First, update device locations for all measurements with explicit locations
// This ensures the location history is complete before we infer locations
await addLocationUpdates(deviceLocationUpdates, device.id, locations)

// Note that the insertion of measurements and update of sensors need to be in one
// transaction, since otherwise other updates could get in between and the data would be
// inconsistent. This shouldn't be a problem for the updates above.
await drizzleClient.transaction(async (tx) => {
// Now process each measurement and infer locations if needed
await insertMeasurementsWithLocation(measurements, locations, device.id, tx)
// Update sensor lastMeasurement values
const [currentDevice] = await tx
.select({
id: device.id,
archivedAt: device.archivedAt,
})
.from(device)
.where(eq(device.id, device.id))
.limit(1)

if (!currentDevice) {
const error = new Error('Device not found')
error.name = 'NotFoundError'
throw error
}

if (currentDevice.archivedAt) {
throw new ArchivedDeviceError(currentDevice.id)
}

const locations = await findOrCreateLocations(deviceLocationUpdates)
await addLocationUpdates(deviceLocationUpdates, minimalDevice.id, locations)
await insertMeasurementsWithLocation(measurements, locations, minimalDevice.id, tx)
await updateLastMeasurements(lastMeasurements, tx)
})
}
Expand Down
9 changes: 9 additions & 0 deletions app/routes/api.boxes.$deviceId.$sensorId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ export const action: ActionFunction = async ({
(err.name === 'ModelError' && err.type === 'UnprocessableEntityError')
)
return StandardResponse.unprocessableContent(err.message)

if (err.name === 'ArchivedDeviceError')
return new Response(
JSON.stringify({ message: err.message || 'Archived devices are read-only' }),
{
status: 409,
headers: { 'Content-Type': 'application/json; charset=utf-8' },
},
)

return StandardResponse.internalServerError(
err.message || 'An unexpected error occurred',
Expand Down
9 changes: 9 additions & 0 deletions app/routes/api.boxes.$deviceId.data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,15 @@ export const action: ActionFunction = async ({
if (err.name === 'UnsupportedMediaTypeError')
return StandardResponse.unsupportedMediaType(err.message)

if (err.name === 'ArchivedDeviceError')
return new Response(
JSON.stringify({ message: err.message || 'Archived devices are read-only' }),
{
status: 409,
headers: { 'Content-Type': 'application/json; charset=utf-8' },
},
)

return StandardResponse.internalServerError(
err.message || 'An unexpected error occurred',
)
Expand Down
Loading