mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
✨ 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:
parent
0f2aae631d
commit
0e5d923faf
30 changed files with 1624 additions and 7 deletions
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
81
services/mana-core-auth/src/settings/dto/index.ts
Normal file
81
services/mana-core-auth/src/settings/dto/index.ts
Normal 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>;
|
||||
}
|
||||
70
services/mana-core-auth/src/settings/settings.controller.ts
Normal file
70
services/mana-core-auth/src/settings/settings.controller.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
11
services/mana-core-auth/src/settings/settings.module.ts
Normal file
11
services/mana-core-auth/src/settings/settings.module.ts
Normal 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 {}
|
||||
191
services/mana-core-auth/src/settings/settings.service.ts
Normal file
191
services/mana-core-auth/src/settings/settings.service.ts
Normal 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>,
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue