diff --git a/app/models/measurement.server.ts b/app/models/measurement.server.ts index 3cca9bc0..26c2eb7c 100644 --- a/app/models/measurement.server.ts +++ b/app/models/measurement.server.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, gte, lte, sql } from 'drizzle-orm' +import { and, desc, eq, gt, gte, inArray, lt, lte, sql } from 'drizzle-orm' import { drizzleClient } from '~/db.server' import { type LastMeasurement, @@ -256,6 +256,32 @@ export async function deleteMeasurementsForSensor(sensorId: string) { .where(eq(measurement.sensorId, sensorId)) } +export async function deleteSensorMeasurementsForTimes( + sensorId: string, + dates: Date[], +) { + return await drizzleClient + .delete(measurement) + .where( + and(eq(measurement.sensorId, sensorId), inArray(measurement.time, dates)), + ) +} + +export async function deleteSensorMeasurementsForTimeRange( + sensorId: string, + greaterThan: Date, + lessThan: Date, +) { + return await drizzleClient + .delete(measurement) + .where( + and( + eq(measurement.sensorId, sensorId), + and(gt(measurement.time, greaterThan), lt(measurement.time, lessThan)), + ), + ) +} + export async function deleteMeasurementsForTime(date: Date) { return await drizzleClient .delete(measurement) diff --git a/app/routes/api.boxes.$deviceId.$sensorId.measurements.ts b/app/routes/api.boxes.$deviceId.$sensorId.measurements.ts new file mode 100644 index 00000000..8be90740 --- /dev/null +++ b/app/routes/api.boxes.$deviceId.$sensorId.measurements.ts @@ -0,0 +1,167 @@ +import { type ActionFunctionArgs } from 'react-router' +import z from 'zod' +import { getUserFromJwt } from '~/lib/jwt' +import { getUserDevices } from '~/models/device.server' +import { + deleteMeasurementsForSensor, + deleteSensorMeasurementsForTimeRange, + deleteSensorMeasurementsForTimes, +} from '~/models/measurement.server' +import { StandardResponse } from '~/utils/response-utils' + +export async function action({ request, params }: ActionFunctionArgs) { + try { + const { deviceId, sensorId } = params + if (!deviceId || !sensorId) + return StandardResponse.badRequest( + 'Invalid device id or sensor id specified', + ) + + const jwtResponse = await getUserFromJwt(request) + + if (typeof jwtResponse === 'string') + return StandardResponse.forbidden( + 'Invalid JWT authorization. Please sign in to obtain new JWT.', + ) + + if (request.method !== 'DELETE') + return StandardResponse.methodNotAllowed('Endpoint only supports DELETE') + + const userDevices = await getUserDevices(jwtResponse.id) + if (!userDevices.some((d) => d.id === deviceId)) + return StandardResponse.forbidden( + 'You are not allowed to delete data of the given device', + ) + + const device = userDevices.find((d) => d.id === deviceId) + if (!device?.sensors.some((s) => s.id === sensorId)) + return StandardResponse.forbidden( + 'You are not allowed to delete data of the given sensor', + ) + + try { + const parsedParams = await parseQueryParams(request) + let count = 0 + + if (parsedParams.deleteAllMeasurements) + count = (await deleteMeasurementsForSensor(sensorId)).count + else if (parsedParams.timestamps) + count = ( + await deleteSensorMeasurementsForTimes( + sensorId, + parsedParams.timestamps, + ) + ).count + else if (parsedParams['from-date'] && parsedParams['to-date']) + count = ( + await deleteSensorMeasurementsForTimeRange( + sensorId, + parsedParams['from-date'], + parsedParams['to-date'], + ) + ).count + + return StandardResponse.ok({ + message: `Successfully deleted ${count} of sensor ${sensorId}`, + }) + } catch (e) { + if (e instanceof Response) return e + else throw e + } + } catch (err: any) { + return StandardResponse.internalServerError( + err.message || 'An unexpected error occured', + ) + } +} + +const DeleteQueryParams = z + .object({ + 'from-date': z + .string() + .transform((s) => new Date(s)) + .refine((d) => !isNaN(d.getTime()), { + message: 'from-date is invalid', + }) + .optional(), + 'to-date': z + .string() + .transform((s) => new Date(s)) + .refine((d) => !isNaN(d.getTime()), { + message: 'to-date is invalid', + }) + .optional(), + timestamps: z + .preprocess((val) => { + if (Array.isArray(val)) return val + else return [val] + }, z.array(z.string())) + .transform((a) => a.map((i) => new Date(i))) + .refine((a) => a.some((i) => !isNaN(i.getTime())), { + message: 'timestamps contains invalid input', + }) + .optional(), + deleteAllMeasurements: z.coerce.boolean().optional(), + }) + .superRefine((data, ctx) => { + const fromDateSet = data['from-date'] !== undefined + const toDateSet = data['to-date'] !== undefined + const timestampsSet = data.timestamps !== undefined + const deleteAllSet = data.deleteAllMeasurements !== undefined + + if (deleteAllSet && (timestampsSet || fromDateSet || toDateSet)) { + const paths: string[] = [] + if (timestampsSet) paths.push('timestamps') + if (fromDateSet) paths.push('from-date') + if (toDateSet) paths.push('to-date') + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Parameter deleteAllMeasurements can only be used by itself', + path: paths, + }) + } else if (!deleteAllSet && timestampsSet && fromDateSet && toDateSet) + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'Please specify only timestamps or a range with from-date and to-date', + path: ['timestamps', 'from-date', 'to-date'], + }) + else if (!deleteAllSet && !timestampsSet && !fromDateSet && !toDateSet) + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'Please specify only timestamps or a range with from-date and to-date', + path: ['timestamps', 'from-date', 'to-date'], + }) + }) + +const parseQueryParams = async ( + request: Request, +): Promise> => { + const url = new URL(request.url) + let params: Record + if (request.method !== 'GET') { + const contentType = request.headers.get('content-type') || '' + if (contentType.includes('application/json')) { + try { + params = await request.json() + } catch { + params = Object.fromEntries(url.searchParams) + } + } else { + params = Object.fromEntries(url.searchParams) + } + } else { + params = Object.fromEntries(url.searchParams) + } + + const parseResult = DeleteQueryParams.safeParse(params) + + if (!parseResult.success) { + const firstError = parseResult.error.errors[0] + const message = firstError.message || 'Invalid query parameters' + throw StandardResponse.badRequest(message) + } + + return parseResult.data +} diff --git a/app/routes/api.boxes.$deviceId.sensors.$sensorId.ts b/app/routes/api.boxes.$deviceId.sensors.$sensorId.ts index 89b38d09..b6fcf6a6 100644 --- a/app/routes/api.boxes.$deviceId.sensors.$sensorId.ts +++ b/app/routes/api.boxes.$deviceId.sensors.$sensorId.ts @@ -1,37 +1,44 @@ -import { type LoaderFunction, type LoaderFunctionArgs } from "react-router"; -import { getLatestMeasurementsForSensor } from "~/lib/measurement-service.server"; -import { StandardResponse } from "~/utils/response-utils"; +import { type LoaderFunction, type LoaderFunctionArgs } from 'react-router' +import { getLatestMeasurementsForSensor } from '~/lib/measurement-service.server' +import { StandardResponse } from '~/utils/response-utils' export const loader: LoaderFunction = async ({ - request, - params, + request, + params, }: LoaderFunctionArgs): Promise => { - try { - const deviceId = params.deviceId; - if (deviceId === undefined) - return StandardResponse.badRequest("Invalid device id specified"); + try { + const deviceId = params.deviceId + if (deviceId === undefined) + return StandardResponse.badRequest('Invalid device id specified') - const sensorId = params.sensorId; - if (sensorId === undefined) - return StandardResponse.badRequest("Invalid sensor id specified"); + const sensorId = params.sensorId + if (sensorId === undefined) + return StandardResponse.badRequest('Invalid sensor id specified') - const searchParams = new URL(request.url).searchParams; - const onlyValue = - (searchParams.get("onlyValue")?.toLowerCase() ?? "") === "true"; - if (sensorId === undefined && onlyValue) - return StandardResponse.badRequest("onlyValue can only be used when a sensor id is specified"); + const searchParams = new URL(request.url).searchParams + const onlyValue = + (searchParams.get('onlyValue')?.toLowerCase() ?? '') === 'true' + if (sensorId === undefined && onlyValue) + return StandardResponse.badRequest( + 'onlyValue can only be used when a sensor id is specified', + ) - const meas = await getLatestMeasurementsForSensor(deviceId, sensorId, undefined); + const meas = await getLatestMeasurementsForSensor( + deviceId, + sensorId, + undefined, + ) - if (meas == null) - return StandardResponse.notFound("Device not found."); + if (meas == null) return StandardResponse.notFound('Device not found.') - if (onlyValue) - return StandardResponse.ok(meas["lastMeasurement"]?.value ?? null); + if (onlyValue) + return StandardResponse.ok(meas['lastMeasurement']?.value ?? null) - return StandardResponse.ok({ ...meas, _id: meas.id } /* for legacy purposes */); - } catch (err) { - console.warn(err); - return StandardResponse.internalServerError(); - } -}; + return StandardResponse.ok( + { ...meas, _id: meas.id } /* for legacy purposes */, + ) + } catch (err) { + console.warn(err) + return StandardResponse.internalServerError() + } +} diff --git a/app/routes/api.boxes.$deviceId.sensors.ts b/app/routes/api.boxes.$deviceId.sensors.ts index 6fd11237..fade97c9 100644 --- a/app/routes/api.boxes.$deviceId.sensors.ts +++ b/app/routes/api.boxes.$deviceId.sensors.ts @@ -1,6 +1,6 @@ -import { type LoaderFunction, type LoaderFunctionArgs } from "react-router"; -import { getLatestMeasurements } from "~/lib/measurement-service.server"; -import { StandardResponse } from "~/utils/response-utils"; +import { type LoaderFunction, type LoaderFunctionArgs } from 'react-router' +import { getLatestMeasurements } from '~/lib/measurement-service.server' +import { StandardResponse } from '~/utils/response-utils' /** * @openapi @@ -31,28 +31,30 @@ import { StandardResponse } from "~/utils/response-utils"; */ export const loader: LoaderFunction = async ({ - request, - params, + request, + params, }: LoaderFunctionArgs): Promise => { - try { - const deviceId = params.deviceId; - if (deviceId === undefined) - return StandardResponse.badRequest("Invalid device id specified"); + try { + const deviceId = params.deviceId + if (deviceId === undefined) + return StandardResponse.badRequest('Invalid device id specified') - const url = new URL(request.url); - const countParam = url.searchParams.get("count"); + const url = new URL(request.url) + const countParam = url.searchParams.get('count') - let count: undefined | number = undefined; - if (countParam !== null && Number.isNaN(countParam)) - return StandardResponse.badRequest("Illegal value for parameter count. allowed values: numbers"); + let count: undefined | number = undefined + if (countParam !== null && Number.isNaN(countParam)) + return StandardResponse.badRequest( + 'Illegal value for parameter count. allowed values: numbers', + ) - count = countParam === null ? undefined : Number(countParam); + count = countParam === null ? undefined : Number(countParam) - const meas = await getLatestMeasurements(deviceId, count); + const meas = await getLatestMeasurements(deviceId, count) - return StandardResponse.ok(meas); - } catch (err) { - console.warn(err); - return StandardResponse.internalServerError(); - } -}; + return StandardResponse.ok(meas) + } catch (err) { + console.warn(err) + return StandardResponse.internalServerError() + } +} diff --git a/app/routes/api.ts b/app/routes/api.ts index a25dbd3b..00ddc17f 100644 --- a/app/routes/api.ts +++ b/app/routes/api.ts @@ -1,6 +1,10 @@ import { type LoaderFunctionArgs } from 'react-router' -type RouteInfo = { path: string; method: 'GET' | 'PUT' | 'POST' | 'DELETE' } +type RouteInfo = { + path: string + method: 'GET' | 'PUT' | 'POST' | 'DELETE' + deprecationNotice?: string +} const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = { noauth: [ @@ -34,7 +38,6 @@ const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = { path: `boxes/data`, method: 'GET', }, - // { // path: `boxes/:boxId`, // method: "GET", @@ -145,10 +148,10 @@ const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = { path: `boxes/:boxId`, method: 'DELETE', }, - // { - // path: `boxes/:boxId/:sensorId/measurements`, - // method: "DELETE", - // }, + { + path: `boxes/:boxId/:sensorId/measurements`, + method: 'DELETE', + }, { path: `users/sign-out`, method: 'POST', @@ -213,7 +216,10 @@ export async function loader({}: LoaderFunctionArgs) { lines.push('\nRoutes requiring valid authentication through JWT:') - for (const r of routes.auth) lines.push(`${r.method}\t${r.path}`) + for (const r of routes.auth) + lines.push( + `${r.method}\t${r.path}\t${r.deprecationNotice ? 'DEPRECATED: ' + r.deprecationNotice : ''}`, + ) return new Response(lines.join('\n'), { status: 200, diff --git a/tests/routes/api.boxes.$deviceId.$sensorId.measurement.spec.ts b/tests/routes/api.boxes.$deviceId.$sensorId.measurement.spec.ts new file mode 100644 index 00000000..ba26d405 --- /dev/null +++ b/tests/routes/api.boxes.$deviceId.$sensorId.measurement.spec.ts @@ -0,0 +1,148 @@ +import { ActionFunctionArgs, Params } from 'react-router' +import { generateTestUserCredentials } from 'tests/data/generate_test_user' +import { BASE_URL } from 'vitest.setup' +import { createToken } from '~/lib/jwt' +import { registerUser } from '~/lib/user-service.server' +import { createDevice, deleteDevice } from '~/models/device.server' +import { insertMeasurements } from '~/models/measurement.server' +import { getSensors } from '~/models/sensor.server' +import { deleteUserByEmail } from '~/models/user.server' +import { action } from '~/routes/api.boxes.$deviceId.$sensorId.measurements' +import { type User } from '~/schema' + +const USER = generateTestUserCredentials() +const DEVICE = { + name: `${USER.name}s Box`, + exposure: 'outdoor', + expiresAt: null, + tags: [], + latitude: 0, + longitude: 0, + //model: 'luftdaten.info', + mqttEnabled: false, + ttnEnabled: false, + sensors: [ + { + title: 'Temp', + unit: '°C', + sensorType: 'dummy', + }, + ], +} + +const MEASUREMENTS = [ + { + value: 1589625, + createdAt: new Date('1954-06-07 12:00:00+00'), + sensor_id: '', + }, + { + value: 3.14159, + createdAt: new Date('1988-03-14 1:59:26+00'), + sensor_id: '', + }, + { + value: 6.62607, + createdAt: new Date('2026-01-01 4:26:57+00'), + sensor_id: '', + }, +] + +describe('openSenseMap API Routes: /boxes/:deviceId/:sensorId/measurement', () => { + let deviceId: string + let sensorId: string + let jwt: string + + beforeAll(async () => { + const u = await registerUser(USER.name, USER.email, USER.password, 'en_US') + const d = await createDevice(DEVICE, (u as User).id) + const s = await getSensors(d.id) + MEASUREMENTS.forEach((m) => (m.sensor_id = s[0].id)) + await insertMeasurements(MEASUREMENTS) + + deviceId = d.id + sensorId = s[0].id + + const t = await createToken(u as User) + jwt = t.token + }) + + describe('DELETE', () => { + it('should remove measurements by date range (from-date, to-date)', async () => { + // Arrange + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/${sensorId}?from-date=${new Date('1954-01-01 00:00:00+00')}&to-date=${new Date('1954-12-31 23:59:59+00')}`, + { method: 'DELETE', headers: { Authorization: `Bearer ${jwt}` } }, + ) + + // Act + const dataFunctionValue = await action({ + request, + params: { + deviceId: `${deviceId}`, + sensorId: `${sensorId}`, + } as Params, + } as ActionFunctionArgs) + const response = dataFunctionValue as Response + const body = await response?.json() + + // Assert + expect(response.status).toBe(200) + expect(body).toHaveProperty('message') + expect(body.message).toBe(`Successfully deleted 1 of sensor ${sensorId}`) + }) + + it('should remove measurements by exact timestamps', async () => { + // Arrange + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/${sensorId}?timestamps=${MEASUREMENTS[1].createdAt.toISOString()}`, + { method: 'DELETE', headers: { Authorization: `Bearer ${jwt}` } }, + ) + + // Act + const dataFunctionValue = await action({ + request, + params: { + deviceId: `${deviceId}`, + sensorId: `${sensorId}`, + } as Params, + } as ActionFunctionArgs) + const response = dataFunctionValue as Response + const body = await response?.json() + + // Assert + expect(response.status).toBe(200) + expect(body).toHaveProperty('message') + expect(body.message).toBe(`Successfully deleted 1 of sensor ${sensorId}`) + }) + + it('should remove all measurements when deleteAll is used', async () => { + // Arrange + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/${sensorId}?deleteAllMeasurements=true`, + { method: 'DELETE', headers: { Authorization: `Bearer ${jwt}` } }, + ) + + // Act + const dataFunctionValue = await action({ + request, + params: { + deviceId: `${deviceId}`, + sensorId: `${sensorId}`, + } as Params, + } as ActionFunctionArgs) + const response = dataFunctionValue as Response + const body = await response?.json() + + // Assert + expect(response.status).toBe(200) + expect(body).toHaveProperty('message') + expect(body.message).toBe(`Successfully deleted 1 of sensor ${sensorId}`) + }) + }) + + afterAll(async () => { + await deleteUserByEmail(USER.email) + await deleteDevice({ id: deviceId }) + }) +})