diff --git a/app/lib/device-transform.ts b/app/lib/device-transform.ts index 0937c223..88d3257b 100644 --- a/app/lib/device-transform.ts +++ b/app/lib/device-transform.ts @@ -1,102 +1,107 @@ -import { type Device, type Sensor } from '~/schema'; +import { type Device, type Sensor } from '~/schema' export type DeviceWithSensors = Device & { - sensors: Sensor[]; -}; + sensors: Sensor[] +} export type TransformedDevice = { - _id: string; - name: string; - description: string | null; - image: string | null; - link: string | null; - grouptag: string[]; - exposure: string | null; - model: string | null; - latitude: number; - longitude: number; - useAuth: boolean | null; - public: boolean | null; - status: string | null; - createdAt: Date; - updatedAt: Date; - expiresAt: Date | null; - userId: string; - sensorWikiModel?: string | null; - currentLocation: { - type: "Point"; - coordinates: number[]; - timestamp: string; - }; - lastMeasurementAt: string; - loc: Array<{ - type: "Feature"; - geometry: { - type: "Point"; - coordinates: number[]; - timestamp: string; - }; - }>; - integrations: { - mqtt: { - enabled: boolean; - }; - }; - sensors: Array<{ - _id: string; - title: string | null; - unit: string | null; - sensorType: string | null; - lastMeasurement: { - value: string; - createdAt: string; - } | null; - }>; -}; + _id: string + name: string + description: string | null + image: string | null + link: string | null + grouptag: string[] + exposure: string | null + model: string | null + latitude: number + longitude: number + useAuth: boolean | null + public: boolean | null + status: string | null + createdAt: Date + updatedAt: Date + expiresAt: Date | null + userId: string + sensorWikiModel?: string | null + currentLocation: { + type: 'Point' + coordinates: number[] + timestamp: string + } + lastMeasurementAt: string + loc: Array<{ + type: 'Feature' + geometry: { + type: 'Point' + coordinates: number[] + timestamp: string + } + }> + integrations: { + mqtt: { + enabled: boolean + } + } + sensors: Array<{ + _id: string + title: string | null + unit: string | null + sensorType: string | null + lastMeasurement: { + value: string + createdAt: string + } | null + }> +} /** * Transforms a device with sensors from database format to openSenseMap API format * @param box - Device object with sensors from database * @returns Transformed device in openSenseMap API format - * + * * Note: Converts lastMeasurement.value from number to string to match API specification */ export function transformDeviceToApiFormat( - box: DeviceWithSensors + box: DeviceWithSensors, ): TransformedDevice { - const { id, tags, sensors, ...rest } = box; - const timestamp = box.updatedAt.toISOString(); - const coordinates = [box.longitude, box.latitude]; - - return { - _id: id, - grouptag: tags || [], - ...rest, - currentLocation: { - type: "Point", - coordinates, - timestamp - }, - lastMeasurementAt: timestamp, - loc: [{ - geometry: { type: "Point", coordinates, timestamp }, - type: "Feature" - }], - integrations: { mqtt: { enabled: false } }, - sensors: sensors?.map((sensor) => ({ - _id: sensor.id, - title: sensor.title, - unit: sensor.unit, - sensorType: sensor.sensorType, - lastMeasurement: sensor.lastMeasurement - ? { - createdAt: sensor.lastMeasurement.createdAt, - // Convert numeric values to string to match API specification - value: typeof sensor.lastMeasurement.value === 'number' - ? String(sensor.lastMeasurement.value) - : sensor.lastMeasurement.value, - } - : null, - })) || [], - }; + const { id, tags, sensors, ...rest } = box + const timestamp = box.updatedAt.toISOString() + const coordinates = [box.longitude, box.latitude] + + return { + _id: id, + grouptag: tags || [], + ...rest, + currentLocation: { + type: 'Point', + coordinates, + timestamp, + }, + lastMeasurementAt: timestamp, + loc: [ + { + geometry: { type: 'Point', coordinates, timestamp }, + type: 'Feature', + }, + ], + integrations: { mqtt: { enabled: false } }, + sensors: + sensors?.map((sensor) => ({ + _id: sensor.id, + title: sensor.title, + unit: sensor.unit, + sensorType: sensor.sensorType, + icon: sensor.icon, + lastMeasurement: sensor.lastMeasurement + ? { + createdAt: sensor.lastMeasurement.createdAt, + // Convert number to string to match API specification + value: + typeof sensor.lastMeasurement.value === 'number' + ? String(sensor.lastMeasurement.value) + : sensor.lastMeasurement.value, + } + : null, + })) || [], + } } diff --git a/app/models/device.server.ts b/app/models/device.server.ts index ee122780..f9b249db 100644 --- a/app/models/device.server.ts +++ b/app/models/device.server.ts @@ -1,581 +1,775 @@ -import { point } from "@turf/helpers"; -import { eq, sql, desc, ilike, arrayContains, and, between } from "drizzle-orm"; +import { point } from '@turf/helpers' +import { eq, sql, desc, ilike, arrayContains, and, between } from 'drizzle-orm' import BaseNewDeviceEmail, { - messages as BaseNewDeviceMessages, -} from "emails/base-new-device"; -import { messages as NewLufdatenDeviceMessages } from "emails/new-device-luftdaten"; -import { messages as NewSenseboxDeviceMessages } from "emails/new-device-sensebox"; -import { type Point } from "geojson"; -import { drizzleClient } from "~/db.server"; -import { sendMail } from "~/lib/mail.server"; + messages as BaseNewDeviceMessages, +} from 'emails/base-new-device' +import { messages as NewLufdatenDeviceMessages } from 'emails/new-device-luftdaten' +import { messages as NewSenseboxDeviceMessages } from 'emails/new-device-sensebox' +import { type Point } from 'geojson' +import { drizzleClient } from '~/db.server' +import { sendMail } from '~/lib/mail.server' import { - device, - deviceToLocation, - location, - sensor, - user, - type Device, - type Sensor, -} from "~/schema"; + device, + deviceToLocation, + location, + sensor, + user, + type Device, + type Sensor, +} from '~/schema' +import { accessToken } from '~/schema/accessToken' +import { addonDefinitions } from '~/utils/addon-definitions' +import { getSensorsForModel } from '~/utils/model-definitions' const BASE_DEVICE_COLUMNS = { - id: true, - name: true, - description: true, - image: true, - link: true, - tags: true, - exposure: true, - model: true, - latitude: true, - longitude: true, - status: true, - createdAt: true, - updatedAt: true, - expiresAt: true, - useAuth: true, - sensorWikiModel: true, -} as const; + id: true, + name: true, + description: true, + image: true, + link: true, + tags: true, + exposure: true, + model: true, + latitude: true, + longitude: true, + status: true, + createdAt: true, + updatedAt: true, + expiresAt: true, + useAuth: true, + sensorWikiModel: true, +} as const const DEVICE_COLUMNS_WITH_SENSORS = { - ...BASE_DEVICE_COLUMNS, - useAuth: true, - public: true, - userId: true, -} as const; - -export function getDevice({ id }: Pick) { - return drizzleClient.query.device.findFirst({ - where: (device, { eq }) => eq(device.id, id), - columns: BASE_DEVICE_COLUMNS, - with: { - user: { - columns: { - id: true, - }, - }, - logEntries: { - where: (entry, { eq }) => eq(entry.public, true), - columns: { - id: true, - content: true, - createdAt: true, - public: true, - deviceId: true, - }, - }, - locations: { - // https://github.com/drizzle-team/drizzle-orm/pull/2778 - // with: { - // geometry: true - // }, - columns: { - // time: true, - }, - extras: { - time: sql`time`.as("time"), - }, - with: { - geometry: { - columns: {}, - extras: { - x: sql`ST_X(${location.location})`.as("x"), - y: sql`ST_Y(${location.location})`.as("y"), - }, - }, - }, - // limit: 1000, - }, - sensors: true, - }, - }); + ...BASE_DEVICE_COLUMNS, + useAuth: true, + public: true, + userId: true, +} as const + +export class DeviceUpdateError extends Error { + constructor( + message: string, + public statusCode: number = 400, + ) { + super(message) + this.name = 'DeviceUpdateError' + } +} + +export function getDevice({ id }: Pick) { + return drizzleClient.query.device.findFirst({ + where: (device, { eq }) => eq(device.id, id), + columns: BASE_DEVICE_COLUMNS, + with: { + user: { + columns: { + id: true, + }, + }, + logEntries: { + where: (entry, { eq }) => eq(entry.public, true), + columns: { + id: true, + content: true, + createdAt: true, + public: true, + deviceId: true, + }, + }, + locations: { + // https://github.com/drizzle-team/drizzle-orm/pull/2778 + // with: { + // geometry: true + // }, + columns: { + // time: true, + }, + extras: { + time: sql`time`.as('time'), + }, + with: { + geometry: { + columns: {}, + extras: { + x: sql`ST_X(${location.location})`.as('x'), + y: sql`ST_Y(${location.location})`.as('y'), + }, + }, + }, + // limit: 1000, + }, + sensors: true, + }, + }) } export function getLocations( - { id }: Pick, - fromDate: Date, - toDate: Date + { id }: Pick, + fromDate: Date, + toDate: Date, ) { - return drizzleClient - .select({ - time: deviceToLocation.time, - x: sql`ST_X(${location.location})`.as("x"), - y: sql`ST_Y(${location.location})`.as("y"), - }) - .from(location) - .innerJoin(deviceToLocation, eq(deviceToLocation.locationId, location.id)) - .where( - and( - eq(deviceToLocation.deviceId, id), - between(deviceToLocation.time, fromDate, toDate) - ) - ) - .orderBy(desc(deviceToLocation.time)); + return drizzleClient + .select({ + time: deviceToLocation.time, + x: sql`ST_X(${location.location})`.as('x'), + y: sql`ST_Y(${location.location})`.as('y'), + }) + .from(location) + .innerJoin(deviceToLocation, eq(deviceToLocation.locationId, location.id)) + .where( + and( + eq(deviceToLocation.deviceId, id), + between(deviceToLocation.time, fromDate, toDate), + ), + ) + .orderBy(desc(deviceToLocation.time)) } -export function getDeviceWithoutSensors({ id }: Pick) { - return drizzleClient.query.device.findFirst({ - where: (device, { eq }) => eq(device.id, id), - columns: { - id: true, - name: true, - exposure: true, - updatedAt: true, - latitude: true, - longitude: true, - }, - }); +export function getDeviceWithoutSensors({ id }: Pick) { + return drizzleClient.query.device.findFirst({ + where: (device, { eq }) => eq(device.id, id), + columns: { + id: true, + name: true, + exposure: true, + updatedAt: true, + latitude: true, + longitude: true, + }, + }) } export type DeviceWithoutSensors = Awaited< - ReturnType ->; - -export function updateDeviceInfo({ - id, - name, - exposure, -}: Pick) { - return drizzleClient - .update(device) - .set({ name: name, exposure: exposure }) - .where(eq(device.id, id)); -} + ReturnType +> export function updateDeviceLocation({ - id, - latitude, - longitude, -}: Pick) { - return drizzleClient - .update(device) - .set({ latitude: latitude, longitude: longitude }) - .where(eq(device.id, id)); + id, + latitude, + longitude, +}: Pick) { + return drizzleClient + .update(device) + .set({ latitude: latitude, longitude: longitude }) + .where(eq(device.id, id)) +} + +export type UpdateDeviceArgs = { + name?: string + exposure?: string + grouptag?: string | string[] + description?: string + link?: string + image?: string + model?: string + useAuth?: boolean + location?: { lat: number; lng: number; height?: number } + sensors?: SensorUpdateArgs[] +} + +type SensorUpdateArgs = { + _id?: string + title?: string + unit?: string + sensorType?: string + icon?: string + deleted?: any + edited?: any + new?: any +} + +export async function updateDevice( + deviceId: string, + args: UpdateDeviceArgs, +): Promise { + const setColumns: Record = {} + const updatableFields: (keyof UpdateDeviceArgs)[] = [ + 'name', + 'exposure', + 'description', + 'image', + 'model', + 'useAuth', + 'link', + ] + + for (const field of updatableFields) { + if (args[field] !== undefined) { + // Handle empty string -> null for specific fields (backwards compatibility) + if ( + (field === 'description' || field === 'link' || field === 'image') && + args[field] === '' + ) { + setColumns[field] = null + } else { + setColumns[field] = args[field] + } + } + } + + if ('grouptag' in args) { + if (Array.isArray(args.grouptag)) { + // Empty array -> null for backwards compatibility + setColumns['tags'] = args.grouptag.length === 0 ? null : args.grouptag + } else if (args.grouptag != null) { + // Empty string -> null + setColumns['tags'] = args.grouptag === '' ? null : [args.grouptag] + } else { + setColumns['tags'] = null + } + } + + const result = await drizzleClient.transaction(async (tx) => { + if (args.location) { + const { lat, lng, height } = args.location + + const pointWKT = `POINT(${lng} ${lat})` + + const [existingLocation] = await tx + .select() + .from(location) + .where(sql`ST_Equals(location, ST_GeomFromText(${pointWKT}, 4326))`) + .limit(1) + + let locationId: bigint + + if (existingLocation) { + locationId = existingLocation.id + } else { + const [newLocation] = await tx + .insert(location) + .values({ + location: sql`ST_GeomFromText(${pointWKT}, 4326)`, + }) + .returning() + + if (!newLocation) { + throw new Error('Failed to create location') + } + + locationId = newLocation.id + } + + await tx + .insert(deviceToLocation) + .values({ + deviceId, + locationId, + time: sql`NOW()`, + }) + .onConflictDoNothing() + + setColumns['latitude'] = lat + setColumns['longitude'] = lng + } + + let updatedDevice + if (Object.keys(setColumns).length > 0) { + ;[updatedDevice] = await tx + .update(device) + .set({ ...setColumns, updatedAt: sql`NOW()` }) + .where(eq(device.id, deviceId)) + .returning() + + if (!updatedDevice) { + throw new DeviceUpdateError(`Device ${deviceId} not found`, 404) + } + } else { + ;[updatedDevice] = await tx + .select() + .from(device) + .where(eq(device.id, deviceId)) + + if (!updatedDevice) { + throw new DeviceUpdateError(`Device ${deviceId} not found`, 404) + } + } + + if (args.sensors?.length) { + const existingSensors = await tx + .select() + .from(sensor) + .where(eq(sensor.deviceId, deviceId)) + + const sensorsToDelete = args.sensors.filter( + (s) => 'deleted' in s && s._id, + ) + const remainingSensorCount = + existingSensors.length - sensorsToDelete.length + + if (sensorsToDelete.length > 0 && remainingSensorCount < 1) { + throw new DeviceUpdateError( + 'Unable to delete sensor(s). A box needs at least one sensor.', + ) + } + + for (const s of args.sensors) { + const hasDeleted = 'deleted' in s + const hasEdited = 'edited' in s + const hasNew = 'new' in s + + if (!hasDeleted && !hasEdited && !hasNew) { + continue + } + + if (hasDeleted) { + if (!s._id) { + throw new DeviceUpdateError('Sensor deletion requires _id') + } + + const sensorExists = existingSensors.some( + (existing) => existing.id === s._id, + ) + + if (!sensorExists) { + throw new DeviceUpdateError( + `Sensor with id ${s._id} not found for deletion.`, + ) + } + + await tx.delete(sensor).where(eq(sensor.id, s._id)) + } else if (hasEdited && hasNew) { + if (!s.title || !s.unit || !s.sensorType) { + throw new DeviceUpdateError( + 'New sensor requires title, unit, and sensorType', + ) + } + + await tx.insert(sensor).values({ + title: s.title, + unit: s.unit, + sensorType: s.sensorType, + icon: s.icon, + deviceId, + }) + } else if (hasEdited && s._id) { + const sensorExists = existingSensors.some( + (existing) => existing.id === s._id, + ) + + if (!sensorExists) { + throw new DeviceUpdateError( + `Sensor with id ${s._id} not found for editing.`, + ) + } + + if (!s.title || !s.unit || !s.sensorType) { + throw new DeviceUpdateError( + 'Editing sensor requires all properties: _id, title, unit, sensorType, icon', + ) + } + + await tx + .update(sensor) + .set({ + title: s.title, + unit: s.unit, + sensorType: s.sensorType, + icon: s.icon, + updatedAt: sql`NOW()`, + }) + .where(eq(sensor.id, s._id)) + } + } + } + return updatedDevice + }) + + return result } -export function deleteDevice({ id }: Pick) { - return drizzleClient.delete(device).where(eq(device.id, id)); +export function deleteDevice({ id }: Pick) { + return drizzleClient.delete(device).where(eq(device.id, id)) } -export function getUserDevices(userId: Device["userId"]) { - return drizzleClient.query.device.findMany({ - where: (device, { eq }) => eq(device.userId, userId), - columns: DEVICE_COLUMNS_WITH_SENSORS, - with: { - sensors: true, - }, - }); +export function getUserDevices(userId: Device['userId']) { + return drizzleClient.query.device.findMany({ + where: (device, { eq }) => eq(device.userId, userId), + columns: DEVICE_COLUMNS_WITH_SENSORS, + with: { + sensors: true, + }, + }) } -type DevicesFormat = "json" | "geojson"; +type DevicesFormat = 'json' | 'geojson' -export async function getDevices(format: "json"): Promise; +export async function getDevices(format: 'json'): Promise export async function getDevices( - format: "geojson" -): Promise>; + format: 'geojson', +): Promise> export async function getDevices( - format?: DevicesFormat -): Promise>; - -export async function getDevices(format: DevicesFormat = "json") { - const devices = await drizzleClient.query.device.findMany({ - columns: { - id: true, - name: true, - latitude: true, - longitude: true, - exposure: true, - status: true, - createdAt: true, - tags: true, - }, - }); - - if (format === "geojson") { - const geojson: GeoJSON.FeatureCollection = { - type: "FeatureCollection", - features: [], - }; - - for (const device of devices) { - const coordinates = [device.longitude, device.latitude]; - const feature = point(coordinates, device); - geojson.features.push(feature); - } - - return geojson; - } - - return devices; + format?: DevicesFormat, +): Promise> + +export async function getDevices(format: DevicesFormat = 'json') { + const devices = await drizzleClient.query.device.findMany({ + columns: { + id: true, + name: true, + latitude: true, + longitude: true, + exposure: true, + status: true, + createdAt: true, + tags: true, + }, + }) + + if (format === 'geojson') { + const geojson: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: [], + } + + for (const device of devices) { + const coordinates = [device.longitude, device.latitude] + const feature = point(coordinates, device) + geojson.features.push(feature) + } + + return geojson + } + + return devices } export async function getDevicesWithSensors() { - const rows = await drizzleClient - .select({ - device: device, - sensor: { - id: sensor.id, - title: sensor.title, - sensorWikiPhenomenon: sensor.sensorWikiPhenomenon, - lastMeasurement: sensor.lastMeasurement, - }, - }) - .from(device) - .leftJoin(sensor, eq(sensor.deviceId, device.id)); - const geojson: GeoJSON.FeatureCollection = { - type: "FeatureCollection", - features: [], - }; - - type PartialSensor = Pick< - Sensor, - "id" | "title" | "sensorWikiPhenomenon" | "lastMeasurement" - >; - const deviceMap = new Map< - string, - { device: Device & { sensors: PartialSensor[] } } - >(); - - const resultArray: Array<{ device: Device & { sensors: PartialSensor[] } }> = - rows.reduce( - (acc, row) => { - const device = row.device; - const sensor = row.sensor; - - if (!deviceMap.has(device.id)) { - const newDevice = { - device: { ...device, sensors: sensor ? [sensor] : [] }, - }; - deviceMap.set(device.id, newDevice); - acc.push(newDevice); - } else if (sensor) { - deviceMap.get(device.id)!.device.sensors.push(sensor); - } - - return acc; - }, - [] as Array<{ device: Device & { sensors: PartialSensor[] } }> - ); - - for (const device of resultArray) { - const coordinates = [device.device.longitude, device.device.latitude]; - const feature = point(coordinates, device.device); - geojson.features.push(feature); - } - - return geojson; + const rows = await drizzleClient + .select({ + device: device, + sensor: { + id: sensor.id, + title: sensor.title, + sensorWikiPhenomenon: sensor.sensorWikiPhenomenon, + lastMeasurement: sensor.lastMeasurement, + }, + }) + .from(device) + .leftJoin(sensor, eq(sensor.deviceId, device.id)) + const geojson: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: [], + } + + type PartialSensor = Pick< + Sensor, + 'id' | 'title' | 'sensorWikiPhenomenon' | 'lastMeasurement' + > + const deviceMap = new Map< + string, + { device: Device & { sensors: PartialSensor[] } } + >() + + const resultArray: Array<{ device: Device & { sensors: PartialSensor[] } }> = + rows.reduce( + (acc, row) => { + const device = row.device + const sensor = row.sensor + + if (!deviceMap.has(device.id)) { + const newDevice = { + device: { ...device, sensors: sensor ? [sensor] : [] }, + } + deviceMap.set(device.id, newDevice) + acc.push(newDevice) + } else if (sensor) { + deviceMap.get(device.id)!.device.sensors.push(sensor) + } + + return acc + }, + [] as Array<{ device: Device & { sensors: PartialSensor[] } }>, + ) + + for (const device of resultArray) { + const coordinates = [device.device.longitude, device.device.latitude] + const feature = point(coordinates, device.device) + geojson.features.push(feature) + } + + return geojson } interface BuildWhereClauseOptions { - name?: string; - phenomenon?: string; - fromDate?: string | Date; - toDate?: string | Date; - bbox?: { - coordinates: number[][][]; - }; - near?: [number, number]; // [lat, lng] - maxDistance?: number; - grouptag?: string[]; - exposure?: string[]; - model?: string[]; + name?: string + phenomenon?: string + fromDate?: string | Date + toDate?: string | Date + bbox?: { + coordinates: (number | undefined)[][][] + } + near?: [number, number] // [lat, lng] + maxDistance?: number + grouptag?: string[] + exposure?: string[] + model?: string[] } export interface FindDevicesOptions extends BuildWhereClauseOptions { - minimal?: string | boolean; - limit?: number; - format?: "json" | "geojson"; + minimal?: string | boolean + limit?: number + format?: 'json' | 'geojson' } interface WhereClauseResult { - includeColumns: Record; - whereClause: any[]; + includeColumns: Record + whereClause: any[] } const buildWhereClause = function buildWhereClause( - opts: BuildWhereClauseOptions = {} + opts: BuildWhereClauseOptions = {}, ): WhereClauseResult { - const { - name, - phenomenon, - fromDate, - toDate, - bbox, - near, - maxDistance, - grouptag, - } = opts; - const clause = []; - const columns = {}; - - if (name) { - clause.push(ilike(device.name, `%${name}%`)); - } - - if (phenomenon) { - // @ts-ignore - columns["sensors"] = { - // @ts-ignore - where: (sensor, { ilike }) => - // @ts-ignore - ilike(sensorTable["title"], `%${phenomenon}%`), - }; - } - - // simple string parameters - // for (const param of ['exposure', 'model'] as const) { - // if (opts[param]) { - // clause.push(inArray(device[param], opts[param]!)); - // } - // } - - if (grouptag) { - clause.push(arrayContains(device.tags, grouptag)); - } - - // https://orm.drizzle.team/learn/guides/postgis-geometry-point - if (bbox) { - const [latSW, lngSW] = bbox.coordinates[0][0]; - const [latNE, lngNE] = bbox.coordinates[0][2]; - clause.push( - sql`ST_Contains( + const { + name, + phenomenon, + fromDate, + toDate, + bbox, + near, + maxDistance, + grouptag, + } = opts + const clause = [] + const columns = {} + + if (name) { + clause.push(ilike(device.name, `%${name}%`)) + } + + if (phenomenon) { + // @ts-ignore + columns['sensors'] = { + // @ts-ignore + where: (sensor, { ilike }) => + // @ts-ignore + ilike(sensorTable['title'], `%${phenomenon}%`), + } + } + + // simple string parameters + // for (const param of ['exposure', 'model'] as const) { + // if (opts[param]) { + // clause.push(inArray(device[param], opts[param]!)); + // } + // } + + if (grouptag) { + clause.push(arrayContains(device.tags, grouptag)) + } + + // https://orm.drizzle.team/learn/guides/postgis-geometry-point + if (bbox && bbox.coordinates[0]) { + const [latSW, lngSW] = bbox.coordinates[0][0] + const [latNE, lngNE] = bbox.coordinates[0][2] + clause.push( + sql`ST_Contains( ST_MakeEnvelope(${lngSW}, ${latSW}, ${lngNE}, ${latNE}, 4326), ST_SetSRID(ST_MakePoint(${device.longitude}, ${device.latitude}), 4326) - )` - ); - } + )`, + ) + } - if (near && maxDistance !== undefined) { - clause.push( - sql`ST_DWithin( + if (near && maxDistance !== undefined) { + clause.push( + sql`ST_DWithin( ST_SetSRID(ST_MakePoint(${device.longitude}, ${device.latitude}), 4326), ST_SetSRID(ST_MakePoint(${near[1]}, ${near[0]}), 4326), ${maxDistance} - )` - ); - } - - if (phenomenon && (fromDate || toDate)) { - // @ts-ignore - columns["sensors"] = { - include: { - measurements: { - where: (measurement: any) => { - const conditions = []; - - if (fromDate && toDate) { - conditions.push( - sql`${measurement.createdAt} BETWEEN ${fromDate} AND ${toDate}` - ); - } else if (fromDate) { - conditions.push(sql`${measurement.createdAt} >= ${fromDate}`); - } else if (toDate) { - conditions.push(sql`${measurement.createdAt} <= ${toDate}`); - } - - return and(...conditions); - }, - }, - }, - }; - } - - return { - includeColumns: columns, - whereClause: clause, - }; -}; + )`, + ) + } + + if (phenomenon && (fromDate || toDate)) { + // @ts-ignore + columns['sensors'] = { + include: { + measurements: { + where: (measurement: any) => { + const conditions = [] + + if (fromDate && toDate) { + conditions.push( + sql`${measurement.createdAt} BETWEEN ${fromDate} AND ${toDate}`, + ) + } else if (fromDate) { + conditions.push(sql`${measurement.createdAt} >= ${fromDate}`) + } else if (toDate) { + conditions.push(sql`${measurement.createdAt} <= ${toDate}`) + } + + return and(...conditions) + }, + }, + }, + } + } + + return { + includeColumns: columns, + whereClause: clause, + } +} const MINIMAL_COLUMNS = { - id: true, - name: true, - exposure: true, - longitude: true, - latitude: true, -}; + id: true, + name: true, + exposure: true, + longitude: true, + latitude: true, +} const DEFAULT_COLUMNS = { - id: true, - name: true, - model: true, - exposure: true, - grouptag: true, - image: true, - description: true, - link: true, - createdAt: true, - updatedAt: true, - longitude: true, - latitude: true, -}; + id: true, + name: true, + model: true, + exposure: true, + grouptag: true, + image: true, + description: true, + link: true, + createdAt: true, + updatedAt: true, + longitude: true, + latitude: true, +} export async function findDevices( - opts: FindDevicesOptions = {}, - columns: Record = {}, - relations: Record = {} + opts: FindDevicesOptions = {}, + columns: Record = {}, + relations: Record = {}, ) { - const { minimal, limit } = opts; - const { includeColumns, whereClause } = buildWhereClause(opts); - columns = minimal ? MINIMAL_COLUMNS : { ...DEFAULT_COLUMNS, ...columns }; - relations = { - ...relations, - ...includeColumns, - }; - const devices = await drizzleClient.query.device.findMany({ - ...(Object.keys(columns).length !== 0 && { columns }), - ...(Object.keys(relations).length !== 0 && { with: relations }), - ...(Object.keys(whereClause).length !== 0 && { - where: (_, { and }) => and(...whereClause), - }), - limit, - }); - - return devices; + const { minimal, limit } = opts + const { includeColumns, whereClause } = buildWhereClause(opts) + columns = minimal ? MINIMAL_COLUMNS : { ...DEFAULT_COLUMNS, ...columns } + relations = { + ...relations, + ...includeColumns, + } + const devices = await drizzleClient.query.device.findMany({ + ...(Object.keys(columns).length !== 0 && { columns }), + ...(Object.keys(relations).length !== 0 && { with: relations }), + ...(Object.keys(whereClause).length !== 0 && { + where: (_, { and }) => and(...whereClause), + }), + limit, + }) + + return devices } export async function createDevice(deviceData: any, userId: string) { - try { - const [newDevice, usr] = await drizzleClient.transaction(async (tx) => { - // Get the user info - const [u] = await tx - .select() - .from(user) - .where(eq(user.id, userId)) - .limit(1); - - // Create the device - const [createdDevice] = await tx - .insert(device) - .values({ - id: deviceData.id, - useAuth: deviceData.useAuth ?? true, - model: deviceData.model, - tags: deviceData.tags, - userId: userId, - name: deviceData.name, - description: deviceData.description, - image: deviceData.image, - link: deviceData.link, - exposure: deviceData.exposure, - public: deviceData.public ?? false, - expiresAt: deviceData.expiresAt - ? new Date(deviceData.expiresAt) - : null, - latitude: deviceData.latitude, - longitude: deviceData.longitude, - }) - .returning(); - - if (!createdDevice) { - throw new Error("Failed to create device."); - } - - // Add sensors in the same transaction and collect them - const createdSensors = []; - if (deviceData.sensors && Array.isArray(deviceData.sensors)) { - for (const sensorData of deviceData.sensors) { - const [newSensor] = await tx - .insert(sensor) - .values({ - title: sensorData.title, - unit: sensorData.unit, - sensorType: sensorData.sensorType, - deviceId: createdDevice.id, // Reference the created device ID - }) - .returning(); - - if (newSensor) { - createdSensors.push(newSensor); - } - } - } - - // Return device with sensors - return [ - { - ...createdDevice, - sensors: createdSensors, - }, - u, - ]; - }); - - const lng = (usr.language?.split("_")[0] as "de" | "en") ?? "en"; - switch (newDevice.model) { - case "luftdaten.info": - await sendMail({ - recipientAddress: usr.email, - recipientName: usr.name, - subject: NewLufdatenDeviceMessages[lng].heading, - body: BaseNewDeviceEmail({ - user: { name: usr.name }, - device: newDevice, - language: lng, - content: NewLufdatenDeviceMessages, - }), - }); - break; - case "homeV2Ethernet": - case "homeV2Lora": - case "homeV2Wifi": - case "senseBox:Edu": - await sendMail({ - recipientAddress: usr.email, - recipientName: usr.name, - subject: NewSenseboxDeviceMessages[lng].heading, - body: BaseNewDeviceEmail({ - user: { name: usr.name }, - device: newDevice, - language: lng, - content: NewSenseboxDeviceMessages, - }), - }); - break; - default: - await sendMail({ - recipientAddress: usr.email, - recipientName: usr.name, - subject: BaseNewDeviceMessages[lng].heading, - body: BaseNewDeviceEmail({ - user: { name: usr.name }, - device: newDevice, - language: lng, - content: BaseNewDeviceMessages, - }), - }); - break; - } - - return newDevice; - } catch (error) { - console.error("Error creating device with sensors:", error); - throw new Error("Failed to create device and its sensors."); - } + try { + const newDevice = await drizzleClient.transaction(async (tx) => { + // Determine sensors to use + let sensorsToAdd = deviceData.sensors + + // If model and sensors are both specified, reject (backwards compatibility) + if (deviceData.model && deviceData.sensors) { + throw new Error( + 'Parameters model and sensors cannot be specified at the same time.', + ) + } + + // If model is specified but sensors are not, get sensors from model layout + if (deviceData.model && !deviceData.sensors) { + const modelSensors = getSensorsForModel(deviceData.model as any) + if (modelSensors) { + sensorsToAdd = modelSensors + } + } + + // Create the device + const [createdDevice] = await tx + .insert(device) + .values({ + id: deviceData.id, + useAuth: deviceData.useAuth ?? true, + model: deviceData.model, + tags: deviceData.tags, + userId: userId, + name: deviceData.name, + description: deviceData.description, + image: deviceData.image, + link: deviceData.link, + exposure: deviceData.exposure, + public: deviceData.public ?? false, + expiresAt: deviceData.expiresAt + ? new Date(deviceData.expiresAt) + : null, + latitude: deviceData.latitude, + longitude: deviceData.longitude, + }) + .returning() + + if (!createdDevice) { + throw new Error('Failed to create device.') + } + + // Add sensors in the same transaction and collect them + const createdSensors = [] + if ( + sensorsToAdd && + Array.isArray(sensorsToAdd) && + sensorsToAdd.length > 0 + ) { + for (const sensorData of sensorsToAdd) { + const [newSensor] = await tx + .insert(sensor) + .values({ + title: sensorData.title, + unit: sensorData.unit, + sensorType: sensorData.sensorType, + icon: sensorData.icon, + deviceId: createdDevice.id, + }) + .returning() + + if (newSensor) { + createdSensors.push(newSensor) + } + } + } + + // Return device with sensors + return { + ...createdDevice, + sensors: createdSensors, + } + }) + return newDevice + } catch (error) { + console.error('Error creating device with sensors:', error) + throw new Error( + `Failed to create device and its sensors: ${error instanceof Error ? error.message : String(error)}`, + ) + } } // get the 10 latest created (createdAt property) devices with id, name, latitude, and longitude export async function getLatestDevices() { - const devices = await drizzleClient - .select({ - id: device.id, - name: device.name, - latitude: device.latitude, - longitude: device.longitude, - }) - .from(device) - .orderBy(desc(device.createdAt)) - .limit(10); - - return devices; + const devices = await drizzleClient + .select({ + id: device.id, + name: device.name, + latitude: device.latitude, + longitude: device.longitude, + }) + .from(device) + .orderBy(desc(device.createdAt)) + .limit(10) + + return devices } export async function findAccessToken( - deviceId: string + deviceId: string, ): Promise<{ token: string } | null> { - const result = await drizzleClient.query.accessToken.findFirst({ - where: (token, { eq }) => eq(token.deviceId, deviceId), - }); + const result = await drizzleClient.query.accessToken.findFirst({ + where: (token, { eq }) => eq(token.deviceId, deviceId), + }) - if (!result || !result.token) return null; + if (!result || !result.token) return null - return { token: result.token }; + return { token: result.token } } diff --git a/app/routes/api.boxes.ts b/app/routes/api.boxes.ts index e867dbbc..ef39f30d 100644 --- a/app/routes/api.boxes.ts +++ b/app/routes/api.boxes.ts @@ -1,10 +1,10 @@ -import { type ActionFunction, type ActionFunctionArgs } from "react-router"; -import { transformDeviceToApiFormat } from "~/lib/device-transform"; -import { CreateBoxSchema } from "~/lib/devices-service.server"; -import { getUserFromJwt } from "~/lib/jwt"; -import { createDevice } from "~/models/device.server"; -import { type User } from "~/schema"; -import { StandardResponse } from "~/utils/response-utils"; +import { type ActionFunction, type ActionFunctionArgs } from 'react-router' +import { transformDeviceToApiFormat } from '~/lib/device-transform' +import { CreateBoxSchema } from '~/lib/devices-service.server' +import { getUserFromJwt } from '~/lib/jwt' +import { createDevice } from '~/models/device.server' +import { type User } from '~/schema' +import { StandardResponse } from '~/utils/response-utils' /** * @openapi @@ -325,71 +325,83 @@ import { StandardResponse } from "~/utils/response-utils"; */ export const action: ActionFunction = async ({ - request, + request, }: ActionFunctionArgs) => { - try { - // Check authentication - const jwtResponse = await getUserFromJwt(request); + try { + // Check authentication + const jwtResponse = await getUserFromJwt(request) - if (typeof jwtResponse === "string") - return StandardResponse.forbidden("Invalid JWT authorization. Please sign in to obtain new JWT."); + if (typeof jwtResponse === 'string') + return StandardResponse.forbidden( + 'Invalid JWT authorization. Please sign in to obtain new JWT.', + ) - switch (request.method) { - case "POST": - return await post(request, jwtResponse); - default: - return StandardResponse.methodNotAllowed("Method Not Allowed"); - } - } catch (err) { - console.error("Error in action:", err); - return StandardResponse.internalServerError(); - } -}; + switch (request.method) { + case 'POST': + return await post(request, jwtResponse) + default: + return StandardResponse.methodNotAllowed('Method Not Allowed') + } + } catch (err) { + console.error('Error in action:', err) + return StandardResponse.internalServerError() + } +} async function post(request: Request, user: User) { - try { - // Parse and validate request body - let requestData; - try { - requestData = await request.json(); - } catch { - return StandardResponse.badRequest("Invalid JSON in request body"); - } - - // Validate request data - const validationResult = CreateBoxSchema.safeParse(requestData); - if (!validationResult.success) { - return Response.json({ - code: "Bad Request", - message: "Invalid request data", - errors: validationResult.error.errors.map(err => `${err.path.join('.')}: ${err.message}`), - }, { status: 400 }); - } + try { + // Parse and validate request body + let requestData + try { + requestData = await request.json() + } catch { + return StandardResponse.badRequest('Invalid JSON in request body') + } - const validatedData = validationResult.data; + // Validate request data + const validationResult = CreateBoxSchema.safeParse(requestData) + if (!validationResult.success) { + return Response.json( + { + code: 'Bad Request', + message: 'Invalid request data', + errors: validationResult.error.errors.map( + (err) => `${err.path.join('.')}: ${err.message}`, + ), + }, + { status: 400 }, + ) + } - // Extract longitude and latitude from location array [longitude, latitude] - const [longitude, latitude] = validatedData.location; - const newBox = await createDevice({ - name: validatedData.name, - exposure: validatedData.exposure, - model: validatedData.model, - latitude: latitude, - longitude: longitude, - tags: validatedData.grouptag, - sensors: validatedData.sensors.map(sensor => ({ - title: sensor.title, - sensorType: sensor.sensorType, - unit: sensor.unit, - })), - }, user.id); + const validatedData = validationResult.data + const sensorsProvided = validatedData.sensors?.length > 0 + // Extract longitude and latitude from location array [longitude, latitude] + const [longitude, latitude] = validatedData.location + const newBox = await createDevice( + { + name: validatedData.name, + exposure: validatedData.exposure, + model: sensorsProvided ? undefined : validatedData.model, + latitude: latitude, + longitude: longitude, + tags: validatedData.grouptag, + sensors: sensorsProvided + ? validatedData.sensors.map((s) => ({ + title: s.title, + sensorType: s.sensorType, + unit: s.unit, + })) + : undefined, + }, + user.id, + ) - // Build response object using helper function - const responseData = transformDeviceToApiFormat(newBox); + // Build response object using helper function + const responseData = transformDeviceToApiFormat(newBox) - return StandardResponse.created(responseData); - } catch (err) { - console.error("Error creating box:", err); - return StandardResponse.internalServerError(); - } + return StandardResponse.created(responseData) + } catch (err) { + console.error('Error creating box:', err) + return StandardResponse.internalServerError() + } } diff --git a/app/routes/api.device.$deviceId.ts b/app/routes/api.device.$deviceId.ts index 5d31aa3c..663c68f6 100644 --- a/app/routes/api.device.$deviceId.ts +++ b/app/routes/api.device.$deviceId.ts @@ -1,6 +1,13 @@ -import { type LoaderFunctionArgs } from "react-router"; -import { getDevice } from "~/models/device.server"; -import { StandardResponse } from "~/utils/response-utils"; +import { type ActionFunctionArgs, type LoaderFunctionArgs } from 'react-router' +import { transformDeviceToApiFormat } from '~/lib/device-transform' +import { getUserFromJwt } from '~/lib/jwt' +import { + DeviceUpdateError, + getDevice, + updateDevice, + type UpdateDeviceArgs, +} from '~/models/device.server' +import { StandardResponse } from '~/utils/response-utils' /** * @openapi @@ -63,21 +70,185 @@ import { StandardResponse } from "~/utils/response-utils"; * description: Internal server error */ export async function loader({ params }: LoaderFunctionArgs) { - const { deviceId } = params; + const { deviceId } = params - if (!deviceId) - return StandardResponse.badRequest("Device ID is required."); + if (!deviceId) return StandardResponse.badRequest('Device ID is required.') - try { - const device = await getDevice({ id: deviceId }); + try { + const device = await getDevice({ id: deviceId }) - if (!device) - return StandardResponse.notFound("Device not found."); + if (!device) return StandardResponse.notFound('Device not found.') - return StandardResponse.ok(device); - } catch (error) { - console.error("Error fetching box:", error); + return StandardResponse.ok(device) + } catch (error) { + console.error('Error fetching box:', error) - return StandardResponse.internalServerError(); - } + if (error instanceof Response) { + throw error + } + + return new Response( + JSON.stringify({ error: 'Internal server error while fetching box' }), + { + status: 500, + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + }, + ) + } +} + +export async function action({ request, params }: ActionFunctionArgs) { + const { deviceId } = params + + if (!deviceId) { + return Response.json({ error: 'Device ID is required.' }, { status: 400 }) + } + + const jwtResponse = await getUserFromJwt(request) + + if (typeof jwtResponse === 'string') { + return Response.json( + { + code: 'Forbidden', + message: + 'Invalid JWT authorization. Please sign in to obtain a new JWT.', + }, + { status: 403 }, + ) + } + + switch (request.method) { + case 'PUT': + return await put(request, jwtResponse, deviceId) + default: + return Response.json({ message: 'Method Not Allowed' }, { status: 405 }) + } +} + +async function put(request: Request, user: any, deviceId: string) { + const body = await request.json() + + const currentDevice = await getDevice({ id: deviceId }) + if (!currentDevice) { + return Response.json( + { code: 'NotFound', message: 'Device not found' }, + { status: 404 }, + ) + } + + // Check for conflicting parameters (backwards compatibility) + if (body.sensors && body.addons?.add) { + return Response.json( + { + code: 'BadRequest', + message: 'sensors and addons can not appear in the same request.', + }, + { status: 400 }, + ) + } + + if (body.addons?.add === 'feinstaub') { + const homeModels = ['homeWifi', 'homeEthernet'] + if (currentDevice.model && homeModels.includes(currentDevice.model)) { + body.model = `${currentDevice.model}Feinstaub` + + const hasPM10 = currentDevice.sensors.some( + (s) => s.sensorType === 'SDS 011' && s.title === 'PM10', + ) + const hasPM25 = currentDevice.sensors.some( + (s) => s.sensorType === 'SDS 011' && s.title === 'PM2.5', + ) + + if (!hasPM10 || !hasPM25) { + body.sensors = [ + ...(body.sensors ?? []), + !hasPM10 && { + new: true, + title: 'PM10', + unit: 'µg/m³', + sensorType: 'SDS 011', + // icon: 'osem-cloud', + }, + !hasPM25 && { + new: true, + title: 'PM2.5', + unit: 'µg/m³', + sensorType: 'SDS 011', + // icon: 'osem-cloud', + }, + ].filter(Boolean) + } + } + } + + // Handle addons (merge with grouptag) + if (body.addons?.add) { + const currentTags = Array.isArray(body.grouptag) ? body.grouptag : [] + body.grouptag = Array.from(new Set([...currentTags, body.addons.add])) + } + + // Handle image deletion + if (body.deleteImage === true) { + body.image = '' + } + + // Prepare location if provided + let locationData: { lat: number; lng: number; height?: number } | undefined + if (body.location) { + locationData = { + lat: body.location.lat, + lng: body.location.lng, + } + if (body.location.height !== undefined) { + locationData.height = body.location.height + } + } + + const updateArgs: UpdateDeviceArgs = { + name: body.name, + exposure: body.exposure, + description: body.description, + image: body.image, + model: body.model, + useAuth: body.useAuth, + link: body.weblink, + location: locationData, + grouptag: body.grouptag, + sensors: body.sensors, + } + + try { + const updatedDevice = await updateDevice(deviceId, updateArgs) + + const deviceWithSensors = await getDevice({ id: updatedDevice.id }) + + const apiResponse = transformDeviceToApiFormat(deviceWithSensors as any) + + return Response.json(apiResponse, { status: 200 }) + } catch (error) { + console.error('Error updating device:', error) + + // Handle specific device update errors + if (error instanceof DeviceUpdateError) { + return Response.json( + { + code: error.statusCode === 400 ? 'BadRequest' : 'NotFound', + message: error.message, + }, + { status: error.statusCode }, + ) + } + + // Return generic error for unexpected errors + return Response.json( + { + code: 'InternalServerError', + message: + error instanceof Error ? error.message : 'Failed to update device', + }, + { status: 500 }, + ) + } } diff --git a/app/routes/api.ts b/app/routes/api.ts index 2a15d59d..7dfe7e57 100644 --- a/app/routes/api.ts +++ b/app/routes/api.ts @@ -1,224 +1,224 @@ -import { type LoaderFunctionArgs } from "react-router"; +import { type LoaderFunctionArgs } from 'react-router' -type RouteInfo = { path: string; method: "GET" | "PUT" | "POST" | "DELETE" }; +type RouteInfo = { path: string; method: 'GET' | 'PUT' | 'POST' | 'DELETE' } const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = { - noauth: [ - { - path: "/", - method: "GET", - }, - { - path: "/stats", - method: "GET", - }, - { - path: "/tags", - method: "GET", - }, - // { - // path: `statistics/idw`, - // method: "GET", + noauth: [ + { + path: '/', + method: 'GET', + }, + { + path: '/stats', + method: 'GET', + }, + { + path: '/tags', + method: 'GET', + }, + // { + // path: `statistics/idw`, + // method: "GET", - // }, - // { - // path: `statistics/descriptive`, - // method: "GET", + // }, + // { + // path: `statistics/descriptive`, + // method: "GET", - // }, - { - path: `boxes`, - method: "GET", - }, - // { - // path: `boxes/data`, - // method: "GET", - // }, + // }, + { + path: `boxes`, + method: 'GET', + }, + // { + // path: `boxes/data`, + // method: "GET", + // }, - // { - // path: `boxes/:boxId`, - // method: "GET", - // }, - { - path: `boxes/:boxId/sensors`, - method: "GET", - }, - { - path: `boxes/:boxId/sensors/:sensorId`, - method: "GET", - }, - // { - // path: `boxes/:boxId/data/:sensorId`, - // method: "GET", - // }, - // { - // path: `boxes/:boxId/locations`, - // method: "GET", - // }, - // { - // path: `boxes/data`, - // method: "POST", - // }, - { - path: `boxes/:boxId/data`, - method: "POST", - }, - { - path: `boxes/:boxId/:sensorId`, - method: "POST", - }, - { - path: `users/register`, - method: "POST", - }, - { - path: `users/request-password-reset`, - method: "POST", - }, - { - path: `users/password-reset`, - method: "POST", - }, - { - path: `users/confirm-email`, - method: "POST", - }, - { - path: `users/sign-in`, - method: "POST", - }, - ], - auth: [ - { - path: `users/refresh-auth`, - method: "POST", - }, - { - path: `users/me`, - method: "GET", - }, - { - path: `users/me`, - method: "PUT", - }, - { - path: `users/me/boxes`, - method: "GET", - }, - { - path: `users/me/boxes/:boxId`, - method: "GET", - }, - // { - // path: `boxes/:boxId/script`, - // method: "GET", - // }, - { - path: `boxes`, - method: "POST", - }, - { - path: `boxes/claim`, - method: "POST", - }, - { - path: `boxes/transfer`, - method: "POST", - }, - { - path: `boxes/transfer`, - method: "DELETE", - }, - { - path: `boxes/transfer/:boxId`, - method: "GET", - }, - { - path: `boxes/transfer/:boxId`, - method: "PUT", - }, - // { - // path: `boxes/:boxId`, - // method: "PUT", - // }, - { - path: `boxes/:boxId`, - method: "DELETE", - }, - // { - // path: `boxes/:boxId/:sensorId/measurements`, - // method: "DELETE", - // }, - { - path: `users/sign-out`, - method: "POST", - }, - { - path: `users/me`, - method: "DELETE", - }, - { - path: `users/me/resend-email-confirmation`, - method: "POST", - }, - ], - // management: [ - // { - // path: `${managementPath}/boxes`, - // method: "GET", - // }, - // { - // path: `${managementPath}/boxes/:boxId`, - // method: "GET", - // }, - // { - // path: `${managementPath}/boxes/:boxId`, - // method: "PUT", - // }, - // { - // path: `${managementPath}/boxes/delete`, - // method: "POST", - // }, - // { - // path: `${managementPath}/users`, - // method: "GET", - // }, - // { - // path: `${managementPath}/users/:userId`, - // method: "GET", - // }, - // { - // path: `${managementPath}/users/:userId`, - // method: "PUT", - // }, - // { - // path: `${managementPath}/users/delete`, - // method: "POST", - // }, - // { - // path: `${managementPath}/users/:userId/exec`, - // method: "POST", - // }, - // ], -}; + // { + // path: `boxes/:boxId`, + // method: "GET", + // }, + { + path: `boxes/:boxId/sensors`, + method: 'GET', + }, + { + path: `boxes/:boxId/sensors/:sensorId`, + method: 'GET', + }, + // { + // path: `boxes/:boxId/data/:sensorId`, + // method: "GET", + // }, + // { + // path: `boxes/:boxId/locations`, + // method: "GET", + // }, + // { + // path: `boxes/data`, + // method: "POST", + // }, + { + path: `boxes/:boxId/data`, + method: 'POST', + }, + { + path: `boxes/:boxId/:sensorId`, + method: 'POST', + }, + { + path: `users/register`, + method: 'POST', + }, + { + path: `users/request-password-reset`, + method: 'POST', + }, + { + path: `users/password-reset`, + method: 'POST', + }, + { + path: `users/confirm-email`, + method: 'POST', + }, + { + path: `users/sign-in`, + method: 'POST', + }, + ], + auth: [ + { + path: `users/refresh-auth`, + method: 'POST', + }, + { + path: `users/me`, + method: 'GET', + }, + { + path: `users/me`, + method: 'PUT', + }, + { + path: `users/me/boxes`, + method: 'GET', + }, + { + path: `users/me/boxes/:boxId`, + method: 'GET', + }, + // { + // path: `boxes/:boxId/script`, + // method: "GET", + // }, + { + path: `boxes`, + method: 'POST', + }, + { + path: `boxes/claim`, + method: 'POST', + }, + { + path: `boxes/transfer`, + method: 'POST', + }, + { + path: `boxes/transfer`, + method: 'DELETE', + }, + { + path: `boxes/transfer/:boxId`, + method: 'GET', + }, + { + path: `boxes/transfer/:boxId`, + method: 'PUT', + }, + { + path: `boxes/:boxId`, + method: 'PUT', + }, + { + path: `boxes/:boxId`, + method: 'DELETE', + }, + // { + // path: `boxes/:boxId/:sensorId/measurements`, + // method: "DELETE", + // }, + { + path: `users/sign-out`, + method: 'POST', + }, + { + path: `users/me`, + method: 'DELETE', + }, + { + path: `users/me/resend-email-confirmation`, + method: 'POST', + }, + ], + // management: [ + // { + // path: `${managementPath}/boxes`, + // method: "GET", + // }, + // { + // path: `${managementPath}/boxes/:boxId`, + // method: "GET", + // }, + // { + // path: `${managementPath}/boxes/:boxId`, + // method: "PUT", + // }, + // { + // path: `${managementPath}/boxes/delete`, + // method: "POST", + // }, + // { + // path: `${managementPath}/users`, + // method: "GET", + // }, + // { + // path: `${managementPath}/users/:userId`, + // method: "GET", + // }, + // { + // path: `${managementPath}/users/:userId`, + // method: "PUT", + // }, + // { + // path: `${managementPath}/users/delete`, + // method: "POST", + // }, + // { + // path: `${managementPath}/users/:userId/exec`, + // method: "POST", + // }, + // ], +} export async function loader({}: LoaderFunctionArgs) { - const lines = [ - `This is the openSenseMap API`, - "You can find a detailed reference at https://docs.opensensemap.org\n", - "Routes requiring no authentication:", - ]; + const lines = [ + `This is the openSenseMap API`, + 'You can find a detailed reference at https://docs.opensensemap.org\n', + 'Routes requiring no authentication:', + ] - for (const r of routes.noauth) lines.push(`${r.method}\t${r.path}`); + for (const r of routes.noauth) lines.push(`${r.method}\t${r.path}`) - lines.push("\nRoutes requiring valid authentication through JWT:"); + lines.push('\nRoutes requiring valid authentication through JWT:') - for (const r of routes.auth) lines.push(`${r.method}\t${r.path}`); + for (const r of routes.auth) lines.push(`${r.method}\t${r.path}`) - return new Response(lines.join("\n"), { - status: 200, - headers: { - "Content-Type": "text/plain; charset=utf-8", - }, - }); + return new Response(lines.join('\n'), { + status: 200, + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + }, + }) } diff --git a/app/routes/device.$deviceId.edit.general.tsx b/app/routes/device.$deviceId.edit.general.tsx index af37f226..743797e1 100644 --- a/app/routes/device.$deviceId.edit.general.tsx +++ b/app/routes/device.$deviceId.edit.general.tsx @@ -1,300 +1,302 @@ -import { Save } from "lucide-react"; -import React, { useState } from "react"; -import { type ActionFunctionArgs, type LoaderFunctionArgs, - data, - redirect, - Form, - useActionData, - useLoaderData, - useOutletContext } from "react-router"; -import invariant from "tiny-invariant"; -import ErrorMessage from "~/components/error-message"; +import { Save } from 'lucide-react' +import React, { useState } from 'react' import { - deleteDevice, - getDeviceWithoutSensors, - updateDeviceInfo, -} from "~/models/device.server"; -import { verifyLogin } from "~/models/user.server"; -import { getUserEmail, getUserId } from "~/utils/session.server"; + type ActionFunctionArgs, + type LoaderFunctionArgs, + data, + redirect, + Form, + useActionData, + useLoaderData, + useOutletContext, +} from 'react-router' +import invariant from 'tiny-invariant' +import ErrorMessage from '~/components/error-message' +import { + updateDevice, + deleteDevice, + getDeviceWithoutSensors, +} from '~/models/device.server' +import { verifyLogin } from '~/models/user.server' +import { getUserEmail, getUserId } from '~/utils/session.server' //***************************************************** export async function loader({ request, params }: LoaderFunctionArgs) { - //* if user is not logged in, redirect to home - const userId = await getUserId(request); - if (!userId) return redirect("/"); + //* if user is not logged in, redirect to home + const userId = await getUserId(request) + if (!userId) return redirect('/') - const deviceID = params.deviceId; + const deviceID = params.deviceId - if (typeof deviceID !== "string") { - return redirect("/profile/me"); - } + if (typeof deviceID !== 'string') { + return redirect('/profile/me') + } - const deviceData = await getDeviceWithoutSensors({ id: deviceID }); + const deviceData = await getDeviceWithoutSensors({ id: deviceID }) - return { device: deviceData }; + return { device: deviceData } } //***************************************************** export async function action({ request, params }: ActionFunctionArgs) { - const formData = await request.formData(); - const { intent, name, exposure, passwordDelete } = - Object.fromEntries(formData); + const formData = await request.formData() + const { intent, name, exposure, passwordDelete } = + Object.fromEntries(formData) + + const exposureLowerCase = exposure?.toString().toLowerCase() - const errors = { - exposure: exposure ? null : "Invalid exposure.", - passwordDelete: passwordDelete ? null : "Password is required.", - }; + const errors = { + exposure: exposure ? null : 'Invalid exposure.', + passwordDelete: passwordDelete ? null : 'Password is required.', + } - const deviceID = params.deviceId; - invariant(typeof deviceID === "string", " Device id not found."); - invariant(typeof name === "string", "Device name is required."); - invariant(typeof exposure === "string", "Device name is required."); + const deviceID = params.deviceId + invariant(typeof deviceID === 'string', ' Device id not found.') + invariant(typeof name === 'string', 'Device name is required.') + invariant(typeof exposure === 'string', 'Device name is required.') - if ( - exposure !== "indoor" && - exposure !== "outdoor" && - exposure !== "mobile" && - exposure !== "unknown" - ) { - return data({ - errors: { - exposure: exposure ? null : "Invalid exposure.", - passwordDelete: errors.passwordDelete, - }, - status: 400, - }); - } + if ( + exposureLowerCase !== 'indoor' && + exposureLowerCase !== 'outdoor' && + exposureLowerCase !== 'mobile' && + exposureLowerCase !== 'unknown' + ) { + return data({ + errors: { + exposure: exposure ? null : 'Invalid exposure.', + passwordDelete: errors.passwordDelete, + }, + status: 400, + }) + } - switch (intent) { - case "save": { - await updateDeviceInfo({ id: deviceID, name: name, exposure: exposure }); - return data({ - errors: { - exposure: null, - passwordDelete: null, - }, - status: 200, - }); - } - case "delete": { - //* check password validaty - if (errors.passwordDelete) { - return data({ - errors, - status: 400, - }); - } - //* 1. get user email - const userEmail = await getUserEmail(request); - invariant(typeof userEmail === "string", "email not found"); - invariant( - typeof passwordDelete === "string", - "password must be a string", - ); - //* 2. check entered password - const user = await verifyLogin(userEmail, passwordDelete); - //* 3. retrun error if password is not correct - if (!user) { - return data( - { - errors: { - exposure: exposure ? null : "Invalid exposure.", - passwordDelete: "Invalid password", - }, - }, - { status: 400 }, - ); - } - //* 4. delete device - await deleteDevice({ id: deviceID }); + switch (intent) { + case 'save': { + await updateDevice(deviceID, { name, exposure: exposureLowerCase }) + return data({ + errors: { + exposure: null, + passwordDelete: null, + }, + status: 200, + }) + } + case 'delete': { + //* check password validaty + if (errors.passwordDelete) { + return data({ + errors, + status: 400, + }) + } + //* 1. get user email + const userEmail = await getUserEmail(request) + invariant(typeof userEmail === 'string', 'email not found') + invariant(typeof passwordDelete === 'string', 'password must be a string') + //* 2. check entered password + const user = await verifyLogin(userEmail, passwordDelete) + //* 3. retrun error if password is not correct + if (!user) { + return data( + { + errors: { + exposure: exposure ? null : 'Invalid exposure.', + passwordDelete: 'Invalid password', + }, + }, + { status: 400 }, + ) + } + //* 4. delete device + await deleteDevice({ id: deviceID }) - return redirect("/profile/me"); - } - } + return redirect('/profile/me') + } + } - return redirect(""); + return redirect('') } //********************************** export default function () { - const { device } = useLoaderData(); - const actionData = useActionData(); - const [passwordDelVal, setPasswordVal] = useState(""); //* to enable delete account button - //* focus when an error occured - const nameRef = React.useRef(null); - const passwordDelRef = React.useRef(null); - const [name, setName] = useState(device?.name); - const [exposure, setExposure] = useState(device?.exposure); - //* to view toast on edit page - const [setToastOpen] = useOutletContext<[(_open: boolean) => void]>(); + const { device } = useLoaderData() + const actionData = useActionData() + const [passwordDelVal, setPasswordVal] = useState('') //* to enable delete account button + //* focus when an error occured + const nameRef = React.useRef(null) + const passwordDelRef = React.useRef(null) + const [name, setName] = useState(device?.name) + const [exposure, setExposure] = useState(device?.exposure) + //* to view toast on edit page + const [setToastOpen] = useOutletContext<[(_open: boolean) => void]>() - React.useEffect(() => { - if (actionData) { - const hasErrors = Object.values(actionData?.errors).some( - (errorMessage) => errorMessage, - ); + React.useEffect(() => { + if (actionData) { + const hasErrors = Object.values(actionData?.errors).some( + (errorMessage) => errorMessage, + ) - //* when device data updated successfully - if (!hasErrors) { - setToastOpen(true); - // setToastOpenTest(true); - } - //* when password is null - else if (hasErrors && actionData?.errors?.passwordDelete) { - passwordDelRef.current?.focus(); - } - } - }, [actionData, setToastOpen]); + //* when device data updated successfully + if (!hasErrors) { + setToastOpen(true) + // setToastOpenTest(true); + } + //* when password is null + else if (hasErrors && actionData?.errors?.passwordDelete) { + passwordDelRef.current?.focus() + } + } + }, [actionData, setToastOpen]) - return ( -
- {/* general form */} -
-
- {/* Form */} -
- {/* Heading */} -
- {/* Title */} -
-
-

General

-
-
- -
-
-
+ return ( +
+ {/* general form */} +
+
+ {/* Form */} + + {/* Heading */} +
+ {/* Title */} +
+
+

General

+
+
+ +
+
+
- {/* divider */} -
+ {/* divider */} +
-
- {/* */} - {/* Name */} -
- +
+ {/* */} + {/* Name */} +
+ -
- setName(e.target.value)} - ref={nameRef} - aria-describedby="name-error" - className="w-full rounded border border-gray-200 px-2 py-1 text-base" - /> -
-
+
+ setName(e.target.value)} + ref={nameRef} + aria-describedby="name-error" + className="w-full rounded border border-gray-200 px-2 py-1 text-base" + /> +
+
- {/* Exposure */} -
- + {/* Exposure */} +
+ -
- -
-
+
+ +
+
- {/* Delete device */} -
-

- Delete senseBox -

-
+ {/* Delete device */} +
+

+ Delete senseBox +

+
-
-

- If you really want to delete your station, please type your - current password - all measurements will be deleted as well. -

-
-
- setPasswordVal(e.target.value)} - /> - {actionData?.errors?.passwordDelete && ( -
- {actionData.errors.passwordDelete} -
- )} -
- {/* Delete button */} -
- -
- {/* */} -
- -
-
-
- ); +
+

+ If you really want to delete your station, please type your + current password - all measurements will be deleted as well. +

+
+
+ setPasswordVal(e.target.value)} + /> + {actionData?.errors?.passwordDelete && ( +
+ {actionData.errors.passwordDelete} +
+ )} +
+ {/* Delete button */} +
+ +
+ {/* */} +
+ +
+
+
+ ) } export function ErrorBoundary() { - return ( -
- -
- ); + return ( +
+ +
+ ) } diff --git a/app/schema/enum.ts b/app/schema/enum.ts index da4eb094..ba7cd39e 100644 --- a/app/schema/enum.ts +++ b/app/schema/enum.ts @@ -1,35 +1,45 @@ -import { pgEnum } from "drizzle-orm/pg-core"; -import { z } from "zod"; +import { pgEnum } from 'drizzle-orm/pg-core' +import { z } from 'zod' // Enum for device exposure types -export const DeviceExposureEnum = pgEnum("exposure", [ - "indoor", - "outdoor", - "mobile", - "unknown", -]); +export const DeviceExposureEnum = pgEnum('exposure', [ + 'indoor', + 'outdoor', + 'mobile', + 'unknown', +]) // Zod schema for validating device exposure types -export const DeviceExposureZodEnum = z.enum(DeviceExposureEnum.enumValues); +export const DeviceExposureZodEnum = z.enum(DeviceExposureEnum.enumValues) // Type inferred from the Zod schema for device exposure types -export type DeviceExposureType = z.infer; +export type DeviceExposureType = z.infer // Enum for device status types -export const DeviceStatusEnum = pgEnum("status", ["active", "inactive", "old"]); +export const DeviceStatusEnum = pgEnum('status', ['active', 'inactive', 'old']) // Zod schema for validating device status types -export const DeviceStatusZodEnum = z.enum(DeviceStatusEnum.enumValues); +export const DeviceStatusZodEnum = z.enum(DeviceStatusEnum.enumValues) // Type inferred from the Zod schema for device status types -export type DeviceStatusType = z.infer; +export type DeviceStatusType = z.infer // Enum for device model types -export const DeviceModelEnum = pgEnum("model", [ - "homeV2Lora", - "homeV2Ethernet", - "homeV2Wifi", - "senseBox:Edu", - "luftdaten.info", - "Custom", -]); +export const DeviceModelEnum = pgEnum('model', [ + 'homeV2Lora', + 'homeV2Ethernet', + 'homeV2Wifi', + 'homeEthernet', + 'homeWifi', + 'homeEthernetFeinstaub', + 'homeWifiFeinstaub', + 'luftdaten_sds011', + 'luftdaten_sds011_dht11', + 'luftdaten_sds011_dht22', + 'luftdaten_sds011_bmp180', + 'luftdaten_sds011_bme280', + 'hackair_home_v2', + 'senseBox:Edu', + 'luftdaten.info', + 'Custom', +]) diff --git a/app/schema/sensor.ts b/app/schema/sensor.ts index 6c823aa1..949ccfe9 100644 --- a/app/schema/sensor.ts +++ b/app/schema/sensor.ts @@ -10,7 +10,7 @@ import { device } from "./device"; import { DeviceStatusEnum } from "./enum"; import { type Measurement } from "./measurement"; -function generateHexId(): string { +export function generateHexId(): string { return randomBytes(12).toString('hex'); } @@ -34,6 +34,7 @@ export const sensor = pgTable("sensor", { title: text("title"), unit: text("unit"), sensorType: text("sensor_type"), + icon: text("icon"), status: DeviceStatusEnum("status").default("inactive"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), diff --git a/app/utils/addon-definitions.ts b/app/utils/addon-definitions.ts new file mode 100644 index 00000000..04ca03d9 --- /dev/null +++ b/app/utils/addon-definitions.ts @@ -0,0 +1,19 @@ +export const addonDefinitions: Record< + string, + { title: string; unit: string; sensorType: string; icon?: string }[] +> = { + feinstaub: [ + { + title: 'PM10', + unit: 'µg/m³', + sensorType: 'SDS 011', + icon: 'particulate_matter', + }, + { + title: 'PM2.5', + unit: 'µg/m³', + sensorType: 'SDS 011', + icon: 'particulate_matter', + }, + ], +} diff --git a/app/utils/model-definitions.ts b/app/utils/model-definitions.ts index 33a1bd8d..77218258 100644 --- a/app/utils/model-definitions.ts +++ b/app/utils/model-definitions.ts @@ -1,91 +1,122 @@ -import { sensorDefinitions } from "./sensor-definitions"; +import { sensorDefinitions } from './sensor-definitions' // Models Definition export const modelDefinitions = { - senseBoxHomeV2: [ - sensorDefinitions.hdc1080_temperature, - sensorDefinitions.hdc1080_humidity, - sensorDefinitions.bmp280_pressure, - sensorDefinitions.tsl45315_lightintensity, - sensorDefinitions.veml6070_uvintensity, - sensorDefinitions.sds011_pm10, - sensorDefinitions.sds011_pm25, - sensorDefinitions.bme680_humidity, - sensorDefinitions.bme680_temperature, - sensorDefinitions.bme680_pressure, - sensorDefinitions.bme680_voc, - sensorDefinitions.smt50_soilmoisture, - sensorDefinitions.smt50_soiltemperature, - sensorDefinitions.soundlevelmeter, - sensorDefinitions.windspeed, - sensorDefinitions.scd30_co2, - sensorDefinitions.dps310_pressure, - sensorDefinitions.sps30_pm1, - sensorDefinitions.sps30_pm4, - sensorDefinitions.sps30_pm10, - sensorDefinitions.sps30_pm25, - ], - "senseBox:Edu": [ - sensorDefinitions.hdc1080_temperature, - sensorDefinitions.hdc1080_humidity, - sensorDefinitions.bmp280_pressure, - sensorDefinitions.tsl45315_lightintensity, - sensorDefinitions.veml6070_uvintensity, - sensorDefinitions.sds011_pm10, - sensorDefinitions.sds011_pm25, - sensorDefinitions.bme680_humidity, - sensorDefinitions.bme680_temperature, - sensorDefinitions.bme680_pressure, - sensorDefinitions.bme680_voc, - sensorDefinitions.smt50_soilmoisture, - sensorDefinitions.smt50_soiltemperature, - sensorDefinitions.soundlevelmeter, - sensorDefinitions.windspeed, - sensorDefinitions.scd30_co2, - sensorDefinitions.dps310_pressure, - sensorDefinitions.sps30_pm1, - sensorDefinitions.sps30_pm4, - sensorDefinitions.sps30_pm10, - sensorDefinitions.sps30_pm25, - ], - "luftdaten.info": [ - sensorDefinitions.pms1003_pm01, - sensorDefinitions.pms1003_pm10, - sensorDefinitions.pms1003_pm25, - sensorDefinitions.pms3003_pm01, - sensorDefinitions.pms3003_pm10, - sensorDefinitions.pms3003_pm25, - sensorDefinitions.pms5003_pm01, - sensorDefinitions.pms5003_pm10, - sensorDefinitions.pms5003_pm25, - sensorDefinitions.pms7003_pm01, - sensorDefinitions.pms7003_pm10, - sensorDefinitions.pms7003_pm25, - sensorDefinitions.sds011_pm10, - sensorDefinitions.sds011_pm25, - sensorDefinitions.sps30_pm1, - sensorDefinitions.sps30_pm4, - sensorDefinitions.sps30_pm10, - sensorDefinitions.sps30_pm25, - sensorDefinitions.sht3x_humidity, - sensorDefinitions.sht3x_temperature, - sensorDefinitions.bmp180_temperature, - sensorDefinitions.bmp180_pressure_pa, - sensorDefinitions.bmp180_pressure_hpa, - sensorDefinitions.bme280_humidity, - sensorDefinitions.bme280_temperature, - sensorDefinitions.bme280_pressure_pa, - sensorDefinitions.bme280_pressure_hpa, - sensorDefinitions.dht11_humidity, - sensorDefinitions.dht11_temperature, - sensorDefinitions.dht22_humidity, - sensorDefinitions.dht22_temperature, - ], - // if custom, return all sensors - Custom: Object.values(sensorDefinitions), -}; + senseBoxHomeV2: [ + sensorDefinitions.hdc1080_temperature, + sensorDefinitions.hdc1080_humidity, + sensorDefinitions.bmp280_pressure, + sensorDefinitions.tsl45315_lightintensity, + sensorDefinitions.veml6070_uvintensity, + sensorDefinitions.sds011_pm10, + sensorDefinitions.sds011_pm25, + sensorDefinitions.bme680_humidity, + sensorDefinitions.bme680_temperature, + sensorDefinitions.bme680_pressure, + sensorDefinitions.bme680_voc, + sensorDefinitions.smt50_soilmoisture, + sensorDefinitions.smt50_soiltemperature, + sensorDefinitions.soundlevelmeter, + sensorDefinitions.windspeed, + sensorDefinitions.scd30_co2, + sensorDefinitions.dps310_pressure, + sensorDefinitions.sps30_pm1, + sensorDefinitions.sps30_pm4, + sensorDefinitions.sps30_pm10, + sensorDefinitions.sps30_pm25, + ], + 'senseBox:Edu': [ + sensorDefinitions.hdc1080_temperature, + sensorDefinitions.hdc1080_humidity, + sensorDefinitions.bmp280_pressure, + sensorDefinitions.tsl45315_lightintensity, + sensorDefinitions.veml6070_uvintensity, + sensorDefinitions.sds011_pm10, + sensorDefinitions.sds011_pm25, + sensorDefinitions.bme680_humidity, + sensorDefinitions.bme680_temperature, + sensorDefinitions.bme680_pressure, + sensorDefinitions.bme680_voc, + sensorDefinitions.smt50_soilmoisture, + sensorDefinitions.smt50_soiltemperature, + sensorDefinitions.soundlevelmeter, + sensorDefinitions.windspeed, + sensorDefinitions.scd30_co2, + sensorDefinitions.dps310_pressure, + sensorDefinitions.sps30_pm1, + sensorDefinitions.sps30_pm4, + sensorDefinitions.sps30_pm10, + sensorDefinitions.sps30_pm25, + ], + 'luftdaten.info': [ + sensorDefinitions.pms1003_pm01, + sensorDefinitions.pms1003_pm10, + sensorDefinitions.pms1003_pm25, + sensorDefinitions.pms3003_pm01, + sensorDefinitions.pms3003_pm10, + sensorDefinitions.pms3003_pm25, + sensorDefinitions.pms5003_pm01, + sensorDefinitions.pms5003_pm10, + sensorDefinitions.pms5003_pm25, + sensorDefinitions.pms7003_pm01, + sensorDefinitions.pms7003_pm10, + sensorDefinitions.pms7003_pm25, + sensorDefinitions.sds011_pm10, + sensorDefinitions.sds011_pm25, + sensorDefinitions.sps30_pm1, + sensorDefinitions.sps30_pm4, + sensorDefinitions.sps30_pm10, + sensorDefinitions.sps30_pm25, + sensorDefinitions.sht3x_humidity, + sensorDefinitions.sht3x_temperature, + sensorDefinitions.bmp180_temperature, + sensorDefinitions.bmp180_pressure_pa, + sensorDefinitions.bmp180_pressure_hpa, + sensorDefinitions.bme280_humidity, + sensorDefinitions.bme280_temperature, + sensorDefinitions.bme280_pressure_pa, + sensorDefinitions.bme280_pressure_hpa, + sensorDefinitions.dht11_humidity, + sensorDefinitions.dht11_temperature, + sensorDefinitions.dht22_humidity, + sensorDefinitions.dht22_temperature, + ], + homeEthernet: [ + sensorDefinitions.hdc1008_temperature, + sensorDefinitions.hdc1008_humidity, + sensorDefinitions.bmp280_pressure, + sensorDefinitions.tsl45315_lightintensity, + sensorDefinitions.veml6070_uvintensity, + ], + + homeWifi: [ + sensorDefinitions.hdc1008_temperature, + sensorDefinitions.hdc1008_humidity, + sensorDefinitions.bmp280_pressure, + sensorDefinitions.tsl45315_lightintensity, + sensorDefinitions.veml6070_uvintensity, + ], + homeEthernetFeinstaub: [ + sensorDefinitions.hdc1008_temperature, + sensorDefinitions.hdc1008_humidity, + sensorDefinitions.bmp280_pressure, + sensorDefinitions.tsl45315_lightintensity, + sensorDefinitions.veml6070_uvintensity, + sensorDefinitions.sds011_pm10, + sensorDefinitions.sds011_pm25, + ], + homeWifiFeinstaub: [ + sensorDefinitions.hdc1008_temperature, + sensorDefinitions.hdc1008_humidity, + sensorDefinitions.bmp280_pressure, + sensorDefinitions.tsl45315_lightintensity, + sensorDefinitions.veml6070_uvintensity, + sensorDefinitions.sds011_pm10, + sensorDefinitions.sds011_pm25, + ], +} // Exporting models export const getSensorsForModel = (model: keyof typeof modelDefinitions) => { - return modelDefinitions[model] || null; -}; + return modelDefinitions[model] || null +} diff --git a/drizzle/0023_red_chameleon.sql b/drizzle/0023_red_chameleon.sql new file mode 100644 index 00000000..afc6b666 --- /dev/null +++ b/drizzle/0023_red_chameleon.sql @@ -0,0 +1,11 @@ +ALTER TYPE "public"."model" ADD VALUE 'homeEthernet' BEFORE 'senseBox:Edu';--> statement-breakpoint +ALTER TYPE "public"."model" ADD VALUE 'homeWifi' BEFORE 'senseBox:Edu';--> statement-breakpoint +ALTER TYPE "public"."model" ADD VALUE 'homeEthernetFeinstaub' BEFORE 'senseBox:Edu';--> statement-breakpoint +ALTER TYPE "public"."model" ADD VALUE 'homeWifiFeinstaub' BEFORE 'senseBox:Edu';--> statement-breakpoint +ALTER TYPE "public"."model" ADD VALUE 'luftdaten_sds011' BEFORE 'senseBox:Edu';--> statement-breakpoint +ALTER TYPE "public"."model" ADD VALUE 'luftdaten_sds011_dht11' BEFORE 'senseBox:Edu';--> statement-breakpoint +ALTER TYPE "public"."model" ADD VALUE 'luftdaten_sds011_dht22' BEFORE 'senseBox:Edu';--> statement-breakpoint +ALTER TYPE "public"."model" ADD VALUE 'luftdaten_sds011_bmp180' BEFORE 'senseBox:Edu';--> statement-breakpoint +ALTER TYPE "public"."model" ADD VALUE 'luftdaten_sds011_bme280' BEFORE 'senseBox:Edu';--> statement-breakpoint +ALTER TYPE "public"."model" ADD VALUE 'hackair_home_v2' BEFORE 'senseBox:Edu';--> statement-breakpoint +ALTER TABLE "sensor" ADD COLUMN "icon" text; \ No newline at end of file diff --git a/drizzle/meta/0022_snapshot.json b/drizzle/meta/0022_snapshot.json index bb478b93..2437bf9a 100644 --- a/drizzle/meta/0022_snapshot.json +++ b/drizzle/meta/0022_snapshot.json @@ -1,1263 +1,1187 @@ { - "id": "95fc2b5e-a6d7-426d-bfd8-7c5238f5722b", - "prevId": "85481101-dd0d-4e15-9158-11971b8ba509", - "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.claim": { - "name": "claim", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "box_id": { - "name": "box_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": 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": { - "claim_expires_at_idx": { - "name": "claim_expires_at_idx", - "columns": [ - { - "expression": "expires_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "claim_box_id_device_id_fk": { - "name": "claim_box_id_device_id_fk", - "tableFrom": "claim", - "tableTo": "device", - "columnsFrom": [ - "box_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "unique_box_id": { - "name": "unique_box_id", - "nullsNotDistinct": false, - "columns": [ - "box_id" - ] - } - }, - "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 + "id": "95fc2b5e-a6d7-426d-bfd8-7c5238f5722b", + "prevId": "85481101-dd0d-4e15-9158-11971b8ba509", + "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.claim": { + "name": "claim", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "box_id": { + "name": "box_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": 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": { + "claim_expires_at_idx": { + "name": "claim_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "claim_box_id_device_id_fk": { + "name": "claim_box_id_device_id_fk", + "tableFrom": "claim", + "tableTo": "device", + "columnsFrom": ["box_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_box_id": { + "name": "unique_box_id", + "nullsNotDistinct": false, + "columns": ["box_id"] + } + }, + "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": {} + } +} diff --git a/drizzle/meta/0023_snapshot.json b/drizzle/meta/0023_snapshot.json new file mode 100644 index 00000000..4efa2740 --- /dev/null +++ b/drizzle/meta/0023_snapshot.json @@ -0,0 +1,1279 @@ +{ + "id": "0a8ab025-8015-4384-a495-a48b4fb17364", + "prevId": "95fc2b5e-a6d7-426d-bfd8-7c5238f5722b", + "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 + }, + "icon": { + "name": "icon", + "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.claim": { + "name": "claim", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "box_id": { + "name": "box_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": 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": { + "claim_expires_at_idx": { + "name": "claim_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "claim_box_id_device_id_fk": { + "name": "claim_box_id_device_id_fk", + "tableFrom": "claim", + "tableTo": "device", + "columnsFrom": [ + "box_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_box_id": { + "name": "unique_box_id", + "nullsNotDistinct": false, + "columns": [ + "box_id" + ] + } + }, + "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", + "homeEthernet", + "homeWifi", + "homeEthernetFeinstaub", + "homeWifiFeinstaub", + "luftdaten_sds011", + "luftdaten_sds011_dht11", + "luftdaten_sds011_dht22", + "luftdaten_sds011_bmp180", + "luftdaten_sds011_bme280", + "hackair_home_v2", + "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 176578a1..37051c5e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1761122113831, "tag": "0022_odd_sugar_man", "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1762933906507, + "tag": "0023_red_chameleon", + "breakpoints": true } ] } \ No newline at end of file diff --git a/tests/models/device.server.spec.ts b/tests/models/device.server.spec.ts index 87a96216..7424f9cd 100644 --- a/tests/models/device.server.spec.ts +++ b/tests/models/device.server.spec.ts @@ -42,7 +42,7 @@ describe('Device Model: createDevice', () => { latitude: 51.969, longitude: 7.596, exposure: 'outdoor', - model: 'homeV2Wifi', + // model: "homeV2Wifi", sensors: [ { title: 'Temperature', unit: '°C', sensorType: 'HDC1080' }, { title: 'Humidity', unit: '%', sensorType: 'HDC1080' }, @@ -101,18 +101,37 @@ describe('Device Model: createDevice', () => { expect(result.sensors).toHaveLength(0) }) + it('should create device with tags/grouptag', async () => { + const deviceData = { + name: 'Tagged Device', + latitude: 51.5, + longitude: 7.5, + exposure: 'outdoor', + // model: 'Custom', + tags: ['weather', 'city', 'test'], + sensors: [{ title: 'Temperature', unit: '°C', sensorType: 'DHT22' }], + } + + const result = await createDevice(deviceData, userId) + + createdDeviceIds.push(result.id) + expect(result).toHaveProperty('tags') + expect(Array.isArray(result.tags)).toBe(true) + expect(result.tags).toEqual(['weather', 'city', 'test']) + expect(result.sensors).toHaveLength(1) + }) + it('should create device with optional fields', async () => { const deviceData = { name: 'Full Featured Device', latitude: 51.0, longitude: 7.0, exposure: 'mobile', - model: 'homeV2Lora', description: 'A comprehensive test device', image: 'https://example.com/device.jpg', link: 'https://example.com', public: true, - tags: [], + tags: ['test'], sensors: [{ title: 'Temperature', unit: '°C', sensorType: 'SHT31' }], } @@ -124,7 +143,6 @@ describe('Device Model: createDevice', () => { expect(result).toHaveProperty('link', 'https://example.com') expect(result).toHaveProperty('public', true) expect(result).toHaveProperty('exposure', 'mobile') - expect(result).toHaveProperty('model', 'homeV2Lora') expect(result.sensors).toHaveLength(1) }) diff --git a/tests/routes/api.boxes.$deviceId.data.$sensorId.spec.ts b/tests/routes/api.boxes.$deviceId.data.$sensorId.spec.ts index e77ed60b..5abd4851 100644 --- a/tests/routes/api.boxes.$deviceId.data.$sensorId.spec.ts +++ b/tests/routes/api.boxes.$deviceId.data.$sensorId.spec.ts @@ -1,154 +1,158 @@ -import { type Params, type LoaderFunctionArgs } from "react-router"; -import { BASE_URL } from "vitest.setup"; -import { registerUser } from "~/lib/user-service.server"; -import { createDevice, deleteDevice } from "~/models/device.server"; -import { deleteMeasurementsForSensor, deleteMeasurementsForTime, insertMeasurements } from "~/models/measurement.server"; -import { getSensors } from "~/models/sensor.server"; -import { deleteUserByEmail } from "~/models/user.server"; -import { loader } from "~/routes/api.boxes.$deviceId.data.$sensorId"; -import { type Sensor, type Device, type User } from "~/schema"; +import { type Params, type LoaderFunctionArgs } from 'react-router' +import { BASE_URL } from 'vitest.setup' +import { registerUser } from '~/lib/user-service.server' +import { createDevice, deleteDevice } from '~/models/device.server' +import { + deleteMeasurementsForSensor, + deleteMeasurementsForTime, + insertMeasurements, +} from '~/models/measurement.server' +import { getSensors } from '~/models/sensor.server' +import { deleteUserByEmail } from '~/models/user.server' +import { loader } from '~/routes/api.boxes.$deviceId.data.$sensorId' +import { type Sensor, type Device, type User } from '~/schema' const DEVICE_SENSORS_ID_USER = { - name: "meTestSensorsIds", - email: "test@box.sensorids", - password: "highlySecurePasswordForTesting", -}; + name: 'meTestSensorIds', + email: 'test@box.sensorIds', + password: 'highlySecurePasswordForTesting', +} const DEVICE_SENSOR_ID_BOX = { - name: `${DEVICE_SENSORS_ID_USER}s Box`, - exposure: "outdoor", - expiresAt: null, - tags: [], - latitude: 0, - longitude: 0, - model: "luftdaten.info", - mqttEnabled: false, - ttnEnabled: false, - sensors: [ - { - title: "Temp", - unit: "°C", - sensorType: "dummy", - }, - { - title: "CO2", - unit: "mol/L", - sensorType: "dummy", - }, - { - title: "Air Pressure", - unit: "kPa", - sensorType: "dummy", - }, - ], -}; + name: `${DEVICE_SENSORS_ID_USER}s Box`, + exposure: 'outdoor', + expiresAt: null, + tags: [], + latitude: 0, + longitude: 0, + // model: "luftdaten.info", + mqttEnabled: false, + ttnEnabled: false, + sensors: [ + { + title: 'Temp', + unit: '°C', + sensorType: 'dummy', + }, + { + title: 'CO2', + unit: 'mol/L', + sensorType: 'dummy', + }, + { + title: 'Air Pressure', + unit: 'kPa', + sensorType: 'dummy', + }, + ], +} const MEASUREMENTS = [ { value: 1589625, createdAt: new Date('1954-06-07 12:00:00+00'), - sensor_id: "" + sensor_id: '', + }, + { + value: 3.14159, + createdAt: new Date('1988-03-14 1:59:26+00'), + sensor_id: '', }, - { - value: 3.14159, - createdAt: new Date('1988-03-14 1:59:26+00'), - sensor_id: "" - } ] -describe("openSenseMap API Routes: /api/boxes/:deviceId/data/:sensorId", () => { - let device: Device; - let deviceId: string = ""; - let sensors: Sensor[] = []; +describe('openSenseMap API Routes: /api/boxes/:deviceId/data/:sensorId', () => { + let device: Device + let deviceId: string = '' + let sensors: Sensor[] = [] - beforeAll(async () => { - const user = await registerUser( - DEVICE_SENSORS_ID_USER.name, - DEVICE_SENSORS_ID_USER.email, - DEVICE_SENSORS_ID_USER.password, - "en_US", - ); + beforeAll(async () => { + const user = await registerUser( + DEVICE_SENSORS_ID_USER.name, + DEVICE_SENSORS_ID_USER.email, + DEVICE_SENSORS_ID_USER.password, + 'en_US', + ) - device = await createDevice(DEVICE_SENSOR_ID_BOX, (user as User).id); - deviceId = device.id; - sensors = await getSensors(deviceId); + device = await createDevice(DEVICE_SENSOR_ID_BOX, (user as User).id) + deviceId = device.id + sensors = await getSensors(deviceId) - MEASUREMENTS.forEach(meas => meas.sensor_id = sensors[0].id) - await insertMeasurements(MEASUREMENTS); - }); + MEASUREMENTS.forEach((meas) => (meas.sensor_id = sensors[0].id)) + await insertMeasurements(MEASUREMENTS) + }) - describe("GET", () => { - it("should return measurements for a single sensor of a box in json format", async () => { - // Arrange - const request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/data/${sensors[0].id}?from-date=${new Date('1954-06-07 11:00:00+00')}&to-date=${new Date('1988-03-14 1:59:27+00')}`, - { method: "GET" }, - ); + describe('GET', () => { + it('should return measurements for a single sensor of a box in json format', async () => { + // Arrange + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/data/${sensors[0].id}?from-date=${new Date('1954-06-07 11:00:00+00')}&to-date=${new Date('1988-03-14 1:59:27+00')}`, + { method: 'GET' }, + ) - // Act - const dataFunctionValue = await loader({ - request, - params: { - deviceId: `${deviceId}`, - sensorId: `${sensors[0].id}` - } as Params, - } as LoaderFunctionArgs); // Assuming a separate loader for single sensor - const response = dataFunctionValue as Response; - const body = await response?.json(); + // Act + const dataFunctionValue = await loader({ + request, + params: { + deviceId: `${deviceId}`, + sensorId: `${sensors[0].id}`, + } as Params, + } as LoaderFunctionArgs) // Assuming a separate loader for single sensor + const response = dataFunctionValue as Response + const body = await response?.json() - // Assert - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); - expect(body).toHaveLength(2); - expect(body[0].sensor_id).toBe(sensors[0].id); - expect(body[1].sensor_id).toBe(sensors[0].id); - expect(body[0].time).toBe('1988-03-14 01:59:26+00'); - expect(body[1].time).toBe('1954-06-07 12:00:00+00'); - expect(body[0].value).toBeCloseTo(3.14159); - expect(body[1].value).toBe(1589625); - }); + // Assert + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(body).toHaveLength(2) + expect(body[0].sensor_id).toBe(sensors[0].id) + expect(body[1].sensor_id).toBe(sensors[0].id) + expect(body[0].time).toBe('1988-03-14 01:59:26+00') + expect(body[1].time).toBe('1954-06-07 12:00:00+00') + expect(body[0].value).toBeCloseTo(3.14159) + expect(body[1].value).toBe(1589625) + }) - it("should return measurements for a single sensor of a box in csv format", async () => { - // Arrange - const request = new Request( - `${BASE_URL}/api/boxes/${deviceId}/data/${sensors[0].id}?from-date=${new Date('1954-06-07 11:00:00+00')}&to-date=${new Date('1988-03-14 1:59:27+00')}&format=csv`, - { method: "GET" }, - ); + it('should return measurements for a single sensor of a box in csv format', async () => { + // Arrange + const request = new Request( + `${BASE_URL}/api/boxes/${deviceId}/data/${sensors[0].id}?from-date=${new Date('1954-06-07 11:00:00+00')}&to-date=${new Date('1988-03-14 1:59:27+00')}&format=csv`, + { method: 'GET' }, + ) - // Act - const dataFunctionValue = await loader({ - request, - params: { - deviceId: `${deviceId}`, - sensorId: `${sensors[0].id}` - } as Params, - } as LoaderFunctionArgs); // Assuming a separate loader for single sensor - const response = dataFunctionValue as Response; - const body = await response?.text(); + // Act + const dataFunctionValue = await loader({ + request, + params: { + deviceId: `${deviceId}`, + sensorId: `${sensors[0].id}`, + } as Params, + } as LoaderFunctionArgs) // Assuming a separate loader for single sensor + const response = dataFunctionValue as Response + const body = await response?.text() - // Assert - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "text/csv; charset=utf-8", - ); - expect(body).toBe( + // Assert + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe( + 'text/csv; charset=utf-8', + ) + expect(body).toBe( 'createdAt,value\n1988-03-14 01:59:26+00,3.14159\n1954-06-07 12:00:00+00,1589625', ) - }); - }); + }) + }) - afterAll(async () => { - //delete measurements - if (sensors.length > 0) { - await deleteMeasurementsForSensor(sensors[0].id); - await deleteMeasurementsForTime(MEASUREMENTS[0].createdAt); - await deleteMeasurementsForTime(MEASUREMENTS[1].createdAt); - } - // delete the valid test user - await deleteUserByEmail(DEVICE_SENSORS_ID_USER.email); - // delete the box - await deleteDevice({ id: deviceId }); - }); -}); + afterAll(async () => { + //delete measurements + if (sensors.length > 0) { + await deleteMeasurementsForSensor(sensors[0].id) + await deleteMeasurementsForTime(MEASUREMENTS[0].createdAt) + await deleteMeasurementsForTime(MEASUREMENTS[1].createdAt) + } + // delete the valid test user + await deleteUserByEmail(DEVICE_SENSORS_ID_USER.email) + // delete the box + await deleteDevice({ id: deviceId }) + }) +}) diff --git a/tests/routes/api.boxes.$deviceId.locations.spec.ts b/tests/routes/api.boxes.$deviceId.locations.spec.ts index 2685f6ee..38c797d5 100644 --- a/tests/routes/api.boxes.$deviceId.locations.spec.ts +++ b/tests/routes/api.boxes.$deviceId.locations.spec.ts @@ -21,7 +21,6 @@ const DEVICE_SENSOR_ID_BOX = { tags: [], latitude: 0, longitude: 0, - model: "luftdaten.info", mqttEnabled: false, ttnEnabled: false, sensors: [ diff --git a/tests/routes/api.boxes.$deviceId.sensors.$sensorId.spec.ts b/tests/routes/api.boxes.$deviceId.sensors.$sensorId.spec.ts index 25667f8e..80efa8a6 100644 --- a/tests/routes/api.boxes.$deviceId.sensors.$sensorId.spec.ts +++ b/tests/routes/api.boxes.$deviceId.sensors.$sensorId.spec.ts @@ -1,126 +1,126 @@ -import { type Params, type LoaderFunctionArgs } from "react-router"; -import { BASE_URL } from "vitest.setup"; -import { registerUser } from "~/lib/user-service.server"; -import { createDevice, deleteDevice } from "~/models/device.server"; -import { getSensors } from "~/models/sensor.server"; -import { deleteUserByEmail } from "~/models/user.server"; -import { loader } from "~/routes/api.boxes.$deviceId.sensors.$sensorId"; -import { type Sensor, type Device, type User } from "~/schema"; +import { type Params, type LoaderFunctionArgs } from 'react-router' +import { BASE_URL } from 'vitest.setup' +import { registerUser } from '~/lib/user-service.server' +import { createDevice, deleteDevice } from '~/models/device.server' +import { getSensors } from '~/models/sensor.server' +import { deleteUserByEmail } from '~/models/user.server' +import { loader } from '~/routes/api.boxes.$deviceId.sensors.$sensorId' +import { type Sensor, type Device, type User } from '~/schema' const DEVICE_SENSORS_ID_USER = { - name: "meTestSensorsIds", - email: "test@box.sensorids", - password: "highlySecurePasswordForTesting", -}; + name: 'meTestSensorsIds', + email: 'test@box.sensorids', + password: 'highlySecurePasswordForTesting', +} const DEVICE_SENSOR_ID_BOX = { - name: `${DEVICE_SENSORS_ID_USER}s Box`, - exposure: "outdoor", - expiresAt: null, - tags: [], - latitude: 0, - longitude: 0, - model: "luftdaten.info", - mqttEnabled: false, - ttnEnabled: false, - sensors: [ - { - title: "Temp", - unit: "°C", - sensorType: "dummy", - }, - { - title: "CO2", - unit: "mol/L", - sensorType: "dummy", - }, - { - title: "Air Pressure", - unit: "kPa", - sensorType: "dummy", - }, - ], -}; + name: `${DEVICE_SENSORS_ID_USER}s Box`, + exposure: 'outdoor', + expiresAt: null, + tags: [], + latitude: 0, + longitude: 0, + // model: "luftdaten.info", + mqttEnabled: false, + ttnEnabled: false, + sensors: [ + { + title: 'Temp', + unit: '°C', + sensorType: 'dummy', + }, + { + title: 'CO2', + unit: 'mol/L', + sensorType: 'dummy', + }, + { + title: 'Air Pressure', + unit: 'kPa', + sensorType: 'dummy', + }, + ], +} -describe("openSenseMap API Routes: /boxes/:deviceId/sensors/:sensorId", () => { - let device: Device; - let deviceId: string = ""; - let sensors: Sensor[] = []; +describe('openSenseMap API Routes: /boxes/:deviceId/sensors/:sensorId', () => { + let device: Device + let deviceId: string = '' + let sensors: Sensor[] = [] - beforeAll(async () => { - const user = await registerUser( - DEVICE_SENSORS_ID_USER.name, - DEVICE_SENSORS_ID_USER.email, - DEVICE_SENSORS_ID_USER.password, - "en_US", - ); + beforeAll(async () => { + const user = await registerUser( + DEVICE_SENSORS_ID_USER.name, + DEVICE_SENSORS_ID_USER.email, + DEVICE_SENSORS_ID_USER.password, + 'en_US', + ) - device = await createDevice(DEVICE_SENSOR_ID_BOX, (user as User).id); - deviceId = device.id; - sensors = await getSensors(deviceId); - }); + device = await createDevice(DEVICE_SENSOR_ID_BOX, (user as User).id) + deviceId = device.id + sensors = await getSensors(deviceId) + }) - describe("GET", () => { - it("should return a single sensor of a box", async () => { - // Arrange - const request = new Request( - `${BASE_URL}/boxes/${deviceId}/sensors/${sensors[0].id}`, - { method: "GET" }, - ); + describe('GET', () => { + it('should return a single sensor of a box', async () => { + // Arrange + const request = new Request( + `${BASE_URL}/boxes/${deviceId}/sensors/${sensors[0].id}`, + { method: 'GET' }, + ) - // Act - const dataFunctionValue = await loader({ - request, - params: { - deviceId: `${deviceId}`, - sensorId: `${sensors[0].id}`, - } as Params, - } as LoaderFunctionArgs); // Assuming a separate loader for single sensor - const response = dataFunctionValue as Response; - const body = await response?.json(); + // Act + const dataFunctionValue = await loader({ + request, + params: { + deviceId: `${deviceId}`, + sensorId: `${sensors[0].id}`, + } as Params, + } as LoaderFunctionArgs) // Assuming a separate loader for single sensor + const response = dataFunctionValue as Response + const body = await response?.json() - // Assert - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); - expect(body).toHaveProperty("_id"); - }); + // Assert + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(body).toHaveProperty('_id') + }) - it("should return only value of a single sensor of a box", async () => { - // Arrange - const request = new Request( - `${BASE_URL}/boxes/${deviceId}/sensors/${sensors[0].id}?onlyValue=true`, - { method: "GET" }, - ); + it('should return only value of a single sensor of a box', async () => { + // Arrange + const request = new Request( + `${BASE_URL}/boxes/${deviceId}/sensors/${sensors[0].id}?onlyValue=true`, + { method: 'GET' }, + ) - // Act - const dataFunctionValue = await loader({ - request, - params: { - deviceId: `${deviceId}`, - sensorId: `${sensors[0].id}`, - } as Params, - } as LoaderFunctionArgs); - const response = dataFunctionValue as Response; - const body = await response?.json(); + // Act + const dataFunctionValue = await loader({ + request, + params: { + deviceId: `${deviceId}`, + sensorId: `${sensors[0].id}`, + } as Params, + } as LoaderFunctionArgs) + const response = dataFunctionValue as Response + const body = await response?.json() - // Assert - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); + // Assert + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) - if (isNaN(Number.parseFloat(body))) expect(body).toBeNull(); - else expect(typeof body).toBe("number"); - }); - }); + if (isNaN(Number.parseFloat(body))) expect(body).toBeNull() + else expect(typeof body).toBe('number') + }) + }) - afterAll(async () => { - // delete the valid test user - await deleteUserByEmail(DEVICE_SENSORS_ID_USER.email); + afterAll(async () => { + // delete the valid test user + await deleteUserByEmail(DEVICE_SENSORS_ID_USER.email) - // delete the box - await deleteDevice({ id: deviceId }); - }); -}); + // delete the box + await deleteDevice({ id: deviceId }) + }) +}) diff --git a/tests/routes/api.boxes.$deviceId.sensors.spec.ts b/tests/routes/api.boxes.$deviceId.sensors.spec.ts index 918ddcf3..e6065724 100644 --- a/tests/routes/api.boxes.$deviceId.sensors.spec.ts +++ b/tests/routes/api.boxes.$deviceId.sensors.spec.ts @@ -1,119 +1,119 @@ -import { type Params, type LoaderFunctionArgs } from "react-router"; -import { BASE_URL } from "vitest.setup"; -import { registerUser } from "~/lib/user-service.server"; -import { createDevice, deleteDevice } from "~/models/device.server"; -import { deleteUserByEmail } from "~/models/user.server"; -import { loader } from "~/routes/api.boxes.$deviceId.sensors"; -import { type User } from "~/schema"; +import { type Params, type LoaderFunctionArgs } from 'react-router' +import { BASE_URL } from 'vitest.setup' +import { registerUser } from '~/lib/user-service.server' +import { createDevice, deleteDevice } from '~/models/device.server' +import { deleteUserByEmail } from '~/models/user.server' +import { loader } from '~/routes/api.boxes.$deviceId.sensors' +import { type User } from '~/schema' const DEVICE_SENSORS_USER = { - name: "meTestSensors", - email: "test@box.sensors", - password: "highlySecurePasswordForTesting", -}; + name: 'meTestSensors', + email: 'test@box.sensors', + password: 'highlySecurePasswordForTesting', +} const DEVICE_SENSOR_BOX = { - name: `${DEVICE_SENSORS_USER}s Box`, - exposure: "outdoor", - expiresAt: null, - tags: [], - latitude: 0, - longitude: 0, - model: "luftdaten.info", - mqttEnabled: false, - ttnEnabled: false, - sensors: [ - { - title: "Temp", - unit: "°C", - sensorType: "dummy", - }, - { - title: "CO2", - unit: "mol/L", - sensorType: "dummy", - }, - { - title: "Air Pressure", - unit: "kPa", - sensorType: "dummy", - }, - ], -}; + name: `${DEVICE_SENSORS_USER}s Box`, + exposure: 'outdoor', + expiresAt: null, + tags: [], + latitude: 0, + longitude: 0, + // model: "luftdaten.info", + mqttEnabled: false, + ttnEnabled: false, + sensors: [ + { + title: 'Temp', + unit: '°C', + sensorType: 'dummy', + }, + { + title: 'CO2', + unit: 'mol/L', + sensorType: 'dummy', + }, + { + title: 'Air Pressure', + unit: 'kPa', + sensorType: 'dummy', + }, + ], +} -describe("openSenseMap API Routes: /boxes/:deviceId/sensors", () => { - let deviceId: string = ""; +describe('openSenseMap API Routes: /boxes/:deviceId/sensors', () => { + let deviceId: string = '' - beforeAll(async () => { - const user = await registerUser( - DEVICE_SENSORS_USER.name, - DEVICE_SENSORS_USER.email, - DEVICE_SENSORS_USER.password, - "en_US", - ); + beforeAll(async () => { + const user = await registerUser( + DEVICE_SENSORS_USER.name, + DEVICE_SENSORS_USER.email, + DEVICE_SENSORS_USER.password, + 'en_US', + ) - const device = await createDevice(DEVICE_SENSOR_BOX, (user as User).id); - deviceId = device.id; - }); + const device = await createDevice(DEVICE_SENSOR_BOX, (user as User).id) + deviceId = device.id + }) - describe("GET", () => { - it("should return all sensors of a box/ device", async () => { - // Arrange - const request = new Request(`${BASE_URL}/boxes/${deviceId}/sensors`, { - method: "GET", - }); + describe('GET', () => { + it('should return all sensors of a box/ device', async () => { + // Arrange + const request = new Request(`${BASE_URL}/boxes/${deviceId}/sensors`, { + method: 'GET', + }) - // Act - const dataFunctionValue = await loader({ - request, - params: { deviceId: `${deviceId}` } as Params, - } as LoaderFunctionArgs); - const response = dataFunctionValue as Response; - const body = await response?.json(); + // Act + const dataFunctionValue = await loader({ + request, + params: { deviceId: `${deviceId}` } as Params, + } as LoaderFunctionArgs) + const response = dataFunctionValue as Response + const body = await response?.json() - // Assert - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); - expect(body).toHaveProperty("sensors"); - }); + // Assert + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(body).toHaveProperty('sensors') + }) - it("should return all sensors of a box with a maximum of 3 measurements when ?count=3 is used", async () => { - // Arrange - const request = new Request( - `${BASE_URL}/boxes/${deviceId}/sensors?count=3`, - { method: "GET" }, - ); + it('should return all sensors of a box with a maximum of 3 measurements when ?count=3 is used', async () => { + // Arrange + const request = new Request( + `${BASE_URL}/boxes/${deviceId}/sensors?count=3`, + { method: 'GET' }, + ) - // Act - const dataFunctionValue = await loader({ - request, - params: { deviceId: `${deviceId}` } as Params, - } as LoaderFunctionArgs); - const response = dataFunctionValue as Response; - const body = await response?.json(); + // Act + const dataFunctionValue = await loader({ + request, + params: { deviceId: `${deviceId}` } as Params, + } as LoaderFunctionArgs) + const response = dataFunctionValue as Response + const body = await response?.json() - // Assert - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); - expect(body.sensors[0].lastMeasurements).toBeDefined(); - expect(body.sensors[0].lastMeasurements).not.toBeNull(); + // Assert + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(body.sensors[0].lastMeasurements).toBeDefined() + expect(body.sensors[0].lastMeasurements).not.toBeNull() - if (body.sensors[0].lastMeasurements.length > 0) - expect( - body.sensors[0].lastMeasurements.measurements.length, - ).toBeGreaterThanOrEqual(3); - }); - }); + if (body.sensors[0].lastMeasurements.length > 0) + expect( + body.sensors[0].lastMeasurements.measurements.length, + ).toBeGreaterThanOrEqual(3) + }) + }) - afterAll(async () => { - // delete the valid test user - await deleteUserByEmail(DEVICE_SENSORS_USER.email); + afterAll(async () => { + // delete the valid test user + await deleteUserByEmail(DEVICE_SENSORS_USER.email) - // delete the box - await deleteDevice({ id: deviceId }); - }); -}); + // delete the box + await deleteDevice({ id: deviceId }) + }) +}) diff --git a/tests/routes/api.boxes.spec.ts b/tests/routes/api.boxes.spec.ts index 0636de82..2fe9afa7 100644 --- a/tests/routes/api.boxes.spec.ts +++ b/tests/routes/api.boxes.spec.ts @@ -1,352 +1,352 @@ -import { type ActionFunctionArgs } from "react-router"; -import { BASE_URL } from "vitest.setup"; -import { createToken } from "~/lib/jwt"; -import { registerUser } from "~/lib/user-service.server"; -import { deleteDevice } from "~/models/device.server"; -import { deleteUserByEmail } from "~/models/user.server"; -import { action } from "~/routes/api.boxes"; -import { type User } from "~/schema"; +import { type ActionFunctionArgs } from 'react-router' +import { BASE_URL } from 'vitest.setup' +import { createToken } from '~/lib/jwt' +import { registerUser } from '~/lib/user-service.server' +import { deleteDevice } from '~/models/device.server' +import { deleteUserByEmail } from '~/models/user.server' +import { action } from '~/routes/api.boxes' +import { type User } from '~/schema' const BOXES_POST_TEST_USER = { - name: "testing post boxes", - email: "test@postboxes.me", - password: "some secure password", -}; - -describe("openSenseMap API Routes: /boxes", () => { - let user: User | null = null; - let jwt: string = ""; - let createdDeviceIds: string[] = []; - - beforeAll(async () => { - const testUser = await registerUser( - BOXES_POST_TEST_USER.name, - BOXES_POST_TEST_USER.email, - BOXES_POST_TEST_USER.password, - "en_US", - ); - user = testUser as User; - const { token } = await createToken(testUser as User); - jwt = token; - }); - - afterAll(async () => { - for (const deviceId of createdDeviceIds) { - try { - await deleteDevice({ id: deviceId }); - } catch (error) { - console.error(`Failed to delete device ${deviceId}:`, error); - } - } - if (user) { - await deleteUserByEmail(BOXES_POST_TEST_USER.email); - } - }); - - describe("POST", () => { - it("should create a new box with sensors", async () => { - const requestBody = { - name: "Test Weather Station", - location: [7.596, 51.969], - exposure: "outdoor", - model: "homeV2Wifi", - grouptag: ["weather", "test"], - sensors: [ - { - id: "0", - title: "Temperature", - unit: "°C", - sensorType: "HDC1080", - }, - { - id: "1", - title: "Humidity", - unit: "%", - sensorType: "HDC1080", - }, - ], - }; - - const request = new Request(`${BASE_URL}/boxes`, { - method: "POST", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }); - - const response = (await action({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - if (body._id) { - createdDeviceIds.push(body._id); - } - - expect(response.status).toBe(201); - expect(body).toHaveProperty("_id"); - expect(body).toHaveProperty("name", "Test Weather Station"); - expect(body).toHaveProperty("sensors"); - expect(body.sensors).toHaveLength(2); - expect(body.sensors[0]).toHaveProperty("title", "Temperature"); - expect(body.sensors[1]).toHaveProperty("title", "Humidity"); - }); - - it("should create a box with minimal data (no sensors)", async () => { - const requestBody = { - name: "Minimal Test Box", - location: [7.5, 51.9], - }; - - const request = new Request(`${BASE_URL}/boxes`, { - method: "POST", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }); - - const response = (await action({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - if (body._id) { - createdDeviceIds.push(body._id); - } - - expect(response.status).toBe(201); - expect(body).toHaveProperty("_id"); - expect(body).toHaveProperty("name", "Minimal Test Box"); - expect(body).toHaveProperty("sensors"); - expect(Array.isArray(body.sensors)).toBe(true); - expect(body.sensors).toHaveLength(0); - }); - - it("should reject creation without authentication", async () => { - const requestBody = { - name: "Unauthorized Box", - location: [7.5, 51.9], - }; - - const request = new Request(`${BASE_URL}/boxes`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }); - - const response = (await action({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(403); - expect(body).toHaveProperty("code", "Forbidden"); - expect(body).toHaveProperty("message"); - }); - - it("should reject creation with invalid JWT", async () => { - const requestBody = { - name: "Invalid JWT Box", - location: [7.5, 51.9], - }; - - const request = new Request(`${BASE_URL}/boxes`, { - method: "POST", - headers: { - Authorization: "Bearer invalid_jwt_token", - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }); - - const response = (await action({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(403); - expect(body).toHaveProperty("code", "Forbidden"); - }); - - it("should reject creation with missing required fields", async () => { - const requestBody = { - location: [7.5, 51.9], - }; - - const request = new Request(`${BASE_URL}/boxes`, { - method: "POST", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }); - - const response = (await action({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(400); - expect(body).toHaveProperty("code", "Bad Request"); - expect(body).toHaveProperty("errors"); - expect(Array.isArray(body.errors)).toBe(true); - }); - - it("should reject creation with invalid location format", async () => { - const requestBody = { - name: "Invalid Location Box", - location: [7.5], - }; - - const request = new Request(`${BASE_URL}/boxes`, { - method: "POST", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }); - - const response = (await action({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(400); - expect(body).toHaveProperty("code", "Bad Request"); - expect(body).toHaveProperty("errors"); - }); - - it("should reject creation with invalid JSON", async () => { - const request = new Request(`${BASE_URL}/boxes`, { - method: "POST", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - body: "invalid json {", - }); - - const response = (await action({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(400); - expect(body).toHaveProperty("code", "Bad Request"); - expect(body).toHaveProperty("message", "Invalid JSON in request body"); - }); - - it("should create box with default values for optional fields", async () => { - const requestBody = { - name: "Default Values Box", - location: [7.5, 51.9], - }; - - const request = new Request(`${BASE_URL}/boxes`, { - method: "POST", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }); - - const response = (await action({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - if (body._id) { - createdDeviceIds.push(body._id); - } - - expect(response.status).toBe(201); - expect(body).toHaveProperty("exposure", "unknown"); - expect(body).toHaveProperty("model", "Custom"); - expect(body).toHaveProperty("grouptag"); - expect(body.grouptag).toEqual([]); - }); - }); - - describe("Method Not Allowed", () => { - it("should return 405 for GET requests", async () => { - const request = new Request(`${BASE_URL}/boxes`, { - method: "GET", - headers: { - Authorization: `Bearer ${jwt}`, - }, - }); - - const response = (await action({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(405); - expect(body).toHaveProperty("message", "Method Not Allowed"); - }); - - it("should return 405 for PUT requests", async () => { - const request = new Request(`${BASE_URL}/boxes`, { - method: "PUT", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ name: "Test" }), - }); - - const response = (await action({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(405); - expect(body).toHaveProperty("message", "Method Not Allowed"); - }); - - it("should return 405 for DELETE requests", async () => { - const request = new Request(`${BASE_URL}/boxes`, { - method: "DELETE", - headers: { - Authorization: `Bearer ${jwt}`, - }, - }); - - const response = (await action({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(405); - expect(body).toHaveProperty("message", "Method Not Allowed"); - }); - - it("should return 405 for PATCH requests", async () => { - const request = new Request(`${BASE_URL}/boxes`, { - method: "PATCH", - headers: { - Authorization: `Bearer ${jwt}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ name: "Test" }), - }); - - const response = (await action({ - request, - } as ActionFunctionArgs)) as Response; - const body = await response.json(); - - expect(response.status).toBe(405); - expect(body).toHaveProperty("message", "Method Not Allowed"); - }); - }); -}); + name: 'testing post boxes', + email: 'test@postboxes.me', + password: 'some secure password', +} + +describe('openSenseMap API Routes: /boxes', () => { + let user: User | null = null + let jwt: string = '' + let createdDeviceIds: string[] = [] + + beforeAll(async () => { + const testUser = await registerUser( + BOXES_POST_TEST_USER.name, + BOXES_POST_TEST_USER.email, + BOXES_POST_TEST_USER.password, + 'en_US', + ) + user = testUser as User + const { token } = await createToken(testUser as User) + jwt = token + }) + + afterAll(async () => { + for (const deviceId of createdDeviceIds) { + try { + await deleteDevice({ id: deviceId }) + } catch (error) { + console.error(`Failed to delete device ${deviceId}:`, error) + } + } + if (user) { + await deleteUserByEmail(BOXES_POST_TEST_USER.email) + } + }) + + describe('POST', () => { + it('should create a new box with sensors', async () => { + const requestBody = { + name: 'Test Weather Station', + location: [7.596, 51.969], + exposure: 'outdoor', + // model: "homeV2Wifi", + grouptag: ['weather', 'test'], + sensors: [ + { + id: '0', + title: 'Temperature', + unit: '°C', + sensorType: 'HDC1080', + }, + { + id: '1', + title: 'Humidity', + unit: '%', + sensorType: 'HDC1080', + }, + ], + } + + const request = new Request(`${BASE_URL}/boxes`, { + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + if (body._id) { + createdDeviceIds.push(body._id) + } + + expect(response.status).toBe(201) + expect(body).toHaveProperty('_id') + expect(body).toHaveProperty('name', 'Test Weather Station') + expect(body).toHaveProperty('sensors') + expect(body.sensors).toHaveLength(2) + expect(body.sensors[0]).toHaveProperty('title', 'Temperature') + expect(body.sensors[1]).toHaveProperty('title', 'Humidity') + }) + + it('should create a box with minimal data (no sensors)', async () => { + const requestBody = { + name: 'Minimal Test Box', + location: [7.5, 51.9], + } + + const request = new Request(`${BASE_URL}/boxes`, { + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + if (body._id) { + createdDeviceIds.push(body._id) + } + + expect(response.status).toBe(201) + expect(body).toHaveProperty('_id') + expect(body).toHaveProperty('name', 'Minimal Test Box') + expect(body).toHaveProperty('sensors') + expect(Array.isArray(body.sensors)).toBe(true) + expect(body.sensors).toHaveLength(0) + }) + + it('should reject creation without authentication', async () => { + const requestBody = { + name: 'Unauthorized Box', + location: [7.5, 51.9], + } + + const request = new Request(`${BASE_URL}/boxes`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(403) + expect(body).toHaveProperty('code', 'Forbidden') + expect(body).toHaveProperty('message') + }) + + it('should reject creation with invalid JWT', async () => { + const requestBody = { + name: 'Invalid JWT Box', + location: [7.5, 51.9], + } + + const request = new Request(`${BASE_URL}/boxes`, { + method: 'POST', + headers: { + Authorization: 'Bearer invalid_jwt_token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(403) + expect(body).toHaveProperty('code', 'Forbidden') + }) + + it('should reject creation with missing required fields', async () => { + const requestBody = { + location: [7.5, 51.9], + } + + const request = new Request(`${BASE_URL}/boxes`, { + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(400) + expect(body).toHaveProperty('code', 'Bad Request') + expect(body).toHaveProperty('errors') + expect(Array.isArray(body.errors)).toBe(true) + }) + + it('should reject creation with invalid location format', async () => { + const requestBody = { + name: 'Invalid Location Box', + location: [7.5], + } + + const request = new Request(`${BASE_URL}/boxes`, { + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(400) + expect(body).toHaveProperty('code', 'Bad Request') + expect(body).toHaveProperty('errors') + }) + + it('should reject creation with invalid JSON', async () => { + const request = new Request(`${BASE_URL}/boxes`, { + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: 'invalid json {', + }) + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(400) + expect(body).toHaveProperty('code', 'Bad Request') + expect(body).toHaveProperty('message', 'Invalid JSON in request body') + }) + + it('should create box with default values for optional fields', async () => { + const requestBody = { + name: 'Default Values Box', + location: [7.5, 51.9], + } + + const request = new Request(`${BASE_URL}/boxes`, { + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + if (body._id) { + createdDeviceIds.push(body._id) + } + + expect(response.status).toBe(201) + expect(body).toHaveProperty('exposure', 'unknown') + expect(body).toHaveProperty('model', 'Custom') + expect(body).toHaveProperty('grouptag') + expect(body.grouptag).toEqual([]) + }) + }) + + describe('Method Not Allowed', () => { + it('should return 405 for GET requests', async () => { + const request = new Request(`${BASE_URL}/boxes`, { + method: 'GET', + headers: { + Authorization: `Bearer ${jwt}`, + }, + }) + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(405) + expect(body).toHaveProperty('message', 'Method Not Allowed') + }) + + it('should return 405 for PUT requests', async () => { + const request = new Request(`${BASE_URL}/boxes`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name: 'Test' }), + }) + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(405) + expect(body).toHaveProperty('message', 'Method Not Allowed') + }) + + it('should return 405 for DELETE requests', async () => { + const request = new Request(`${BASE_URL}/boxes`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${jwt}`, + }, + }) + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(405) + expect(body).toHaveProperty('message', 'Method Not Allowed') + }) + + it('should return 405 for PATCH requests', async () => { + const request = new Request(`${BASE_URL}/boxes`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name: 'Test' }), + }) + + const response = (await action({ + request, + } as ActionFunctionArgs)) as Response + const body = await response.json() + + expect(response.status).toBe(405) + expect(body).toHaveProperty('message', 'Method Not Allowed') + }) + }) +}) diff --git a/tests/routes/api.device.feinstaub.spec.ts b/tests/routes/api.device.feinstaub.spec.ts new file mode 100644 index 00000000..4fdde0f7 --- /dev/null +++ b/tests/routes/api.device.feinstaub.spec.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { BASE_URL } from 'vitest.setup' +import { createToken } from '~/lib/jwt' +import { registerUser } from '~/lib/user-service.server' +import { createDevice, deleteDevice, getDevice } from '~/models/device.server' +import { deleteUserByEmail } from '~/models/user.server' +import { action as deviceUpdateAction } from '~/routes/api.device.$deviceId' +import { type User, type Device } from '~/schema' + +const TEST_USER = { + name: 'feinstaubAddonUpdateTestUser', + email: 'feinstaubUpdate.addon@test', + password: 'secureTestPassword123!', +} + +let user: User +let jwt: string +let baseDevice: Device + +const generateMinimalDevice = (model = 'homeEthernetFeinstaub') => ({ + exposure: 'mobile', + latitude: 12.34, + longitude: 56.78, + name: 'senseBox' + Date.now(), + model: model, +}) + +describe('Device API: Feinstaub Addon behavior', () => { + let queryableDevice: Device | null = null + + beforeAll(async () => { + const testUser = await registerUser( + TEST_USER.name, + TEST_USER.email, + TEST_USER.password, + 'en_US', + ) + user = testUser as User + const { token: t } = await createToken(testUser as User) + jwt = t + + queryableDevice = await createDevice( + { + ...generateMinimalDevice(), + latitude: 123, + longitude: 12, + tags: ['newgroup'], + }, + (testUser as User).id, + ) + }) + + afterAll(async () => { + await deleteUserByEmail(TEST_USER.email) + await deleteDevice({ id: queryableDevice!.id }) + }) + + it('should allow to register a homeEthernetFeinstaub device and include SDS011 sensors', async () => { + const device = await createDevice(generateMinimalDevice(), user.id) + + const fetched = await getDevice({ id: device.id }) + expect(fetched).toBeDefined() + + const hasPM10 = fetched!.sensors.some( + (s) => s.sensorType === 'SDS 011' && s.title === 'PM10', + ) + const hasPM25 = fetched!.sensors.some( + (s) => s.sensorType === 'SDS 011' && s.title === 'PM2.5', + ) + + expect(hasPM10).toBe(true) + expect(hasPM25).toBe(true) + + await deleteDevice({ id: device.id }) + }) + + it('should allow to register a homeWifiFeinstaub device and include SDS011 sensors', async () => { + const device = await createDevice( + generateMinimalDevice('homeWifiFeinstaub'), + user.id, + ) + + const fetched = await getDevice({ id: device.id }) + expect(fetched).toBeDefined() + + const hasPM10 = fetched!.sensors.some( + (s) => s.sensorType === 'SDS 011' && s.title === 'PM10', + ) + const hasPM25 = fetched!.sensors.some( + (s) => s.sensorType === 'SDS 011' && s.title === 'PM2.5', + ) + + expect(hasPM10).toBe(true) + expect(hasPM25).toBe(true) + + await deleteDevice({ id: device.id }) + }) + + it('should allow to add the feinstaub addon via PUT for a homeWifi device', async () => { + const device = await createDevice( + generateMinimalDevice('homeWifiFeinstaub'), + user.id, + ) + + const updatePayload = { addons: { add: 'feinstaub' } } + + const request = new Request(`${BASE_URL}/${device.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(updatePayload), + }) as unknown as Request + + const response: any = await deviceUpdateAction({ + request, + params: { deviceId: device.id }, + context: {} as any, + }) + + expect(response.status).toBe(200) + const data = await response.json() + + expect(data.model).toBe('homeWifiFeinstaub') + + const hasPM10 = data.sensors.some( + (s: any) => s.sensorType === 'SDS 011' && s.title === 'PM10', + ) + const hasPM25 = data.sensors.some( + (s: any) => s.sensorType === 'SDS 011' && s.title === 'PM2.5', + ) + + expect(hasPM10).toBe(true) + expect(hasPM25).toBe(true) + + const secondRequest = new Request(`${BASE_URL}/${device.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(updatePayload), + }) as unknown as Request + + // Second PUT should be idempotent — same sensors + const secondResponse: any = await deviceUpdateAction({ + request: secondRequest, + params: { deviceId: device.id }, + context: {} as any, + }) + + expect(secondResponse.status).toBe(200) + const secondData = await secondResponse.json() + expect(secondData.sensors).toEqual(data.sensors) + + await deleteDevice({ id: device.id }) + }) + + it('should do nothing when adding the feinstaub addon to a non-home device', async () => { + const device = await createDevice( + { + ...generateMinimalDevice('Custom'), + // sensors: [{ title: 'temp', unit: 'K', sensorType: 'some Sensor' }], + }, + user.id, + ) + + const updatePayload = { addons: { add: 'feinstaub' } } + + const request = new Request(`${BASE_URL}/${device.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(updatePayload), + }) as unknown as Request + + const response: any = await deviceUpdateAction({ + request, + params: { deviceId: device.id }, + context: {} as any, + }) + + expect(response.status).toBe(200) + const data = await response.json() + + // Model should not change + expect(data.model).toBe('Custom') + + // Should not have SDS011 sensors + const hasPM10 = data.sensors.some( + (s: any) => s.sensorType === 'SDS 011' && s.title === 'PM10', + ) + const hasPM25 = data.sensors.some( + (s: any) => s.sensorType === 'SDS 011' && s.title === 'PM2.5', + ) + expect(hasPM10).toBe(false) + expect(hasPM25).toBe(false) + + await deleteDevice({ id: device.id }) + }) +}) diff --git a/tests/routes/api.device.sensors.spec.ts b/tests/routes/api.device.sensors.spec.ts new file mode 100644 index 00000000..93772720 --- /dev/null +++ b/tests/routes/api.device.sensors.spec.ts @@ -0,0 +1,356 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { BASE_URL } from 'vitest.setup' +import { createToken } from '~/lib/jwt' +import { registerUser } from '~/lib/user-service.server' +import { createDevice, deleteDevice, getDevice } from '~/models/device.server' +import { deleteUserByEmail } from '~/models/user.server' +import { action as deviceUpdateAction } from '~/routes/api.device.$deviceId' +import { type User, type Device } from '~/schema' + +const DEVICE_TEST_USER = { + name: 'deviceTestUpdateSensors', + email: 'test@deviceSensorsUpdate.endpoint', + password: 'highlySecurePasswordForTesting', +} + +let user: User +let jwt: string +let queryableDevice: Device + +const generateMinimalDevice = () => ({ + exposure: 'mobile', + location: { lat: 12.34, lng: 56.78 }, + name: 'senseBox' + Date.now(), +}) + +describe('Device Sensors API: updating sensors', () => { + beforeAll(async () => { + const testUser = await registerUser( + DEVICE_TEST_USER.name, + DEVICE_TEST_USER.email, + DEVICE_TEST_USER.password, + 'en_US', + ) + user = testUser as User + const { token } = await createToken(user) + jwt = token + + queryableDevice = await createDevice( + { + ...generateMinimalDevice(), + latitude: 12.34, + longitude: 56.78, + sensors: [ + { + title: 'Temperature', + unit: '°C', + sensorType: 'DHT22', + }, + { + title: 'Humidity', + unit: '%', + sensorType: 'DHT22', + }, + { + title: 'Pressure', + unit: 'hPa', + sensorType: 'BMP280', + }, + { + title: 'Light', + unit: 'lux', + sensorType: 'TSL2561', + }, + { + title: 'UV', + unit: 'µW/cm²', + sensorType: 'VEML6070', + }, + ], + }, + user.id, + ) + }) + + afterAll(async () => { + await deleteDevice({ id: queryableDevice.id }) + await deleteUserByEmail(DEVICE_TEST_USER.email) + }) + + it('should allow to add a sensor', async () => { + const newSensor = { + title: 'PM10', + unit: 'µg/m³', + sensorType: 'SDS 011', + icon: 'osem-particulate-matter', + new: 'true', + edited: 'true', + } + + const payload = { sensors: [newSensor] } + + const request = new Request(`${BASE_URL}/${queryableDevice.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(payload), + }) as unknown as Request + + const response: any = await deviceUpdateAction({ + request, + params: { deviceId: queryableDevice.id }, + context: {} as any, + }) + + expect(response.status).toBe(200) + const data = await response.json() + + const addedSensor = data.sensors.find( + (s: any) => s.title === newSensor.title, + ) + expect(addedSensor).toBeDefined() + expect(addedSensor.unit).toBe(newSensor.unit) + expect(addedSensor.sensorType).toBe(newSensor.sensorType) + expect(addedSensor.icon).toBe(newSensor.icon) + + const freshDevice = await getDevice({ id: queryableDevice.id }) + const verifiedSensor = freshDevice?.sensors?.find( + (s: any) => s.title === newSensor.title, + ) + expect(verifiedSensor).toBeDefined() + }) + + it('should allow to add multiple sensors via PUT', async () => { + const newSensors = [ + { + title: 'PM2.5', + unit: 'µg/m³', + sensorType: 'SDS 011', + edited: 'true', + new: 'true', + }, + { + title: 'CO2', + unit: 'ppm', + sensorType: 'MH-Z19', + edited: 'true', + new: 'true', + }, + ] + + const payload = { sensors: newSensors } + + const request = new Request(`${BASE_URL}/${queryableDevice.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(payload), + }) as unknown as Request + + const response: any = await deviceUpdateAction({ + request, + params: { deviceId: queryableDevice.id }, + context: {} as any, + }) + + expect(response.status).toBe(200) + const data = await response.json() + + const hasPM25 = data.sensors.some((s: any) => s.title === 'PM2.5') + const hasCO2 = data.sensors.some((s: any) => s.title === 'CO2') + + expect(hasPM25).toBe(true) + expect(hasCO2).toBe(true) + + const freshDevice = await getDevice({ id: queryableDevice.id }) + const verifiedPM25 = freshDevice?.sensors?.some( + (s: any) => s.title === 'PM2.5', + ) + const verifiedCO2 = freshDevice?.sensors?.some( + (s: any) => s.title === 'CO2', + ) + + expect(verifiedPM25).toBe(true) + expect(verifiedCO2).toBe(true) + }) + + it('should allow to edit a sensor', async () => { + const freshDevice = await getDevice({ id: queryableDevice.id }) + const existingSensor = freshDevice?.sensors?.[0] + + if (!existingSensor) { + throw new Error('No sensors found on device') + } + + const updatedSensor = { + _id: existingSensor.id, + title: 'editedTitle', + unit: 'editedUnit', + sensorType: 'editedType', + icon: 'editedIcon', + edited: 'true', + } + + const payload = { sensors: [updatedSensor] } + + const request = new Request(`${BASE_URL}/${queryableDevice.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(payload), + }) as unknown as Request + + const response: any = await deviceUpdateAction({ + request, + params: { deviceId: queryableDevice.id }, + context: {} as any, + }) + + expect(response.status).toBe(200) + const data = await response.json() + + const editedSensor = data.sensors.find( + (s: any) => s._id === existingSensor.id, + ) + expect(editedSensor).toBeDefined() + expect(editedSensor.title).toBe(updatedSensor.title) + expect(editedSensor.unit).toBe(updatedSensor.unit) + expect(editedSensor.sensorType).toBe(updatedSensor.sensorType) + }) + + it('should allow to delete a single sensor via PUT', async () => { + const freshDevice = await getDevice({ id: queryableDevice.id }) + + if (!freshDevice?.sensors || freshDevice.sensors.length < 2) { + throw new Error('Not enough sensors to test deletion') + } + + const sensorToDelete = freshDevice.sensors[0] + const initialSensorCount = freshDevice.sensors.length + + const payload = { + sensors: [ + { + _id: sensorToDelete.id, + deleted: true, + }, + ], + } + + const request = new Request(`${BASE_URL}/${queryableDevice.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(payload), + }) as unknown as Request + + const response: any = await deviceUpdateAction({ + request, + params: { deviceId: queryableDevice.id }, + context: {} as any, + }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.sensors).toHaveLength(initialSensorCount - 1) + + const deletedSensorStillExists = data.sensors.some( + (s: any) => s._id === sensorToDelete.id, + ) + expect(deletedSensorStillExists).toBe(false) + + const updatedDevice = await getDevice({ id: queryableDevice.id }) + expect(updatedDevice?.sensors?.length).toBe(initialSensorCount - 1) + }) + + it('should allow to delete multiple sensors at once', async () => { + const freshDevice = await getDevice({ id: queryableDevice.id }) + + if (!freshDevice?.sensors || freshDevice.sensors.length < 3) { + throw new Error('Not enough sensors to test deletion') + } + + const sensorsToDelete = freshDevice.sensors.slice(0, 2).map((s: any) => ({ + _id: s.id, + deleted: true, + })) + + const initialSensorCount = freshDevice.sensors.length + + const payload = { sensors: sensorsToDelete } + + const request = new Request(`${BASE_URL}/${queryableDevice.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(payload), + }) as unknown as Request + + const response: any = await deviceUpdateAction({ + request, + params: { deviceId: queryableDevice.id }, + context: {} as any, + }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.sensors).toHaveLength(initialSensorCount - 2) + + const remainingSensors = data.sensors.map((s: any) => s._id) + + sensorsToDelete.forEach((s: any) => { + expect(remainingSensors).not.toContain(s._id) + }) + + const updatedDevice = await getDevice({ id: queryableDevice.id }) + expect(updatedDevice?.sensors?.length).toBe(initialSensorCount - 2) + }) + + it('should NOT allow to delete all sensors', async () => { + const freshDevice = await getDevice({ id: queryableDevice.id }) + + if (!freshDevice?.sensors) { + throw new Error('No sensors found on device') + } + + const allSensors = freshDevice.sensors.map((s: any) => ({ + _id: s.id, + deleted: true, + })) + + const payload = { sensors: allSensors } + + const request = new Request(`${BASE_URL}/${queryableDevice.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(payload), + }) as unknown as Request + + const response: any = await deviceUpdateAction({ + request, + params: { deviceId: queryableDevice.id }, + context: {} as any, + }) + + expect(response.status).toBe(400) + const data = await response.json() + expect(data.code).toBe('BadRequest') + expect(data.message).toContain('Unable to delete sensor') + + const unchangedDevice = await getDevice({ id: queryableDevice.id }) + expect(unchangedDevice?.sensors?.length).toBe(freshDevice.sensors.length) + }) +}) diff --git a/tests/routes/api.devices.spec.ts b/tests/routes/api.devices.spec.ts index 4a3d5452..207beeac 100644 --- a/tests/routes/api.devices.spec.ts +++ b/tests/routes/api.devices.spec.ts @@ -8,7 +8,10 @@ import { createToken } from '~/lib/jwt' import { registerUser } from '~/lib/user-service.server' import { createDevice, deleteDevice } from '~/models/device.server' import { deleteUserByEmail } from '~/models/user.server' -import { loader as deviceLoader } from '~/routes/api.device.$deviceId' +import { + loader as deviceLoader, + action as deviceUpdateAction, +} from '~/routes/api.device.$deviceId' import { loader as devicesLoader, action as devicesAction, @@ -49,317 +52,299 @@ describe('openSenseMap API Routes: /boxes', () => { jwt = t queryableDevice = await createDevice( - { ...generateMinimalDevice(), latitude: 123, longitude: 12, tags: ["newgroup"] }, + { + ...generateMinimalDevice(), + latitude: 123, + longitude: 12, + tags: ['testgroup'], + }, (testUser as User).id, ) }) - describe('GET', () => { - it('should search for boxes with a specific name', async () => { - // Arrange - const request = new Request( - `${BASE_URL}?format=geojson&name=${queryableDevice?.name}`, - { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }, - ) + it('should search for boxes with a specific name and limit the results', async () => { + // Arrange + const request = new Request( + `${BASE_URL}?format=geojson&name=${queryableDevice?.name}&limit=2`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }, + ) - // Act - const response: any = await devicesLoader({ - request: request, - } as LoaderFunctionArgs) + // Act + const response: any = await devicesLoader({ + request: request, + } as LoaderFunctionArgs) - expect(response).toBeDefined() - expect(Array.isArray(response?.features)).toBe(true) - expect(response?.features.length).lessThanOrEqual(5) // 5 is default limit - }) + expect(response).toBeDefined() + expect(Array.isArray(response?.features)).toBe(true) + expect(response?.features.length).lessThanOrEqual(2) + }) - it('should search for boxes with a specific name and limit the results', async () => { - // Arrange - const request = new Request( - `${BASE_URL}?format=geojson&name=${queryableDevice?.name}&limit=2`, - { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }, - ) + it('should deny searching for a name if limit is greater than max value', async () => { + // Arrange + const request = new Request( + `${BASE_URL}?format=geojson&name=${queryableDevice?.name}&limit=21`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }, + ) - // Act - const response: any = await devicesLoader({ + // Act + await expect(async () => { + await devicesLoader({ request: request, } as LoaderFunctionArgs) + }).rejects.toThrow() + }) - expect(response).toBeDefined() - expect(Array.isArray(response?.features)).toBe(true) - expect(response?.features.length).lessThanOrEqual(2) - }) + it('should deny searching for a name if limit is lower than min value', async () => { + // Arrange + const request = new Request( + `${BASE_URL}?format=geojson&name=sensebox&limit=0`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }, + ) - it('should deny searching for a name if limit is greater than max value', async () => { - // Arrange - const request = new Request( - `${BASE_URL}?format=geojson&name=${queryableDevice?.name}&limit=21`, - { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }, - ) + // Act + await expect(async () => { + await devicesLoader({ + request: request, + } as LoaderFunctionArgs) + }).rejects.toThrow() + }) - // Act - await expect(async () => { - await devicesLoader({ - request: request, - } as LoaderFunctionArgs) - }).rejects.toThrow() + it('should allow to request minimal boxes', async () => { + // Arrange + const request = new Request(`${BASE_URL}?minimal=true&format=geojson`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, }) - it('should deny searching for a name if limit is lower than min value', async () => { - // Arrange - const request = new Request( - `${BASE_URL}?format=geojson&name=sensebox&limit=0`, - { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }, - ) + // Act + const response: any = await devicesLoader({ + request: request, + } as LoaderFunctionArgs) + + // Assert + expect(response).toBeDefined() + expect(response.type).toBe('FeatureCollection') + expect(Array.isArray(response?.features)).toBe(true) + + if (response.features.length > 0) { + const feature = response.features[0] + expect(feature.type).toBe('Feature') + expect(feature.properties).toBeDefined() + + // Should have minimal fields + const props = feature.properties + expect(props?._id || props?.id).toBeDefined() + expect(props?.name).toBeDefined() + + // Should NOT include these fields in minimal mode + expect(props?.loc).toBeUndefined() + expect(props?.locations).toBeUndefined() + expect(props?.weblink).toBeUndefined() + expect(props?.image).toBeUndefined() + expect(props?.description).toBeUndefined() + expect(props?.model).toBeUndefined() + expect(props?.sensors).toBeUndefined() + } + }) - // Act - await expect(async () => { - await devicesLoader({ - request: request, - } as LoaderFunctionArgs) - }).rejects.toThrow() - }) + it('should return the correct count and correct schema of boxes for /boxes GET with date parameter', async () => { + const tenDaysAgoIso = new Date( + Date.now() - 10 * 24 * 60 * 60 * 1000, + ).toISOString() - it('should allow to request minimal boxes', async () => { - // Arrange - const request = new Request(`${BASE_URL}?minimal=true&format=geojson`, { + // Arrange + const request = new Request( + `${BASE_URL}?format=geojson&date=${tenDaysAgoIso}`, + { method: 'GET', headers: { 'Content-Type': 'application/json' }, - }) + }, + ) - // Act - const response: any = await devicesLoader({ - request: request, - } as LoaderFunctionArgs) + // Act + const response: any = await devicesLoader({ + request: request, + } as LoaderFunctionArgs) - // Assert - expect(response).toBeDefined() - expect(response.type).toBe('FeatureCollection') - expect(Array.isArray(response?.features)).toBe(true) + // Assert + expect(response).toBeDefined() + expect(response.type).toBe('FeatureCollection') + expect(Array.isArray(response?.features)).toBe(true) - if (response.features.length > 0) { - const feature = response.features[0] + // Verify that returned boxes have sensor measurements after the specified date + if (response.features.length > 0) { + response.features.forEach((feature: any) => { expect(feature.type).toBe('Feature') expect(feature.properties).toBeDefined() - // Should have minimal fields - const props = feature.properties - expect(props?._id || props?.id).toBeDefined() - expect(props?.name).toBeDefined() - - // Should NOT include these fields in minimal mode - expect(props?.loc).toBeUndefined() - expect(props?.locations).toBeUndefined() - expect(props?.weblink).toBeUndefined() - expect(props?.image).toBeUndefined() - expect(props?.description).toBeUndefined() - expect(props?.model).toBeUndefined() - expect(props?.sensors).toBeUndefined() - } - }) + // If the box has sensors with measurements, they should be after the date + if ( + feature.properties?.sensors && + Array.isArray(feature.properties.sensors) + ) { + const hasRecentMeasurement = feature.properties.sensors.some( + (sensor: any) => { + if (sensor.lastMeasurement?.createdAt) { + const measurementDate = new Date( + sensor.lastMeasurement.createdAt, + ) + const filterDate = new Date(tenDaysAgoIso) + return measurementDate >= filterDate + } + return false + }, + ) - it('should return the correct count and correct schema of boxes for /boxes GET with date parameter', async () => { - const tenDaysAgoIso = new Date( - Date.now() - 10 * 24 * 60 * 60 * 1000, - ).toISOString() + // If there are sensors with lastMeasurement, at least one should be recent + if ( + feature.properties.sensors.some( + (s: any) => s.lastMeasurement?.createdAt, + ) + ) { + expect(hasRecentMeasurement).toBe(true) + } + } + }) + } + }) - // Arrange - const request = new Request( - `${BASE_URL}?format=geojson&date=${tenDaysAgoIso}`, - { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }, - ) + it('should reject filtering boxes near a location with wrong parameter values', async () => { + // Arrange + const request = new Request(`${BASE_URL}?near=test,60`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) - // Act - const response: any = await devicesLoader({ + // Act & Assert + await expect(async () => { + await devicesLoader({ request: request, } as LoaderFunctionArgs) + }).rejects.toThrow() + }) - // Assert - expect(response).toBeDefined() - expect(response.type).toBe('FeatureCollection') - expect(Array.isArray(response?.features)).toBe(true) + it('should return 422 error on wrong format parameter', async () => { + // Arrange + const request = new Request(`${BASE_URL}?format=potato`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) - // Verify that returned boxes have sensor measurements after the specified date - if (response.features.length > 0) { - response.features.forEach((feature: any) => { - expect(feature.type).toBe('Feature') - expect(feature.properties).toBeDefined() + try { + await devicesLoader({ + request: request, + } as LoaderFunctionArgs) + expect(true).toBe(false) + } catch (error) { + expect(error).toBeInstanceOf(Response) + expect((error as Response).status).toBe(422) + + const errorData = await (error as Response).json() + expect(errorData.error).toBe('Invalid format parameter') + } + }) - // If the box has sensors with measurements, they should be after the date - if ( - feature.properties?.sensors && - Array.isArray(feature.properties.sensors) - ) { - const hasRecentMeasurement = feature.properties.sensors.some( - (sensor: any) => { - if (sensor.lastMeasurement?.createdAt) { - const measurementDate = new Date( - sensor.lastMeasurement.createdAt, - ) - const filterDate = new Date(tenDaysAgoIso) - return measurementDate >= filterDate - } - return false - }, - ) + it('should return geojson format when requested', async () => { + // Arrange + const request = new Request(`${BASE_URL}?format=geojson`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) - // If there are sensors with lastMeasurement, at least one should be recent - if ( - feature.properties.sensors.some( - (s: any) => s.lastMeasurement?.createdAt, - ) - ) { - expect(hasRecentMeasurement).toBe(true) - } - } - }) + // Act + const geojsonData: any = await devicesLoader({ + request: request, + } as LoaderFunctionArgs) + + expect(geojsonData).toBeDefined() + if (geojsonData) { + // Assert - this should always be GeoJSON since that's what the loader returns + expect(geojsonData.type).toBe('FeatureCollection') + expect(Array.isArray(geojsonData.features)).toBe(true) + + if (geojsonData.features.length > 0) { + expect(geojsonData.features[0].type).toBe('Feature') + expect(geojsonData.features[0].geometry).toBeDefined() + // @ts-ignore + expect(geojsonData.features[0].geometry.coordinates[0]).toBeDefined() + // @ts-ignore + expect(geojsonData.features[0].geometry.coordinates[1]).toBeDefined() + expect(geojsonData.features[0].properties).toBeDefined() } + } + }) + + it('should allow to filter boxes by grouptag', async () => { + // Arrange + const request = new Request(`${BASE_URL}?grouptag=testgroup`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, }) - it('should reject filtering boxes near a location with wrong parameter values', async () => { - // Arrange - const request = new Request(`${BASE_URL}?near=test,60`, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }) + // Act + const response = await devicesLoader({ request } as LoaderFunctionArgs) - // Act & Assert - await expect(async () => { - await devicesLoader({ - request: request, - } as LoaderFunctionArgs) - }).rejects.toThrow() - }) + // Handle case where loader returned a Response (e.g. validation error) + const data = response instanceof Response ? await response.json() : response - it('should return 422 error on wrong format parameter', async () => { - // Arrange - const request = new Request(`${BASE_URL}?format=potato`, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }) + expect(data).toBeDefined() + expect(Array.isArray(data)).toBe(true) - try { - await devicesLoader({ - request: request, - } as LoaderFunctionArgs) - expect(true).toBe(false) - } catch (error) { - expect(error).toBeInstanceOf(Response) - expect((error as Response).status).toBe(422) + expect(data).toHaveLength(1) - const errorData = await (error as Response).json() - expect(errorData.error).toBe('Invalid format parameter') - } - }) + if (response instanceof Response) { + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toMatch(/application\/json/) + } + }) - it('should return geojson format when requested', async () => { - // Arrange - const request = new Request(`${BASE_URL}?format=geojson`, { + it('should allow filtering boxes by bounding box', async () => { + // Arrange + const request = new Request( + `${BASE_URL}?format=geojson&bbox=120,60,121,61`, + { method: 'GET', headers: { 'Content-Type': 'application/json' }, - }) - - // Act - const geojsonData: any = await devicesLoader({ - request: request, - } as LoaderFunctionArgs) - - expect(geojsonData).toBeDefined() - if (geojsonData) { - // Assert - this should always be GeoJSON since that's what the loader returns - expect(geojsonData.type).toBe('FeatureCollection') - expect(Array.isArray(geojsonData.features)).toBe(true) - - if (geojsonData.features.length > 0) { - expect(geojsonData.features[0].type).toBe('Feature') - expect(geojsonData.features[0].geometry).toBeDefined() - // @ts-ignore - expect(geojsonData.features[0].geometry.coordinates[0]).toBeDefined() - // @ts-ignore - expect(geojsonData.features[0].geometry.coordinates[1]).toBeDefined() - expect(geojsonData.features[0].properties).toBeDefined() - } - } - }) - - it('should allow to filter boxes by grouptag', async () => { - // Arrange - const request = new Request(`${BASE_URL}?grouptag=newgroup`, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }); - - // Act - const response = await devicesLoader({ request } as LoaderFunctionArgs); - - // Handle case where loader returned a Response (e.g. validation error) - const data = response instanceof Response ? await response.json() : response; - - expect(data).toBeDefined(); - expect(Array.isArray(data)).toBe(true); - - expect(data).toHaveLength(1); - - if (response instanceof Response) { - expect(response.status).toBe(200); - expect(response.headers.get('content-type')).toMatch(/application\/json/); - } - }); - - - it('should allow filtering boxes by bounding box', async () => { - // Arrange - const request = new Request( - `${BASE_URL}?format=geojson&bbox=120,60,121,61`, - { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }, - ) + }, + ) - // Act - const response: any = await devicesLoader({ - request: request, - } as LoaderFunctionArgs) + // Act + const response: any = await devicesLoader({ + request: request, + } as LoaderFunctionArgs) - expect(response).toBeDefined() + expect(response).toBeDefined() - if (response) { - // Assert - expect(response.type).toBe('FeatureCollection') - expect(Array.isArray(response.features)).toBe(true) + if (response) { + // Assert + expect(response.type).toBe('FeatureCollection') + expect(Array.isArray(response.features)).toBe(true) - if (response.features.length > 0) { - response.features.forEach((feature: any) => { - expect(feature.type).toBe('Feature') - expect(feature.geometry).toBeDefined() - expect(feature.geometry.coordinates).toBeDefined() + if (response.features.length > 0) { + response.features.forEach((feature: any) => { + expect(feature.type).toBe('Feature') + expect(feature.geometry).toBeDefined() + expect(feature.geometry.coordinates).toBeDefined() - const [longitude, latitude] = feature.geometry.coordinates + const [longitude, latitude] = feature.geometry.coordinates - // Verify coordinates are within the bounding box [120,60,121,61] - expect(longitude).toBeGreaterThanOrEqual(120) - expect(longitude).toBeLessThanOrEqual(121) - expect(latitude).toBeGreaterThanOrEqual(60) - expect(latitude).toBeLessThanOrEqual(61) - }) - } + // Verify coordinates are within the bounding box [120,60,121,61] + expect(longitude).toBeGreaterThanOrEqual(120) + expect(longitude).toBeLessThanOrEqual(121) + expect(latitude).toBeGreaterThanOrEqual(60) + expect(latitude).toBeLessThanOrEqual(61) + }) } - }) + } }) describe('POST', () => { @@ -431,51 +416,54 @@ describe('openSenseMap API Routes: /boxes', () => { it('should reject a new box with invalid coords', async () => { function minimalSensebox(coords: number[]) { - return { - name: "Test Box", - location: coords, - sensors: [], - }; + return { + name: 'Test Box', + location: coords, + sensors: [], + } } - - const requestBody = minimalSensebox([52]); - + + const requestBody = minimalSensebox([52]) + const request = new Request(BASE_URL, { - method: 'POST', - headers: { - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(requestBody), - }); - + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(requestBody), + }) + try { - await devicesAction({ request } as ActionFunctionArgs); + await devicesAction({ request } as ActionFunctionArgs) } catch (error) { - if (error instanceof Response) { - expect(error.status).toBe(422); - - const errorData = await error.json(); - expect(errorData.message).toBe( - 'Illegal value for parameter location. missing latitude or longitude in location [52]' - ); - } else { - throw error; - } + if (error instanceof Response) { + expect(error.status).toBe(422) + + const errorData = await error.json() + expect(errorData.message).toBe( + 'Illegal value for parameter location. missing latitude or longitude in location [52]', + ) + } else { + throw error + } } - }); - + }) it('should reject a new box without location field', async () => { // Arrange - function minimalSensebox(coords: number[]): {name: string, location?: number[], sensors: any[]} { + function minimalSensebox(coords: number[]): { + name: string + location?: number[] + sensors: any[] + } { return { - name: "Test Box", - location: coords, - sensors: [], - }; - } - - const requestBody = minimalSensebox([52]); + name: 'Test Box', + location: coords, + sensors: [], + } + } + + const requestBody = minimalSensebox([52]) delete requestBody.location const request = new Request(BASE_URL, { @@ -560,6 +548,252 @@ describe('openSenseMap API Routes: /boxes', () => { }) }) + describe('PUT', () => { + it('should allow to update the device via PUT', async () => { + const update_payload = { + name: 'neuername', + exposure: 'indoor', + grouptag: 'testgroup', + description: 'total neue beschreibung', + location: { lat: 54.2, lng: 21.1 }, + weblink: 'http://www.google.de', + image: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=', + } + + const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(update_payload), + }) + + const response = await deviceUpdateAction({ + request, + params: { deviceId: queryableDevice?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) + + expect(response.status).toBe(200) + + const data = await response.json() + + expect(data.name).toBe(update_payload.name) + expect(data.exposure).toBe(update_payload.exposure) + expect(Array.isArray(data.grouptag)).toBe(true) + expect(data.grouptag).toContain(update_payload.grouptag) + expect(data.description).toBe(update_payload.description) + + expect(data.currentLocation).toEqual({ + type: 'Point', + coordinates: [ + update_payload.location.lng, + update_payload.location.lat, + ], + timestamp: expect.any(String), + }) + + expect(data.loc).toEqual([ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [ + update_payload.location.lng, + update_payload.location.lat, + ], + timestamp: expect.any(String), + }, + }, + ]) + }) + + it('should allow to update the device via PUT with array as grouptags', async () => { + const update_payload = { + name: 'neuername', + exposure: 'outdoor', + grouptag: ['testgroup'], + description: 'total neue beschreibung', + location: { lat: 54.2, lng: 21.1 }, + weblink: 'http://www.google.de', + image: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=', + } + + const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(update_payload), + }) + + const response: any = await deviceUpdateAction({ + request, + params: { deviceId: queryableDevice?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) + + expect(response.status).toBe(200) + + const data = await response.json() + expect(data.name).toBe(update_payload.name) + expect(data.exposure).toBe(update_payload.exposure) + + expect(Array.isArray(data.grouptag)).toBe(true) + expect(data.grouptag).toEqual(update_payload.grouptag) + + expect(data.description).toBe(update_payload.description) + expect(data.currentLocation.coordinates).toEqual([ + update_payload.location.lng, + update_payload.location.lat, + ]) + expect(data.loc[0].geometry.coordinates).toEqual([ + update_payload.location.lng, + update_payload.location.lat, + ]) + + //TODO: this fails, check if we actually need timestamps in images + // const parts = data.image.split('_') + // const ts36 = parts[1].replace('.png', '') + // const tsMs = parseInt(ts36, 36) * 1000 + // expect(Date.now() - tsMs).toBeLessThan(1000) + }) + it('should remove image when deleteImage=true', async () => { + const update_payload = { + deleteImage: true, + } + + const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(update_payload), + }) + + const response = await deviceUpdateAction({ + request, + params: { deviceId: queryableDevice?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.image).toBeNull() + }) + + it('should nullify description when set to empty string', async () => { + const update_payload = { + description: '', + } + + const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(update_payload), + }) + + const response = await deviceUpdateAction({ + request, + params: { deviceId: queryableDevice?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.description).toBeNull() + }) + + it('should clear group tags when empty array provided', async () => { + const update_payload = { + grouptag: [], + } + + const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(update_payload), + }) + + const response = await deviceUpdateAction({ + request, + params: { deviceId: queryableDevice?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.grouptag).toHaveLength(0) + }) + + it('should merge addons.add into grouptags', async () => { + const update_payload = { + addons: { add: 'feinstaub' }, + grouptag: ['existinggroup'], + } + + const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(update_payload), + }) + + const response = await deviceUpdateAction({ + request, + params: { deviceId: queryableDevice?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) + + expect(response.status).toBe(200) + const data = await response.json() + + expect(Array.isArray(data.grouptag)).toBe(true) + expect(data.grouptag).toContain('existinggroup') + expect(data.grouptag).toContain('feinstaub') + }) + + it('should accept multi-valued grouptag array', async () => { + const update_payload = { + grouptag: ['tag1', 'tag2', 'tag3'], + } + + const request = new Request(`${BASE_URL}/${queryableDevice?.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(update_payload), + }) + + const response = await deviceUpdateAction({ + request, + params: { deviceId: queryableDevice?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.grouptag).toEqual( + expect.arrayContaining(['tag1', 'tag2', 'tag3']), + ) + }) + }) + describe('DELETE', () => { let deletableDevice: Device | null = null diff --git a/tests/routes/api.location.spec.ts b/tests/routes/api.location.spec.ts index 8db61caf..b0d57e57 100644 --- a/tests/routes/api.location.spec.ts +++ b/tests/routes/api.location.spec.ts @@ -1,680 +1,703 @@ -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"; +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", -}; + 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 Content"); - 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 Content"); - 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 + name: `'${TEST_USER.name}'s Box`, + exposure: 'outdoor', + expiresAt: null, + tags: [], + latitude: 0, + longitude: 0, + 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 Content') + 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 Content') + 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]); + // } + // } + // }); + }) +}) diff --git a/tests/routes/api.measurements.spec.ts b/tests/routes/api.measurements.spec.ts index cd0e810e..f96125b3 100644 --- a/tests/routes/api.measurements.spec.ts +++ b/tests/routes/api.measurements.spec.ts @@ -1,561 +1,535 @@ -import { type AppLoadContext, type ActionFunctionArgs } from "react-router"; -import { csvExampleData, jsonSubmitData, byteSubmitData } from "tests/data"; -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 { accessToken, type User } from "~/schema"; - -const mockAccessToken = "valid-access-token"; +import { type AppLoadContext, type ActionFunctionArgs } from 'react-router' +import { csvExampleData, jsonSubmitData, byteSubmitData } from 'tests/data' +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 { accessToken, type User } from '~/schema' + +const mockAccessToken = 'valid-access-token' const TEST_USER = { - name: "testing measurement submits", - email: "test@measurementsubmits.me", - password: "some secure password", -}; + 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 }); - }); -}); + 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 }) + }) +}) diff --git a/tests/routes/api.tags.spec.ts b/tests/routes/api.tags.spec.ts index fa5e63fb..f5dfec82 100644 --- a/tests/routes/api.tags.spec.ts +++ b/tests/routes/api.tags.spec.ts @@ -1,93 +1,93 @@ -import { type LoaderFunctionArgs } from "react-router"; -import { BASE_URL } from "vitest.setup"; -import { registerUser } from "~/lib/user-service.server"; -import { createDevice, deleteDevice } from "~/models/device.server"; -import { deleteUserByEmail } from "~/models/user.server"; -import { loader } from "~/routes/api.tags"; -import { type User } from "~/schema"; +import { type LoaderFunctionArgs } from 'react-router' +import { BASE_URL } from 'vitest.setup' +import { registerUser } from '~/lib/user-service.server' +import { createDevice, deleteDevice } from '~/models/device.server' +import { deleteUserByEmail } from '~/models/user.server' +import { loader } from '~/routes/api.tags' +import { type User } from '~/schema' const TAGS_TEST_USER = { - name: "testing all my tags", - email: "test@tags.me", - password: "some secure password", -}; + name: 'testing all my tags', + email: 'test@tags.me', + password: 'some secure password', +} const TEST_TAG_BOX = { - name: `'${TAGS_TEST_USER.name}'s Box`, - exposure: "outdoor", - expiresAt: null, - tags: ["tag1", "tag2"], - latitude: 0, - longitude: 0, - model: "luftdaten.info", - mqttEnabled: false, - ttnEnabled: false, -}; + name: `'${TAGS_TEST_USER.name}'s Box`, + exposure: 'outdoor', + expiresAt: null, + tags: ['tag1', 'tag2', 'testgroup'], + latitude: 0, + longitude: 0, + model: 'luftdaten.info', + mqttEnabled: false, + ttnEnabled: false, +} -describe("openSenseMap API Routes: /tags", () => { - let userId: string = ""; - let deviceId: string = ""; +describe('openSenseMap API Routes: /tags', () => { + let userId: string = '' + let deviceId: string = '' - beforeAll(async () => { - const user = await registerUser( - TAGS_TEST_USER.name, - TAGS_TEST_USER.email, - TAGS_TEST_USER.password, - "en_US", - ); - userId = (user as User).id; - }); + beforeAll(async () => { + const user = await registerUser( + TAGS_TEST_USER.name, + TAGS_TEST_USER.email, + TAGS_TEST_USER.password, + 'en_US', + ) + userId = (user as User).id + }) - it("should return empty array of tags when none are there", async () => { - // Arrange - const request = new Request(`${BASE_URL}/tags`, { - method: "GET", - headers: { Accept: "application/json" }, - }); + it('should return empty array of tags when none are there', async () => { + // Arrange + const request = new Request(`${BASE_URL}/tags`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }) - // Act - const dataFunctionValue = await loader({ - request: request, - } as LoaderFunctionArgs); - const response = dataFunctionValue as Response; - const body = await response.json(); + // Act + const dataFunctionValue = await loader({ + request: request, + } as LoaderFunctionArgs) + const response = dataFunctionValue as Response + const body = await response.json() - // Assert - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); - expect(Array.isArray(body.data)).toBe(true); - expect(body.data).toHaveLength(0); - }); + // Assert + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(Array.isArray(body.data)).toBe(true) + expect(body.data).toHaveLength(0) + }) - it("should return distinct grouptags of boxes", async () => { - // Arrange - const request = new Request(`${BASE_URL}/tags`, { - method: "GET", - headers: { Accept: "application/json" }, - }); - const device = await createDevice(TEST_TAG_BOX, userId); - deviceId = device.id; + it('should return distinct grouptags of boxes', async () => { + // Arrange + const request = new Request(`${BASE_URL}/tags`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }) + const device = await createDevice(TEST_TAG_BOX, userId) + deviceId = device.id - // Act - const dataFunctionValue = await loader({ - request: request, - } as LoaderFunctionArgs); - const response = dataFunctionValue as Response; - const body = await response.json(); + // Act + const dataFunctionValue = await loader({ + request: request, + } as LoaderFunctionArgs) + const response = dataFunctionValue as Response + const body = await response.json() - // Assert - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json; charset=utf-8", - ); - expect(Array.isArray(body.data)).toBe(true); - expect(body.data).toHaveLength(2); - }); + // Assert + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + expect(Array.isArray(body.data)).toBe(true) + expect(body.data).toHaveLength(3) + }) - afterAll(async () => { - // delete the valid test user - await deleteUserByEmail(TAGS_TEST_USER.email); - await deleteDevice({ id: deviceId }); - }); -}); + afterAll(async () => { + // delete the valid test user + await deleteUserByEmail(TAGS_TEST_USER.email) + await deleteDevice({ id: deviceId }) + }) +})