From 0d7e199e378c6d454acf3480c46d3b1abeba17aa Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <fabio@manganiello.tech> Date: Mon, 10 Mar 2025 01:33:33 +0100 Subject: [PATCH] Added POST/DELETE /gpsdata endpoints. Also, added deviceId to location_history table. --- .env.example | 3 ++ src/db/Db.ts | 3 +- src/db/types/GPSData.ts | 7 +++ src/models/GPSPoint.ts | 42 ++++++++-------- src/repos/Location.ts | 98 ++++++++++++++++++++++++++++++++++++ src/repos/UserDevices.ts | 10 ++++ src/routes/api/v1/GPSData.ts | 42 +++++++++++++--- 7 files changed, 176 insertions(+), 29 deletions(-) diff --git a/.env.example b/.env.example index ce91c63..cae0061 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,9 @@ DB_LOCATION_TABLE=location_history # The name of the column that contains the primary key of each location point DB_LOCATION__ID=id +# The name of the column that contains the device ID of each location point +DB_LOCATION__DEVICE_ID=deviceId + # The name of the column that contains the timestamp of each location point DB_LOCATION__TIMESTAMP=timestamp diff --git a/src/db/Db.ts b/src/db/Db.ts index a683259..934ec9c 100644 --- a/src/db/Db.ts +++ b/src/db/Db.ts @@ -86,6 +86,7 @@ class Db { opts.locationTableColumns = [ 'id', + 'deviceId', 'timestamp', 'latitude', 'longitude', @@ -96,7 +97,7 @@ class Db { 'postalCode' ].reduce((acc: any, name: string) => { acc[name] = process.env[this.prefixedEnv(name)]; - if (!acc[name]?.length && requiredColumns[name]) { + if (!acc[name]?.length && (requiredColumns[name] || opts.locationUrl === opts.url)) { // Default to the name of the required field acc[name] = name; } diff --git a/src/db/types/GPSData.ts b/src/db/types/GPSData.ts index 60f7f8d..0f4103e 100644 --- a/src/db/types/GPSData.ts +++ b/src/db/types/GPSData.ts @@ -9,6 +9,13 @@ function GPSData(locationTableColumns: Record<string, string>): Record<string, a autoIncrement: true }; + const deviceIdCol: string = locationTableColumns['deviceId']; + if (deviceIdCol?.length) { + typeDef[deviceIdCol] = { + type: DataTypes.UUID, + }; + } + typeDef[locationTableColumns['latitude']] = { type: DataTypes.FLOAT, allowNull: false diff --git a/src/models/GPSPoint.ts b/src/models/GPSPoint.ts index 3533f39..a4f5c58 100644 --- a/src/models/GPSPoint.ts +++ b/src/models/GPSPoint.ts @@ -1,25 +1,27 @@ class GPSPoint { - public id: number; - public latitude: number; - public longitude: number; - public altitude: number | null; - public address: string | null; - public locality: string | null; - public country: string | null; - public postalCode: string | null; - public timestamp: Date; + public id: number; + public deviceId: string; + public latitude: number; + public longitude: number; + public altitude: number | null; + public address: string | null; + public locality: string | null; + public country: string | null; + public postalCode: string | null; + public timestamp: Date; - constructor(record: any) { - this.id = record.id; - this.latitude = record.latitude; - this.longitude = record.longitude; - this.altitude = record.altitude; - this.address = record.address; - this.locality = record.locality; - this.country = record.country; - this.postalCode = record.postalCode; - this.timestamp = record.timestamp; - } + constructor(record: any) { + this.id = record.id; + this.deviceId = record.deviceId; + this.latitude = record.latitude; + this.longitude = record.longitude; + this.altitude = record.altitude; + this.address = record.address; + this.locality = record.locality; + this.country = record.country; + this.postalCode = record.postalCode; + this.timestamp = record.timestamp; + } } export default GPSPoint; diff --git a/src/repos/Location.ts b/src/repos/Location.ts index 4f3a32a..2a0c2d2 100644 --- a/src/repos/Location.ts +++ b/src/repos/Location.ts @@ -18,6 +18,7 @@ class Location { return new GPSPoint({ id: data[mappings.id], + deviceId: data[mappings.deviceId], latitude: data[mappings.latitude], longitude: data[mappings.longitude], altitude: data[mappings.altitude], @@ -32,6 +33,103 @@ class Location { throw new Error(`Error parsing data: ${error}`); } } + + public async getByIds(ids: number[]): Promise<GPSPoint[]> { + let apiResponse: any[] = []; + + try { + apiResponse = await $db.GPSData().findAll({ + where: { + id: ids + } + }); + } catch (error) { + throw new Error(`Error fetching data: ${error}`); + } + + try { + return apiResponse.map((p) => { + const data = p.dataValues; + const mappings: any = $db.locationTableColumns; + + return new GPSPoint({ + id: data[mappings.id], + deviceId: data[mappings.deviceId], + latitude: data[mappings.latitude], + longitude: data[mappings.longitude], + altitude: data[mappings.altitude], + address: data[mappings.address], + locality: data[mappings.locality], + country: data[mappings.country], + postalCode: data[mappings.postalCode], + timestamp: data[mappings.timestamp], + }); + }); + } catch (error) { + throw new Error(`Error parsing data: ${error}`); + } + } + + public async createPoints(points: GPSPoint[]): Promise<GPSPoint[]> { + const mappings: any = $db.locationTableColumns; + // Lowercase the keys of the mappings object - + // some databases are case-insensitive and this will help with consistency + const normalizedPoints = points.map((p) => + Object.entries(p).reduce((acc, [key, value]) => { + acc[key.toLowerCase()] = value; + return acc; + } , {} as Record<string, any>) + ); + + try { + return ( + await $db.GPSData().bulkCreate( + normalizedPoints.map((p) => { + return { + [mappings.deviceId]: p.deviceid, + [mappings.latitude]: p.latitude, + [mappings.longitude]: p.longitude, + [mappings.altitude]: p.altitude, + [mappings.address]: p.address, + [mappings.locality]: p.locality, + [mappings.country]: p.country, + [mappings.postalCode]: p.postalcode, + [mappings.timestamp]: p.timestamp + } + }, + { returning: true } + )) + ).map((p) => { + const data = p.dataValues; + return new GPSPoint({ + id: data[mappings.id], + deviceId: data[mappings.deviceId], + latitude: data[mappings.latitude], + longitude: data[mappings.longitude], + altitude: data[mappings.altitude], + address: data[mappings.address], + locality: data[mappings.locality], + country: data[mappings.country], + postalCode: data[mappings.postalCode], + timestamp: data[mappings.timestamp], + }); + }); + } catch (error) { + throw new Error(`Error saving data: ${error}`); + } + } + + public async deletePoints(points: number[]): Promise<void> { + try { + await $db.GPSData().destroy({ + where: { + id: points + } + }); + } catch (error) { + throw new Error(`Error deleting data: ${error}`); + } + } } export default Location; diff --git a/src/repos/UserDevices.ts b/src/repos/UserDevices.ts index 3497950..5a22044 100644 --- a/src/repos/UserDevices.ts +++ b/src/repos/UserDevices.ts @@ -17,6 +17,16 @@ class UserDevices { return new UserDevice(dbDevice.dataValues); } + public async getAll(deviceIds: string[]): Promise<UserDevice[]> { + const dbDevices = await $db.UserDevice().findAll({ + where: { + name: deviceIds, + } + }); + + return dbDevices.map((d) => new UserDevice(d.dataValues)); + } + public async create(name: string, args: { userId: number, }): Promise<UserDevice> { diff --git a/src/routes/api/v1/GPSData.ts b/src/routes/api/v1/GPSData.ts index 6a5814c..71e7354 100644 --- a/src/routes/api/v1/GPSData.ts +++ b/src/routes/api/v1/GPSData.ts @@ -1,7 +1,10 @@ import { Request, Response } from 'express'; import { authenticate } from '../../../auth'; +import { AuthInfo } from '../../../auth'; import { LocationRequest } from '../../../requests'; +import { Optional } from '../../../types'; +import { RoleName } from '../../../models'; import ApiV1Route from './Route'; class GPSData extends ApiV1Route { @@ -9,11 +12,22 @@ class GPSData extends ApiV1Route { super('/gpsdata'); } + private validateOwnership = async (deviceIds: string[], auth: AuthInfo) => { + const user = auth.user; + const notOwnedDevices = (await $repos.userDevices.getAll(deviceIds)) + .filter((d) => d.userId !== user.id); + + if (notOwnedDevices.length > 0) { + authenticate([RoleName.Admin]); + } + }; + @authenticate() get = async (req: Request, res: Response) => { let query: LocationRequest try { + // TODO Limit to the points that the user has access to query = new LocationRequest(req.query); } catch (error) { const e = `Error parsing query: ${error}`; @@ -22,14 +36,26 @@ class GPSData extends ApiV1Route { return; } - try { - const gpsData = await $repos.location.getHistory(query); - res.json(gpsData); - } catch (error) { - const e = `Error fetching data: ${error}`; - console.error(e); - res.status(500).send(e); - } + const gpsData = await $repos.location.getHistory(query); + res.json(gpsData); + } + + @authenticate() + post = async (req: Request, res: Response, auth: Optional<AuthInfo>) => { + const deviceIds = req.body.map((p: any) => p.deviceId).filter((d: any) => !!d); + this.validateOwnership(deviceIds, auth!); + await $repos.location.createPoints(req.body); + res.status(201).send(); + } + + @authenticate() + delete = async (req: Request, res: Response, auth: Optional<AuthInfo>) => { + const pointIds = req.body as number[]; + const points = await $repos.location.getByIds(pointIds); + const deviceIds = points.map((p) => p.deviceId); + this.validateOwnership(deviceIds, auth!); + await $repos.location.deletePoints(pointIds); + res.status(204).send(); } }