mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
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:
parent
5921cfd257
commit
c6f8b9f87c
11 changed files with 863 additions and 416 deletions
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>) || {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue