mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 23:26:42 +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
|
|
@ -11,11 +11,6 @@ import { AuthModule } from './auth/auth.module';
|
|||
import { FeedbackModule } from './feedback/feedback.module';
|
||||
import { GuildsModule } from './guilds/guilds.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { StorageModule } from './storage/storage.module';
|
||||
import { TagGroupsModule } from './tag-groups/tag-groups.module';
|
||||
import { TagLinksModule } from './tag-links/tag-links.module';
|
||||
import { TagsModule } from './tags/tags.module';
|
||||
import { MeModule } from './me/me.module';
|
||||
import { SubscriptionsModule } from './subscriptions/subscriptions.module';
|
||||
import { StripeModule } from './stripe/stripe.module';
|
||||
|
|
@ -56,11 +51,6 @@ import { SecurityModule } from './security';
|
|||
FeedbackModule,
|
||||
GuildsModule,
|
||||
HealthModule,
|
||||
SettingsModule,
|
||||
StorageModule,
|
||||
TagsModule,
|
||||
TagGroupsModule,
|
||||
TagLinksModule,
|
||||
MeModule,
|
||||
StripeModule,
|
||||
SubscriptionsModule,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,3 @@ export * from './feedback.schema';
|
|||
export * from './login-attempts.schema';
|
||||
export * from './organizations.schema';
|
||||
export * from './subscriptions.schema';
|
||||
export * from './tag-groups.schema';
|
||||
export * from './tag-links.schema';
|
||||
export * from './tags.schema';
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
import {
|
||||
pgTable,
|
||||
varchar,
|
||||
text,
|
||||
uuid,
|
||||
timestamp,
|
||||
index,
|
||||
unique,
|
||||
integer,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
export const tagGroups = pgTable(
|
||||
'tag_groups',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
name: varchar('name', { length: 100 }).notNull(),
|
||||
color: varchar('color', { length: 7 }).default('#3B82F6'),
|
||||
icon: varchar('icon', { length: 50 }),
|
||||
sortOrder: integer('sort_order').default(0).notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('tag_groups_user_idx').on(table.userId),
|
||||
unique('tag_groups_user_name_unique').on(table.userId, table.name),
|
||||
]
|
||||
);
|
||||
|
||||
export type TagGroup = typeof tagGroups.$inferSelect;
|
||||
export type NewTagGroup = typeof tagGroups.$inferInsert;
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import { pgTable, varchar, text, uuid, timestamp, index, unique } from 'drizzle-orm/pg-core';
|
||||
import { tags } from './tags.schema';
|
||||
|
||||
export const tagLinks = pgTable(
|
||||
'tag_links',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
tagId: uuid('tag_id')
|
||||
.notNull()
|
||||
.references(() => tags.id, { onDelete: 'cascade' }),
|
||||
appId: varchar('app_id', { length: 50 }).notNull(),
|
||||
entityId: varchar('entity_id', { length: 255 }).notNull(),
|
||||
entityType: varchar('entity_type', { length: 100 }).notNull(),
|
||||
userId: text('user_id').notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('tag_links_tag_idx').on(table.tagId),
|
||||
index('tag_links_entity_idx').on(table.appId, table.entityId),
|
||||
index('tag_links_user_app_idx').on(table.userId, table.appId),
|
||||
unique('tag_links_unique').on(table.tagId, table.appId, table.entityId),
|
||||
]
|
||||
);
|
||||
|
||||
export type TagLink = typeof tagLinks.$inferSelect;
|
||||
export type NewTagLink = typeof tagLinks.$inferInsert;
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import {
|
||||
pgTable,
|
||||
varchar,
|
||||
text,
|
||||
uuid,
|
||||
timestamp,
|
||||
index,
|
||||
unique,
|
||||
integer,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { tagGroups } from './tag-groups.schema';
|
||||
|
||||
/**
|
||||
* Central tags table for all Manacore applications.
|
||||
* Tags created here can be used in Todo, Calendar, Contacts, and other apps.
|
||||
*/
|
||||
export const tags = pgTable(
|
||||
'tags',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
name: varchar('name', { length: 100 }).notNull(),
|
||||
color: varchar('color', { length: 7 }).default('#3B82F6'),
|
||||
icon: varchar('icon', { length: 50 }), // Optional: Phosphor Icon name
|
||||
groupId: uuid('group_id').references(() => tagGroups.id, { onDelete: 'set null' }),
|
||||
sortOrder: integer('sort_order').default(0).notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('tags_user_idx').on(table.userId),
|
||||
index('tags_group_idx').on(table.groupId),
|
||||
unique('tags_user_name_unique').on(table.userId, table.name),
|
||||
]
|
||||
);
|
||||
|
||||
export type Tag = typeof tags.$inferSelect;
|
||||
export type NewTag = typeof tags.$inferInsert;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue