Skip to content

Commit d201c47

Browse files
timber-theyTimber
andauthored
Feat/migrate boxes sense box id locations (#650)
* feat: create response utils * feat: add response types to api.boxes.deviceId.data.sensorId * fix: replace tab with space * feat: implement location endpoint * feat: only get required locations from database * feat: unit tests * fix: lint * feat: delete unused method * feat: delete outdated comment * fix: remove duplicated whitespace * fix: delete measurements for all test times * fix: remove eslint ignore comment * fix: lint * feat: remove TODO --------- Co-authored-by: Timber <[email protected]>
1 parent ba12d22 commit d201c47

File tree

7 files changed

+498
-66
lines changed

7 files changed

+498
-66
lines changed

app/models/device.server.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { point } from '@turf/helpers'
2-
import { eq, sql, desc, ilike, arrayContains, and } from 'drizzle-orm'
2+
import { eq, sql, desc, ilike, arrayContains, and, between } from 'drizzle-orm'
33
import { type Point } from 'geojson'
44
import { drizzleClient } from '~/db.server'
5-
import { device, location, sensor, type Device, type Sensor } from '~/schema'
5+
import { device, deviceToLocation, location, sensor, type Device, type Sensor } from '~/schema'
66

77
const BASE_DEVICE_COLUMNS = {
88
id: true,
@@ -77,6 +77,23 @@ export function getDevice({ id }: Pick<Device, 'id'>) {
7777
})
7878
}
7979

80+
export function getLocations({ id }: Pick<Device, 'id'>, fromDate: Date, toDate: Date) {
81+
return drizzleClient
82+
.select({
83+
time: deviceToLocation.time,
84+
x: sql<number>`ST_X(${location.location})`.as('x'),
85+
y: sql<number>`ST_Y(${location.location})`.as('y'),
86+
})
87+
.from(location)
88+
.innerJoin(deviceToLocation, eq(deviceToLocation.locationId, location.id))
89+
.where(
90+
and(
91+
eq(deviceToLocation.deviceId, id),
92+
between(deviceToLocation.time, fromDate, toDate)
93+
)
94+
)
95+
.orderBy(desc(deviceToLocation.time));
96+
}
8097
export function getDeviceWithoutSensors({ id }: Pick<Device, 'id'>) {
8198
return drizzleClient.query.device.findFirst({
8299
where: (device, { eq }) => eq(device.id, id),

app/routes/api.boxes.$deviceId.data.$sensorId.ts

Lines changed: 52 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,7 @@ import { getMeasurements } from "~/models/sensor.server";
44
import { type Measurement } from "~/schema";
55
import { convertToCsv } from "~/utils/csv";
66
import { parseDateParam, parseEnumParam } from "~/utils/param-utils";
7-
8-
const badRequestInit = {
9-
status: 400,
10-
headers: {
11-
"Content-Type": "application/json; charset=utf-8",
12-
},
13-
};
7+
import { badRequest, internalServerError, notFound } from "~/utils/response-utils";
148

159
/**
1610
* @openapi
@@ -97,6 +91,51 @@ const badRequestInit = {
9791
* 200:
9892
* description: Success
9993
* content:
94+
* application/json:
95+
* schema:
96+
* type: array
97+
* 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"}]'
98+
* text/csv:
99+
* example: "createdAt,value
100+
* 2023-09-29T08:06:13.254Z,6.38
101+
* 2023-09-29T08:06:12.312Z,6.38
102+
* 2023-09-29T08:06:11.513Z,6.38
103+
* 2023-09-29T08:06:10.380Z,6.38
104+
* 2023-09-29T08:06:09.569Z,6.38
105+
* 2023-09-29T08:06:05.967Z,6.38"
106+
* 400:
107+
* description: Bad Request
108+
* content:
109+
* application/json:
110+
* schema:
111+
* type: object
112+
* properties:
113+
* error:
114+
* type: string
115+
* message:
116+
* type: string
117+
* 404:
118+
* description: Not found
119+
* content:
120+
* application/json:
121+
* schema:
122+
* type: object
123+
* properties:
124+
* error:
125+
* type: string
126+
* message:
127+
* type: string
128+
* 500:
129+
* description: Internal Server Error
130+
* content:
131+
* application/json:
132+
* schema:
133+
* type: object
134+
* properties:
135+
* error:
136+
* type: string
137+
* message:
138+
* type: string
100139
*/
101140

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

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

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

132-
let responseInit: ResponseInit = {
166+
const responseInit: ResponseInit = {
133167
status: 200,
134168
headers: headers,
135169
};
@@ -143,19 +177,7 @@ export const loader: LoaderFunction = async ({
143177

144178
} catch (err) {
145179
console.warn(err);
146-
return Response.json(
147-
{
148-
error: "Internal Server Error",
149-
message:
150-
"The server was unable to complete your request. Please try again later.",
151-
},
152-
{
153-
status: 500,
154-
headers: {
155-
"Content-Type": "application/json; charset=utf-8",
156-
},
157-
},
158-
);
180+
return internalServerError();
159181
}
160182
};
161183

@@ -174,20 +196,10 @@ function collectParameters(request: Request, params: Params<string>):
174196
// deviceId is there for legacy reasons
175197
const deviceId = params.deviceId;
176198
if (deviceId === undefined)
177-
return Response.json(
178-
{
179-
code: "Bad Request",
180-
message: "Invalid device id specified",
181-
}, badRequestInit
182-
);
199+
return badRequest("Invalid device id specified");
183200
const sensorId = params.sensorId;
184201
if (sensorId === undefined)
185-
return Response.json(
186-
{
187-
code: "Bad Request",
188-
message: "Invalid sensor id specified",
189-
}, badRequestInit
190-
);
202+
return badRequest("Invalid sensor id specified");
191203

192204
const url = new URL(request.url);
193205

@@ -199,12 +211,7 @@ function collectParameters(request: Request, params: Params<string>):
199211
let outlierWindow: number = 15;
200212
if (outlierWindowParam !== null) {
201213
if (Number.isNaN(outlierWindowParam) || Number(outlierWindowParam) < 1 || Number(outlierWindowParam) > 50)
202-
return Response.json(
203-
{
204-
error: "Bad Request",
205-
message: "Illegal value for parameter outlier-window. Allowed values: numbers between 1 and 50",
206-
}, badRequestInit
207-
);
214+
return badRequest("Illegal value for parameter outlier-window. Allowed values: numbers between 1 and 50");
208215
outlierWindow = Number(outlierWindowParam);
209216
}
210217

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { type Params, type LoaderFunction, type LoaderFunctionArgs } from "react-router";
2+
import { getLocations } from "~/models/device.server";
3+
import { parseDateParam, parseEnumParam } from "~/utils/param-utils";
4+
import { badRequest, internalServerError, notFound } from "~/utils/response-utils";
5+
6+
/**
7+
* @openapi
8+
* /boxes/{deviceId}/locations:
9+
* get:
10+
* tags:
11+
* - Boxes
12+
* summary: Get locations of a senseBox
13+
* description: Get all locations of the specified senseBox ordered by date as an array of GeoJSON Points.
14+
* If `format=geojson`, a GeoJSON linestring will be returned, with `properties.timestamps`
15+
* being an array with the timestamp for each coordinate.
16+
* parameters:
17+
* - in: path
18+
* name: deviceId
19+
* required: true
20+
* schema:
21+
* type: string
22+
* description: the ID of the senseBox you are referring to
23+
* - in: query
24+
* name: from-date
25+
* required: false
26+
* schema:
27+
* type: string
28+
* description: RFC3339Date
29+
* format: date-time
30+
* description: "Beginning date of measurement data (default: 48 hours ago from now)"
31+
* - in: query
32+
* name: to-date
33+
* required: false
34+
* schema:
35+
* type: string
36+
* descrption: TFC3339Date
37+
* format: date-time
38+
* description: "End date of measurement data (default: now)"
39+
* - in: query
40+
* name: format
41+
* required: false
42+
* schema:
43+
* type: string
44+
* enum:
45+
* - json
46+
* - geojson
47+
* default: json
48+
* description: "Can be 'json' (default) or 'geojson' (default: json)"
49+
* responses:
50+
* 200:
51+
* description: Success
52+
* content:
53+
* application/json:
54+
* schema:
55+
* type: array
56+
* 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"}]'
57+
* application/geojson:
58+
* example: ''
59+
* 400:
60+
* description: Bad Request
61+
* content:
62+
* application/json:
63+
* schema:
64+
* type: object
65+
* properties:
66+
* error:
67+
* type: string
68+
* message:
69+
* type: string
70+
* 404:
71+
* description: Not found
72+
* content:
73+
* application/json:
74+
* schema:
75+
* type: object
76+
* properties:
77+
* error:
78+
* type: string
79+
* message:
80+
* type: string
81+
* 500:
82+
* description: Internal Server Error
83+
* content:
84+
* application/json:
85+
* schema:
86+
* type: object
87+
* properties:
88+
* error:
89+
* type: string
90+
* message:
91+
* type: string
92+
*/
93+
94+
export const loader: LoaderFunction = async ({
95+
request,
96+
params,
97+
}: LoaderFunctionArgs): Promise<Response> => {
98+
try {
99+
100+
const collected = collectParameters(request, params);
101+
if (collected instanceof Response)
102+
return collected;
103+
const {deviceId, fromDate, toDate, format} = collected;
104+
105+
const locations = await getLocations({ id: deviceId}, fromDate, toDate);
106+
if (!locations)
107+
return notFound("Device not found");
108+
109+
const jsonLocations = locations.map((location) => {
110+
return {
111+
coordinates: [location.x, location.y],
112+
type: 'Point',
113+
timestamp: location.time,
114+
}
115+
});
116+
117+
let headers: HeadersInit = {
118+
"content-type": format == "json" ? "application/json; charset=utf-8" : "application/geo+json; charset=utf-8",
119+
};
120+
121+
const responseInit: ResponseInit = {
122+
status: 200,
123+
headers: headers,
124+
};
125+
126+
if (format == "json")
127+
return Response.json(jsonLocations, responseInit);
128+
else {
129+
const geoJsonLocations = {
130+
type: 'Feature',
131+
geometry: {
132+
type: 'LineString', coordinates: jsonLocations.map(location => location.coordinates)
133+
},
134+
properties: {
135+
timestamps: jsonLocations.map(location => location.timestamp)
136+
}
137+
};
138+
return Response.json(geoJsonLocations, responseInit)
139+
}
140+
141+
} catch (err) {
142+
console.warn(err);
143+
return internalServerError();
144+
}
145+
};
146+
147+
function collectParameters(request: Request, params: Params<string>):
148+
Response | {
149+
deviceId: string,
150+
fromDate: Date,
151+
toDate: Date,
152+
format: string | null
153+
} {
154+
const deviceId = params.deviceId;
155+
if (deviceId === undefined)
156+
return badRequest("Invalid device id specified");
157+
158+
const url = new URL(request.url);
159+
160+
const fromDate = parseDateParam(url, "from-date", new Date(new Date().setDate(new Date().getDate() - 2)))
161+
if (fromDate instanceof Response)
162+
return fromDate
163+
164+
const toDate = parseDateParam(url, "to-date", new Date())
165+
if (toDate instanceof Response)
166+
return toDate
167+
168+
const format = parseEnumParam(url, "format", ["json", "geojson"], "json");
169+
if (format instanceof Response)
170+
return format
171+
172+
return {
173+
deviceId,
174+
fromDate,
175+
toDate,
176+
format
177+
};
178+
}

app/routes/api.device.$deviceId.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ import { getDevice } from "~/models/device.server";
4848
* error:
4949
* type: string
5050
* example: "Device not found"
51-
* 400:
51+
* 400:
5252
* description: Device ID is required
5353
* content:
5454
* application/json:

0 commit comments

Comments
 (0)