From faad9e97363ba08806a30d1b990166d9b2746831 Mon Sep 17 00:00:00 2001 From: kenneth Date: Sun, 25 Jan 2026 19:38:52 +0000 Subject: [PATCH] feat(backend): add LocationService - Add LocationService to manage LocationSource per user - Add UserNotFoundError for generic user-related errors - updateUserLocation throws if source not initialized Co-authored-by: Ona --- apps/aris-backend/src/lib/error.ts | 8 ++ .../aris-backend/src/location/service.test.ts | 111 ++++++++++++++++++ apps/aris-backend/src/location/service.ts | 55 +++++++++ 3 files changed, 174 insertions(+) create mode 100644 apps/aris-backend/src/lib/error.ts create mode 100644 apps/aris-backend/src/location/service.test.ts create mode 100644 apps/aris-backend/src/location/service.ts diff --git a/apps/aris-backend/src/lib/error.ts b/apps/aris-backend/src/lib/error.ts new file mode 100644 index 0000000..00fdd57 --- /dev/null +++ b/apps/aris-backend/src/lib/error.ts @@ -0,0 +1,8 @@ +export class UserNotFoundError extends Error { + constructor( + public readonly userId: string, + message?: string, + ) { + super(message ? `${message}: user not found: ${userId}` : `User not found: ${userId}`) + } +} diff --git a/apps/aris-backend/src/location/service.test.ts b/apps/aris-backend/src/location/service.test.ts new file mode 100644 index 0000000..a25d46a --- /dev/null +++ b/apps/aris-backend/src/location/service.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "bun:test" + +import { UserNotFoundError } from "../lib/error.ts" +import { LocationService } from "./service.ts" + +describe("LocationService", () => { + test("feedSourceForUser creates source on first call", () => { + const service = new LocationService() + const source = service.feedSourceForUser("user-1") + + expect(source).toBeDefined() + expect(source.id).toBe("location") + }) + + test("feedSourceForUser returns same source for same user", () => { + const service = new LocationService() + const source1 = service.feedSourceForUser("user-1") + const source2 = service.feedSourceForUser("user-1") + + expect(source1).toBe(source2) + }) + + test("feedSourceForUser returns different sources for different users", () => { + const service = new LocationService() + const source1 = service.feedSourceForUser("user-1") + const source2 = service.feedSourceForUser("user-2") + + expect(source1).not.toBe(source2) + }) + + test("updateUserLocation updates the source", () => { + const service = new LocationService() + const source = service.feedSourceForUser("user-1") + const location = { + lat: 51.5074, + lng: -0.1278, + accuracy: 10, + timestamp: new Date(), + } + + service.updateUserLocation("user-1", location) + + expect(source.lastLocation).toEqual(location) + }) + + test("updateUserLocation throws if source does not exist", () => { + const service = new LocationService() + const location = { + lat: 51.5074, + lng: -0.1278, + accuracy: 10, + timestamp: new Date(), + } + + expect(() => service.updateUserLocation("user-1", location)).toThrow(UserNotFoundError) + }) + + test("lastUserLocation returns null for unknown user", () => { + const service = new LocationService() + + expect(service.lastUserLocation("unknown")).toBeNull() + }) + + test("lastUserLocation returns last location", () => { + const service = new LocationService() + service.feedSourceForUser("user-1") + const location1 = { + lat: 51.5074, + lng: -0.1278, + accuracy: 10, + timestamp: new Date(), + } + const location2 = { + lat: 52.0, + lng: -0.2, + accuracy: 5, + timestamp: new Date(), + } + + service.updateUserLocation("user-1", location1) + service.updateUserLocation("user-1", location2) + + expect(service.lastUserLocation("user-1")).toEqual(location2) + }) + + test("removeUser removes the source", () => { + const service = new LocationService() + service.feedSourceForUser("user-1") + const location = { + lat: 51.5074, + lng: -0.1278, + accuracy: 10, + timestamp: new Date(), + } + + service.updateUserLocation("user-1", location) + service.removeUser("user-1") + + expect(service.lastUserLocation("user-1")).toBeNull() + }) + + test("removeUser allows new source to be created", () => { + const service = new LocationService() + const source1 = service.feedSourceForUser("user-1") + + service.removeUser("user-1") + const source2 = service.feedSourceForUser("user-1") + + expect(source1).not.toBe(source2) + }) +}) diff --git a/apps/aris-backend/src/location/service.ts b/apps/aris-backend/src/location/service.ts new file mode 100644 index 0000000..f82672b --- /dev/null +++ b/apps/aris-backend/src/location/service.ts @@ -0,0 +1,55 @@ +import { LocationSource, type Location } from "@aris/source-location" + +import { UserNotFoundError } from "../lib/error.ts" + +/** + * Manages LocationSource instances per user. + */ +export class LocationService { + private sources = new Map() + + /** + * Get or create a LocationSource for a user. + * @param userId - The user's unique identifier + * @returns The user's LocationSource instance + */ + feedSourceForUser(userId: string): LocationSource { + let source = this.sources.get(userId) + if (!source) { + source = new LocationSource() + this.sources.set(userId, source) + } + return source + } + + /** + * Update location for a user. + * @param userId - The user's unique identifier + * @param location - The new location data + * @throws {UserNotFoundError} If no source exists for the user + */ + updateUserLocation(userId: string, location: Location): void { + const source = this.sources.get(userId) + if (!source) { + throw new UserNotFoundError(userId) + } + source.pushLocation(location) + } + + /** + * Get last known location for a user. + * @param userId - The user's unique identifier + * @returns The last location, or null if none exists + */ + lastUserLocation(userId: string): Location | null { + return this.sources.get(userId)?.lastLocation ?? null + } + + /** + * Remove a user's LocationSource. + * @param userId - The user's unique identifier + */ + removeUser(userId: string): void { + this.sources.delete(userId) + } +}