mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user