mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
feat(infra): add mana-sync and mana-notify-go to docker-compose
- mana-sync on port 3051 (Go sync server for local-first apps) - mana-notify-go on port 3040 (Go notification service) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
313779f439
commit
ef19018e71
60 changed files with 908 additions and 2876 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <noreply@mana.how>"
|
||||
EXPO_ACCESS_TOKEN: ${EXPO_ACCESS_TOKEN:-}
|
||||
MATRIX_HOMESERVER_URL: http://mana-matrix-synapse:8008
|
||||
MATRIX_ACCESS_TOKEN: ${MATRIX_NOTIFY_BOT_TOKEN:-}
|
||||
ports:
|
||||
- "3040:3040"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3040/health"]
|
||||
interval: 120s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
mana-crawler:
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -1,174 +0,0 @@
|
|||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsObject,
|
||||
ValidateNested,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsArray,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
// Nav settings
|
||||
export class NavSettingsDto {
|
||||
@IsOptional()
|
||||
@IsIn(['top', 'bottom'])
|
||||
desktopPosition?: 'top' | 'bottom';
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
sidebarCollapsed?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
hiddenNavItems?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
// Theme settings
|
||||
export class ThemeSettingsDto {
|
||||
@IsOptional()
|
||||
@IsIn(['light', 'dark', 'system'])
|
||||
mode?: 'light' | 'dark' | 'system';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
colorScheme?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
pinnedThemes?: string[];
|
||||
}
|
||||
|
||||
// Global settings update
|
||||
export class UpdateGlobalSettingsDto {
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => NavSettingsDto)
|
||||
nav?: NavSettingsDto;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ThemeSettingsDto)
|
||||
theme?: ThemeSettingsDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
locale?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
recentEmojis?: string[];
|
||||
|
||||
// Profile fields (from onboarding)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
displayName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
interests?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
onboardingCompleted?: boolean;
|
||||
}
|
||||
|
||||
// App override update
|
||||
export class UpdateAppOverrideDto {
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => NavSettingsDto)
|
||||
nav?: NavSettingsDto;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ThemeSettingsDto)
|
||||
theme?: ThemeSettingsDto;
|
||||
}
|
||||
|
||||
// Device settings update
|
||||
export class UpdateDeviceAppSettingsDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
deviceName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['desktop', 'mobile', 'tablet'])
|
||||
deviceType?: 'desktop' | 'mobile' | 'tablet';
|
||||
|
||||
@IsObject()
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Register/update device info
|
||||
export class RegisterDeviceDto {
|
||||
@IsString()
|
||||
deviceId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
deviceName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['desktop', 'mobile', 'tablet'])
|
||||
deviceType?: 'desktop' | 'mobile' | 'tablet';
|
||||
}
|
||||
|
||||
// Response types (for documentation)
|
||||
export interface NavSettings {
|
||||
desktopPosition: 'top' | 'bottom';
|
||||
sidebarCollapsed: boolean;
|
||||
hiddenNavItems?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export interface ThemeSettings {
|
||||
mode: 'light' | 'dark' | 'system';
|
||||
colorScheme: string;
|
||||
pinnedThemes: string[];
|
||||
}
|
||||
|
||||
export interface GlobalSettings {
|
||||
nav: NavSettings;
|
||||
theme: ThemeSettings;
|
||||
locale: string;
|
||||
recentEmojis?: string[];
|
||||
// Profile fields (from onboarding)
|
||||
displayName?: string;
|
||||
interests?: string[];
|
||||
onboardingCompleted?: boolean;
|
||||
}
|
||||
|
||||
export interface AppOverride {
|
||||
nav?: Partial<NavSettings>;
|
||||
theme?: Partial<ThemeSettings>;
|
||||
}
|
||||
|
||||
// Device-specific app settings
|
||||
export interface DeviceAppSettings {
|
||||
deviceName: string;
|
||||
deviceType: 'desktop' | 'mobile' | 'tablet';
|
||||
lastSeen: string;
|
||||
apps: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
// Device info for listing
|
||||
export interface DeviceInfo {
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
deviceType: 'desktop' | 'mobile' | 'tablet';
|
||||
lastSeen: string;
|
||||
appCount: number;
|
||||
}
|
||||
|
||||
export interface UserSettingsResponse {
|
||||
globalSettings: GlobalSettings;
|
||||
appOverrides: Record<string, AppOverride>;
|
||||
deviceSettings: Record<string, DeviceAppSettings>;
|
||||
}
|
||||
|
||||
export interface DevicesListResponse {
|
||||
devices: DeviceInfo[];
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -1,384 +0,0 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getDb } from '../db/connection';
|
||||
import { userSettings } from '../db/schema';
|
||||
import {
|
||||
type UpdateGlobalSettingsDto,
|
||||
type UpdateAppOverrideDto,
|
||||
type UpdateDeviceAppSettingsDto,
|
||||
type GlobalSettings,
|
||||
type AppOverride,
|
||||
type DeviceAppSettings,
|
||||
type DeviceInfo,
|
||||
type UserSettingsResponse,
|
||||
type DevicesListResponse,
|
||||
} from './dto';
|
||||
|
||||
// Default settings for new users
|
||||
const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
||||
nav: { desktopPosition: 'top', sidebarCollapsed: false },
|
||||
theme: { mode: 'system', colorScheme: 'ocean', pinnedThemes: [] },
|
||||
locale: 'de',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SettingsService {
|
||||
private readonly logger = new Logger(SettingsService.name);
|
||||
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
private getDb() {
|
||||
const databaseUrl = this.configService.get<string>('database.url');
|
||||
return getDb(databaseUrl!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user settings, creating defaults if they don't exist
|
||||
*/
|
||||
async getSettings(userId: string): Promise<UserSettingsResponse> {
|
||||
const db = this.getDb();
|
||||
|
||||
// Try to get existing settings
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(userSettings)
|
||||
.where(eq(userSettings.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
return {
|
||||
globalSettings: existing.globalSettings as GlobalSettings,
|
||||
appOverrides: existing.appOverrides as Record<string, AppOverride>,
|
||||
deviceSettings: (existing.deviceSettings as Record<string, DeviceAppSettings>) || {},
|
||||
};
|
||||
}
|
||||
|
||||
// Create default settings for new user
|
||||
const [created] = await db
|
||||
.insert(userSettings)
|
||||
.values({
|
||||
userId,
|
||||
globalSettings: DEFAULT_GLOBAL_SETTINGS,
|
||||
appOverrides: {},
|
||||
deviceSettings: {},
|
||||
})
|
||||
.returning();
|
||||
|
||||
this.logger.debug(`Created default settings for user ${userId}`);
|
||||
|
||||
return {
|
||||
globalSettings: created.globalSettings as GlobalSettings,
|
||||
appOverrides: created.appOverrides as Record<string, AppOverride>,
|
||||
deviceSettings: (created.deviceSettings as Record<string, DeviceAppSettings>) || {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update global settings (merges with existing)
|
||||
*/
|
||||
async updateGlobalSettings(
|
||||
userId: string,
|
||||
dto: UpdateGlobalSettingsDto
|
||||
): Promise<UserSettingsResponse> {
|
||||
const db = this.getDb();
|
||||
|
||||
// Get current settings
|
||||
const current = await this.getSettings(userId);
|
||||
|
||||
// Deep merge the settings
|
||||
const updatedGlobal: GlobalSettings = {
|
||||
nav: { ...current.globalSettings.nav, ...dto.nav },
|
||||
theme: { ...current.globalSettings.theme, ...dto.theme },
|
||||
locale: dto.locale ?? current.globalSettings.locale,
|
||||
recentEmojis: dto.recentEmojis ?? current.globalSettings.recentEmojis,
|
||||
// Profile fields
|
||||
displayName: dto.displayName ?? current.globalSettings.displayName,
|
||||
interests: dto.interests ?? current.globalSettings.interests,
|
||||
onboardingCompleted: dto.onboardingCompleted ?? current.globalSettings.onboardingCompleted,
|
||||
};
|
||||
|
||||
// Update in database
|
||||
const [updated] = await db
|
||||
.update(userSettings)
|
||||
.set({
|
||||
globalSettings: updatedGlobal,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(userSettings.userId, userId))
|
||||
.returning();
|
||||
|
||||
this.logger.debug(`Updated global settings for user ${userId}`);
|
||||
|
||||
return {
|
||||
globalSettings: updated.globalSettings as GlobalSettings,
|
||||
appOverrides: updated.appOverrides as Record<string, AppOverride>,
|
||||
deviceSettings: (updated.deviceSettings as Record<string, DeviceAppSettings>) || {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or create app-specific override
|
||||
*/
|
||||
async updateAppOverride(
|
||||
userId: string,
|
||||
appId: string,
|
||||
dto: UpdateAppOverrideDto
|
||||
): Promise<UserSettingsResponse> {
|
||||
const db = this.getDb();
|
||||
|
||||
// Get current settings
|
||||
const current = await this.getSettings(userId);
|
||||
|
||||
// Merge with existing app override
|
||||
const existingOverride = current.appOverrides[appId] || {};
|
||||
const updatedOverride: AppOverride = {
|
||||
nav: dto.nav ? { ...existingOverride.nav, ...dto.nav } : existingOverride.nav,
|
||||
theme: dto.theme ? { ...existingOverride.theme, ...dto.theme } : existingOverride.theme,
|
||||
};
|
||||
|
||||
// Clean up empty objects
|
||||
if (updatedOverride.nav && Object.keys(updatedOverride.nav).length === 0) {
|
||||
delete updatedOverride.nav;
|
||||
}
|
||||
if (updatedOverride.theme && Object.keys(updatedOverride.theme).length === 0) {
|
||||
delete updatedOverride.theme;
|
||||
}
|
||||
|
||||
// Update app overrides
|
||||
const updatedOverrides = { ...current.appOverrides };
|
||||
if (Object.keys(updatedOverride).length > 0) {
|
||||
updatedOverrides[appId] = updatedOverride;
|
||||
} else {
|
||||
delete updatedOverrides[appId];
|
||||
}
|
||||
|
||||
// Update in database
|
||||
const [updated] = await db
|
||||
.update(userSettings)
|
||||
.set({
|
||||
appOverrides: updatedOverrides,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(userSettings.userId, userId))
|
||||
.returning();
|
||||
|
||||
this.logger.debug(`Updated app override for user ${userId}, app ${appId}`);
|
||||
|
||||
return {
|
||||
globalSettings: updated.globalSettings as GlobalSettings,
|
||||
appOverrides: updated.appOverrides as Record<string, AppOverride>,
|
||||
deviceSettings: (updated.deviceSettings as Record<string, DeviceAppSettings>) || {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove app-specific override (revert to global settings)
|
||||
*/
|
||||
async removeAppOverride(userId: string, appId: string): Promise<UserSettingsResponse> {
|
||||
const db = this.getDb();
|
||||
|
||||
// Get current settings
|
||||
const current = await this.getSettings(userId);
|
||||
|
||||
// Remove the app override
|
||||
const updatedOverrides = { ...current.appOverrides };
|
||||
delete updatedOverrides[appId];
|
||||
|
||||
// Update in database
|
||||
const [updated] = await db
|
||||
.update(userSettings)
|
||||
.set({
|
||||
appOverrides: updatedOverrides,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(userSettings.userId, userId))
|
||||
.returning();
|
||||
|
||||
this.logger.debug(`Removed app override for user ${userId}, app ${appId}`);
|
||||
|
||||
return {
|
||||
globalSettings: updated.globalSettings as GlobalSettings,
|
||||
appOverrides: updated.appOverrides as Record<string, AppOverride>,
|
||||
deviceSettings: (updated.deviceSettings as Record<string, DeviceAppSettings>) || {},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Device Settings Methods
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get list of all devices for a user
|
||||
*/
|
||||
async getDevices(userId: string): Promise<DevicesListResponse> {
|
||||
const current = await this.getSettings(userId);
|
||||
const deviceSettings = current.deviceSettings || {};
|
||||
|
||||
const devices: DeviceInfo[] = Object.entries(deviceSettings).map(([deviceId, device]) => ({
|
||||
deviceId,
|
||||
deviceName: device.deviceName || 'Unbekanntes Gerät',
|
||||
deviceType: device.deviceType || 'desktop',
|
||||
lastSeen: device.lastSeen || new Date().toISOString(),
|
||||
appCount: Object.keys(device.apps || {}).length,
|
||||
}));
|
||||
|
||||
// Sort by lastSeen descending
|
||||
devices.sort((a, b) => new Date(b.lastSeen).getTime() - new Date(a.lastSeen).getTime());
|
||||
|
||||
return { devices };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get settings for a specific device and app
|
||||
*/
|
||||
async getDeviceAppSettings(
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
appId: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
const current = await this.getSettings(userId);
|
||||
const deviceSettings = current.deviceSettings || {};
|
||||
const device = deviceSettings[deviceId];
|
||||
|
||||
if (!device || !device.apps || !device.apps[appId]) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return device.apps[appId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update settings for a specific device and app
|
||||
*/
|
||||
async updateDeviceAppSettings(
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
appId: string,
|
||||
dto: UpdateDeviceAppSettingsDto
|
||||
): Promise<UserSettingsResponse> {
|
||||
const db = this.getDb();
|
||||
|
||||
// Get current settings
|
||||
const current = await this.getSettings(userId);
|
||||
const deviceSettings = { ...(current.deviceSettings || {}) };
|
||||
|
||||
// Get or create device entry
|
||||
const existingDevice = deviceSettings[deviceId] || {
|
||||
deviceName: dto.deviceName || 'Unbekanntes Gerät',
|
||||
deviceType: dto.deviceType || 'desktop',
|
||||
lastSeen: new Date().toISOString(),
|
||||
apps: {},
|
||||
};
|
||||
|
||||
// Update device info if provided
|
||||
const updatedDevice: DeviceAppSettings = {
|
||||
deviceName: dto.deviceName || existingDevice.deviceName,
|
||||
deviceType: dto.deviceType || existingDevice.deviceType,
|
||||
lastSeen: new Date().toISOString(),
|
||||
apps: {
|
||||
...existingDevice.apps,
|
||||
[appId]: {
|
||||
...(existingDevice.apps?.[appId] || {}),
|
||||
...dto.settings,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
deviceSettings[deviceId] = updatedDevice;
|
||||
|
||||
// Update in database
|
||||
const [updated] = await db
|
||||
.update(userSettings)
|
||||
.set({
|
||||
deviceSettings,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(userSettings.userId, userId))
|
||||
.returning();
|
||||
|
||||
this.logger.debug(
|
||||
`Updated device settings for user ${userId}, device ${deviceId}, app ${appId}`
|
||||
);
|
||||
|
||||
return {
|
||||
globalSettings: updated.globalSettings as GlobalSettings,
|
||||
appOverrides: updated.appOverrides as Record<string, AppOverride>,
|
||||
deviceSettings: (updated.deviceSettings as Record<string, DeviceAppSettings>) || {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a device entirely
|
||||
*/
|
||||
async removeDevice(userId: string, deviceId: string): Promise<UserSettingsResponse> {
|
||||
const db = this.getDb();
|
||||
|
||||
// Get current settings
|
||||
const current = await this.getSettings(userId);
|
||||
const deviceSettings = { ...(current.deviceSettings || {}) };
|
||||
|
||||
// Remove the device
|
||||
delete deviceSettings[deviceId];
|
||||
|
||||
// Update in database
|
||||
const [updated] = await db
|
||||
.update(userSettings)
|
||||
.set({
|
||||
deviceSettings,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(userSettings.userId, userId))
|
||||
.returning();
|
||||
|
||||
this.logger.debug(`Removed device ${deviceId} for user ${userId}`);
|
||||
|
||||
return {
|
||||
globalSettings: updated.globalSettings as GlobalSettings,
|
||||
appOverrides: updated.appOverrides as Record<string, AppOverride>,
|
||||
deviceSettings: (updated.deviceSettings as Record<string, DeviceAppSettings>) || {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove app settings from a specific device
|
||||
*/
|
||||
async removeDeviceAppSettings(
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
appId: string
|
||||
): Promise<UserSettingsResponse> {
|
||||
const db = this.getDb();
|
||||
|
||||
// Get current settings
|
||||
const current = await this.getSettings(userId);
|
||||
const deviceSettings = { ...(current.deviceSettings || {}) };
|
||||
|
||||
if (deviceSettings[deviceId]?.apps) {
|
||||
const device = { ...deviceSettings[deviceId] };
|
||||
const apps = { ...device.apps };
|
||||
delete apps[appId];
|
||||
device.apps = apps;
|
||||
device.lastSeen = new Date().toISOString();
|
||||
deviceSettings[deviceId] = device;
|
||||
}
|
||||
|
||||
// Update in database
|
||||
const [updated] = await db
|
||||
.update(userSettings)
|
||||
.set({
|
||||
deviceSettings,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(userSettings.userId, userId))
|
||||
.returning();
|
||||
|
||||
this.logger.debug(`Removed app ${appId} settings from device ${deviceId} for user ${userId}`);
|
||||
|
||||
return {
|
||||
globalSettings: updated.globalSettings as GlobalSettings,
|
||||
appOverrides: updated.appOverrides as Record<string, AppOverride>,
|
||||
deviceSettings: (updated.deviceSettings as Record<string, DeviceAppSettings>) || {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export { StorageModule } from './storage.module';
|
||||
export { StorageService } from './storage.service';
|
||||
export { StorageController } from './storage.controller';
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
createManaCoreStorage,
|
||||
generateUserFileKey,
|
||||
getContentType,
|
||||
validateFileExtension,
|
||||
IMAGE_EXTENSIONS,
|
||||
} from '@manacore/shared-storage';
|
||||
import type { StorageClient } from '@manacore/shared-storage';
|
||||
|
||||
const MAX_AVATAR_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
private readonly logger = new Logger(StorageService.name);
|
||||
private storage: StorageClient | null = null;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
try {
|
||||
const publicUrl = this.configService.get<string>('storage.publicUrl');
|
||||
this.storage = createManaCoreStorage(publicUrl);
|
||||
|
||||
this.storage.hooks.on('upload', ({ key, sizeBytes }) => {
|
||||
this.logger.debug(`Uploaded avatar ${key} (${sizeBytes} bytes)`);
|
||||
});
|
||||
this.storage.hooks.on('upload:error', ({ key, error }) => {
|
||||
this.logger.error(`Avatar upload failed for ${key}: ${error.message}`);
|
||||
});
|
||||
|
||||
this.logger.log('Storage service initialized');
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
'Storage service not configured - avatar uploads will be disabled',
|
||||
error instanceof Error ? error.message : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if storage is available
|
||||
*/
|
||||
isAvailable(): boolean {
|
||||
return this.storage !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a presigned URL for avatar upload
|
||||
*/
|
||||
async getAvatarUploadUrl(
|
||||
userId: string,
|
||||
filename: string
|
||||
): Promise<{
|
||||
uploadUrl: string;
|
||||
fileUrl: string;
|
||||
key: string;
|
||||
expiresIn: number;
|
||||
}> {
|
||||
if (!this.storage) {
|
||||
throw new BadRequestException('Storage service is not configured');
|
||||
}
|
||||
|
||||
if (!validateFileExtension(filename, IMAGE_EXTENSIONS)) {
|
||||
throw new BadRequestException(`Invalid file type. Allowed: ${IMAGE_EXTENSIONS.join(', ')}`);
|
||||
}
|
||||
|
||||
const key = generateUserFileKey(userId, filename, 'avatars');
|
||||
const expiresIn = 3600;
|
||||
const uploadUrl = await this.storage.getUploadUrl(key, { expiresIn });
|
||||
const fileUrl = this.storage.getPublicUrl(key) ?? '';
|
||||
|
||||
return { uploadUrl, fileUrl, key, expiresIn };
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload avatar directly (for server-side uploads)
|
||||
*/
|
||||
async uploadAvatar(
|
||||
userId: string,
|
||||
buffer: Buffer,
|
||||
filename: string
|
||||
): Promise<{ url: string; key: string }> {
|
||||
if (!this.storage) {
|
||||
throw new BadRequestException('Storage service is not configured');
|
||||
}
|
||||
|
||||
if (!validateFileExtension(filename, IMAGE_EXTENSIONS)) {
|
||||
throw new BadRequestException(`Invalid file type. Allowed: ${IMAGE_EXTENSIONS.join(', ')}`);
|
||||
}
|
||||
|
||||
const key = generateUserFileKey(userId, filename, 'avatars');
|
||||
|
||||
const result = await this.storage.upload(key, buffer, {
|
||||
contentType: getContentType(filename),
|
||||
public: true,
|
||||
maxSizeBytes: MAX_AVATAR_SIZE,
|
||||
cacheControl: 'public, max-age=31536000, immutable',
|
||||
});
|
||||
|
||||
return { url: result.url ?? this.storage.getPublicUrl(key) ?? '', key };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete avatar
|
||||
*/
|
||||
async deleteAvatar(key: string): Promise<void> {
|
||||
if (!this.storage) {
|
||||
throw new BadRequestException('Storage service is not configured');
|
||||
}
|
||||
|
||||
await this.storage.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all avatars for a user (account deletion).
|
||||
*/
|
||||
async deleteAllUserAvatars(userId: string): Promise<number> {
|
||||
if (!this.storage) return 0;
|
||||
return this.storage.deleteByPrefix(`users/${userId}/`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from './create-tag-group.dto';
|
||||
export * from './update-tag-group.dto';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export * from './tag-groups.module';
|
||||
export * from './tag-groups.service';
|
||||
export * from './tag-groups.controller';
|
||||
export * from './dto';
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
import { Test } from '@nestjs/testing';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { TagGroupsController } from './tag-groups.controller';
|
||||
import { TagGroupsService } from './tag-groups.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import type { CurrentUserData } from '../common/decorators/current-user.decorator';
|
||||
|
||||
describe('TagGroupsController', () => {
|
||||
let controller: TagGroupsController;
|
||||
let tagGroupsService: jest.Mocked<TagGroupsService>;
|
||||
|
||||
const mockUser: CurrentUserData = {
|
||||
userId: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
role: 'user',
|
||||
};
|
||||
|
||||
const mockTagGroup = {
|
||||
id: 'group-1',
|
||||
userId: 'test-user-id',
|
||||
name: 'Kategorien',
|
||||
color: '#3B82F6',
|
||||
icon: null,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockTagGroupsServiceValue = {
|
||||
findByUserId: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
reorder: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [TagGroupsController],
|
||||
providers: [
|
||||
{
|
||||
provide: TagGroupsService,
|
||||
useValue: mockTagGroupsServiceValue,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue({ canActivate: jest.fn(() => true) })
|
||||
.compile();
|
||||
|
||||
controller = module.get<TagGroupsController>(TagGroupsController);
|
||||
tagGroupsService = module.get(TagGroupsService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// GET /tag-groups
|
||||
// ============================================================================
|
||||
|
||||
describe('GET /tag-groups', () => {
|
||||
it('should return all tag groups for the authenticated user', async () => {
|
||||
const groups = [
|
||||
mockTagGroup,
|
||||
{ ...mockTagGroup, id: 'group-2', name: 'Projekte', sortOrder: 1 },
|
||||
];
|
||||
|
||||
tagGroupsService.findByUserId.mockResolvedValue(groups);
|
||||
|
||||
const result = await controller.findAll(mockUser);
|
||||
|
||||
expect(result).toEqual(groups);
|
||||
expect(tagGroupsService.findByUserId).toHaveBeenCalledWith('test-user-id');
|
||||
});
|
||||
|
||||
it('should return empty array when user has no groups', async () => {
|
||||
tagGroupsService.findByUserId.mockResolvedValue([]);
|
||||
|
||||
const result = await controller.findAll(mockUser);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /tag-groups
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /tag-groups', () => {
|
||||
it('should create a new tag group and return it', async () => {
|
||||
const createDto = { name: 'Neues Projekt', color: '#10B981' };
|
||||
const createdGroup = { ...mockTagGroup, ...createDto, id: 'group-new' };
|
||||
|
||||
tagGroupsService.create.mockResolvedValue(createdGroup);
|
||||
|
||||
const result = await controller.create(mockUser, createDto);
|
||||
|
||||
expect(result).toEqual(createdGroup);
|
||||
expect(tagGroupsService.create).toHaveBeenCalledWith('test-user-id', createDto);
|
||||
});
|
||||
|
||||
it('should propagate ConflictException for duplicate group name', async () => {
|
||||
const createDto = { name: 'Kategorien' };
|
||||
|
||||
tagGroupsService.create.mockRejectedValue(
|
||||
new ConflictException('Tag group "Kategorien" already exists')
|
||||
);
|
||||
|
||||
await expect(controller.create(mockUser, createDto)).rejects.toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// PUT /tag-groups/reorder
|
||||
// ============================================================================
|
||||
|
||||
describe('PUT /tag-groups/reorder', () => {
|
||||
it('should reorder tag groups and return updated list', async () => {
|
||||
const reorderedGroups = [
|
||||
{ ...mockTagGroup, id: 'group-2', sortOrder: 0 },
|
||||
{ ...mockTagGroup, id: 'group-1', sortOrder: 1 },
|
||||
];
|
||||
|
||||
tagGroupsService.reorder.mockResolvedValue(reorderedGroups);
|
||||
|
||||
const result = await controller.reorder(mockUser, { ids: ['group-2', 'group-1'] });
|
||||
|
||||
expect(result).toEqual(reorderedGroups);
|
||||
expect(tagGroupsService.reorder).toHaveBeenCalledWith('test-user-id', ['group-2', 'group-1']);
|
||||
});
|
||||
|
||||
it('should propagate NotFoundException when a group ID is invalid', async () => {
|
||||
tagGroupsService.reorder.mockRejectedValue(
|
||||
new NotFoundException('One or more tag groups not found')
|
||||
);
|
||||
|
||||
await expect(
|
||||
controller.reorder(mockUser, { ids: ['group-1', 'nonexistent'] })
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// PUT /tag-groups/:id
|
||||
// ============================================================================
|
||||
|
||||
describe('PUT /tag-groups/:id', () => {
|
||||
it('should update a tag group and return the updated version', async () => {
|
||||
const updateDto = { name: 'Umbenannt', color: '#EF4444' };
|
||||
const updatedGroup = { ...mockTagGroup, ...updateDto };
|
||||
|
||||
tagGroupsService.update.mockResolvedValue(updatedGroup);
|
||||
|
||||
const result = await controller.update(mockUser, 'group-1', updateDto);
|
||||
|
||||
expect(result).toEqual(updatedGroup);
|
||||
expect(tagGroupsService.update).toHaveBeenCalledWith('group-1', 'test-user-id', updateDto);
|
||||
});
|
||||
|
||||
it('should propagate NotFoundException when group does not exist', async () => {
|
||||
const updateDto = { name: 'Updated' };
|
||||
|
||||
tagGroupsService.update.mockRejectedValue(new NotFoundException('Tag group not found'));
|
||||
|
||||
await expect(controller.update(mockUser, 'nonexistent', updateDto)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
|
||||
it('should propagate ConflictException when renaming to an existing name', async () => {
|
||||
const updateDto = { name: 'Kategorien' };
|
||||
|
||||
tagGroupsService.update.mockRejectedValue(
|
||||
new ConflictException('Tag group "Kategorien" already exists')
|
||||
);
|
||||
|
||||
await expect(controller.update(mockUser, 'group-2', updateDto)).rejects.toThrow(
|
||||
ConflictException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// DELETE /tag-groups/:id
|
||||
// ============================================================================
|
||||
|
||||
describe('DELETE /tag-groups/:id', () => {
|
||||
it('should delete a tag group and return void', async () => {
|
||||
tagGroupsService.delete.mockResolvedValue(undefined);
|
||||
|
||||
const result = await controller.delete(mockUser, 'group-1');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(tagGroupsService.delete).toHaveBeenCalledWith('group-1', 'test-user-id');
|
||||
});
|
||||
|
||||
it('should propagate NotFoundException when group does not exist', async () => {
|
||||
tagGroupsService.delete.mockRejectedValue(new NotFoundException('Tag group not found'));
|
||||
|
||||
await expect(controller.delete(mockUser, 'nonexistent')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { getDb } from '../db/connection';
|
||||
import { tagGroups, tags } from '../db/schema';
|
||||
import { CreateTagGroupDto } from './dto/create-tag-group.dto';
|
||||
import { UpdateTagGroupDto } from './dto/update-tag-group.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TagGroupsService {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
private getDb() {
|
||||
const databaseUrl = this.configService.get<string>('database.url');
|
||||
return getDb(databaseUrl!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tag groups for a user, ordered by sortOrder
|
||||
*/
|
||||
async findByUserId(userId: string) {
|
||||
const db = this.getDb();
|
||||
return db
|
||||
.select()
|
||||
.from(tagGroups)
|
||||
.where(eq(tagGroups.userId, userId))
|
||||
.orderBy(tagGroups.sortOrder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single tag group by ID (only if owned by user)
|
||||
*/
|
||||
async findById(id: string, userId: string) {
|
||||
const db = this.getDb();
|
||||
const [group] = await db
|
||||
.select()
|
||||
.from(tagGroups)
|
||||
.where(and(eq(tagGroups.id, id), eq(tagGroups.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
return group || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new tag group
|
||||
*/
|
||||
async create(userId: string, dto: CreateTagGroupDto) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Check for duplicate name
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(tagGroups)
|
||||
.where(and(eq(tagGroups.userId, userId), eq(tagGroups.name, dto.name)))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(`Tag group "${dto.name}" already exists`);
|
||||
}
|
||||
|
||||
const [group] = await db
|
||||
.insert(tagGroups)
|
||||
.values({
|
||||
userId,
|
||||
name: dto.name,
|
||||
color: dto.color || '#3B82F6',
|
||||
icon: dto.icon || null,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing tag group
|
||||
*/
|
||||
async update(id: string, userId: string, dto: UpdateTagGroupDto) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Verify group exists and belongs to user
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(tagGroups)
|
||||
.where(and(eq(tagGroups.id, id), eq(tagGroups.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException(`Tag group not found`);
|
||||
}
|
||||
|
||||
// Check for duplicate name if name is being changed
|
||||
if (dto.name && dto.name !== existing.name) {
|
||||
const [duplicate] = await db
|
||||
.select()
|
||||
.from(tagGroups)
|
||||
.where(and(eq(tagGroups.userId, userId), eq(tagGroups.name, dto.name)))
|
||||
.limit(1);
|
||||
|
||||
if (duplicate) {
|
||||
throw new ConflictException(`Tag group "${dto.name}" already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
const [group] = await db
|
||||
.update(tagGroups)
|
||||
.set({
|
||||
...dto,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(tagGroups.id, id), eq(tagGroups.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a tag group. Tags in the group get groupId set to null.
|
||||
*/
|
||||
async delete(id: string, userId: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Verify group exists and belongs to user
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(tagGroups)
|
||||
.where(and(eq(tagGroups.id, id), eq(tagGroups.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException(`Tag group not found`);
|
||||
}
|
||||
|
||||
// Unlink tags from this group (set groupId to null)
|
||||
await db
|
||||
.update(tags)
|
||||
.set({ groupId: null, updatedAt: new Date() })
|
||||
.where(and(eq(tags.groupId, id), eq(tags.userId, userId)));
|
||||
|
||||
// Delete the group
|
||||
await db.delete(tagGroups).where(and(eq(tagGroups.id, id), eq(tagGroups.userId, userId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder tag groups by providing an ordered array of IDs
|
||||
*/
|
||||
async reorder(userId: string, ids: string[]) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Verify all groups belong to user
|
||||
const userGroups = await db
|
||||
.select()
|
||||
.from(tagGroups)
|
||||
.where(and(eq(tagGroups.userId, userId), inArray(tagGroups.id, ids)));
|
||||
|
||||
if (userGroups.length !== ids.length) {
|
||||
throw new NotFoundException('One or more tag groups not found');
|
||||
}
|
||||
|
||||
// Update sort order for each group
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
await db
|
||||
.update(tagGroups)
|
||||
.set({ sortOrder: i, updatedAt: new Date() })
|
||||
.where(and(eq(tagGroups.id, ids[i]), eq(tagGroups.userId, userId)));
|
||||
}
|
||||
|
||||
// Return updated groups
|
||||
return this.findByUserId(userId);
|
||||
}
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from './create-tag-link.dto';
|
||||
export * from './query-tag-links.dto';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export * from './tag-links.module';
|
||||
export * from './tag-links.service';
|
||||
export * from './tag-links.controller';
|
||||
export * from './dto';
|
||||
|
|
@ -1,263 +0,0 @@
|
|||
import { Test } from '@nestjs/testing';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { TagLinksController } from './tag-links.controller';
|
||||
import { TagLinksService } from './tag-links.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import type { CurrentUserData } from '../common/decorators/current-user.decorator';
|
||||
|
||||
describe('TagLinksController', () => {
|
||||
let controller: TagLinksController;
|
||||
let tagLinksService: jest.Mocked<TagLinksService>;
|
||||
|
||||
const mockUser: CurrentUserData = {
|
||||
userId: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
role: 'user',
|
||||
};
|
||||
|
||||
const mockTagLink = {
|
||||
id: 'link-1',
|
||||
tagId: 'tag-1',
|
||||
appId: 'todo',
|
||||
entityId: 'task-1',
|
||||
entityType: 'task',
|
||||
userId: 'test-user-id',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const mockTag = {
|
||||
id: 'tag-1',
|
||||
userId: 'test-user-id',
|
||||
name: 'Arbeit',
|
||||
color: '#3B82F6',
|
||||
icon: 'Briefcase',
|
||||
groupId: null,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockTagLinksServiceValue = {
|
||||
create: jest.fn(),
|
||||
bulkCreate: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
query: jest.fn(),
|
||||
getTagsForEntity: jest.fn(),
|
||||
sync: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [TagLinksController],
|
||||
providers: [
|
||||
{
|
||||
provide: TagLinksService,
|
||||
useValue: mockTagLinksServiceValue,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue({ canActivate: jest.fn(() => true) })
|
||||
.compile();
|
||||
|
||||
controller = module.get<TagLinksController>(TagLinksController);
|
||||
tagLinksService = module.get(TagLinksService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /tag-links
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /tag-links', () => {
|
||||
it('should create a tag link and return it', async () => {
|
||||
const createDto = {
|
||||
tagId: 'tag-1',
|
||||
appId: 'todo',
|
||||
entityId: 'task-1',
|
||||
entityType: 'task',
|
||||
};
|
||||
|
||||
tagLinksService.create.mockResolvedValue(mockTagLink);
|
||||
|
||||
const result = await controller.create(mockUser, createDto);
|
||||
|
||||
expect(result).toEqual(mockTagLink);
|
||||
expect(tagLinksService.create).toHaveBeenCalledWith('test-user-id', createDto);
|
||||
});
|
||||
|
||||
it('should propagate NotFoundException when tag does not exist', async () => {
|
||||
const createDto = {
|
||||
tagId: 'nonexistent',
|
||||
appId: 'todo',
|
||||
entityId: 'task-1',
|
||||
entityType: 'task',
|
||||
};
|
||||
|
||||
tagLinksService.create.mockRejectedValue(new NotFoundException('Tag not found'));
|
||||
|
||||
await expect(controller.create(mockUser, createDto)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /tag-links/bulk
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /tag-links/bulk', () => {
|
||||
it('should bulk create tag links and return them', async () => {
|
||||
const links = [
|
||||
{ tagId: 'tag-1', appId: 'todo', entityId: 'task-1', entityType: 'task' },
|
||||
{ tagId: 'tag-2', appId: 'todo', entityId: 'task-1', entityType: 'task' },
|
||||
];
|
||||
const createdLinks = [mockTagLink, { ...mockTagLink, id: 'link-2', tagId: 'tag-2' }];
|
||||
|
||||
tagLinksService.bulkCreate.mockResolvedValue(createdLinks);
|
||||
|
||||
const result = await controller.bulkCreate(mockUser, { links });
|
||||
|
||||
expect(result).toEqual(createdLinks);
|
||||
expect(tagLinksService.bulkCreate).toHaveBeenCalledWith('test-user-id', links);
|
||||
});
|
||||
|
||||
it('should propagate NotFoundException when one or more tags not found', async () => {
|
||||
const links = [
|
||||
{ tagId: 'tag-1', appId: 'todo', entityId: 'task-1', entityType: 'task' },
|
||||
{ tagId: 'nonexistent', appId: 'todo', entityId: 'task-1', entityType: 'task' },
|
||||
];
|
||||
|
||||
tagLinksService.bulkCreate.mockRejectedValue(
|
||||
new NotFoundException('One or more tags not found')
|
||||
);
|
||||
|
||||
await expect(controller.bulkCreate(mockUser, { links })).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// PUT /tag-links/sync
|
||||
// ============================================================================
|
||||
|
||||
describe('PUT /tag-links/sync', () => {
|
||||
it('should sync entity tags and return updated tag list', async () => {
|
||||
const syncDto = {
|
||||
appId: 'todo',
|
||||
entityId: 'task-1',
|
||||
entityType: 'task',
|
||||
tagIds: ['tag-1', 'tag-3'],
|
||||
};
|
||||
const updatedTags = [mockTag, { ...mockTag, id: 'tag-3', name: 'Familie' }];
|
||||
|
||||
tagLinksService.sync.mockResolvedValue(updatedTags);
|
||||
|
||||
const result = await controller.sync(mockUser, syncDto);
|
||||
|
||||
expect(result).toEqual(updatedTags);
|
||||
expect(tagLinksService.sync).toHaveBeenCalledWith('test-user-id', 'todo', 'task-1', 'task', [
|
||||
'tag-1',
|
||||
'tag-3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should propagate NotFoundException when tags do not belong to user', async () => {
|
||||
const syncDto = {
|
||||
appId: 'todo',
|
||||
entityId: 'task-1',
|
||||
entityType: 'task',
|
||||
tagIds: ['nonexistent'],
|
||||
};
|
||||
|
||||
tagLinksService.sync.mockRejectedValue(new NotFoundException('One or more tags not found'));
|
||||
|
||||
await expect(controller.sync(mockUser, syncDto)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// GET /tag-links/tags-for-entity
|
||||
// ============================================================================
|
||||
|
||||
describe('GET /tag-links/tags-for-entity', () => {
|
||||
it('should return full tag objects for an entity', async () => {
|
||||
const entityTags = [mockTag];
|
||||
|
||||
tagLinksService.getTagsForEntity.mockResolvedValue(entityTags);
|
||||
|
||||
const result = await controller.getTagsForEntity(mockUser, {
|
||||
appId: 'todo',
|
||||
entityId: 'task-1',
|
||||
});
|
||||
|
||||
expect(result).toEqual(entityTags);
|
||||
expect(tagLinksService.getTagsForEntity).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
'todo',
|
||||
'task-1'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when entity has no tags', async () => {
|
||||
tagLinksService.getTagsForEntity.mockResolvedValue([]);
|
||||
|
||||
const result = await controller.getTagsForEntity(mockUser, {
|
||||
appId: 'todo',
|
||||
entityId: 'task-99',
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// GET /tag-links
|
||||
// ============================================================================
|
||||
|
||||
describe('GET /tag-links', () => {
|
||||
it('should query tag links with filters', async () => {
|
||||
const links = [mockTagLink];
|
||||
tagLinksService.query.mockResolvedValue(links);
|
||||
|
||||
const queryDto = { appId: 'todo', entityType: 'task' };
|
||||
|
||||
const result = await controller.query(mockUser, queryDto);
|
||||
|
||||
expect(result).toEqual(links);
|
||||
expect(tagLinksService.query).toHaveBeenCalledWith('test-user-id', queryDto);
|
||||
});
|
||||
|
||||
it('should return all links when no filters provided', async () => {
|
||||
const links = [mockTagLink, { ...mockTagLink, id: 'link-2', appId: 'calendar' }];
|
||||
tagLinksService.query.mockResolvedValue(links);
|
||||
|
||||
const result = await controller.query(mockUser, {});
|
||||
|
||||
expect(result).toEqual(links);
|
||||
expect(tagLinksService.query).toHaveBeenCalledWith('test-user-id', {});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// DELETE /tag-links/:id
|
||||
// ============================================================================
|
||||
|
||||
describe('DELETE /tag-links/:id', () => {
|
||||
it('should delete a tag link and return void', async () => {
|
||||
tagLinksService.delete.mockResolvedValue(undefined);
|
||||
|
||||
const result = await controller.delete(mockUser, 'link-1');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(tagLinksService.delete).toHaveBeenCalledWith('link-1', 'test-user-id');
|
||||
});
|
||||
|
||||
it('should propagate NotFoundException when link does not exist', async () => {
|
||||
tagLinksService.delete.mockRejectedValue(new NotFoundException('Tag link not found'));
|
||||
|
||||
await expect(controller.delete(mockUser, 'nonexistent')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -1,241 +0,0 @@
|
|||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { getDb } from '../db/connection';
|
||||
import { tagLinks, tags } from '../db/schema';
|
||||
import { CreateTagLinkDto } from './dto/create-tag-link.dto';
|
||||
import { QueryTagLinksDto } from './dto/query-tag-links.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TagLinksService {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
private getDb() {
|
||||
const databaseUrl = this.configService.get<string>('database.url');
|
||||
return getDb(databaseUrl!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a tag to an entity
|
||||
*/
|
||||
async create(userId: string, dto: CreateTagLinkDto) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Verify tag belongs to user
|
||||
const [tag] = await db
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(and(eq(tags.id, dto.tagId), eq(tags.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!tag) {
|
||||
throw new NotFoundException('Tag not found');
|
||||
}
|
||||
|
||||
const [link] = await db
|
||||
.insert(tagLinks)
|
||||
.values({
|
||||
tagId: dto.tagId,
|
||||
appId: dto.appId,
|
||||
entityId: dto.entityId,
|
||||
entityType: dto.entityType,
|
||||
userId,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
|
||||
// If conflict (already exists), return the existing link
|
||||
if (!link) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(tagLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(tagLinks.tagId, dto.tagId),
|
||||
eq(tagLinks.appId, dto.appId),
|
||||
eq(tagLinks.entityId, dto.entityId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
return existing;
|
||||
}
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk link tags to entities
|
||||
*/
|
||||
async bulkCreate(userId: string, dtos: CreateTagLinkDto[]) {
|
||||
if (dtos.length === 0) return [];
|
||||
|
||||
const db = this.getDb();
|
||||
|
||||
// Verify all tags belong to user
|
||||
const tagIds = [...new Set(dtos.map((d) => d.tagId))];
|
||||
const userTags = await db
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(and(inArray(tags.id, tagIds), eq(tags.userId, userId)));
|
||||
|
||||
if (userTags.length !== tagIds.length) {
|
||||
throw new NotFoundException('One or more tags not found');
|
||||
}
|
||||
|
||||
const values = dtos.map((dto) => ({
|
||||
tagId: dto.tagId,
|
||||
appId: dto.appId,
|
||||
entityId: dto.entityId,
|
||||
entityType: dto.entityType,
|
||||
userId,
|
||||
}));
|
||||
|
||||
const links = await db.insert(tagLinks).values(values).onConflictDoNothing().returning();
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a tag link by ID
|
||||
*/
|
||||
async delete(id: string, userId: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(tagLinks)
|
||||
.where(and(eq(tagLinks.id, id), eq(tagLinks.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Tag link not found');
|
||||
}
|
||||
|
||||
await db.delete(tagLinks).where(and(eq(tagLinks.id, id), eq(tagLinks.userId, userId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Query tag links with optional filters
|
||||
*/
|
||||
async query(userId: string, query: QueryTagLinksDto) {
|
||||
const db = this.getDb();
|
||||
|
||||
const conditions = [eq(tagLinks.userId, userId)];
|
||||
|
||||
if (query.appId) {
|
||||
conditions.push(eq(tagLinks.appId, query.appId));
|
||||
}
|
||||
if (query.entityId) {
|
||||
conditions.push(eq(tagLinks.entityId, query.entityId));
|
||||
}
|
||||
if (query.entityType) {
|
||||
conditions.push(eq(tagLinks.entityType, query.entityType));
|
||||
}
|
||||
if (query.tagId) {
|
||||
conditions.push(eq(tagLinks.tagId, query.tagId));
|
||||
}
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(tagLinks)
|
||||
.where(and(...conditions));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full Tag objects for a specific entity (joins with tags table)
|
||||
*/
|
||||
async getTagsForEntity(userId: string, appId: string, entityId: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
const results = await db
|
||||
.select({
|
||||
id: tags.id,
|
||||
userId: tags.userId,
|
||||
name: tags.name,
|
||||
color: tags.color,
|
||||
icon: tags.icon,
|
||||
groupId: tags.groupId,
|
||||
sortOrder: tags.sortOrder,
|
||||
createdAt: tags.createdAt,
|
||||
updatedAt: tags.updatedAt,
|
||||
})
|
||||
.from(tagLinks)
|
||||
.innerJoin(tags, eq(tagLinks.tagId, tags.id))
|
||||
.where(
|
||||
and(eq(tagLinks.userId, userId), eq(tagLinks.appId, appId), eq(tagLinks.entityId, entityId))
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync tags for an entity: adds missing links, removes extra ones.
|
||||
* Wrapped in a transaction to prevent race conditions.
|
||||
*/
|
||||
async sync(
|
||||
userId: string,
|
||||
appId: string,
|
||||
entityId: string,
|
||||
entityType: string,
|
||||
tagIds: string[]
|
||||
) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Verify all tags belong to user (before transaction)
|
||||
if (tagIds.length > 0) {
|
||||
const userTags = await db
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(and(inArray(tags.id, tagIds), eq(tags.userId, userId)));
|
||||
|
||||
if (userTags.length !== tagIds.length) {
|
||||
throw new NotFoundException('One or more tags not found');
|
||||
}
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
// Get current links for this entity
|
||||
const currentLinks = await tx
|
||||
.select()
|
||||
.from(tagLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(tagLinks.userId, userId),
|
||||
eq(tagLinks.appId, appId),
|
||||
eq(tagLinks.entityId, entityId)
|
||||
)
|
||||
);
|
||||
|
||||
const currentTagIds = currentLinks.map((l) => l.tagId);
|
||||
const toAdd = tagIds.filter((id) => !currentTagIds.includes(id));
|
||||
const toRemove = currentLinks.filter((l) => !tagIds.includes(l.tagId));
|
||||
|
||||
// Add missing links
|
||||
if (toAdd.length > 0) {
|
||||
await tx
|
||||
.insert(tagLinks)
|
||||
.values(
|
||||
toAdd.map((tagId) => ({
|
||||
tagId,
|
||||
appId,
|
||||
entityId,
|
||||
entityType,
|
||||
userId,
|
||||
}))
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
// Remove extra links
|
||||
if (toRemove.length > 0) {
|
||||
const removeIds = toRemove.map((l) => l.id);
|
||||
await tx
|
||||
.delete(tagLinks)
|
||||
.where(and(inArray(tagLinks.id, removeIds), eq(tagLinks.userId, userId)));
|
||||
}
|
||||
});
|
||||
|
||||
// Return updated tags for entity (after transaction commits)
|
||||
return this.getTagsForEntity(userId, appId, entityId);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from './create-tag.dto';
|
||||
export * from './update-tag.dto';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export * from './tags.module';
|
||||
export * from './tags.service';
|
||||
export * from './tags.controller';
|
||||
export * from './dto';
|
||||
|
|
@ -1,241 +0,0 @@
|
|||
import { Test } from '@nestjs/testing';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { TagsController } from './tags.controller';
|
||||
import { TagsService } from './tags.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import type { CurrentUserData } from '../common/decorators/current-user.decorator';
|
||||
|
||||
describe('TagsController', () => {
|
||||
let controller: TagsController;
|
||||
let tagsService: jest.Mocked<TagsService>;
|
||||
|
||||
const mockUser: CurrentUserData = {
|
||||
userId: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
role: 'user',
|
||||
};
|
||||
|
||||
const mockTag = {
|
||||
id: 'tag-1',
|
||||
userId: 'test-user-id',
|
||||
name: 'Arbeit',
|
||||
color: '#3B82F6',
|
||||
icon: 'Briefcase',
|
||||
groupId: null,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockTagsServiceValue = {
|
||||
findByUserId: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
getByIds: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
createDefaultTags: jest.fn(),
|
||||
findByGroupId: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [TagsController],
|
||||
providers: [
|
||||
{
|
||||
provide: TagsService,
|
||||
useValue: mockTagsServiceValue,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue({ canActivate: jest.fn(() => true) })
|
||||
.compile();
|
||||
|
||||
controller = module.get<TagsController>(TagsController);
|
||||
tagsService = module.get(TagsService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// GET /tags
|
||||
// ============================================================================
|
||||
|
||||
describe('GET /tags', () => {
|
||||
it('should return all tags for the authenticated user', async () => {
|
||||
const userTags = [
|
||||
mockTag,
|
||||
{ ...mockTag, id: 'tag-2', name: 'Persönlich', color: '#10B981', icon: 'User' },
|
||||
];
|
||||
|
||||
tagsService.findByUserId.mockResolvedValue(userTags);
|
||||
|
||||
const result = await controller.findAll(mockUser);
|
||||
|
||||
expect(result).toEqual(userTags);
|
||||
expect(tagsService.findByUserId).toHaveBeenCalledWith('test-user-id');
|
||||
});
|
||||
|
||||
it('should return empty array when user has no tags', async () => {
|
||||
tagsService.findByUserId.mockResolvedValue([]);
|
||||
|
||||
const result = await controller.findAll(mockUser);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// GET /tags/by-ids
|
||||
// ============================================================================
|
||||
|
||||
describe('GET /tags/by-ids', () => {
|
||||
it('should resolve tag IDs to full tag objects', async () => {
|
||||
const resolvedTags = [mockTag];
|
||||
tagsService.getByIds.mockResolvedValue(resolvedTags);
|
||||
|
||||
const result = await controller.getByIds(mockUser, 'tag-1,tag-2');
|
||||
|
||||
expect(result).toEqual(resolvedTags);
|
||||
expect(tagsService.getByIds).toHaveBeenCalledWith(['tag-1', 'tag-2'], 'test-user-id');
|
||||
});
|
||||
|
||||
it('should return empty array when no ids provided', async () => {
|
||||
const result = await controller.getByIds(mockUser, undefined);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(tagsService.getByIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return empty array when ids is empty string', async () => {
|
||||
const result = await controller.getByIds(mockUser, '');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(tagsService.getByIds).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// GET /tags/:id
|
||||
// ============================================================================
|
||||
|
||||
describe('GET /tags/:id', () => {
|
||||
it('should return a single tag by ID', async () => {
|
||||
tagsService.findById.mockResolvedValue(mockTag);
|
||||
|
||||
const result = await controller.findOne(mockUser, 'tag-1');
|
||||
|
||||
expect(result).toEqual(mockTag);
|
||||
expect(tagsService.findById).toHaveBeenCalledWith('tag-1', 'test-user-id');
|
||||
});
|
||||
|
||||
it('should return null when tag not found', async () => {
|
||||
tagsService.findById.mockResolvedValue(null as any);
|
||||
|
||||
const result = await controller.findOne(mockUser, 'nonexistent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /tags
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /tags', () => {
|
||||
it('should create a new tag and return it', async () => {
|
||||
const createDto = { name: 'Neuer Tag', color: '#FF5733', icon: 'Star' };
|
||||
const createdTag = { ...mockTag, ...createDto, id: 'tag-new' };
|
||||
|
||||
tagsService.create.mockResolvedValue(createdTag);
|
||||
|
||||
const result = await controller.create(mockUser, createDto);
|
||||
|
||||
expect(result).toEqual(createdTag);
|
||||
expect(tagsService.create).toHaveBeenCalledWith('test-user-id', createDto);
|
||||
});
|
||||
|
||||
it('should propagate ConflictException for duplicate tag name', async () => {
|
||||
const createDto = { name: 'Arbeit' };
|
||||
|
||||
tagsService.create.mockRejectedValue(new ConflictException('Tag "Arbeit" already exists'));
|
||||
|
||||
await expect(controller.create(mockUser, createDto)).rejects.toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /tags/defaults
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /tags/defaults', () => {
|
||||
it('should create default tags for the user', async () => {
|
||||
const defaultTags = [
|
||||
{ ...mockTag, name: 'Arbeit' },
|
||||
{ ...mockTag, id: 'tag-2', name: 'Persönlich' },
|
||||
{ ...mockTag, id: 'tag-3', name: 'Familie' },
|
||||
{ ...mockTag, id: 'tag-4', name: 'Wichtig' },
|
||||
];
|
||||
|
||||
tagsService.createDefaultTags.mockResolvedValue(defaultTags);
|
||||
|
||||
const result = await controller.createDefaults(mockUser);
|
||||
|
||||
expect(result).toEqual(defaultTags);
|
||||
expect(tagsService.createDefaultTags).toHaveBeenCalledWith('test-user-id');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// PUT /tags/:id
|
||||
// ============================================================================
|
||||
|
||||
describe('PUT /tags/:id', () => {
|
||||
it('should update a tag and return the updated version', async () => {
|
||||
const updateDto = { name: 'Aktualisiert', color: '#000000' };
|
||||
const updatedTag = { ...mockTag, ...updateDto };
|
||||
|
||||
tagsService.update.mockResolvedValue(updatedTag);
|
||||
|
||||
const result = await controller.update(mockUser, 'tag-1', updateDto);
|
||||
|
||||
expect(result).toEqual(updatedTag);
|
||||
expect(tagsService.update).toHaveBeenCalledWith('tag-1', 'test-user-id', updateDto);
|
||||
});
|
||||
|
||||
it('should propagate NotFoundException when tag does not exist', async () => {
|
||||
const updateDto = { name: 'Updated' };
|
||||
|
||||
tagsService.update.mockRejectedValue(new NotFoundException('Tag not found'));
|
||||
|
||||
await expect(controller.update(mockUser, 'nonexistent', updateDto)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// DELETE /tags/:id
|
||||
// ============================================================================
|
||||
|
||||
describe('DELETE /tags/:id', () => {
|
||||
it('should delete a tag and return void', async () => {
|
||||
tagsService.delete.mockResolvedValue(undefined);
|
||||
|
||||
const result = await controller.delete(mockUser, 'tag-1');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(tagsService.delete).toHaveBeenCalledWith('tag-1', 'test-user-id');
|
||||
});
|
||||
|
||||
it('should propagate NotFoundException when tag does not exist', async () => {
|
||||
tagsService.delete.mockRejectedValue(new NotFoundException('Tag not found'));
|
||||
|
||||
await expect(controller.delete(mockUser, 'nonexistent')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { getDb } from '../db/connection';
|
||||
import { tags } from '../db/schema';
|
||||
import { CreateTagDto } from './dto/create-tag.dto';
|
||||
import { UpdateTagDto } from './dto/update-tag.dto';
|
||||
|
||||
// Default tags created for new users
|
||||
const DEFAULT_TAGS = [
|
||||
{ name: 'Arbeit', color: '#3B82F6', icon: 'Briefcase' },
|
||||
{ name: 'Persönlich', color: '#10B981', icon: 'User' },
|
||||
{ name: 'Familie', color: '#EC4899', icon: 'Heart' },
|
||||
{ name: 'Wichtig', color: '#EF4444', icon: 'Star' },
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class TagsService {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
private getDb() {
|
||||
const databaseUrl = this.configService.get<string>('database.url');
|
||||
return getDb(databaseUrl!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tags for a user
|
||||
*/
|
||||
async findByUserId(userId: string) {
|
||||
const db = this.getDb();
|
||||
return db.select().from(tags).where(eq(tags.userId, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single tag by ID (only if owned by user)
|
||||
*/
|
||||
async findById(id: string, userId: string) {
|
||||
const db = this.getDb();
|
||||
const [tag] = await db
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(and(eq(tags.id, id), eq(tags.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
return tag || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple tags by IDs (only those owned by user)
|
||||
* Used by apps to resolve tagIds to full tag objects
|
||||
*/
|
||||
async getByIds(ids: string[], userId: string) {
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
const db = this.getDb();
|
||||
return db
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(and(inArray(tags.id, ids), eq(tags.userId, userId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new tag
|
||||
*/
|
||||
async create(userId: string, dto: CreateTagDto) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Check for duplicate name
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(and(eq(tags.userId, userId), eq(tags.name, dto.name)))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(`Tag "${dto.name}" already exists`);
|
||||
}
|
||||
|
||||
const [tag] = await db
|
||||
.insert(tags)
|
||||
.values({
|
||||
userId,
|
||||
name: dto.name,
|
||||
color: dto.color || '#3B82F6',
|
||||
icon: dto.icon || null,
|
||||
groupId: dto.groupId || null,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing tag
|
||||
*/
|
||||
async update(id: string, userId: string, dto: UpdateTagDto) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Verify tag exists and belongs to user
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(and(eq(tags.id, id), eq(tags.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException(`Tag not found`);
|
||||
}
|
||||
|
||||
// Check for duplicate name if name is being changed
|
||||
if (dto.name && dto.name !== existing.name) {
|
||||
const [duplicate] = await db
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(and(eq(tags.userId, userId), eq(tags.name, dto.name)))
|
||||
.limit(1);
|
||||
|
||||
if (duplicate) {
|
||||
throw new ConflictException(`Tag "${dto.name}" already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
const [tag] = await db
|
||||
.update(tags)
|
||||
.set({
|
||||
...dto,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(tags.id, id), eq(tags.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a tag
|
||||
*/
|
||||
async delete(id: string, userId: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Verify tag exists and belongs to user
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(and(eq(tags.id, id), eq(tags.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException(`Tag not found`);
|
||||
}
|
||||
|
||||
await db.delete(tags).where(and(eq(tags.id, id), eq(tags.userId, userId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tags in a specific group (only those owned by user)
|
||||
*/
|
||||
async findByGroupId(groupId: string, userId: string) {
|
||||
const db = this.getDb();
|
||||
return db
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(and(eq(tags.groupId, groupId), eq(tags.userId, userId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default tags for a new user
|
||||
* Called during user registration or first access
|
||||
*/
|
||||
async createDefaultTags(userId: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Check if user already has tags
|
||||
const existingTags = await db.select().from(tags).where(eq(tags.userId, userId)).limit(1);
|
||||
|
||||
if (existingTags.length > 0) {
|
||||
// User already has tags, return existing
|
||||
return this.findByUserId(userId);
|
||||
}
|
||||
|
||||
// Create default tags
|
||||
const createdTags = await db
|
||||
.insert(tags)
|
||||
.values(
|
||||
DEFAULT_TAGS.map((tag) => ({
|
||||
userId,
|
||||
name: tag.name,
|
||||
color: tag.color,
|
||||
icon: tag.icon,
|
||||
}))
|
||||
)
|
||||
.returning();
|
||||
|
||||
return createdTags;
|
||||
}
|
||||
}
|
||||
72
services/mana-user/CLAUDE.md
Normal file
72
services/mana-user/CLAUDE.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# mana-user
|
||||
|
||||
User preferences, tags, and storage service. Extracted from mana-core-auth.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| **Runtime** | Bun |
|
||||
| **Framework** | Hono |
|
||||
| **Database** | PostgreSQL + Drizzle ORM |
|
||||
| **Auth** | JWT validation via JWKS from mana-core-auth |
|
||||
|
||||
## Port: 3062
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
bun run dev # Start with hot reload
|
||||
bun run db:push # Push schema to DB
|
||||
bun run db:studio # Open Drizzle Studio
|
||||
```
|
||||
|
||||
## API Endpoints (all JWT auth)
|
||||
|
||||
### Tags (`/api/v1/tags`)
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/` | List user's tags |
|
||||
| POST | `/` | Create tag |
|
||||
| PUT | `/:id` | Update tag |
|
||||
| DELETE | `/:id` | Delete tag |
|
||||
| POST | `/defaults` | Create default tags |
|
||||
| POST | `/resolve` | Batch resolve by IDs |
|
||||
|
||||
### Tag Groups (`/api/v1/tag-groups`)
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/` | List user's groups |
|
||||
| POST | `/` | Create group |
|
||||
| PUT | `/:id` | Update group |
|
||||
| DELETE | `/:id` | Delete group |
|
||||
|
||||
### Tag Links (`/api/v1/tag-links`)
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/entity?appId=&entityId=` | Get tags for entity |
|
||||
| POST | `/` | Create link |
|
||||
| POST | `/sync` | Sync all links for entity |
|
||||
| GET | `/query?appId=&tagId=` | Query links |
|
||||
| DELETE | `/:id` | Delete link |
|
||||
|
||||
### Settings (`/api/v1/settings`)
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/` | Get user settings |
|
||||
| PUT | `/global` | Update global settings |
|
||||
| PUT | `/app/:appId` | Update app-specific override |
|
||||
| PUT | `/device/:deviceId` | Update device-specific settings |
|
||||
|
||||
## Database: `mana_user`
|
||||
|
||||
Tables: tags, tag_groups, tag_links, user_settings
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```env
|
||||
PORT=3062
|
||||
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/mana_user
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
CORS_ORIGINS=http://localhost:5173
|
||||
```
|
||||
16
services/mana-user/Dockerfile
Normal file
16
services/mana-user/Dockerfile
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
FROM oven/bun:1 AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json bun.lock* ./
|
||||
RUN bun install --frozen-lockfile 2>/dev/null || bun install
|
||||
|
||||
COPY src ./src
|
||||
COPY tsconfig.json drizzle.config.ts ./
|
||||
|
||||
EXPOSE 3062
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||
CMD bun -e "fetch('http://localhost:3062/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||
|
||||
CMD ["bun", "run", "src/index.ts"]
|
||||
10
services/mana-user/drizzle.config.ts
Normal file
10
services/mana-user/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/db/schema/*.ts',
|
||||
out: './drizzle',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/mana_user',
|
||||
},
|
||||
});
|
||||
24
services/mana-user/package.json
Normal file
24
services/mana-user/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "@mana/user",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"start": "bun run src/index.ts",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.7.0",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"postgres": "^3.4.5",
|
||||
"jose": "^6.1.2",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"drizzle-kit": "^0.30.4",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
18
services/mana-user/src/config.ts
Normal file
18
services/mana-user/src/config.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export interface Config {
|
||||
port: number;
|
||||
databaseUrl: string;
|
||||
manaAuthUrl: string;
|
||||
serviceKey: string;
|
||||
cors: { origins: string[] };
|
||||
}
|
||||
|
||||
export function loadConfig(): Config {
|
||||
const env = (key: string, fallback?: string) => process.env[key] || fallback || '';
|
||||
return {
|
||||
port: parseInt(env('PORT', '3062'), 10),
|
||||
databaseUrl: env('DATABASE_URL', 'postgresql://manacore:devpassword@localhost:5432/mana_user'),
|
||||
manaAuthUrl: env('MANA_CORE_AUTH_URL', 'http://localhost:3001'),
|
||||
serviceKey: env('MANA_CORE_SERVICE_KEY', 'dev-service-key'),
|
||||
cors: { origins: env('CORS_ORIGINS', 'http://localhost:5173').split(',') },
|
||||
};
|
||||
}
|
||||
15
services/mana-user/src/db/connection.ts
Normal file
15
services/mana-user/src/db/connection.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema/index';
|
||||
|
||||
let db: ReturnType<typeof drizzle<typeof schema>> | null = null;
|
||||
|
||||
export function getDb(databaseUrl: string) {
|
||||
if (!db) {
|
||||
const client = postgres(databaseUrl, { max: 10 });
|
||||
db = drizzle(client, { schema });
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export type Database = ReturnType<typeof getDb>;
|
||||
4
services/mana-user/src/db/schema/index.ts
Normal file
4
services/mana-user/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './tag-groups';
|
||||
export * from './tags';
|
||||
export * from './tag-links';
|
||||
export * from './settings';
|
||||
18
services/mana-user/src/db/schema/settings.ts
Normal file
18
services/mana-user/src/db/schema/settings.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { pgTable, text, jsonb, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const userSettings = pgTable('user_settings', {
|
||||
userId: text('user_id').primaryKey(),
|
||||
globalSettings: jsonb('global_settings')
|
||||
.default({
|
||||
nav: { desktopPosition: 'top', sidebarCollapsed: false },
|
||||
theme: { mode: 'system', colorScheme: 'ocean' },
|
||||
locale: 'de',
|
||||
})
|
||||
.notNull(),
|
||||
appOverrides: jsonb('app_overrides').default({}).notNull(),
|
||||
deviceSettings: jsonb('device_settings').default({}).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type UserSettings = typeof userSettings.$inferSelect;
|
||||
|
|
@ -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',
|
||||
|
|
@ -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(),
|
||||
50
services/mana-user/src/index.ts
Normal file
50
services/mana-user/src/index.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* mana-user — User preferences, tags, and storage service
|
||||
*
|
||||
* Hono + Bun runtime. Extracted from mana-core-auth.
|
||||
* Handles: user settings, tags, tag groups, tag links, avatar storage.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { loadConfig } from './config';
|
||||
import { getDb } from './db/connection';
|
||||
import { errorHandler } from './middleware/error-handler';
|
||||
import { jwtAuth } from './middleware/jwt-auth';
|
||||
import { TagsService } from './services/tags';
|
||||
import { SettingsService } from './services/settings';
|
||||
import { healthRoutes } from './routes/health';
|
||||
import { createTagRoutes } from './routes/tags';
|
||||
import { createTagGroupRoutes } from './routes/tag-groups';
|
||||
import { createTagLinkRoutes } from './routes/tag-links';
|
||||
import { createSettingsRoutes } from './routes/settings';
|
||||
|
||||
const config = loadConfig();
|
||||
const db = getDb(config.databaseUrl);
|
||||
|
||||
const tagsService = new TagsService(db);
|
||||
const settingsService = new SettingsService(db);
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.onError(errorHandler);
|
||||
app.use('*', cors({ origin: config.cors.origins, credentials: true }));
|
||||
|
||||
// Health (no auth)
|
||||
app.route('/health', healthRoutes);
|
||||
|
||||
// All API routes require JWT auth
|
||||
app.use('/api/v1/*', jwtAuth(config.manaAuthUrl));
|
||||
|
||||
// Routes
|
||||
app.route('/api/v1/tags', createTagRoutes(tagsService));
|
||||
app.route('/api/v1/tag-groups', createTagGroupRoutes(tagsService));
|
||||
app.route('/api/v1/tag-links', createTagLinkRoutes(tagsService));
|
||||
app.route('/api/v1/settings', createSettingsRoutes(settingsService));
|
||||
|
||||
console.log(`mana-user starting on port ${config.port}...`);
|
||||
|
||||
export default {
|
||||
port: config.port,
|
||||
fetch: app.fetch,
|
||||
};
|
||||
43
services/mana-user/src/lib/errors.ts
Normal file
43
services/mana-user/src/lib/errors.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { HTTPException } from 'hono/http-exception';
|
||||
|
||||
export class BadRequestError extends HTTPException {
|
||||
constructor(message: string) {
|
||||
super(400, { message });
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends HTTPException {
|
||||
constructor(message = 'Unauthorized') {
|
||||
super(401, { message });
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends HTTPException {
|
||||
constructor(message = 'Forbidden') {
|
||||
super(403, { message });
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends HTTPException {
|
||||
constructor(message = 'Not found') {
|
||||
super(404, { message });
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends HTTPException {
|
||||
constructor(message = 'Conflict') {
|
||||
super(409, { message });
|
||||
}
|
||||
}
|
||||
|
||||
export class InsufficientCreditsError extends HTTPException {
|
||||
constructor(
|
||||
public readonly required: number,
|
||||
public readonly available: number
|
||||
) {
|
||||
super(402, {
|
||||
message: 'Insufficient credits',
|
||||
cause: { required, available },
|
||||
});
|
||||
}
|
||||
}
|
||||
29
services/mana-user/src/middleware/error-handler.ts
Normal file
29
services/mana-user/src/middleware/error-handler.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Global error handler middleware for Hono.
|
||||
*/
|
||||
|
||||
import type { ErrorHandler } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
|
||||
export const errorHandler: ErrorHandler = (err, c) => {
|
||||
if (err instanceof HTTPException) {
|
||||
const cause = err.cause as Record<string, unknown> | undefined;
|
||||
return c.json(
|
||||
{
|
||||
statusCode: err.status,
|
||||
message: err.message,
|
||||
...(cause ? { details: cause } : {}),
|
||||
},
|
||||
err.status
|
||||
);
|
||||
}
|
||||
|
||||
console.error('Unhandled error:', err);
|
||||
return c.json(
|
||||
{
|
||||
statusCode: 500,
|
||||
message: 'Internal server error',
|
||||
},
|
||||
500
|
||||
);
|
||||
};
|
||||
57
services/mana-user/src/middleware/jwt-auth.ts
Normal file
57
services/mana-user/src/middleware/jwt-auth.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* JWT Authentication Middleware
|
||||
*
|
||||
* Validates Bearer tokens via JWKS from mana-core-auth.
|
||||
* Uses jose library with EdDSA algorithm.
|
||||
*/
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
import { UnauthorizedError } from '../lib/errors';
|
||||
|
||||
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||
|
||||
function getJwks(authUrl: string) {
|
||||
if (!jwks) {
|
||||
jwks = createRemoteJWKSet(new URL('/api/auth/jwks', authUrl));
|
||||
}
|
||||
return jwks;
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware that validates JWT tokens from Authorization: Bearer header.
|
||||
* Sets c.set('user', { userId, email, role }) on success.
|
||||
*/
|
||||
export function jwtAuth(authUrl: string): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedError('Missing or invalid Authorization header');
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getJwks(authUrl), {
|
||||
issuer: authUrl,
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const user: AuthUser = {
|
||||
userId: payload.sub || '',
|
||||
email: (payload.email as string) || '',
|
||||
role: (payload.role as string) || 'user',
|
||||
};
|
||||
|
||||
c.set('user', user);
|
||||
await next();
|
||||
} catch {
|
||||
throw new UnauthorizedError('Invalid or expired token');
|
||||
}
|
||||
};
|
||||
}
|
||||
26
services/mana-user/src/middleware/service-auth.ts
Normal file
26
services/mana-user/src/middleware/service-auth.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* Service-to-Service Authentication Middleware
|
||||
*
|
||||
* Validates X-Service-Key header for backend-to-backend calls.
|
||||
* Used by /internal/* routes.
|
||||
*/
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { UnauthorizedError } from '../lib/errors';
|
||||
|
||||
/**
|
||||
* Middleware that validates X-Service-Key header.
|
||||
* Sets c.set('appId', ...) from X-App-Id header.
|
||||
*/
|
||||
export function serviceAuth(serviceKey: string): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
const key = c.req.header('X-Service-Key');
|
||||
if (!key || key !== serviceKey) {
|
||||
throw new UnauthorizedError('Invalid or missing service key');
|
||||
}
|
||||
|
||||
const appId = c.req.header('X-App-Id') || 'unknown';
|
||||
c.set('appId', appId);
|
||||
await next();
|
||||
};
|
||||
}
|
||||
5
services/mana-user/src/routes/health.ts
Normal file
5
services/mana-user/src/routes/health.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Hono } from 'hono';
|
||||
|
||||
export const healthRoutes = new Hono().get('/', (c) =>
|
||||
c.json({ status: 'ok', service: 'mana-user', timestamp: new Date().toISOString() })
|
||||
);
|
||||
30
services/mana-user/src/routes/settings.ts
Normal file
30
services/mana-user/src/routes/settings.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { Hono } from 'hono';
|
||||
import type { SettingsService } from '../services/settings';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
|
||||
export function createSettingsRoutes(settingsService: SettingsService) {
|
||||
return new Hono<{ Variables: { user: AuthUser } }>()
|
||||
.get('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
return c.json(await settingsService.getSettings(user.userId));
|
||||
})
|
||||
.put('/global', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = await c.req.json();
|
||||
return c.json(await settingsService.updateGlobalSettings(user.userId, body));
|
||||
})
|
||||
.put('/app/:appId', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = await c.req.json();
|
||||
return c.json(
|
||||
await settingsService.updateAppOverride(user.userId, c.req.param('appId'), body)
|
||||
);
|
||||
})
|
||||
.put('/device/:deviceId', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = await c.req.json();
|
||||
return c.json(
|
||||
await settingsService.updateDeviceSettings(user.userId, c.req.param('deviceId'), body)
|
||||
);
|
||||
});
|
||||
}
|
||||
26
services/mana-user/src/routes/tag-groups.ts
Normal file
26
services/mana-user/src/routes/tag-groups.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { Hono } from 'hono';
|
||||
import type { TagsService } from '../services/tags';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
|
||||
export function createTagGroupRoutes(tagsService: TagsService) {
|
||||
return new Hono<{ Variables: { user: AuthUser } }>()
|
||||
.get('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
return c.json(await tagsService.getUserGroups(user.userId));
|
||||
})
|
||||
.post('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = await c.req.json();
|
||||
return c.json(await tagsService.createGroup(user.userId, body), 201);
|
||||
})
|
||||
.put('/:id', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = await c.req.json();
|
||||
return c.json(await tagsService.updateGroup(user.userId, c.req.param('id'), body));
|
||||
})
|
||||
.delete('/:id', async (c) => {
|
||||
const user = c.get('user');
|
||||
await tagsService.deleteGroup(user.userId, c.req.param('id'));
|
||||
return c.json({ success: true });
|
||||
});
|
||||
}
|
||||
40
services/mana-user/src/routes/tag-links.ts
Normal file
40
services/mana-user/src/routes/tag-links.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Hono } from 'hono';
|
||||
import type { TagsService } from '../services/tags';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
|
||||
export function createTagLinkRoutes(tagsService: TagsService) {
|
||||
return new Hono<{ Variables: { user: AuthUser } }>()
|
||||
.get('/entity', async (c) => {
|
||||
const user = c.get('user');
|
||||
const appId = c.req.query('appId') || '';
|
||||
const entityId = c.req.query('entityId') || '';
|
||||
const resolved = await tagsService.getLinksForEntity(user.userId, appId, entityId);
|
||||
return c.json(resolved);
|
||||
})
|
||||
.post('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = await c.req.json();
|
||||
const link = await tagsService.createLink(user.userId, body);
|
||||
return c.json(link, 201);
|
||||
})
|
||||
.post('/sync', async (c) => {
|
||||
const user = c.get('user');
|
||||
const { appId, entityId, entityType, tagIds } = await c.req.json();
|
||||
const result = await tagsService.syncLinks(user.userId, appId, entityId, entityType, tagIds);
|
||||
return c.json(result);
|
||||
})
|
||||
.get('/query', async (c) => {
|
||||
const user = c.get('user');
|
||||
const links = await tagsService.queryLinks(user.userId, {
|
||||
appId: c.req.query('appId'),
|
||||
entityId: c.req.query('entityId'),
|
||||
tagId: c.req.query('tagId'),
|
||||
});
|
||||
return c.json(links);
|
||||
})
|
||||
.delete('/:id', async (c) => {
|
||||
const user = c.get('user');
|
||||
await tagsService.deleteLink(user.userId, c.req.param('id'));
|
||||
return c.json({ success: true });
|
||||
});
|
||||
}
|
||||
42
services/mana-user/src/routes/tags.ts
Normal file
42
services/mana-user/src/routes/tags.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { Hono } from 'hono';
|
||||
import type { TagsService } from '../services/tags';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
|
||||
export function createTagRoutes(tagsService: TagsService) {
|
||||
return (
|
||||
new Hono<{ Variables: { user: AuthUser } }>()
|
||||
.get('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
const allTags = await tagsService.getUserTags(user.userId);
|
||||
return c.json(allTags);
|
||||
})
|
||||
.post('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = await c.req.json();
|
||||
const tag = await tagsService.createTag(user.userId, body);
|
||||
return c.json(tag, 201);
|
||||
})
|
||||
.put('/:id', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = await c.req.json();
|
||||
const tag = await tagsService.updateTag(user.userId, c.req.param('id'), body);
|
||||
return c.json(tag);
|
||||
})
|
||||
.delete('/:id', async (c) => {
|
||||
const user = c.get('user');
|
||||
await tagsService.deleteTag(user.userId, c.req.param('id'));
|
||||
return c.json({ success: true });
|
||||
})
|
||||
.post('/defaults', async (c) => {
|
||||
const user = c.get('user');
|
||||
const defaultTags = await tagsService.createDefaultTags(user.userId);
|
||||
return c.json(defaultTags);
|
||||
})
|
||||
// Batch resolve
|
||||
.post('/resolve', async (c) => {
|
||||
const { ids } = await c.req.json();
|
||||
const resolved = await tagsService.getTagsByIds(ids || []);
|
||||
return c.json(resolved);
|
||||
})
|
||||
);
|
||||
}
|
||||
92
services/mana-user/src/services/settings.ts
Normal file
92
services/mana-user/src/services/settings.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* Settings Service — User preferences, theme, nav, device settings
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { userSettings } from '../db/schema/settings';
|
||||
import type { Database } from '../db/connection';
|
||||
|
||||
export class SettingsService {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
async getSettings(userId: string) {
|
||||
const [settings] = await this.db
|
||||
.select()
|
||||
.from(userSettings)
|
||||
.where(eq(userSettings.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!settings) return this.initializeSettings(userId);
|
||||
return settings;
|
||||
}
|
||||
|
||||
async initializeSettings(userId: string) {
|
||||
const [settings] = await this.db
|
||||
.insert(userSettings)
|
||||
.values({ userId })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
|
||||
if (!settings) {
|
||||
// Already exists, fetch it
|
||||
const [existing] = await this.db
|
||||
.select()
|
||||
.from(userSettings)
|
||||
.where(eq(userSettings.userId, userId))
|
||||
.limit(1);
|
||||
return existing;
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
async updateGlobalSettings(userId: string, updates: Record<string, unknown>) {
|
||||
const current = await this.getSettings(userId);
|
||||
const merged = { ...(current.globalSettings as Record<string, unknown>), ...updates };
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(userSettings)
|
||||
.set({ globalSettings: merged, updatedAt: new Date() })
|
||||
.where(eq(userSettings.userId, userId))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateAppOverride(userId: string, appId: string, overrides: Record<string, unknown>) {
|
||||
const current = await this.getSettings(userId);
|
||||
const appOverrides = { ...(current.appOverrides as Record<string, unknown>) };
|
||||
appOverrides[appId] = {
|
||||
...((appOverrides[appId] as Record<string, unknown>) || {}),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(userSettings)
|
||||
.set({ appOverrides, updatedAt: new Date() })
|
||||
.where(eq(userSettings.userId, userId))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateDeviceSettings(userId: string, deviceId: string, settings: Record<string, unknown>) {
|
||||
const current = await this.getSettings(userId);
|
||||
const deviceSettings = { ...(current.deviceSettings as Record<string, unknown>) };
|
||||
deviceSettings[deviceId] = {
|
||||
...((deviceSettings[deviceId] as Record<string, unknown>) || {}),
|
||||
...settings,
|
||||
};
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(userSettings)
|
||||
.set({ deviceSettings, updatedAt: new Date() })
|
||||
.where(eq(userSettings.userId, userId))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteSettings(userId: string) {
|
||||
await this.db.delete(userSettings).where(eq(userSettings.userId, userId));
|
||||
}
|
||||
}
|
||||
210
services/mana-user/src/services/tags.ts
Normal file
210
services/mana-user/src/services/tags.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
/**
|
||||
* Tags Service — CRUD for user tags, tag groups, and tag links
|
||||
*/
|
||||
|
||||
import { eq, and, desc, inArray } from 'drizzle-orm';
|
||||
import { tags, tagGroups, tagLinks } from '../db/schema/index';
|
||||
import type { Database } from '../db/connection';
|
||||
import { NotFoundError, BadRequestError } from '../lib/errors';
|
||||
|
||||
const DEFAULT_TAGS = [
|
||||
{ name: 'Arbeit', color: '#3B82F6', icon: 'briefcase' },
|
||||
{ name: 'Persönlich', color: '#10B981', icon: 'user' },
|
||||
{ name: 'Familie', color: '#F59E0B', icon: 'users' },
|
||||
{ name: 'Wichtig', color: '#EF4444', icon: 'star' },
|
||||
];
|
||||
|
||||
export class TagsService {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
// ─── Tags ───────────────────────────────────────────────
|
||||
|
||||
async getUserTags(userId: string) {
|
||||
return this.db.select().from(tags).where(eq(tags.userId, userId)).orderBy(tags.sortOrder);
|
||||
}
|
||||
|
||||
async getTagById(userId: string, tagId: string) {
|
||||
const [tag] = await this.db
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(and(eq(tags.id, tagId), eq(tags.userId, userId)))
|
||||
.limit(1);
|
||||
return tag;
|
||||
}
|
||||
|
||||
async getTagsByIds(tagIds: string[]) {
|
||||
if (tagIds.length === 0) return [];
|
||||
return this.db.select().from(tags).where(inArray(tags.id, tagIds));
|
||||
}
|
||||
|
||||
async createTag(
|
||||
userId: string,
|
||||
data: { name: string; color?: string; icon?: string; groupId?: string; sortOrder?: number }
|
||||
) {
|
||||
const [tag] = await this.db
|
||||
.insert(tags)
|
||||
.values({ userId, ...data })
|
||||
.returning();
|
||||
return tag;
|
||||
}
|
||||
|
||||
async updateTag(
|
||||
userId: string,
|
||||
tagId: string,
|
||||
data: {
|
||||
name?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
groupId?: string | null;
|
||||
sortOrder?: number;
|
||||
}
|
||||
) {
|
||||
const [tag] = await this.db
|
||||
.update(tags)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(and(eq(tags.id, tagId), eq(tags.userId, userId)))
|
||||
.returning();
|
||||
if (!tag) throw new NotFoundError('Tag not found');
|
||||
return tag;
|
||||
}
|
||||
|
||||
async deleteTag(userId: string, tagId: string) {
|
||||
const result = await this.db
|
||||
.delete(tags)
|
||||
.where(and(eq(tags.id, tagId), eq(tags.userId, userId)))
|
||||
.returning();
|
||||
if (result.length === 0) throw new NotFoundError('Tag not found');
|
||||
}
|
||||
|
||||
async createDefaultTags(userId: string) {
|
||||
const existing = await this.getUserTags(userId);
|
||||
if (existing.length > 0) return existing;
|
||||
|
||||
const created = [];
|
||||
for (let i = 0; i < DEFAULT_TAGS.length; i++) {
|
||||
const [tag] = await this.db
|
||||
.insert(tags)
|
||||
.values({ userId, ...DEFAULT_TAGS[i], sortOrder: i })
|
||||
.returning();
|
||||
created.push(tag);
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
// ─── Tag Groups ─────────────────────────────────────────
|
||||
|
||||
async getUserGroups(userId: string) {
|
||||
return this.db
|
||||
.select()
|
||||
.from(tagGroups)
|
||||
.where(eq(tagGroups.userId, userId))
|
||||
.orderBy(tagGroups.sortOrder);
|
||||
}
|
||||
|
||||
async createGroup(
|
||||
userId: string,
|
||||
data: { name: string; color?: string; icon?: string; sortOrder?: number }
|
||||
) {
|
||||
const [group] = await this.db
|
||||
.insert(tagGroups)
|
||||
.values({ userId, ...data })
|
||||
.returning();
|
||||
return group;
|
||||
}
|
||||
|
||||
async updateGroup(
|
||||
userId: string,
|
||||
groupId: string,
|
||||
data: { name?: string; color?: string; icon?: string; sortOrder?: number }
|
||||
) {
|
||||
const [group] = await this.db
|
||||
.update(tagGroups)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(and(eq(tagGroups.id, groupId), eq(tagGroups.userId, userId)))
|
||||
.returning();
|
||||
if (!group) throw new NotFoundError('Tag group not found');
|
||||
return group;
|
||||
}
|
||||
|
||||
async deleteGroup(userId: string, groupId: string) {
|
||||
// Unlink tags from this group first (set groupId to null)
|
||||
await this.db.update(tags).set({ groupId: null }).where(eq(tags.groupId, groupId));
|
||||
const result = await this.db
|
||||
.delete(tagGroups)
|
||||
.where(and(eq(tagGroups.id, groupId), eq(tagGroups.userId, userId)))
|
||||
.returning();
|
||||
if (result.length === 0) throw new NotFoundError('Tag group not found');
|
||||
}
|
||||
|
||||
// ─── Tag Links ──────────────────────────────────────────
|
||||
|
||||
async getLinksForEntity(userId: string, appId: string, entityId: string) {
|
||||
const links = await this.db
|
||||
.select()
|
||||
.from(tagLinks)
|
||||
.where(
|
||||
and(eq(tagLinks.userId, userId), eq(tagLinks.appId, appId), eq(tagLinks.entityId, entityId))
|
||||
);
|
||||
|
||||
// Resolve full tag objects
|
||||
const tagIds = links.map((l) => l.tagId);
|
||||
const resolvedTags = tagIds.length > 0 ? await this.getTagsByIds(tagIds) : [];
|
||||
return resolvedTags;
|
||||
}
|
||||
|
||||
async createLink(
|
||||
userId: string,
|
||||
data: { tagId: string; appId: string; entityId: string; entityType: string }
|
||||
) {
|
||||
const [link] = await this.db
|
||||
.insert(tagLinks)
|
||||
.values({ userId, ...data })
|
||||
.returning();
|
||||
return link;
|
||||
}
|
||||
|
||||
async syncLinks(
|
||||
userId: string,
|
||||
appId: string,
|
||||
entityId: string,
|
||||
entityType: string,
|
||||
tagIds: string[]
|
||||
) {
|
||||
return this.db.transaction(async (tx) => {
|
||||
// Delete all existing links for this entity
|
||||
await tx
|
||||
.delete(tagLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(tagLinks.userId, userId),
|
||||
eq(tagLinks.appId, appId),
|
||||
eq(tagLinks.entityId, entityId)
|
||||
)
|
||||
);
|
||||
|
||||
// Insert new links
|
||||
if (tagIds.length > 0) {
|
||||
await tx
|
||||
.insert(tagLinks)
|
||||
.values(tagIds.map((tagId) => ({ tagId, appId, entityId, entityType, userId })));
|
||||
}
|
||||
|
||||
return { synced: tagIds.length };
|
||||
});
|
||||
}
|
||||
|
||||
async deleteLink(userId: string, linkId: string) {
|
||||
await this.db.delete(tagLinks).where(and(eq(tagLinks.id, linkId), eq(tagLinks.userId, userId)));
|
||||
}
|
||||
|
||||
async queryLinks(
|
||||
userId: string,
|
||||
filters: { appId?: string; entityId?: string; entityType?: string; tagId?: string }
|
||||
) {
|
||||
let query = this.db.select().from(tagLinks).where(eq(tagLinks.userId, userId)).$dynamic();
|
||||
if (filters.appId) query = query.where(eq(tagLinks.appId, filters.appId));
|
||||
if (filters.entityId) query = query.where(eq(tagLinks.entityId, filters.entityId));
|
||||
if (filters.tagId) query = query.where(eq(tagLinks.tagId, filters.tagId));
|
||||
return query;
|
||||
}
|
||||
}
|
||||
13
services/mana-user/tsconfig.json
Normal file
13
services/mana-user/tsconfig.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue