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

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

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

View file

@ -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,

View file

@ -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';

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -1,174 +0,0 @@
import {
IsOptional,
IsString,
IsObject,
ValidateNested,
IsBoolean,
IsIn,
IsArray,
} from 'class-validator';
import { Type } from 'class-transformer';
// Nav settings
export class NavSettingsDto {
@IsOptional()
@IsIn(['top', 'bottom'])
desktopPosition?: 'top' | 'bottom';
@IsOptional()
@IsBoolean()
sidebarCollapsed?: boolean;
@IsOptional()
@IsObject()
hiddenNavItems?: Record<string, string[]>;
}
// Theme settings
export class ThemeSettingsDto {
@IsOptional()
@IsIn(['light', 'dark', 'system'])
mode?: 'light' | 'dark' | 'system';
@IsOptional()
@IsString()
colorScheme?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
pinnedThemes?: string[];
}
// Global settings update
export class UpdateGlobalSettingsDto {
@IsOptional()
@ValidateNested()
@Type(() => NavSettingsDto)
nav?: NavSettingsDto;
@IsOptional()
@ValidateNested()
@Type(() => ThemeSettingsDto)
theme?: ThemeSettingsDto;
@IsOptional()
@IsString()
locale?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
recentEmojis?: string[];
// Profile fields (from onboarding)
@IsOptional()
@IsString()
displayName?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
interests?: string[];
@IsOptional()
@IsBoolean()
onboardingCompleted?: boolean;
}
// App override update
export class UpdateAppOverrideDto {
@IsOptional()
@ValidateNested()
@Type(() => NavSettingsDto)
nav?: NavSettingsDto;
@IsOptional()
@ValidateNested()
@Type(() => ThemeSettingsDto)
theme?: ThemeSettingsDto;
}
// Device settings update
export class UpdateDeviceAppSettingsDto {
@IsOptional()
@IsString()
deviceName?: string;
@IsOptional()
@IsIn(['desktop', 'mobile', 'tablet'])
deviceType?: 'desktop' | 'mobile' | 'tablet';
@IsObject()
settings: Record<string, unknown>;
}
// Register/update device info
export class RegisterDeviceDto {
@IsString()
deviceId: string;
@IsOptional()
@IsString()
deviceName?: string;
@IsOptional()
@IsIn(['desktop', 'mobile', 'tablet'])
deviceType?: 'desktop' | 'mobile' | 'tablet';
}
// Response types (for documentation)
export interface NavSettings {
desktopPosition: 'top' | 'bottom';
sidebarCollapsed: boolean;
hiddenNavItems?: Record<string, string[]>;
}
export interface ThemeSettings {
mode: 'light' | 'dark' | 'system';
colorScheme: string;
pinnedThemes: string[];
}
export interface GlobalSettings {
nav: NavSettings;
theme: ThemeSettings;
locale: string;
recentEmojis?: string[];
// Profile fields (from onboarding)
displayName?: string;
interests?: string[];
onboardingCompleted?: boolean;
}
export interface AppOverride {
nav?: Partial<NavSettings>;
theme?: Partial<ThemeSettings>;
}
// Device-specific app settings
export interface DeviceAppSettings {
deviceName: string;
deviceType: 'desktop' | 'mobile' | 'tablet';
lastSeen: string;
apps: Record<string, Record<string, unknown>>;
}
// Device info for listing
export interface DeviceInfo {
deviceId: string;
deviceName: string;
deviceType: 'desktop' | 'mobile' | 'tablet';
lastSeen: string;
appCount: number;
}
export interface UserSettingsResponse {
globalSettings: GlobalSettings;
appOverrides: Record<string, AppOverride>;
deviceSettings: Record<string, DeviceAppSettings>;
}
export interface DevicesListResponse {
devices: DeviceInfo[];
}

View file

