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
2 changes: 1 addition & 1 deletion app/components/device-detail/device-detail-box.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export default function DeviceDetailBox() {

const [sensors, setSensors] = useState<SensorWithLatestMeasurement[]>();
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);
Expand Down
119 changes: 119 additions & 0 deletions app/lib/devices-service.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { Device, User } from '~/schema'

Check warning on line 1 in app/lib/devices-service.server.ts

View workflow job for this annotation

GitHub Actions / ⬣ Lint

All imports in the declaration are only used as types. Use `import type`
import {

Check warning on line 2 in app/lib/devices-service.server.ts

View workflow job for this annotation

GitHub Actions / ⬣ Lint

`~/models/device.server` import should occur before import of `~/schema`
deleteDevice as deleteDeviceById,
} from '~/models/device.server'
import { verifyLogin } from '~/models/user.server'

Check warning on line 5 in app/lib/devices-service.server.ts

View workflow job for this annotation

GitHub Actions / ⬣ Lint

`~/models/user.server` import should occur before import of `~/schema`
import { z } from 'zod'

Check warning on line 6 in app/lib/devices-service.server.ts

View workflow job for this annotation

GitHub Actions / ⬣ Lint

`zod` import should occur before import of `~/schema`

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<typeof BoxesQuerySchema>;

/**
* 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<boolean | 'unauthorized'> => {
const verifiedUser = await verifyLogin(user.email, password)
if (verifiedUser === null) return 'unauthorized'
return (await deleteDeviceById({ id: device.id })).count > 0
}
191 changes: 180 additions & 11 deletions app/models/device.server.ts
Original file line number Diff line number Diff line change
@@ -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'

Check warning on line 2 in app/models/device.server.ts

View workflow job for this annotation

GitHub Actions / ⬣ Lint

'inArray' is defined but never used. Allowed unused vars must match /^ignored/u
import { type Point } from 'geojson'
import { drizzleClient } from '~/db.server'
import { device, location, sensor, type Device, type Sensor } from '~/schema'
Expand Down Expand Up @@ -122,7 +122,13 @@
})
}

export async function getDevices() {
type DevicesFormat = 'json' | 'geojson'

export async function getDevices(format: 'json'): Promise<Device[]>
export async function getDevices(format: 'geojson'): Promise<GeoJSON.FeatureCollection<Point>>
export async function getDevices(format?: DevicesFormat): Promise<Device[] | GeoJSON.FeatureCollection<Point>>

export async function getDevices(format: DevicesFormat = 'json') {
const devices = await drizzleClient.query.device.findMany({
columns: {
id: true,
Expand All @@ -135,18 +141,23 @@
tags: true,
},
})
const geojson: GeoJSON.FeatureCollection<Point> = {
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<Point> = {
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() {
Expand Down Expand Up @@ -206,6 +217,164 @@
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<string, any>;
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<string, any> = {},
relations: Record<string, any> = {}
) {
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) => {
Expand Down
12 changes: 6 additions & 6 deletions app/models/sensor.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,16 @@ export function getSensorsFromDevice(deviceId: Sensor["deviceId"]) {

export async function getSensorsWithLastMeasurement(
deviceId: Sensor["deviceId"],
): Promise<SensorWithLatestMeasurement[]>;
export async function getSensorsWithLastMeasurement(
deviceId: Sensor["deviceId"],
sensorId: Sensor["id"],
): Promise<SensorWithLatestMeasurement>;
sensorId?: Sensor["id"],
count?: number,
): Promise<SensorWithLatestMeasurement | SensorWithLatestMeasurement[]>;


export async function getSensorsWithLastMeasurement(
deviceId: Sensor["deviceId"],
sensorId: Sensor["id"] | undefined = undefined,
count: number = 1,
) {
): Promise<SensorWithLatestMeasurement | SensorWithLatestMeasurement[]> {
const result = await drizzleClient.execute(
sql`SELECT
s.id,
Expand Down
Loading
Loading