mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +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
|
├── services/ # Standalone microservices
|
||||||
│ ├── mana-core-auth/ # Central authentication service
|
│ ├── mana-core-auth/ # Central authentication service
|
||||||
│ ├── mana-credits/ # Credit system (Hono + Bun, extracted from auth)
|
│ ├── mana-credits/ # Credit system (Hono + Bun, extracted from auth)
|
||||||
|
│ ├── mana-user/ # User settings, tags, storage (Hono + Bun, extracted from auth)
|
||||||
│ ├── mana-search/ # Central search & content extraction (NestJS, legacy)
|
│ ├── mana-search/ # Central search & content extraction (NestJS, legacy)
|
||||||
│ ├── mana-search-go/ # Central search & content extraction (Go, active)
|
│ ├── mana-search-go/ # Central search & content extraction (Go, active)
|
||||||
│ ├── mana-crawler/ # Web crawler service
|
│ ├── mana-crawler/ # Web crawler service
|
||||||
|
|
|
||||||
|
|
@ -224,9 +224,9 @@ services:
|
||||||
CONTACTS_BACKEND_URL: http://contacts-backend:3034
|
CONTACTS_BACKEND_URL: http://contacts-backend:3034
|
||||||
PICTURE_BACKEND_URL: http://picture-backend:3040
|
PICTURE_BACKEND_URL: http://picture-backend:3040
|
||||||
PRESI_BACKEND_URL: http://presi-backend:3036
|
PRESI_BACKEND_URL: http://presi-backend:3036
|
||||||
ZITARE_BACKEND_URL: http://zitare-backend:3007
|
# ZITARE_BACKEND_URL: removed — migrated to local-first
|
||||||
PHOTOS_BACKEND_URL: http://photos-backend:3039
|
PHOTOS_BACKEND_URL: http://photos-backend:3039
|
||||||
CLOCK_BACKEND_URL: http://clock-backend:3033
|
# CLOCK_BACKEND_URL: removed — migrated to local-first
|
||||||
STORAGE_BACKEND_URL: http://storage-backend:3035
|
STORAGE_BACKEND_URL: http://storage-backend:3035
|
||||||
ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
|
ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
|
||||||
MANA_LLM_URL: http://mana-llm:3025
|
MANA_LLM_URL: http://mana-llm:3025
|
||||||
|
|
@ -369,6 +369,62 @@ services:
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 5s
|
start_period: 5s
|
||||||
|
|
||||||
|
mana-sync:
|
||||||
|
build:
|
||||||
|
context: services/mana-sync
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: mana-sync:local
|
||||||
|
container_name: mana-core-sync
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
PORT: 3051
|
||||||
|
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/mana?sslmode=disable
|
||||||
|
JWKS_URL: http://mana-core-auth:3001/api/v1/auth/jwks
|
||||||
|
CORS_ORIGINS: "https://mana.how,https://*.mana.how"
|
||||||
|
ports:
|
||||||
|
- "3051:3051"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3051/health"]
|
||||||
|
interval: 120s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
|
mana-notify:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: services/mana-notify-go/Dockerfile
|
||||||
|
image: mana-notify:local
|
||||||
|
container_name: mana-core-notify
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
PORT: 3040
|
||||||
|
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/mana?sslmode=disable
|
||||||
|
SERVICE_KEY: ${NOTIFY_SERVICE_KEY:-dev-service-key}
|
||||||
|
MANA_CORE_AUTH_URL: http://mana-core-auth:3001
|
||||||
|
SMTP_HOST: ${SMTP_HOST:-smtp-relay.brevo.com}
|
||||||
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
|
SMTP_USER: ${SMTP_USER:-}
|
||||||
|
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||||
|
SMTP_FROM: "ManaCore <noreply@mana.how>"
|
||||||
|
EXPO_ACCESS_TOKEN: ${EXPO_ACCESS_TOKEN:-}
|
||||||
|
MATRIX_HOMESERVER_URL: http://mana-matrix-synapse:8008
|
||||||
|
MATRIX_ACCESS_TOKEN: ${MATRIX_NOTIFY_BOT_TOKEN:-}
|
||||||
|
ports:
|
||||||
|
- "3040:3040"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3040/health"]
|
||||||
|
interval: 120s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
mana-crawler:
|
mana-crawler:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
|
@ -559,35 +615,7 @@ services:
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
clock-backend:
|
# clock-backend: REMOVED — migrated to local-first (mana-sync handles CRUD)
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: apps/clock/apps/backend/Dockerfile
|
|
||||||
image: clock-backend:local
|
|
||||||
container_name: mana-app-clock-backend
|
|
||||||
restart: always
|
|
||||||
depends_on:
|
|
||||||
mana-auth:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
NODE_ENV: production
|
|
||||||
PORT: 3033
|
|
||||||
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/clock
|
|
||||||
DB_HOST: postgres
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_USER: postgres
|
|
||||||
MANA_CORE_AUTH_URL: http://mana-auth:3001
|
|
||||||
CORS_ORIGINS: https://clock.mana.how,https://mana.how
|
|
||||||
ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
|
|
||||||
GLITCHTIP_DSN: http://4d5ea890019d4a988e9834bc3e374e0a@glitchtip:8020/7
|
|
||||||
ports:
|
|
||||||
- "3033:3033"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3033/health"]
|
|
||||||
interval: 120s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 50s
|
|
||||||
|
|
||||||
contacts-backend:
|
contacts-backend:
|
||||||
build:
|
build:
|
||||||
|
|
@ -810,35 +838,7 @@ services:
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 60s
|
start_period: 60s
|
||||||
|
|
||||||
zitare-backend:
|
# zitare-backend: REMOVED — migrated to local-first (mana-sync handles CRUD)
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: apps/zitare/apps/backend/Dockerfile
|
|
||||||
image: zitare-backend:local
|
|
||||||
container_name: mana-app-zitare-backend
|
|
||||||
restart: always
|
|
||||||
depends_on:
|
|
||||||
mana-auth:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
NODE_ENV: production
|
|
||||||
PORT: 3007
|
|
||||||
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/zitare
|
|
||||||
DB_HOST: postgres
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_USER: postgres
|
|
||||||
MANA_CORE_AUTH_URL: http://mana-auth:3001
|
|
||||||
CORS_ORIGINS: https://zitare.mana.how,https://mana.how
|
|
||||||
ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
|
|
||||||
GLITCHTIP_DSN: http://53b871913d864628a8c7cb97b3f69e06@glitchtip:8020/8
|
|
||||||
ports:
|
|
||||||
- "3007:3007"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3007/health"]
|
|
||||||
interval: 120s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 55s
|
|
||||||
|
|
||||||
mukke-backend:
|
mukke-backend:
|
||||||
build:
|
build:
|
||||||
|
|
@ -1090,9 +1090,9 @@ services:
|
||||||
# Backend URLs
|
# Backend URLs
|
||||||
TODO_BACKEND_URL: http://todo-backend:3031
|
TODO_BACKEND_URL: http://todo-backend:3031
|
||||||
CALENDAR_BACKEND_URL: http://calendar-backend:3032
|
CALENDAR_BACKEND_URL: http://calendar-backend:3032
|
||||||
CLOCK_BACKEND_URL: http://clock-backend:3033
|
# CLOCK_BACKEND_URL: removed — migrated to local-first
|
||||||
CONTACTS_BACKEND_URL: http://contacts-backend:3034
|
CONTACTS_BACKEND_URL: http://contacts-backend:3034
|
||||||
ZITARE_BACKEND_URL: http://zitare-backend:3007
|
# ZITARE_BACKEND_URL: removed — migrated to local-first
|
||||||
PLANTA_BACKEND_URL: http://planta-backend:3022
|
PLANTA_BACKEND_URL: http://planta-backend:3022
|
||||||
NUTRIPHI_BACKEND_URL: http://nutriphi-backend:3037
|
NUTRIPHI_BACKEND_URL: http://nutriphi-backend:3037
|
||||||
STORAGE_BACKEND_URL: http://storage-backend:3035
|
STORAGE_BACKEND_URL: http://storage-backend:3035
|
||||||
|
|
@ -1199,15 +1199,14 @@ services:
|
||||||
container_name: mana-app-zitare-web
|
container_name: mana-app-zitare-web
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
zitare-backend:
|
mana-auth:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 5018
|
PORT: 5018
|
||||||
PUBLIC_ZITARE_API_URL: http://zitare-backend:3007
|
|
||||||
PUBLIC_MANA_CORE_AUTH_URL: http://mana-auth:3001
|
PUBLIC_MANA_CORE_AUTH_URL: http://mana-auth:3001
|
||||||
PUBLIC_ZITARE_API_URL_CLIENT: https://zitare-api.mana.how
|
|
||||||
PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.mana.how
|
PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.mana.how
|
||||||
|
PUBLIC_SYNC_SERVER_URL: ws://mana-sync:3050
|
||||||
ports:
|
ports:
|
||||||
- "5018:5018"
|
- "5018:5018"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,6 @@ import { AuthModule } from './auth/auth.module';
|
||||||
import { FeedbackModule } from './feedback/feedback.module';
|
import { FeedbackModule } from './feedback/feedback.module';
|
||||||
import { GuildsModule } from './guilds/guilds.module';
|
import { GuildsModule } from './guilds/guilds.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { SettingsModule } from './settings/settings.module';
|
|
||||||
import { StorageModule } from './storage/storage.module';
|
|
||||||
import { TagGroupsModule } from './tag-groups/tag-groups.module';
|
|
||||||
import { TagLinksModule } from './tag-links/tag-links.module';
|
|
||||||
import { TagsModule } from './tags/tags.module';
|
|
||||||
import { MeModule } from './me/me.module';
|
import { MeModule } from './me/me.module';
|
||||||
import { SubscriptionsModule } from './subscriptions/subscriptions.module';
|
import { SubscriptionsModule } from './subscriptions/subscriptions.module';
|
||||||
import { StripeModule } from './stripe/stripe.module';
|
import { StripeModule } from './stripe/stripe.module';
|
||||||
|
|
@ -56,11 +51,6 @@ import { SecurityModule } from './security';
|
||||||
FeedbackModule,
|
FeedbackModule,
|
||||||
GuildsModule,
|
GuildsModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
SettingsModule,
|
|
||||||
StorageModule,
|
|
||||||
TagsModule,
|
|
||||||
TagGroupsModule,
|
|
||||||
TagLinksModule,
|
|
||||||
MeModule,
|
MeModule,
|
||||||
StripeModule,
|
StripeModule,
|
||||||
SubscriptionsModule,
|
SubscriptionsModule,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,3 @@ export * from './feedback.schema';
|
||||||
export * from './login-attempts.schema';
|
export * from './login-attempts.schema';
|
||||||
export * from './organizations.schema';
|
export * from './organizations.schema';
|
||||||
export * from './subscriptions.schema';
|
export * from './subscriptions.schema';
|
||||||
export * from './tag-groups.schema';
|
|
||||||
export * from './tag-links.schema';
|
|
||||||
export * from './tags.schema';
|
|
||||||
|
|
|
||||||
|
|
@ -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 { pgTable, varchar, text, uuid, timestamp, index, unique } from 'drizzle-orm/pg-core';
|
||||||
import { tags } from './tags.schema';
|
import { tags } from './tags';
|
||||||
|
|
||||||
export const tagLinks = pgTable(
|
export const tagLinks = pgTable(
|
||||||
'tag_links',
|
'tag_links',
|
||||||
|
|
@ -8,12 +8,8 @@ import {
|
||||||
unique,
|
unique,
|
||||||
integer,
|
integer,
|
||||||
} from 'drizzle-orm/pg-core';
|
} from 'drizzle-orm/pg-core';
|
||||||
import { tagGroups } from './tag-groups.schema';
|
import { tagGroups } from './tag-groups';
|
||||||
|
|
||||||
/**
|
|
||||||
* Central tags table for all Manacore applications.
|
|
||||||
* Tags created here can be used in Todo, Calendar, Contacts, and other apps.
|
|
||||||
*/
|
|
||||||
export const tags = pgTable(
|
export const tags = pgTable(
|
||||||
'tags',
|
'tags',
|
||||||
{
|
{
|
||||||
|
|
@ -21,7 +17,7 @@ export const tags = pgTable(
|
||||||
userId: text('user_id').notNull(),
|
userId: text('user_id').notNull(),
|
||||||
name: varchar('name', { length: 100 }).notNull(),
|
name: varchar('name', { length: 100 }).notNull(),
|
||||||
color: varchar('color', { length: 7 }).default('#3B82F6'),
|
color: varchar('color', { length: 7 }).default('#3B82F6'),
|
||||||
icon: varchar('icon', { length: 50 }), // Optional: Phosphor Icon name
|
icon: varchar('icon', { length: 50 }),
|
||||||
groupId: uuid('group_id').references(() => tagGroups.id, { onDelete: 'set null' }),
|
groupId: uuid('group_id').references(() => tagGroups.id, { onDelete: 'set null' }),
|
||||||
sortOrder: integer('sort_order').default(0).notNull(),
|
sortOrder: integer('sort_order').default(0).notNull(),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
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