feat(settings): add device-specific settings storage

Implement per-device settings sync via mana-core-auth. Settings are now
stored both locally (localStorage) and in the cloud, with each device
(desktop, mobile, tablet) maintaining its own configuration.

Changes:
- Add deviceSettings JSONB column to user_settings table
- Add device API endpoints (GET/PATCH/DELETE /settings/device/:id/:app)
- Extend user-settings-store with device ID generation and detection
- Integrate calendar settings with cloud sync per device
- Remove todos from calendar header row (sidebar + grid only)
- Add hours dropdown to CalendarHeader for time range configuration

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-11 23:49:18 +01:00
parent 5921cfd257
commit c6f8b9f87c
11 changed files with 863 additions and 416 deletions

View file

@ -141,10 +141,14 @@ export const userSettings = authSchema.table('user_settings', {
})
.notNull(),
// Per-app overrides
// Per-app overrides (applies to all devices)
// { "calendar": { nav: {...}, theme: {...} }, "chat": {...} }
appOverrides: jsonb('app_overrides').default({}).notNull(),
// Per-device settings (device-specific app settings)
// { "device-abc-123": { deviceName: "MacBook", deviceType: "desktop", lastSeen: "...", apps: { "calendar": { dayStartHour: 6, ... } } } }
deviceSettings: jsonb('device_settings').default({}).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});

View file

