From 53a9a2afeb414aafef73fb9940f9eebacd143444 Mon Sep 17 00:00:00 2001
From: Fabio Manganiello <fabio@manganiello.tech>
Date: Sun, 23 Mar 2025 20:23:26 +0100
Subject: [PATCH] Support description for location points.

---
 .env.example                          |   4 +
 frontend/src/components/Map.vue       |  35 ++++++--
 frontend/src/components/PointInfo.vue | 117 +++++++++++++++++++++++++-
 frontend/src/mixins/api/GPSData.vue   |  21 +++--
 frontend/src/models/GPSPoint.ts       |  48 ++++++++---
 src/db/Db.ts                          |   3 +-
 src/db/migrations/000_initial.ts      |   4 +
 src/db/types/GPSData.ts               |   8 ++
 src/models/GPSPoint.ts                |   2 +
 src/repos/Location.ts                 |  39 +++++++++
 src/requests/LocationRequest.ts       |   6 ++
 src/routes/api/v1/GPSData.ts          |  16 +++-
 12 files changed, 275 insertions(+), 28 deletions(-)

diff --git a/.env.example b/.env.example
index e864cae..9ff4586 100644
--- a/.env.example
+++ b/.env.example
@@ -84,6 +84,10 @@ ADMIN_EMAIL=admin@example.com
 # Comment or leave empty if the postal code is not available.
 # DB_LOCATION__POSTAL_CODE=postalCode
 
+# The name of the column that contains the description of each location point
+# Comment or leave empty if the description is not available.
+# DB_LOCATION__DESCRIPTION=description
+
 ###
 ### Frontend configuration.
 ### This is only required if you want to run the frontend in development mode
diff --git a/frontend/src/components/Map.vue b/frontend/src/components/Map.vue
index 0165224..ba4a9b2 100644
--- a/frontend/src/components/Map.vue
+++ b/frontend/src/components/Map.vue
@@ -30,7 +30,8 @@
         <PointInfo :point="selectedPoint"
                    ref="popup"
                    @remove="onRemove"
-                   @close="selectedPoint = null" />
+                   @edit="editPoint"
+                   @close="clearSelectedPoint" />
 
         <div class="controls">
           <div class="form-container" v-if="showControls">
@@ -135,6 +136,7 @@ export default {
       routesLayer: null as Optional<VectorLayer>,
       selectedFeature: null as Optional<Feature>,
       selectedPoint: null as Optional<GPSPoint>,
+      selectedPointIndex: null as Optional<number>,
       showControls: false,
       showMetrics: new TimelineMetricsConfiguration(),
     }
@@ -206,11 +208,20 @@ export default {
       } finally {
         this.loading = false
         this.pointToRemove = null
-        this.selectedPoint = null
-        this.selectedFeature = null
+        this.clearSelectedPoint()
       }
     },
 
+    async editPoint(value: GPSPoint) {
+      const index = this.selectedPointIndex
+      if (index === null) {
+        return
+      }
+
+      await this.updatePoints([value])
+      this.gpsPoints[index] = value
+    },
+
     onRemove(point: GPSPoint) {
       this.pointToRemove = point
     },
