diff --git a/CLAUDE.md b/CLAUDE.md index 9c2f0485c..d335fcb3b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,6 +131,7 @@ manacore-monorepo/ ├── services/ # Standalone microservices │ ├── mana-core-auth/ # Central authentication service │ ├── 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-go/ # Central search & content extraction (Go, active) │ ├── mana-crawler/ # Web crawler service diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index 3e8f9c5c9..977e95789 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -224,9 +224,9 @@ services: CONTACTS_BACKEND_URL: http://contacts-backend:3034 PICTURE_BACKEND_URL: http://picture-backend:3040 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 - CLOCK_BACKEND_URL: http://clock-backend:3033 + # CLOCK_BACKEND_URL: removed — migrated to local-first STORAGE_BACKEND_URL: http://storage-backend:3035 ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY} MANA_LLM_URL: http://mana-llm:3025 @@ -369,6 +369,62 @@ services: retries: 3 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 " + 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: build: context: . @@ -559,35 +615,7 @@ services: retries: 3 start_period: 40s - clock-backend: - 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 + # clock-backend: REMOVED — migrated to local-first (mana-sync handles CRUD) contacts-backend: build: @@ -810,35 +838,7 @@ services: retries: 3 start_period: 60s - zitare-backend: - 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 + # zitare-backend: REMOVED — migrated to local-first (mana-sync handles CRUD) mukke-backend: build: @@ -1090,9 +1090,9 @@ services: # Backend URLs TODO_BACKEND_URL: http://todo-backend:3031 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 - ZITARE_BACKEND_URL: http://zitare-backend:3007 + # ZITARE_BACKEND_URL: removed — migrated to local-first PLANTA_BACKEND_URL: http://planta-backend:3022 NUTRIPHI_BACKEND_URL: http://nutriphi-backend:3037 STORAGE_BACKEND_URL: http://storage-backend:3035 @@ -1199,15 +1199,14 @@ services: container_name: mana-app-zitare-web restart: always depends_on: - zitare-backend: + mana-auth: condition: service_healthy environment: NODE_ENV: production PORT: 5018 - PUBLIC_ZITARE_API_URL: http://zitare-backend:3007 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_SYNC_SERVER_URL: ws://mana-sync:3050 ports: - "5018:5018" healthcheck: diff --git a/services/mana-core-auth/src/app.module.ts b/services/mana-core-auth/src/app.module.ts index 5572d5b6a..42966fc27 100644 --- a/services/mana-core-auth/src/app.module.ts +++ b/services/mana-core-auth/src/app.module.ts @@ -11,11 +11,6 @@ import { AuthModule } from './auth/auth.module'; import { FeedbackModule } from './feedback/feedback.module'; import { GuildsModule } from './guilds/guilds.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 { SubscriptionsModule } from './subscriptions/subscriptions.module'; import { StripeModule } from './stripe/stripe.module'; @@ -56,11 +51,6 @@ import { SecurityModule } from './security'; FeedbackModule, GuildsModule, HealthModule, - SettingsModule, - StorageModule, - TagsModule, - TagGroupsModule, - TagLinksModule, MeModule, StripeModule, SubscriptionsModule, diff --git a/services/mana-core-auth/src/db/schema/index.ts b/services/mana-core-auth/src/db/schema/index.ts index 1a34df06b..cb7461096 100644 --- a/services/mana-core-auth/src/db/schema/index.ts +++ b/services/mana-core-auth/src/db/schema/index.ts @@ -4,6 +4,3 @@ export * from './feedback.schema'; export * from './login-attempts.schema'; export * from './organizations.schema'; export * from './subscriptions.schema'; -export * from './tag-groups.schema'; -export * from './tag-links.schema'; -export * from './tags.schema'; diff --git a/services/mana-core-auth/src/settings/dto/index.ts b/services/mana-core-auth/src/settings/dto/index.ts deleted file mode 100644 index 986140021..000000000 --- a/services/mana-core-auth/src/settings/dto/index.ts +++ /dev/null @@ -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; -} - -// 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; -} - -// 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; -} - -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; - theme?: Partial; -} - -// Device-specific app settings -export interface DeviceAppSettings { - deviceName: string; - deviceType: 'desktop' | 'mobile' | 'tablet'; - lastSeen: string; - apps: Record>; -} - -// 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; - deviceSettings: Record; -} - -export interface DevicesListResponse { - devices: DeviceInfo[]; -} diff --git a/services/mana-core-auth/src/settings/settings.controller.ts b/services/mana-core-auth/src/settings/settings.controller.ts deleted file mode 100644 index 4516b3bce..000000000 --- a/services/mana-core-auth/src/settings/settings.controller.ts +++ /dev/null @@ -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, - }; - } -} diff --git a/services/mana-core-auth/src/settings/settings.module.ts b/services/mana-core-auth/src/settings/settings.module.ts deleted file mode 100644 index deed7882b..000000000 --- a/services/mana-core-auth/src/settings/settings.module.ts +++ /dev/null @@ -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 {} diff --git a/services/mana-core-auth/src/settings/settings.service.ts b/services/mana-core-auth/src/settings/settings.service.ts deleted file mode 100644 index e65efc9f0..000000000 --- a/services/mana-core-auth/src/settings/settings.service.ts +++ /dev/null @@ -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('database.url'); - return getDb(databaseUrl!); - } - - /** - * Get user settings, creating defaults if they don't exist - */ - async getSettings(userId: string): Promise { - 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, - deviceSettings: (existing.deviceSettings as Record) || {}, - }; - } - - // 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, - deviceSettings: (created.deviceSettings as Record) || {}, - }; - } - - /** - * Update global settings (merges with existing) - */ - async updateGlobalSettings( - userId: string, - dto: UpdateGlobalSettingsDto - ): Promise { - 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, - deviceSettings: (updated.deviceSettings as Record) || {}, - }; - } - - /** - * Update or create app-specific override - */ - async updateAppOverride( - userId: string, - appId: string, - dto: UpdateAppOverrideDto - ): Promise { - 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, - deviceSettings: (updated.deviceSettings as Record) || {}, - }; - } - - /** - * Remove app-specific override (revert to global settings) - */ - async removeAppOverride(userId: string, appId: string): Promise { - 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, - deviceSettings: (updated.deviceSettings as Record) || {}, - }; - } - - // ============================================================================ - // Device Settings Methods - // ============================================================================ - - /** - * Get list of all devices for a user - */ - async getDevices(userId: string): Promise { - 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> { - 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 { - 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, - deviceSettings: (updated.deviceSettings as Record) || {}, - }; - } - - /** - * Remove a device entirely - */ - async removeDevice(userId: string, deviceId: string): Promise { - 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, - deviceSettings: (updated.deviceSettings as Record) || {}, - }; - } - - /** - * Remove app settings from a specific device - */ - async removeDeviceAppSettings( - userId: string, - deviceId: string, - appId: string - ): Promise { - 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, - deviceSettings: (updated.deviceSettings as Record) || {}, - }; - } -} diff --git a/services/mana-core-auth/src/storage/index.ts b/services/mana-core-auth/src/storage/index.ts deleted file mode 100644 index 8a60e8b04..000000000 --- a/services/mana-core-auth/src/storage/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { StorageModule } from './storage.module'; -export { StorageService } from './storage.service'; -export { StorageController } from './storage.controller'; diff --git a/services/mana-core-auth/src/storage/storage.controller.ts b/services/mana-core-auth/src/storage/storage.controller.ts deleted file mode 100644 index e4a922543..000000000 --- a/services/mana-core-auth/src/storage/storage.controller.ts +++ /dev/null @@ -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); - } -} diff --git a/services/mana-core-auth/src/storage/storage.module.ts b/services/mana-core-auth/src/storage/storage.module.ts deleted file mode 100644 index 3b34c65d1..000000000 --- a/services/mana-core-auth/src/storage/storage.module.ts +++ /dev/null @@ -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 {} diff --git a/services/mana-core-auth/src/storage/storage.service.ts b/services/mana-core-auth/src/storage/storage.service.ts deleted file mode 100644 index bf6c63d0e..000000000 --- a/services/mana-core-auth/src/storage/storage.service.ts +++ /dev/null @@ -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('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 { - 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 { - if (!this.storage) return 0; - return this.storage.deleteByPrefix(`users/${userId}/`); - } -} diff --git a/services/mana-core-auth/src/tag-groups/dto/create-tag-group.dto.ts b/services/mana-core-auth/src/tag-groups/dto/create-tag-group.dto.ts deleted file mode 100644 index b87e7a6e7..000000000 --- a/services/mana-core-auth/src/tag-groups/dto/create-tag-group.dto.ts +++ /dev/null @@ -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; -} diff --git a/services/mana-core-auth/src/tag-groups/dto/index.ts b/services/mana-core-auth/src/tag-groups/dto/index.ts deleted file mode 100644 index 0139a1369..000000000 --- a/services/mana-core-auth/src/tag-groups/dto/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './create-tag-group.dto'; -export * from './update-tag-group.dto'; diff --git a/services/mana-core-auth/src/tag-groups/dto/update-tag-group.dto.ts b/services/mana-core-auth/src/tag-groups/dto/update-tag-group.dto.ts deleted file mode 100644 index 065bb0492..000000000 --- a/services/mana-core-auth/src/tag-groups/dto/update-tag-group.dto.ts +++ /dev/null @@ -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; -} diff --git a/services/mana-core-auth/src/tag-groups/index.ts b/services/mana-core-auth/src/tag-groups/index.ts deleted file mode 100644 index ac9b381e7..000000000 --- a/services/mana-core-auth/src/tag-groups/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './tag-groups.module'; -export * from './tag-groups.service'; -export * from './tag-groups.controller'; -export * from './dto'; diff --git a/services/mana-core-auth/src/tag-groups/tag-groups.controller.spec.ts b/services/mana-core-auth/src/tag-groups/tag-groups.controller.spec.ts deleted file mode 100644 index 01ac7eb3d..000000000 --- a/services/mana-core-auth/src/tag-groups/tag-groups.controller.spec.ts +++ /dev/null @@ -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; - - 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); - 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); - }); - }); -}); diff --git a/services/mana-core-auth/src/tag-groups/tag-groups.controller.ts b/services/mana-core-auth/src/tag-groups/tag-groups.controller.ts deleted file mode 100644 index b05cf5187..000000000 --- a/services/mana-core-auth/src/tag-groups/tag-groups.controller.ts +++ /dev/null @@ -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); - } -} diff --git a/services/mana-core-auth/src/tag-groups/tag-groups.module.ts b/services/mana-core-auth/src/tag-groups/tag-groups.module.ts deleted file mode 100644 index 661bde0a1..000000000 --- a/services/mana-core-auth/src/tag-groups/tag-groups.module.ts +++ /dev/null @@ -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 {} diff --git a/services/mana-core-auth/src/tag-groups/tag-groups.service.ts b/services/mana-core-auth/src/tag-groups/tag-groups.service.ts deleted file mode 100644 index c0bd97aac..000000000 --- a/services/mana-core-auth/src/tag-groups/tag-groups.service.ts +++ /dev/null @@ -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('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); - } -} diff --git a/services/mana-core-auth/src/tag-links/dto/create-tag-link.dto.ts b/services/mana-core-auth/src/tag-links/dto/create-tag-link.dto.ts deleted file mode 100644 index fbddff2a1..000000000 --- a/services/mana-core-auth/src/tag-links/dto/create-tag-link.dto.ts +++ /dev/null @@ -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[]; -} diff --git a/services/mana-core-auth/src/tag-links/dto/index.ts b/services/mana-core-auth/src/tag-links/dto/index.ts deleted file mode 100644 index d05759070..000000000 --- a/services/mana-core-auth/src/tag-links/dto/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './create-tag-link.dto'; -export * from './query-tag-links.dto'; diff --git a/services/mana-core-auth/src/tag-links/dto/query-tag-links.dto.ts b/services/mana-core-auth/src/tag-links/dto/query-tag-links.dto.ts deleted file mode 100644 index 3786707c3..000000000 --- a/services/mana-core-auth/src/tag-links/dto/query-tag-links.dto.ts +++ /dev/null @@ -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; -} diff --git a/services/mana-core-auth/src/tag-links/index.ts b/services/mana-core-auth/src/tag-links/index.ts deleted file mode 100644 index ead52312a..000000000 --- a/services/mana-core-auth/src/tag-links/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './tag-links.module'; -export * from './tag-links.service'; -export * from './tag-links.controller'; -export * from './dto'; diff --git a/services/mana-core-auth/src/tag-links/tag-links.controller.spec.ts b/services/mana-core-auth/src/tag-links/tag-links.controller.spec.ts deleted file mode 100644 index f2449f4e5..000000000 --- a/services/mana-core-auth/src/tag-links/tag-links.controller.spec.ts +++ /dev/null @@ -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; - - 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); - 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); - }); - }); -}); diff --git a/services/mana-core-auth/src/tag-links/tag-links.controller.ts b/services/mana-core-auth/src/tag-links/tag-links.controller.ts deleted file mode 100644 index 3c027e3a0..000000000 --- a/services/mana-core-auth/src/tag-links/tag-links.controller.ts +++ /dev/null @@ -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); - } -} diff --git a/services/mana-core-auth/src/tag-links/tag-links.module.ts b/services/mana-core-auth/src/tag-links/tag-links.module.ts deleted file mode 100644 index 8fcec6302..000000000 --- a/services/mana-core-auth/src/tag-links/tag-links.module.ts +++ /dev/null @@ -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 {} diff --git a/services/mana-core-auth/src/tag-links/tag-links.service.ts b/services/mana-core-auth/src/tag-links/tag-links.service.ts deleted file mode 100644 index 333c1ece3..000000000 --- a/services/mana-core-auth/src/tag-links/tag-links.service.ts +++ /dev/null @@ -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('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); - } -} diff --git a/services/mana-core-auth/src/tags/dto/create-tag.dto.ts b/services/mana-core-auth/src/tags/dto/create-tag.dto.ts deleted file mode 100644 index 31ed9a647..000000000 --- a/services/mana-core-auth/src/tags/dto/create-tag.dto.ts +++ /dev/null @@ -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; -} diff --git a/services/mana-core-auth/src/tags/dto/index.ts b/services/mana-core-auth/src/tags/dto/index.ts deleted file mode 100644 index 2b7672a7f..000000000 --- a/services/mana-core-auth/src/tags/dto/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './create-tag.dto'; -export * from './update-tag.dto'; diff --git a/services/mana-core-auth/src/tags/dto/update-tag.dto.ts b/services/mana-core-auth/src/tags/dto/update-tag.dto.ts deleted file mode 100644 index 6b96d6201..000000000 --- a/services/mana-core-auth/src/tags/dto/update-tag.dto.ts +++ /dev/null @@ -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; -} diff --git a/services/mana-core-auth/src/tags/index.ts b/services/mana-core-auth/src/tags/index.ts deleted file mode 100644 index 239717d1d..000000000 --- a/services/mana-core-auth/src/tags/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './tags.module'; -export * from './tags.service'; -export * from './tags.controller'; -export * from './dto'; diff --git a/services/mana-core-auth/src/tags/tags.controller.spec.ts b/services/mana-core-auth/src/tags/tags.controller.spec.ts deleted file mode 100644 index c5205e011..000000000 --- a/services/mana-core-auth/src/tags/tags.controller.spec.ts +++ /dev/null @@ -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; - - 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); - 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); - }); - }); -}); diff --git a/services/mana-core-auth/src/tags/tags.controller.ts b/services/mana-core-auth/src/tags/tags.controller.ts deleted file mode 100644 index b73bbe266..000000000 --- a/services/mana-core-auth/src/tags/tags.controller.ts +++ /dev/null @@ -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); - } -} diff --git a/services/mana-core-auth/src/tags/tags.module.ts b/services/mana-core-auth/src/tags/tags.module.ts deleted file mode 100644 index 58e7480a4..000000000 --- a/services/mana-core-auth/src/tags/tags.module.ts +++ /dev/null @@ -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 {} diff --git a/services/mana-core-auth/src/tags/tags.service.ts b/services/mana-core-auth/src/tags/tags.service.ts deleted file mode 100644 index b2426f379..000000000 --- a/services/mana-core-auth/src/tags/tags.service.ts +++ /dev/null @@ -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('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; - } -} diff --git a/services/mana-user/CLAUDE.md b/services/mana-user/CLAUDE.md new file mode 100644 index 000000000..a9e2f431f --- /dev/null +++ b/services/mana-user/CLAUDE.md @@ -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 +``` diff --git a/services/mana-user/Dockerfile b/services/mana-user/Dockerfile new file mode 100644 index 000000000..1b80acd69 --- /dev/null +++ b/services/mana-user/Dockerfile @@ -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"] diff --git a/services/mana-user/drizzle.config.ts b/services/mana-user/drizzle.config.ts new file mode 100644 index 000000000..86eb46eb0 --- /dev/null +++ b/services/mana-user/drizzle.config.ts @@ -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', + }, +}); diff --git a/services/mana-user/package.json b/services/mana-user/package.json new file mode 100644 index 000000000..ca5dbecb6 --- /dev/null +++ b/services/mana-user/package.json @@ -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" + } +} diff --git a/services/mana-user/src/config.ts b/services/mana-user/src/config.ts new file mode 100644 index 000000000..0f72c425a --- /dev/null +++ b/services/mana-user/src/config.ts @@ -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(',') }, + }; +} diff --git a/services/mana-user/src/db/connection.ts b/services/mana-user/src/db/connection.ts new file mode 100644 index 000000000..89a121b3c --- /dev/null +++ b/services/mana-user/src/db/connection.ts @@ -0,0 +1,15 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema/index'; + +let db: ReturnType> | 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; diff --git a/services/mana-user/src/db/schema/index.ts b/services/mana-user/src/db/schema/index.ts new file mode 100644 index 000000000..a095950b3 --- /dev/null +++ b/services/mana-user/src/db/schema/index.ts @@ -0,0 +1,4 @@ +export * from './tag-groups'; +export * from './tags'; +export * from './tag-links'; +export * from './settings'; diff --git a/services/mana-user/src/db/schema/settings.ts b/services/mana-user/src/db/schema/settings.ts new file mode 100644 index 000000000..cc6fbe745 --- /dev/null +++ b/services/mana-user/src/db/schema/settings.ts @@ -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; diff --git a/services/mana-core-auth/src/db/schema/tag-groups.schema.ts b/services/mana-user/src/db/schema/tag-groups.ts similarity index 100% rename from services/mana-core-auth/src/db/schema/tag-groups.schema.ts rename to services/mana-user/src/db/schema/tag-groups.ts diff --git a/services/mana-core-auth/src/db/schema/tag-links.schema.ts b/services/mana-user/src/db/schema/tag-links.ts similarity index 96% rename from services/mana-core-auth/src/db/schema/tag-links.schema.ts rename to services/mana-user/src/db/schema/tag-links.ts index 869a40f3c..12d93a447 100644 --- a/services/mana-core-auth/src/db/schema/tag-links.schema.ts +++ b/services/mana-user/src/db/schema/tag-links.ts @@ -1,5 +1,5 @@ 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( 'tag_links', diff --git a/services/mana-core-auth/src/db/schema/tags.schema.ts b/services/mana-user/src/db/schema/tags.ts similarity index 77% rename from services/mana-core-auth/src/db/schema/tags.schema.ts rename to services/mana-user/src/db/schema/tags.ts index 29d1dfbb8..b1dfa9c8d 100644 --- a/services/mana-core-auth/src/db/schema/tags.schema.ts +++ b/services/mana-user/src/db/schema/tags.ts @@ -8,12 +8,8 @@ import { unique, integer, } 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( 'tags', { @@ -21,7 +17,7 @@ export const tags = pgTable( userId: text('user_id').notNull(), name: varchar('name', { length: 100 }).notNull(), 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' }), sortOrder: integer('sort_order').default(0).notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), diff --git a/services/mana-user/src/index.ts b/services/mana-user/src/index.ts new file mode 100644 index 000000000..703b7cd03 --- /dev/null +++ b/services/mana-user/src/index.ts @@ -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, +}; diff --git a/services/mana-user/src/lib/errors.ts b/services/mana-user/src/lib/errors.ts new file mode 100644 index 000000000..d3b2c3392 --- /dev/null +++ b/services/mana-user/src/lib/errors.ts @@ -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 }, + }); + } +} diff --git a/services/mana-user/src/middleware/error-handler.ts b/services/mana-user/src/middleware/error-handler.ts new file mode 100644 index 000000000..cec6640e8 --- /dev/null +++ b/services/mana-user/src/middleware/error-handler.ts @@ -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 | 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 + ); +}; diff --git a/services/mana-user/src/middleware/jwt-auth.ts b/services/mana-user/src/middleware/jwt-auth.ts new file mode 100644 index 000000000..390319288 --- /dev/null +++ b/services/mana-user/src/middleware/jwt-auth.ts @@ -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 | 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'); + } + }; +} diff --git a/services/mana-user/src/middleware/service-auth.ts b/services/mana-user/src/middleware/service-auth.ts new file mode 100644 index 000000000..a1012a11d --- /dev/null +++ b/services/mana-user/src/middleware/service-auth.ts @@ -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(); + }; +} diff --git a/services/mana-user/src/routes/health.ts b/services/mana-user/src/routes/health.ts new file mode 100644 index 000000000..9c41d6f54 --- /dev/null +++ b/services/mana-user/src/routes/health.ts @@ -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() }) +); diff --git a/services/mana-user/src/routes/settings.ts b/services/mana-user/src/routes/settings.ts new file mode 100644 index 000000000..f2be06259 --- /dev/null +++ b/services/mana-user/src/routes/settings.ts @@ -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) + ); + }); +} diff --git a/services/mana-user/src/routes/tag-groups.ts b/services/mana-user/src/routes/tag-groups.ts new file mode 100644 index 000000000..25c69644d --- /dev/null +++ b/services/mana-user/src/routes/tag-groups.ts @@ -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 }); + }); +} diff --git a/services/mana-user/src/routes/tag-links.ts b/services/mana-user/src/routes/tag-links.ts new file mode 100644 index 000000000..3f81ed908 --- /dev/null +++ b/services/mana-user/src/routes/tag-links.ts @@ -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 }); + }); +} diff --git a/services/mana-user/src/routes/tags.ts b/services/mana-user/src/routes/tags.ts new file mode 100644 index 000000000..52c34ede3 --- /dev/null +++ b/services/mana-user/src/routes/tags.ts @@ -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); + }) + ); +} diff --git a/services/mana-user/src/services/settings.ts b/services/mana-user/src/services/settings.ts new file mode 100644 index 000000000..c2756d656 --- /dev/null +++ b/services/mana-user/src/services/settings.ts @@ -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) { + const current = await this.getSettings(userId); + const merged = { ...(current.globalSettings as Record), ...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) { + const current = await this.getSettings(userId); + const appOverrides = { ...(current.appOverrides as Record) }; + appOverrides[appId] = { + ...((appOverrides[appId] as Record) || {}), + ...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) { + const current = await this.getSettings(userId); + const deviceSettings = { ...(current.deviceSettings as Record) }; + deviceSettings[deviceId] = { + ...((deviceSettings[deviceId] as Record) || {}), + ...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)); + } +} diff --git a/services/mana-user/src/services/tags.ts b/services/mana-user/src/services/tags.ts new file mode 100644 index 000000000..94b6f3fe5 --- /dev/null +++ b/services/mana-user/src/services/tags.ts @@ -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; + } +} diff --git a/services/mana-user/tsconfig.json b/services/mana-user/tsconfig.json new file mode 100644 index 000000000..8c513d34d --- /dev/null +++ b/services/mana-user/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +}