feat(infra): add mana-sync and mana-notify-go to docker-compose

- mana-sync on port 3051 (Go sync server for local-first apps)
- mana-notify-go on port 3040 (Go notification service)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-27 22:35:05 +01:00
parent 313779f439
commit ef19018e71
60 changed files with 908 additions and 2876 deletions

View file

@ -131,6 +131,7 @@ manacore-monorepo/
├── services/ # Standalone microservices ├── services/ # Standalone microservices
│ ├── mana-core-auth/ # Central authentication service │ ├── mana-core-auth/ # Central authentication service
│ ├── mana-credits/ # Credit system (Hono + Bun, extracted from auth) │ ├── mana-credits/ # Credit system (Hono + Bun, extracted from auth)
│ ├── mana-user/ # User settings, tags, storage (Hono + Bun, extracted from auth)
│ ├── mana-search/ # Central search & content extraction (NestJS, legacy) │ ├── mana-search/ # Central search & content extraction (NestJS, legacy)
│ ├── mana-search-go/ # Central search & content extraction (Go, active) │ ├── mana-search-go/ # Central search & content extraction (Go, active)
│ ├── mana-crawler/ # Web crawler service │ ├── mana-crawler/ # Web crawler service

View file

@ -224,9 +224,9 @@ services:
CONTACTS_BACKEND_URL: http://contacts-backend:3034 CONTACTS_BACKEND_URL: http://contacts-backend:3034
PICTURE_BACKEND_URL: http://picture-backend:3040 PICTURE_BACKEND_URL: http://picture-backend:3040
PRESI_BACKEND_URL: http://presi-backend:3036 PRESI_BACKEND_URL: http://presi-backend:3036
ZITARE_BACKEND_URL: http://zitare-backend:3007 # ZITARE_BACKEND_URL: removed — migrated to local-first
PHOTOS_BACKEND_URL: http://photos-backend:3039 PHOTOS_BACKEND_URL: http://photos-backend:3039
CLOCK_BACKEND_URL: http://clock-backend:3033 # CLOCK_BACKEND_URL: removed — migrated to local-first
STORAGE_BACKEND_URL: http://storage-backend:3035 STORAGE_BACKEND_URL: http://storage-backend:3035
ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY} ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
MANA_LLM_URL: http://mana-llm:3025 MANA_LLM_URL: http://mana-llm:3025
@ -369,6 +369,62 @@ services:
retries: 3 retries: 3
start_period: 5s start_period: 5s
mana-sync:
build:
context: services/mana-sync
dockerfile: Dockerfile
image: mana-sync:local
container_name: mana-core-sync
restart: always
depends_on:
postgres:
condition: service_healthy
environment:
PORT: 3051
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/mana?sslmode=disable
JWKS_URL: http://mana-core-auth:3001/api/v1/auth/jwks
CORS_ORIGINS: "https://mana.how,https://*.mana.how"
ports:
- "3051:3051"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3051/health"]
interval: 120s
timeout: 5s
retries: 3
start_period: 5s
mana-notify:
build:
context: .
dockerfile: services/mana-notify-go/Dockerfile
image: mana-notify:local
container_name: mana-core-notify
restart: always
depends_on:
postgres:
condition: service_healthy
environment:
PORT: 3040
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/mana?sslmode=disable
SERVICE_KEY: ${NOTIFY_SERVICE_KEY:-dev-service-key}
MANA_CORE_AUTH_URL: http://mana-core-auth:3001
SMTP_HOST: ${SMTP_HOST:-smtp-relay.brevo.com}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM: "ManaCore <noreply@mana.how>"
EXPO_ACCESS_TOKEN: ${EXPO_ACCESS_TOKEN:-}
MATRIX_HOMESERVER_URL: http://mana-matrix-synapse:8008
MATRIX_ACCESS_TOKEN: ${MATRIX_NOTIFY_BOT_TOKEN:-}
ports:
- "3040:3040"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3040/health"]
interval: 120s
timeout: 5s
retries: 3
start_period: 5s
mana-crawler: mana-crawler:
build: build:
context: . context: .
@ -559,35 +615,7 @@ services:
retries: 3 retries: 3
start_period: 40s start_period: 40s
clock-backend: # clock-backend: REMOVED — migrated to local-first (mana-sync handles CRUD)
build:
context: .
dockerfile: apps/clock/apps/backend/Dockerfile
image: clock-backend:local
container_name: mana-app-clock-backend
restart: always
depends_on:
mana-auth:
condition: service_healthy
environment:
NODE_ENV: production
PORT: 3033
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/clock
DB_HOST: postgres
DB_PORT: 5432
DB_USER: postgres
MANA_CORE_AUTH_URL: http://mana-auth:3001
CORS_ORIGINS: https://clock.mana.how,https://mana.how
ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
GLITCHTIP_DSN: http://4d5ea890019d4a988e9834bc3e374e0a@glitchtip:8020/7
ports:
- "3033:3033"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3033/health"]
interval: 120s
timeout: 10s
retries: 3
start_period: 50s
contacts-backend: contacts-backend:
build: build:
@ -810,35 +838,7 @@ services:
retries: 3 retries: 3
start_period: 60s start_period: 60s
zitare-backend: # zitare-backend: REMOVED — migrated to local-first (mana-sync handles CRUD)
build:
context: .
dockerfile: apps/zitare/apps/backend/Dockerfile
image: zitare-backend:local
container_name: mana-app-zitare-backend
restart: always
depends_on:
mana-auth:
condition: service_healthy
environment:
NODE_ENV: production
PORT: 3007
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/zitare
DB_HOST: postgres
DB_PORT: 5432
DB_USER: postgres
MANA_CORE_AUTH_URL: http://mana-auth:3001
CORS_ORIGINS: https://zitare.mana.how,https://mana.how
ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
GLITCHTIP_DSN: http://53b871913d864628a8c7cb97b3f69e06@glitchtip:8020/8
ports:
- "3007:3007"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3007/health"]
interval: 120s
timeout: 10s
retries: 3
start_period: 55s
mukke-backend: mukke-backend:
build: build:
@ -1090,9 +1090,9 @@ services:
# Backend URLs # Backend URLs
TODO_BACKEND_URL: http://todo-backend:3031 TODO_BACKEND_URL: http://todo-backend:3031
CALENDAR_BACKEND_URL: http://calendar-backend:3032 CALENDAR_BACKEND_URL: http://calendar-backend:3032
CLOCK_BACKEND_URL: http://clock-backend:3033 # CLOCK_BACKEND_URL: removed — migrated to local-first
CONTACTS_BACKEND_URL: http://contacts-backend:3034 CONTACTS_BACKEND_URL: http://contacts-backend:3034
ZITARE_BACKEND_URL: http://zitare-backend:3007 # ZITARE_BACKEND_URL: removed — migrated to local-first
PLANTA_BACKEND_URL: http://planta-backend:3022 PLANTA_BACKEND_URL: http://planta-backend:3022
NUTRIPHI_BACKEND_URL: http://nutriphi-backend:3037 NUTRIPHI_BACKEND_URL: http://nutriphi-backend:3037
STORAGE_BACKEND_URL: http://storage-backend:3035 STORAGE_BACKEND_URL: http://storage-backend:3035
@ -1199,15 +1199,14 @@ services:
container_name: mana-app-zitare-web container_name: mana-app-zitare-web
restart: always restart: always
depends_on: depends_on:
zitare-backend: mana-auth:
condition: service_healthy condition: service_healthy
environment: environment:
NODE_ENV: production NODE_ENV: production
PORT: 5018 PORT: 5018
PUBLIC_ZITARE_API_URL: http://zitare-backend:3007
PUBLIC_MANA_CORE_AUTH_URL: http://mana-auth:3001 PUBLIC_MANA_CORE_AUTH_URL: http://mana-auth:3001
PUBLIC_ZITARE_API_URL_CLIENT: https://zitare-api.mana.how
PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.mana.how PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.mana.how
PUBLIC_SYNC_SERVER_URL: ws://mana-sync:3050
ports: ports:
- "5018:5018" - "5018:5018"
healthcheck: healthcheck:

View file

@ -11,11 +11,6 @@ import { AuthModule } from './auth/auth.module';
import { FeedbackModule } from './feedback/feedback.module'; import { FeedbackModule } from './feedback/feedback.module';
import { GuildsModule } from './guilds/guilds.module'; import { GuildsModule } from './guilds/guilds.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { SettingsModule } from './settings/settings.module';
import { StorageModule } from './storage/storage.module';
import { TagGroupsModule } from './tag-groups/tag-groups.module';
import { TagLinksModule } from './tag-links/tag-links.module';
import { TagsModule } from './tags/tags.module';
import { MeModule } from './me/me.module'; import { MeModule } from './me/me.module';
import { SubscriptionsModule } from './subscriptions/subscriptions.module'; import { SubscriptionsModule } from './subscriptions/subscriptions.module';
import { StripeModule } from './stripe/stripe.module'; import { StripeModule } from './stripe/stripe.module';
@ -56,11 +51,6 @@ import { SecurityModule } from './security';
FeedbackModule, FeedbackModule,
GuildsModule, GuildsModule,
HealthModule, HealthModule,
SettingsModule,
StorageModule,
TagsModule,
TagGroupsModule,
TagLinksModule,
MeModule, MeModule,
StripeModule, StripeModule,
SubscriptionsModule, SubscriptionsModule,

View file

@ -4,6 +4,3 @@ export * from './feedback.schema';
export * from './login-attempts.schema'; export * from './login-attempts.schema';
export * from './organizations.schema'; export * from './organizations.schema';
export * from './subscriptions.schema'; export * from './subscriptions.schema';
export * from './tag-groups.schema';
export * from './tag-links.schema';
export * from './tags.schema';

View file

@ -1,174 +0,0 @@
import {
IsOptional,
IsString,
IsObject,
ValidateNested,
IsBoolean,
IsIn,
IsArray,
} from 'class-validator';
import { Type } from 'class-transformer';
// Nav settings
export class NavSettingsDto {
@IsOptional()
@IsIn(['top', 'bottom'])
desktopPosition?: 'top' | 'bottom';
@IsOptional()
@IsBoolean()
sidebarCollapsed?: boolean;
@IsOptional()
@IsObject()
hiddenNavItems?: Record<string, string[]>;
}
// Theme settings
export class ThemeSettingsDto {
@IsOptional()
@IsIn(['light', 'dark', 'system'])
mode?: 'light' | 'dark' | 'system';
@IsOptional()
@IsString()
colorScheme?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
pinnedThemes?: string[];
}
// Global settings update
export class UpdateGlobalSettingsDto {
@IsOptional()
@ValidateNested()
@Type(() => NavSettingsDto)
nav?: NavSettingsDto;
@IsOptional()
@ValidateNested()
@Type(() => ThemeSettingsDto)
theme?: ThemeSettingsDto;
@IsOptional()
@IsString()
locale?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
recentEmojis?: string[];
// Profile fields (from onboarding)
@IsOptional()
@IsString()
displayName?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
interests?: string[];
@IsOptional()
@IsBoolean()
onboardingCompleted?: boolean;
}
// App override update
export class UpdateAppOverrideDto {
@IsOptional()
@ValidateNested()
@Type(() => NavSettingsDto)
nav?: NavSettingsDto;
@IsOptional()
@ValidateNested()
@Type(() => ThemeSettingsDto)
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';
sidebarCollapsed: boolean;
hiddenNavItems?: Record<string, string[]>;
}
export interface ThemeSettings {
mode: 'light' | 'dark' | 'system';
colorScheme: string;
pinnedThemes: string[];
}
export interface GlobalSettings {
nav: NavSettings;
theme: ThemeSettings;
locale: string;
recentEmojis?: string[];
// Profile fields (from onboarding)
displayName?: string;
interests?: string[];
onboardingCompleted?: boolean;
}
export interface AppOverride {
nav?: Partial<NavSettings>;
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

@ -1,163 +0,0 @@
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 } from '../common/decorators/current-user.decorator';
import type { CurrentUserData } from '../common/decorators/current-user.decorator';
import { UpdateGlobalSettingsDto, UpdateDeviceAppSettingsDto } from './dto';
import type { 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 + device settings)
*/
@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,
};
}
// ============================================================================
// 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

@ -1,11 +0,0 @@
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

@ -1,384 +0,0 @@
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 {
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
const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
nav: { desktopPosition: 'top', sidebarCollapsed: false },
theme: { mode: 'system', colorScheme: 'ocean', pinnedThemes: [] },
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>,
deviceSettings: (existing.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
// Create default settings for new user
const [created] = await db
.insert(userSettings)
.values({
userId,
globalSettings: DEFAULT_GLOBAL_SETTINGS,
appOverrides: {},
deviceSettings: {},
})
.returning();
this.logger.debug(`Created default settings for user ${userId}`);
return {
globalSettings: created.globalSettings as GlobalSettings,
appOverrides: created.appOverrides as Record<string, AppOverride>,
deviceSettings: (created.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
/**
* 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,
recentEmojis: dto.recentEmojis ?? current.globalSettings.recentEmojis,
// Profile fields
displayName: dto.displayName ?? current.globalSettings.displayName,
interests: dto.interests ?? current.globalSettings.interests,
onboardingCompleted: dto.onboardingCompleted ?? current.globalSettings.onboardingCompleted,
};
// 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>,
deviceSettings: (updated.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
/**
* 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>,
deviceSettings: (updated.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
/**
* 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>,
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>) || {},
};
}
}

View file

@ -1,3 +0,0 @@
export { StorageModule } from './storage.module';
export { StorageService } from './storage.service';
export { StorageController } from './storage.controller';

View file

@ -1,113 +0,0 @@
import {
Controller,
Post,
Body,
UseGuards,
UseInterceptors,
UploadedFile,
BadRequestException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
import { StorageService } from './storage.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';
interface GetUploadUrlDto {
filename: string;
}
@ApiTags('storage')
@Controller('storage')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
export class StorageController {
constructor(private readonly storageService: StorageService) {}
/**
* Get a presigned URL for avatar upload
*
* Returns a presigned URL that the client can use to upload
* the avatar directly to S3/MinIO. This is the recommended approach
* for frontend uploads as it's more efficient.
*/
@Post('avatar/upload-url')
@ApiOperation({
summary: 'Get presigned URL for avatar upload',
description:
'Returns a presigned URL for direct upload to storage. Use this URL to PUT the file.',
})
@ApiResponse({
status: 200,
description: 'Returns presigned upload URL',
schema: {
type: 'object',
properties: {
uploadUrl: { type: 'string', description: 'PUT this URL with the file' },
fileUrl: { type: 'string', description: 'Public URL after upload' },
key: { type: 'string', description: 'Storage key' },
expiresIn: { type: 'number', description: 'URL expires in seconds' },
},
},
})
@ApiResponse({ status: 400, description: 'Invalid file type or storage not configured' })
async getAvatarUploadUrl(
@CurrentUser() user: CurrentUserData,
@Body() dto: GetUploadUrlDto
): Promise<{
uploadUrl: string;
fileUrl: string;
key: string;
expiresIn: number;
}> {
if (!dto.filename) {
throw new BadRequestException('filename is required');
}
return this.storageService.getAvatarUploadUrl(user.userId, dto.filename);
}
/**
* Upload avatar directly (multipart/form-data)
*
* Alternative to presigned URLs. The file is uploaded to the backend
* which then uploads it to S3/MinIO. Simpler but less efficient for
* large files.
*/
@Post('avatar')
@UseInterceptors(
FileInterceptor('file', {
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
},
})
)
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'Upload avatar directly',
description: 'Upload avatar file directly to the server',
})
@ApiResponse({
status: 201,
description: 'Avatar uploaded successfully',
schema: {
type: 'object',
properties: {
url: { type: 'string', description: 'Public URL of the uploaded avatar' },
key: { type: 'string', description: 'Storage key' },
},
},
})
@ApiResponse({ status: 400, description: 'Invalid file type or size' })
async uploadAvatar(
@CurrentUser() user: CurrentUserData,
@UploadedFile() file: Express.Multer.File
): Promise<{ url: string; key: string }> {
if (!file) {
throw new BadRequestException('No file uploaded');
}
return this.storageService.uploadAvatar(user.userId, file.buffer, file.originalname);
}
}

View file

@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { StorageService } from './storage.service';
import { StorageController } from './storage.controller';
@Module({
imports: [ConfigModule],
controllers: [StorageController],
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}

View file

@ -1,121 +0,0 @@
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
createManaCoreStorage,
generateUserFileKey,
getContentType,
validateFileExtension,
IMAGE_EXTENSIONS,
} from '@manacore/shared-storage';
import type { StorageClient } from '@manacore/shared-storage';
const MAX_AVATAR_SIZE = 5 * 1024 * 1024; // 5MB
@Injectable()
export class StorageService {
private readonly logger = new Logger(StorageService.name);
private storage: StorageClient | null = null;
constructor(private readonly configService: ConfigService) {
try {
const publicUrl = this.configService.get<string>('storage.publicUrl');
this.storage = createManaCoreStorage(publicUrl);
this.storage.hooks.on('upload', ({ key, sizeBytes }) => {
this.logger.debug(`Uploaded avatar ${key} (${sizeBytes} bytes)`);
});
this.storage.hooks.on('upload:error', ({ key, error }) => {
this.logger.error(`Avatar upload failed for ${key}: ${error.message}`);
});
this.logger.log('Storage service initialized');
} catch (error) {
this.logger.warn(
'Storage service not configured - avatar uploads will be disabled',
error instanceof Error ? error.message : undefined
);
}
}
/**
* Check if storage is available
*/
isAvailable(): boolean {
return this.storage !== null;
}
/**
* Generate a presigned URL for avatar upload
*/
async getAvatarUploadUrl(
userId: string,
filename: string
): Promise<{
uploadUrl: string;
fileUrl: string;
key: string;
expiresIn: number;
}> {
if (!this.storage) {
throw new BadRequestException('Storage service is not configured');
}
if (!validateFileExtension(filename, IMAGE_EXTENSIONS)) {
throw new BadRequestException(`Invalid file type. Allowed: ${IMAGE_EXTENSIONS.join(', ')}`);
}
const key = generateUserFileKey(userId, filename, 'avatars');
const expiresIn = 3600;
const uploadUrl = await this.storage.getUploadUrl(key, { expiresIn });
const fileUrl = this.storage.getPublicUrl(key) ?? '';
return { uploadUrl, fileUrl, key, expiresIn };
}
/**
* Upload avatar directly (for server-side uploads)
*/
async uploadAvatar(
userId: string,
buffer: Buffer,
filename: string
): Promise<{ url: string; key: string }> {
if (!this.storage) {
throw new BadRequestException('Storage service is not configured');
}
if (!validateFileExtension(filename, IMAGE_EXTENSIONS)) {
throw new BadRequestException(`Invalid file type. Allowed: ${IMAGE_EXTENSIONS.join(', ')}`);
}
const key = generateUserFileKey(userId, filename, 'avatars');
const result = await this.storage.upload(key, buffer, {
contentType: getContentType(filename),
public: true,
maxSizeBytes: MAX_AVATAR_SIZE,
cacheControl: 'public, max-age=31536000, immutable',
});
return { url: result.url ?? this.storage.getPublicUrl(key) ?? '', key };
}
/**
* Delete avatar
*/
async deleteAvatar(key: string): Promise<void> {
if (!this.storage) {
throw new BadRequestException('Storage service is not configured');
}
await this.storage.delete(key);
}
/**
* Delete all avatars for a user (account deletion).
*/
async deleteAllUserAvatars(userId: string): Promise<number> {
if (!this.storage) return 0;
return this.storage.deleteByPrefix(`users/${userId}/`);
}
}

View file

@ -1,32 +0,0 @@
import {
IsString,
IsOptional,
IsNotEmpty,
IsInt,
MinLength,
MaxLength,
Matches,
} from 'class-validator';
export class CreateTagGroupDto {
@IsString()
@IsNotEmpty({ message: 'Group name must not be empty' })
@MinLength(1)
@MaxLength(100)
name: string;
@IsOptional()
@IsString()
@MaxLength(7)
@Matches(/^#[0-9A-Fa-f]{6}$/, { message: 'color must be a valid hex color (e.g., #3B82F6)' })
color?: string;
@IsOptional()
@IsString()
@MaxLength(50)
icon?: string;
@IsOptional()
@IsInt()
sortOrder?: number;
}

View file

@ -1,2 +0,0 @@
export * from './create-tag-group.dto';
export * from './update-tag-group.dto';

View file

@ -1,23 +0,0 @@
import { IsString, IsOptional, IsInt, MaxLength, Matches } from 'class-validator';
export class UpdateTagGroupDto {
@IsOptional()
@IsString()
@MaxLength(100)
name?: string;
@IsOptional()
@IsString()
@MaxLength(7)
@Matches(/^#[0-9A-Fa-f]{6}$/, { message: 'color must be a valid hex color (e.g., #3B82F6)' })
color?: string;
@IsOptional()
@IsString()
@MaxLength(50)
icon?: string;
@IsOptional()
@IsInt()
sortOrder?: number;
}

View file

@ -1,4 +0,0 @@
export * from './tag-groups.module';
export * from './tag-groups.service';
export * from './tag-groups.controller';
export * from './dto';

View file

@ -1,207 +0,0 @@
import { Test } from '@nestjs/testing';
import type { TestingModule } from '@nestjs/testing';
import { NotFoundException, ConflictException } from '@nestjs/common';
import { TagGroupsController } from './tag-groups.controller';
import { TagGroupsService } from './tag-groups.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import type { CurrentUserData } from '../common/decorators/current-user.decorator';
describe('TagGroupsController', () => {
let controller: TagGroupsController;
let tagGroupsService: jest.Mocked<TagGroupsService>;
const mockUser: CurrentUserData = {
userId: 'test-user-id',
email: 'test@example.com',
role: 'user',
};
const mockTagGroup = {
id: 'group-1',
userId: 'test-user-id',
name: 'Kategorien',
color: '#3B82F6',
icon: null,
sortOrder: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
const mockTagGroupsServiceValue = {
findByUserId: jest.fn(),
findById: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
reorder: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [TagGroupsController],
providers: [
{
provide: TagGroupsService,
useValue: mockTagGroupsServiceValue,
},
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: jest.fn(() => true) })
.compile();
controller = module.get<TagGroupsController>(TagGroupsController);
tagGroupsService = module.get(TagGroupsService);
});
afterEach(() => {
jest.clearAllMocks();
});
// ============================================================================
// GET /tag-groups
// ============================================================================
describe('GET /tag-groups', () => {
it('should return all tag groups for the authenticated user', async () => {
const groups = [
mockTagGroup,
{ ...mockTagGroup, id: 'group-2', name: 'Projekte', sortOrder: 1 },
];
tagGroupsService.findByUserId.mockResolvedValue(groups);
const result = await controller.findAll(mockUser);
expect(result).toEqual(groups);
expect(tagGroupsService.findByUserId).toHaveBeenCalledWith('test-user-id');
});
it('should return empty array when user has no groups', async () => {
tagGroupsService.findByUserId.mockResolvedValue([]);
const result = await controller.findAll(mockUser);
expect(result).toEqual([]);
});
});
// ============================================================================
// POST /tag-groups
// ============================================================================
describe('POST /tag-groups', () => {
it('should create a new tag group and return it', async () => {
const createDto = { name: 'Neues Projekt', color: '#10B981' };
const createdGroup = { ...mockTagGroup, ...createDto, id: 'group-new' };
tagGroupsService.create.mockResolvedValue(createdGroup);
const result = await controller.create(mockUser, createDto);
expect(result).toEqual(createdGroup);
expect(tagGroupsService.create).toHaveBeenCalledWith('test-user-id', createDto);
});
it('should propagate ConflictException for duplicate group name', async () => {
const createDto = { name: 'Kategorien' };
tagGroupsService.create.mockRejectedValue(
new ConflictException('Tag group "Kategorien" already exists')
);
await expect(controller.create(mockUser, createDto)).rejects.toThrow(ConflictException);
});
});
// ============================================================================
// PUT /tag-groups/reorder
// ============================================================================
describe('PUT /tag-groups/reorder', () => {
it('should reorder tag groups and return updated list', async () => {
const reorderedGroups = [
{ ...mockTagGroup, id: 'group-2', sortOrder: 0 },
{ ...mockTagGroup, id: 'group-1', sortOrder: 1 },
];
tagGroupsService.reorder.mockResolvedValue(reorderedGroups);
const result = await controller.reorder(mockUser, { ids: ['group-2', 'group-1'] });
expect(result).toEqual(reorderedGroups);
expect(tagGroupsService.reorder).toHaveBeenCalledWith('test-user-id', ['group-2', 'group-1']);
});
it('should propagate NotFoundException when a group ID is invalid', async () => {
tagGroupsService.reorder.mockRejectedValue(
new NotFoundException('One or more tag groups not found')
);
await expect(
controller.reorder(mockUser, { ids: ['group-1', 'nonexistent'] })
).rejects.toThrow(NotFoundException);
});
});
// ============================================================================
// PUT /tag-groups/:id
// ============================================================================
describe('PUT /tag-groups/:id', () => {
it('should update a tag group and return the updated version', async () => {
const updateDto = { name: 'Umbenannt', color: '#EF4444' };
const updatedGroup = { ...mockTagGroup, ...updateDto };
tagGroupsService.update.mockResolvedValue(updatedGroup);
const result = await controller.update(mockUser, 'group-1', updateDto);
expect(result).toEqual(updatedGroup);
expect(tagGroupsService.update).toHaveBeenCalledWith('group-1', 'test-user-id', updateDto);
});
it('should propagate NotFoundException when group does not exist', async () => {
const updateDto = { name: 'Updated' };
tagGroupsService.update.mockRejectedValue(new NotFoundException('Tag group not found'));
await expect(controller.update(mockUser, 'nonexistent', updateDto)).rejects.toThrow(
NotFoundException
);
});
it('should propagate ConflictException when renaming to an existing name', async () => {
const updateDto = { name: 'Kategorien' };
tagGroupsService.update.mockRejectedValue(
new ConflictException('Tag group "Kategorien" already exists')
);
await expect(controller.update(mockUser, 'group-2', updateDto)).rejects.toThrow(
ConflictException
);
});
});
// ============================================================================
// DELETE /tag-groups/:id
// ============================================================================
describe('DELETE /tag-groups/:id', () => {
it('should delete a tag group and return void', async () => {
tagGroupsService.delete.mockResolvedValue(undefined);
const result = await controller.delete(mockUser, 'group-1');
expect(result).toBeUndefined();
expect(tagGroupsService.delete).toHaveBeenCalledWith('group-1', 'test-user-id');
});
it('should propagate NotFoundException when group does not exist', async () => {
tagGroupsService.delete.mockRejectedValue(new NotFoundException('Tag group not found'));
await expect(controller.delete(mockUser, 'nonexistent')).rejects.toThrow(NotFoundException);
});
});
});

View file

@ -1,68 +0,0 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { TagGroupsService } from './tag-groups.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 { CreateTagGroupDto, UpdateTagGroupDto } from './dto';
@Controller('tag-groups')
@UseGuards(JwtAuthGuard)
export class TagGroupsController {
constructor(private readonly tagGroupsService: TagGroupsService) {}
/**
* Get all tag groups for the authenticated user
*/
@Get()
async findAll(@CurrentUser() user: CurrentUserData) {
return this.tagGroupsService.findByUserId(user.userId);
}
/**
* Create a new tag group
*/
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateTagGroupDto) {
return this.tagGroupsService.create(user.userId, dto);
}
/**
* Reorder tag groups
*/
@Put('reorder')
async reorder(@CurrentUser() user: CurrentUserData, @Body() body: { ids: string[] }) {
return this.tagGroupsService.reorder(user.userId, body.ids);
}
/**
* Update an existing tag group
*/
@Put(':id')
async update(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: UpdateTagGroupDto
) {
return this.tagGroupsService.update(id, user.userId, dto);
}
/**
* Delete a tag group (tags in group get groupId = null)
*/
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.tagGroupsService.delete(id, user.userId);
}
}

View file

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { TagGroupsController } from './tag-groups.controller';
import { TagGroupsService } from './tag-groups.service';
@Module({
controllers: [TagGroupsController],
providers: [TagGroupsService],
exports: [TagGroupsService],
})
export class TagGroupsModule {}

View file

@ -1,171 +0,0 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq, and, inArray } from 'drizzle-orm';
import { getDb } from '../db/connection';
import { tagGroups, tags } from '../db/schema';
import { CreateTagGroupDto } from './dto/create-tag-group.dto';
import { UpdateTagGroupDto } from './dto/update-tag-group.dto';
@Injectable()
export class TagGroupsService {
constructor(private configService: ConfigService) {}
private getDb() {
const databaseUrl = this.configService.get<string>('database.url');
return getDb(databaseUrl!);
}
/**
* Get all tag groups for a user, ordered by sortOrder
*/
async findByUserId(userId: string) {
const db = this.getDb();
return db
.select()
.from(tagGroups)
.where(eq(tagGroups.userId, userId))
.orderBy(tagGroups.sortOrder);
}
/**
* Get a single tag group by ID (only if owned by user)
*/
async findById(id: string, userId: string) {
const db = this.getDb();
const [group] = await db
.select()
.from(tagGroups)
.where(and(eq(tagGroups.id, id), eq(tagGroups.userId, userId)))
.limit(1);
return group || null;
}
/**
* Create a new tag group
*/
async create(userId: string, dto: CreateTagGroupDto) {
const db = this.getDb();
// Check for duplicate name
const [existing] = await db
.select()
.from(tagGroups)
.where(and(eq(tagGroups.userId, userId), eq(tagGroups.name, dto.name)))
.limit(1);
if (existing) {
throw new ConflictException(`Tag group "${dto.name}" already exists`);
}
const [group] = await db
.insert(tagGroups)
.values({
userId,
name: dto.name,
color: dto.color || '#3B82F6',
icon: dto.icon || null,
sortOrder: dto.sortOrder ?? 0,
})
.returning();
return group;
}
/**
* Update an existing tag group
*/
async update(id: string, userId: string, dto: UpdateTagGroupDto) {
const db = this.getDb();
// Verify group exists and belongs to user
const [existing] = await db
.select()
.from(tagGroups)
.where(and(eq(tagGroups.id, id), eq(tagGroups.userId, userId)))
.limit(1);
if (!existing) {
throw new NotFoundException(`Tag group not found`);
}
// Check for duplicate name if name is being changed
if (dto.name && dto.name !== existing.name) {
const [duplicate] = await db
.select()
.from(tagGroups)
.where(and(eq(tagGroups.userId, userId), eq(tagGroups.name, dto.name)))
.limit(1);
if (duplicate) {
throw new ConflictException(`Tag group "${dto.name}" already exists`);
}
}
const [group] = await db
.update(tagGroups)
.set({
...dto,
updatedAt: new Date(),
})
.where(and(eq(tagGroups.id, id), eq(tagGroups.userId, userId)))
.returning();
return group;
}
/**
* Delete a tag group. Tags in the group get groupId set to null.
*/
async delete(id: string, userId: string) {
const db = this.getDb();
// Verify group exists and belongs to user
const [existing] = await db
.select()
.from(tagGroups)
.where(and(eq(tagGroups.id, id), eq(tagGroups.userId, userId)))
.limit(1);
if (!existing) {
throw new NotFoundException(`Tag group not found`);
}
// Unlink tags from this group (set groupId to null)
await db
.update(tags)
.set({ groupId: null, updatedAt: new Date() })
.where(and(eq(tags.groupId, id), eq(tags.userId, userId)));
// Delete the group
await db.delete(tagGroups).where(and(eq(tagGroups.id, id), eq(tagGroups.userId, userId)));
}
/**
* Reorder tag groups by providing an ordered array of IDs
*/
async reorder(userId: string, ids: string[]) {
const db = this.getDb();
// Verify all groups belong to user
const userGroups = await db
.select()
.from(tagGroups)
.where(and(eq(tagGroups.userId, userId), inArray(tagGroups.id, ids)));
if (userGroups.length !== ids.length) {
throw new NotFoundException('One or more tag groups not found');
}
// Update sort order for each group
for (let i = 0; i < ids.length; i++) {
await db
.update(tagGroups)
.set({ sortOrder: i, updatedAt: new Date() })
.where(and(eq(tagGroups.id, ids[i]), eq(tagGroups.userId, userId)));
}
// Return updated groups
return this.findByUserId(userId);
}
}

View file

@ -1,44 +0,0 @@
import { IsString, IsUUID, IsArray, MaxLength, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class CreateTagLinkDto {
@IsUUID()
tagId: string;
@IsString()
@MaxLength(50)
appId: string;
@IsString()
@MaxLength(255)
entityId: string;
@IsString()
@MaxLength(100)
entityType: string;
}
export class BulkCreateTagLinksDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateTagLinkDto)
links: CreateTagLinkDto[];
}
export class SyncTagLinksDto {
@IsString()
@MaxLength(50)
appId: string;
@IsString()
@MaxLength(255)
entityId: string;
@IsString()
@MaxLength(100)
entityType: string;
@IsArray()
@IsUUID('4', { each: true })
tagIds: string[];
}

View file

@ -1,2 +0,0 @@
export * from './create-tag-link.dto';
export * from './query-tag-links.dto';

View file

@ -1,32 +0,0 @@
import { IsString, IsOptional, IsUUID, MaxLength } from 'class-validator';
export class QueryTagLinksDto {
@IsOptional()
@IsString()
@MaxLength(50)
appId?: string;
@IsOptional()
@IsString()
@MaxLength(255)
entityId?: string;
@IsOptional()
@IsString()
@MaxLength(100)
entityType?: string;
@IsOptional()
@IsUUID()
tagId?: string;
}
export class GetTagsForEntityDto {
@IsString()
@MaxLength(50)
appId: string;
@IsString()
@MaxLength(255)
entityId: string;
}

View file

@ -1,4 +0,0 @@
export * from './tag-links.module';
export * from './tag-links.service';
export * from './tag-links.controller';
export * from './dto';

View file

@ -1,263 +0,0 @@
import { Test } from '@nestjs/testing';
import type { TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { TagLinksController } from './tag-links.controller';
import { TagLinksService } from './tag-links.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import type { CurrentUserData } from '../common/decorators/current-user.decorator';
describe('TagLinksController', () => {
let controller: TagLinksController;
let tagLinksService: jest.Mocked<TagLinksService>;
const mockUser: CurrentUserData = {
userId: 'test-user-id',
email: 'test@example.com',
role: 'user',
};
const mockTagLink = {
id: 'link-1',
tagId: 'tag-1',
appId: 'todo',
entityId: 'task-1',
entityType: 'task',
userId: 'test-user-id',
createdAt: new Date(),
};
const mockTag = {
id: 'tag-1',
userId: 'test-user-id',
name: 'Arbeit',
color: '#3B82F6',
icon: 'Briefcase',
groupId: null,
sortOrder: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
const mockTagLinksServiceValue = {
create: jest.fn(),
bulkCreate: jest.fn(),
delete: jest.fn(),
query: jest.fn(),
getTagsForEntity: jest.fn(),
sync: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [TagLinksController],
providers: [
{
provide: TagLinksService,
useValue: mockTagLinksServiceValue,
},
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: jest.fn(() => true) })
.compile();
controller = module.get<TagLinksController>(TagLinksController);
tagLinksService = module.get(TagLinksService);
});
afterEach(() => {
jest.clearAllMocks();
});
// ============================================================================
// POST /tag-links
// ============================================================================
describe('POST /tag-links', () => {
it('should create a tag link and return it', async () => {
const createDto = {
tagId: 'tag-1',
appId: 'todo',
entityId: 'task-1',
entityType: 'task',
};
tagLinksService.create.mockResolvedValue(mockTagLink);
const result = await controller.create(mockUser, createDto);
expect(result).toEqual(mockTagLink);
expect(tagLinksService.create).toHaveBeenCalledWith('test-user-id', createDto);
});
it('should propagate NotFoundException when tag does not exist', async () => {
const createDto = {
tagId: 'nonexistent',
appId: 'todo',
entityId: 'task-1',
entityType: 'task',
};
tagLinksService.create.mockRejectedValue(new NotFoundException('Tag not found'));
await expect(controller.create(mockUser, createDto)).rejects.toThrow(NotFoundException);
});
});
// ============================================================================
// POST /tag-links/bulk
// ============================================================================
describe('POST /tag-links/bulk', () => {
it('should bulk create tag links and return them', async () => {
const links = [
{ tagId: 'tag-1', appId: 'todo', entityId: 'task-1', entityType: 'task' },
{ tagId: 'tag-2', appId: 'todo', entityId: 'task-1', entityType: 'task' },
];
const createdLinks = [mockTagLink, { ...mockTagLink, id: 'link-2', tagId: 'tag-2' }];
tagLinksService.bulkCreate.mockResolvedValue(createdLinks);
const result = await controller.bulkCreate(mockUser, { links });
expect(result).toEqual(createdLinks);
expect(tagLinksService.bulkCreate).toHaveBeenCalledWith('test-user-id', links);
});
it('should propagate NotFoundException when one or more tags not found', async () => {
const links = [
{ tagId: 'tag-1', appId: 'todo', entityId: 'task-1', entityType: 'task' },
{ tagId: 'nonexistent', appId: 'todo', entityId: 'task-1', entityType: 'task' },
];
tagLinksService.bulkCreate.mockRejectedValue(
new NotFoundException('One or more tags not found')
);
await expect(controller.bulkCreate(mockUser, { links })).rejects.toThrow(NotFoundException);
});
});
// ============================================================================
// PUT /tag-links/sync
// ============================================================================
describe('PUT /tag-links/sync', () => {
it('should sync entity tags and return updated tag list', async () => {
const syncDto = {
appId: 'todo',
entityId: 'task-1',
entityType: 'task',
tagIds: ['tag-1', 'tag-3'],
};
const updatedTags = [mockTag, { ...mockTag, id: 'tag-3', name: 'Familie' }];
tagLinksService.sync.mockResolvedValue(updatedTags);
const result = await controller.sync(mockUser, syncDto);
expect(result).toEqual(updatedTags);
expect(tagLinksService.sync).toHaveBeenCalledWith('test-user-id', 'todo', 'task-1', 'task', [
'tag-1',
'tag-3',
]);
});
it('should propagate NotFoundException when tags do not belong to user', async () => {
const syncDto = {
appId: 'todo',
entityId: 'task-1',
entityType: 'task',
tagIds: ['nonexistent'],
};
tagLinksService.sync.mockRejectedValue(new NotFoundException('One or more tags not found'));
await expect(controller.sync(mockUser, syncDto)).rejects.toThrow(NotFoundException);
});
});
// ============================================================================
// GET /tag-links/tags-for-entity
// ============================================================================
describe('GET /tag-links/tags-for-entity', () => {
it('should return full tag objects for an entity', async () => {
const entityTags = [mockTag];
tagLinksService.getTagsForEntity.mockResolvedValue(entityTags);
const result = await controller.getTagsForEntity(mockUser, {
appId: 'todo',
entityId: 'task-1',
});
expect(result).toEqual(entityTags);
expect(tagLinksService.getTagsForEntity).toHaveBeenCalledWith(
'test-user-id',
'todo',
'task-1'
);
});
it('should return empty array when entity has no tags', async () => {
tagLinksService.getTagsForEntity.mockResolvedValue([]);
const result = await controller.getTagsForEntity(mockUser, {
appId: 'todo',
entityId: 'task-99',
});
expect(result).toEqual([]);
});
});
// ============================================================================
// GET /tag-links
// ============================================================================
describe('GET /tag-links', () => {
it('should query tag links with filters', async () => {
const links = [mockTagLink];
tagLinksService.query.mockResolvedValue(links);
const queryDto = { appId: 'todo', entityType: 'task' };
const result = await controller.query(mockUser, queryDto);
expect(result).toEqual(links);
expect(tagLinksService.query).toHaveBeenCalledWith('test-user-id', queryDto);
});
it('should return all links when no filters provided', async () => {
const links = [mockTagLink, { ...mockTagLink, id: 'link-2', appId: 'calendar' }];
tagLinksService.query.mockResolvedValue(links);
const result = await controller.query(mockUser, {});
expect(result).toEqual(links);
expect(tagLinksService.query).toHaveBeenCalledWith('test-user-id', {});
});
});
// ============================================================================
// DELETE /tag-links/:id
// ============================================================================
describe('DELETE /tag-links/:id', () => {
it('should delete a tag link and return void', async () => {
tagLinksService.delete.mockResolvedValue(undefined);
const result = await controller.delete(mockUser, 'link-1');
expect(result).toBeUndefined();
expect(tagLinksService.delete).toHaveBeenCalledWith('link-1', 'test-user-id');
});
it('should propagate NotFoundException when link does not exist', async () => {
tagLinksService.delete.mockRejectedValue(new NotFoundException('Tag link not found'));
await expect(controller.delete(mockUser, 'nonexistent')).rejects.toThrow(NotFoundException);
});
});
});

View file

@ -1,87 +0,0 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { TagLinksService } from './tag-links.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 {
CreateTagLinkDto,
BulkCreateTagLinksDto,
SyncTagLinksDto,
} from './dto/create-tag-link.dto';
import { QueryTagLinksDto, GetTagsForEntityDto } from './dto/query-tag-links.dto';
@Controller('tag-links')
@UseGuards(JwtAuthGuard)
export class TagLinksController {
constructor(private readonly tagLinksService: TagLinksService) {}
/**
* Link a tag to an entity
*/
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateTagLinkDto) {
return this.tagLinksService.create(user.userId, dto);
}
/**
* Bulk link tags to entities
*/
@Post('bulk')
async bulkCreate(@CurrentUser() user: CurrentUserData, @Body() dto: BulkCreateTagLinksDto) {
return this.tagLinksService.bulkCreate(user.userId, dto.links);
}
/**
* Sync tags for an entity (replaces all tag links)
*/
@Put('sync')
async sync(@CurrentUser() user: CurrentUserData, @Body() dto: SyncTagLinksDto) {
return this.tagLinksService.sync(
user.userId,
dto.appId,
dto.entityId,
dto.entityType,
dto.tagIds
);
}
/**
* Get full Tag objects for a specific entity
*/
@Get('tags-for-entity')
async getTagsForEntity(
@CurrentUser() user: CurrentUserData,
@Query() query: GetTagsForEntityDto
) {
return this.tagLinksService.getTagsForEntity(user.userId, query.appId, query.entityId);
}
/**
* Query tag links with optional filters
*/
@Get()
async query(@CurrentUser() user: CurrentUserData, @Query() query: QueryTagLinksDto) {
return this.tagLinksService.query(user.userId, query);
}
/**
* Delete a tag link by ID
*/
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.tagLinksService.delete(id, user.userId);
}
}

View file

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { TagLinksController } from './tag-links.controller';
import { TagLinksService } from './tag-links.service';
@Module({
controllers: [TagLinksController],
providers: [TagLinksService],
exports: [TagLinksService],
})
export class TagLinksModule {}

View file

@ -1,241 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq, and, inArray } from 'drizzle-orm';
import { getDb } from '../db/connection';
import { tagLinks, tags } from '../db/schema';
import { CreateTagLinkDto } from './dto/create-tag-link.dto';
import { QueryTagLinksDto } from './dto/query-tag-links.dto';
@Injectable()
export class TagLinksService {
constructor(private configService: ConfigService) {}
private getDb() {
const databaseUrl = this.configService.get<string>('database.url');
return getDb(databaseUrl!);
}
/**
* Link a tag to an entity
*/
async create(userId: string, dto: CreateTagLinkDto) {
const db = this.getDb();
// Verify tag belongs to user
const [tag] = await db
.select()
.from(tags)
.where(and(eq(tags.id, dto.tagId), eq(tags.userId, userId)))
.limit(1);
if (!tag) {
throw new NotFoundException('Tag not found');
}
const [link] = await db
.insert(tagLinks)
.values({
tagId: dto.tagId,
appId: dto.appId,
entityId: dto.entityId,
entityType: dto.entityType,
userId,
})
.onConflictDoNothing()
.returning();
// If conflict (already exists), return the existing link
if (!link) {
const [existing] = await db
.select()
.from(tagLinks)
.where(
and(
eq(tagLinks.tagId, dto.tagId),
eq(tagLinks.appId, dto.appId),
eq(tagLinks.entityId, dto.entityId)
)
)
.limit(1);
return existing;
}
return link;
}
/**
* Bulk link tags to entities
*/
async bulkCreate(userId: string, dtos: CreateTagLinkDto[]) {
if (dtos.length === 0) return [];
const db = this.getDb();
// Verify all tags belong to user
const tagIds = [...new Set(dtos.map((d) => d.tagId))];
const userTags = await db
.select()
.from(tags)
.where(and(inArray(tags.id, tagIds), eq(tags.userId, userId)));
if (userTags.length !== tagIds.length) {
throw new NotFoundException('One or more tags not found');
}
const values = dtos.map((dto) => ({
tagId: dto.tagId,
appId: dto.appId,
entityId: dto.entityId,
entityType: dto.entityType,
userId,
}));
const links = await db.insert(tagLinks).values(values).onConflictDoNothing().returning();
return links;
}
/**
* Delete a tag link by ID
*/
async delete(id: string, userId: string) {
const db = this.getDb();
const [existing] = await db
.select()
.from(tagLinks)
.where(and(eq(tagLinks.id, id), eq(tagLinks.userId, userId)))
.limit(1);
if (!existing) {
throw new NotFoundException('Tag link not found');
}
await db.delete(tagLinks).where(and(eq(tagLinks.id, id), eq(tagLinks.userId, userId)));
}
/**
* Query tag links with optional filters
*/
async query(userId: string, query: QueryTagLinksDto) {
const db = this.getDb();
const conditions = [eq(tagLinks.userId, userId)];
if (query.appId) {
conditions.push(eq(tagLinks.appId, query.appId));
}
if (query.entityId) {
conditions.push(eq(tagLinks.entityId, query.entityId));
}
if (query.entityType) {
conditions.push(eq(tagLinks.entityType, query.entityType));
}
if (query.tagId) {
conditions.push(eq(tagLinks.tagId, query.tagId));
}
return db
.select()
.from(tagLinks)
.where(and(...conditions));
}
/**
* Get full Tag objects for a specific entity (joins with tags table)
*/
async getTagsForEntity(userId: string, appId: string, entityId: string) {
const db = this.getDb();
const results = await db
.select({
id: tags.id,
userId: tags.userId,
name: tags.name,
color: tags.color,
icon: tags.icon,
groupId: tags.groupId,
sortOrder: tags.sortOrder,
createdAt: tags.createdAt,
updatedAt: tags.updatedAt,
})
.from(tagLinks)
.innerJoin(tags, eq(tagLinks.tagId, tags.id))
.where(
and(eq(tagLinks.userId, userId), eq(tagLinks.appId, appId), eq(tagLinks.entityId, entityId))
);
return results;
}
/**
* Sync tags for an entity: adds missing links, removes extra ones.
* Wrapped in a transaction to prevent race conditions.
*/
async sync(
userId: string,
appId: string,
entityId: string,
entityType: string,
tagIds: string[]
) {
const db = this.getDb();
// Verify all tags belong to user (before transaction)
if (tagIds.length > 0) {
const userTags = await db
.select()
.from(tags)
.where(and(inArray(tags.id, tagIds), eq(tags.userId, userId)));
if (userTags.length !== tagIds.length) {
throw new NotFoundException('One or more tags not found');
}
}
await db.transaction(async (tx) => {
// Get current links for this entity
const currentLinks = await tx
.select()
.from(tagLinks)
.where(
and(
eq(tagLinks.userId, userId),
eq(tagLinks.appId, appId),
eq(tagLinks.entityId, entityId)
)
);
const currentTagIds = currentLinks.map((l) => l.tagId);
const toAdd = tagIds.filter((id) => !currentTagIds.includes(id));
const toRemove = currentLinks.filter((l) => !tagIds.includes(l.tagId));
// Add missing links
if (toAdd.length > 0) {
await tx
.insert(tagLinks)
.values(
toAdd.map((tagId) => ({
tagId,
appId,
entityId,
entityType,
userId,
}))
)
.onConflictDoNothing();
}
// Remove extra links
if (toRemove.length > 0) {
const removeIds = toRemove.map((l) => l.id);
await tx
.delete(tagLinks)
.where(and(inArray(tagLinks.id, removeIds), eq(tagLinks.userId, userId)));
}
});
// Return updated tags for entity (after transaction commits)
return this.getTagsForEntity(userId, appId, entityId);
}
}

View file

@ -1,37 +0,0 @@
import {
IsString,
IsOptional,
IsNotEmpty,
IsUUID,
IsInt,
MinLength,
MaxLength,
Matches,
} from 'class-validator';
export class CreateTagDto {
@IsString()
@IsNotEmpty({ message: 'Tag name must not be empty' })
@MinLength(1)
@MaxLength(100)
name: string;
@IsOptional()
@IsString()
@MaxLength(7)
@Matches(/^#[0-9A-Fa-f]{6}$/, { message: 'color must be a valid hex color (e.g., #3B82F6)' })
color?: string;
@IsOptional()
@IsString()
@MaxLength(50)
icon?: string;
@IsOptional()
@IsUUID()
groupId?: string;
@IsOptional()
@IsInt()
sortOrder?: number;
}

View file

@ -1,2 +0,0 @@
export * from './create-tag.dto';
export * from './update-tag.dto';

View file

@ -1,27 +0,0 @@
import { IsString, IsOptional, IsUUID, IsInt, MaxLength, Matches } from 'class-validator';
export class UpdateTagDto {
@IsOptional()
@IsString()
@MaxLength(100)
name?: string;
@IsOptional()
@IsString()
@MaxLength(7)
@Matches(/^#[0-9A-Fa-f]{6}$/, { message: 'color must be a valid hex color (e.g., #3B82F6)' })
color?: string;
@IsOptional()
@IsString()
@MaxLength(50)
icon?: string;
@IsOptional()
@IsUUID()
groupId?: string | null;
@IsOptional()
@IsInt()
sortOrder?: number;
}

View file

@ -1,4 +0,0 @@
export * from './tags.module';
export * from './tags.service';
export * from './tags.controller';
export * from './dto';

View file

@ -1,241 +0,0 @@
import { Test } from '@nestjs/testing';
import type { TestingModule } from '@nestjs/testing';
import { NotFoundException, ConflictException } from '@nestjs/common';
import { TagsController } from './tags.controller';
import { TagsService } from './tags.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import type { CurrentUserData } from '../common/decorators/current-user.decorator';
describe('TagsController', () => {
let controller: TagsController;
let tagsService: jest.Mocked<TagsService>;
const mockUser: CurrentUserData = {
userId: 'test-user-id',
email: 'test@example.com',
role: 'user',
};
const mockTag = {
id: 'tag-1',
userId: 'test-user-id',
name: 'Arbeit',
color: '#3B82F6',
icon: 'Briefcase',
groupId: null,
sortOrder: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
const mockTagsServiceValue = {
findByUserId: jest.fn(),
findById: jest.fn(),
getByIds: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
createDefaultTags: jest.fn(),
findByGroupId: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [TagsController],
providers: [
{
provide: TagsService,
useValue: mockTagsServiceValue,
},
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: jest.fn(() => true) })
.compile();
controller = module.get<TagsController>(TagsController);
tagsService = module.get(TagsService);
});
afterEach(() => {
jest.clearAllMocks();
});
// ============================================================================
// GET /tags
// ============================================================================
describe('GET /tags', () => {
it('should return all tags for the authenticated user', async () => {
const userTags = [
mockTag,
{ ...mockTag, id: 'tag-2', name: 'Persönlich', color: '#10B981', icon: 'User' },
];
tagsService.findByUserId.mockResolvedValue(userTags);
const result = await controller.findAll(mockUser);
expect(result).toEqual(userTags);
expect(tagsService.findByUserId).toHaveBeenCalledWith('test-user-id');
});
it('should return empty array when user has no tags', async () => {
tagsService.findByUserId.mockResolvedValue([]);
const result = await controller.findAll(mockUser);
expect(result).toEqual([]);
});
});
// ============================================================================
// GET /tags/by-ids
// ============================================================================
describe('GET /tags/by-ids', () => {
it('should resolve tag IDs to full tag objects', async () => {
const resolvedTags = [mockTag];
tagsService.getByIds.mockResolvedValue(resolvedTags);
const result = await controller.getByIds(mockUser, 'tag-1,tag-2');
expect(result).toEqual(resolvedTags);
expect(tagsService.getByIds).toHaveBeenCalledWith(['tag-1', 'tag-2'], 'test-user-id');
});
it('should return empty array when no ids provided', async () => {
const result = await controller.getByIds(mockUser, undefined);
expect(result).toEqual([]);
expect(tagsService.getByIds).not.toHaveBeenCalled();
});
it('should return empty array when ids is empty string', async () => {
const result = await controller.getByIds(mockUser, '');
expect(result).toEqual([]);
expect(tagsService.getByIds).not.toHaveBeenCalled();
});
});
// ============================================================================
// GET /tags/:id
// ============================================================================
describe('GET /tags/:id', () => {
it('should return a single tag by ID', async () => {
tagsService.findById.mockResolvedValue(mockTag);
const result = await controller.findOne(mockUser, 'tag-1');
expect(result).toEqual(mockTag);
expect(tagsService.findById).toHaveBeenCalledWith('tag-1', 'test-user-id');
});
it('should return null when tag not found', async () => {
tagsService.findById.mockResolvedValue(null as any);
const result = await controller.findOne(mockUser, 'nonexistent');
expect(result).toBeNull();
});
});
// ============================================================================
// POST /tags
// ============================================================================
describe('POST /tags', () => {
it('should create a new tag and return it', async () => {
const createDto = { name: 'Neuer Tag', color: '#FF5733', icon: 'Star' };
const createdTag = { ...mockTag, ...createDto, id: 'tag-new' };
tagsService.create.mockResolvedValue(createdTag);
const result = await controller.create(mockUser, createDto);
expect(result).toEqual(createdTag);
expect(tagsService.create).toHaveBeenCalledWith('test-user-id', createDto);
});
it('should propagate ConflictException for duplicate tag name', async () => {
const createDto = { name: 'Arbeit' };
tagsService.create.mockRejectedValue(new ConflictException('Tag "Arbeit" already exists'));
await expect(controller.create(mockUser, createDto)).rejects.toThrow(ConflictException);
});
});
// ============================================================================
// POST /tags/defaults
// ============================================================================
describe('POST /tags/defaults', () => {
it('should create default tags for the user', async () => {
const defaultTags = [
{ ...mockTag, name: 'Arbeit' },
{ ...mockTag, id: 'tag-2', name: 'Persönlich' },
{ ...mockTag, id: 'tag-3', name: 'Familie' },
{ ...mockTag, id: 'tag-4', name: 'Wichtig' },
];
tagsService.createDefaultTags.mockResolvedValue(defaultTags);
const result = await controller.createDefaults(mockUser);
expect(result).toEqual(defaultTags);
expect(tagsService.createDefaultTags).toHaveBeenCalledWith('test-user-id');
});
});
// ============================================================================
// PUT /tags/:id
// ============================================================================
describe('PUT /tags/:id', () => {
it('should update a tag and return the updated version', async () => {
const updateDto = { name: 'Aktualisiert', color: '#000000' };
const updatedTag = { ...mockTag, ...updateDto };
tagsService.update.mockResolvedValue(updatedTag);
const result = await controller.update(mockUser, 'tag-1', updateDto);
expect(result).toEqual(updatedTag);
expect(tagsService.update).toHaveBeenCalledWith('tag-1', 'test-user-id', updateDto);
});
it('should propagate NotFoundException when tag does not exist', async () => {
const updateDto = { name: 'Updated' };
tagsService.update.mockRejectedValue(new NotFoundException('Tag not found'));
await expect(controller.update(mockUser, 'nonexistent', updateDto)).rejects.toThrow(
NotFoundException
);
});
});
// ============================================================================
// DELETE /tags/:id
// ============================================================================
describe('DELETE /tags/:id', () => {
it('should delete a tag and return void', async () => {
tagsService.delete.mockResolvedValue(undefined);
const result = await controller.delete(mockUser, 'tag-1');
expect(result).toBeUndefined();
expect(tagsService.delete).toHaveBeenCalledWith('tag-1', 'test-user-id');
});
it('should propagate NotFoundException when tag does not exist', async () => {
tagsService.delete.mockRejectedValue(new NotFoundException('Tag not found'));
await expect(controller.delete(mockUser, 'nonexistent')).rejects.toThrow(NotFoundException);
});
});
});

View file

@ -1,92 +0,0 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { TagsService } from './tags.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 { CreateTagDto, UpdateTagDto } from './dto';
@Controller('tags')
@UseGuards(JwtAuthGuard)
export class TagsController {
constructor(private readonly tagsService: TagsService) {}
/**
* Get all tags for the authenticated user
*/
@Get()
async findAll(@CurrentUser() user: CurrentUserData) {
return this.tagsService.findByUserId(user.userId);
}
/**
* Get multiple tags by IDs
* Used by apps to resolve tagIds to full tag objects
* Query: ?ids=id1,id2,id3
*/
@Get('by-ids')
async getByIds(@CurrentUser() user: CurrentUserData, @Query('ids') ids?: string) {
if (!ids) {
return [];
}
const idArray = ids.split(',').filter((id) => id.trim());
return this.tagsService.getByIds(idArray, user.userId);
}
/**
* Get a single tag by ID
*/
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.tagsService.findById(id, user.userId);
}
/**
* Create a new tag
*/
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() createTagDto: CreateTagDto) {
return this.tagsService.create(user.userId, createTagDto);
}
/**
* Create default tags for the user (if not already created)
* Called on first access or explicitly
*/
@Post('defaults')
async createDefaults(@CurrentUser() user: CurrentUserData) {
return this.tagsService.createDefaultTags(user.userId);
}
/**
* Update an existing tag
*/
@Put(':id')
async update(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() updateTagDto: UpdateTagDto
) {
return this.tagsService.update(id, user.userId, updateTagDto);
}
/**
* Delete a tag
*/
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.tagsService.delete(id, user.userId);
}
}

View file

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { TagsController } from './tags.controller';
import { TagsService } from './tags.service';
@Module({
controllers: [TagsController],
providers: [TagsService],
exports: [TagsService],
})
export class TagsModule {}

View file

@ -1,197 +0,0 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq, and, inArray } from 'drizzle-orm';
import { getDb } from '../db/connection';
import { tags } from '../db/schema';
import { CreateTagDto } from './dto/create-tag.dto';
import { UpdateTagDto } from './dto/update-tag.dto';
// Default tags created for new users
const DEFAULT_TAGS = [
{ name: 'Arbeit', color: '#3B82F6', icon: 'Briefcase' },
{ name: 'Persönlich', color: '#10B981', icon: 'User' },
{ name: 'Familie', color: '#EC4899', icon: 'Heart' },
{ name: 'Wichtig', color: '#EF4444', icon: 'Star' },
];
@Injectable()
export class TagsService {
constructor(private configService: ConfigService) {}
private getDb() {
const databaseUrl = this.configService.get<string>('database.url');
return getDb(databaseUrl!);
}
/**
* Get all tags for a user
*/
async findByUserId(userId: string) {
const db = this.getDb();
return db.select().from(tags).where(eq(tags.userId, userId));
}
/**
* Get a single tag by ID (only if owned by user)
*/
async findById(id: string, userId: string) {
const db = this.getDb();
const [tag] = await db
.select()
.from(tags)
.where(and(eq(tags.id, id), eq(tags.userId, userId)))
.limit(1);
return tag || null;
}
/**
* Get multiple tags by IDs (only those owned by user)
* Used by apps to resolve tagIds to full tag objects
*/
async getByIds(ids: string[], userId: string) {
if (ids.length === 0) return [];
const db = this.getDb();
return db
.select()
.from(tags)
.where(and(inArray(tags.id, ids), eq(tags.userId, userId)));
}
/**
* Create a new tag
*/
async create(userId: string, dto: CreateTagDto) {
const db = this.getDb();
// Check for duplicate name
const [existing] = await db
.select()
.from(tags)
.where(and(eq(tags.userId, userId), eq(tags.name, dto.name)))
.limit(1);
if (existing) {
throw new ConflictException(`Tag "${dto.name}" already exists`);
}
const [tag] = await db
.insert(tags)
.values({
userId,
name: dto.name,
color: dto.color || '#3B82F6',
icon: dto.icon || null,
groupId: dto.groupId || null,
sortOrder: dto.sortOrder ?? 0,
})
.returning();
return tag;
}
/**
* Update an existing tag
*/
async update(id: string, userId: string, dto: UpdateTagDto) {
const db = this.getDb();
// Verify tag exists and belongs to user
const [existing] = await db
.select()
.from(tags)
.where(and(eq(tags.id, id), eq(tags.userId, userId)))
.limit(1);
if (!existing) {
throw new NotFoundException(`Tag not found`);
}
// Check for duplicate name if name is being changed
if (dto.name && dto.name !== existing.name) {
const [duplicate] = await db
.select()
.from(tags)
.where(and(eq(tags.userId, userId), eq(tags.name, dto.name)))
.limit(1);
if (duplicate) {
throw new ConflictException(`Tag "${dto.name}" already exists`);
}
}
const [tag] = await db
.update(tags)
.set({
...dto,
updatedAt: new Date(),
})
.where(and(eq(tags.id, id), eq(tags.userId, userId)))
.returning();
return tag;
}
/**
* Delete a tag
*/
async delete(id: string, userId: string) {
const db = this.getDb();
// Verify tag exists and belongs to user
const [existing] = await db
.select()
.from(tags)
.where(and(eq(tags.id, id), eq(tags.userId, userId)))
.limit(1);
if (!existing) {
throw new NotFoundException(`Tag not found`);
}
await db.delete(tags).where(and(eq(tags.id, id), eq(tags.userId, userId)));
}
/**
* Get all tags in a specific group (only those owned by user)
*/
async findByGroupId(groupId: string, userId: string) {
const db = this.getDb();
return db
.select()
.from(tags)
.where(and(eq(tags.groupId, groupId), eq(tags.userId, userId)));
}
/**
* Create default tags for a new user
* Called during user registration or first access
*/
async createDefaultTags(userId: string) {
const db = this.getDb();
// Check if user already has tags
const existingTags = await db.select().from(tags).where(eq(tags.userId, userId)).limit(1);
if (existingTags.length > 0) {
// User already has tags, return existing
return this.findByUserId(userId);
}
// Create default tags
const createdTags = await db
.insert(tags)
.values(
DEFAULT_TAGS.map((tag) => ({
userId,
name: tag.name,
color: tag.color,
icon: tag.icon,
}))
)
.returning();
return createdTags;
}
}

View file

@ -0,0 +1,72 @@
# mana-user
User preferences, tags, and storage service. Extracted from mana-core-auth.
## Tech Stack
| Layer | Technology |
|-------|------------|
| **Runtime** | Bun |
| **Framework** | Hono |
| **Database** | PostgreSQL + Drizzle ORM |
| **Auth** | JWT validation via JWKS from mana-core-auth |
## Port: 3062
## Quick Start
```bash
bun run dev # Start with hot reload
bun run db:push # Push schema to DB
bun run db:studio # Open Drizzle Studio
```
## API Endpoints (all JWT auth)
### Tags (`/api/v1/tags`)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/` | List user's tags |
| POST | `/` | Create tag |
| PUT | `/:id` | Update tag |
| DELETE | `/:id` | Delete tag |
| POST | `/defaults` | Create default tags |
| POST | `/resolve` | Batch resolve by IDs |
### Tag Groups (`/api/v1/tag-groups`)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/` | List user's groups |
| POST | `/` | Create group |
| PUT | `/:id` | Update group |
| DELETE | `/:id` | Delete group |
### Tag Links (`/api/v1/tag-links`)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/entity?appId=&entityId=` | Get tags for entity |
| POST | `/` | Create link |
| POST | `/sync` | Sync all links for entity |
| GET | `/query?appId=&tagId=` | Query links |
| DELETE | `/:id` | Delete link |
### Settings (`/api/v1/settings`)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/` | Get user settings |
| PUT | `/global` | Update global settings |
| PUT | `/app/:appId` | Update app-specific override |
| PUT | `/device/:deviceId` | Update device-specific settings |
## Database: `mana_user`
Tables: tags, tag_groups, tag_links, user_settings
## Environment Variables
```env
PORT=3062
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/mana_user
MANA_CORE_AUTH_URL=http://localhost:3001
CORS_ORIGINS=http://localhost:5173
```

View file

@ -0,0 +1,16 @@
FROM oven/bun:1 AS production
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile 2>/dev/null || bun install
COPY src ./src
COPY tsconfig.json drizzle.config.ts ./
EXPOSE 3062
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD bun -e "fetch('http://localhost:3062/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
CMD ["bun", "run", "src/index.ts"]

View file

@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema/*.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/mana_user',
},
});

View file

@ -0,0 +1,24 @@
{
"name": "@mana/user",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"hono": "^4.7.0",
"drizzle-orm": "^0.38.3",
"postgres": "^3.4.5",
"jose": "^6.1.2",
"zod": "^3.24.0"
},
"devDependencies": {
"drizzle-kit": "^0.30.4",
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,18 @@
export interface Config {
port: number;
databaseUrl: string;
manaAuthUrl: string;
serviceKey: string;
cors: { origins: string[] };
}
export function loadConfig(): Config {
const env = (key: string, fallback?: string) => process.env[key] || fallback || '';
return {
port: parseInt(env('PORT', '3062'), 10),
databaseUrl: env('DATABASE_URL', 'postgresql://manacore:devpassword@localhost:5432/mana_user'),
manaAuthUrl: env('MANA_CORE_AUTH_URL', 'http://localhost:3001'),
serviceKey: env('MANA_CORE_SERVICE_KEY', 'dev-service-key'),
cors: { origins: env('CORS_ORIGINS', 'http://localhost:5173').split(',') },
};
}

View file

@ -0,0 +1,15 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema/index';
let db: ReturnType<typeof drizzle<typeof schema>> | null = null;
export function getDb(databaseUrl: string) {
if (!db) {
const client = postgres(databaseUrl, { max: 10 });
db = drizzle(client, { schema });
}
return db;
}
export type Database = ReturnType<typeof getDb>;

View file

@ -0,0 +1,4 @@
export * from './tag-groups';
export * from './tags';
export * from './tag-links';
export * from './settings';

View file

@ -0,0 +1,18 @@
import { pgTable, text, jsonb, timestamp } from 'drizzle-orm/pg-core';
export const userSettings = pgTable('user_settings', {
userId: text('user_id').primaryKey(),
globalSettings: jsonb('global_settings')
.default({
nav: { desktopPosition: 'top', sidebarCollapsed: false },
theme: { mode: 'system', colorScheme: 'ocean' },
locale: 'de',
})
.notNull(),
appOverrides: jsonb('app_overrides').default({}).notNull(),
deviceSettings: jsonb('device_settings').default({}).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type UserSettings = typeof userSettings.$inferSelect;

View file

@ -1,5 +1,5 @@
import { pgTable, varchar, text, uuid, timestamp, index, unique } from 'drizzle-orm/pg-core'; import { pgTable, varchar, text, uuid, timestamp, index, unique } from 'drizzle-orm/pg-core';
import { tags } from './tags.schema'; import { tags } from './tags';
export const tagLinks = pgTable( export const tagLinks = pgTable(
'tag_links', 'tag_links',

View file

@ -8,12 +8,8 @@ import {
unique, unique,
integer, integer,
} from 'drizzle-orm/pg-core'; } from 'drizzle-orm/pg-core';
import { tagGroups } from './tag-groups.schema'; import { tagGroups } from './tag-groups';
/**
* Central tags table for all Manacore applications.
* Tags created here can be used in Todo, Calendar, Contacts, and other apps.
*/
export const tags = pgTable( export const tags = pgTable(
'tags', 'tags',
{ {
@ -21,7 +17,7 @@ export const tags = pgTable(
userId: text('user_id').notNull(), userId: text('user_id').notNull(),
name: varchar('name', { length: 100 }).notNull(), name: varchar('name', { length: 100 }).notNull(),
color: varchar('color', { length: 7 }).default('#3B82F6'), color: varchar('color', { length: 7 }).default('#3B82F6'),
icon: varchar('icon', { length: 50 }), // Optional: Phosphor Icon name icon: varchar('icon', { length: 50 }),
groupId: uuid('group_id').references(() => tagGroups.id, { onDelete: 'set null' }), groupId: uuid('group_id').references(() => tagGroups.id, { onDelete: 'set null' }),
sortOrder: integer('sort_order').default(0).notNull(), sortOrder: integer('sort_order').default(0).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(), createdAt: timestamp('created_at').defaultNow().notNull(),

View file

@ -0,0 +1,50 @@
/**
* mana-user User preferences, tags, and storage service
*
* Hono + Bun runtime. Extracted from mana-core-auth.
* Handles: user settings, tags, tag groups, tag links, avatar storage.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { loadConfig } from './config';
import { getDb } from './db/connection';
import { errorHandler } from './middleware/error-handler';
import { jwtAuth } from './middleware/jwt-auth';
import { TagsService } from './services/tags';
import { SettingsService } from './services/settings';
import { healthRoutes } from './routes/health';
import { createTagRoutes } from './routes/tags';
import { createTagGroupRoutes } from './routes/tag-groups';
import { createTagLinkRoutes } from './routes/tag-links';
import { createSettingsRoutes } from './routes/settings';
const config = loadConfig();
const db = getDb(config.databaseUrl);
const tagsService = new TagsService(db);
const settingsService = new SettingsService(db);
const app = new Hono();
app.onError(errorHandler);
app.use('*', cors({ origin: config.cors.origins, credentials: true }));
// Health (no auth)
app.route('/health', healthRoutes);
// All API routes require JWT auth
app.use('/api/v1/*', jwtAuth(config.manaAuthUrl));
// Routes
app.route('/api/v1/tags', createTagRoutes(tagsService));
app.route('/api/v1/tag-groups', createTagGroupRoutes(tagsService));
app.route('/api/v1/tag-links', createTagLinkRoutes(tagsService));
app.route('/api/v1/settings', createSettingsRoutes(settingsService));
console.log(`mana-user starting on port ${config.port}...`);
export default {
port: config.port,
fetch: app.fetch,
};

View file

@ -0,0 +1,43 @@
import { HTTPException } from 'hono/http-exception';
export class BadRequestError extends HTTPException {
constructor(message: string) {
super(400, { message });
}
}
export class UnauthorizedError extends HTTPException {
constructor(message = 'Unauthorized') {
super(401, { message });
}
}
export class ForbiddenError extends HTTPException {
constructor(message = 'Forbidden') {
super(403, { message });
}
}
export class NotFoundError extends HTTPException {
constructor(message = 'Not found') {
super(404, { message });
}
}
export class ConflictError extends HTTPException {
constructor(message = 'Conflict') {
super(409, { message });
}
}
export class InsufficientCreditsError extends HTTPException {
constructor(
public readonly required: number,
public readonly available: number
) {
super(402, {
message: 'Insufficient credits',
cause: { required, available },
});
}
}

View file

@ -0,0 +1,29 @@
/**
* Global error handler middleware for Hono.
*/
import type { ErrorHandler } from 'hono';
import { HTTPException } from 'hono/http-exception';
export const errorHandler: ErrorHandler = (err, c) => {
if (err instanceof HTTPException) {
const cause = err.cause as Record<string, unknown> | undefined;
return c.json(
{
statusCode: err.status,
message: err.message,
...(cause ? { details: cause } : {}),
},
err.status
);
}
console.error('Unhandled error:', err);
return c.json(
{
statusCode: 500,
message: 'Internal server error',
},
500
);
};

View file

@ -0,0 +1,57 @@
/**
* JWT Authentication Middleware
*
* Validates Bearer tokens via JWKS from mana-core-auth.
* Uses jose library with EdDSA algorithm.
*/
import type { MiddlewareHandler } from 'hono';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { UnauthorizedError } from '../lib/errors';
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
function getJwks(authUrl: string) {
if (!jwks) {
jwks = createRemoteJWKSet(new URL('/api/auth/jwks', authUrl));
}
return jwks;
}
export interface AuthUser {
userId: string;
email: string;
role: string;
}
/**
* Middleware that validates JWT tokens from Authorization: Bearer header.
* Sets c.set('user', { userId, email, role }) on success.
*/
export function jwtAuth(authUrl: string): MiddlewareHandler {
return async (c, next) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedError('Missing or invalid Authorization header');
}
const token = authHeader.slice(7);
try {
const { payload } = await jwtVerify(token, getJwks(authUrl), {
issuer: authUrl,
audience: 'manacore',
});
const user: AuthUser = {
userId: payload.sub || '',
email: (payload.email as string) || '',
role: (payload.role as string) || 'user',
};
c.set('user', user);
await next();
} catch {
throw new UnauthorizedError('Invalid or expired token');
}
};
}

View file

@ -0,0 +1,26 @@
/**
* Service-to-Service Authentication Middleware
*
* Validates X-Service-Key header for backend-to-backend calls.
* Used by /internal/* routes.
*/
import type { MiddlewareHandler } from 'hono';
import { UnauthorizedError } from '../lib/errors';
/**
* Middleware that validates X-Service-Key header.
* Sets c.set('appId', ...) from X-App-Id header.
*/
export function serviceAuth(serviceKey: string): MiddlewareHandler {
return async (c, next) => {
const key = c.req.header('X-Service-Key');
if (!key || key !== serviceKey) {
throw new UnauthorizedError('Invalid or missing service key');
}
const appId = c.req.header('X-App-Id') || 'unknown';
c.set('appId', appId);
await next();
};
}

View file

@ -0,0 +1,5 @@
import { Hono } from 'hono';
export const healthRoutes = new Hono().get('/', (c) =>
c.json({ status: 'ok', service: 'mana-user', timestamp: new Date().toISOString() })
);

View file

@ -0,0 +1,30 @@
import { Hono } from 'hono';
import type { SettingsService } from '../services/settings';
import type { AuthUser } from '../middleware/jwt-auth';
export function createSettingsRoutes(settingsService: SettingsService) {
return new Hono<{ Variables: { user: AuthUser } }>()
.get('/', async (c) => {
const user = c.get('user');
return c.json(await settingsService.getSettings(user.userId));
})
.put('/global', async (c) => {
const user = c.get('user');
const body = await c.req.json();
return c.json(await settingsService.updateGlobalSettings(user.userId, body));
})
.put('/app/:appId', async (c) => {
const user = c.get('user');
const body = await c.req.json();
return c.json(
await settingsService.updateAppOverride(user.userId, c.req.param('appId'), body)
);
})
.put('/device/:deviceId', async (c) => {
const user = c.get('user');
const body = await c.req.json();
return c.json(
await settingsService.updateDeviceSettings(user.userId, c.req.param('deviceId'), body)
);
});
}

View file

@ -0,0 +1,26 @@
import { Hono } from 'hono';
import type { TagsService } from '../services/tags';
import type { AuthUser } from '../middleware/jwt-auth';
export function createTagGroupRoutes(tagsService: TagsService) {
return new Hono<{ Variables: { user: AuthUser } }>()
.get('/', async (c) => {
const user = c.get('user');
return c.json(await tagsService.getUserGroups(user.userId));
})
.post('/', async (c) => {
const user = c.get('user');
const body = await c.req.json();
return c.json(await tagsService.createGroup(user.userId, body), 201);
})
.put('/:id', async (c) => {
const user = c.get('user');
const body = await c.req.json();
return c.json(await tagsService.updateGroup(user.userId, c.req.param('id'), body));
})
.delete('/:id', async (c) => {
const user = c.get('user');
await tagsService.deleteGroup(user.userId, c.req.param('id'));
return c.json({ success: true });
});
}

View file

@ -0,0 +1,40 @@
import { Hono } from 'hono';
import type { TagsService } from '../services/tags';
import type { AuthUser } from '../middleware/jwt-auth';
export function createTagLinkRoutes(tagsService: TagsService) {
return new Hono<{ Variables: { user: AuthUser } }>()
.get('/entity', async (c) => {
const user = c.get('user');
const appId = c.req.query('appId') || '';
const entityId = c.req.query('entityId') || '';
const resolved = await tagsService.getLinksForEntity(user.userId, appId, entityId);
return c.json(resolved);
})
.post('/', async (c) => {
const user = c.get('user');
const body = await c.req.json();
const link = await tagsService.createLink(user.userId, body);
return c.json(link, 201);
})
.post('/sync', async (c) => {
const user = c.get('user');
const { appId, entityId, entityType, tagIds } = await c.req.json();
const result = await tagsService.syncLinks(user.userId, appId, entityId, entityType, tagIds);
return c.json(result);
})
.get('/query', async (c) => {
const user = c.get('user');
const links = await tagsService.queryLinks(user.userId, {
appId: c.req.query('appId'),
entityId: c.req.query('entityId'),
tagId: c.req.query('tagId'),
});
return c.json(links);
})
.delete('/:id', async (c) => {
const user = c.get('user');
await tagsService.deleteLink(user.userId, c.req.param('id'));
return c.json({ success: true });
});
}

View file

@ -0,0 +1,42 @@
import { Hono } from 'hono';
import type { TagsService } from '../services/tags';
import type { AuthUser } from '../middleware/jwt-auth';
export function createTagRoutes(tagsService: TagsService) {
return (
new Hono<{ Variables: { user: AuthUser } }>()
.get('/', async (c) => {
const user = c.get('user');
const allTags = await tagsService.getUserTags(user.userId);
return c.json(allTags);
})
.post('/', async (c) => {
const user = c.get('user');
const body = await c.req.json();
const tag = await tagsService.createTag(user.userId, body);
return c.json(tag, 201);
})
.put('/:id', async (c) => {
const user = c.get('user');
const body = await c.req.json();
const tag = await tagsService.updateTag(user.userId, c.req.param('id'), body);
return c.json(tag);
})
.delete('/:id', async (c) => {
const user = c.get('user');
await tagsService.deleteTag(user.userId, c.req.param('id'));
return c.json({ success: true });
})
.post('/defaults', async (c) => {
const user = c.get('user');
const defaultTags = await tagsService.createDefaultTags(user.userId);
return c.json(defaultTags);
})
// Batch resolve
.post('/resolve', async (c) => {
const { ids } = await c.req.json();
const resolved = await tagsService.getTagsByIds(ids || []);
return c.json(resolved);
})
);
}

View file

@ -0,0 +1,92 @@
/**
* Settings Service User preferences, theme, nav, device settings
*/
import { eq } from 'drizzle-orm';
import { userSettings } from '../db/schema/settings';
import type { Database } from '../db/connection';
export class SettingsService {
constructor(private db: Database) {}
async getSettings(userId: string) {
const [settings] = await this.db
.select()
.from(userSettings)
.where(eq(userSettings.userId, userId))
.limit(1);
if (!settings) return this.initializeSettings(userId);
return settings;
}
async initializeSettings(userId: string) {
const [settings] = await this.db
.insert(userSettings)
.values({ userId })
.onConflictDoNothing()
.returning();
if (!settings) {
// Already exists, fetch it
const [existing] = await this.db
.select()
.from(userSettings)
.where(eq(userSettings.userId, userId))
.limit(1);
return existing;
}
return settings;
}
async updateGlobalSettings(userId: string, updates: Record<string, unknown>) {
const current = await this.getSettings(userId);
const merged = { ...(current.globalSettings as Record<string, unknown>), ...updates };
const [updated] = await this.db
.update(userSettings)
.set({ globalSettings: merged, updatedAt: new Date() })
.where(eq(userSettings.userId, userId))
.returning();
return updated;
}
async updateAppOverride(userId: string, appId: string, overrides: Record<string, unknown>) {
const current = await this.getSettings(userId);
const appOverrides = { ...(current.appOverrides as Record<string, unknown>) };
appOverrides[appId] = {
...((appOverrides[appId] as Record<string, unknown>) || {}),
...overrides,
};
const [updated] = await this.db
.update(userSettings)
.set({ appOverrides, updatedAt: new Date() })
.where(eq(userSettings.userId, userId))
.returning();
return updated;
}
async updateDeviceSettings(userId: string, deviceId: string, settings: Record<string, unknown>) {
const current = await this.getSettings(userId);
const deviceSettings = { ...(current.deviceSettings as Record<string, unknown>) };
deviceSettings[deviceId] = {
...((deviceSettings[deviceId] as Record<string, unknown>) || {}),
...settings,
};
const [updated] = await this.db
.update(userSettings)
.set({ deviceSettings, updatedAt: new Date() })
.where(eq(userSettings.userId, userId))
.returning();
return updated;
}
async deleteSettings(userId: string) {
await this.db.delete(userSettings).where(eq(userSettings.userId, userId));
}
}

View file

@ -0,0 +1,210 @@
/**
* Tags Service CRUD for user tags, tag groups, and tag links
*/
import { eq, and, desc, inArray } from 'drizzle-orm';
import { tags, tagGroups, tagLinks } from '../db/schema/index';
import type { Database } from '../db/connection';
import { NotFoundError, BadRequestError } from '../lib/errors';
const DEFAULT_TAGS = [
{ name: 'Arbeit', color: '#3B82F6', icon: 'briefcase' },
{ name: 'Persönlich', color: '#10B981', icon: 'user' },
{ name: 'Familie', color: '#F59E0B', icon: 'users' },
{ name: 'Wichtig', color: '#EF4444', icon: 'star' },
];
export class TagsService {
constructor(private db: Database) {}
// ─── Tags ───────────────────────────────────────────────
async getUserTags(userId: string) {
return this.db.select().from(tags).where(eq(tags.userId, userId)).orderBy(tags.sortOrder);
}
async getTagById(userId: string, tagId: string) {
const [tag] = await this.db
.select()
.from(tags)
.where(and(eq(tags.id, tagId), eq(tags.userId, userId)))
.limit(1);
return tag;
}
async getTagsByIds(tagIds: string[]) {
if (tagIds.length === 0) return [];
return this.db.select().from(tags).where(inArray(tags.id, tagIds));
}
async createTag(
userId: string,
data: { name: string; color?: string; icon?: string; groupId?: string; sortOrder?: number }
) {
const [tag] = await this.db
.insert(tags)
.values({ userId, ...data })
.returning();
return tag;
}
async updateTag(
userId: string,
tagId: string,
data: {
name?: string;
color?: string;
icon?: string;
groupId?: string | null;
sortOrder?: number;
}
) {
const [tag] = await this.db
.update(tags)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(tags.id, tagId), eq(tags.userId, userId)))
.returning();
if (!tag) throw new NotFoundError('Tag not found');
return tag;
}
async deleteTag(userId: string, tagId: string) {
const result = await this.db
.delete(tags)
.where(and(eq(tags.id, tagId), eq(tags.userId, userId)))
.returning();
if (result.length === 0) throw new NotFoundError('Tag not found');
}
async createDefaultTags(userId: string) {
const existing = await this.getUserTags(userId);
if (existing.length > 0) return existing;
const created = [];
for (let i = 0; i < DEFAULT_TAGS.length; i++) {
const [tag] = await this.db
.insert(tags)
.values({ userId, ...DEFAULT_TAGS[i], sortOrder: i })
.returning();
created.push(tag);
}
return created;
}
// ─── Tag Groups ─────────────────────────────────────────
async getUserGroups(userId: string) {
return this.db
.select()
.from(tagGroups)
.where(eq(tagGroups.userId, userId))
.orderBy(tagGroups.sortOrder);
}
async createGroup(
userId: string,
data: { name: string; color?: string; icon?: string; sortOrder?: number }
) {
const [group] = await this.db
.insert(tagGroups)
.values({ userId, ...data })
.returning();
return group;
}
async updateGroup(
userId: string,
groupId: string,
data: { name?: string; color?: string; icon?: string; sortOrder?: number }
) {
const [group] = await this.db
.update(tagGroups)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(tagGroups.id, groupId), eq(tagGroups.userId, userId)))
.returning();
if (!group) throw new NotFoundError('Tag group not found');
return group;
}
async deleteGroup(userId: string, groupId: string) {
// Unlink tags from this group first (set groupId to null)
await this.db.update(tags).set({ groupId: null }).where(eq(tags.groupId, groupId));
const result = await this.db
.delete(tagGroups)
.where(and(eq(tagGroups.id, groupId), eq(tagGroups.userId, userId)))
.returning();
if (result.length === 0) throw new NotFoundError('Tag group not found');
}
// ─── Tag Links ──────────────────────────────────────────
async getLinksForEntity(userId: string, appId: string, entityId: string) {
const links = await this.db
.select()
.from(tagLinks)
.where(
and(eq(tagLinks.userId, userId), eq(tagLinks.appId, appId), eq(tagLinks.entityId, entityId))
);
// Resolve full tag objects
const tagIds = links.map((l) => l.tagId);
const resolvedTags = tagIds.length > 0 ? await this.getTagsByIds(tagIds) : [];
return resolvedTags;
}
async createLink(
userId: string,
data: { tagId: string; appId: string; entityId: string; entityType: string }
) {
const [link] = await this.db
.insert(tagLinks)
.values({ userId, ...data })
.returning();
return link;
}
async syncLinks(
userId: string,
appId: string,
entityId: string,
entityType: string,
tagIds: string[]
) {
return this.db.transaction(async (tx) => {
// Delete all existing links for this entity
await tx
.delete(tagLinks)
.where(
and(
eq(tagLinks.userId, userId),
eq(tagLinks.appId, appId),
eq(tagLinks.entityId, entityId)
)
);
// Insert new links
if (tagIds.length > 0) {
await tx
.insert(tagLinks)
.values(tagIds.map((tagId) => ({ tagId, appId, entityId, entityType, userId })));
}
return { synced: tagIds.length };
});
}
async deleteLink(userId: string, linkId: string) {
await this.db.delete(tagLinks).where(and(eq(tagLinks.id, linkId), eq(tagLinks.userId, userId)));
}
async queryLinks(
userId: string,
filters: { appId?: string; entityId?: string; entityType?: string; tagId?: string }
) {
let query = this.db.select().from(tagLinks).where(eq(tagLinks.userId, userId)).$dynamic();
if (filters.appId) query = query.where(eq(tagLinks.appId, filters.appId));
if (filters.entityId) query = query.where(eq(tagLinks.entityId, filters.entityId));
if (filters.tagId) query = query.where(eq(tagLinks.tagId, filters.tagId));
return query;
}
}

View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"]
}