@ -1,163 +0,0 @@
import { Controller, Get, Patch, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { SettingsService } from './settings.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { CurrentUserData } from '../common/decorators/current-user.decorator';
import { UpdateGlobalSettingsDto, UpdateDeviceAppSettingsDto } from './dto';
import type { UpdateAppOverrideDto } from './dto';
@Controller('settings')
@UseGuards(JwtAuthGuard)
export class SettingsController {
constructor(private readonly settingsService: SettingsService) {}
/**
* GET /api/v1/settings
* Get all user settings (global + app overrides + device settings)
*/
@Get()
async getSettings(@CurrentUser() user: CurrentUserData) {
const settings = await this.settingsService.getSettings(user.userId);
return {
success: true,
...settings,
};
}
/**
* PATCH /api/v1/settings/global
* Update global settings (applies to all apps by default)
*/
@Patch('global')
async updateGlobalSettings(
@CurrentUser() user: CurrentUserData,
@Body() dto: UpdateGlobalSettingsDto
) {
const settings = await this.settingsService.updateGlobalSettings(user.userId, dto);
return {
success: true,
...settings,
};
}
/**
* PATCH /api/v1/settings/app/:appId
* Update app-specific override settings
*/
@Patch('app/:appId')
async updateAppOverride(
@CurrentUser() user: CurrentUserData,
@Param('appId') appId: string,
@Body() dto: UpdateAppOverrideDto
) {
const settings = await this.settingsService.updateAppOverride(user.userId, appId, dto);
return {
success: true,
...settings,
};
}
/**
* DELETE /api/v1/settings/app/:appId
* Remove app-specific override (revert to global settings)
*/
@Delete('app/:appId')
async removeAppOverride(@CurrentUser() user: CurrentUserData, @Param('appId') appId: string) {
const settings = await this.settingsService.removeAppOverride(user.userId, appId);
return {
success: true,
...settings,
};
}
// ============================================================================
// Device Settings Endpoints
// ============================================================================
/**
* GET /api/v1/settings/devices
* List all devices for the current user
*/
@Get('devices')
async getDevices(@CurrentUser() user: CurrentUserData) {
const result = await this.settingsService.getDevices(user.userId);
return {
success: true,
...result,
};
}
/**
* GET /api/v1/settings/device/:deviceId/:appId
* Get settings for a specific device and app
*/
@Get('device/:deviceId/:appId')
async getDeviceAppSettings(
@CurrentUser() user: CurrentUserData,
@Param('deviceId') deviceId: string,
@Param('appId') appId: string
) {
const settings = await this.settingsService.getDeviceAppSettings(user.userId, deviceId, appId);
return {
success: true,
settings,
};
}
/**
* PATCH /api/v1/settings/device/:deviceId/:appId
* Update settings for a specific device and app
*/
@Patch('device/:deviceId/:appId')
async updateDeviceAppSettings(
@CurrentUser() user: CurrentUserData,
@Param('deviceId') deviceId: string,
@Param('appId') appId: string,
@Body() dto: UpdateDeviceAppSettingsDto
) {
const settings = await this.settingsService.updateDeviceAppSettings(
user.userId,
deviceId,
appId,
dto
);
return {
success: true,
...settings,
};
}
/**
* DELETE /api/v1/settings/device/:deviceId
* Remove a device entirely
*/
@Delete('device/:deviceId')
async removeDevice(@CurrentUser() user: CurrentUserData, @Param('deviceId') deviceId: string) {
const settings = await this.settingsService.removeDevice(user.userId, deviceId);
return {
success: true,
...settings,
};
}
/**
* DELETE /api/v1/settings/device/:deviceId/:appId
* Remove app settings from a specific device
*/
@Delete('device/:deviceId/:appId')
async removeDeviceAppSettings(
@CurrentUser() user: CurrentUserData,
@Param('deviceId') deviceId: string,
@Param('appId') appId: string
) {
const settings = await this.settingsService.removeDeviceAppSettings(
user.userId,
deviceId,
appId
);
return {
success: true,
...settings,
};
}
}

View file

@ -1,11 +0,0 @@
import { Module } from '@nestjs/common';
import { SettingsController } from './settings.controller';
import { SettingsService } from './settings.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
@Module({
controllers: [SettingsController],
providers: [SettingsService, JwtAuthGuard],
exports: [SettingsService],
})
export class SettingsModule {}

View file

@ -1,384 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq } from 'drizzle-orm';
import { getDb } from '../db/connection';
import { userSettings } from '../db/schema';
import {
type UpdateGlobalSettingsDto,
type UpdateAppOverrideDto,
type UpdateDeviceAppSettingsDto,
type GlobalSettings,
type AppOverride,
type DeviceAppSettings,
type DeviceInfo,
type UserSettingsResponse,
type DevicesListResponse,
} from './dto';
// Default settings for new users
const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
nav: { desktopPosition: 'top', sidebarCollapsed: false },
theme: { mode: 'system', colorScheme: 'ocean', pinnedThemes: [] },
locale: 'de',
};
@Injectable()
export class SettingsService {
private readonly logger = new Logger(SettingsService.name);
constructor(private configService: ConfigService) {}
private getDb() {
const databaseUrl = this.configService.get<string>('database.url');
return getDb(databaseUrl!);
}
/**
* Get user settings, creating defaults if they don't exist
*/
async getSettings(userId: string): Promise<UserSettingsResponse> {
const db = this.getDb();
// Try to get existing settings
const [existing] = await db
.select()
.from(userSettings)
.where(eq(userSettings.userId, userId))
.limit(1);
if (existing) {
return {
globalSettings: existing.globalSettings as GlobalSettings,
appOverrides: existing.appOverrides as Record<string, AppOverride>,
deviceSettings: (existing.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
// Create default settings for new user
const [created] = await db
.insert(userSettings)
.values({
userId,
globalSettings: DEFAULT_GLOBAL_SETTINGS,
appOverrides: {},
deviceSettings: {},
})
.returning();
this.logger.debug(`Created default settings for user ${userId}`);
return {
globalSettings: created.globalSettings as GlobalSettings,
appOverrides: created.appOverrides as Record<string, AppOverride>,
deviceSettings: (created.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
/**
* Update global settings (merges with existing)
*/
async updateGlobalSettings(
userId: string,
dto: UpdateGlobalSettingsDto
): Promise<UserSettingsResponse> {
const db = this.getDb();
// Get current settings
const current = await this.getSettings(userId);
// Deep merge the settings
const updatedGlobal: GlobalSettings = {
nav: { ...current.globalSettings.nav, ...dto.nav },
theme: { ...current.globalSettings.theme, ...dto.theme },
locale: dto.locale ?? current.globalSettings.locale,
recentEmojis: dto.recentEmojis ?? current.globalSettings.recentEmojis,
// Profile fields
displayName: dto.displayName ?? current.globalSettings.displayName,
interests: dto.interests ?? current.globalSettings.interests,
onboardingCompleted: dto.onboardingCompleted ?? current.globalSettings.onboardingCompleted,
};
// Update in database
const [updated] = await db
.update(userSettings)
.set({
globalSettings: updatedGlobal,
updatedAt: new Date(),
})
.where(eq(userSettings.userId, userId))
.returning();
this.logger.debug(`Updated global settings for user ${userId}`);
return {
globalSettings: updated.globalSettings as GlobalSettings,
appOverrides: updated.appOverrides as Record<string, AppOverride>,
deviceSettings: (updated.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
/**
* Update or create app-specific override
*/
async updateAppOverride(
userId: string,
appId: string,
dto: UpdateAppOverrideDto
): Promise<UserSettingsResponse> {
const db = this.getDb();
// Get current settings
const current = await this.getSettings(userId);
// Merge with existing app override
const existingOverride = current.appOverrides[appId] || {};
const updatedOverride: AppOverride = {
nav: dto.nav ? { ...existingOverride.nav, ...dto.nav } : existingOverride.nav,
theme: dto.theme ? { ...existingOverride.theme, ...dto.theme } : existingOverride.theme,
};
// Clean up empty objects
if (updatedOverride.nav && Object.keys(updatedOverride.nav).length === 0) {
delete updatedOverride.nav;
}
if (updatedOverride.theme && Object.keys(updatedOverride.theme).length === 0) {
delete updatedOverride.theme;
}
// Update app overrides
const updatedOverrides = { ...current.appOverrides };
if (Object.keys(updatedOverride).length > 0) {
updatedOverrides[appId] = updatedOverride;
} else {
delete updatedOverrides[appId];
}
// Update in database
const [updated] = await db
.update(userSettings)
.set({
appOverrides: updatedOverrides,
updatedAt: new Date(),
})
.where(eq(userSettings.userId, userId))
.returning();
this.logger.debug(`Updated app override for user ${userId}, app ${appId}`);
return {
globalSettings: updated.globalSettings as GlobalSettings,
appOverrides: updated.appOverrides as Record<string, AppOverride>,
deviceSettings: (updated.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
/**
* Remove app-specific override (revert to global settings)
*/
async removeAppOverride(userId: string, appId: string): Promise<UserSettingsResponse> {
const db = this.getDb();
// Get current settings
const current = await this.getSettings(userId);
// Remove the app override
const updatedOverrides = { ...current.appOverrides };
delete updatedOverrides[appId];
// Update in database
const [updated] = await db
.update(userSettings)
.set({
appOverrides: updatedOverrides,
updatedAt: new Date(),
})
.where(eq(userSettings.userId, userId))
.returning();
this.logger.debug(`Removed app override for user ${userId}, app ${appId}`);
return {
globalSettings: updated.globalSettings as GlobalSettings,
appOverrides: updated.appOverrides as Record<string, AppOverride>,
deviceSettings: (updated.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
// ============================================================================
// Device Settings Methods
// ============================================================================
/**
* Get list of all devices for a user
*/
async getDevices(userId: string): Promise<DevicesListResponse> {
const current = await this.getSettings(userId);
const deviceSettings = current.deviceSettings || {};
const devices: DeviceInfo[] = Object.entries(deviceSettings).map(([deviceId, device]) => ({
deviceId,
deviceName: device.deviceName || 'Unbekanntes Gerät',
deviceType: device.deviceType || 'desktop',
lastSeen: device.lastSeen || new Date().toISOString(),
appCount: Object.keys(device.apps || {}).length,
}));
// Sort by lastSeen descending
devices.sort((a, b) => new Date(b.lastSeen).getTime() - new Date(a.lastSeen).getTime());
return { devices };
}
/**
* Get settings for a specific device and app
*/
async getDeviceAppSettings(
userId: string,
deviceId: string,
appId: string
): Promise<Record<string, unknown>> {
const current = await this.getSettings(userId);
const deviceSettings = current.deviceSettings || {};
const device = deviceSettings[deviceId];
if (!device || !device.apps || !device.apps[appId]) {
return {};
}
return device.apps[appId];
}
/**
* Update settings for a specific device and app
*/
async updateDeviceAppSettings(
userId: string,
deviceId: string,
appId: string,
dto: UpdateDeviceAppSettingsDto
): Promise<UserSettingsResponse> {
const db = this.getDb();
// Get current settings
const current = await this.getSettings(userId);
const deviceSettings = { ...(current.deviceSettings || {}) };
// Get or create device entry
const existingDevice = deviceSettings[deviceId] || {
deviceName: dto.deviceName || 'Unbekanntes Gerät',
deviceType: dto.deviceType || 'desktop',
lastSeen: new Date().toISOString(),
apps: {},
};
// Update device info if provided
const updatedDevice: DeviceAppSettings = {
deviceName: dto.deviceName || existingDevice.deviceName,
deviceType: dto.deviceType || existingDevice.deviceType,
lastSeen: new Date().toISOString(),
apps: {
...existingDevice.apps,
[appId]: {
...(existingDevice.apps?.[appId] || {}),
...dto.settings,
},
},
};
deviceSettings[deviceId] = updatedDevice;
// Update in database
const [updated] = await db
.update(userSettings)
.set({
deviceSettings,
updatedAt: new Date(),
})
.where(eq(userSettings.userId, userId))
.returning();
this.logger.debug(
`Updated device settings for user ${userId}, device ${deviceId}, app ${appId}`
);
return {
globalSettings: updated.globalSettings as GlobalSettings,
appOverrides: updated.appOverrides as Record<string, AppOverride>,
deviceSettings: (updated.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
/**
* Remove a device entirely
*/
async removeDevice(userId: string, deviceId: string): Promise<UserSettingsResponse> {
const db = this.getDb();
// Get current settings
const current = await this.getSettings(userId);
const deviceSettings = { ...(current.deviceSettings || {}) };
// Remove the device
delete deviceSettings[deviceId];
// Update in database
const [updated] = await db
.update(userSettings)
.set({
deviceSettings,
updatedAt: new Date(),
})
.where(eq(userSettings.userId, userId))
.returning();
this.logger.debug(`Removed device ${deviceId} for user ${userId}`);
return {
globalSettings: updated.globalSettings as GlobalSettings,
appOverrides: updated.appOverrides as Record<string, AppOverride>,
deviceSettings: (updated.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
/**
* Remove app settings from a specific device
*/
async removeDeviceAppSettings(
userId: string,
deviceId: string,
appId: string
): Promise<UserSettingsResponse> {
const db = this.getDb();
// Get current settings
const current = await this.getSettings(userId);
const deviceSettings = { ...(current.deviceSettings || {}) };
if (deviceSettings[deviceId]?.apps) {
const device = { ...deviceSettings[deviceId] };
const apps = { ...device.apps };
delete apps[appId];
device.apps = apps;
device.lastSeen = new Date().toISOString();
deviceSettings[deviceId] = device;
}
// Update in database
const [updated] = await db
.update(userSettings)
.set({
deviceSettings,
updatedAt: new Date(),
})
.where(eq(userSettings.userId, userId))
.returning();
this.logger.debug(`Removed app ${appId} settings from device ${deviceId} for user ${userId}`);
return {
globalSettings: updated.globalSettings as GlobalSettings,
appOverrides: updated.appOverrides as Record<string, AppOverride>,
deviceSettings: (updated.deviceSettings as Record<string, DeviceAppSettings>) || {},
};
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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