diff --git a/app/lib/decoding-service.server.ts b/app/lib/decoding-service.server.ts new file mode 100644 index 00000000..61b921af --- /dev/null +++ b/app/lib/decoding-service.server.ts @@ -0,0 +1,355 @@ +function parseLocation( + loc: any, +): { lng: number; lat: number; height?: number } | null { + if (!loc) return null + + if (Array.isArray(loc)) { + if (loc.length >= 2) { + const lng = Number(loc[0]) + const lat = Number(loc[1]) + + if (isNaN(lng) || isNaN(lat)) { + console.warn('Invalid coordinates in array format:', loc) + return null + } + + return { + lng, + lat, + height: loc[2] !== undefined ? Number(loc[2]) : undefined, + } + } + return null + } + + if (typeof loc === 'object' && loc !== null) { + if (loc.lng !== undefined && loc.lat !== undefined) { + const lng = Number(loc.lng) + const lat = Number(loc.lat) + + if (isNaN(lng) || isNaN(lat)) { + console.warn('Invalid coordinates in object format:', loc) + return null + } + + return { + lng, + lat, + height: loc.height !== undefined ? Number(loc.height) : undefined, + } + } + + if (loc.longitude !== undefined && loc.latitude !== undefined) { + const lng = Number(loc.longitude) + const lat = Number(loc.latitude) + + if (isNaN(lng) || isNaN(lat)) { + console.warn('Invalid coordinates in longitude/latitude format:', loc) + return null + } + + return { + lng, + lat, + height: loc.height !== undefined ? Number(loc.height) : undefined, + } + } + } + + console.warn('Unrecognized location format:', loc) + return null +} + +const decodeHandlers: { + [key: string]: { decodeMessage: (data: any, options: any) => any[] } +} = { + 'application/json': { + decodeMessage: (body: any, { sensors }: { sensors: any[] }) => { + if (Array.isArray(body)) { + return body.map((measurement) => ({ + sensor_id: measurement.sensor, + value: parseFloat(measurement.value), + createdAt: measurement.createdAt + ? new Date(measurement.createdAt) + : new Date(), + location: parseLocation(measurement.location), + })) + } else { + return Object.entries(body).map(([sensorId, value]: [string, any]) => { + let measurementValue, createdAt, location + + if (Array.isArray(value)) { + measurementValue = parseFloat(value[0]) + createdAt = value[1] ? new Date(value[1]) : new Date() + location = parseLocation(value[2]) + } else { + measurementValue = parseFloat(value) + createdAt = new Date() + location = null + } + + return { + sensor_id: sensorId, + value: measurementValue, + createdAt, + location, + } + }) + } + }, + }, + + 'text/csv': { + decodeMessage: (body: string, { sensors }: { sensors: any[] }) => { + const lines = body.trim().split('\n') + return lines.map((line) => { + const parts = line.split(',').map((part) => part.trim()) + const sensorId = parts[0] + const value = parseFloat(parts[1]) + const createdAt = parts[2] ? new Date(parts[2]) : new Date() + + let location = null + if (parts[3] && parts[4]) { + location = parseLocation({ + lng: parseFloat(parts[3]), + lat: parseFloat(parts[4]), + height: parts[5] ? parseFloat(parts[5]) : undefined, + }) + } + + return { + sensor_id: sensorId, + value, + createdAt, + location, + } + }) + }, + }, + + luftdaten: { + decodeMessage: (body: any, { sensors }: { sensors: any[] }) => { + const sensorMappings: { [key: string]: string } = { + SDS_P1: 'PM10', + SDS_P2: 'PM2.5', + } + + return body.sensordatavalues.map((item: any) => { + const mappedTitle = sensorMappings[item.value_type] + const sensor = sensors.find((s) => s.title === mappedTitle) + + if (!sensor) { + throw new Error(`No sensor found for value_type: ${item.value_type}`) + } + + return { + sensor_id: sensor.id, + value: parseFloat(item.value), + createdAt: new Date(), + location: null, + } + }) + }, + }, + + hackair: { + decodeMessage: (body: any, { sensors }: { sensors: any[] }) => { + const sensorMappings: { [key: string]: string } = { + 'PM2.5_AirPollutantValue': 'PM2.5', + PM10_AirPollutantValue: 'PM10', + } + + return Object.entries(body.reading) + .map(([key, value]: [string, any]) => { + const mappedTitle = sensorMappings[key] + if (!mappedTitle) return null + + const sensor = sensors.find((s) => s.title === mappedTitle) + if (!sensor) { + throw new Error(`No sensor found for sensor_description: ${key}`) + } + + return { + sensor_id: sensor.id, + value: parseFloat(value), + createdAt: new Date(), + location: null, + } + }) + .filter(Boolean) + }, + }, + + 'application/sbx-bytes': { + decodeMessage: (body: ArrayBuffer, { sensors }: { sensors: any[] }) => { + const DATA_LENGTH_NO_TIMESTAMP = 16 // 12 bytes sensorId + 4 bytes float32 + const bytes = new Uint8Array(body) + const measurements = [] + + if (bytes.length % DATA_LENGTH_NO_TIMESTAMP !== 0) { + throw new Error('Invalid data length for sbx-bytes format') + } + + const measurementCount = bytes.length / DATA_LENGTH_NO_TIMESTAMP + if (measurementCount > 2500) { + throw new Error( + 'Too many measurements. Please submit at most 2500 measurements at once.', + ) + } + + if (measurementCount === 0) { + throw new Error('Cannot save empty measurements.') + } + + for ( + let first = 0; + first < bytes.length; + first += DATA_LENGTH_NO_TIMESTAMP + ) { + const measurement = extractMeasurement(bytes, first, sensors, false) + if (measurement) { + measurements.push(measurement) + } + } + + return measurements + }, + }, + + 'application/sbx-bytes-ts': { + decodeMessage: (body: ArrayBuffer, { sensors }: { sensors: any[] }) => { + const DATA_LENGTH_WITH_TIMESTAMP = 20 // 12 bytes sensorId + 4 bytes float32 + 4 bytes timestamp + const bytes = new Uint8Array(body) + const measurements = [] + + if (bytes.length % DATA_LENGTH_WITH_TIMESTAMP !== 0) { + throw new Error('Invalid data length for sbx-bytes-ts format') + } + + const measurementCount = bytes.length / DATA_LENGTH_WITH_TIMESTAMP + if (measurementCount > 2500) { + throw new Error( + 'Too many measurements. Please submit at most 2500 measurements at once.', + ) + } + + if (measurementCount === 0) { + throw new Error('Cannot save empty measurements.') + } + + for ( + let first = 0; + first < bytes.length; + first += DATA_LENGTH_WITH_TIMESTAMP + ) { + const measurement = extractMeasurement(bytes, first, sensors, true) + if (measurement) { + measurements.push(measurement) + } + } + + return measurements + }, + }, +} + +export function hasDecoder(contentType: string): boolean { + return ( + Object.prototype.hasOwnProperty.call(decodeHandlers, contentType) || + contentType.includes('application/json') || + contentType.includes('text/csv') || + contentType.includes('application/sbx-bytes') || + contentType.includes('text/plain;charset=UTF-8') + ) +} + +function normalizeContentType(contentType: string): string { + const normalized = contentType.toLowerCase().split(';')[0].trim() + + if (normalized.includes('json')) return 'application/json' + + if (normalized.includes('csv')) return 'text/csv' + + if (normalized === 'application/sbx-bytes-ts') + return 'application/sbx-bytes-ts' + + if (normalized === 'application/sbx-bytes') return 'application/sbx-bytes' + + return normalized +} + +export async function decodeMeasurements( + measurements: any, + + options: { contentType: string; sensors: any[] }, +): Promise { + try { + const normalizedContentType = normalizeContentType(options.contentType) + + const handler = decodeHandlers[normalizedContentType] + + if (!handler) { + throw new Error( + `No decoder found for content-type: ${options.contentType}`, + ) + } + + return handler.decodeMessage(measurements, { sensors: options.sensors }) + } catch (err: any) { + const error = new Error(err.message) + + error.name = 'ModelError' + ;(error as any).type = 'UnprocessableEntityError' + + throw error + } +} + +function extractMeasurement( + bytes: Uint8Array, + offset: number, + sensors: any[], + withTimestamp: boolean, + ): any | null { + const view = new DataView(bytes.buffer, bytes.byteOffset + offset); + + // Extract sensor ID (first 12 bytes as hex string) + const sensorIdBytes = bytes.slice(offset, offset + 12); + const sensorId = Array.from(sensorIdBytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + + const matchingSensor = sensors.find((s) => + s.id.toLowerCase() === sensorId.toLowerCase() + ); + + if (!matchingSensor) { + console.warn(`No matching sensor found for ID: ${sensorId}`); + return null; + } + + // Extract value (4 bytes float32, little endian) + const value = view.getFloat32(12, true); + + // Extract timestamp if present + let createdAt = new Date(); + if (withTimestamp) { + const timestampSeconds = view.getUint32(16, true); + createdAt = new Date(timestampSeconds * 1000); + + const now = new Date(); + const maxFutureTime = 5 * 60 * 1000; + if (createdAt.getTime() > now.getTime() + maxFutureTime) { + throw new Error( + `Timestamp ${createdAt.toISOString()} is too far into the future.`, + ); + } + } + + return { + sensor_id: matchingSensor.id, + value: value, + createdAt: createdAt, + location: null, + }; + } diff --git a/app/lib/measurement-service.server.ts b/app/lib/measurement-service.server.ts index c0937a07..b8aaac97 100644 --- a/app/lib/measurement-service.server.ts +++ b/app/lib/measurement-service.server.ts @@ -1,4 +1,6 @@ -import { getDeviceWithoutSensors } from "~/models/device.server"; +import { decodeMeasurements, hasDecoder } from "~/lib/decoding-service.server"; +import { getDeviceWithoutSensors, getDevice, findAccessToken } from "~/models/device.server"; +import { saveMeasurements } from "~/models/measurement.server"; import { getSensorsWithLastMeasurement } from "~/models/sensor.server"; /** @@ -25,3 +27,170 @@ export const getLatestMeasurements = async ( (device as any).sensors = sensorsWithMeasurements; return device; }; + +interface PostMeasurementsOptions { + contentType: string; + luftdaten: boolean; + hackair: boolean; + authorization?: string | null; +} + +interface SingleMeasurementBody { + value: number; + createdAt?: string; + location?: [number, number, number] | { lat: number; lng: number; height?: number }; +} + +interface LocationData { + lng: number; + lat: number; + height?: number; +} + +const normalizeLocation = (location: SingleMeasurementBody['location']): LocationData | null => { + if (!location) return null; + + if (Array.isArray(location)) { + if (location.length < 2) return null; + return { + lng: location[0], + lat: location[1], + height: location[2], + }; + } + + if (typeof location === 'object' && 'lat' in location && 'lng' in location) { + return { + lng: location.lng, + lat: location.lat, + height: location.height, + }; + } + + return null; +}; + +const validateLocationCoordinates = (loc: LocationData): boolean => { + return loc.lng >= -180 && loc.lng <= 180 && + loc.lat >= -90 && loc.lat <= 90; +}; + +export const postNewMeasurements = async ( + deviceId: string, + body: any, + options: PostMeasurementsOptions, +): Promise => { + const { luftdaten, hackair, authorization } = options; + let { contentType } = options; + + if (hackair) { + contentType = "hackair"; + } else if (luftdaten) { + contentType = "luftdaten"; + } + + if (!hasDecoder(contentType)) { + throw new Error("UnsupportedMediaTypeError: Unsupported content-type."); + } + + const device = await getDevice({id: deviceId}); + if (!device) { + throw new Error("NotFoundError: Device not found"); + } + + if (device.useAuth) { + const deviceAccessToken = await findAccessToken(deviceId); + + if (deviceAccessToken?.token && deviceAccessToken.token !== authorization) { + const error = new Error("Device access token not valid!"); + error.name = "UnauthorizedError"; + throw error; + } + } + + const measurements = await decodeMeasurements(body, { + contentType, + sensors: device.sensors, + }); + + await saveMeasurements(device, measurements); +}; + +export const postSingleMeasurement = async ( + deviceId: string, + sensorId: string, + body: SingleMeasurementBody, + authorization?: string | null, +): Promise => { + try { + if (typeof body.value !== 'number' || isNaN(body.value)) { + const error = new Error("Invalid measurement value"); + error.name = "UnprocessableEntityError"; + throw error; + } + + const device = await getDevice({ id: deviceId }); + + if (!device) { + const error = new Error("Device not found"); + error.name = "NotFoundError"; + throw error; + } + + const sensor = device.sensors?.find((s: any) => s.id === sensorId); + if (!sensor) { + const error = new Error("Sensor not found on device"); + error.name = "NotFoundError"; + throw error; + } + + if (device.useAuth) { + const deviceAccessToken = await findAccessToken(deviceId); + + if (deviceAccessToken?.token && deviceAccessToken.token !== authorization) { + const error = new Error("Device access token not valid!"); + error.name = "UnauthorizedError"; + throw error; + } + } + + let timestamp: Date | undefined; + if (body.createdAt) { + timestamp = new Date(body.createdAt); + + if (isNaN(timestamp.getTime())) { + const error = new Error("Invalid timestamp format"); + error.name = "UnprocessableEntityError"; + throw error; + } + } + + let locationData: LocationData | null = null; + if (body.location) { + locationData = normalizeLocation(body.location); + + if (locationData && !validateLocationCoordinates(locationData)) { + const error = new Error("Invalid location coordinates"); + error.name = "UnprocessableEntityError"; + throw error; + } + } + + const measurements = [{ + sensor_id: sensorId, + value: body.value, + createdAt: timestamp, + location: locationData, + }]; + + await saveMeasurements(device, measurements); + } catch (error) { + if (error instanceof Error && + ['UnauthorizedError', 'NotFoundError', 'UnprocessableEntityError'].includes(error.name)) { + throw error; + } + + console.error('Error in postSingleMeasurement:', error); + throw error; + } +}; \ No newline at end of file diff --git a/app/models/device.server.ts b/app/models/device.server.ts index d01db3f3..588919f3 100644 --- a/app/models/device.server.ts +++ b/app/models/device.server.ts @@ -3,6 +3,7 @@ import { eq, sql, desc, ilike, arrayContains, and } from 'drizzle-orm' import { type Point } from 'geojson' import { drizzleClient } from '~/db.server' import { device, location, sensor, type Device, type Sensor } from '~/schema' +import { accessToken } from '~/schema/accessToken' const BASE_DEVICE_COLUMNS = { id: true, @@ -19,6 +20,7 @@ const BASE_DEVICE_COLUMNS = { createdAt: true, updatedAt: true, expiresAt: true, + useAuth: true, sensorWikiModel: true, } as const; @@ -71,6 +73,7 @@ export function getDevice({ id }: Pick) { }, // limit: 1000, }, + sensors: true }, }) } @@ -453,3 +456,13 @@ export async function getLatestDevices() { return devices } + +export async function findAccessToken(deviceId: string): Promise<{ token: string } | null> { + const result = await drizzleClient.query.accessToken.findFirst({ + where: (token, { eq }) => eq(token.deviceId, deviceId) + }); + + if (!result || !result.token) return null; + + return { token: result.token }; + } diff --git a/app/models/measurement.server.ts b/app/models/measurement.server.ts index 7bfeb556..0b707b17 100644 --- a/app/models/measurement.server.ts +++ b/app/models/measurement.server.ts @@ -1,6 +1,7 @@ import { and, desc, eq, gte, lte, sql } from "drizzle-orm"; import { drizzleClient } from "~/db.server"; import { + deviceToLocation, location, measurement, measurements10minView, @@ -8,6 +9,7 @@ import { measurements1hourView, measurements1monthView, measurements1yearView, + sensor, } from "~/schema"; // This function retrieves measurements from the database based on the provided parameters. @@ -163,3 +165,246 @@ export function getMeasurement( limit: 3600, // 60 measurements per hour * 24 hours * 2.5 days }); } + +interface MeasurementWithLocation { + sensor_id: string; + value: number; + createdAt?: Date; + location?: { + lng: number; + lat: number; + height?: number; + } | null; +} + +interface MeasurementWithLocation { + sensor_id: string; + value: number; + createdAt?: Date; + location?: { + lng: number; + lat: number; + height?: number; + } | null; +} + +/** + * Get the device location that was valid at a specific timestamp + * Returns the most recent location that was set before or at the given timestamp + */ +async function getDeviceLocationAtTime( + tx: any, + deviceId: string, + timestamp: Date +): Promise { + const locationAtTime = await tx + .select({ + locationId: deviceToLocation.locationId, + }) + .from(deviceToLocation) + .where( + and( + eq(deviceToLocation.deviceId, deviceId), + lte(deviceToLocation.time, timestamp) + ) + ) + .orderBy(desc(deviceToLocation.time)) + .limit(1); + + return locationAtTime.length > 0 ? locationAtTime[0].locationId : null; +} + +async function findOrCreateLocation( + tx: any, + lng: number, + lat: number +): Promise { + const existingLocation = await tx + .select({ id: location.id }) + .from(location) + .where( + sql`ST_Equals( + ${location.location}, + ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326) + )` + ) + .limit(1); + + + if (existingLocation.length > 0) { + return existingLocation[0].id; + } + + const [newLocation] = await tx + .insert(location) + .values({ + location: sql`ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)`, + }) + .returning(); + + return newLocation.id; +} + + +export async function saveMeasurements( + device: any, + measurements: MeasurementWithLocation[] +): Promise { + if (!Array.isArray(measurements)) throw new Error("Array expected"); + + const sensorIds = device.sensors.map((s: any) => s.id); + const lastMeasurements: Record = {}; + + // Track measurements that update device location (those with explicit locations) + const deviceLocationUpdates: Array<{ + location: { lng: number; lat: number; height?: number }; + time: Date; + }> = []; + + // Validate and prepare measurements + for (let i = measurements.length - 1; i >= 0; i--) { + const m = measurements[i]; + + if (!sensorIds.includes(m.sensor_id)) { + const error = new Error( + `Measurement for sensor with id ${m.sensor_id} does not belong to box` + ); + error.name = "ModelError"; + throw error; + } + + const now = new Date(); + const maxFutureTime = 30 * 1000; // 30 seconds + + 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()}` + ); + error.name = "ModelError"; + (error as any).type = "UnprocessableEntityError"; + throw error; + } + + if (!lastMeasurements[m.sensor_id]) { + lastMeasurements[m.sensor_id] = { + value: m.value, + createdAt: measurementTime.toISOString(), + sensorId: m.sensor_id, + }; + } + + // Track measurements with explicit locations for device location updates + if (m.location) { + deviceLocationUpdates.push({ + location: m.location, + time: measurementTime, + }); + } + } + + // Sort device location updates by time (oldest first) to process in order + deviceLocationUpdates.sort((a, b) => a.time.getTime() - b.time.getTime()); + + await drizzleClient.transaction(async (tx) => { + // First, update device locations for all measurements with explicit locations + // This ensures the location history is complete before we infer locations + for (const update of deviceLocationUpdates) { + const locationId = await findOrCreateLocation( + tx, + update.location.lng, + update.location.lat + ); + + // Check if we should add this to device location history + // Only add if it's newer than the current latest location + const currentLatestLocation = await tx + .select({ time: deviceToLocation.time }) + .from(deviceToLocation) + .where(eq(deviceToLocation.deviceId, device.id)) + .orderBy(desc(deviceToLocation.time)) + .limit(1); + + const shouldAdd = + currentLatestLocation.length === 0 || + update.time >= currentLatestLocation[0].time; + + if (shouldAdd) { + await tx + .insert(deviceToLocation) + .values({ + deviceId: device.id, + locationId: locationId, + time: update.time, + }) + .onConflictDoNothing(); + } + } + + // Now process each measurement and infer locations if needed + for (const m of measurements) { + const measurementTime = m.createdAt || new Date(); + let locationId: bigint | null = null; + + if (m.location) { + // Measurement has explicit location + locationId = await findOrCreateLocation( + tx, + m.location.lng, + m.location.lat + ); + } else { + // No explicit location - infer from device location history + locationId = await getDeviceLocationAtTime( + tx, + device.id, + measurementTime + ); + } + + // Insert measurement with locationId (may be null for measurements + // without location and before any device location was set) + await tx.insert(measurement).values({ + sensorId: m.sensor_id, + value: m.value, + time: measurementTime, + locationId: locationId, + }).onConflictDoNothing(); + + } + + // Update sensor lastMeasurement values + const updatePromises = Object.entries(lastMeasurements).map( + ([sensorId, lastMeasurement]) => + tx + .update(sensor) + .set({ lastMeasurement }) + .where(eq(sensor.id, sensorId)) + ); + + + await Promise.all(updatePromises); + + }); +} + +async function insertMeasurements(measurements: any[]): Promise { + const measurementInserts = measurements.map(measurement => ({ + sensorId: measurement.sensor_id, + value: measurement.value, + time: measurement.createdAt || new Date(), + })); + + + + await drizzleClient.insert(measurement).values(measurementInserts); +} + +async function insertMeasurement(measurement: any): Promise { + return drizzleClient.insert(measurement).values({ + sensorId: measurement.sensor_id, + value: measurement.value, + time: measurement.createdAt + }); +} + diff --git a/app/routes/api.boxes.$deviceId.$sensorId.ts b/app/routes/api.boxes.$deviceId.$sensorId.ts new file mode 100644 index 00000000..88da5c54 --- /dev/null +++ b/app/routes/api.boxes.$deviceId.$sensorId.ts @@ -0,0 +1,128 @@ +import { type ActionFunction, type ActionFunctionArgs } from "react-router"; +import { postSingleMeasurement } from "~/lib/measurement-service.server"; + +export const action: ActionFunction = async ({ + request, + params, +}: ActionFunctionArgs): Promise => { + try { + const { deviceId, sensorId } = params; + + if (!deviceId || !sensorId) { + return Response.json( + { + code: "Bad Request", + message: "Invalid device id or sensor id specified", + }, + { + status: 400, + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + }, + ); + } + + const authorization = request.headers.get("authorization"); + const contentType = request.headers.get("content-type") || ""; + + if (!contentType.includes("application/json")) { + return Response.json( + { + code: "Unsupported Media Type", + message: "Content-Type must be application/json", + }, + { + status: 415, + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + }, + ); + } + + const body = await request.json(); + + await postSingleMeasurement(deviceId, sensorId, body, authorization); + + return new Response("Measurement saved in box", { + status: 201, + headers: { + "Content-Type": "text/plain; charset=utf-8", + }, + }); + } catch (err: any) { + if (err.name === "UnauthorizedError") { + return Response.json( + { + code: "Unauthorized", + message: err.message, + }, + { + status: 401, + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + }, + ); + } + + if (err.name === "NotFoundError") { + return Response.json( + { + code: "Not Found", + message: err.message, + }, + { + status: 404, + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + }, + ); + } + + if (err.name === "UnprocessableEntityError" || err.type === "UnprocessableEntityError") { + return Response.json( + { + code: "Unprocessable Entity", + message: err.message, + }, + { + status: 422, + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + }, + ); + } + + if (err.name === "ModelError" && err.type === "UnprocessableEntityError") { + return Response.json( + { + code: "Unprocessable Entity", + message: err.message, + }, + { + status: 422, + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + }, + ); + } + + return Response.json( + { + code: "Internal Server Error", + message: err.message || "An unexpected error occurred", + }, + { + status: 500, + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + }, + ); + } +}; \ No newline at end of file diff --git a/app/routes/api.boxes.$deviceId.data.ts b/app/routes/api.boxes.$deviceId.data.ts new file mode 100644 index 00000000..6c29c686 --- /dev/null +++ b/app/routes/api.boxes.$deviceId.data.ts @@ -0,0 +1,111 @@ +import { type ActionFunction, type ActionFunctionArgs } from "react-router"; +import { postNewMeasurements } from "~/lib/measurement-service.server"; + +export const action: ActionFunction = async ({ + request, + params, +}: ActionFunctionArgs): Promise => { + try { + const deviceId = params.deviceId; + if (deviceId === undefined) { + return Response.json( + { + code: "Bad Request", + message: "Invalid device id specified", + }, + { + status: 400, + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + }, + ); + } + + const searchParams = new URL(request.url).searchParams; + const luftdaten = searchParams.get("luftdaten") !== null; + const hackair = searchParams.get("hackair") !== null; + + const contentType = request.headers.get("content-type") || ""; + const authorization = request.headers.get("authorization"); + + let body: any; + if (contentType.includes("application/json")) { + body = await request.json(); + } else if (contentType.includes("text/csv")) { + body = await request.text(); + } else if (contentType.includes("application/sbx-bytes")) { + body = await request.arrayBuffer(); + } else { + body = await request.text(); + } + + await postNewMeasurements(deviceId, body, { + contentType, + luftdaten, + hackair, + authorization, + }); + + return new Response("Measurements saved in box", { + status: 201, + headers: { + "Content-Type": "text/plain; charset=utf-8", + }, + }); + } catch (err: any) { + // Handle different error types + if (err.name === "UnauthorizedError") { + return Response.json( + { + code: "Unauthorized", + message: err.message, + }, + { + status: 401, + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + }, + ); + } + + if (err.name === "ModelError" && err.type === "UnprocessableEntityError") { + return Response.json( + { + code: "UnprocessableEntity", + message: err.message, + }, + { status: 422 } + ); + } + + if (err.name === "UnsupportedMediaTypeError") { + return Response.json( + { + code: "Unsupported Media Type", + message: err.message, + }, + { + status: 415, + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + }, + ); + } + + return Response.json( + { + code: "Internal Server Error", + message: err.message || "An unexpected error occurred", + }, + { + status: 500, + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + }, + ); + } +}; \ No newline at end of file diff --git a/app/routes/api.ts b/app/routes/api.ts index 57ed3ebf..edb37256 100644 --- a/app/routes/api.ts +++ b/app/routes/api.ts @@ -59,14 +59,14 @@ const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = { // path: `boxes/data`, // method: "POST", // }, - // { - // path: `boxes/:boxId/data`, - // method: "POST", - // }, - // { - // path: `boxes/:boxId/:sensorId`, - // method: "POST", - // }, + { + path: `boxes/:boxId/data`, + method: "POST", + }, + { + path: `boxes/:boxId/:sensorId`, + method: "POST", + }, { path: `users/register`, method: "POST", diff --git a/app/schema/accessToken.ts b/app/schema/accessToken.ts new file mode 100644 index 00000000..5431b07e --- /dev/null +++ b/app/schema/accessToken.ts @@ -0,0 +1,20 @@ +import { pgTable, text } from "drizzle-orm/pg-core"; +import { device } from "./device"; +import { InferSelectModel, relations } from "drizzle-orm"; + +export const accessToken = pgTable('access_token', { + deviceId: text('device_id').notNull() + .references(() => device.id, { + onDelete: 'cascade' + }), + token: text('token'), + }); + + export const accessTokenRelations = relations(accessToken, ({ one }) => ({ + user: one(device, { + fields: [accessToken.deviceId], + references: [device.id] + }) + })); + +export type AccessToken = InferSelectModel; diff --git a/app/schema/index.ts b/app/schema/index.ts index 0f55c20c..b91766a1 100644 --- a/app/schema/index.ts +++ b/app/schema/index.ts @@ -10,3 +10,4 @@ export * from "./user"; export * from "./location"; export * from "./log-entry"; export * from "./refreshToken"; +export * from "./accessToken"; diff --git a/app/schema/sensor.ts b/app/schema/sensor.ts index 0279c4cc..6c823aa1 100644 --- a/app/schema/sensor.ts +++ b/app/schema/sensor.ts @@ -1,3 +1,4 @@ +import { randomBytes } from "crypto"; import { createId } from "@paralleldrive/cuid2"; import { relations, @@ -9,6 +10,10 @@ import { device } from "./device"; import { DeviceStatusEnum } from "./enum"; import { type Measurement } from "./measurement"; +function generateHexId(): string { + return randomBytes(12).toString('hex'); +} + /** * Type for lastMeasurement JSON field */ @@ -25,7 +30,7 @@ export const sensor = pgTable("sensor", { id: text("id") .primaryKey() .notNull() - .$defaultFn(() => createId()), + .$defaultFn(() => generateHexId()), // store as hex strings to maintain compatibility with the byte protocol title: text("title"), unit: text("unit"), sensorType: text("sensor_type"), diff --git a/drizzle/0021_tense_sir_ram.sql b/drizzle/0021_tense_sir_ram.sql new file mode 100644 index 00000000..8ec9e69f --- /dev/null +++ b/drizzle/0021_tense_sir_ram.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS "access_token" ( + "device_id" text NOT NULL, + "token" text +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "access_token" ADD CONSTRAINT "access_token_device_id_device_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."device"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/drizzle/meta/0021_snapshot.json b/drizzle/meta/0021_snapshot.json new file mode 100644 index 00000000..168b62c7 --- /dev/null +++ b/drizzle/meta/0021_snapshot.json @@ -0,0 +1,1174 @@ +{ + "id": "85481101-dd0d-4e15-9158-11971b8ba509", + "prevId": "9d89599f-78ef-4878-85a7-91a31bf984dc", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "ARRAY[]::text[]" + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_to_location": { + "name": "device_to_location", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_id": { + "name": "location_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_to_location_device_id_device_id_fk": { + "name": "device_to_location_device_id_device_id_fk", + "tableFrom": "device_to_location", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "device_to_location_location_id_location_id_fk": { + "name": "device_to_location_location_id_location_id_fk", + "tableFrom": "device_to_location", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "device_to_location_device_id_location_id_time_pk": { + "name": "device_to_location_device_id_location_id_time_pk", + "columns": [ + "device_id", + "location_id", + "time" + ] + } + }, + "uniqueConstraints": { + "device_to_location_device_id_location_id_time_unique": { + "name": "device_to_location_device_id_location_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "device_id", + "location_id", + "time" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.measurement": { + "name": "measurement", + "schema": "", + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "value": { + "name": "value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "location_id": { + "name": "location_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "measurement_location_id_location_id_fk": { + "name": "measurement_location_id_location_id_fk", + "tableFrom": "measurement", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "sensor_id", + "time" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_user_id_user_id_fk": { + "name": "password_user_id_user_id_fk", + "tableFrom": "password", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_request": { + "name": "password_reset_request", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_request_user_id_user_id_fk": { + "name": "password_reset_request_user_id_user_id_fk", + "tableFrom": "password_reset_request", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_request_user_id_unique": { + "name": "password_reset_request_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile_image": { + "name": "profile_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "alt_text": { + "name": "alt_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blob": { + "name": "blob", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "profile_image_profile_id_profile_id_fk": { + "name": "profile_image_profile_id_profile_id_fk", + "tableFrom": "profile_image", + "tableTo": "profile", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastMeasurement": { + "name": "lastMeasurement", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "sensor_device_id_device_id_fk": { + "name": "sensor_device_id_device_id_fk", + "tableFrom": "sensor", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unconfirmed_email": { + "name": "unconfirmed_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'en_US'" + }, + "email_is_confirmed": { + "name": "email_is_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "email_confirmation_token": { + "name": "email_confirmation_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_unconfirmed_email_unique": { + "name": "user_unconfirmed_email_unique", + "nullsNotDistinct": false, + "columns": [ + "unconfirmed_email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.location": { + "name": "location", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "location": { + "name": "location", + "type": "geometry(point)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "location_index": { + "name": "location_index", + "columns": [ + { + "expression": "location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "location_location_unique": { + "name": "location_location_unique", + "nullsNotDistinct": false, + "columns": [ + "location" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log_entry": { + "name": "log_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_token": { + "name": "refresh_token", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "refresh_token_user_id_user_id_fk": { + "name": "refresh_token_user_id_user_id_fk", + "tableFrom": "refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.token_revocation": { + "name": "token_revocation", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "access_token_device_id_device_id_fk": { + "name": "access_token_device_id_device_id_fk", + "tableFrom": "access_token", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "homeV2Lora", + "homeV2Ethernet", + "homeV2Wifi", + "senseBox:Edu", + "luftdaten.info", + "Custom" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "public.measurement_10min": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_10min", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1day": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1day", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1hour": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1hour", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1month": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1month", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1year": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1year", + "schema": "public", + "isExisting": true, + "materialized": true + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 6dc906f7..01fc353f 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1748352273109, "tag": "0020_cloudy_misty_knight", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1759928743654, + "tag": "0021_tense_sir_ram", + "breakpoints": true } ] } \ No newline at end of file diff --git a/tests/data/byte_submit_data.ts b/tests/data/byte_submit_data.ts new file mode 100644 index 00000000..476d3535 --- /dev/null +++ b/tests/data/byte_submit_data.ts @@ -0,0 +1,33 @@ +export function byteSubmitData( + sensors: { id: string }[], + withTimestamps = false +): Uint8Array { + const bytesPerSensor = withTimestamps ? 20 : 16; + const buffer = new ArrayBuffer(sensors.length * bytesPerSensor); + const view = new DataView(buffer); + const bytes = new Uint8Array(buffer); + + sensors.forEach((sensor, i) => { + const offset = i * bytesPerSensor; + + const idHex = sensor.id.toLowerCase(); + + if (!/^[0-9a-f]{24}$/i.test(idHex)) { + throw new Error(`Invalid sensor ID format: ${sensor.id}. Expected 24 hex characters.`); + } + + for (let j = 0; j < 12; j++) { + const hexByte = idHex.slice(j * 2, j * 2 + 2); + bytes[offset + j] = parseInt(hexByte, 16); + } + + view.setFloat32(offset + 12, 20.0 + i, true); + + if (withTimestamps) { + const timestampSeconds = Math.floor((Date.now() - i * 60_000) / 1000); + view.setUint32(offset + 16, timestampSeconds, true); + } + }); + + return bytes; +} \ No newline at end of file diff --git a/tests/data/csv_example_data.ts b/tests/data/csv_example_data.ts new file mode 100644 index 00000000..ed19a762 --- /dev/null +++ b/tests/data/csv_example_data.ts @@ -0,0 +1,58 @@ +function noTimestamps(sensors: { id: string }[]) { + return sensors.map((sensor, index) => `${sensor.id},${index}`).join("\n"); + } + + function withTimestamps(sensors: { id: string }[]) { + return sensors + .map( + (sensor, index) => + `${sensor.id},${index},${new Date(Date.now() - index * 60_000).toISOString()}` + ) + .join("\n"); + } + + function withTimestampsFuture(sensors: { id: string }[]) { + return sensors + .map( + (sensor, index) => + `${sensor.id},${index},${new Date(Date.now() + index * 60_000).toISOString()}` + ) + .join("\n"); + } + + function withTooMany(sensors: { id: string }[]) { + return sensors + .map( + (sensor, index) => + `${sensor.id},${index},${new Date(Date.now() + index * 60_000).toISOString()}` + ) + .join("\n"); + } + + function tenDaysAgoMany(sensors: { id: string }[]) { + const iterations = 5; + const base = Date.now() - 10 * 24 * 60 * 60 * 1000; // 10 days ago in ms + let rows: string[] = []; + + for (let i = 0; i < iterations; i++) { + const chunk = sensors + .map((sensor, index) => { + const timestamp = new Date(base - (index + i) * 60_000).toISOString(); + return `${sensor.id},${index},${timestamp}`; + }) + .join("\n"); + + rows.push(chunk); + } + + return rows.join("\n"); + } + + export const csvExampleData = { + noTimestamps, + withTimestamps, + withTimestampsFuture, + withTooMany, + tenDaysAgoMany, + }; + \ No newline at end of file diff --git a/tests/data/index.ts b/tests/data/index.ts new file mode 100644 index 00000000..ceefdff5 --- /dev/null +++ b/tests/data/index.ts @@ -0,0 +1,3 @@ +export * from "./csv_example_data" +export * from "./json_submit_data" +export * from "./byte_submit_data" diff --git a/tests/data/json_submit_data.ts b/tests/data/json_submit_data.ts new file mode 100644 index 00000000..a85796a5 --- /dev/null +++ b/tests/data/json_submit_data.ts @@ -0,0 +1,20 @@ +function jsonObj(sensors: { id: string }[]) { + const obj: Record = {}; + sensors.forEach((sensor, index) => { + obj[sensor.id] = index; + }); + return obj; + } + + function jsonArr(sensors: { id: string }[]) { + return sensors.map((sensor, index) => ({ + sensor: sensor.id, + value: index, + })); + } + + export const jsonSubmitData = { + jsonObj, + jsonArr, + }; + \ No newline at end of file diff --git a/tests/routes/api.location.spec.ts b/tests/routes/api.location.spec.ts new file mode 100644 index 00000000..b9550e52 --- /dev/null +++ b/tests/routes/api.location.spec.ts @@ -0,0 +1,680 @@ +import { eq, sql } from "drizzle-orm"; +import { type AppLoadContext, type ActionFunctionArgs } from "react-router"; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { BASE_URL } from "vitest.setup"; +import { drizzleClient } from "~/db.server"; +import { registerUser } from "~/lib/user-service.server"; +import { createDevice, deleteDevice, getDevice } from "~/models/device.server"; +import { deleteUserByEmail } from "~/models/user.server"; +import { action as postSingleMeasurementAction } from "~/routes/api.boxes.$deviceId.$sensorId"; +import { action as postMeasurementsAction} from "~/routes/api.boxes.$deviceId.data"; +import { location, deviceToLocation, measurement, type User, device } from "~/schema"; + +const mockAccessToken = "valid-access-token-location-tests"; + +const TEST_USER = { + name: "testing location measurements", + email: "test@locationmeasurement.me", + password: "some secure password for locations", +}; + +const TEST_BOX = { + name: `'${TEST_USER.name}'s Box`, + exposure: "outdoor", + expiresAt: null, + tags: [], + latitude: 0, + longitude: 0, + model: "luftdaten.info", + mqttEnabled: false, + ttnEnabled: false, + sensors: [ + { title: "Temperature", unit: "°C", sensorType: "temperature" }, + { title: "Humidity", unit: "%", sensorType: "humidity" }, + { title: "Pressure", unit: "hPa", sensorType: "pressure" } + ], +}; + +describe("openSenseMap API Routes: Location Measurements", () => { + let userId: string = ""; + let deviceId: string = ""; + let sensorIds: string[] = []; + let sensors: any[] = []; + + // Helper function to get device's current location + async function getDeviceCurrentLocation(deviceId: string) { + const deviceWithLocations = await drizzleClient.query.device.findFirst({ + where: (device, { eq }) => eq(device.id, deviceId), + with: { + locations: { + orderBy: (deviceToLocation, { desc }) => [desc(deviceToLocation.time)], + limit: 1, + with: { + geometry: { + columns: {}, + extras: { + x: sql`ST_X(${location.location})`.as('x'), + y: sql`ST_Y(${location.location})`.as('y'), + }, + }, + }, + }, + }, + }); + + if (deviceWithLocations?.locations?.[0]?.geometry) { + const geo = deviceWithLocations.locations[0].geometry; + return { + coordinates: [geo.x, geo.y, 0], + time: deviceWithLocations.locations[0].time, + }; + } + return null; + } + + // Helper to get all device locations + async function getDeviceLocations(deviceId: string) { + const result = await drizzleClient + .select({ + timestamp: deviceToLocation.time, + x: sql`ST_X(${location.location})`, + y: sql`ST_Y(${location.location})`, + }) + .from(deviceToLocation) + .innerJoin(location, eq(deviceToLocation.locationId, location.id)) + .where(eq(deviceToLocation.deviceId, deviceId)) + .orderBy(deviceToLocation.time); + + return result.map(r => ({ + timestamp: r.timestamp, + coordinates: [r.x, r.y, 0], + })); + } + + // Helper to get measurements for a sensor + async function getSensorMeasurements(sensorId: string) { + const results = await drizzleClient + .select({ + value: measurement.value, + time: measurement.time, + locationId: measurement.locationId, + x: sql`ST_X(${location.location})`, + y: sql`ST_Y(${location.location})`, + }) + .from(measurement) + .leftJoin(location, eq(measurement.locationId, location.id)) + .where(eq(measurement.sensorId, sensorId)) + .orderBy(measurement.time); + + return results.map(r => ({ + value: String(r.value), + time: r.time, + location: r.x && r.y ? [r.x, r.y, 0] : null, + })); + } + + beforeAll(async () => { + const user = await registerUser( + TEST_USER.name, + TEST_USER.email, + TEST_USER.password, + "en_US", + ); + userId = (user as User).id; + const device = await createDevice(TEST_BOX, userId); + deviceId = device.id; + + const deviceWithSensors = await getDevice({ id: deviceId }); + sensorIds = deviceWithSensors?.sensors?.map((sensor: any) => sensor.id) || []; + sensors = deviceWithSensors?.sensors?.map((sensor: any) => sensor) || []; + }); + + afterAll(async () => { + await deleteUserByEmail(TEST_USER.email); + await deleteDevice({ id: deviceId }); + }); + + describe("POST /boxes/:deviceId/:sensorId with locations", () => { + it("should allow updating a box's location via new measurement (array)", async () => { + const measurement = { + value: 3, + location: [3, 3, 3] + }; + + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[0]}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: mockAccessToken, + }, + body: JSON.stringify(measurement), + } + ); + + const response: any = await postSingleMeasurementAction({ + request, + params: { deviceId: deviceId, sensorId: sensorIds[0] }, + context: {} as AppLoadContext + } satisfies ActionFunctionArgs); + + expect(response).toBeInstanceOf(Response); + expect(response.status).toBe(201); + expect(await response.text()).toBe("Measurement saved in box"); + + const currentLocation = await getDeviceCurrentLocation(deviceId); + expect(currentLocation).not.toBeNull(); + expect(currentLocation!.coordinates[0]).toBeCloseTo(3, 5); + expect(currentLocation!.coordinates[1]).toBeCloseTo(3, 5); + }); + + it("should allow updating a box's location via new measurement (latLng)", async () => { + const measurement = { + value: 4, + location: { lat: 4, lng: 4, height: 4 } + }; + + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[0]}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: mockAccessToken, + }, + body: JSON.stringify(measurement), + } + ); + + const response: any = await postSingleMeasurementAction({ + request, + params: { deviceId: deviceId, sensorId: sensorIds[0] }, + context: {} as AppLoadContext + } satisfies ActionFunctionArgs); + + expect(response).toBeInstanceOf(Response); + expect(response.status).toBe(201); + + const currentLocation = await getDeviceCurrentLocation(deviceId); + expect(currentLocation).not.toBeNull(); + expect(currentLocation!.coordinates[0]).toBeCloseTo(4, 5); + expect(currentLocation!.coordinates[1]).toBeCloseTo(4, 5); + }); + + it("should not update box.currentLocation for an earlier timestamp", async () => { + // First, post a measurement with current time and location [4, 4] + const currentMeasurement = { + value: 4.1, + location: [4, 4, 0] + }; + + let request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[0]}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: mockAccessToken, + }, + body: JSON.stringify(currentMeasurement), + } + ); + + await postSingleMeasurementAction({ + request, + params: { deviceId: deviceId, sensorId: sensorIds[0] }, + context: {} as AppLoadContext + } satisfies ActionFunctionArgs); + + // Get current location after first post + const locationAfterCurrent = await getDeviceCurrentLocation(deviceId); + expect(locationAfterCurrent!.coordinates[0]).toBeCloseTo(4, 5); + expect(locationAfterCurrent!.coordinates[1]).toBeCloseTo(4, 5); + + // Now post a measurement with an earlier timestamp + const pastTime = new Date(Date.now() - 60000); // 1 minute ago + const pastMeasurement = { + value: -1, + location: [-1, -1, -1], + createdAt: pastTime.toISOString(), + }; + + request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[0]}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: mockAccessToken, + }, + body: JSON.stringify(pastMeasurement), + } + ); + + const response: any = await postSingleMeasurementAction({ + request, + params: { deviceId: deviceId, sensorId: sensorIds[0] }, + context: {} as AppLoadContext + } satisfies ActionFunctionArgs); + + expect(response.status).toBe(201); + + // Verify location was NOT updated (should still be [4, 4]) + const locationAfterPast = await getDeviceCurrentLocation(deviceId); + expect(locationAfterPast!.coordinates[0]).toBeCloseTo(4, 5); + expect(locationAfterPast!.coordinates[1]).toBeCloseTo(4, 5); + }); + + it("should predate first location for measurement with timestamp and no location", async () => { + // Create a fresh device for this test to avoid interference + const testDevice = await createDevice({ + ...TEST_BOX, + name: "Location Predate Test Box" + }, userId); + + + const testDeviceData = await getDevice({ id: testDevice.id }); + const testSensorId = testDeviceData?.sensors?.[0]?.id; + + const createdAt = new Date(Date.now() - 600000); // 10 minutes ago + const measurement = { + value: -1, + createdAt: createdAt.toISOString() + }; + + const request = new Request( + `${BASE_URL}/api/boxes/${testDevice.id}/${testSensorId}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: mockAccessToken, + }, + body: JSON.stringify(measurement), + } + ); + + const response: any = await postSingleMeasurementAction({ + request, + params: { deviceId: testDevice.id, sensorId: testSensorId }, + context: {} as AppLoadContext + } satisfies ActionFunctionArgs); + + expect(response.status).toBe(201); + + // Get device locations - should be empty since no location was provided + const locations = await getDeviceLocations(testDevice.id); + expect(locations).toHaveLength(0); + + // Cleanup + await deleteDevice({ id: testDevice.id }); + }); + + it("should infer measurement.location for measurements without location", async () => { + // Create a fresh device for this test + const testDevice = await createDevice({ + ...TEST_BOX, + name: "Location Inference Test Box" + }, userId); + + const testDeviceData = await getDevice({ id: testDevice.id }); + const testSensorId = testDeviceData?.sensors?.[0]?.id; + + // First, set a location at time T-2 minutes + const time1 = new Date(Date.now() - 120000); + const measurement1 = { + value: -1, + location: [-1, -1, -1], + createdAt: time1.toISOString() + }; + + let request = new Request( + `${BASE_URL}/api/boxes/${testDevice.id}/${testSensorId}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: mockAccessToken, + }, + body: JSON.stringify(measurement1), + } + ); + + await postSingleMeasurementAction({ + request, + params: { deviceId: testDevice.id, sensorId: testSensorId }, + context: {} as AppLoadContext + } satisfies ActionFunctionArgs); + + // Second, set a different location at time T (now) + const time2 = new Date(); + const measurement2 = { + value: 1, + location: [1, 1, 1], + createdAt: time2.toISOString() + }; + + request = new Request( + `${BASE_URL}/api/boxes/${testDevice.id}/${testSensorId}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: mockAccessToken, + }, + body: JSON.stringify(measurement2), + } + ); + + await postSingleMeasurementAction({ + request, + params: { deviceId: testDevice.id, sensorId: testSensorId }, + context: {} as AppLoadContext + } satisfies ActionFunctionArgs); + + // Now post a measurement without location at T-1 minute (between the two locations) + const time3 = new Date(Date.now() - 60000); + const measurement3 = { + value: -0.5, + createdAt: time3.toISOString() + }; + + request = new Request( + `${BASE_URL}/api/boxes/${testDevice.id}/${testSensorId}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: mockAccessToken, + }, + body: JSON.stringify(measurement3), + } + ); + + await postSingleMeasurementAction({ + request, + params: { deviceId: testDevice.id, sensorId: testSensorId }, + context: {} as AppLoadContext + } satisfies ActionFunctionArgs); + + // Get all measurements and check their inferred locations + const measurements = await getSensorMeasurements(testSensorId!); + + const m1 = measurements.find(m => m.value === '-0.5'); + expect(m1).toBeDefined(); + expect(m1!.location).not.toBeNull(); + expect(m1!.location![0]).toBeCloseTo(-1, 5); // Should have location from T-2 + expect(m1!.location![1]).toBeCloseTo(-1, 5); + + const m2 = measurements.find(m => m.value === '1'); + expect(m2).toBeDefined(); + expect(m2!.location).not.toBeNull(); + expect(m2!.location![0]).toBeCloseTo(1, 5); + expect(m2!.location![1]).toBeCloseTo(1, 5); + + // Cleanup + await deleteDevice({ id: testDevice.id }); + }); + + it("should not update location of measurements for retroactive measurements", async () => { + // Create a fresh device for this test + const testDevice = await createDevice({ + ...TEST_BOX, + name: "Retroactive Measurements Test Box" + }, userId); + + const testDeviceData = await getDevice({ id: testDevice.id }); + const testSensorId = testDeviceData?.sensors?.[0]?.id; + + // Post three measurements out of order + const now = new Date(); + + // First post: measurement3 at T with location [6,6,6] + const measurement3 = { + value: 6, + location: [6, 6, 6], + createdAt: now.toISOString() + }; + + let request = new Request( + `${BASE_URL}/api/boxes/${testDevice.id}/${testSensorId}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: mockAccessToken, + }, + body: JSON.stringify(measurement3), + } + ); + + await postSingleMeasurementAction({ + request, + params: { deviceId: testDevice.id, sensorId: testSensorId }, + context: {} as AppLoadContext + } satisfies ActionFunctionArgs); + + // Second post: measurement2 at T-2ms without location + const time2 = new Date(now.getTime() - 2); + const measurement2 = { + value: 4.5, + createdAt: time2.toISOString() + }; + + request = new Request( + `${BASE_URL}/api/boxes/${testDevice.id}/${testSensorId}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: mockAccessToken, + }, + body: JSON.stringify(measurement2), + } + ); + + await postSingleMeasurementAction({ + request, + params: { deviceId: testDevice.id, sensorId: testSensorId }, + context: {} as AppLoadContext + } satisfies ActionFunctionArgs); + + // Third post: measurement1 at T-4ms with location [5,5,5] + const time1 = new Date(now.getTime() - 4); + const measurement1 = { + value: 5, + location: [5, 5, 5], + createdAt: time1.toISOString() + }; + + request = new Request( + `${BASE_URL}/api/boxes/${testDevice.id}/${testSensorId}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: mockAccessToken, + }, + body: JSON.stringify(measurement1), + } + ); + + await postSingleMeasurementAction({ + request, + params: { deviceId: testDevice.id, sensorId: testSensorId }, + context: {} as AppLoadContext + } satisfies ActionFunctionArgs); + + // Get all measurements and verify their locations + const measurements = await getSensorMeasurements(testSensorId!); + + // measurement2 (value 4.5) at T-2ms should have no location + // because at the time it was posted, there was no location before T-2ms + const m2 = measurements.find(m => m.value === '4.5'); + expect(m2).toBeDefined(); + expect(m2!.location).toBeNull(); + + // measurement1 should have its explicit location + const m1 = measurements.find(m => m.value === '5'); + expect(m1).toBeDefined(); + expect(m1!.location).not.toBeNull(); + expect(m1!.location![0]).toBeCloseTo(5, 5); + expect(m1!.location![1]).toBeCloseTo(5, 5); + + // measurement3 should have its explicit location + const m3 = measurements.find(m => m.value === '6'); + expect(m3).toBeDefined(); + expect(m3!.location).not.toBeNull(); + expect(m3!.location![0]).toBeCloseTo(6, 5); + expect(m3!.location![1]).toBeCloseTo(6, 5); + + // Cleanup + await deleteDevice({ id: testDevice.id }); + }); + + it("should reject invalid location coordinates (longitude out of range)", async () => { + const measurement = { + value: 100, + location: [200, 50, 0] + }; + + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[0]}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: mockAccessToken, + }, + body: JSON.stringify(measurement), + } + ); + + const response: any = await postSingleMeasurementAction({ + request, + params: { deviceId: deviceId, sensorId: sensorIds[0] }, + context: {} as AppLoadContext + } satisfies ActionFunctionArgs); + + expect(response.status).toBe(422); + const errorData = await response.json(); + expect(errorData.code).toBe("Unprocessable Entity"); + expect(errorData.message).toBe("Invalid location coordinates"); + }); + + it("should reject invalid location coordinates (latitude out of range)", async () => { + const measurement = { + value: 101, + location: [50, 100, 0] + }; + + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[0]}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: mockAccessToken, + }, + body: JSON.stringify(measurement), + } + ); + + const response: any = await postSingleMeasurementAction({ + request, + params: { deviceId: deviceId, sensorId: sensorIds[0] }, + context: {} as AppLoadContext + } satisfies ActionFunctionArgs); + + expect(response.status).toBe(422); + const errorData = await response.json(); + expect(errorData.code).toBe("Unprocessable Entity"); + expect(errorData.message).toBe("Invalid location coordinates"); + }); + }); + + describe("openSenseMap API Routes: POST /boxes/:deviceId/data (application/json)", () => { + + it("should accept location in measurement object with [value, time, loc]", async () => { + const now = new Date(); + const body = { + [sensorIds[0]]: [7, new Date(now.getTime() - 2).toISOString(), [7, 7, 7]], + [sensorIds[1]]: [8, now.toISOString(), { lat: 8, lng: 8, height: 8 }], + }; + const request = new Request(`${BASE_URL}/api/boxes/${deviceId}/data`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: mockAccessToken, + }, + body: JSON.stringify(body), + }); + + const response: any = await postMeasurementsAction({ + request, + params: { deviceId }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs); + + expect(response).toBeInstanceOf(Response); + expect(response.status).toBe(201); + + const currentLocation = await getDeviceCurrentLocation(deviceId); + expect(currentLocation).not.toBeNull(); + expect(currentLocation!.coordinates).toEqual([8, 8, 0]); + }); + + it("should accept location in measurement array", async () => { + const sensor = sensorIds[2]; + const measurements = [ + { sensor: sensor, value: 9.6 }, + { sensor: sensor, value: 10, location: { lat: 10, lng: 10, height: 10 } }, + { sensor: sensor, value: 9.5, createdAt: new Date().toISOString() }, + { + sensor: sensor, + value: 9, + createdAt: new Date(Date.now() - 2).toISOString(), + location: [9, 9, 9], + }, + { sensor: sensor, value: 10.5 }, + ]; + + const request = new Request(`${BASE_URL}/api/boxes/${deviceId}/data`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: mockAccessToken, + }, + body: JSON.stringify(measurements), + }); + + const response: any = await postMeasurementsAction({ + request, + params: { deviceId }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs); + + expect(response.status).toBe(201); + + const currentLocation = await getDeviceCurrentLocation(deviceId); + expect(currentLocation).not.toBeNull(); + expect(currentLocation!.coordinates).toEqual([10, 10, 0]); + }); + + // it("should set & infer locations correctly for measurements", async () => { + // const sensor = sensorIds[2]; + // const measurements = await getSensorMeasurements(sensor); + + // expect(measurements.length).toBeGreaterThanOrEqual(5); + + // for (const m of measurements) { + // // For this dataset, value should roghly match coordinate + // const v = parseInt(m.value, 10); + // if (m.location) { + // expect(m.location).toEqual([v, v, 0]); + // } + // } + // }); + }); +}); \ No newline at end of file diff --git a/tests/routes/api.measurements.spec.ts b/tests/routes/api.measurements.spec.ts new file mode 100644 index 00000000..562f3c10 --- /dev/null +++ b/tests/routes/api.measurements.spec.ts @@ -0,0 +1,561 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { AppLoadContext, type ActionFunctionArgs } from "react-router"; +import { action as postMeasurementsAction } from "~/routes/api.boxes.$deviceId.data"; +import { action as postSingleMeasurementAction } from "~/routes/api.boxes.$deviceId.$sensorId"; +import { BASE_URL } from "vitest.setup"; +import { csvExampleData, jsonSubmitData, byteSubmitData } from "tests/data"; +import { createDevice, deleteDevice, getDevice } from "~/models/device.server"; +import { registerUser } from "~/lib/user-service.server"; +import { accessToken, type User } from "~/schema"; +import { deleteUserByEmail } from "~/models/user.server"; +import { drizzleClient } from "~/db.server"; + +const mockAccessToken = "valid-access-token"; + +const TEST_USER = { + name: "testing measurement submits", + email: "test@measurementsubmits.me", + password: "some secure password", +}; + +const TEST_BOX = { + name: `'${TEST_USER.name}'s Box`, + exposure: "outdoor", + expiresAt: null, + tags: [], + latitude: 0, + longitude: 0, + model: "luftdaten.info", + mqttEnabled: false, + ttnEnabled: false, + sensors: [ + { title: "Temperature", unit: "°C", sensorType: "temperature" }, + { title: "Humidity", unit: "%", sensorType: "humidity" }, + ], +}; + +describe("openSenseMap API Routes: /boxes", () => { + let userId: string = ""; + let deviceId: string = ""; + let sensorIds: string[] = [] + let sensors: any[] = [] + + beforeAll(async () => { + + const user = await registerUser( + TEST_USER.name, + TEST_USER.email, + TEST_USER.password, + "en_US", + ); + userId = (user as User).id; + const device = await createDevice(TEST_BOX, userId); + deviceId = device.id + + const deviceWithSensors = await getDevice({ id: deviceId }); + sensorIds = deviceWithSensors?.sensors?.map((sensor: any) => sensor.id) || []; + sensors = deviceWithSensors?.sensors?.map((sensor: any) => sensor) || [] + + await drizzleClient.insert(accessToken).values({ + deviceId: deviceId, + token: "valid-access-token", + }) + + }); + + + + // --------------------------------------------------- + // Single measurement POST /boxes/:boxId/:sensorId + // --------------------------------------------------- + describe("single measurement POST", () => { + it("should accept a single measurement via POST", async () => { + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[0]}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: mockAccessToken, + }, + body: JSON.stringify({ value: 312.1 }), + } + ); + + const response: any = await postSingleMeasurementAction({ + request, + params: { deviceId: deviceId, sensorId: sensorIds[0] }, + context: {} as AppLoadContext + } satisfies ActionFunctionArgs); + + expect(response).toBeInstanceOf(Response); + expect(response.status).toBe(201); + expect(await response.text()).toBe("Measurement saved in box"); + }); + + it("should reject with wrong access token", async () => { + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[0]}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "wrongAccessToken", + }, + body: JSON.stringify({ value: 312.1 }), + } + ); + + const response: any = await postSingleMeasurementAction({ + request, + params: { deviceId: deviceId, sensorId: sensorIds[0] }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.message).toBe("Device access token not valid!"); + }); + + it("should accept a single measurement with timestamp", async () => { + const timestamp = new Date().toISOString(); + + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[1]}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: mockAccessToken, + }, + body: JSON.stringify({ value: 123.4, createdAt: timestamp }), + } + ); + + const response: any = await postSingleMeasurementAction({ + request, + params: { deviceId: deviceId, sensorId: sensorIds[1] }, + context: {} as AppLoadContext + } satisfies ActionFunctionArgs); + + expect(response.status).toBe(201); + expect(await response.text()).toBe("Measurement saved in box"); + }); + + it("should reject measurement with timestamp too far into the future", async () => { + const future = new Date(Date.now() + 90_000).toISOString(); // 1.5 min future + + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/${sensorIds[1]}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: mockAccessToken, + }, + body: JSON.stringify({ value: 123.4, createdAt: future }), + } + ); + + const response: any = await postSingleMeasurementAction({ + request, + params: { deviceId: deviceId, sensorId: sensorIds[1] }, + context: {} as AppLoadContext + } satisfies ActionFunctionArgs); + + expect(response.status).toBe(422); + }); + }); + + // --------------------------------------------------- +// Multiple CSV POST +// --------------------------------------------------- +describe("multiple CSV POST /boxes/:id/data", () => { + it("should accept multiple measurements as CSV via POST (no timestamps)", async () => { + const csvPayload = csvExampleData.noTimestamps(sensors); + + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/data`, + { + method: "POST", + headers: { + "Content-Type": "text/csv", + Authorization: mockAccessToken, + }, + body: csvPayload, + } + ); + + const response: any = await postMeasurementsAction({ + request, + params: { deviceId: deviceId }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs); + + expect(response.status).toBe(201); + expect(await response.text()).toContain("Measurements saved in box"); + }); + + it("should accept multiple measurements as CSV via POST (with timestamps)", async () => { + const csvPayload = csvExampleData.withTimestamps(sensors); + + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/data`, + { + method: "POST", + headers: { + "Content-Type": "text/csv", + Authorization: mockAccessToken, + }, + body: csvPayload, + } + ); + + const response: any = await postMeasurementsAction({ + request, + params: { deviceId: deviceId }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs); + + expect(response.status).toBe(201); + }); + + it("should reject CSV with future timestamps", async () => { + const csvPayload = csvExampleData.withTimestampsFuture(sensors); + + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/data`, + { + method: "POST", + headers: { + "Content-Type": "text/csv", + Authorization: mockAccessToken, + }, + body: csvPayload, + } + ); + + const response: any = await postMeasurementsAction({ + request, + params: { deviceId: deviceId }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs); + + expect(response.status).toBe(422); + }); + }); + + + // --------------------------------------------------- + // Multiple bytes POST + // --------------------------------------------------- + describe("multiple bytes POST /boxes/:id/data", () => { + + it("should accept multiple measurements as bytes via POST", async () => { + + const submitTime = new Date(); + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/data`, + { + method: "POST", + headers: { + "Content-Type": "application/sbx-bytes", + Authorization: mockAccessToken, + }, + body: byteSubmitData(sensors), + } + ); + + const response: any = await postMeasurementsAction({ + request, + params: { deviceId: deviceId }, + context: {} as AppLoadContext + } as ActionFunctionArgs); + + expect(response.status).toBe(201); + expect(await response.text()).toContain("Measurements saved in box"); + + const updatedDevice = await getDevice({ id: deviceId }); + + expect(updatedDevice?.sensors).toBeDefined(); + updatedDevice?.sensors?.forEach((sensor: any) => { + expect(sensor.lastMeasurement).toBeDefined(); + expect(sensor.lastMeasurement).not.toBeNull(); + + // Verify the measurement timestamp is recent + if (sensor.lastMeasurement?.createdAt) { + const createdAt = new Date(sensor.lastMeasurement.createdAt); + const diffMinutes = Math.abs(submitTime.getTime() - createdAt.getTime()) / (1000 * 60); + expect(diffMinutes).toBeLessThan(4); + } + }); + }); + + it("should accept multiple measurements as bytes with timestamps", async () => { + const submitTime = new Date(); + + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/data`, + { + method: "POST", + headers: { + "Content-Type": "application/sbx-bytes-ts", + Authorization: mockAccessToken, + }, + body: byteSubmitData(sensors, true), + } + ); + + const response: any = await postMeasurementsAction({ + request, + params: { deviceId: deviceId }, + context: {} as AppLoadContext + } as ActionFunctionArgs); + + expect(response.status).toBe(201); + expect(await response.text()).toBe("Measurements saved in box"); + + const updatedDevice = await getDevice({ id: deviceId }); + + expect(updatedDevice?.sensors).toBeDefined(); + expect(updatedDevice?.sensors?.length).toBeGreaterThan(0); + + updatedDevice?.sensors?.forEach((sensor: any) => { + expect(sensor.lastMeasurement).toBeDefined(); + expect(sensor.lastMeasurement).not.toBeNull(); + + expect(sensor.lastMeasurement.createdAt).toBeDefined(); + + // Verify the timestamp is within 5 minutes of submission + const createdAt = new Date(sensor.lastMeasurement.createdAt); + const diffMinutes = Math.abs(submitTime.getTime() - createdAt.getTime()) / (1000 * 60); + expect(diffMinutes).toBeLessThan(5); + }); + }); + }); + + it("should reject measurements with invalid sensor IDs", async () => { + // Create byte data with a non-existent sensor ID + const fakeSensorId = "fakeid123456"; + const bytesPerSensor = 16; + const buffer = new ArrayBuffer(bytesPerSensor); + const view = new DataView(buffer); + const bytes = new Uint8Array(buffer); + + function stringToHex(str: string): string { + let hex = ''; + for (let i = 0; i < str.length; i++) { + const charCode = str.charCodeAt(i); + hex += charCode.toString(16).padStart(2, '0'); + } + return hex; + } + + + // Encode fake sensor ID + const fakeIdHex = stringToHex(fakeSensorId).slice(0, 24); + for (let j = 0; j < 12; j++) { + const hexByteStart = j * 2; + const hexByte = fakeIdHex.slice(hexByteStart, hexByteStart + 2); + bytes[j] = parseInt(hexByte, 16) || 0; + } + view.setFloat32(12, 25.5, true); + + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/data`, + { + method: "POST", + headers: { + "Content-Type": "application/sbx-bytes", + Authorization: mockAccessToken, + }, + body: bytes, + } + ); + + const response: any = await postMeasurementsAction({ + request, + params: { deviceId: deviceId }, + context: {} as AppLoadContext + } as ActionFunctionArgs); + + console.log("response invalid sensor", response) + + // Should either reject or silently skip invalid sensors + expect(response.status).toBeGreaterThanOrEqual(200); + }); + + it("should handle empty measurements", async () => { + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/data`, + { + method: "POST", + headers: { + "Content-Type": "application/sbx-bytes", + Authorization: mockAccessToken, + }, + body: new Uint8Array(0), + } + ); + + const response: any = await postMeasurementsAction({ + request, + params: { deviceId: deviceId }, + context: {} as AppLoadContext + } as ActionFunctionArgs); + + expect(response.status).toBe(422); // Unprocessable Entity + }); + + // --------------------------------------------------- + // MQTT publishing + // --------------------------------------------------- + // describe("MQTT submission", () => { + // it("should accept measurements through mqtt", async () => { + // // NOTE: You’ll need to wire up a real or mock MQTT client. + // // Example: use `mqtt` npm package and connect to a local broker in test env. + // // Here we just stub: + + // const fakePublishMqttMessage = async ( + // topic: string, + // payload: string + // ) => { + // // call your app’s MQTT ingestion handler directly instead of broker + // const request = new Request(`${BASE_URL}/api/mqtt`, { + // method: "POST", + // headers: { "Content-Type": "application/json" }, + // body: payload, + // }); + // return postMeasurementsAction({ + // request, + // params: { deviceId: deviceId }, + // context: {} as AppLoadContext + + // } as ActionFunctionArgs); + // }; + + // const payload = JSON.stringify(jsonSubmitData.jsonArr(sensors)); + // const mqttResponse: any = await fakePublishMqttMessage("mytopic", payload); + + // expect(mqttResponse.status).toBe(201); + // }); + // }); + +describe("multiple JSON POST /boxes/:id/data", () => { + it("should accept multiple measurements with timestamps as JSON object via POST (content-type: json)", async () => { + const submitData = jsonSubmitData.jsonObj(sensors); + + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/data`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: mockAccessToken, + }, + body: JSON.stringify(submitData), + } + ); + + const before = new Date(); + + const response: any = await postMeasurementsAction({ + request, + params: { deviceId }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs); + + const after = new Date(); + + expect(response.status).toBe(201); + expect(await response.text()).toContain("Measurements saved in box"); + + // Verify sensors got updated + const updatedDevice = await getDevice({ id: deviceId }); + for (const sensor of updatedDevice?.sensors || []) { + expect(sensor.lastMeasurement).toBeTruthy(); + expect(new Date((sensor.lastMeasurement as any).createdAt).getTime()) + .toBeGreaterThanOrEqual(before.getTime() - 1000); + expect(new Date((sensor.lastMeasurement as any).createdAt).getTime()) + .toBeLessThanOrEqual(after.getTime() + 1000 * 60 * 4); // within ~4 min + } + }); + + it("should accept multiple measurements with timestamps as JSON object via POST", async () => { + const submitData = jsonSubmitData.jsonObj(sensors); + + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/data`, + { + method: "POST", + headers: { + Authorization: mockAccessToken, + // TODO: remove header here + "Content-Type": "application/json", + }, + body: JSON.stringify(submitData), + } + ); + + const before = new Date(); + const response: any = await postMeasurementsAction({ + request, + params: { deviceId }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs); + const after = new Date(); + + expect(response.status).toBe(201); + expect(await response.text()).toContain("Measurements saved in box"); + + const updatedDevice = await getDevice({ id: deviceId }); + for (const sensor of updatedDevice?.sensors || []) { + expect(sensor.lastMeasurement).toBeTruthy(); + const createdAt = new Date((sensor.lastMeasurement as any).createdAt); + expect(createdAt.getTime()).toBeGreaterThanOrEqual(before.getTime() - 1000); + expect(createdAt.getTime()).toBeLessThanOrEqual(after.getTime() + 1000 * 60 * 4); + } + }); + + it("should accept multiple measurements with timestamps as JSON array via POST", async () => { + const submitData = jsonSubmitData.jsonArr(sensors); + + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/data`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: mockAccessToken, + }, + body: JSON.stringify(submitData), + } + ); + + const before = new Date(); + const response: any = await postMeasurementsAction({ + request, + params: { deviceId }, + context: {} as AppLoadContext, + } satisfies ActionFunctionArgs); + const after = new Date(); + + expect(response.status).toBe(201); + expect(await response.text()).toContain("Measurements saved in box"); + + const updatedDevice = await getDevice({ id: deviceId }); + for (const sensor of updatedDevice?.sensors || []) { + expect(sensor.lastMeasurement).toBeTruthy(); + const createdAt = new Date((sensor.lastMeasurement as any).createdAt); + expect(createdAt.getTime()).toBeGreaterThanOrEqual(before.getTime() - 1000); + expect(createdAt.getTime()).toBeLessThanOrEqual(after.getTime() + 1000 * 60 * 4); + } + }); +}); + + + afterAll(async () => { + await deleteUserByEmail(TEST_USER.email); + await deleteDevice({ id: deviceId }); + }); +});