@@ -273,9 +284,14 @@ export default {
 
         if (feature) {
           this.selectedFeature = feature as Feature
-          const point = this.gpsPoints.find((gps: GPSPoint) => {
+          const point = this.gpsPoints.find((gps: GPSPoint, index: number) => {
             const [longitude, latitude] = (feature.getGeometry() as any).getCoordinates()
-            return gps.longitude === longitude && gps.latitude === latitude
+            if (gps.longitude === longitude && gps.latitude === latitude) {
+              this.selectedPointIndex = index
+              return true
+            }
+
+            return false
           })
 
           if (point) {
@@ -286,12 +302,17 @@ export default {
             map.getView().setCenter(event.coordinate)
           }
         } else {
-          this.selectedPoint = null
-          this.selectedFeature = null
+          this.clearSelectedPoint()
         }
       })
     },
 
+    clearSelectedPoint() {
+      this.selectedPoint = null
+      this.selectedPointIndex = null
+      this.selectedFeature = null
+    },
+
     refreshShowMetricsFromURL() {
       this.showMetrics = new TimelineMetricsConfiguration(this.parseQuery(window.location.href))
     },
diff --git a/frontend/src/components/PointInfo.vue b/frontend/src/components/PointInfo.vue
index 6d426b0..594cfae 100644
--- a/frontend/src/components/PointInfo.vue
+++ b/frontend/src/components/PointInfo.vue
@@ -22,6 +22,34 @@
           <font-awesome-icon icon="fas fa-mountain" />
           {{ Math.round(point.altitude) }} m
         </p>
+
+        <form class="description editor" @submit.prevent="editPoint" v-if="editDescription">
+          <div class="row">
+            <textarea
+              :value="point.description"
+              @keydown.enter="editPoint"
+              @blur="onDescriptionBlur"
+              ref="description"
+              placeholder="Enter a description" />
+
+            <button type="submit" title="Save">
+              <font-awesome-icon icon="fas fa-save" />
+            </button>
+          </div>
+        </form>
+
+        <p class="description"
+           :class="{ 'no-content': !point.description?.length }"
+           @click="editDescription = true"
+           v-else>
+          <span class="icon">
+            <font-awesome-icon icon="fas fa-edit" />
+          </span>
+          <span class="text">
+            {{ point.description?.length ? point.description : 'No description' }}
+          </span>
+        </p>
+
         <p class="locality" v-if="point.locality">{{ point.locality }}</p>
         <p class="postal-code" v-if="point.postalCode">{{ point.postalCode }}</p>
         <p class="country" v-if="country">
@@ -51,7 +79,7 @@ import Dates from '../mixins/Dates.vue';
 import GPSPoint from '../models/GPSPoint';
 
 export default {
-  emit: ['close', 'remove'],
+  emit: ['close', 'edit', 'remove'],
   mixins: [Dates],
   props: {
     point: {
@@ -61,6 +89,8 @@ export default {
 
   data() {
     return {
+      newValue: null,
+      editDescription: false,
       popup: null as Overlay | null,
     }
   },
@@ -99,12 +129,44 @@ export default {
       map.addOverlay(this.popup)
     },
 
+    editPoint() {
+      this.newValue.description = (this.$refs.description as HTMLTextAreaElement).value
+      this.$emit('edit', this.newValue)
+      this.editDescription = false
+    },
+
+    onDescriptionBlur() {
+      // Give it a moment to allow relevant click events to trigger
+      setTimeout(() => {
+        this.editDescription = false
+      }, 100)
+    },
+
     setPosition(coordinates: number[]) {
       if (this.popup) {
         this.popup.setPosition(coordinates)
       }
     },
   },
+
+  watch: {
+    point: {
+      immediate: true,
+      handler(point: GPSPoint | null) {
+        if (point) {
+          this.newValue = point
+        }
+      },
+    },
+
+    editDescription(edit: boolean) {
+      if (edit) {
+        this.$nextTick(() => {
+          this.$refs.description?.focus()
+        })
+      }
+    },
+  },
 }
 </script>
 
@@ -128,7 +190,7 @@ export default {
   .header {
     position: absolute;
     top: 0.5em;
-    right: 0.5em;
+    right: 0;
 
     button {
       background: none;
@@ -158,6 +220,57 @@ export default {
     margin: -0.25em 0 0.25em 0;
   }
 
+  .description {
+    cursor: pointer;
+
+    &:hover {
+      .icon {
+        color: var(--color-accent);
+      }
+    }
+
+    .icon {
+      margin-right: 0.5em;
+    }
+
+    .text {
+      font-style: italic;
+    }
+
+    &:not(.no-content) {
+      .text {
+        font-weight: bold;
+      }
+
+      .icon {
+        color: var(--color-accent);
+      }
+    }
+
+    &.no-content {
+      font-size: 0.9em;
+      opacity: 0.5;
+    }
+
+    textarea {
+      min-height: 5em;
+    }
+
+    button {
+      background: var(--color-accent);
+      border: none;
+      color: var(--color-accent);
+      font-size: 0.9em;
+      margin: 0;
+      padding: 0.5em 1.5em;
+      cursor: pointer;
+
+      &:hover {
+        color: var(--color-heading);
+      }
+    }
+  }
+
   .timestamp {
     color: var(--color-heading);
     font-weight: bold;
diff --git a/frontend/src/mixins/api/GPSData.vue b/frontend/src/mixins/api/GPSData.vue
index 29081fd..0f5e705 100644
--- a/frontend/src/mixins/api/GPSData.vue
+++ b/frontend/src/mixins/api/GPSData.vue
@@ -12,13 +12,13 @@ export default {
       }) || []
 
       return points.map((gps: any) =>
-          new GPSPoint({
-            ...gps,
-            // Normalize timestamp to Date object
-            timestamp: new Date(gps.timestamp),
-          })
-        )
-        .sort((a: GPSPoint, b: GPSPoint) => a.timestamp.getTime() - b.timestamp.getTime())
+        new GPSPoint({
+          ...gps,
+          // Normalize timestamp to Date object
+          timestamp: new Date(gps.timestamp),
+        })
+      )
+      .sort((a: GPSPoint, b: GPSPoint) => a.timestamp.getTime() - b.timestamp.getTime())
     },
 
     async deletePoints(points: GPSPoint[]) {
@@ -27,6 +27,13 @@ export default {
         body: points.map((point: GPSPoint) => point.id),
       })
     },
+
+    async updatePoints(points: GPSPoint[]) {
+      await this.request('/gpsdata', {
+        method: 'PATCH',
+        body: points,
+      })
+    },
   },
 }
 </script>
diff --git a/frontend/src/models/GPSPoint.ts b/frontend/src/models/GPSPoint.ts
index 67e28db..14c68c3 100644
--- a/frontend/src/models/GPSPoint.ts
+++ b/frontend/src/models/GPSPoint.ts
@@ -3,22 +3,50 @@ class GPSPoint {
   public latitude: number;
   public longitude: number;
   public altitude: number;
+  public deviceId: string;
   public address: string;
   public locality: string;
   public country: string;
   public postalCode: string;
+  public description?: string;
   public timestamp: Date;
 
-  constructor(public data: any) {
-    this.id = data.id;
-    this.latitude = data.latitude;
-    this.longitude = data.longitude;
-    this.altitude = data.altitude;
-    this.address = data.address;
-    this.locality = data.locality;
-    this.country = data.country;
-    this.postalCode = data.postalCode;
-    this.timestamp = data.timestamp;
+  constructor({
+    id,
+    latitude,
+    longitude,
+    altitude,
+    deviceId,
+    address,
+    locality,
+    country,
+    postalCode,
+    description,
+    timestamp,
+  }: {
+    id: number;
+    latitude: number;
+    longitude: number;
+    altitude: number;
+    deviceId: string;
+    address: string;
+    locality: string;
+    country: string;
+    postalCode: string;
+    description?: string;
+    timestamp: Date;
+  }) {
+    this.id = id;
+    this.latitude = latitude;
+    this.longitude = longitude;
+    this.altitude = altitude;
+    this.deviceId = deviceId;
+    this.address = address;
+    this.locality = locality;
+    this.country = country;
+    this.postalCode = postalCode;
+    this.description = description;
+    this.timestamp = timestamp;
   }
 }
 
diff --git a/src/db/Db.ts b/src/db/Db.ts
index 934ec9c..4e0f9ad 100644
--- a/src/db/Db.ts
+++ b/src/db/Db.ts
@@ -94,7 +94,8 @@ class Db {
       'address',
       'locality',
       'country',
-      'postalCode'
+      'postalCode',
+      'description',
     ].reduce((acc: any, name: string) => {
       acc[name] = process.env[this.prefixedEnv(name)];
       if (!acc[name]?.length && (requiredColumns[name] || opts.locationUrl === opts.url)) {
diff --git a/src/db/migrations/000_initial.ts b/src/db/migrations/000_initial.ts
index 928cb46..bdde658 100644
--- a/src/db/migrations/000_initial.ts
+++ b/src/db/migrations/000_initial.ts
@@ -218,6 +218,10 @@ async function createLocationHistoryTable(query: { context: any }) {
       type: DataTypes.STRING,
       allowNull: true
     },
+    [$db.locationTableColumns['description']]: {
+      type: DataTypes.STRING,
+      allowNull: true
+    },
     [$db.locationTableColumns['timestamp']]: {
       type: DataTypes.DATE,
       defaultValue: DataTypes.NOW,
diff --git a/src/db/types/GPSData.ts b/src/db/types/GPSData.ts
index 0f4103e..72c249e 100644
--- a/src/db/types/GPSData.ts
+++ b/src/db/types/GPSData.ts
@@ -66,6 +66,14 @@ function GPSData(locationTableColumns: Record<string, string>): Record<string, a
     };
   }
 
+  const descriptionCol: string = locationTableColumns['description'];
+  if (descriptionCol?.length) {
+    typeDef[descriptionCol] = {
+      type: DataTypes.STRING,
+      allowNull: true
+    };
+  }
+
   typeDef[locationTableColumns['timestamp']] = {
     type: DataTypes.DATE,
     defaultValue: DataTypes.NOW
diff --git a/src/models/GPSPoint.ts b/src/models/GPSPoint.ts
index a4f5c58..a90ee4a 100644
--- a/src/models/GPSPoint.ts
+++ b/src/models/GPSPoint.ts
@@ -8,6 +8,7 @@ class GPSPoint {
   public locality: string | null;
   public country: string | null;
   public postalCode: string | null;
+  public description: string | null;
   public timestamp: Date;
 
   constructor(record: any) {
@@ -20,6 +21,7 @@ class GPSPoint {
     this.locality = record.locality;
     this.country = record.country;
     this.postalCode = record.postalCode;
+    this.description = record.description;
     this.timestamp = record.timestamp;
   }
 }
diff --git a/src/repos/Location.ts b/src/repos/Location.ts
index 2a0c2d2..cf99e54 100644
--- a/src/repos/Location.ts
+++ b/src/repos/Location.ts
@@ -26,6 +26,7 @@ class Location {
           locality: data[mappings.locality],
           country: data[mappings.country],
           postalCode: data[mappings.postalCode],
+          description: data[mappings.description],
           timestamp: data[mappings.timestamp],
         });
       });
@@ -62,6 +63,7 @@ class Location {
           locality: data[mappings.locality],
           country: data[mappings.country],
           postalCode: data[mappings.postalCode],
+          description: data[mappings.description],
           timestamp: data[mappings.timestamp],
         });
       });
@@ -94,6 +96,7 @@ class Location {
               [mappings.locality]: p.locality,
               [mappings.country]: p.country,
               [mappings.postalCode]: p.postalcode,
+              [mappings.description]: p.description,
               [mappings.timestamp]: p.timestamp
             }
           },
@@ -111,6 +114,7 @@ class Location {
           locality: data[mappings.locality],
           country: data[mappings.country],
           postalCode: data[mappings.postalCode],
+          description: data[mappings.description],
           timestamp: data[mappings.timestamp],
         });
       });
@@ -119,6 +123,41 @@ class Location {
     }
   }
 
+  public async updatePoints(points: GPSPoint[]): Promise<void> {
+    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 {
+      await $db.GPSData().bulkCreate(
+        normalizedPoints.map((p) => {
+          return {
+            [mappings.id]: p.id,
+            [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.description]: p.description,
+            [mappings.timestamp]: p.timestamp
+          }
+        }),
+        { updateOnDuplicate: Object.keys(mappings) }
+      );
+    } catch (error) {
+      throw new Error(`Error updating data: ${error}`);
+    }
+  }
+
   public async deletePoints(points: number[]): Promise<void> {
     try {
       await $db.GPSData().destroy({
diff --git a/src/requests/LocationRequest.ts b/src/requests/LocationRequest.ts
index d73a326..2383d9a 100644
--- a/src/requests/LocationRequest.ts
+++ b/src/requests/LocationRequest.ts
@@ -14,6 +14,7 @@ class LocationRequest {
   country: Optional<string> = null;
   locality: Optional<string> = null;
   postalCode: Optional<string> = null;
+  description: Optional<string> = null;
   orderBy: string = 'timestamp';
   order: string = 'DESC';
 
@@ -27,6 +28,7 @@ class LocationRequest {
     this.country = req.country;
     this.locality = req.locality;
     this.postalCode = req.postalCode;
+    this.description = req.description;
     this.orderBy = req.orderBy || this.orderBy;
     this.order = req.order || this.order;
   }
@@ -95,6 +97,10 @@ class LocationRequest {
       where[colMapping.postalCode || 'postalCode'] = this.postalCode;
     }
 
+    if (this.description != null) {
+      where[colMapping.description || 'description'] = {[Op.like]: `%${this.description}%`};
+    }
+
     queryMap.where = where;
     queryMap.order = [[colMapping[this.orderBy], this.order.toUpperCase()]];
     return queryMap;
diff --git a/src/routes/api/v1/GPSData.ts b/src/routes/api/v1/GPSData.ts
index 71e7354..5d88020 100644
--- a/src/routes/api/v1/GPSData.ts
+++ b/src/routes/api/v1/GPSData.ts
@@ -4,7 +4,7 @@ import { authenticate } from '../../../auth';
 import { AuthInfo } from '../../../auth';
 import { LocationRequest } from '../../../requests';
 import { Optional } from '../../../types';
-import { RoleName } from '../../../models';
+import { GPSPoint, RoleName } from '../../../models';
 import ApiV1Route from './Route';
 
 class GPSData extends ApiV1Route {
@@ -48,6 +48,20 @@ class GPSData extends ApiV1Route {
     res.status(201).send();
   }
 
+  @authenticate()
+  patch = async (req: Request, res: Response, auth: Optional<AuthInfo>) => {
+    const points = (req.body as GPSPoint[]).map((p) => {
+      const descr = p.description?.trim()
+      p.description = descr?.length ? descr : null;
+      return p;
+    });
+
+    const deviceIds = points.map((p: any) => p.deviceId).filter((d: any) => !!d);
+    this.validateOwnership(deviceIds, auth!);
+    await $repos.location.updatePoints(points);
+    res.status(204).send();
+  }
+
   @authenticate()
   delete = async (req: Request, res: Response, auth: Optional<AuthInfo>) => {
     const pointIds = req.body as number[];