@ -70,6 +70,34 @@ export class UpdateAppOverrideDto {
theme?: ThemeSettingsDto;
}
// Device settings update
export class UpdateDeviceAppSettingsDto {
@IsOptional()
@IsString()
deviceName?: string;
@IsOptional()
@IsIn(['desktop', 'mobile', 'tablet'])
deviceType?: 'desktop' | 'mobile' | 'tablet';
@IsObject()
settings: Record<string, unknown>;
}
// Register/update device info
export class RegisterDeviceDto {
@IsString()
deviceId: string;
@IsOptional()
@IsString()
deviceName?: string;
@IsOptional()
@IsIn(['desktop', 'mobile', 'tablet'])
deviceType?: 'desktop' | 'mobile' | 'tablet';
}
// Response types (for documentation)
export interface NavSettings {
desktopPosition: 'top' | 'bottom';
@ -94,7 +122,29 @@ export interface AppOverride {
theme?: Partial<ThemeSettings>;
}
// Device-specific app settings
export interface DeviceAppSettings {
deviceName: string;
deviceType: 'desktop' | 'mobile' | 'tablet';
lastSeen: string;
apps: Record<string, Record<string, unknown>>;
}
// Device info for listing
export interface DeviceInfo {
deviceId: string;
deviceName: string;
deviceType: 'desktop' | 'mobile' | 'tablet';
lastSeen: string;
appCount: number;
}
export interface UserSettingsResponse {
globalSettings: GlobalSettings;
appOverrides: Record<string, AppOverride>;
deviceSettings: Record<string, DeviceAppSettings>;
}
export interface DevicesListResponse {
devices: DeviceInfo[];
}

View file

@ -3,7 +3,7 @@ import { SettingsService } from './settings.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { CurrentUserData } from '../common/decorators/current-user.decorator';
import { UpdateGlobalSettingsDto } from './dto';
import { UpdateGlobalSettingsDto, UpdateDeviceAppSettingsDto } from './dto';
import type { UpdateAppOverrideDto } from './dto';
@Controller('settings')
@ -13,7 +13,7 @@ export class SettingsController {
/**
* GET /api/v1/settings
* Get all user settings (global + app overrides)
* Get all user settings (global + app overrides + device settings)
*/
@Get()
async getSettings(@CurrentUser() user: CurrentUserData) {
@ -69,4 +69,95 @@ export class SettingsController {
...settings,
};
}
// ============================================================================
// Device Settings Endpoints
// ============================================================================
/**
* GET /api/v1/settings/devices
* List all devices for the current user
*/
@Get('devices')
async getDevices(@CurrentUser() user: CurrentUserData) {
const result = await this.settingsService.getDevices(user.userId);
return {
success: true,
...result,
};
}
/**
* GET /api/v1/settings/device/:deviceId/:appId
* Get settings for a specific device and app
*/
@Get('device/:deviceId/:appId')
async getDeviceAppSettings(
@CurrentUser() user: CurrentUserData,
@Param('deviceId') deviceId: string,
@Param('appId') appId: string
) {
const settings = await this.settingsService.getDeviceAppSettings(user.userId, deviceId, appId);
return {
success: true,
settings,
};
}
/**
* PATCH /api/v1/settings/device/:deviceId/:appId
* Update settings for a specific device and app
*/
@Patch('device/:deviceId/:appId')
async updateDeviceAppSettings(
@CurrentUser() user: CurrentUserData,
@Param('deviceId') deviceId: string,
@Param('appId') appId: string,
@Body() dto: UpdateDeviceAppSettingsDto
) {
const settings = await this.settingsService.updateDeviceAppSettings(
user.userId,
deviceId,
appId,
dto
);
return {
success: true,
...settings,
};
}
/**
* DELETE /api/v1/settings/device/:deviceId
* Remove a device entirely
*/
@Delete('device/:deviceId')
async removeDevice(@CurrentUser() user: CurrentUserData, @Param('deviceId') deviceId: string) {
const settings = await this.settingsService.removeDevice(user.userId, deviceId);
return {
success: true,
...settings,
};
}
/**
* DELETE /api/v1/settings/device/:deviceId/:appId
* Remove app settings from a specific device
*/
@Delete('device/:deviceId/:appId')
async removeDeviceAppSettings(
@CurrentUser() user: CurrentUserData,
@Param('deviceId') deviceId: string,
@Param('appId') appId: string
) {
const settings = await this.settingsService.removeDeviceAppSettings(
user.userId,
deviceId,
appId
);
return {
success: true,
...settings,
};
}
}

View file

@ -6,9 +6,13 @@ import { userSettings } from '../db/schema';
import {
type UpdateGlobalSettingsDto,
type UpdateAppOverrideDto,
type UpdateDeviceAppSettingsDto,
type GlobalSettings,
type AppOverride,
type DeviceAppSettings,
type DeviceInfo,
type UserSettingsResponse,
type DevicesListResponse,
} from './dto';
// Default settings for new users
@ -46,6 +50,7 @@ export class SettingsService {
return {
globalSettings: existing.globalSettings as GlobalSettings,
appOverrides: existing.appOverrides as Record<string, AppOverride>,
deviceSettings: (existing.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
@ -56,6 +61,7 @@ export class SettingsService {
userId,
globalSettings: DEFAULT_GLOBAL_SETTINGS,
appOverrides: {},
deviceSettings: {},
})
.returning();
@ -64,6 +70,7 @@ export class SettingsService {
return {
globalSettings: created.globalSettings as GlobalSettings,
appOverrides: created.appOverrides as Record<string, AppOverride>,
deviceSettings: (created.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
@ -101,6 +108,7 @@ export class SettingsService {
return {
globalSettings: updated.globalSettings as GlobalSettings,
appOverrides: updated.appOverrides as Record<string, AppOverride>,
deviceSettings: (updated.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
@ -155,6 +163,7 @@ export class SettingsService {
return {
globalSettings: updated.globalSettings as GlobalSettings,
appOverrides: updated.appOverrides as Record<string, AppOverride>,
deviceSettings: (updated.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
@ -186,6 +195,185 @@ export class SettingsService {
return {
globalSettings: updated.globalSettings as GlobalSettings,
appOverrides: updated.appOverrides as Record<string, AppOverride>,
deviceSettings: (updated.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
// ============================================================================
// Device Settings Methods
// ============================================================================
/**
* Get list of all devices for a user
*/
async getDevices(userId: string): Promise<DevicesListResponse> {
const current = await this.getSettings(userId);
const deviceSettings = current.deviceSettings || {};
const devices: DeviceInfo[] = Object.entries(deviceSettings).map(([deviceId, device]) => ({
deviceId,
deviceName: device.deviceName || 'Unbekanntes Gerät',
deviceType: device.deviceType || 'desktop',
lastSeen: device.lastSeen || new Date().toISOString(),
appCount: Object.keys(device.apps || {}).length,
}));
// Sort by lastSeen descending
devices.sort((a, b) => new Date(b.lastSeen).getTime() - new Date(a.lastSeen).getTime());
return { devices };
}
/**
* Get settings for a specific device and app
*/
async getDeviceAppSettings(
userId: string,
deviceId: string,
appId: string
): Promise<Record<string, unknown>> {
const current = await this.getSettings(userId);
const deviceSettings = current.deviceSettings || {};
const device = deviceSettings[deviceId];
if (!device || !device.apps || !device.apps[appId]) {
return {};
}
return device.apps[appId];
}
/**
* Update settings for a specific device and app
*/
async updateDeviceAppSettings(
userId: string,
deviceId: string,
appId: string,
dto: UpdateDeviceAppSettingsDto
): Promise<UserSettingsResponse> {
const db = this.getDb();
// Get current settings
const current = await this.getSettings(userId);
const deviceSettings = { ...(current.deviceSettings || {}) };
// Get or create device entry
const existingDevice = deviceSettings[deviceId] || {
deviceName: dto.deviceName || 'Unbekanntes Gerät',
deviceType: dto.deviceType || 'desktop',
lastSeen: new Date().toISOString(),
apps: {},
};
// Update device info if provided
const updatedDevice: DeviceAppSettings = {
deviceName: dto.deviceName || existingDevice.deviceName,
deviceType: dto.deviceType || existingDevice.deviceType,
lastSeen: new Date().toISOString(),
apps: {
...existingDevice.apps,
[appId]: {
...(existingDevice.apps?.[appId] || {}),
...dto.settings,
},
},
};
deviceSettings[deviceId] = updatedDevice;
// Update in database
const [updated] = await db
.update(userSettings)
.set({
deviceSettings,
updatedAt: new Date(),
})
.where(eq(userSettings.userId, userId))
.returning();
this.logger.debug(
`Updated device settings for user ${userId}, device ${deviceId}, app ${appId}`
);
return {
globalSettings: updated.globalSettings as GlobalSettings,
appOverrides: updated.appOverrides as Record<string, AppOverride>,
deviceSettings: (updated.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
/**
* Remove a device entirely
*/
async removeDevice(userId: string, deviceId: string): Promise<UserSettingsResponse> {
const db = this.getDb();
// Get current settings
const current = await this.getSettings(userId);
const deviceSettings = { ...(current.deviceSettings || {}) };
// Remove the device
delete deviceSettings[deviceId];
// Update in database
const [updated] = await db
.update(userSettings)
.set({
deviceSettings,
updatedAt: new Date(),
})
.where(eq(userSettings.userId, userId))
.returning();
this.logger.debug(`Removed device ${deviceId} for user ${userId}`);
return {
globalSettings: updated.globalSettings as GlobalSettings,
appOverrides: updated.appOverrides as Record<string, AppOverride>,
deviceSettings: (updated.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
/**
* Remove app settings from a specific device
*/
async removeDeviceAppSettings(
userId: string,
deviceId: string,
appId: string
): Promise<UserSettingsResponse> {
const db = this.getDb();
// Get current settings
const current = await this.getSettings(userId);
const deviceSettings = { ...(current.deviceSettings || {}) };
if (deviceSettings[deviceId]?.apps) {
const device = { ...deviceSettings[deviceId] };
const apps = { ...device.apps };
delete apps[appId];
device.apps = apps;
device.lastSeen = new Date().toISOString();
deviceSettings[deviceId] = device;
}
// Update in database
const [updated] = await db
.update(userSettings)
.set({
deviceSettings,
updatedAt: new Date(),
})
.where(eq(userSettings.userId, userId))
.returning();
this.logger.debug(`Removed app ${appId} settings from device ${deviceId} for user ${userId}`);
return {
globalSettings: updated.globalSettings as GlobalSettings,
appOverrides: updated.appOverrides as Record<string, AppOverride>,
deviceSettings: (updated.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
}