Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions app/models/device.server.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { point } from '@turf/helpers'
import { eq, sql, desc, ilike, arrayContains, and } from 'drizzle-orm'
import { eq, sql, desc, ilike, arrayContains, and, between } from 'drizzle-orm'
import { type Point } from 'geojson'
import { drizzleClient } from '~/db.server'
import { device, location, sensor, type Device, type Sensor } from '~/schema'
import { device, deviceToLocation, location, sensor, type Device, type Sensor } from '~/schema'

const BASE_DEVICE_COLUMNS = {
id: true,
Expand Down Expand Up @@ -77,6 +77,23 @@ export function getDevice({ id }: Pick<Device, 'id'>) {
})
}

export function getLocations({ id }: Pick<Device, 'id'>, fromDate: Date, toDate: Date) {
return drizzleClient
.select({
time: deviceToLocation.time,
x: sql<number>`ST_X(${location.location})`.as('x'),
y: sql<number>`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<Device, 'id'>) {
return drizzleClient.query.device.findFirst({
where: (device, { eq }) => eq(device.id, id),
Expand Down
97 changes: 52 additions & 45 deletions app/routes/api.boxes.$deviceId.data.$sensorId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,7 @@ import { getMeasurements } from "~/models/sensor.server";
import { type Measurement } from "~/schema";
import { convertToCsv } from "~/utils/csv";
import { parseDateParam, parseEnumParam } from "~/utils/param-utils";

const badRequestInit = {
status: 400,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
};
import { badRequest, internalServerError, notFound } from "~/utils/response-utils";

/**
* @openapi
Expand Down Expand Up @@ -97,6 +91,51 @@ const badRequestInit = {
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: array
* example: '[{"sensor_id":"6649b23072c4c40007105953","time":"2025-11-06 23:59:57.189+00","value":4.78,"location_id":"5752066"},{"sensor_id":"6649b23072c4c40007105953","time":"2025-11-06 23:57:06.03+00","value":4.13,"location_id":"5752066"}]'
* text/csv:
* example: "createdAt,value
* 2023-09-29T08:06:13.254Z,6.38
* 2023-09-29T08:06:12.312Z,6.38
* 2023-09-29T08:06:11.513Z,6.38
* 2023-09-29T08:06:10.380Z,6.38
* 2023-09-29T08:06:09.569Z,6.38
* 2023-09-29T08:06:05.967Z,6.38"
* 400:
* description: Bad Request
* content:
* application/json:
* schema:
* type: object
* properties:
* error:
* type: string
* message:
* type: string
* 404:
* description: Not found
* content:
* application/json:
* schema:
* type: object
* properties:
* error:
* type: string
* message:
* type: string
* 500:
* description: Internal Server Error
* content:
* application/json:
* schema:
* type: object
* properties:
* error:
* type: string
* message:
* type: string
*/

export const loader: LoaderFunction = async ({
Expand All @@ -113,12 +152,7 @@ export const loader: LoaderFunction = async ({

let meas: Measurement[] | TransformedMeasurement[] = await getMeasurements(sensorId, fromDate.toISOString(), toDate.toISOString());
if (meas == null)
return new Response(JSON.stringify({ message: "Device not found." }), {
status: 404,
headers: {
"content-type": "application/json; charset=utf-8",
},
});
return notFound("Device not found.");

if (outliers)
meas = transformOutliers(meas, outlierWindow, outliers == "replace");
Expand All @@ -129,7 +163,7 @@ export const loader: LoaderFunction = async ({
if (download)
headers["Content-Disposition"] = `attachment; filename=${sensorId}.${format}`;

let responseInit: ResponseInit = {
const responseInit: ResponseInit = {
status: 200,
headers: headers,
};
Expand All @@ -143,19 +177,7 @@ export const loader: LoaderFunction = async ({

} 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,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
},
);
return internalServerError();
}
};

Expand All @@ -174,20 +196,10 @@ function collectParameters(request: Request, params: Params<string>):
// deviceId is there for legacy reasons
const deviceId = params.deviceId;
if (deviceId === undefined)
return Response.json(
{
code: "Bad Request",
message: "Invalid device id specified",
}, badRequestInit
);
return badRequest("Invalid device id specified");
const sensorId = params.sensorId;
if (sensorId === undefined)
return Response.json(
{
code: "Bad Request",
message: "Invalid sensor id specified",
}, badRequestInit
);
return badRequest("Invalid sensor id specified");

const url = new URL(request.url);

Expand All @@ -199,12 +211,7 @@ function collectParameters(request: Request, params: Params<string>):
let outlierWindow: number = 15;
if (outlierWindowParam !== null) {
if (Number.isNaN(outlierWindowParam) || Number(outlierWindowParam) < 1 || Number(outlierWindowParam) > 50)
return Response.json(
{
error: "Bad Request",
message: "Illegal value for parameter outlier-window. Allowed values: numbers between 1 and 50",
}, badRequestInit
);
return badRequest("Illegal value for parameter outlier-window. Allowed values: numbers between 1 and 50");
outlierWindow = Number(outlierWindowParam);
}

Expand Down
178 changes: 178 additions & 0 deletions app/routes/api.boxes.$deviceId.locations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { type Params, type LoaderFunction, type LoaderFunctionArgs } from "react-router";
import { getLocations } from "~/models/device.server";
import { parseDateParam, parseEnumParam } from "~/utils/param-utils";
import { badRequest, internalServerError, notFound } from "~/utils/response-utils";

/**
* @openapi
* /boxes/{deviceId}/locations:
* get:
* tags:
* - Boxes
* summary: Get locations of a senseBox
* description: Get all locations of the specified senseBox ordered by date as an array of GeoJSON Points.
* If `format=geojson`, a GeoJSON linestring will be returned, with `properties.timestamps`
* being an array with the timestamp for each coordinate.
* parameters:
* - in: path
* name: deviceId
* required: true
* schema:
* type: string
* description: the ID of the senseBox you are referring to
* - in: query
* name: from-date
* required: false
* schema:
* type: string
* description: RFC3339Date
* format: date-time
* description: "Beginning date of measurement data (default: 48 hours ago from now)"
* - in: query
* name: to-date
* required: false
* schema:
* type: string
* descrption: TFC3339Date
* format: date-time
* description: "End date of measurement data (default: now)"
* - in: query
* name: format
* required: false
* schema:
* type: string
* enum:
* - json
* - geojson
* default: json
* description: "Can be 'json' (default) or 'geojson' (default: json)"
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: array
* example: '[{ "coordinates": [7.68123, 51.9123], "type": "Point", "timestamp": "2017-07-27T12:00.000Z"},{ "coordinates": [7.68223, 51.9433, 66.6], "type": "Point", "timestamp": "2017-07-27T12:01.000Z"},{ "coordinates": [7.68323, 51.9423], "type": "Point", "timestamp": "2017-07-27T12:02.000Z"}]'
* application/geojson:
* example: ''
* 400:
* description: Bad Request
* content:
* application/json:
* schema:
* type: object
* properties:
* error:
* type: string
* message:
* type: string
* 404:
* description: Not found
* content:
* application/json:
* schema:
* type: object
* properties:
* error:
* type: string
* message:
* type: string
* 500:
* description: Internal Server Error
* content:
* application/json:
* schema:
* type: object
* properties:
* error:
* type: string
* message:
* type: string
*/

export const loader: LoaderFunction = async ({
request,
params,
}: LoaderFunctionArgs): Promise<Response> => {
try {

const collected = collectParameters(request, params);
if (collected instanceof Response)
return collected;
const {deviceId, fromDate, toDate, format} = collected;

const locations = await getLocations({ id: deviceId}, fromDate, toDate);
if (!locations)
return notFound("Device not found");

const jsonLocations = locations.map((location) => {
return {
coordinates: [location.x, location.y],
type: 'Point',
timestamp: location.time,
}
});

let headers: HeadersInit = {
"content-type": format == "json" ? "application/json; charset=utf-8" : "application/geo+json; charset=utf-8",
};

const responseInit: ResponseInit = {
status: 200,
headers: headers,
};

if (format == "json")
return Response.json(jsonLocations, responseInit);
else {
const geoJsonLocations = {
type: 'Feature',
geometry: {
type: 'LineString', coordinates: jsonLocations.map(location => location.coordinates)
},
properties: {
timestamps: jsonLocations.map(location => location.timestamp)
}
};
return Response.json(geoJsonLocations, responseInit)
}

} catch (err) {
console.warn(err);
return internalServerError();
}
};

function collectParameters(request: Request, params: Params<string>):
Response | {
deviceId: string,
fromDate: Date,
toDate: Date,
format: string | null
} {
const deviceId = params.deviceId;
if (deviceId === undefined)
return badRequest("Invalid device id specified");

const url = new URL(request.url);

const fromDate = parseDateParam(url, "from-date", new Date(new Date().setDate(new Date().getDate() - 2)))
if (fromDate instanceof Response)
return fromDate

const toDate = parseDateParam(url, "to-date", new Date())
if (toDate instanceof Response)
return toDate

const format = parseEnumParam(url, "format", ["json", "geojson"], "json");
if (format instanceof Response)
return format

return {
deviceId,
fromDate,
toDate,
format
};
}
2 changes: 1 addition & 1 deletion app/routes/api.device.$deviceId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ import { getDevice } from "~/models/device.server";
* error:
* type: string
* example: "Device not found"
* 400:
* 400:
* description: Device ID is required
* content:
* application/json:
Expand Down
Loading
Loading