From ace370b8881a88edfa4b45444524f686f5ac51e9 Mon Sep 17 00:00:00 2001 From: Jos Nienhuis <6952249+joszz@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:59:30 +0200 Subject: [PATCH] feat(settings): keep settings with the account so they follow it across devices Theme, language, units, the dashboard layout and the map's layers and filters were stored per browser, so every phone and computer had to be set up on its own. They now belong to the signed-in account. - Server: a UserSettings table (migration AddUserSettings) holds one JSON object per account per section (ui, dashboard, map), which the server does not interpret beyond the section list, key names and a 16 KB cap. GET /api/settings and PATCH /api/settings/{section}; a save merges key by key with a version column and a randomised retry, so devices saving at the same moment keep each other's keys, and answers 409 settings.conflict when it keeps losing. Accounts are keyed by a SHA-256 of the sign-in method and subject, so no name or email is stored and a password account never shares settings with a provider account of the same name. - Browser: persistSettings hands each store to the new settingsSync store. The router guard reads the account's copy alongside the session check and mounts no protected view before it is taken in. Each local write sends only the keys that changed; the page reads again when it comes back into view (at most every 30 s) or back online. An account with no copy yet is filled from the first browser that signs in. Each browser keeps its own copy, so pages open instantly and the login page keeps the theme and language. - The notification checklist stays per device: it decides whether that browser is subscribed to push, so unticking everything on one computer must not unsubscribe a phone. - Applying the vehicle type's card defaults moved from a dashboard watcher into VehicleTypeSetting, so an override arriving from another device cannot reapply defaults over its cards. Checked on PostgreSQL 18: eight devices saving one section at the same moment all succeed, both before the account has a row and after. Two-browser check in demo mode: theme and units follow, the notification checklist does not. The entry chunk grows by 1 KB gzip. Co-Authored-By: Claude Opus 5.5 --- .env.example | 6 +- .github/SECURITY.md | 1 + README.md | 11 +- documentation/ARCHITECTURE.md | 13 +- documentation/AUTHENTICATION.md | 6 + .../settings/VehicleTypeSetting.vue | 13 +- .../__tests__/VehicleTypeSetting.spec.ts | 64 +- frontend/src/router/__tests__/router.spec.ts | 33 + frontend/src/router/index.ts | 12 + frontend/src/services/apiCore.ts | 14 +- frontend/src/services/settingsApi.ts | 16 + .../src/stores/__tests__/settingsSync.spec.ts | 220 +++++ frontend/src/stores/settingsDashboard.ts | 58 +- frontend/src/stores/settingsMap.ts | 7 +- frontend/src/stores/settingsShared.ts | 51 +- frontend/src/stores/settingsSync.ts | 237 +++++ frontend/src/stores/settingsUi.ts | 16 +- frontend/src/views/DashboardView.vue | 7 - .../Authentication/SessionPrincipal.cs | 18 + .../Endpoints/SettingsEndpoints.cs | 102 ++ src/GarageStack.Api/Program.cs | 1 + .../Helpers/SettingsDocument.cs | 74 ++ .../Interfaces/IUserSettingsRepository.cs | 24 + src/GarageStack.Core/Models/UserSettings.cs | 62 ++ src/GarageStack.Data/AppDbContext.cs | 12 + .../Extensions/ServiceCollectionExtensions.cs | 3 + ...20261005120400_AddUserSettings.Designer.cs | 880 ++++++++++++++++++ .../20261005120400_AddUserSettings.cs | 46 + .../Migrations/AppDbContextModelSnapshot.cs | 37 + .../Repositories/UserSettingsRepository.cs | 90 ++ .../AuthenticationFlowTests.cs | 94 ++ .../SettingsDocumentTests.cs | 57 ++ .../SettingsEndpointsTests.cs | 91 ++ .../UserSettingsRepositoryTests.cs | 177 ++++ 34 files changed, 2475 insertions(+), 78 deletions(-) create mode 100644 frontend/src/services/settingsApi.ts create mode 100644 frontend/src/stores/__tests__/settingsSync.spec.ts create mode 100644 frontend/src/stores/settingsSync.ts create mode 100644 src/GarageStack.Api/Endpoints/SettingsEndpoints.cs create mode 100644 src/GarageStack.Core/Helpers/SettingsDocument.cs create mode 100644 src/GarageStack.Core/Interfaces/IUserSettingsRepository.cs create mode 100644 src/GarageStack.Core/Models/UserSettings.cs create mode 100644 src/GarageStack.Data/Migrations/20261005120400_AddUserSettings.Designer.cs create mode 100644 src/GarageStack.Data/Migrations/20261005120400_AddUserSettings.cs create mode 100644 src/GarageStack.Data/Repositories/UserSettingsRepository.cs create mode 100644 src/GarageStack.Tests/SettingsDocumentTests.cs create mode 100644 src/GarageStack.Tests/SettingsEndpointsTests.cs create mode 100644 src/GarageStack.Tests/UserSettingsRepositoryTests.cs diff --git a/.env.example b/.env.example index aa173089..0884ff09 100644 --- a/.env.example +++ b/.env.example @@ -183,7 +183,7 @@ OPENCHARGEMAP_API_KEY= # ── Speed cameras (OpenStreetMap) ───────────────────────────────────────────── # Speed camera positions as mapped in OpenStreetMap, shown as a map layer that is -# off by default and switched on per browser under the map's layer panel. Tagged +# off by default and switched on per account under the map's layer panel. Tagged # positions only: GarageStack is a dashboard, not a navigation app, and it never # warns about a camera ahead while driving. # Set to false to remove the layer from the deployment entirely, which is what to @@ -196,7 +196,7 @@ OPENCHARGEMAP_API_KEY= # street the car is parked in. No API key is required. Answers are cached in the # database for 90 days, so the same spot is only ever looked up once. # Set to false to keep coordinates off a third-party service; the map then falls -# back to showing dates in the trip list. Each browser can also switch place names +# back to showing dates in the trip list. Each account can also switch place names # off on its own under Settings > Map, which is on by default. # GEOCODING__ENABLED=true # Point this at your own Nominatim to avoid the public instance entirely. @@ -208,7 +208,7 @@ OPENCHARGEMAP_API_KEY= # required. Snapped trips are cached in the database for 30 days, so a trip is only # ever matched once. # Set to false to keep trip geometry off a third-party service; trips then keep -# being drawn from their raw fixes. Each browser can also switch snapping off on +# being drawn from their raw fixes. Each account can also switch snapping off on # its own under the map's filter panel, which is on by default. # MAPMATCHING__ENABLED=true # Point this at your own Valhalla to avoid the public instance entirely. diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 50295f79..a0da1a18 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -81,6 +81,7 @@ This project follows a coordinated vulnerability disclosure model. Good-faith se ### Database - The bundled deployments use one PostgreSQL role, owned by the application, for both the API and the Worker. There is no split into per-service roles; on an external server you can create a dedicated role with access to the `garagestack` database only +- User settings are stored per account under a SHA-256 hash of the sign-in method and subject, so the settings table holds no user name or email address. The server accepts only the known sections, plain identifier keys and 16 KB per section - Personal identifiers are kept out of the rotating log files: the MG account email and VIN are redacted from MQTT topics before logging, VINs are shortened to their last four characters, and failed login attempts log only the client IP. Location data is stored in the database and never logged ### Dependencies diff --git a/README.md b/README.md index d1cfcf35..fab91fa4 100644 --- a/README.md +++ b/README.md @@ -55,13 +55,14 @@ The statistics, trip log, maintenance list and light theme are in the [screensho - **Fuel stations** -- Overlay nearby petrol and diesel stations on the map (HEV and PHEV only; not shown for BEV). Sourced from OpenStreetMap via the Overpass API -- no API key required. POI data is cached in the database for 7 days and pre-populated by the Worker for a 100 km radius around the car's last known position so the overlay is instant on first view. - **Motorway service areas** -- Overlay motorway service areas and rest stops on the map (all vehicle types). Same DB-backed cache and Worker pre-cache as fuel stations; useful for BEV drivers who often find fast chargers at service areas. - **Speed cameras** -- Overlay the speed cameras mapped in OpenStreetMap (`highway=speed_camera`), with the limit each one enforces and the kind of camera it is where OSM records them. Same DB-backed cache and Worker pre-cache as fuel stations, off until you switch it on in the map's layer panel, and removable from the deployment with `SPEEDCAMERAS__ENABLED=false` -- see the note on jurisdictions in the Map overlays section. -- **Place names** -- Trips are listed by where they went ("Zwolle to Deventer") instead of by date alone, and the dashboard's location card names the street the car is parked in. Sourced from OpenStreetMap via [Nominatim](https://nominatim.openstreetmap.org) -- no API key required, answers are cached in the database for 90 days, and the whole feature can be switched off per browser under Settings > Map, or for the deployment with `GEOCODING__ENABLED=false`. +- **Place names** -- Trips are listed by where they went ("Zwolle to Deventer") instead of by date alone, and the dashboard's location card names the street the car is parked in. Sourced from OpenStreetMap via [Nominatim](https://nominatim.openstreetmap.org) -- no API key required, answers are cached in the database for 90 days, and the whole feature can be switched off under Settings > Map, or for the deployment with `GEOCODING__ENABLED=false`. - **Snapped trip lines** -- A selected trip is drawn along the roads it was driven on rather than in straight lines between GPS fixes, which also gives a truer distance than the fixes alone. Matched against OpenStreetMap by [Valhalla](https://valhalla1.openstreetmap.de) -- no API key required, snapped trips are cached in the database for 30 days, and it can be switched off in the map's filter panel or for the deployment with `MAPMATCHING__ENABLED=false`. - **Speed limits** -- The selected trip can be coloured against the limits signposted along it, green within and red above, with how far over it went and over how much of the trip a limit was known. Read from OpenStreetMap's `maxspeed` tags by the same match that snapped the trip, so it costs no extra request and needs no configuration. - **Themed vector basemap** -- Every map is drawn from OpenStreetMap vector tiles by MapLibre GL, in a dark or light style that follows the interface theme (or in full colour, if you prefer) and with labels in the interface language. Served by [OpenFreeMap](https://openfreemap.org) without an API key, point it at your own tile server if you prefer, and it falls back to raster tiles where WebGL is unavailable. - **Single sign-on** -- Sign in through your own identity provider (Authentik, Authelia, Keycloak, Pocket ID, Google, and anything else speaking OpenID Connect), with optional auto-login and group or email based access restrictions. A built-in username/password login remains available for installs without a provider. See [`AUTHENTICATION.md`](documentation/AUTHENTICATION.md). - **Multi-language support** -- Interface available in English and Dutch, with locale resolved from query string, cookie, or browser preference. -- **Units** -- Kilometres or miles, Celsius or Fahrenheit, bar, psi or kPa for tyres, and L/100 km or miles per UK or US gallon for fuel, chosen per browser under Settings > Units. Everything is stored in metric and converted only for display, so switching back and forth loses nothing; the trip log's CSV export follows the chosen distance unit. The homepage widget, Home Assistant and the `TYRE_PRESSURE_*_BAR` thresholds stay metric. +- **Units** -- Kilometres or miles, Celsius or Fahrenheit, bar, psi or kPa for tyres, and L/100 km or miles per UK or US gallon for fuel, chosen under Settings > Units. Everything is stored in metric and converted only for display, so switching back and forth loses nothing; the trip log's CSV export follows the chosen distance unit. The homepage widget, Home Assistant and the `TYRE_PRESSURE_*_BAR` thresholds stay metric. +- **Settings follow you** -- Theme, language, units, the dashboard layout and the map's layers and filters are saved to your account, so every phone, tablet and computer you sign in on looks the same. Each browser keeps a copy too, so pages open instantly and the login page keeps your theme and language. After upgrading, the first device to sign in brings its settings along. - **Self-hosted** -- Runs entirely on your own infrastructure via Docker (all-in-one container or Docker Compose). No cloud account or subscription required beyond the SAIC iSmart API. ### Dashboard cards @@ -420,7 +421,7 @@ Sourced from OpenStreetMap (`highway=services`) via the Overpass API -- no API k Sourced from OpenStreetMap (`highway=speed_camera`) via the Overpass API -- no API key required. - Shows the cameras OSM holds a position for, all vehicle types. A marker's popup names the limit the camera enforces (`maxspeed`), the kind of camera (fixed, mobile site, average speed check, red light) and its operator, as far as the node is tagged for them. Coverage and accuracy are whatever OSM's mappers have entered. -- Off by default. **The map's layer panel > Speed cameras** switches it on per browser. +- Off by default. **The map's layer panel > Speed cameras** switches it on. - Positions on a map, not warnings: GarageStack is a dashboard of the car's state, and nothing here alerts you to a camera ahead while driving. - Several countries restrict pointing a driver at camera positions, France and Germany among them. Set `SPEEDCAMERAS__ENABLED=false` to remove the layer from the deployment: the toggle then disappears from the layer panel, the endpoint serves nothing, and the Worker fetches no camera data either. - Same DB-backed cache and Worker pre-population as fuel stations. The same Overpass slowness applies for uncached tiles outside the pre-populated radius -- see the note in the Fuel stations section above. @@ -434,7 +435,7 @@ Sourced from OpenStreetMap via [Nominatim](https://nominatim.openstreetmap.org) - Answers are cached in PostgreSQL for 90 days, keyed by a coordinate grid and by language, so the same parking spot is looked up once. "Nothing mapped here" is cached for a day, since OSM coverage grows. - The public Nominatim instance allows about one request per second, so a request resolves at most three new coordinates and the browser asks again for the rest; a cold trip list fills in over a few seconds and is instant from then on. - Names follow the interface language, so switching between English and Dutch looks the places up again in that language. -- **Settings > Map > Show place names** turns the whole thing off per browser (it is on by default). Off, trips are listed by date, the location card shows no address, and no coordinates leave the browser. +- **Settings > Map > Show place names** turns the whole thing off (it is on by default). Off, trips are listed by date, the location card shows no address, and no coordinates leave the browser. - Set `GEOCODING__ENABLED=false` to switch it off for the whole deployment instead, or point `GEOCODING__BASEURL` at your own Nominatim instance. ### Snapped trip lines (map matching) @@ -447,7 +448,7 @@ Telemetry arrives as GPS fixes tens of seconds apart, so a trip drawn straight f - A trip with a hole in its telemetry (the gateway stopped publishing for a while) is matched in parts and stitched back together, with the hole left as the straight jump it always was. Valhalla refuses a trace containing a jump wider than 10 km, which is exactly what such a hole looks like. - A match whose length no longer resembles the fixes it came from is discarded and the raw line kept: with sparse fixes a router can return a plausible route that is not the one taken. - Snapped trips are cached in PostgreSQL for 30 days, keyed by a hash of the fixes, so a trip is matched once however often it is selected. "These fixes snap to nothing" is cached for a day. -- **The map's filter panel > Snap to roads** turns it off per browser (it is on by default), and trips are then drawn from their raw fixes as before. +- **The map's filter panel > Snap to roads** turns it off (it is on by default), and trips are then drawn from their raw fixes as before. - Set `MAPMATCHING__ENABLED=false` to switch it off for the whole deployment instead, or point `MAPMATCHING__BASEURL` at your own Valhalla instance. ### Speed limits diff --git a/documentation/ARCHITECTURE.md b/documentation/ARCHITECTURE.md index 71e864ac..bd92bae1 100644 --- a/documentation/ARCHITECTURE.md +++ b/documentation/ARCHITECTURE.md @@ -178,6 +178,15 @@ Both `/api/auth/oidc/login` and the callback answer failures with a redirect to The callback lives at `/api/auth/oidc/callback` rather than the handler's default `/signin-oidc` because nginx only proxies `/api/` and `/hubs/` to the API. Operator-facing documentation is in [`AUTHENTICATION.md`](AUTHENTICATION.md); `AuthenticationFlowTests` drives the whole round-trip against a fake provider. +## Settings + +A user's settings (theme, language, units, the dashboard layout, the map's layers and filters) belong to the account they sign in with and follow it to every device. The browser owns what they mean; the server only keeps them. + +- **Stores.** `settingsUi`, `settingsDashboard` and `settingsMap` each hold one section. `persistSettings` in `settingsShared.ts` keeps a section in localStorage, which is what a page starts with and all there is while signed out, and hands it to `settingsSync`. +- **Sync.** `settingsSync` reads `GET /api/settings` alongside the router guard's session check, and the guard lets no protected view mount until it has been taken in (the dashboard arranges its cards on a first visit, and must not do so over a layout saved elsewhere). From then on each local write sends only the keys that differ from what the account was last known to hold (`PATCH /api/settings/{section}`). A key changed on this page wins over the account's copy; every other key takes the account's. The page reads again when it comes back into view (at most every 30 seconds) or back online, and an account with no copy of a section yet is filled from this browser's own. +- **Server.** `UserSettings` holds one row per account per section: a JSON object the server does not interpret beyond the fixed section list, key names, and a 16 KB cap. `UserSettingsRepository.MergeAsync` merges a save key by key, with a version column and a short randomised retry, so devices saving at the same moment keep each other's keys. Accounts are keyed by `SessionPrincipal.ResolveAccountKey`, a SHA-256 of the sign-in method and subject: no user name or email address is stored, and a password account and a provider account of the same name stay apart. +- **A new setting** needs no backend change: a field in its store's interface, defaults and parse function travels with the section. The parse function is what reads the account's copy too, so it must fall back on values it does not know, which an older or newer build on another device may have saved. + ## Database Code-first EF Core migrations live in `src/GarageStack.Data/Migrations/`. `Program.cs` runs `db.Database.MigrateAsync()` on Api startup in normal operation; in `DEMO_MODE` it calls `DemoSeeder.SeedAsync()` instead, which creates the in-memory schema and seeds fake data, bypassing a real Postgres server entirely. @@ -218,8 +227,8 @@ REST calls go through `frontend/src/services/` - `apiCore.ts` centralizes the `f The vehicle store owns `activeVehicle`/`activeVin` (the one car this instance follows) and `effectiveVehicleType` (the user's manual override, else the drivetrain the API detected from the gateway's `hw_version` series code and the car's `BType` configuration code, in `VehicleTypeHelper`); views read those rather than indexing into the vehicle list or repeating the override logic. The TypeScript interfaces in `services/` mirror the API's DTOs by hand; the history endpoint returns `TelemetryHistoryPoint` (the chart fields only), not full snapshots. -The API speaks metric only (km, km/h, bar, °C, litres, L/100 km), and so does the database. A -browser's unit choice lives in the UI settings store and is applied at the last moment by +The API speaks metric only (km, km/h, bar, °C, litres, L/100 km), and so does the database. The +chosen units live in the UI settings store and are applied at the last moment by `UnitFormatter` in `utils/units.ts`, reached through the `useUnits` composable: a value on screen goes through `units.format(quantity, metricValue)` (or `measure` when the number and its unit are styled apart), never through a hand-written `km` or `bar`. A distance typed into a form goes back diff --git a/documentation/AUTHENTICATION.md b/documentation/AUTHENTICATION.md index a2b53515..58a805f0 100644 --- a/documentation/AUTHENTICATION.md +++ b/documentation/AUTHENTICATION.md @@ -259,6 +259,12 @@ The usual reason to want it: your identity provider runs on the same machine as Set `AUTH_COOKIE_SECURE=true` whenever GarageStack is reachable over HTTPS. It defaults to `false` so plain-HTTP LAN installs work out of the box. +### Settings belong to the account + +Theme, language, units, the dashboard layout and the map settings are saved per account, identified by the sign-in method and the account's subject (the provider's `sub`, or the password login's user name). Everyone signing in through your provider gets their own settings, and a password account never shares settings with a provider account, even one of the same name. + +Switching from the password login to a provider, or changing `AUTH_USERNAME`, therefore starts a new, empty account. Nothing is lost: the first device to sign in to it fills it from the settings that browser already had. + --- ## Upgrading from the previous login diff --git a/frontend/src/components/settings/VehicleTypeSetting.vue b/frontend/src/components/settings/VehicleTypeSetting.vue index 4124a246..8710f392 100644 --- a/frontend/src/components/settings/VehicleTypeSetting.vue +++ b/frontend/src/components/settings/VehicleTypeSetting.vue @@ -3,13 +3,23 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import Multiselect from '@vueform/multiselect' import { useUiSettingsStore, type VehicleTypeOverride } from '@/stores/settingsUi' +import { useDashboardSettingsStore } from '@/stores/settingsDashboard' import { useVehicleStore } from '@/stores/vehicle' // What the gateway says the car is, and the override for when it gets that wrong. const { t } = useI18n() const settings = useUiSettingsStore() +const dashboardSettings = useDashboardSettingsStore() const vehicleStore = useVehicleStore() +// The cards that depend on the drivetrain take the chosen type's defaults. Only here, where the +// driver makes the choice: the same choice arriving from another device comes with the cards +// that already follow it, and whatever was changed there since must stay. +function chooseType(type: VehicleTypeOverride) { + settings.vehicleTypeOverride = type + dashboardSettings.applyTypeDefaults(vehicleStore.effectiveVehicleType) +} + const detectedLabel = computed(() => { const type = vehicleStore.detectedVehicleType if (type === 'unknown') return null @@ -40,13 +50,14 @@ const typeOptions = computed((): { value: VehicleTypeOverride; label: string }[]
diff --git a/frontend/src/components/settings/__tests__/VehicleTypeSetting.spec.ts b/frontend/src/components/settings/__tests__/VehicleTypeSetting.spec.ts index 00d6914a..dc6294fe 100644 --- a/frontend/src/components/settings/__tests__/VehicleTypeSetting.spec.ts +++ b/frontend/src/components/settings/__tests__/VehicleTypeSetting.spec.ts @@ -1,31 +1,56 @@ -import { describe, it, expect, vi } from 'vitest' +import { beforeEach, describe, it, expect, vi } from 'vitest' import { nextTick } from 'vue' import { shallowMount } from '@vue/test-utils' import { createI18n } from 'vue-i18n' import Multiselect from '@vueform/multiselect' import VehicleTypeSetting from '../VehicleTypeSetting.vue' +const { uiSettings, applyTypeDefaults } = vi.hoisted(() => ({ + uiSettings: { vehicleTypeOverride: 'auto' }, + applyTypeDefaults: vi.fn<(type: string) => void>(), +})) + vi.mock('@/stores/vehicle', () => ({ - useVehicleStore: () => ({ detectedVehicleType: 'unknown', vehicleConfig: {} }), + useVehicleStore: () => ({ + detectedVehicleType: 'unknown', + vehicleConfig: {}, + // Follows the override, as the real store's does. + get effectiveVehicleType() { + return uiSettings.vehicleTypeOverride === 'auto' ? 'unknown' : uiSettings.vehicleTypeOverride + }, + }), })) vi.mock('@/stores/settingsUi', () => ({ - useUiSettingsStore: () => ({ vehicleTypeOverride: 'auto' }), + useUiSettingsStore: () => uiSettings, +})) + +vi.mock('@/stores/settingsDashboard', () => ({ + useDashboardSettingsStore: () => ({ applyTypeDefaults }), })) +function i18n() { + return createI18n({ + legacy: false, + locale: 'en', + missingWarn: false, + fallbackWarn: false, + messages: { + en: { settings: { vehicleType: { auto: 'Automatic' } } }, + nl: { settings: { vehicleType: { auto: 'Automatisch' } } }, + }, + }) +} + describe('VehicleTypeSetting', () => { + beforeEach(() => { + uiSettings.vehicleTypeOverride = 'auto' + applyTypeDefaults.mockReset() + }) + it('relabels the vehicle type options when the language changes', async () => { - const i18n = createI18n({ - legacy: false, - locale: 'en', - missingWarn: false, - fallbackWarn: false, - messages: { - en: { settings: { vehicleType: { auto: 'Automatic' } } }, - nl: { settings: { vehicleType: { auto: 'Automatisch' } } }, - }, - }) - const wrapper = shallowMount(VehicleTypeSetting, { global: { plugins: [i18n] } }) + const plugin = i18n() + const wrapper = shallowMount(VehicleTypeSetting, { global: { plugins: [plugin] } }) const autoLabel = () => ( wrapper.findComponent(Multiselect).props('options') as { value: string; label: string }[] @@ -33,9 +58,18 @@ describe('VehicleTypeSetting', () => { expect(autoLabel()).toBe('Automatic') - i18n.global.locale.value = 'nl' + plugin.global.locale.value = 'nl' await nextTick() expect(autoLabel()).toBe('Automatisch') }) + + it('gives the drivetrain cards the chosen type defaults when the driver picks a type', () => { + const wrapper = shallowMount(VehicleTypeSetting, { global: { plugins: [i18n()] } }) + + wrapper.findComponent(Multiselect).vm.$emit('update:model-value', 'bev') + + expect(uiSettings.vehicleTypeOverride).toBe('bev') + expect(applyTypeDefaults).toHaveBeenCalledExactlyOnceWith('bev') + }) }) diff --git a/frontend/src/router/__tests__/router.spec.ts b/frontend/src/router/__tests__/router.spec.ts index 5fffa3d7..247b2895 100644 --- a/frontend/src/router/__tests__/router.spec.ts +++ b/frontend/src/router/__tests__/router.spec.ts @@ -3,12 +3,20 @@ import { createPinia, setActivePinia } from 'pinia' import { defineComponent } from 'vue' import type { RouteLocationNormalized } from 'vue-router' import { authApi, type MeResponse } from '@/services/authApi' +import { settingsApi, type AccountSettings } from '@/services/settingsApi' import router, { preloadRouteComponents } from '@/router' vi.mock('@/services/authApi', () => ({ authApi: { me: vi.fn<() => Promise>() }, })) +vi.mock('@/services/settingsApi', () => ({ + settingsApi: { + load: vi.fn<() => Promise>(), + save: vi.fn<() => Promise>(), + }, +})) + const AUTH_USERNAME_KEY = 'garagestack-auth-username' const AUTH_EXPIRES_KEY = 'garagestack-auth-expires' @@ -39,6 +47,7 @@ beforeEach(() => { localStorage.clear() setActivePinia(createPinia()) vi.mocked(authApi.me).mockReset() + vi.mocked(settingsApi.load).mockReset().mockResolvedValue({}) }) afterEach(() => { @@ -85,6 +94,30 @@ describe('router guard', () => { expect(router.currentRoute.value.path).toBe('/preload-probe') }) + it("asks for the account's settings alongside the session check, and enters once they are in", async () => { + storeSession() + const answerSession = holdSessionCheck() + let answerSettings!: (settings: AccountSettings) => void + vi.mocked(settingsApi.load).mockReturnValue( + new Promise((resolve) => (answerSettings = resolve)), + ) + router.addRoute({ path: '/settings-probe', component: View }) + + const navigation = router.push('/settings-probe') + + // Both on their way at once, rather than one after the other. + await vi.waitFor(() => expect(settingsApi.load).toHaveBeenCalledOnce()) + expect(authApi.me).toHaveBeenCalledOnce() + + answerSession() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(router.currentRoute.value.path).not.toBe('/settings-probe') + + answerSettings({}) + await navigation + expect(router.currentRoute.value.path).toBe('/settings-probe') + }) + it('does not download a protected view for a visitor without a session', async () => { vi.mocked(authApi.me).mockRejectedValue(new Error('401')) const load = vi.fn(() => Promise.resolve({ default: View })) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 550564d9..a8a422ba 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,5 +1,6 @@ import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router' import { useAuthStore } from '@/stores/auth' +import { useSettingsSyncStore } from '@/stores/settingsSync' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -61,13 +62,20 @@ export function preloadRouteComponents(to: RouteLocationNormalized): void { router.beforeEach(async (to) => { const auth = useAuthStore() + const settingsSync = useSettingsSyncStore() const isPublic = to.meta.public === true // Only when the session held in storage makes it likely the guard lets this navigation // through: a visitor about to be sent to the login page has no use for the dashboard's code. + // The account's settings are asked for alongside the session check for the same reason, so a + // cold load waits on one round trip rather than two. if (isPublic || auth.isAuthenticated) preloadRouteComponents(to) + if (auth.isAuthenticated) void settingsSync.start() await auth.ensureVerified() + // Signed out, or the stored session turned out to be stale: nothing more goes to the account. + if (!auth.isAuthenticated) settingsSync.stop() + if (isPublic) { if (to.name === 'login' && auth.isAuthenticated) { return { name: 'dashboard' } @@ -82,6 +90,10 @@ router.beforeEach(async (to) => { } } + // Views read the settings as they mount, and some write them back (the dashboard arranges its + // cards on a first visit), so none may mount before the account's copy has been taken in. + // Right after signing in, this is where it is first asked for. + await settingsSync.start() return true }) diff --git a/frontend/src/services/apiCore.ts b/frontend/src/services/apiCore.ts index 6b7e1175..9cc5c5fa 100644 --- a/frontend/src/services/apiCore.ts +++ b/frontend/src/services/apiCore.ts @@ -89,10 +89,20 @@ export function requestJson(path: string, method: string, body?: unknown): Pr return request(path, jsonInit(method, body)) } -/** Sends a JSON body (or none) and ignores the response body, for endpoints that answer with an empty 200. */ -export async function send(path: string, method: string, body?: unknown): Promise { +/** + * Sends a JSON body (or none) and ignores the response body, for endpoints that answer with an + * empty 200 or 204. `init` adds fetch options, such as `keepalive` for a request that has to + * arrive even when the page is closing. + */ +export async function send( + path: string, + method: string, + body?: unknown, + init?: RequestInit, +): Promise { const res = await fetch(`${BASE_URL}${path}`, { ...jsonInit(method, body), + ...init, credentials: 'include', }) diff --git a/frontend/src/services/settingsApi.ts b/frontend/src/services/settingsApi.ts new file mode 100644 index 00000000..05ab11e4 --- /dev/null +++ b/frontend/src/services/settingsApi.ts @@ -0,0 +1,16 @@ +import { request, send } from '@/services/apiCore' + +/** The sections an account's settings are kept in, one per settings store. Mirrors UserSettingsLimits.Sections. */ +export const SETTINGS_SECTIONS = ['ui', 'dashboard', 'map'] as const +export type SettingsSection = (typeof SETTINGS_SECTIONS)[number] + +/** Every section the account has saved, each a JSON object of its settings. One never saved is absent. */ +export type AccountSettings = Partial>> + +export const settingsApi = { + load: () => request('/api/settings'), + // Only the keys given change; the rest of the section keeps what the account holds. keepalive, + // because the last change before a tab closes is sent while the page is going away. + save: (section: SettingsSection, changes: Record) => + send(`/api/settings/${section}`, 'PATCH', changes, { keepalive: true }), +} diff --git a/frontend/src/stores/__tests__/settingsSync.spec.ts b/frontend/src/stores/__tests__/settingsSync.spec.ts new file mode 100644 index 00000000..53c75f10 --- /dev/null +++ b/frontend/src/stores/__tests__/settingsSync.spec.ts @@ -0,0 +1,220 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { settingsApi, type AccountSettings } from '@/services/settingsApi' +import { PULL_INTERVAL_MS, useSettingsSyncStore } from '@/stores/settingsSync' +import { useUiSettingsStore } from '@/stores/settingsUi' +import { useMapSettingsStore } from '@/stores/settingsMap' +import { useDashboardSettingsStore } from '@/stores/settingsDashboard' +import { METRIC_UNITS } from '@/utils/units' + +vi.mock('@/services/settingsApi', () => ({ + settingsApi: { + load: vi.fn<() => Promise>(), + save: vi.fn<() => Promise>(), + }, +})) + +const load = vi.mocked(settingsApi.load) +const save = vi.mocked(settingsApi.save) + +// Local writes wait out a burst of changes (see createDebouncedSave) before the account hears. +const SAVE_DEBOUNCE_MS = 300 +const UI_KEY = 'garagestack-settings-ui' +const MAP_KEY = 'garagestack-settings-map' + +/** Lets a local write, and the save it sends, run to the end. */ +async function settle() { + await vi.advanceTimersByTimeAsync(SAVE_DEBOUNCE_MS) + for (let i = 0; i < 10; i++) await Promise.resolve() +} + +/** An answer from the server that stays out until the test gives it. */ +function holdLoad() { + let answer!: (settings: AccountSettings) => void + load.mockReturnValue(new Promise((resolve) => (answer = resolve))) + return (settings: AccountSettings) => answer(settings) +} + +beforeEach(() => { + localStorage.clear() + setActivePinia(createPinia()) + load.mockReset().mockResolvedValue({}) + save.mockReset().mockResolvedValue(undefined) + vi.useFakeTimers() +}) + +afterEach(() => { + // Takes its page listeners with it, so a store a test left running cannot answer the next + // test's visibility change. + useSettingsSyncStore().$dispose() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('settings kept on the account', () => { + it('takes in the account copy once keeping starts', async () => { + localStorage.setItem(UI_KEY, JSON.stringify({ theme: 'dark', locale: 'en' })) + load.mockResolvedValue({ ui: { theme: 'light', locale: 'nl' } }) + const ui = useUiSettingsStore() + + await useSettingsSyncStore().start() + + expect(ui.theme).toBe('light') + expect(ui.locale).toBe('nl') + await settle() + expect(save).not.toHaveBeenCalled() + }) + + it('keeps a change made while the account copy was on its way, and sends it', async () => { + const answer = holdLoad() + const ui = useUiSettingsStore() + const ready = useSettingsSyncStore().start() + + ui.locale = 'nl' + answer({ ui: { theme: 'light', locale: 'en' } }) + await ready + + expect(ui.theme).toBe('light') + expect(ui.locale).toBe('nl') + await vi.waitFor(() => expect(save).toHaveBeenCalledExactlyOnceWith('ui', { locale: 'nl' })) + }) + + it("fills an account that has none yet from this browser's own copy", async () => { + localStorage.setItem(MAP_KEY, JSON.stringify({ heatmapEnabled: false })) + const map = useMapSettingsStore() + + await useSettingsSyncStore().start() + + await vi.waitFor(() => expect(save).toHaveBeenCalledOnce()) + const [section, changes] = save.mock.calls[0]! + expect(section).toBe('map') + // Every key, so the account holds the whole section from here on. + expect(Object.keys(changes).sort()).toEqual(Object.keys(map.$state).sort()) + expect(changes.heatmapEnabled).toBe(false) + }) + + it('leaves an empty account alone when this browser has nothing of its own either', async () => { + useUiSettingsStore() + + await useSettingsSyncStore().start() + await settle() + + expect(save).not.toHaveBeenCalled() + }) + + it('sends only the keys that changed', async () => { + load.mockResolvedValue({ ui: { theme: 'dark' } }) + const ui = useUiSettingsStore() + await useSettingsSyncStore().start() + + ui.units.distance = 'mi' + await settle() + + expect(save).toHaveBeenCalledExactlyOnceWith('ui', { + units: { ...METRIC_UNITS, distance: 'mi' }, + }) + }) + + it("keeps the notification checklist on this device, since it decides this browser's push", async () => { + localStorage.setItem(UI_KEY, JSON.stringify({ notificationTypeExclusions: ['low-tyre'] })) + load.mockResolvedValue({ ui: { theme: 'light', notificationTypeExclusions: ['engine-start'] } }) + const ui = useUiSettingsStore() + await useSettingsSyncStore().start() + + expect(ui.theme).toBe('light') + expect(ui.notificationTypeExclusions).toEqual(['low-tyre']) + + ui.notificationTypeExclusions = [] + ui.locale = 'nl' + await settle() + + expect(save).toHaveBeenCalledExactlyOnceWith('ui', { locale: 'nl' }) + }) + + it('sends nothing before keeping starts or after it stops', async () => { + const ui = useUiSettingsStore() + const sync = useSettingsSyncStore() + + ui.theme = 'light' + await settle() + expect(save).not.toHaveBeenCalled() + + // The change made while signed out is this browser's own copy now, so it fills the account. + await sync.start() + await settle() + expect(save).toHaveBeenCalledOnce() + expect(save.mock.calls[0]![1].theme).toBe('light') + + sync.stop() + ui.locale = 'nl' + await settle() + expect(save).toHaveBeenCalledOnce() + }) + + it('sends a change that could not be saved again with the next one', async () => { + const ui = useUiSettingsStore() + await useSettingsSyncStore().start() + save.mockRejectedValueOnce(new Error('offline')) + + ui.theme = 'light' + await settle() + ui.locale = 'nl' + await settle() + + expect(save).toHaveBeenCalledTimes(2) + expect(save).toHaveBeenLastCalledWith('ui', { theme: 'light', locale: 'nl' }) + }) + + it('gives a store first used after the account copy came in that copy at once', async () => { + load.mockResolvedValue({ dashboard: { showLocationMap: false } }) + useUiSettingsStore() + await useSettingsSyncStore().start() + + const dashboard = useDashboardSettingsStore() + + expect(dashboard.showLocationMap).toBe(false) + // A layout saved on another device is the user's own, not a first visit's to rearrange. + expect(dashboard.hasSavedLayout).toBe(true) + }) + + it('replaces an account copy this build cannot read with its own', async () => { + load.mockResolvedValue({ dashboard: { cards: [null] } }) + const dashboard = useDashboardSettingsStore() + + await useSettingsSyncStore().start() + + await vi.waitFor(() => expect(save).toHaveBeenCalledOnce()) + expect(save.mock.calls[0]![1].cards).toEqual(JSON.parse(JSON.stringify(dashboard.cards))) + }) + + it('looks for changes made on other devices when the page comes back into view', async () => { + vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible') + load.mockResolvedValue({ ui: { theme: 'dark' } }) + const ui = useUiSettingsStore() + await useSettingsSyncStore().start() + load.mockResolvedValue({ ui: { theme: 'light' } }) + + // Straight back: too soon to ask again. + document.dispatchEvent(new Event('visibilitychange')) + expect(load).toHaveBeenCalledOnce() + + vi.advanceTimersByTime(PULL_INTERVAL_MS) + document.dispatchEvent(new Event('visibilitychange')) + + await vi.waitFor(() => expect(ui.theme).toBe('light')) + expect(load).toHaveBeenCalledTimes(2) + }) + + it('drops an answer that arrives after signing out', async () => { + const answer = holdLoad() + const ui = useUiSettingsStore() + const sync = useSettingsSyncStore() + const ready = sync.start() + + sync.stop() + answer({ ui: { theme: 'light' } }) + await ready + + expect(ui.theme).toBe('dark') + }) +}) diff --git a/frontend/src/stores/settingsDashboard.ts b/frontend/src/stores/settingsDashboard.ts index 6e0a2932..75b4511c 100644 --- a/frontend/src/stores/settingsDashboard.ts +++ b/frontend/src/stores/settingsDashboard.ts @@ -17,6 +17,7 @@ import { ALL_STATS_INSIGHT_IDS, ALL_STATS_CHART_IDS, readLegacyBlob, + hasStoredCopy, persistSettings, } from './settingsShared' @@ -53,33 +54,26 @@ function storedCards(parsed: Record): CardConfig[] { return Array.isArray(parsed.cards) ? migrateCards(parsed.cards) : defaultCards('unknown') } +function parseDashboardFields(parsed: Record): DashboardSettings { + return { + cards: storedCards(parsed), + statsInsights: loadStatsItems(parsed.statsInsights, ALL_STATS_INSIGHT_IDS), + statsCharts: loadStatsItems(parsed.statsCharts, ALL_STATS_CHART_IDS), + showTyreDiagram: parsed.showTyreDiagram !== false, + showLocationMap: parsed.showLocationMap !== false, + } +} + function loadDashboardSettings(): DashboardSettings { try { const raw = localStorage.getItem(STORAGE_KEY) - if (raw) { - const parsed = JSON.parse(raw) - return { - cards: storedCards(parsed), - statsInsights: loadStatsItems(parsed.statsInsights, ALL_STATS_INSIGHT_IDS), - statsCharts: loadStatsItems(parsed.statsCharts, ALL_STATS_CHART_IDS), - showTyreDiagram: parsed.showTyreDiagram !== false, - showLocationMap: parsed.showLocationMap !== false, - } - } + if (raw) return parseDashboardFields(JSON.parse(raw)) } catch { // ignore parse errors } const legacy = readLegacyBlob() - if (legacy) { - return { - cards: storedCards(legacy), - statsInsights: loadStatsItems(legacy.statsInsights, ALL_STATS_INSIGHT_IDS), - statsCharts: loadStatsItems(legacy.statsCharts, ALL_STATS_CHART_IDS), - showTyreDiagram: legacy.showTyreDiagram !== false, - showLocationMap: legacy.showLocationMap !== false, - } - } + if (legacy) return parseDashboardFields(legacy) return defaultsFor() } @@ -125,22 +119,22 @@ function byData(cards: CardConfig[], hasData: (id: CardId) => boolean): CardConf ] } -// Whether this browser has ever stored a dashboard layout, under the current key or the -// pre-split blob. The dashboard uses it to tell a first visit (where it may order cards by -// which ones have data) from a returning user whose ordering must be left alone. -function hasPersistedLayout(): boolean { - try { - return localStorage.getItem(STORAGE_KEY) !== null || readLegacyBlob() !== null - } catch { - return false - } -} - export const useDashboardSettingsStore = defineStore('settingsDashboard', () => { const settings = reactive(loadDashboardSettings()) - const hasSavedLayout = ref(hasPersistedLayout()) - persistSettings(STORAGE_KEY, settings, () => { + // Whether a layout was ever saved, in this browser or on the account. The dashboard uses it to + // tell a first visit (where it may order cards by which ones have data) from a returning user + // whose ordering must be left alone. + const hasSavedLayout = ref(hasStoredCopy(STORAGE_KEY)) + const markSaved = () => { hasSavedLayout.value = true + } + persistSettings({ + storageKey: STORAGE_KEY, + section: 'dashboard', + state: settings, + parse: parseDashboardFields, + onSave: markSaved, + onAdopted: markSaved, }) function resetCards(type: VehicleType | 'unknown' = 'unknown') { diff --git a/frontend/src/stores/settingsMap.ts b/frontend/src/stores/settingsMap.ts index 667dce7c..e970acfe 100644 --- a/frontend/src/stores/settingsMap.ts +++ b/frontend/src/stores/settingsMap.ts @@ -83,6 +83,11 @@ function loadMapSettings(): MapSettings { export const useMapSettingsStore = defineStore('settingsMap', () => { const settings = reactive(loadMapSettings()) - persistSettings(STORAGE_KEY, settings) + persistSettings({ + storageKey: STORAGE_KEY, + section: 'map', + state: settings, + parse: parseMapFields, + }) return toRefs(settings) }) diff --git a/frontend/src/stores/settingsShared.ts b/frontend/src/stores/settingsShared.ts index e2a7d5d2..772ecf92 100644 --- a/frontend/src/stores/settingsShared.ts +++ b/frontend/src/stores/settingsShared.ts @@ -1,10 +1,12 @@ import { watch } from 'vue' import type { VehicleType } from './vehicle' +import { useSettingsSyncStore } from './settingsSync' +import type { SettingsSection } from '@/services/settingsApi' import { ALL_CARD_IDS, defaultCards } from '@/cards/registry' import type { CardConfig, CardId } from '@/cards/registry' // The card registry owns what a card is (icon, defaults, data predicate); the settings stores -// own which ones this browser shows and in what order. Re-exported so store consumers keep a +// own which ones are shown and in what order. Re-exported so store consumers keep a // single import for both halves. export { ALL_CARD_IDS, defaultCards } export type { CardConfig, CardId } @@ -149,6 +151,15 @@ export function readLegacyBlob(): Record | null { } } +/** Whether this browser has stored settings under `key`, or before the split, in the legacy blob. */ +export function hasStoredCopy(key: string): boolean { + try { + return localStorage.getItem(key) !== null || readLegacyBlob() !== null + } catch { + return false + } +} + // Coalesces bursts of ref changes (drag-reordering, dragging a slider that sets two refs in the // same tick) into a single localStorage write instead of one write per ref per tick. Registers a // pagehide flush so a change made right before closing the tab is never lost. @@ -171,16 +182,42 @@ export function createDebouncedSave(save: () => void, delayMs = 300): () => void return scheduleSave } +export interface PersistOptions { + /** Where this browser keeps its own copy: what the page starts with, and all there is while signed out. */ + storageKey: string + /** Where the signed-in account keeps its copy. */ + section: SettingsSection + state: T + /** Reads the account's copy the way the store reads its own: unknown values fall back. */ + parse: (raw: Record) => T + /** Runs after each write to this browser's copy. */ + onSave?: () => void + /** Runs when the account's copy has been taken in. */ + onAdopted?: () => void + /** Keys kept in this browser only, never on the account. */ + localKeys?: readonly (keyof T & string)[] +} + /** - * Keeps a settings store's `state` in localStorage under `key`: any change, however deep, is - * written whole once the burst it belongs to is over. - * - * @param onSave Runs after each write. + * Keeps a settings store's `state` in this browser and on the signed-in account. Any change, + * however deep, is written whole to localStorage once the burst it belongs to is over, and the + * keys it touched then go to the account (see settingsSync). */ -export function persistSettings(key: string, state: object, onSave?: () => void): void { +export function persistSettings(options: PersistOptions): void { + const { storageKey, section, state, parse, onSave, onAdopted, localKeys } = options + const sync = useSettingsSyncStore() const scheduleSave = createDebouncedSave(() => { - localStorage.setItem(key, JSON.stringify(state)) + localStorage.setItem(storageKey, JSON.stringify(state)) onSave?.() + sync.push(section) }) watch(state, scheduleSave, { deep: true }) + sync.register({ + section, + state, + parse, + hasOwnCopy: () => hasStoredCopy(storageKey), + onAdopted, + localKeys, + }) } diff --git a/frontend/src/stores/settingsSync.ts b/frontend/src/stores/settingsSync.ts new file mode 100644 index 00000000..8aeae051 --- /dev/null +++ b/frontend/src/stores/settingsSync.ts @@ -0,0 +1,237 @@ +import { defineStore } from 'pinia' +import { onScopeDispose } from 'vue' +import { settingsApi, type AccountSettings, type SettingsSection } from '@/services/settingsApi' + +/** + * Keeps the settings stores on the signed-in account, so settings follow it to every device. Each + * store keeps its own copy in this browser as well: that copy is what the page starts with, and all + * there is while signed out. + * + * Settings travel key by key. The page remembers what the account held for each key when it last + * heard, so it can tell a change made here from one made on another device: changes made here go + * to the account, and the account's copy fills in everything this page left alone. + */ + +type Fields = Record + +/** What a settings store hands over to have its settings kept on the account. */ +export interface SyncedSection { + section: SettingsSection + /** The store's reactive settings. The account's copy is written into it key by key. */ + state: object + /** Reads the account's copy the way the store reads its own: unknown values fall back. */ + parse: (raw: Fields) => object + /** Whether this browser has stored the section itself, so it may fill an account that has none. */ + hasOwnCopy: () => boolean + /** Runs when the account's copy of the section has been taken in. */ + onAdopted?: () => void + /** Keys that belong to this device alone: never sent to the account, never taken from it. */ + localKeys?: readonly string[] +} + +/** Each key's value as JSON, as the account was last known to hold it. */ +type Baseline = Map + +interface Entry { + binding: SyncedSection + /** Null while nothing is being kept on the account. */ + baseline: Baseline | null + /** Saves run one after another, so an older one can never land after a newer one. */ + saving: Promise +} + +/** + * How long a page may go without hearing of changes made on other devices before coming back into + * view asks again: long enough that flicking between tabs does not ask every time. + */ +export const PULL_INTERVAL_MS = 30_000 + +function snapshot(fields: object): Baseline { + return new Map(Object.entries(fields).map(([key, value]) => [key, JSON.stringify(value)])) +} + +function changedKeys(fields: object, baseline: Baseline): string[] { + return Object.entries(fields) + .filter(([key, value]) => baseline.get(key) !== JSON.stringify(value)) + .map(([key]) => key) +} + +function isFields(value: unknown): value is Fields { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** The keys of `fields` that travel between this device and the account. */ +function syncedFields(binding: SyncedSection, fields: object): Fields { + const local = binding.localKeys ?? [] + return Object.fromEntries(Object.entries(fields).filter(([key]) => !local.includes(key))) +} + +export const useSettingsSyncStore = defineStore('settingsSync', () => { + const entries = new Map() + + let started = false + // Bumped whenever keeping starts or stops, so an answer meant for an earlier sign-in is dropped. + let session = 0 + /** The account's settings as last read in this sign-in; null until the first read succeeds. */ + let remote: AccountSettings | null = null + let ready: Promise = Promise.resolve() + let pulling: Promise | null = null + let lastPullAt = 0 + + async function save(entry: Entry) { + // Until the account's copy has been read, a value here cannot be told apart from a default + // the account already overrode, so nothing goes out before it has. + if (!started || remote === null || entry.baseline === null) return + const state = entry.binding.state as Fields + const keys = changedKeys(syncedFields(entry.binding, state), entry.baseline) + if (keys.length === 0) return + + const changes = Object.fromEntries(keys.map((key) => [key, state[key]])) + const sent = snapshot(changes) + const current = session + try { + await settingsApi.save(entry.binding.section, changes) + if (current !== session) return + for (const [key, json] of sent) entry.baseline?.set(key, json) + } catch { + // Offline, or refused: the keys still count as changed, and go with the next save or read. + } + } + + function queueSave(entry: Entry) { + entry.saving = entry.saving.then(() => save(entry)) + } + + // The account's copy of a section as the store reads it, or null when there is none or this + // build cannot make sense of it. + function parseRemote(binding: SyncedSection): Fields | null { + const theirs = remote?.[binding.section] + if (!isFields(theirs)) return null + try { + return syncedFields(binding, binding.parse(theirs)) + } catch { + return null + } + } + + function adopt(entry: Entry) { + const { binding } = entry + const state = binding.state as Fields + const parsed = parseRemote(binding) + // Changed on this page since keeping began: the latest word, which the account's copy must + // not undo. They go to the account below. + const mine = new Set( + entry.baseline ? changedKeys(syncedFields(binding, state), entry.baseline) : [], + ) + + if (parsed === null) { + // The account holds none yet, or one that cannot be read: this browser's own copy fills + // it, when it has one. An empty baseline sends every key. + const replace = remote?.[binding.section] !== undefined || binding.hasOwnCopy() + entry.baseline = replace + ? new Map() + : (entry.baseline ?? snapshot(syncedFields(binding, state))) + } else { + for (const [key, value] of Object.entries(parsed)) { + // Only what differs is written, so a list that did not change is not handed to every + // component showing it as a new one. + if (!mine.has(key) && JSON.stringify(state[key]) !== JSON.stringify(value)) { + state[key] = value + } + } + entry.baseline = snapshot(parsed) + binding.onAdopted?.() + } + queueSave(entry) + } + + async function load(current: number) { + try { + const settings = await settingsApi.load() + if (current !== session) return + remote = settings ?? {} + lastPullAt = Date.now() + for (const entry of entries.values()) adopt(entry) + } catch { + // Offline, or the session is gone: the page carries on with this browser's own copy. + } + } + + /** + * Reads the account's settings and takes them in. Resolves once done, whether or not the server + * could be reached; a read already under way is shared rather than asked for twice. + */ + function pull(): Promise { + if (!started) return Promise.resolve() + if (pulling) return pulling + const attempt: Promise = load(session).finally(() => { + if (pulling === attempt) pulling = null + }) + pulling = attempt + return attempt + } + + /** + * Starts keeping settings on the signed-in account, beginning with its copy. Resolves once that + * copy has been taken in, or could not be read. While already keeping, it changes nothing. + */ + function start(): Promise { + if (started) return ready + started = true + session++ + for (const entry of entries.values()) + entry.baseline = snapshot(syncedFields(entry.binding, entry.binding.state)) + ready = pull() + return ready + } + + /** Stops keeping settings on the account, on sign-out. This browser keeps its own copy. */ + function stop() { + if (!started) return + started = false + session++ + remote = null + pulling = null + for (const entry of entries.values()) entry.baseline = null + } + + /** Called by each settings store as it is created. */ + function register(binding: SyncedSection) { + const entry: Entry = { + binding, + baseline: started ? snapshot(syncedFields(binding, binding.state)) : null, + saving: Promise.resolve(), + } + entries.set(binding.section, entry) + // A store first used after the account's copy came in takes it at once, before anything + // has read the store. + if (started && remote !== null) adopt(entry) + } + + /** Sends what changed in a section since the account last heard. Called after each local write. */ + function push(section: SettingsSection) { + const entry = entries.get(section) + if (!entry || !started) return + // Not read yet (the server was out of reach at start-up, say): reading it sends the change too. + if (remote === null) void pull() + else queueSave(entry) + } + + // Changes made on another device show up when this one is looked at again, or back online. + function onVisibilityChange() { + if (document.visibilityState === 'visible' && Date.now() - lastPullAt >= PULL_INTERVAL_MS) { + void pull() + } + } + function onOnline() { + void pull() + } + document.addEventListener('visibilitychange', onVisibilityChange) + window.addEventListener('online', onOnline) + onScopeDispose(() => { + document.removeEventListener('visibilitychange', onVisibilityChange) + window.removeEventListener('online', onOnline) + }) + + return { register, start, stop, pull, push } +}) diff --git a/frontend/src/stores/settingsUi.ts b/frontend/src/stores/settingsUi.ts index e67bc006..fbd26c4b 100644 --- a/frontend/src/stores/settingsUi.ts +++ b/frontend/src/stores/settingsUi.ts @@ -66,8 +66,9 @@ const CAR_COLOR_SCHEME_IDS = CAR_COLOR_SCHEMES.map((s) => s.id) // build's stored value is not thrown away. const MAX_FILTER_DAYS = 365 -// localStorage is user-editable and survives old builds: an unknown value would otherwise be -// applied verbatim (an unstyled theme, a select with no matching option). +// localStorage is user-editable and survives old builds, and the account's copy may have been +// saved by an older or newer build on another device: an unknown value would otherwise be applied +// verbatim (an unstyled theme, a select with no matching option). function oneOf(value: unknown, allowed: readonly T[], fallback: T): T { return (allowed as readonly unknown[]).includes(value) ? (value as T) : fallback } @@ -137,7 +138,16 @@ function applyCarColors(id: string) { export const useUiSettingsStore = defineStore('settingsUi', () => { const settings = reactive(loadUiSettings()) - persistSettings(STORAGE_KEY, settings) + persistSettings({ + storageKey: STORAGE_KEY, + section: 'ui', + state: settings, + parse: (raw) => parseUiFields(raw, defaultsFor()), + // The notification checklist decides whether this browser is subscribed to push + // (useNotificationPushSync), so it stays with the device: unticking everything on one + // computer must not unsubscribe a phone, or subscribe it without a tap there. + localKeys: ['notificationTypeExclusions'], + }) // The theme and the car's colours live on the document, so they follow the setting at once. watch( diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index b7b40bf6..382ee376 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -3,7 +3,6 @@ import { onMounted, onUnmounted, computed, defineAsyncComponent, ref, watch } fr import { useI18n } from 'vue-i18n' import { useVehicleStore } from '@/stores/vehicle' import { cardsHiddenByType, useDashboardSettingsStore } from '@/stores/settingsDashboard' -import { useUiSettingsStore } from '@/stores/settingsUi' import type { CardId } from '@/cards/registry' import { cardHasData, cardIcon } from '@/cards/registry' import { useCardData } from '@/cards/useCardData' @@ -29,7 +28,6 @@ const EditableCardGrid = defineAsyncComponent( const { t } = useI18n() const store = useVehicleStore() const settings = useDashboardSettingsStore() -const uiSettings = useUiSettingsStore() const vin = computed(() => store.activeVin) const status = computed(() => store.currentStatus) @@ -74,11 +72,6 @@ const editableCards = computed({ set: (cards) => settings.setApplicableCards(vehicleType.value, cards), }) -watch( - () => uiSettings.vehicleTypeOverride, - () => settings.applyTypeDefaults(vehicleType.value), -) - const units = useUnits() // Card descriptions that name a unit take it from here; the others ignore these parameters. diff --git a/src/GarageStack.Api/Authentication/SessionPrincipal.cs b/src/GarageStack.Api/Authentication/SessionPrincipal.cs index a92b97af..054a0004 100644 --- a/src/GarageStack.Api/Authentication/SessionPrincipal.cs +++ b/src/GarageStack.Api/Authentication/SessionPrincipal.cs @@ -1,4 +1,6 @@ using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; namespace GarageStack.Api.Authentication; @@ -54,5 +56,21 @@ internal static string ResolveDisplayName(ClaimsPrincipal principal) internal static string? ResolveSubject(ClaimsPrincipal principal) => principal.FindFirst(SubjectClaimType)?.Value ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + /// + /// Who owns what the server keeps for a session (its settings), or null for a session that + /// names no subject. The sign-in method is part of it, so a password account and a provider + /// account that happen to share a name stay apart. Hashed, so the database holds no user name + /// or email address: the password login's subject is often the MG account's email. + /// + internal static string? ResolveAccountKey(ClaimsPrincipal principal) + { + var subject = ResolveSubject(principal); + var method = principal.Identity?.AuthenticationType; + if (string.IsNullOrWhiteSpace(subject) || string.IsNullOrWhiteSpace(method)) + return null; + + return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes($"{method}\n{subject}"))); + } + internal static string NewSessionId() => Guid.NewGuid().ToString("N"); } diff --git a/src/GarageStack.Api/Endpoints/SettingsEndpoints.cs b/src/GarageStack.Api/Endpoints/SettingsEndpoints.cs new file mode 100644 index 00000000..cb2a7eab --- /dev/null +++ b/src/GarageStack.Api/Endpoints/SettingsEndpoints.cs @@ -0,0 +1,102 @@ +using System.Text.Json; +using GarageStack.Api.Authentication; +using GarageStack.Core.Interfaces; +using GarageStack.Core.Models; +using Microsoft.AspNetCore.Mvc; + +namespace GarageStack.Api.Endpoints; + +/// +/// The signed-in account's settings, kept on the server so they follow it to every device. The +/// browser owns what the settings mean; the server keeps them per section and merges each save +/// key by key. +/// +public static class SettingsEndpoints +{ + /// A save carries a few changed keys; even a whole section is a fraction of this. + internal const long MaxRequestBytes = 2L * UserSettingsLimits.JsonMaxLength; + + public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/settings") + .WithTags("Settings") + .RequireAuthorization(); + + group.MapGet("/", async (HttpContext httpContext, IUserSettingsRepository settings, CancellationToken ct) => + { + var account = SessionPrincipal.ResolveAccountKey(httpContext.User); + if (account is null) return NoAccount(); + + return Results.Ok(await settings.GetAsync(account, ct)); + }) + .WithSummary("Get the signed-in account's settings, by section; a section never saved is left out"); + + group.MapPatch("/{section}", async ( + HttpContext httpContext, + string section, + [FromBody] JsonElement changes, + IUserSettingsRepository settings, + CancellationToken ct) => + { + var account = SessionPrincipal.ResolveAccountKey(httpContext.User); + if (account is null) return NoAccount(); + + if (!UserSettingsLimits.Sections.Contains(section)) + return ApiProblems.Problem(StatusCodes.Status404NotFound, "settings.unknownSection", "There is no such settings section"); + + var error = Validate(changes); + if (error is not null) return ApiProblems.BadRequest(error); + + return await settings.MergeAsync(account, section, ToChanges(changes), ct) switch + { + SettingsSaveResult.TooLarge => ApiProblems.Problem(StatusCodes.Status413PayloadTooLarge, "settings.tooLarge", + $"A settings section may hold at most {UserSettingsLimits.JsonMaxLength} characters"), + SettingsSaveResult.Conflict => ApiProblems.Problem(StatusCodes.Status409Conflict, "settings.conflict", + "Other devices were saving the same settings at the same moment; send the change again"), + _ => Results.NoContent(), + }; + }) + .WithMetadata(new RequestSizeLimitAttribute(MaxRequestBytes)) + .WithSummary("Save some of a section's settings; the keys left out keep their saved values"); + + return app; + } + + /// Why a save is refused, or null when it may be merged. + internal static ValidationError? Validate(JsonElement changes) + { + if (changes.ValueKind != JsonValueKind.Object) + return new("settings.notAnObject", "Settings must be a JSON object of the keys to save"); + + var count = 0; + foreach (var property in changes.EnumerateObject()) + { + if (++count > UserSettingsLimits.MaxKeys) + return new("settings.tooManyKeys", $"A save may carry at most {UserSettingsLimits.MaxKeys} keys"); + if (!IsKey(property.Name)) + return new("settings.invalidKey", "Setting keys are letters and digits, starting with a letter"); + } + + return count == 0 ? new("settings.empty", "A save must carry at least one key") : null; + } + + // The browser's own field names: camelCase identifiers. Nothing else is needed, and keeping + // to them rules out keys such as "__proto__" before they ever reach a browser. + private static bool IsKey(string name) => + name.Length is > 0 and <= UserSettingsLimits.KeyMaxLength + && char.IsAsciiLetter(name[0]) + && name.All(char.IsAsciiLetterOrDigit); + + // A key sent twice counts once, the last time, as a JSON parser in the browser would read it. + private static Dictionary ToChanges(JsonElement changes) + { + var byKey = new Dictionary(StringComparer.Ordinal); + foreach (var property in changes.EnumerateObject()) + byKey[property.Name] = property.Value; + return byKey; + } + + private static IResult NoAccount() => + ApiProblems.Problem(StatusCodes.Status403Forbidden, "settings.noAccount", + "This session names no account to keep settings for"); +} diff --git a/src/GarageStack.Api/Program.cs b/src/GarageStack.Api/Program.cs index 9ae6ffe7..009e0309 100644 --- a/src/GarageStack.Api/Program.cs +++ b/src/GarageStack.Api/Program.cs @@ -149,6 +149,7 @@ app.MapHealthEndpoints(); app.MapHub("/hubs/telemetry").RequireAuthorization(); app.MapAuthEndpoints(); + app.MapSettingsEndpoints(); app.MapVehicleEndpoints(); app.MapTripEndpoints(); app.MapPushEndpoints(); diff --git a/src/GarageStack.Core/Helpers/SettingsDocument.cs b/src/GarageStack.Core/Helpers/SettingsDocument.cs new file mode 100644 index 00000000..68552127 --- /dev/null +++ b/src/GarageStack.Core/Helpers/SettingsDocument.cs @@ -0,0 +1,74 @@ +using System.Buffers; +using System.Text; +using System.Text.Json; + +namespace GarageStack.Core.Helpers; + +/// +/// A settings section as stored: a JSON object whose top-level keys a device saves a few at a +/// time. Merging by key is what lets two devices that changed different settings both keep theirs. +/// Values are copied as they came, never re-parsed into a model, since only the browser knows +/// what they mean. +/// +public static class SettingsDocument +{ + private static readonly JsonElement Empty = ParseEmpty(); + + /// + /// The stored section with written over it. Keys the changes leave + /// out keep their stored values. + /// + public static string Merge( + string? stored, + IReadOnlyDictionary changes, + Action? onUnreadable = null) + { + var buffer = new ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartObject(); + foreach (var property in Read(stored, onUnreadable).EnumerateObject()) + { + if (!changes.ContainsKey(property.Name)) property.WriteTo(writer); + } + + foreach (var (key, value) in changes) + { + writer.WritePropertyName(key); + value.WriteTo(writer); + } + + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(buffer.WrittenSpan); + } + + /// + /// The stored section as a JSON object. One that cannot be read (edited by hand in the + /// database, say) counts as empty, so it neither breaks the page nor blocks the next save. + /// + public static JsonElement Read(string? stored, Action? onUnreadable = null) + { + if (string.IsNullOrEmpty(stored)) return Empty; + + try + { + using var document = JsonDocument.Parse(stored); + if (document.RootElement.ValueKind == JsonValueKind.Object) return document.RootElement.Clone(); + onUnreadable?.Invoke(new JsonException("The stored settings are not a JSON object")); + } + catch (JsonException ex) + { + onUnreadable?.Invoke(ex); + } + + return Empty; + } + + private static JsonElement ParseEmpty() + { + using var document = JsonDocument.Parse("{}"); + return document.RootElement.Clone(); + } +} diff --git a/src/GarageStack.Core/Interfaces/IUserSettingsRepository.cs b/src/GarageStack.Core/Interfaces/IUserSettingsRepository.cs new file mode 100644 index 00000000..7a51988b --- /dev/null +++ b/src/GarageStack.Core/Interfaces/IUserSettingsRepository.cs @@ -0,0 +1,24 @@ +using System.Text.Json; +using GarageStack.Core.Models; + +namespace GarageStack.Core.Interfaces; + +/// +/// Keeps each account's settings, a JSON object per section, so they follow the account to every +/// device. Accounts are addressed by their account key, never by name. +/// +public interface IUserSettingsRepository +{ + /// Every section the account has saved, by name. A section never saved is absent. + Task> GetAsync(string accountKey, CancellationToken ct = default); + + /// + /// Writes over the saved section, keeping the keys they leave out. + /// Safe against another device saving the same section at the same moment: both keep their keys. + /// + Task MergeAsync( + string accountKey, + string section, + IReadOnlyDictionary changes, + CancellationToken ct = default); +} diff --git a/src/GarageStack.Core/Models/UserSettings.cs b/src/GarageStack.Core/Models/UserSettings.cs new file mode 100644 index 00000000..4a9ec861 --- /dev/null +++ b/src/GarageStack.Core/Models/UserSettings.cs @@ -0,0 +1,62 @@ +using System.Collections.Frozen; + +namespace GarageStack.Core.Models; + +/// +/// One section ("ui", "dashboard" or "map") of one account's settings: a JSON object of top-level +/// keys, as the browser keeps them. Kept on the server so an account's settings follow it to every +/// device. The server does not interpret the values; the browser checks each one as it reads it, +/// just as it does with its own stored copy. +/// +public class UserSettings +{ + public long Id { get; set; } + + /// + /// Whose settings these are: a hash of the sign-in method and the account's subject, so the + /// table holds no user name or email address. + /// + public string AccountKey { get; set; } = string.Empty; + + public string Section { get; set; } = string.Empty; + + /// The section's settings as a JSON object. + public string Json { get; set; } = "{}"; + + /// Bumped on every save, so two devices saving at once cannot drop each other's keys. + public int Version { get; set; } + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + +public static class UserSettingsLimits +{ + /// The sections the browser keeps. A fixed list, so an account cannot add rows without end. + public static readonly FrozenSet Sections = FrozenSet.Create(StringComparer.Ordinal, "ui", "dashboard", "map"); + + public const int SectionMaxLength = 16; + + /// A SHA-256 hash in hex. + public const int AccountKeyLength = 64; + + public const int KeyMaxLength = 64; + + public const int MaxKeys = 64; + + /// + /// The most one section may hold once a change is merged in: about ten times what the largest, + /// the dashboard's card layout, needs. + /// + public const int JsonMaxLength = 16 * 1024; +} + +public enum SettingsSaveResult +{ + Saved, + + /// The section would grow past ; nothing was saved. + TooLarge, + + /// Other devices kept saving the same section first; nothing was saved, and the browser sends it again. + Conflict, +} diff --git a/src/GarageStack.Data/AppDbContext.cs b/src/GarageStack.Data/AppDbContext.cs index 4cc8c878..007ea966 100644 --- a/src/GarageStack.Data/AppDbContext.cs +++ b/src/GarageStack.Data/AppDbContext.cs @@ -17,6 +17,7 @@ public class AppDbContext(DbContextOptions options) : DbContext(op public DbSet MaintenanceItems => Set(); public DbSet MaintenanceLogEntries => Set(); public DbSet RevokedTokens => Set(); + public DbSet UserSettings => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -180,6 +181,17 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) e.HasIndex(r => r.Jti).IsUnique(); e.Property(r => r.Jti).HasMaxLength(64).IsRequired(); }); + + modelBuilder.Entity(e => + { + e.HasKey(s => s.Id); + // One row per section per account, which is also the lookup every save runs. + e.HasIndex(s => new { s.AccountKey, s.Section }).IsUnique(); + e.Property(s => s.AccountKey).HasMaxLength(UserSettingsLimits.AccountKeyLength).IsRequired(); + e.Property(s => s.Section).HasMaxLength(UserSettingsLimits.SectionMaxLength).IsRequired(); + e.Property(s => s.Json).IsRequired(); + e.Property(s => s.Version).IsConcurrencyToken(); + }); } } diff --git a/src/GarageStack.Data/Extensions/ServiceCollectionExtensions.cs b/src/GarageStack.Data/Extensions/ServiceCollectionExtensions.cs index 1eb008fd..cd630660 100644 --- a/src/GarageStack.Data/Extensions/ServiceCollectionExtensions.cs +++ b/src/GarageStack.Data/Extensions/ServiceCollectionExtensions.cs @@ -26,6 +26,7 @@ public static IServiceCollection AddGarageStackData(this IServiceCollection serv services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddGarageStackPoiClients(); return services; @@ -44,6 +45,8 @@ public static IServiceCollection AddDemoServices(this IServiceCollection service services.AddScoped(); services.AddScoped(); services.AddScoped(); + // The real one: demo settings live in the in-memory database like everything else there. + services.AddScoped(); services.AddGarageStackPoiClients(); return services; diff --git a/src/GarageStack.Data/Migrations/20261005120400_AddUserSettings.Designer.cs b/src/GarageStack.Data/Migrations/20261005120400_AddUserSettings.Designer.cs new file mode 100644 index 00000000..cbeb9944 --- /dev/null +++ b/src/GarageStack.Data/Migrations/20261005120400_AddUserSettings.Designer.cs @@ -0,0 +1,880 @@ +// +using System; +using GarageStack.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GarageStack.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20261005120400_AddUserSettings")] + partial class AddUserSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("GarageStack.Core.Models.AppNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("VehicleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("IsDeleted", "CreatedAt"); + + b.ToTable("AppNotifications"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.GeocodeCacheEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CachedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CellLat") + .HasColumnType("integer"); + + b.Property("CellLng") + .HasColumnType("integer"); + + b.Property("City") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CountryCode") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("DisplayName") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HouseNumber") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Postcode") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Precision") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Road") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("IX_GeocodeCacheEntries_ExpiresAt"); + + b.HasIndex("Precision", "Language", "CellLat", "CellLng") + .IsUnique() + .HasDatabaseName("IX_GeocodeCacheEntries_Precision_Language_CellLatLng"); + + b.ToTable("GeocodeCacheEntries"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.MaintenanceItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IntervalKm") + .HasColumnType("double precision"); + + b.Property("IntervalMonths") + .HasColumnType("integer"); + + b.Property("LastServiceDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastServiceOdometerKm") + .HasColumnType("double precision"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("VehicleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("VehicleId"); + + b.ToTable("MaintenanceItems"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.MaintenanceLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaintenanceItemId") + .HasColumnType("integer"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("OdometerKm") + .HasColumnType("double precision"); + + b.Property("PerformedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("MaintenanceItemId", "PerformedAt"); + + b.ToTable("MaintenanceLogEntries"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.MapMatchCacheEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CachedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MatchedKm") + .HasColumnType("double precision"); + + b.Property("PointIndexesJson") + .HasColumnType("text"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Shape") + .HasColumnType("text"); + + b.Property("SpeedLimitRunsJson") + .HasColumnType("text"); + + b.Property("TraceHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("IX_MapMatchCacheEntries_ExpiresAt"); + + b.HasIndex("Provider", "TraceHash") + .IsUnique() + .HasDatabaseName("IX_MapMatchCacheEntries_Provider_TraceHash"); + + b.ToTable("MapMatchCacheEntries"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.PoiCacheTile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CachedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CellLat") + .HasColumnType("integer"); + + b.Property("CellLng") + .HasColumnType("integer"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PoiType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("IX_PoiCacheTiles_ExpiresAt"); + + b.HasIndex("Source", "PoiType", "CellLat", "CellLng") + .IsUnique() + .HasDatabaseName("IX_PoiCacheTiles_Source_PoiType_CellLatLng"); + + b.ToTable("PoiCacheTiles"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.PoiItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Brand") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CellLat") + .HasColumnType("integer"); + + b.Property("CellLng") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Latitude") + .HasColumnType("double precision"); + + b.Property("Longitude") + .HasColumnType("double precision"); + + b.Property("MetaJson") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("PoiType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Source", "ExternalId") + .IsUnique() + .HasDatabaseName("IX_PoiItems_Source_ExternalId"); + + b.HasIndex("Source", "PoiType", "Brand") + .HasDatabaseName("IX_PoiItems_Source_PoiType_Brand"); + + b.HasIndex("Source", "PoiType", "CellLat", "CellLng") + .HasDatabaseName("IX_PoiItems_Source_PoiType_CellLatLng"); + + b.HasIndex("Source", "PoiType", "Latitude", "Longitude") + .HasDatabaseName("IX_PoiItems_Source_PoiType_LatLon"); + + b.ToTable("PoiItems"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.PushSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("P256DhKey") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Endpoint") + .IsUnique(); + + b.ToTable("PushSubscriptions"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.RevokedToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Jti") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("Jti") + .IsUnique(); + + b.ToTable("RevokedTokens"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.TelemetrySnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BatteryHeating") + .HasColumnType("boolean"); + + b.Property("BatteryHeatingScheduleMode") + .HasColumnType("text"); + + b.Property("BatteryHeatingScheduleStartTime") + .HasColumnType("text"); + + b.Property("BatteryVoltage") + .HasColumnType("double precision"); + + b.Property("BmsChargeStatus") + .HasColumnType("text"); + + b.Property("BonnetOpen") + .HasColumnType("boolean"); + + b.Property("ChargerConnected") + .HasColumnType("boolean"); + + b.Property("ChargingCableLock") + .HasColumnType("boolean"); + + b.Property("ChargingLastEndAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ChargingScheduleEndTime") + .HasColumnType("text"); + + b.Property("ChargingScheduleMode") + .HasColumnType("text"); + + b.Property("ChargingScheduleStartTime") + .HasColumnType("text"); + + b.Property("ChargingType") + .HasColumnType("text"); + + b.Property("ClimateOn") + .HasColumnType("boolean"); + + b.Property("CurrentJourneyDistance") + .HasColumnType("double precision"); + + b.Property("DriverDoorOpen") + .HasColumnType("boolean"); + + b.Property("DriverWindowOpen") + .HasColumnType("boolean"); + + b.Property("ElectricRangeKm") + .HasColumnType("double precision"); + + b.Property("Elevation") + .HasColumnType("double precision"); + + b.Property("EngineRunning") + .HasColumnType("boolean"); + + b.Property("EvSocPercent") + .HasColumnType("double precision"); + + b.Property("ExteriorTemperature") + .HasColumnType("double precision"); + + b.Property("FuelLevelPercent") + .HasColumnType("double precision"); + + b.Property("FuelRangeKm") + .HasColumnType("double precision"); + + b.Property("Heading") + .HasColumnType("double precision"); + + b.Property("HeatedSeatFrontLeft") + .HasColumnType("integer"); + + b.Property("HeatedSeatFrontRight") + .HasColumnType("integer"); + + b.Property("HvBatteryActive") + .HasColumnType("boolean"); + + b.Property("HvCurrent") + .HasColumnType("double precision"); + + b.Property("HvPower") + .HasColumnType("double precision"); + + b.Property("HvSocKwh") + .HasColumnType("double precision"); + + b.Property("HvTotalCapacityKwh") + .HasColumnType("double precision"); + + b.Property("HvVoltage") + .HasColumnType("double precision"); + + b.Property("InteriorTemperature") + .HasColumnType("double precision"); + + b.Property("IsAvailable") + .HasColumnType("boolean"); + + b.Property("IsCharging") + .HasColumnType("boolean"); + + b.Property("IsLocked") + .HasColumnType("boolean"); + + b.Property("LastChargeEndingPower") + .HasColumnType("double precision"); + + b.Property("LastChargeStateAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastVehicleStateAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Latitude") + .HasColumnType("double precision"); + + b.Property("LightsDippedBeam") + .HasColumnType("boolean"); + + b.Property("LightsMainBeam") + .HasColumnType("boolean"); + + b.Property("LightsSide") + .HasColumnType("boolean"); + + b.Property("Longitude") + .HasColumnType("double precision"); + + b.Property("MileageOfTheDay") + .HasColumnType("double precision"); + + b.Property("MileageSinceLastCharge") + .HasColumnType("double precision"); + + b.Property("ObcCurrent") + .HasColumnType("double precision"); + + b.Property("ObcPowerSinglePhase") + .HasColumnType("double precision"); + + b.Property("ObcPowerThreePhase") + .HasColumnType("double precision"); + + b.Property("ObcVoltage") + .HasColumnType("double precision"); + + b.Property("OdometerKm") + .HasColumnType("double precision"); + + b.Property("OffboardChargerPlugStatus") + .HasColumnType("integer"); + + b.Property("OnboardChargerPlugStatus") + .HasColumnType("integer"); + + b.Property("PassengerDoorOpen") + .HasColumnType("boolean"); + + b.Property("PassengerWindowOpen") + .HasColumnType("boolean"); + + b.Property("PowerUsageOfDay") + .HasColumnType("double precision"); + + b.Property("PowerUsageSinceLastCharge") + .HasColumnType("double precision"); + + b.Property("RawTopic") + .HasColumnType("text"); + + b.Property("RearLeftDoorOpen") + .HasColumnType("boolean"); + + b.Property("RearLeftWindowOpen") + .HasColumnType("boolean"); + + b.Property("RearRightDoorOpen") + .HasColumnType("boolean"); + + b.Property("RearRightWindowOpen") + .HasColumnType("boolean"); + + b.Property("RearWindowDefroster") + .HasColumnType("boolean"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RemainingChargingTime") + .HasColumnType("integer"); + + b.Property("RemoteTemperature") + .HasColumnType("double precision"); + + b.Property("Speed") + .HasColumnType("double precision"); + + b.Property("SunRoofOpen") + .HasColumnType("boolean"); + + b.Property("TrunkOpen") + .HasColumnType("boolean"); + + b.Property("TyrePressureFrontLeft") + .HasColumnType("double precision"); + + b.Property("TyrePressureFrontRight") + .HasColumnType("double precision"); + + b.Property("TyrePressureRearLeft") + .HasColumnType("double precision"); + + b.Property("TyrePressureRearRight") + .HasColumnType("double precision"); + + b.Property("VehicleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("VehicleId", "RecordedAt"); + + b.HasIndex("VehicleId", "Latitude", "Longitude", "RecordedAt") + .HasDatabaseName("IX_TelemetrySnapshots_VehicleId_LatLon_RecordedAt") + .HasFilter("\"Latitude\" IS NOT NULL AND \"Longitude\" IS NOT NULL"); + + b.HasIndex(new[] { "VehicleId", "RecordedAt" }, "IX_TelemetrySnapshots_VehicleId_RecordedAt_Schedule") + .HasFilter("\"ChargingScheduleMode\" IS NOT NULL OR \"ChargingScheduleStartTime\" IS NOT NULL OR \"ChargingScheduleEndTime\" IS NOT NULL OR \"BatteryHeatingScheduleMode\" IS NOT NULL OR \"BatteryHeatingScheduleStartTime\" IS NOT NULL"); + + b.ToTable("TelemetrySnapshots"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.Trip", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DistanceKm") + .HasColumnType("double precision"); + + b.Property("EndLatitude") + .HasColumnType("double precision"); + + b.Property("EndLongitude") + .HasColumnType("double precision"); + + b.Property("EndPlaceJson") + .HasColumnType("text"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OdometerEndKm") + .HasColumnType("double precision"); + + b.Property("OdometerStartKm") + .HasColumnType("double precision"); + + b.Property("PointCount") + .HasColumnType("integer"); + + b.Property("PointsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Purpose") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("StartLatitude") + .HasColumnType("double precision"); + + b.Property("StartLongitude") + .HasColumnType("double precision"); + + b.Property("StartPlaceJson") + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VehicleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("VehicleId", "StartedAt") + .IsUnique(); + + b.ToTable("Trips"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.UserSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Json") + .IsRequired() + .HasColumnType("text"); + + b.Property("Section") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountKey", "Section") + .IsUnique(); + + b.ToTable("UserSettings"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.Vehicle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigJson") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastMessageId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastParkedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Model") + .HasColumnType("text"); + + b.Property("SaicUser") + .HasColumnType("text"); + + b.Property("Series") + .HasColumnType("text"); + + b.Property("TelemetryCompactedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("TripsRecordedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("Vin") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("character varying(17)"); + + b.HasKey("Id"); + + b.HasIndex("Vin") + .IsUnique(); + + b.ToTable("Vehicles"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.MaintenanceItem", b => + { + b.HasOne("GarageStack.Core.Models.Vehicle", "Vehicle") + .WithMany() + .HasForeignKey("VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.MaintenanceLogEntry", b => + { + b.HasOne("GarageStack.Core.Models.MaintenanceItem", "MaintenanceItem") + .WithMany("LogEntries") + .HasForeignKey("MaintenanceItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MaintenanceItem"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.TelemetrySnapshot", b => + { + b.HasOne("GarageStack.Core.Models.Vehicle", "Vehicle") + .WithMany("TelemetrySnapshots") + .HasForeignKey("VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.Trip", b => + { + b.HasOne("GarageStack.Core.Models.Vehicle", "Vehicle") + .WithMany() + .HasForeignKey("VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.MaintenanceItem", b => + { + b.Navigation("LogEntries"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.Vehicle", b => + { + b.Navigation("TelemetrySnapshots"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/GarageStack.Data/Migrations/20261005120400_AddUserSettings.cs b/src/GarageStack.Data/Migrations/20261005120400_AddUserSettings.cs new file mode 100644 index 00000000..f6180ada --- /dev/null +++ b/src/GarageStack.Data/Migrations/20261005120400_AddUserSettings.cs @@ -0,0 +1,46 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GarageStack.Data.Migrations +{ + /// + public partial class AddUserSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UserSettings", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + AccountKey = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Section = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + Json = table.Column(type: "text", nullable: false), + Version = table.Column(type: "integer", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserSettings", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_UserSettings_AccountKey_Section", + table: "UserSettings", + columns: new[] { "AccountKey", "Section" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserSettings"); + } + } +} diff --git a/src/GarageStack.Data/Migrations/AppDbContextModelSnapshot.cs b/src/GarageStack.Data/Migrations/AppDbContextModelSnapshot.cs index ce532715..1b02e225 100644 --- a/src/GarageStack.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/GarageStack.Data/Migrations/AppDbContextModelSnapshot.cs @@ -732,6 +732,43 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Trips"); }); + modelBuilder.Entity("GarageStack.Core.Models.UserSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Json") + .IsRequired() + .HasColumnType("text"); + + b.Property("Section") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountKey", "Section") + .IsUnique(); + + b.ToTable("UserSettings"); + }); + modelBuilder.Entity("GarageStack.Core.Models.Vehicle", b => { b.Property("Id") diff --git a/src/GarageStack.Data/Repositories/UserSettingsRepository.cs b/src/GarageStack.Data/Repositories/UserSettingsRepository.cs new file mode 100644 index 00000000..271daa31 --- /dev/null +++ b/src/GarageStack.Data/Repositories/UserSettingsRepository.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using GarageStack.Core.Helpers; +using GarageStack.Core.Interfaces; +using GarageStack.Core.Models; +using GarageStack.Data.Extensions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace GarageStack.Data.Repositories; + +// logger is optional (DI always supplies one) so tests can construct this with just a DbContext. +public class UserSettingsRepository(AppDbContext db, ILogger? logger = null) : IUserSettingsRepository +{ + // Devices saving the same section at the same moment are the only way a save can be beaten to + // its row. Each round of a collision has one winner, so this covers as many devices at once. + private const int MaxAttempts = 5; + + public async Task> GetAsync(string accountKey, CancellationToken ct = default) + { + var rows = await db.UserSettings + .AsNoTracking() + .Where(s => s.AccountKey == accountKey) + .Select(s => new { s.Section, s.Json }) + .ToListAsync(ct); + + return rows.ToDictionary( + row => row.Section, + row => SettingsDocument.Read(row.Json, ex => LogUnreadable(ex, row.Section))); + } + + public async Task MergeAsync( + string accountKey, + string section, + IReadOnlyDictionary changes, + CancellationToken ct = default) + { + for (var attempt = 1; ; attempt++) + { + try + { + return await MergeAttemptAsync(accountKey, section, changes, ct); + } + catch (DbUpdateException ex) when (ex is DbUpdateConcurrencyException || ex.IsUniqueViolation()) + { + if (attempt == MaxAttempts) + { + logger?.LogWarning( + "Settings section {Section} lost {Attempts} saves in a row to other devices; refusing this one", + section, MaxAttempts); + return SettingsSaveResult.Conflict; + } + + // Another device saved this section between the read and the write. Its keys are + // committed now, so merging again into what it saved keeps both devices' changes. + logger?.LogDebug("Settings section {Section} was saved concurrently, merging again", section); + db.ChangeTracker.Clear(); + // A random pause spreads out the devices that collided, so they do not collide again. + await Task.Delay(Random.Shared.Next(10, 50) * attempt, ct); + } + } + } + + private async Task MergeAttemptAsync( + string accountKey, + string section, + IReadOnlyDictionary changes, + CancellationToken ct) + { + var row = await db.UserSettings.FirstOrDefaultAsync(s => s.AccountKey == accountKey && s.Section == section, ct); + + var merged = SettingsDocument.Merge(row?.Json, changes, ex => LogUnreadable(ex, section)); + if (merged.Length > UserSettingsLimits.JsonMaxLength) return SettingsSaveResult.TooLarge; + + if (row is null) + { + row = new UserSettings { AccountKey = accountKey, Section = section }; + db.UserSettings.Add(row); + } + + row.Json = merged; + row.Version++; + row.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(ct); + + return SettingsSaveResult.Saved; + } + + private void LogUnreadable(Exception ex, string section) => + logger?.LogWarning(ex, "Saved settings section {Section} is unreadable, treating it as empty", section); +} diff --git a/src/GarageStack.Tests/AuthenticationFlowTests.cs b/src/GarageStack.Tests/AuthenticationFlowTests.cs index 7ad94865..b944210a 100644 --- a/src/GarageStack.Tests/AuthenticationFlowTests.cs +++ b/src/GarageStack.Tests/AuthenticationFlowTests.cs @@ -497,8 +497,102 @@ public async Task GlobalRateLimit_FailsStartup_WhenTheBudgetLocksEveryoneOut() Assert.Throws(() => CreateClient(factory)); } + // ── Settings kept for the account ───────────────────────────────────────── + + // The demo database outlives each host, so every test saves values of its own rather than + // counting on an account that has never saved anything. + + [Fact] + public async Task Settings_FollowTheAccountToItsOtherDevices_KeyByKey() + { + var ct = TestContext.Current.CancellationToken; + await using var factory = CreatePasswordOnlyFactory(); + using var laptop = CreateClient(factory); + using var phone = CreateClient(factory); + await PasswordLoginAsync(laptop, ct); + await PasswordLoginAsync(phone, ct); + var theme = $"theme-{Guid.NewGuid():N}"; + var locale = $"locale-{Guid.NewGuid():N}"; + + var laptopSave = await laptop.PatchAsJsonAsync("/api/settings/ui", new { theme }, ct); + var phoneSave = await phone.PatchAsJsonAsync("/api/settings/ui", new { locale }, ct); + + Assert.Equal(HttpStatusCode.NoContent, laptopSave.StatusCode); + Assert.Equal(HttpStatusCode.NoContent, phoneSave.StatusCode); + // Each device saved one key, and neither save dropped the other's. + Assert.Equal(theme, await SavedSettingAsync(laptop, "ui", "theme", ct)); + Assert.Equal(locale, await SavedSettingAsync(laptop, "ui", "locale", ct)); + } + + [Fact] + public async Task Settings_OfAProviderAccount_StayApartFromAPasswordAccountOfTheSameName() + { + var ct = TestContext.Current.CancellationToken; + using var idp = new FakeIdentityProvider { Subject = DemoUsername }; + await using var factory = CreateOidcFactory(idp, settings => + { + settings["Auth__Username"] = DemoUsername; + settings["Auth__Password"] = DemoPassword; + settings["Auth__PasswordLoginEnabled"] = "true"; + }); + using var provider = CreateClient(factory); + using var password = CreateClient(factory); + await SignInAsync(provider, idp, "/", ct); + await PasswordLoginAsync(password, ct); + var theme = $"theme-{Guid.NewGuid():N}"; + + var save = await provider.PatchAsJsonAsync("/api/settings/ui", new { theme }, ct); + + Assert.Equal(HttpStatusCode.NoContent, save.StatusCode); + Assert.Equal(theme, await SavedSettingAsync(provider, "ui", "theme", ct)); + Assert.NotEqual(theme, await SavedSettingAsync(password, "ui", "theme", ct)); + } + + [Fact] + public async Task Settings_RefuseVisitorsAndUnknownSections() + { + var ct = TestContext.Current.CancellationToken; + await using var factory = CreatePasswordOnlyFactory(); + using var visitor = CreateClient(factory); + using var client = CreateClient(factory); + await PasswordLoginAsync(client, ct); + + var anonymous = await visitor.GetAsync("/api/settings", ct); + var unknown = await client.PatchAsJsonAsync("/api/settings/secrets", new { theme = "light" }, ct); + var notAnObject = await client.PatchAsJsonAsync("/api/settings/ui", new[] { "light" }, ct); + + Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode); + Assert.Equal("settings.unknownSection", await ProblemCodeAsync(unknown, ct)); + Assert.Equal(HttpStatusCode.BadRequest, notAnObject.StatusCode); + Assert.Equal("settings.notAnObject", await ProblemCodeAsync(notAnObject, ct)); + } + // ── Helpers ─────────────────────────────────────────────────────────────── + private static async Task PasswordLoginAsync(HttpClient client, CancellationToken ct) + { + var login = await client.PostAsJsonAsync( + "/api/auth/login", + new { username = DemoUsername, password = DemoPassword, rememberMe = false }, + ct); + login.EnsureSuccessStatusCode(); + } + + private static async Task SavedSettingAsync(HttpClient client, string section, string key, CancellationToken ct) + { + var sections = await client.GetFromJsonAsync>("/api/settings", ct); + return sections!.TryGetValue(section, out var saved) && saved.TryGetProperty(key, out var value) + ? value.GetString() + : null; + } + + private static async Task ProblemCodeAsync(HttpResponseMessage response, CancellationToken ct) + { + using var problem = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + return problem.RootElement.GetProperty("code").GetString(); + } + /// /// Walks the whole sign-in: challenge, copy the nonce the handler generated into the fake /// provider so its id_token matches, then replay the provider's redirect back to the callback. diff --git a/src/GarageStack.Tests/SettingsDocumentTests.cs b/src/GarageStack.Tests/SettingsDocumentTests.cs new file mode 100644 index 00000000..1bb3fb01 --- /dev/null +++ b/src/GarageStack.Tests/SettingsDocumentTests.cs @@ -0,0 +1,57 @@ +using System.Text.Json; +using GarageStack.Core.Helpers; + +namespace GarageStack.Tests; + +public class SettingsDocumentTests +{ + private static Dictionary Changes(string json) + { + using var document = JsonDocument.Parse(json); + return document.RootElement.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.Clone()); + } + + [Fact] + public void Merge_WritesTheChangesOverTheStoredKeys_AndKeepsTheRest() + { + var merged = SettingsDocument.Merge( + """{"theme":"dark","locale":"nl","units":{"distance":"km","pressure":"bar"}}""", + Changes("""{"theme":"light","units":{"distance":"mi"}}""")); + + using var document = JsonDocument.Parse(merged); + var root = document.RootElement; + Assert.Equal("light", root.GetProperty("theme").GetString()); + Assert.Equal("nl", root.GetProperty("locale").GetString()); + // A key's value is replaced whole, never merged into: the browser always sends it whole. + Assert.Equal("""{"distance":"mi"}""", root.GetProperty("units").GetRawText()); + } + + [Fact] + public void Merge_IntoNothingStored_IsJustTheChanges() + { + var merged = SettingsDocument.Merge(null, Changes("""{"cards":[{"id":"doors","visible":false}],"showTyreDiagram":null}""")); + + Assert.Equal("""{"cards":[{"id":"doors","visible":false}],"showTyreDiagram":null}""", merged); + } + + [Theory] + [InlineData("not json")] + [InlineData("[1,2]")] + [InlineData("42")] + public void Read_SomethingOtherThanAnObject_IsEmpty_AndSaysSo(string stored) + { + Exception? reported = null; + + var read = SettingsDocument.Read(stored, ex => reported = ex); + + Assert.Equal(JsonValueKind.Object, read.ValueKind); + Assert.Empty(read.EnumerateObject()); + Assert.NotNull(reported); + } + + [Fact] + public void Merge_OverAnUnreadableSection_StartsItAfresh() + { + Assert.Equal("""{"theme":"light"}""", SettingsDocument.Merge("{broken", Changes("""{"theme":"light"}"""))); + } +} diff --git a/src/GarageStack.Tests/SettingsEndpointsTests.cs b/src/GarageStack.Tests/SettingsEndpointsTests.cs new file mode 100644 index 00000000..ae4ca3b8 --- /dev/null +++ b/src/GarageStack.Tests/SettingsEndpointsTests.cs @@ -0,0 +1,91 @@ +using System.Security.Claims; +using System.Text.Json; +using GarageStack.Api.Authentication; +using GarageStack.Api.Endpoints; +using GarageStack.Core.Models; + +namespace GarageStack.Tests; + +public class SettingsEndpointsTests +{ + private static string? CodeFor(string json) + { + using var document = JsonDocument.Parse(json); + return SettingsEndpoints.Validate(document.RootElement)?.Code; + } + + [Fact] + public void Validate_AcceptsTheBrowsersOwnKeys() + { + Assert.Null(CodeFor("""{"theme":"light","units":{"distance":"mi"},"statsInsights":[],"chargingMinPowerKw":50}""")); + } + + [Theory] + [InlineData("[]")] + [InlineData("42")] + [InlineData("\"light\"")] + [InlineData("null")] + public void Validate_RefusesAnythingButAnObject(string json) => + Assert.Equal("settings.notAnObject", CodeFor(json)); + + [Fact] + public void Validate_RefusesASaveOfNothing() => + Assert.Equal("settings.empty", CodeFor("{}")); + + [Theory] + [InlineData("__proto__")] + [InlineData("constructor-name")] + [InlineData("1theme")] + [InlineData("the me")] + [InlineData("")] + public void Validate_RefusesKeysThatAreNotPlainIdentifiers(string key) => + Assert.Equal("settings.invalidKey", CodeFor($$"""{"{{key}}":true}""")); + + [Fact] + public void Validate_RefusesAKeyLongerThanTheLimit() => + Assert.Equal("settings.invalidKey", CodeFor($$"""{"{{new string('a', UserSettingsLimits.KeyMaxLength + 1)}}":true}""")); + + [Fact] + public void Validate_RefusesMoreKeysThanASectionMayHold() + { + var keys = Enumerable.Range(0, UserSettingsLimits.MaxKeys + 1).Select(i => $"\"key{i}\":{i}"); + + Assert.Equal("settings.tooManyKeys", CodeFor($"{{{string.Join(',', keys)}}}")); + } + + // ── The account key ─────────────────────────────────────────────────────── + + private static ClaimsPrincipal Session(string? subject, string method) => + SessionPrincipal.Create("someone", subject, method, SessionPrincipal.NewSessionId()); + + [Fact] + public void AccountKey_IsTheSameForEverySessionOfOneAccount() + { + var first = SessionPrincipal.ResolveAccountKey(Session("alice", "OpenIdConnect")); + var second = SessionPrincipal.ResolveAccountKey(Session("alice", "OpenIdConnect")); + + Assert.NotNull(first); + Assert.Equal(first, second); + Assert.Matches("^[0-9a-f]{64}$", first); + } + + [Fact] + public void AccountKey_KeepsAPasswordAccountApartFromAProviderAccountOfTheSameName() + { + Assert.NotEqual( + SessionPrincipal.ResolveAccountKey(Session("demo", "Cookies")), + SessionPrincipal.ResolveAccountKey(Session("demo", "OpenIdConnect"))); + } + + [Fact] + public void AccountKey_HoldsNoTraceOfTheSubject() + { + var key = SessionPrincipal.ResolveAccountKey(Session("driver@example.com", "Cookies")); + + Assert.DoesNotContain("driver", key, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void AccountKey_IsNull_ForASessionWithoutASubject() => + Assert.Null(SessionPrincipal.ResolveAccountKey(Session(null, "OpenIdConnect"))); +} diff --git a/src/GarageStack.Tests/UserSettingsRepositoryTests.cs b/src/GarageStack.Tests/UserSettingsRepositoryTests.cs new file mode 100644 index 00000000..8c433e8c --- /dev/null +++ b/src/GarageStack.Tests/UserSettingsRepositoryTests.cs @@ -0,0 +1,177 @@ +using System.Text.Json; +using GarageStack.Core.Models; +using GarageStack.Data; +using GarageStack.Data.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Storage; + +namespace GarageStack.Tests; + +public class UserSettingsRepositoryTests +{ + private const string Alice = "alice-account-key"; + private const string Bob = "bob-account-key"; + + // Every context of a test shares this root, so they see one database whatever else differs + // between their options (the race test gives one of them an interceptor). + private readonly InMemoryDatabaseRoot _root = new(); + + private AppDbContext NewDb(string name, params IInterceptor[] interceptors) => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(name, _root) + .AddInterceptors(interceptors) + .Options); + + private static Dictionary Changes(string json) + { + using var document = JsonDocument.Parse(json); + return document.RootElement.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.Clone()); + } + + private static async Task ValueAsync(UserSettingsRepository repo, string account, string section, string key, CancellationToken ct) + { + var sections = await repo.GetAsync(account, ct); + return sections.TryGetValue(section, out var saved) && saved.TryGetProperty(key, out var value) + ? value.GetRawText() + : null; + } + + [Fact] + public async Task Get_ForAnAccountThatNeverSaved_IsEmpty() + { + var ct = TestContext.Current.CancellationToken; + await using var db = NewDb(Guid.NewGuid().ToString()); + + Assert.Empty(await new UserSettingsRepository(db).GetAsync(Alice, ct)); + } + + [Fact] + public async Task Merge_CreatesTheSection_ThenKeepsTheKeysALaterSaveLeavesOut() + { + var ct = TestContext.Current.CancellationToken; + await using var db = NewDb(Guid.NewGuid().ToString()); + var repo = new UserSettingsRepository(db); + + Assert.Equal(SettingsSaveResult.Saved, await repo.MergeAsync(Alice, "ui", Changes("""{"theme":"dark","locale":"nl"}"""), ct)); + Assert.Equal(SettingsSaveResult.Saved, await repo.MergeAsync(Alice, "ui", Changes("""{"theme":"light"}"""), ct)); + + Assert.Equal("\"light\"", await ValueAsync(repo, Alice, "ui", "theme", ct)); + Assert.Equal("\"nl\"", await ValueAsync(repo, Alice, "ui", "locale", ct)); + var row = Assert.Single(await db.UserSettings.ToListAsync(ct)); + Assert.Equal(2, row.Version); + } + + [Fact] + public async Task Merge_KeepsAccountsAndSectionsApart() + { + var ct = TestContext.Current.CancellationToken; + await using var db = NewDb(Guid.NewGuid().ToString()); + var repo = new UserSettingsRepository(db); + + await repo.MergeAsync(Alice, "ui", Changes("""{"theme":"light"}"""), ct); + await repo.MergeAsync(Alice, "map", Changes("""{"heatmapEnabled":false}"""), ct); + await repo.MergeAsync(Bob, "ui", Changes("""{"theme":"dark"}"""), ct); + + var alice = await repo.GetAsync(Alice, ct); + Assert.Equal(["map", "ui"], alice.Keys.Order()); + Assert.Equal("\"light\"", await ValueAsync(repo, Alice, "ui", "theme", ct)); + Assert.Equal("\"dark\"", await ValueAsync(repo, Bob, "ui", "theme", ct)); + Assert.Null(await ValueAsync(repo, Bob, "map", "heatmapEnabled", ct)); + } + + [Fact] + public async Task Merge_ThatWouldGrowTheSectionTooLarge_IsRefused_AndKeepsWhatWasSaved() + { + var ct = TestContext.Current.CancellationToken; + await using var db = NewDb(Guid.NewGuid().ToString()); + var repo = new UserSettingsRepository(db); + await repo.MergeAsync(Alice, "map", Changes("""{"heatmapEnabled":false}"""), ct); + var huge = new string('x', UserSettingsLimits.JsonMaxLength); + + var result = await repo.MergeAsync(Alice, "map", Changes($$"""{"fuelBrandFilter":["{{huge}}"]}"""), ct); + + Assert.Equal(SettingsSaveResult.TooLarge, result); + Assert.Null(await ValueAsync(repo, Alice, "map", "fuelBrandFilter", ct)); + Assert.Equal("false", await ValueAsync(repo, Alice, "map", "heatmapEnabled", ct)); + } + + [Fact] + public async Task Merge_ThatRacedAnotherDevice_KeepsBothDevicesKeys() + { + var ct = TestContext.Current.CancellationToken; + var name = Guid.NewGuid().ToString(); + await using (var setup = NewDb(name)) + await new UserSettingsRepository(setup).MergeAsync(Alice, "ui", Changes("""{"theme":"dark"}"""), ct); + + // The phone saves its locale in between this device reading the row and writing it back. + var race = new RaceBeforeSaves(times: 1, async _ => + { + await using var phone = NewDb(name); + await new UserSettingsRepository(phone).MergeAsync(Alice, "ui", Changes("""{"locale":"nl"}"""), ct); + }); + await using var db = NewDb(name, race); + + var result = await new UserSettingsRepository(db).MergeAsync(Alice, "ui", Changes("""{"theme":"light"}"""), ct); + + Assert.Equal(SettingsSaveResult.Saved, result); + await using var check = NewDb(name); + var repo = new UserSettingsRepository(check); + Assert.Equal("\"light\"", await ValueAsync(repo, Alice, "ui", "theme", ct)); + Assert.Equal("\"nl\"", await ValueAsync(repo, Alice, "ui", "locale", ct)); + } + + [Fact] + public async Task Merge_ThatLosesEveryRace_GivesUp_AndLeavesTheWinnersSaves() + { + var ct = TestContext.Current.CancellationToken; + var name = Guid.NewGuid().ToString(); + await using (var setup = NewDb(name)) + await new UserSettingsRepository(setup).MergeAsync(Alice, "ui", Changes("""{"theme":"dark"}"""), ct); + + // Another device gets in first every single time. + var race = new RaceBeforeSaves(times: int.MaxValue, async round => + { + await using var other = NewDb(name); + await new UserSettingsRepository(other).MergeAsync(Alice, "ui", Changes($$"""{"round{{round}}":true}"""), ct); + }); + await using var db = NewDb(name, race); + + var result = await new UserSettingsRepository(db).MergeAsync(Alice, "ui", Changes("""{"theme":"light"}"""), ct); + + Assert.Equal(SettingsSaveResult.Conflict, result); + await using var check = NewDb(name); + var repo = new UserSettingsRepository(check); + Assert.Equal("\"dark\"", await ValueAsync(repo, Alice, "ui", "theme", ct)); + Assert.Equal("true", await ValueAsync(repo, Alice, "ui", "round1", ct)); + } + + [Fact] + public async Task Get_AnUnreadableSection_IsServedEmpty() + { + var ct = TestContext.Current.CancellationToken; + await using var db = NewDb(Guid.NewGuid().ToString()); + db.UserSettings.Add(new UserSettings { AccountKey = Alice, Section = "ui", Json = "{broken" }); + await db.SaveChangesAsync(ct); + + var sections = await new UserSettingsRepository(db).GetAsync(Alice, ct); + + Assert.Empty(sections["ui"].EnumerateObject()); + } + + /// + /// Runs just before each of the first saves it + /// sees go out, passing the round (1, 2, ...). + /// + private sealed class RaceBeforeSaves(int times, Func race) : SaveChangesInterceptor + { + private int _rounds; + + public override async ValueTask> SavingChangesAsync( + DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + if (_rounds < times) await race(++_rounds); + return result; + } + } +}