feat(auth): add centralized user settings synced across all apps

- Add settings module to mana-core-auth with REST API endpoints
- Create user_settings table with globalSettings and appOverrides (JSONB)
- Add createUserSettingsStore() factory in shared-theme package
- Integrate user settings in all app layouts (calendar, chat, contacts, etc.)
- Support for nav position, theme, locale settings with per-app overrides
- Optimistic updates with localStorage caching for offline support
- Add comprehensive documentation in docs/USER_SETTINGS.md

API Endpoints:
- GET /api/v1/settings - Get all user settings
- PATCH /api/v1/settings/global - Update global settings
- PATCH /api/v1/settings/app/:appId - Set app override
- DELETE /api/v1/settings/app/:appId - Remove app override

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-03 00:09:47 +01:00
parent 0f2aae631d
commit 0e5d923faf
30 changed files with 1624 additions and 7 deletions

View file

@ -6,6 +6,7 @@ import configuration from './config/configuration';
import { AuthModule } from './auth/auth.module';
import { CreditsModule } from './credits/credits.module';
import { FeedbackModule } from './feedback/feedback.module';
import { SettingsModule } from './settings/settings.module';
import { AiModule } from './ai/ai.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
@ -25,6 +26,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
AuthModule,
CreditsModule,
FeedbackModule,
SettingsModule,
],
providers: [
{

View file

@ -124,3 +124,27 @@ export const jwks = authSchema.table('jwks', {
privateKey: text('private_key').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
// User settings table (synced across all apps)
export const userSettings = authSchema.table('user_settings', {
userId: text('user_id')
.primaryKey()
.references(() => users.id, { onDelete: 'cascade' }),
// Global defaults (applies to all apps)
// { nav: { desktopPosition, sidebarCollapsed }, theme: { mode, colorScheme }, locale }
globalSettings: jsonb('global_settings')
.default({
nav: { desktopPosition: 'top', sidebarCollapsed: false },
theme: { mode: 'system', colorScheme: 'ocean' },
locale: 'de',
})
.notNull(),
// Per-app overrides
// { "calendar": { nav: {...}, theme: {...} }, "chat": {...} }
appOverrides: jsonb('app_overrides').default({}).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});

View file

@ -0,0 +1,81 @@
import { IsOptional, IsString, IsObject, ValidateNested, IsBoolean, IsIn } from 'class-validator';
import { Type } from 'class-transformer';
// Nav settings
export class NavSettingsDto {
@IsOptional()
@IsIn(['top', 'bottom'])
desktopPosition?: 'top' | 'bottom';
@IsOptional()
@IsBoolean()
sidebarCollapsed?: boolean;
}
// Theme settings
export class ThemeSettingsDto {
@IsOptional()
@IsIn(['light', 'dark', 'system'])
mode?: 'light' | 'dark' | 'system';
@IsOptional()
@IsString()
colorScheme?: string;
}
// Global settings update
export class UpdateGlobalSettingsDto {
@IsOptional()
@ValidateNested()
@Type(() => NavSettingsDto)
nav?: NavSettingsDto;
@IsOptional()
@ValidateNested()
@Type(() => ThemeSettingsDto)
theme?: ThemeSettingsDto;
@IsOptional()
@IsString()
locale?: string;
}
// App override update
export class UpdateAppOverrideDto {
@IsOptional()
@ValidateNested()
@Type(() => NavSettingsDto)
nav?: NavSettingsDto;
@IsOptional()
@ValidateNested()
@Type(() => ThemeSettingsDto)
theme?: ThemeSettingsDto;
}
// Response types (for documentation)
export interface NavSettings {
desktopPosition: 'top' | 'bottom';
sidebarCollapsed: boolean;
}
export interface ThemeSettings {
mode: 'light' | 'dark' | 'system';
colorScheme: string;
}
export interface GlobalSettings {
nav: NavSettings;
theme: ThemeSettings;
locale: string;
}
export interface AppOverride {
nav?: Partial<NavSettings>;
theme?: Partial<ThemeSettings>;
}
export interface UserSettingsResponse {
globalSettings: GlobalSettings;
appOverrides: Record<string, AppOverride>;
}

View file

@ -0,0 +1,70 @@
import { Controller, Get, Patch, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { SettingsService } from './settings.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
import { UpdateGlobalSettingsDto, UpdateAppOverrideDto } from './dto';
@Controller('settings')
@UseGuards(JwtAuthGuard)
export class SettingsController {
constructor(private readonly settingsService: SettingsService) {}
/**
* GET /api/v1/settings
* Get all user settings (global + app overrides)
*/
@Get()
async getSettings(@CurrentUser() user: CurrentUserData) {
const settings = await this.settingsService.getSettings(user.userId);
return {
success: true,
...settings,
};
}
/**
* PATCH /api/v1/settings/global
* Update global settings (applies to all apps by default)
*/
@Patch('global')
async updateGlobalSettings(
@CurrentUser() user: CurrentUserData,
@Body() dto: UpdateGlobalSettingsDto
) {
const settings = await this.settingsService.updateGlobalSettings(user.userId, dto);
return {
success: true,
...settings,
};
}
/**
* PATCH /api/v1/settings/app/:appId
* Update app-specific override settings
*/
@Patch('app/:appId')
async updateAppOverride(
@CurrentUser() user: CurrentUserData,
@Param('appId') appId: string,
@Body() dto: UpdateAppOverrideDto
) {
const settings = await this.settingsService.updateAppOverride(user.userId, appId, dto);
return {
success: true,
...settings,
};
}
/**
* DELETE /api/v1/settings/app/:appId
* Remove app-specific override (revert to global settings)
*/
@Delete('app/:appId')
async removeAppOverride(@CurrentUser() user: CurrentUserData, @Param('appId') appId: string) {
const settings = await this.settingsService.removeAppOverride(user.userId, appId);
return {
success: true,
...settings,
};
}
}

View file

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { SettingsController } from './settings.controller';
import { SettingsService } from './settings.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
@Module({
controllers: [SettingsController],
providers: [SettingsService, JwtAuthGuard],
exports: [SettingsService],
})
export class SettingsModule {}

View file

@ -0,0 +1,191 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq } from 'drizzle-orm';
import { getDb } from '../db/connection';
import { userSettings } from '../db/schema';
import {
UpdateGlobalSettingsDto,
UpdateAppOverrideDto,
GlobalSettings,
AppOverride,
UserSettingsResponse,
} from './dto';
// Default settings for new users
const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
nav: { desktopPosition: 'top', sidebarCollapsed: false },
theme: { mode: 'system', colorScheme: 'ocean' },
locale: 'de',
};
@Injectable()
export class SettingsService {
private readonly logger = new Logger(SettingsService.name);
constructor(private configService: ConfigService) {}
private getDb() {
const databaseUrl = this.configService.get<string>('database.url');
return getDb(databaseUrl!);
}
/**
* Get user settings, creating defaults if they don't exist
*/
async getSettings(userId: string): Promise<UserSettingsResponse> {
const db = this.getDb();
// Try to get existing settings
const [existing] = await db
.select()
.from(userSettings)
.where(eq(userSettings.userId, userId))
.limit(1);
if (existing) {
return {
globalSettings: existing.globalSettings as GlobalSettings,
appOverrides: existing.appOverrides as Record<string, AppOverride>,
};
}
// Create default settings for new user
const [created] = await db
.insert(userSettings)
.values({
userId,
globalSettings: DEFAULT_GLOBAL_SETTINGS,
appOverrides: {},
})
.returning();
this.logger.debug(`Created default settings for user ${userId}`);
return {
globalSettings: created.globalSettings as GlobalSettings,
appOverrides: created.appOverrides as Record<string, AppOverride>,
};
}
/**
* Update global settings (merges with existing)
*/
async updateGlobalSettings(
userId: string,
dto: UpdateGlobalSettingsDto
): Promise<UserSettingsResponse> {
const db = this.getDb();
// Get current settings
const current = await this.getSettings(userId);
// Deep merge the settings
const updatedGlobal: GlobalSettings = {
nav: { ...current.globalSettings.nav, ...dto.nav },
theme: { ...current.globalSettings.theme, ...dto.theme },
locale: dto.locale ?? current.globalSettings.locale,
};
// Update in database
const [updated] = await db
.update(userSettings)
.set({
globalSettings: updatedGlobal,
updatedAt: new Date(),
})
.where(eq(userSettings.userId, userId))
.returning();
this.logger.debug(`Updated global settings for user ${userId}`);
return {
globalSettings: updated.globalSettings as GlobalSettings,
appOverrides: updated.appOverrides as Record<string, AppOverride>,
};
}
/**
* Update or create app-specific override
*/
async updateAppOverride(
userId: string,
appId: string,
dto: UpdateAppOverrideDto
): Promise<UserSettingsResponse> {
const db = this.getDb();
// Get current settings
const current = await this.getSettings(userId);
// Merge with existing app override
const existingOverride = current.appOverrides[appId] || {};
const updatedOverride: AppOverride = {
nav: dto.nav ? { ...existingOverride.nav, ...dto.nav } : existingOverride.nav,
theme: dto.theme ? { ...existingOverride.theme, ...dto.theme } : existingOverride.theme,
};
// Clean up empty objects
if (updatedOverride.nav && Object.keys(updatedOverride.nav).length === 0) {
delete updatedOverride.nav;
}
if (updatedOverride.theme && Object.keys(updatedOverride.theme).length === 0) {
delete updatedOverride.theme;
}
// Update app overrides
const updatedOverrides = { ...current.appOverrides };
if (Object.keys(updatedOverride).length > 0) {
updatedOverrides[appId] = updatedOverride;
} else {
delete updatedOverrides[appId];
}
// Update in database
const [updated] = await db
.update(userSettings)
.set({
appOverrides: updatedOverrides,
updatedAt: new Date(),
})
.where(eq(userSettings.userId, userId))
.returning();
this.logger.debug(`Updated app override for user ${userId}, app ${appId}`);
return {
globalSettings: updated.globalSettings as GlobalSettings,
appOverrides: updated.appOverrides as Record<string, AppOverride>,
};
}
/**
* Remove app-specific override (revert to global settings)
*/
async removeAppOverride(userId: string, appId: string): Promise<UserSettingsResponse> {
const db = this.getDb();
// Get current settings
const current = await this.getSettings(userId);
// Remove the app override
const updatedOverrides = { ...current.appOverrides };
delete updatedOverrides[appId];
// Update in database
const [updated] = await db
.update(userSettings)
.set({
appOverrides: updatedOverrides,
updatedAt: new Date(),
})
.where(eq(userSettings.userId, userId))
.returning();
this.logger.debug(`Removed app override for user ${userId}, app ${appId}`);
return {
globalSettings: updated.globalSettings as GlobalSettings,
appOverrides: updated.appOverrides as Record<string, AppOverride>,
};
}
}