fix(planner): road trip mode, the dawarich trail and the route overview follow the account to another browser

The three planner view switches lived in sessionStorage only, so a new tab,
another browser or another device opened every trip on the plain view again.
They are now kept per trip in one user setting, planner_trip_views, validated
against a contract in @trek/shared. The server keeps only switches that are on
and only trips the user can still open, so a deleted trip or one the user left
drops out on the next write. The tab copy in sessionStorage stays and answers
until the settings have loaded.

Refs #2639
This commit is contained in:
Maurice
2026-10-12 17:41:56 +02:00
parent adb88142ab
commit fbba8f96b7
18 changed files with 502 additions and 51 deletions
@@ -12,7 +12,9 @@ import { SettingsService, isAdminOnlyEndpointSetting } from './settings.service'
import { Body, Controller, Get, HttpCode, HttpException, Post, Put, Req, UseGuards } from '@nestjs/common';
import {
MASKED_SETTING_VALUE,
PLANNER_TRIP_VIEWS_KEY,
adminDefaultUserSettingsResponseSchema,
plannerTripViewsSchema,
settingUpsertResponseSchema,
settingsBulkResponseSchema,
settingsListResponseSchema,
@@ -61,6 +63,22 @@ export class SettingsController {
}
}
/**
* The one value with a shape the route checks (#2639): the planner's view
* switches per trip. Clearing it stays open, like every other key.
*/
private assertValidValues(settings: Record<string, unknown>) {
const value = settings[PLANNER_TRIP_VIEWS_KEY];
if (value === undefined || value === null) return;
const parsed = plannerTripViewsSchema.safeParse(value);
if (!parsed.success) {
throw new HttpException(
{ error: `Invalid value for ${PLANNER_TRIP_VIEWS_KEY}: ${parsed.error.issues[0]?.message ?? 'not accepted'}` },
400,
);
}
}
@Get()
@ResponseContract(settingsListResponseSchema)
async list(@CurrentUser() user: User) {
@@ -71,6 +89,7 @@ export class SettingsController {
@ResponseContract(settingUpsertResponseSchema)
async upsert(@CurrentUser() user: User, @Body() body: SettingUpsertDto) {
this.assertMayWriteInstanceEndpoint({ [body.key]: body.value });
this.assertValidValues({ [body.key]: body.value });
// assertMayWriteInstanceEndpoint only covers llm_base_url and provider 'local'.
// llm_api_key and llm_model are writable by every user, and on a managed
// install both cost the operator money, so the key list decides here.
@@ -90,6 +109,7 @@ export class SettingsController {
@ResponseContract(settingsBulkResponseSchema)
async bulk(@CurrentUser() user: User, @Body() body: SettingsBulkDto) {
this.assertMayWriteInstanceEndpoint(body.settings);
this.assertValidValues(body.settings);
const { allowed, blocked } = splitManagedKeys(body.settings, this.env.isManaged());
try {
const updated = await this.settings.bulkUpsertSettings(user.id, allowed);
+4 -2
View File
@@ -1,5 +1,6 @@
import { AppSettings } from '../../db/entities/AppSettings.entity';
import { Settings } from '../../db/entities/Settings.entity';
import { Trips } from '../../db/entities/Trips.entity';
import { AppConfigModule } from '../app-config/app-config.module';
import { AuditModule } from '../audit/audit.module';
import { AdminDefaultUserSettingsController, SettingsController } from './settings.controller';
@@ -19,8 +20,9 @@ import { Module } from '@nestjs/common';
// (nothing resolves it off an ambient MikroORM request context — an
// earlier design tried that and broke ~185 hand-built unit tests, see
// task-5-review.md §4), so nothing in this module ever needs
// `@InjectRepository(Users)`.
imports: [AppConfigModule, AuditModule, MikroOrmModule.forFeature([AppSettings, Settings])],
// `@InjectRepository(Users)`. Trips is here for the planner view row (#2639),
// which keeps only trips the user can still open.
imports: [AppConfigModule, AuditModule, MikroOrmModule.forFeature([AppSettings, Settings, Trips])],
controllers: [SettingsController, AdminDefaultUserSettingsController],
providers: [SettingsService, SettingsMcp],
exports: [SettingsService],
+37 -3
View File
@@ -1,13 +1,23 @@
import { readEnv } from '../../app-config';
import { AppSettings } from '../../db/entities/AppSettings.entity';
import { Settings } from '../../db/entities/Settings.entity';
import { Trips } from '../../db/entities/Trips.entity';
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
import type { SettingsRepository } from '../../db/repositories/Settings.repository';
import type { TripsRepository } from '../../db/repositories/Trips.repository';
import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { UnitOfWork } from '../database/unit-of-work';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import { MASKED_SETTING_VALUE, WEEK_START_VALUES, normalizeAppearance } from '@trek/shared';
import {
MASKED_SETTING_VALUE,
PLANNER_TRIP_VIEWS_KEY,
WEEK_START_VALUES,
compactPlannerTripViews,
normalizeAppearance,
plannerTripViewsSchema,
type PlannerTripViews,
} from '@trek/shared';
/**
* Exported so a caller that hands settings to somebody else can assert its own
@@ -170,8 +180,30 @@ export class SettingsService {
private readonly uow: UnitOfWork,
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
@InjectRepository(Settings) private readonly settings: SettingsRepository,
@InjectRepository(Trips) private readonly trips: TripsRepository,
) {}
/**
* The planner's remembered view switches (#2639), reduced to trips this user
* can still open and to switches that are on. The client only ever adds to
* the row, so this is where a deleted trip, or one the user was removed
* from, leaves it again. Anything the contract does not accept is stored as
* an empty row; the controller has already answered 400 for a request.
*/
private async keepOpenableTripViews(userId: number, value: unknown): Promise<PlannerTripViews | null> {
if (value === null || value === undefined) return null;
const parsed = plannerTripViewsSchema.safeParse(value);
if (!parsed.success) return {};
const views = compactPlannerTripViews(parsed.data);
if (Object.keys(views).length === 0) return views;
const openable = new Set((await this.trips.listAccessibleIds(userId)).map(String));
return Object.fromEntries(Object.entries(views).filter(([tripId]) => openable.has(tripId)));
}
private async prepareValue(userId: number, key: string, value: unknown): Promise<unknown> {
return key === PLANNER_TRIP_VIEWS_KEY ? await this.keepOpenableTripViews(userId, value) : value;
}
async getAdminUserDefaults(): Promise<Record<string, unknown>> {
const rows = await this.appSettings.findByKeyPrefix('default_user_setting_');
const defaults: Record<string, unknown> = {};
@@ -297,7 +329,8 @@ export class SettingsService {
}
async upsertSetting(userId: number, key: string, value: unknown) {
await this.settings.upsertForUser(userId, key, serializeValue(key, value));
const prepared = await this.prepareValue(userId, key, value);
await this.settings.upsertForUser(userId, key, serializeValue(key, prepared));
}
async bulkUpsertSettings(userId: number, settings: Record<string, unknown>) {
@@ -308,7 +341,8 @@ export class SettingsService {
// bulk save can never overwrite a stored secret with the mask (the
// single-upsert route has the same no-op in the controller).
if (value === MASKED_SETTING_VALUE) continue;
await this.settings.upsertForUser(userId, key, serializeValue(key, value));
const prepared = await this.prepareValue(userId, key, value);
await this.settings.upsertForUser(userId, key, serializeValue(key, prepared));
written++;
}
});