diff --git a/app/components/device-detail/device-detail-box.tsx b/app/components/device-detail/device-detail-box.tsx index c15df07f..f093c65d 100644 --- a/app/components/device-detail/device-detail-box.tsx +++ b/app/components/device-detail/device-detail-box.tsx @@ -107,7 +107,7 @@ export default function DeviceDetailBox() { const [sensors, setSensors] = useState(); useEffect(() => { - const sortedSensors = [...data.sensors].sort( + const sortedSensors = [...data.sensors as any].sort( (a, b) => (a.id as unknown as number) - (b.id as unknown as number), ); setSensors(sortedSensors); diff --git a/app/lib/devices-service.server.ts b/app/lib/devices-service.server.ts new file mode 100644 index 00000000..dd7120e3 --- /dev/null +++ b/app/lib/devices-service.server.ts @@ -0,0 +1,119 @@ +import { Device, User } from '~/schema' +import { + deleteDevice as deleteDeviceById, +} from '~/models/device.server' +import { verifyLogin } from '~/models/user.server' +import { z } from 'zod' + +export const BoxesQuerySchema = z.object({ + format: z.enum(["json", "geojson"] ,{ + errorMap: () => ({ message: "Format must be either 'json' or 'geojson'" }), + }).default("json"), + minimal: z.enum(["true", "false"]).default("false") + .transform((v) => v === "true"), + full: z.enum(["true", "false"]).default("false") + .transform((v) => v === "true"), + limit: z + .string() + .default("5") + .transform((val) => parseInt(val, 10)) + .refine((val) => !isNaN(val), { message: "Limit must be a number" }) + .refine((val) => val >= 1, { message: "Limit must be at least 1" }) + .refine((val) => val <= 20, { message: "Limit must not exceed 20" }), + + name: z.string().optional(), + date: z.preprocess( + (val) => { + if (typeof val === "string") return [val]; + if (Array.isArray(val)) return val; + return val; + }, + z.array(z.string()) + .min(1, "At least one date required") + .max(2, "At most two dates allowed") + .transform((arr) => { + const [fromDateStr, toDateStr] = arr; + const fromDate = new Date(fromDateStr); + if (isNaN(fromDate.getTime())) throw new Error(`Invalid date: ${fromDateStr}`); + + if (!toDateStr) { + return { + fromDate: new Date(fromDate.getTime() - 4 * 60 * 60 * 1000), + toDate: new Date(fromDate.getTime() + 4 * 60 * 60 * 1000), + }; + } + + const toDate = new Date(toDateStr); + if (isNaN(toDate.getTime())) throw new Error(`Invalid date: ${toDateStr}`); + return { fromDate, toDate }; + }) + ).optional(), + phenomenon: z.string().optional(), + grouptag: z.string().transform((v) => [v]).optional(), + model: z.string().transform((v) => [v]).optional(), + exposure: z.string().transform((v) => [v]).optional(), + + near: z + .string() + .regex(/^[-+]?\d+(\.\d+)?,[-+]?\d+(\.\d+)?$/, { + message: "Invalid 'near' parameter format. Expected: 'lat,lng'", + }) + .transform((val) => val.split(",").map(Number) as [number, number]) + .optional(), + + maxDistance: z.string().transform((v) => Number(v)).optional(), + + bbox: z + .string() + .transform((val) => { + const coords = val.split(",").map(Number); + if (coords.length !== 4 || coords.some((n) => isNaN(n))) { + throw new Error("Invalid bbox parameter"); + } + const [swLng, swLat, neLng, neLat] = coords; + return { + coordinates: [ + [ + [swLat, swLng], + [neLat, swLng], + [neLat, neLng], + [swLat, neLng], + [swLat, swLng], + ], + ], + }; + }) + .optional(), + + fromDate: z.string().datetime().transform((v) => new Date(v)).optional(), + toDate: z.string().datetime().transform((v) => new Date(v)).optional(), + }) +// .refine( +// (data) => +// !(data.date && !data.phenomenon) && !(data.phenomenon && !data.date), +// { +// message: "Date and phenomenon must be used together", +// path: ["date"], +// } +// ); + + + export type BoxesQueryParams = z.infer; + +/** + * Deletes a device after verifiying that the user is entitled by checking + * the password. + * @param user The user deleting the device + * @param password The users password to verify + * @returns True if the device was deleted, otherwise false or "unauthorized" + * if the user is not entitled to delete the device with the given parameters + */ +export const deleteDevice = async ( + user: User, + device: Device, + password: string, +): Promise => { + const verifiedUser = await verifyLogin(user.email, password) + if (verifiedUser === null) return 'unauthorized' + return (await deleteDeviceById({ id: device.id })).count > 0 +} diff --git a/app/models/device.server.ts b/app/models/device.server.ts index 2f3ff2e4..f2c66c10 100644 --- a/app/models/device.server.ts +++ b/app/models/device.server.ts @@ -1,5 +1,5 @@ import { point } from '@turf/helpers' -import { eq, sql, desc } from 'drizzle-orm' +import { eq, sql, desc, ilike, inArray, arrayContains, and } from 'drizzle-orm' import { type Point } from 'geojson' import { drizzleClient } from '~/db.server' import { device, location, sensor, type Device, type Sensor } from '~/schema' @@ -122,7 +122,13 @@ export function getUserDevices(userId: Device['userId']) { }) } -export async function getDevices() { +type DevicesFormat = 'json' | 'geojson' + +export async function getDevices(format: 'json'): Promise +export async function getDevices(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, @@ -135,18 +141,23 @@ export async function getDevices() { tags: true, }, }) - 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) + 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 geojson + return devices } export async function getDevicesWithSensors() { @@ -206,6 +217,164 @@ export async function getDevicesWithSensors() { 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[]; + } + + export interface FindDevicesOptions extends BuildWhereClauseOptions { + minimal?: string | boolean; + limit?: number; + format?: "json" | "geojson" + } + + interface WhereClauseResult { + includeColumns: Record; + whereClause: any[]; + } + + const buildWhereClause = function buildWhereClause( + 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 }) => 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( + 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( + 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 + }; + }; + + const MINIMAL_COLUMNS = { + 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 + }; + + export async function findDevices ( + 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; + }; + export async function createDevice(deviceData: any, userId: string) { try { const newDevice = await drizzleClient.transaction(async (tx) => { diff --git a/app/models/sensor.server.ts b/app/models/sensor.server.ts index 9e5f99ee..5d262f4d 100644 --- a/app/models/sensor.server.ts +++ b/app/models/sensor.server.ts @@ -71,16 +71,16 @@ export function getSensorsFromDevice(deviceId: Sensor["deviceId"]) { export async function getSensorsWithLastMeasurement( deviceId: Sensor["deviceId"], -): Promise; -export async function getSensorsWithLastMeasurement( - deviceId: Sensor["deviceId"], - sensorId: Sensor["id"], -): Promise; + sensorId?: Sensor["id"], + count?: number, +): Promise; + + export async function getSensorsWithLastMeasurement( deviceId: Sensor["deviceId"], sensorId: Sensor["id"] | undefined = undefined, count: number = 1, -) { +): Promise { const result = await drizzleClient.execute( sql`SELECT s.id, diff --git a/app/routes/api.device.$deviceId.ts b/app/routes/api.device.$deviceId.ts new file mode 100644 index 00000000..159aa0b0 --- /dev/null +++ b/app/routes/api.device.$deviceId.ts @@ -0,0 +1,101 @@ +import { json, type LoaderFunctionArgs } from '@remix-run/node' +import { getDevice } from '~/models/device.server' + +/** + * @openapi + * /api/device/{deviceId}: + * get: + * summary: Get device by ID + * description: Retrieve a single device by their unique identifier + * tags: + * - Device + * parameters: + * - in: path + * name: id + * required: true + * description: Unique identifier of the user + * schema: + * type: string + * example: "12345" + * responses: + * 200: + * description: Device retrieved successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * example: "12345" + * name: + * type: string + * example: "John Doe" + * email: + * type: string + * example: "john.doe@example.com" + * createdAt: + * type: string + * format: date-time + * example: "2023-01-15T10:30:00Z" + * 404: + * description: Device not found + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * example: "Device not found" + * 400: + * description: Device ID is required + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * example: "Device ID is required." + * 500: + * description: Internal server error + */ +export async function loader({ params }: LoaderFunctionArgs) { + const { deviceId } = params + + if (!deviceId) { + return new Response(JSON.stringify({ message: 'Device ID is required.' }), { + status: 400, + headers: { + 'content-type': 'application/json; charset=utf-8', + }, + }) + } + + try { + const device = await getDevice({ id: deviceId }) + + if (!device) { + return new Response(JSON.stringify({ message: 'Device not found.' }), { + status: 404, + headers: { + 'content-type': 'application/json; charset=utf-8', + }, + }) + } + + return json(device) + } catch (error) { + console.error('Error fetching box:', error) + + if (error instanceof Response) { + throw error + } + + throw json( + { error: 'Internal server error while fetching box' }, + { status: 500 }, + ) + } +} diff --git a/app/routes/api.devices.ts b/app/routes/api.devices.ts new file mode 100644 index 00000000..74151627 --- /dev/null +++ b/app/routes/api.devices.ts @@ -0,0 +1,529 @@ +import { json, type LoaderFunctionArgs } from '@remix-run/node' +import { + createDevice, + findDevices, + FindDevicesOptions, + // deleteDevice, + getDevice, + getDevices, + getDevicesWithSensors, +} from '~/models/device.server' +import { ActionFunctionArgs } from 'react-router' +import { getUserFromJwt } from '~/lib/jwt' +import { + BoxesQuerySchema, + deleteDevice, + type BoxesQueryParams, +} from '~/lib/devices-service.server' +import { Device, DeviceExposureType, User } from '~/schema' +import { Point } from 'geojson' + +function fromToTimeParamsSanityCheck(fromDate: Date | null, toDate: Date | null) { + if ((fromDate && !toDate) || (toDate && !fromDate)) { + throw json( + { error: 'fromDate and toDate need to be specified simultaneously' }, + { status: 400 } + ); + } + + if (fromDate && toDate) { + if (fromDate.getTime() > toDate.getTime()) { + throw json( + { error: `Invalid time frame specified: fromDate (${fromDate.toISOString()}) is after toDate (${toDate.toISOString()})` }, + { status: 422 } + ); + } + } + } + +/** + * @openapi + * /api/devices: + * get: + * tags: + * - Devices + * summary: Get devices with filtering options + * description: Retrieves devices based on various filter criteria. Supports both JSON and GeoJSON formats. + * parameters: + * - name: format + * in: query + * required: false + * schema: + * type: string + * enum: [json, geojson] + * default: json + * description: Response format + * - name: minimal + * in: query + * required: false + * schema: + * type: string + * enum: [true, false] + * default: false + * description: Return minimal device information + * - name: full + * in: query + * required: false + * schema: + * type: string + * enum: [true, false] + * default: false + * description: Return full device information + * - name: limit + * in: query + * required: false + * schema: + * type: integer + * minimum: 1 + * maximum: 20 + * default: 5 + * description: Maximum number of devices to return + * - name: name + * in: query + * required: false + * schema: + * type: string + * description: Filter devices by name + * - name: phenomenon + * in: query + * required: false + * schema: + * type: string + * description: Filter devices by phenomenon type + * - name: fromDate + * in: query + * required: false + * schema: + * type: string + * format: date-time + * description: Filter devices from this date + * example: "2023-05-15T10:00:00Z" + * - name: toDate + * in: query + * required: false + * schema: + * type: string + * format: date-time + * description: Filter devices to this date + * example: "2023-05-15T12:00:00Z" + * - name: grouptag + * in: query + * required: false + * schema: + * type: string + * description: Filter devices by group tag + * - name: exposure + * in: query + * required: false + * schema: + * type: string + * description: Filter devices by exposure type + * - name: near + * in: query + * required: false + * schema: + * type: string + * pattern: '^-?\d+\.?\d*,-?\d+\.?\d*$' + * description: Find devices near coordinates (lat,lng) + * example: "52.5200,13.4050" + * - name: maxDistance + * in: query + * required: false + * schema: + * type: number + * default: 1000 + * description: Maximum distance in meters when using 'near' parameter + * - name: bbox + * in: query + * required: false + * schema: + * type: string + * pattern: '^-?\d+\.?\d*,-?\d+\.?\d*,-?\d+\.?\d*,-?\d+\.?\d*$' + * description: Bounding box coordinates (swLng,swLat,neLng,neLat) + * example: "13.2,52.4,13.6,52.6" + * - name: date + * in: query + * required: false + * schema: + * type: string + * format: date-time + * description: Specific date filter (TODO - not implemented) + * responses: + * 200: + * description: Successfully retrieved devices + * content: + * application/json: + * schema: + * oneOf: + * - type: array + * items: + * $ref: '#/components/schemas/Device' + * - $ref: '#/components/schemas/GeoJSONFeatureCollection' + * 400: + * description: Invalid request + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 422: + * description: Invalid parameters + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * examples: + * invalidFormat: + * summary: Invalid format parameter + * value: + * error: "Failed to fetch devices" + * invalidLimit: + * summary: Invalid limit parameter + * value: + * error: "Limit must be at least 1" + * exceedsLimit: + * summary: Limit exceeds maximum + * value: + * error: "Limit should not exceed 20" + * invalidNear: + * summary: Invalid near parameter + * value: + * error: "Invalid 'near' parameter format. Expected: 'lat,lng'" + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * example: + * error: "Failed to fetch devices" + * + * components: + * schemas: + * Device: + * type: object + * required: + * - id + * - latitude + * - longitude + * properties: + * id: + * type: string + * description: Unique device identifier + * example: "device-123" + * name: + * type: string + * description: Device name + * example: "Temperature Sensor A1" + * latitude: + * type: number + * format: float + * description: Device latitude coordinate + * example: 52.5200 + * longitude: + * type: number + * format: float + * description: Device longitude coordinate + * example: 13.4050 + * phenomenon: + * type: string + * description: Type of phenomenon measured + * example: "temperature" + * grouptag: + * type: string + * description: Group tag for device categorization + * example: "outdoor-sensors" + * exposure: + * type: string + * description: Device exposure type + * example: "outdoor" + * createdAt: + * type: string + * format: date-time + * description: Device creation timestamp + * example: "2023-05-15T10:00:00Z" + * updatedAt: + * type: string + * format: date-time + * description: Device last update timestamp + * example: "2023-05-15T12:00:00Z" + * + * GeoJSONFeatureCollection: + * type: object + * required: + * - type + * - features + * properties: + * type: + * type: string + * enum: [FeatureCollection] + * example: "FeatureCollection" + * features: + * type: array + * items: + * $ref: '#/components/schemas/GeoJSONFeature' + * + * GeoJSONFeature: + * type: object + * required: + * - type + * - geometry + * - properties + * properties: + * type: + * type: string + * enum: [Feature] + * example: "Feature" + * geometry: + * $ref: '#/components/schemas/GeoJSONPoint' + * properties: + * $ref: '#/components/schemas/Device' + * + * GeoJSONPoint: + * type: object + * required: + * - type + * - coordinates + * properties: + * type: + * type: string + * enum: [Point] + * example: "Point" + * coordinates: + * type: array + * items: + * type: number + * minItems: 2 + * maxItems: 2 + * description: Longitude and latitude coordinates + * example: [13.4050, 52.5200] + * + * ErrorResponse: + * type: object + * required: + * - error + * properties: + * error: + * type: string + * description: Error message + * example: "Failed to fetch devices" + */ +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + const queryObj = Object.fromEntries(url.searchParams); + const max_limit = 20; + + const parseResult = BoxesQuerySchema.safeParse(queryObj); + if (!parseResult.success) { + const { fieldErrors, formErrors } = parseResult.error.flatten(); + if (fieldErrors.format) { + throw json( + { error: "Invalid format parameter" }, + { status: 422 } + ); + } + + throw json( + { error: parseResult.error.flatten() }, + { status: 422 } + ); + } + + const params: FindDevicesOptions = parseResult.data; + + const devices = await findDevices(params) + + if (params.format === "geojson"){ + const geojson = { + type: 'FeatureCollection', + features: devices.map((device: Device) => ({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [device.longitude, device.latitude] + }, + properties: { + ...device + } + })) + }; + + return geojson; + } + else { + return devices + } +} + +export async function action({ request, params }: ActionFunctionArgs) { + try { + const jwtResponse = await getUserFromJwt(request) + + if (typeof jwtResponse === 'string') + return Response.json( + { + code: 'Forbidden', + message: + 'Invalid JWT authorization. Please sign in to obtain new JWT.', + }, + { + status: 403, + }, + ) + switch (request.method) { + case 'POST': + return await post(request, jwtResponse) + case 'DELETE': + return await del(request, jwtResponse, params) + default: + return Response.json({ msg: 'Method Not Allowed' }, { status: 405 }) + } + } catch (err) { + console.warn(err) + return Response.json( + { + error: 'Internal Server Error', + message: + 'The server was unable to complete your request. Please try again later.', + }, + { + status: 500, + }, + ) + } +} + +async function del(request: Request, user: User, params: any) { + const { deviceId } = params + + if (!deviceId) { + throw json({ message: 'Device ID is required' }, { status: 400 }) + } + + const device = (await getDevice({ id: deviceId })) as unknown as Device + + if (!device) { + throw json({ message: 'Device not found' }, { status: 404 }) + } + + const body = await request.json() + + if (!body.password) { + throw json( + { message: 'Password is required for device deletion' }, + { status: 400 }, + ) + } + + try { + const deleted = await deleteDevice(user, device, body.password) + + if (deleted === 'unauthorized') + return Response.json( + { message: 'Password incorrect' }, + { + status: 401, + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + }, + ) + + return Response.json(null, { + status: 200, + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + }) + } catch (err) { + console.warn(err) + return new Response('Internal Server Error', { status: 500 }) + } +} + +async function post(request: Request, user: User) { + try { + const body = await request.json() + + if (!body.location) { + throw json( + { message: 'missing required parameter location' }, + { status: 400 }, + ) + } + + let latitude: number, longitude: number, height: number | undefined + + if (Array.isArray(body.location)) { + // Handle array format [lat, lng, height?] + if (body.location.length < 2) { + throw json( + { + message: `Illegal value for parameter location. missing latitude or longitude in location [${body.location.join(',')}]`, + }, + { status: 422 }, + ) + } + latitude = Number(body.location[0]) + longitude = Number(body.location[1]) + height = body.location[2] ? Number(body.location[2]) : undefined + } else if (typeof body.location === 'object' && body.location !== null) { + // Handle object format { lat, lng, height? } + if (!('lat' in body.location) || !('lng' in body.location)) { + throw json( + { + message: + 'Illegal value for parameter location. missing latitude or longitude', + }, + { status: 422 }, + ) + } + latitude = Number(body.location.lat) + longitude = Number(body.location.lng) + height = body.location.height ? Number(body.location.height) : undefined + } else { + throw json( + { + message: + 'Illegal value for parameter location. Expected array or object', + }, + { status: 422 }, + ) + } + + if (isNaN(latitude) || isNaN(longitude)) { + throw json( + { message: 'Invalid latitude or longitude values' }, + { status: 422 }, + ) + } + + const rawAuthorizationHeader = request.headers.get('authorization') + if (!rawAuthorizationHeader) { + throw json({ message: 'Authorization header required' }, { status: 401 }) + } + const [, jwtString] = rawAuthorizationHeader.split(' ') + + const deviceData = { + ...body, + latitude, + longitude, + } + + const newDevice = await createDevice(deviceData, user.id) + + return json( + { + data: { + ...newDevice, + access_token: jwtString, + createdAt: newDevice.createdAt || new Date(), + }, + }, + { status: 201 }, + ) + } catch (error) { + console.error('Error creating device:', error) + + if (error instanceof Response) { + throw error + } + + throw json({ message: 'Internal server error' }, { status: 500 }) + } +} diff --git a/app/routes/api.ts b/app/routes/api.ts index 4c9a8407..5dc54f8a 100644 --- a/app/routes/api.ts +++ b/app/routes/api.ts @@ -26,10 +26,10 @@ const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = { // method: "GET", // }, - // { - // path: `boxes`, - // method: "GET", - // }, + { + path: `boxes`, + method: "GET", + }, // { // path: `boxes/data`, // method: "GET", @@ -141,10 +141,10 @@ const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = { // path: `boxes/:boxId`, // method: "PUT", // }, - // { - // path: `boxes/:boxId`, - // method: "DELETE", - // }, + { + path: `boxes/:boxId`, + method: "DELETE", + }, // { // path: `boxes/:boxId/:sensorId/measurements`, // method: "DELETE", diff --git a/app/routes/explore.tsx b/app/routes/explore.tsx index b5035740..a315760e 100644 --- a/app/routes/explore.tsx +++ b/app/routes/explore.tsx @@ -330,7 +330,7 @@ export default function Explore() { var deviceLoc: any; let selectedDevice: any; if (deviceId) { - selectedDevice = devices.features.find( + selectedDevice = (devices as any).features.find( (device: any) => device.properties.id === deviceId, ); deviceLoc = [ diff --git a/package.json b/package.json index d02273a4..ab01b57a 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,11 @@ "test": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", + "db:check": "drizzle-kit check", "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio", "db:setup": "npm run db:generate && npm run db:migrate", "db:seed": "tsx ./db/seed.ts", "components": "npx shadcn-ui", diff --git a/tests/routes/api.devices.spec.ts b/tests/routes/api.devices.spec.ts new file mode 100644 index 00000000..0ffbd09d --- /dev/null +++ b/tests/routes/api.devices.spec.ts @@ -0,0 +1,656 @@ +import { + type AppLoadContext, + type LoaderFunctionArgs, + type ActionFunctionArgs, +} from 'react-router' +import { BASE_URL } from 'vitest.setup' +import { createToken } from '~/lib/jwt' +import { registerUser } from '~/lib/user-service.server' +import { createDevice, deleteDevice } from '~/models/device.server' +import { deleteUserByEmail } from '~/models/user.server' +import { loader as deviceLoader } from '~/routes/api.device.$deviceId' +import { + loader as devicesLoader, + action as devicesAction, +} from '~/routes/api.devices' +import { type User, type Device } from '~/schema' + +const DEVICE_TEST_USER = { + name: 'deviceTest', + email: 'test@devices.endpoint', + password: 'highlySecurePasswordForTesting', +} + +const generateMinimalDevice = ( + location: number[] | {} = [123, 12, 34], + exposure = 'mobile', + name = 'senseBox' + new Date().getTime(), +) => ({ + exposure, + location, + name, + model: 'homeV2Ethernet', +}) + +describe('openSenseMap API Routes: /boxes', () => { + let user: User | null = null + let jwt: string = '' + let queryableDevice: Device | null = null + + 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: t } = await createToken(testUser as User) + jwt = t + + queryableDevice = await createDevice( + { ...generateMinimalDevice(), latitude: 123, longitude: 12, tags: ["newgroup"] }, + (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' }, + }, + ) + + // 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 + }) + + 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) + + 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 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() + }) + + 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 + 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' }, + }) + + // 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() + } + }) + + 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() + + // 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) + + // Assert + expect(response).toBeDefined() + expect(response.type).toBe('FeatureCollection') + expect(Array.isArray(response?.features)).toBe(true) + + // 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() + + // 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 + }, + ) + + // 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) + } + } + }) + } + }) + + 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 & Assert + await expect(async () => { + await devicesLoader({ + request: request, + } as LoaderFunctionArgs) + }).rejects.toThrow() + }) + + 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' }, + }) + + 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') + } + }) + + it('should return geojson format when requested', async () => { + // Arrange + const request = new Request(`${BASE_URL}?format=geojson`, { + 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.length).toBe(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) + + expect(response).toBeDefined() + + 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() + + 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) + }) + } + } + }) + }) + + describe('POST', () => { + it('should allow to set the location for a new box as array', async () => { + // Arrange + const loc = [0, 0, 0] + const requestBody = generateMinimalDevice(loc) + + const request = new Request(BASE_URL, { + method: 'POST', + headers: { Authorization: `Bearer ${jwt}` }, + body: JSON.stringify(requestBody), + }) + + // Act + const response = await devicesAction({ + request: request, + } as ActionFunctionArgs) + const responseData = await response.json() + await deleteDevice({ id: responseData.data!.id }) + + // Assert + expect(response.status).toBe(201) + expect(responseData.data.latitude).toBeDefined() + expect(responseData.data.longitude).toBeDefined() + expect(responseData.data.latitude).toBe(loc[0]) + expect(responseData.data.longitude).toBe(loc[1]) + expect(responseData.data.createdAt).toBeDefined() + + // Check that createdAt is recent (within 5 minutes) + const now = new Date() + const createdAt = new Date(responseData.data.createdAt) + const diffInMs = now.getTime() - createdAt.getTime() + expect(diffInMs).toBeLessThan(300000) // 5 minutes in milliseconds + }) + + it('should allow to set the location for a new box as latLng object', async () => { + // Arrange + const loc = { lng: 120.123456, lat: 60.654321 } + const requestBody = generateMinimalDevice(loc) + + const request = new Request(BASE_URL, { + method: 'POST', + headers: { Authorization: `Bearer ${jwt}` }, + body: JSON.stringify(requestBody), + }) + + // Act + const response = await devicesAction({ + request: request, + } as ActionFunctionArgs) + const responseData = await response.json() + await deleteDevice({ id: responseData.data!.id }) + + // Assert + expect(response.status).toBe(201) + expect(responseData.data.latitude).toBeDefined() + expect(responseData.data.latitude).toBe(loc.lat) + expect(responseData.data.longitude).toBeDefined() + expect(responseData.data.longitude).toBe(loc.lng) + expect(responseData.data.createdAt).toBeDefined() + + // Check that createdAt is recent (within 5 minutes) + const now = new Date() + const createdAt = new Date(responseData.data.createdAt) + const diffInMs = now.getTime() - createdAt.getTime() + expect(diffInMs).toBeLessThan(300000) // 5 minutes in milliseconds + }) + + it('should reject a new box with invalid coords', async () => { + function minimalSensebox(coords: number[]) { + return { + name: "Test Box", + location: coords, + sensors: [], + }; + } + + const requestBody = minimalSensebox([52]); + + const request = new Request(BASE_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(requestBody), + }); + + try { + 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; + } + } + }); + + + it('should reject a new box without location field', async () => { + // Arrange + function minimalSensebox(coords: number[]): {name: string, location?: number[], sensors: any[]} { + return { + name: "Test Box", + location: coords, + sensors: [], + }; + } + + const requestBody = minimalSensebox([52]); + delete requestBody.location + + const request = new Request(BASE_URL, { + method: 'POST', + headers: { Authorization: `Bearer ${jwt}` }, + + body: JSON.stringify(requestBody), + }) + + // Act & Assert + try { + await devicesAction({ + request: request, + } as ActionFunctionArgs) + } catch (error) { + if (error instanceof Response) { + expect(error.status).toBe(400) + const errorData = await error.json() + expect(errorData.message).toBe('missing required parameter location') + } else { + throw error + } + } + }) + }) + + describe('/:deviceId', () => { + describe('GET', () => { + let result: any + + beforeAll(async () => { + // Arrange + const request = new Request(`${BASE_URL}/${queryableDevice!.id}`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) + + // Act + const dataFunctionValue = await deviceLoader({ + request: request, + params: { deviceId: queryableDevice!.id }, + context: {} as AppLoadContext, + } as LoaderFunctionArgs) + + const response = dataFunctionValue as Response + + // Assert initial response + expect(dataFunctionValue).toBeInstanceOf(Response) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + + // Get the body for subsequent tests + result = await response.json() + }) + + it('should return the device with correct location data', () => { + expect(result).toBeDefined() + expect(result._id || result.id).toBe(queryableDevice?.id) + expect(result.latitude).toBeDefined() + expect(result.longitude).toBeDefined() + expect(result.latitude).toBe(queryableDevice?.latitude) + expect(result.longitude).toBe(queryableDevice?.longitude) + }) + + it('should return the device name and model', () => { + expect(result.name).toBe(queryableDevice?.name) + expect(result.model).toBe('homeV2Ethernet') + expect(result.exposure).toBe('mobile') + }) + + it('should return the creation timestamp', () => { + expect(result.createdAt).toBeDefined() + expect(result.createdAt).toBe(queryableDevice?.createdAt.toISOString()) + }) + + it('should NOT return sensitive data (if any)', () => { + // Add assertions for fields that shouldn't be returned + // For example, if there are internal fields that shouldn't be exposed: + // expect(result.internalField).toBeUndefined() + }) + }) + + describe('DELETE', () => { + let deletableDevice: Device | null = null + + beforeAll(async () => { + deletableDevice = await createDevice( + { ...generateMinimalDevice(), latitude: 123, longitude: 12 }, + user!.id, + ) + }) + + it('should deny deletion with incorrect password', async () => { + const badDeleteRequest = new Request( + `${BASE_URL}/${queryableDevice?.id}`, + { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify({ password: 'wrong password' }), + }, + ) + + const badDeleteResponse = await devicesAction({ + request: badDeleteRequest, + params: { deviceId: queryableDevice?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) + + expect(badDeleteResponse).toBeInstanceOf(Response) + expect(badDeleteResponse.status).toBe(401) + expect(badDeleteResponse.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + + const badResult = await badDeleteResponse.json() + expect(badResult).toEqual({ message: 'Password incorrect' }) + }) + + it('should successfully delete the device with correct password', async () => { + const validDeleteRequest = new Request( + `${BASE_URL}/${deletableDevice?.id}`, + { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify({ password: DEVICE_TEST_USER.password }), + }, + ) + + const validDeleteResponse = await devicesAction({ + request: validDeleteRequest, + params: { deviceId: deletableDevice?.id }, + context: {} as AppLoadContext, + } as ActionFunctionArgs) + + expect(validDeleteResponse).toBeInstanceOf(Response) + expect(validDeleteResponse.status).toBe(200) + expect(validDeleteResponse.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + }) + + it('should return 404 when trying to get the deleted device', async () => { + const getDeletedRequest = new Request( + `${BASE_URL}/${deletableDevice?.id}`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }, + ) + + const getDeletedResponse = await deviceLoader({ + request: getDeletedRequest, + params: { deviceId: deletableDevice?.id }, + context: {} as AppLoadContext, + } as LoaderFunctionArgs) + + expect(getDeletedResponse).toBeInstanceOf(Response) + expect(getDeletedResponse.status).toBe(404) + expect(getDeletedResponse.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ) + }) + }) + }) + + afterAll(async () => { + await deleteDevice({ id: queryableDevice!.id }) + await deleteUserByEmail(DEVICE_TEST_USER.email) + }) +})