diff --git a/apps/calendar/apps/backend/Dockerfile b/apps/calendar/apps/backend/Dockerfile deleted file mode 100644 index 74df283b3..000000000 --- a/apps/calendar/apps/backend/Dockerfile +++ /dev/null @@ -1,55 +0,0 @@ -# syntax=docker/dockerfile:1 -# Build stage — inherits pre-built shared packages from nestjs-base -FROM nestjs-base:local AS builder - -# Copy calendar-specific packages and backend -COPY apps/calendar/packages ./apps/calendar/packages -COPY apps/calendar/apps/backend ./apps/calendar/apps/backend - -# Reinstall to link app-specific dependencies -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --ignore-scripts - -# Build the backend -WORKDIR /app/apps/calendar/apps/backend -RUN pnpm build - -# Remove devDependencies and unnecessary files from node_modules -WORKDIR /app -RUN pnpm prune --prod --no-optional 2>/dev/null || true \ - && find node_modules -name '*.ts' -not -name '*.d.ts' -delete 2>/dev/null || true \ - && find node_modules -name '*.map' -delete 2>/dev/null || true \ - && find node_modules -type d \( -name 'test' -o -name 'tests' -o -name '__tests__' -o -name 'docs' \) -prune -exec rm -rf {} + 2>/dev/null || true - -# Production stage -FROM node:20-alpine AS production - -# Install postgresql-client for health checks -RUN apk add --no-cache postgresql-client - -WORKDIR /app - -# Copy everything from builder (including node_modules) -COPY --from=builder /app/pnpm-workspace.yaml ./ -COPY --from=builder /app/package.json ./ -COPY --from=builder /app/pnpm-lock.yaml ./ -COPY --from=builder /app/node_modules ./node_modules -COPY --from=builder /app/packages ./packages -COPY --from=builder /app/apps/calendar ./apps/calendar - -# Copy entrypoint script -COPY apps/calendar/apps/backend/docker-entrypoint.sh /usr/local/bin/ -RUN chmod +x /usr/local/bin/docker-entrypoint.sh - -WORKDIR /app/apps/calendar/apps/backend - -# Default port (overridden by PORT env var in docker-compose) -ENV PORT=3014 -EXPOSE ${PORT} - -# Health check using PORT env var -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:${PORT}/health || exit 1 - -# Run entrypoint script -ENTRYPOINT ["docker-entrypoint.sh"] -CMD ["node", "dist/main.js"] diff --git a/apps/calendar/apps/backend/docker-entrypoint.sh b/apps/calendar/apps/backend/docker-entrypoint.sh deleted file mode 100644 index bda857019..000000000 --- a/apps/calendar/apps/backend/docker-entrypoint.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/sh -set -e - -echo "=== Calendar Backend Entrypoint ===" - -# Wait for PostgreSQL to be ready -echo "Waiting for PostgreSQL..." -until pg_isready -h ${DB_HOST:-postgres} -p ${DB_PORT:-5432} -U ${DB_USER:-postgres} 2>/dev/null; do - echo "PostgreSQL is unavailable - sleeping" - sleep 2 -done -echo "PostgreSQL is up!" - -cd /app/apps/calendar/apps/backend - -# Run schema push -echo "Pushing database schema..." -npx drizzle-kit push --force -echo "Schema push completed!" - -# Execute the main command -echo "Starting application..." -exec "$@" diff --git a/apps/calendar/apps/backend/drizzle.config.ts b/apps/calendar/apps/backend/drizzle.config.ts deleted file mode 100644 index c774b197e..000000000 --- a/apps/calendar/apps/backend/drizzle.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { createDrizzleConfig } from '@manacore/shared-drizzle-config'; - -export default createDrizzleConfig({ dbName: 'calendar' }); diff --git a/apps/calendar/apps/backend/eslint.config.mjs b/apps/calendar/apps/backend/eslint.config.mjs deleted file mode 100644 index 41ef245c0..000000000 --- a/apps/calendar/apps/backend/eslint.config.mjs +++ /dev/null @@ -1,17 +0,0 @@ -// @ts-check -import { - baseConfig, - typescriptConfig, - nestjsConfig, - prettierConfig, -} from '@manacore/eslint-config'; - -export default [ - { - ignores: ['dist/**', 'node_modules/**'], - }, - ...baseConfig, - ...typescriptConfig, - ...nestjsConfig, - ...prettierConfig, -]; diff --git a/apps/calendar/apps/backend/jest.config.js b/apps/calendar/apps/backend/jest.config.js deleted file mode 100644 index fdb8eec5b..000000000 --- a/apps/calendar/apps/backend/jest.config.js +++ /dev/null @@ -1,25 +0,0 @@ -/** @type {import('jest').Config} */ -module.exports = { - moduleFileExtensions: ['js', 'json', 'ts'], - rootDir: 'src', - testRegex: '.*\\.spec\\.ts$', - transform: { - '^.+\\.(t|j)s$': 'ts-jest', - }, - collectCoverageFrom: ['**/*.(t|j)s', '!**/*.spec.ts', '!**/index.ts', '!main.ts'], - coverageDirectory: '../coverage', - testEnvironment: 'node', - moduleNameMapper: { - '^@calendar/shared$': '/../../packages/shared/src', - }, - coverageThreshold: { - global: { - branches: 80, - functions: 80, - lines: 80, - statements: 80, - }, - }, - // Ignore node_modules except for workspace packages - transformIgnorePatterns: ['node_modules/(?!(@calendar|@manacore)/)'], -}; diff --git a/apps/calendar/apps/backend/nest-cli.json b/apps/calendar/apps/backend/nest-cli.json deleted file mode 100644 index b4a4fa09c..000000000 --- a/apps/calendar/apps/backend/nest-cli.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src", - "compilerOptions": { - "deleteOutDir": false, - "assets": [], - "watchAssets": false - } -} diff --git a/apps/calendar/apps/backend/package.json b/apps/calendar/apps/backend/package.json deleted file mode 100644 index b9a2dd282..000000000 --- a/apps/calendar/apps/backend/package.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "name": "@calendar/backend", - "version": "1.0.0", - "private": true, - "scripts": { - "build": "nest build", - "start": "nest start", - "dev": "nest start --watch", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "type-check": "tsc --noEmit", - "test": "jest", - "test:watch": "jest --watch", - "test:cov": "jest --coverage", - "migration:generate": "drizzle-kit generate", - "migration:run": "tsx src/db/migrate.ts", - "db:push": "drizzle-kit push", - "db:studio": "drizzle-kit studio", - "db:seed": "tsx src/db/seed.ts" - }, - "dependencies": { - "@calendar/shared": "workspace:*", - "@manacore/credit-operations": "workspace:*", - "@manacore/nestjs-integration": "workspace:*", - "@manacore/shared-error-tracking": "workspace:^", - "@manacore/shared-nestjs-auth": "workspace:*", - "@manacore/shared-nestjs-health": "workspace:*", - "@manacore/shared-nestjs-metrics": "workspace:*", - "@manacore/shared-nestjs-setup": "workspace:*", - "@nestjs/common": "^10.4.15", - "@nestjs/config": "^3.3.0", - "@nestjs/core": "^10.4.15", - "@nestjs/platform-express": "^10.4.15", - "@nestjs/schedule": "^4.1.2", - "@nestjs/swagger": "^11.2.6", - "@nestjs/throttler": "^6.2.1", - "class-transformer": "^0.5.1", - "class-validator": "^0.14.1", - "dotenv": "^16.4.7", - "drizzle-kit": "^0.30.2", - "drizzle-orm": "^0.38.3", - "expo-server-sdk": "^3.10.0", - "ical.js": "^2.1.0", - "nodemailer": "^6.9.16", - "postgres": "^3.4.5", - "prom-client": "^15.1.0", - "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1", - "tsdav": "^2.1.1", - "uuid": "^11.0.4" - }, - "devDependencies": { - "@nestjs/cli": "^10.4.9", - "@nestjs/schematics": "^10.2.3", - "@nestjs/testing": "^10.4.15", - "@types/express": "^5.0.0", - "@types/jest": "^29.5.14", - "@types/node": "^22.10.2", - "@types/nodemailer": "^6.4.17", - "@types/uuid": "^10.0.0", - "@typescript-eslint/eslint-plugin": "^8.18.1", - "@typescript-eslint/parser": "^8.18.1", - "eslint": "^9.17.0", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-prettier": "^5.2.1", - "jest": "^30.3.0", - "prettier": "^3.4.2", - "source-map-support": "^0.5.21", - "ts-jest": "^29.4.5", - "ts-loader": "^9.5.1", - "ts-node": "^10.9.2", - "tsconfig-paths": "^4.2.0", - "tsx": "^4.19.2", - "typescript": "^5.7.2" - } -} diff --git a/apps/calendar/apps/backend/src/__tests__/utils/mock-db.ts b/apps/calendar/apps/backend/src/__tests__/utils/mock-db.ts deleted file mode 100644 index a290aca90..000000000 --- a/apps/calendar/apps/backend/src/__tests__/utils/mock-db.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Mock database utilities for testing - */ - -import type { Database } from '../../db/connection'; - -/** - * Create a mock database with chainable query methods - */ -export function createMockDb(): jest.Mocked { - const mockChain = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - returning: jest.fn().mockResolvedValue([]), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - delete: jest.fn().mockReturnThis(), - execute: jest.fn().mockResolvedValue([]), - }; - - // Make all methods return the chain - Object.keys(mockChain).forEach((key) => { - if (key !== 'returning' && key !== 'execute') { - (mockChain as any)[key].mockReturnValue(mockChain); - } - }); - - return mockChain as unknown as jest.Mocked; -} - -/** - * Setup mock database to return specific data - */ -export function setupMockDbQuery(mockDb: jest.Mocked, data: T[]): void { - // For SELECT queries - the final method in the chain resolves to data - (mockDb as any).where.mockResolvedValueOnce(data); -} - -/** - * Setup mock database for INSERT operations - */ -export function setupMockDbInsert(mockDb: jest.Mocked, data: T[]): void { - (mockDb as any).returning.mockResolvedValueOnce(data); -} - -/** - * Setup mock database for UPDATE operations - */ -export function setupMockDbUpdate(mockDb: jest.Mocked, data: T[]): void { - (mockDb as any).returning.mockResolvedValueOnce(data); -} - -/** - * Reset all mock calls on the database - */ -export function resetMockDb(mockDb: jest.Mocked): void { - Object.values(mockDb).forEach((fn) => { - if (typeof fn === 'function' && fn.mockClear) { - fn.mockClear(); - } - }); -} diff --git a/apps/calendar/apps/backend/src/__tests__/utils/mock-factories.ts b/apps/calendar/apps/backend/src/__tests__/utils/mock-factories.ts deleted file mode 100644 index be4951adf..000000000 --- a/apps/calendar/apps/backend/src/__tests__/utils/mock-factories.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Mock factories for test data generation - */ - -import { v4 as uuidv4 } from 'uuid'; -import type { Calendar } from '../../db/schema/calendars.schema'; -import type { Event } from '../../db/schema/events.schema'; -import type { Reminder } from '../../db/schema/reminders.schema'; -import type { CalendarShare } from '../../db/schema/calendar-shares.schema'; -import type { DeviceToken } from '../../db/schema/device-tokens.schema'; - -// Default test user -export const TEST_USER_ID = 'test-user-123'; -export const TEST_USER_EMAIL = 'test@example.com'; - -/** - * Create a mock calendar - */ -export function createMockCalendar(overrides: Partial = {}): Calendar { - return { - id: uuidv4(), - userId: TEST_USER_ID, - name: 'Test Calendar', - description: 'A test calendar', - color: '#3B82F6', - isDefault: false, - isVisible: true, - timezone: 'Europe/Berlin', - settings: null, - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }; -} - -/** - * Create a mock event - */ -export function createMockEvent(overrides: Partial = {}): Event { - const startTime = new Date(); - startTime.setHours(startTime.getHours() + 1); - const endTime = new Date(startTime); - endTime.setHours(endTime.getHours() + 1); - - return { - id: uuidv4(), - calendarId: uuidv4(), - userId: TEST_USER_ID, - title: 'Test Event', - description: 'A test event', - location: null, - startTime, - endTime, - isAllDay: false, - timezone: 'Europe/Berlin', - recurrenceRule: null, - recurrenceEndDate: null, - recurrenceExceptions: null, - parentEventId: null, - color: null, - status: 'confirmed', - externalId: null, - externalCalendarId: null, - lastSyncedAt: null, - metadata: null, - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }; -} - -/** - * Create a mock reminder - */ -export function createMockReminder(overrides: Partial = {}): Reminder { - const reminderTime = new Date(); - reminderTime.setMinutes(reminderTime.getMinutes() + 15); - - return { - id: uuidv4(), - eventId: uuidv4(), - userId: TEST_USER_ID, - userEmail: TEST_USER_EMAIL, - minutesBefore: 15, - reminderTime, - notifyPush: true, - notifyEmail: false, - status: 'pending', - sentAt: null, - eventInstanceDate: null, - createdAt: new Date(), - ...overrides, - }; -} - -/** - * Create a mock calendar share - */ -export function createMockCalendarShare(overrides: Partial = {}): CalendarShare { - return { - id: uuidv4(), - calendarId: uuidv4(), - sharedWithUserId: null, - sharedWithEmail: 'shared@example.com', - permission: 'read', - shareToken: null, - shareUrl: null, - status: 'pending', - invitedBy: TEST_USER_ID, - acceptedAt: null, - expiresAt: null, - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }; -} - -/** - * Create a mock device token - */ -export function createMockDeviceToken(overrides: Partial = {}): DeviceToken { - return { - id: uuidv4(), - userId: TEST_USER_ID, - pushToken: `ExponentPushToken[${uuidv4()}]`, - platform: 'ios', - deviceName: 'Test iPhone', - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }; -} - -/** - * Create mock database query result - */ -export function createQueryResult(data: T[]): T[] { - return data; -} diff --git a/apps/calendar/apps/backend/src/admin/admin.controller.ts b/apps/calendar/apps/backend/src/admin/admin.controller.ts deleted file mode 100644 index 55980bc92..000000000 --- a/apps/calendar/apps/backend/src/admin/admin.controller.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - Controller, - Get, - Delete, - Param, - UseGuards, - Logger, - HttpCode, - HttpStatus, -} from '@nestjs/common'; -import { AdminService } from './admin.service'; -import { ServiceAuthGuard } from './guards/service-auth.guard'; -import { UserDataResponse, DeleteUserDataResponse } from './dto/user-data-response.dto'; - -@Controller('admin') -@UseGuards(ServiceAuthGuard) -export class AdminController { - private readonly logger = new Logger(AdminController.name); - - constructor(private readonly adminService: AdminService) {} - - @Get('user-data/:userId') - async getUserData(@Param('userId') userId: string): Promise { - this.logger.log(`Admin request: getUserData for userId=${userId}`); - return this.adminService.getUserData(userId); - } - - @Delete('user-data/:userId') - @HttpCode(HttpStatus.OK) - async deleteUserData(@Param('userId') userId: string): Promise { - this.logger.log(`Admin request: deleteUserData for userId=${userId}`); - return this.adminService.deleteUserData(userId); - } -} diff --git a/apps/calendar/apps/backend/src/admin/admin.module.ts b/apps/calendar/apps/backend/src/admin/admin.module.ts deleted file mode 100644 index a8f6ed50c..000000000 --- a/apps/calendar/apps/backend/src/admin/admin.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { AdminController } from './admin.controller'; -import { AdminService } from './admin.service'; -import { DatabaseModule } from '../db/database.module'; - -@Module({ - imports: [ConfigModule, DatabaseModule], - controllers: [AdminController], - providers: [AdminService], -}) -export class AdminModule {} diff --git a/apps/calendar/apps/backend/src/admin/admin.service.ts b/apps/calendar/apps/backend/src/admin/admin.service.ts deleted file mode 100644 index 2aa41e6c8..000000000 --- a/apps/calendar/apps/backend/src/admin/admin.service.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Inject } from '@nestjs/common'; -import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; -import { eq, sql, desc, inArray } from 'drizzle-orm'; -import * as schema from '../db/schema'; -import { - UserDataResponse, - DeleteUserDataResponse, - EntityCount, -} from './dto/user-data-response.dto'; - -@Injectable() -export class AdminService { - private readonly logger = new Logger(AdminService.name); - - constructor( - @Inject('DATABASE_CONNECTION') - private readonly db: PostgresJsDatabase - ) {} - - async getUserData(userId: string): Promise { - this.logger.log(`Getting user data for userId: ${userId}`); - - // Count calendars - const calendarsResult = await this.db - .select({ count: sql`count(*)::int` }) - .from(schema.calendars) - .where(eq(schema.calendars.userId, userId)); - const calendarsCount = calendarsResult[0]?.count ?? 0; - - // Count events - const eventsResult = await this.db - .select({ count: sql`count(*)::int` }) - .from(schema.events) - .where(eq(schema.events.userId, userId)); - const eventsCount = eventsResult[0]?.count ?? 0; - - // Count reminders - const remindersResult = await this.db - .select({ count: sql`count(*)::int` }) - .from(schema.reminders) - .where(eq(schema.reminders.userId, userId)); - const remindersCount = remindersResult[0]?.count ?? 0; - - // Count calendar shares (invited by user) - const sharesResult = await this.db - .select({ count: sql`count(*)::int` }) - .from(schema.calendarShares) - .where(eq(schema.calendarShares.invitedBy, userId)); - const sharesCount = sharesResult[0]?.count ?? 0; - - // Count external calendars - const externalCalendarsResult = await this.db - .select({ count: sql`count(*)::int` }) - .from(schema.externalCalendars) - .where(eq(schema.externalCalendars.userId, userId)); - const externalCalendarsCount = externalCalendarsResult[0]?.count ?? 0; - - // Get last activity - const lastEvent = await this.db - .select({ updatedAt: schema.events.updatedAt }) - .from(schema.events) - .where(eq(schema.events.userId, userId)) - .orderBy(desc(schema.events.updatedAt)) - .limit(1); - const lastActivityAt = lastEvent[0]?.updatedAt?.toISOString(); - - const entities: EntityCount[] = [ - { entity: 'calendars', count: calendarsCount, label: 'Calendars' }, - { entity: 'events', count: eventsCount, label: 'Events' }, - { entity: 'reminders', count: remindersCount, label: 'Reminders' }, - { entity: 'calendar_shares', count: sharesCount, label: 'Calendar Shares' }, - { entity: 'external_calendars', count: externalCalendarsCount, label: 'External Calendars' }, - ]; - - const totalCount = - calendarsCount + eventsCount + remindersCount + sharesCount + externalCalendarsCount; - - return { entities, totalCount, lastActivityAt }; - } - - async deleteUserData(userId: string): Promise { - this.logger.log(`Deleting user data for userId: ${userId}`); - - const deletedCounts: EntityCount[] = []; - let totalDeleted = 0; - - // Delete reminders - const deletedReminders = await this.db - .delete(schema.reminders) - .where(eq(schema.reminders.userId, userId)) - .returning(); - deletedCounts.push({ entity: 'reminders', count: deletedReminders.length, label: 'Reminders' }); - totalDeleted += deletedReminders.length; - - // Delete calendar shares (where user invited) - const deletedShares = await this.db - .delete(schema.calendarShares) - .where(eq(schema.calendarShares.invitedBy, userId)) - .returning(); - deletedCounts.push({ - entity: 'calendar_shares', - count: deletedShares.length, - label: 'Calendar Shares', - }); - totalDeleted += deletedShares.length; - - // Delete events (cascades from calendars but also direct) - const deletedEvents = await this.db - .delete(schema.events) - .where(eq(schema.events.userId, userId)) - .returning(); - deletedCounts.push({ entity: 'events', count: deletedEvents.length, label: 'Events' }); - totalDeleted += deletedEvents.length; - - // Delete external calendars - const deletedExternalCalendars = await this.db - .delete(schema.externalCalendars) - .where(eq(schema.externalCalendars.userId, userId)) - .returning(); - deletedCounts.push({ - entity: 'external_calendars', - count: deletedExternalCalendars.length, - label: 'External Calendars', - }); - totalDeleted += deletedExternalCalendars.length; - - // Delete calendars - const deletedCalendars = await this.db - .delete(schema.calendars) - .where(eq(schema.calendars.userId, userId)) - .returning(); - deletedCounts.push({ entity: 'calendars', count: deletedCalendars.length, label: 'Calendars' }); - totalDeleted += deletedCalendars.length; - - // Delete device tokens - const deletedDeviceTokens = await this.db - .delete(schema.deviceTokens) - .where(eq(schema.deviceTokens.userId, userId)) - .returning(); - deletedCounts.push({ - entity: 'device_tokens', - count: deletedDeviceTokens.length, - label: 'Device Tokens', - }); - totalDeleted += deletedDeviceTokens.length; - - this.logger.log(`Deleted ${totalDeleted} records for userId: ${userId}`); - - return { success: true, deletedCounts, totalDeleted }; - } -} diff --git a/apps/calendar/apps/backend/src/admin/dto/user-data-response.dto.ts b/apps/calendar/apps/backend/src/admin/dto/user-data-response.dto.ts deleted file mode 100644 index 562a2eb6d..000000000 --- a/apps/calendar/apps/backend/src/admin/dto/user-data-response.dto.ts +++ /dev/null @@ -1,17 +0,0 @@ -export interface EntityCount { - entity: string; - count: number; - label: string; -} - -export interface UserDataResponse { - entities: EntityCount[]; - totalCount: number; - lastActivityAt?: string; -} - -export interface DeleteUserDataResponse { - success: boolean; - deletedCounts: EntityCount[]; - totalDeleted: number; -} diff --git a/apps/calendar/apps/backend/src/admin/guards/service-auth.guard.ts b/apps/calendar/apps/backend/src/admin/guards/service-auth.guard.ts deleted file mode 100644 index 535b89f4e..000000000 --- a/apps/calendar/apps/backend/src/admin/guards/service-auth.guard.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { - Injectable, - CanActivate, - ExecutionContext, - UnauthorizedException, - Logger, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { Request } from 'express'; - -@Injectable() -export class ServiceAuthGuard implements CanActivate { - private readonly logger = new Logger(ServiceAuthGuard.name); - private readonly serviceKey: string; - - constructor(private readonly configService: ConfigService) { - this.serviceKey = this.configService.get('ADMIN_SERVICE_KEY', 'dev-admin-key'); - } - - canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest(); - const providedKey = request.headers['x-service-key'] as string; - - if (!providedKey) { - this.logger.warn('Missing X-Service-Key header'); - throw new UnauthorizedException('Missing service key'); - } - - if (providedKey !== this.serviceKey) { - this.logger.warn('Invalid service key provided'); - throw new UnauthorizedException('Invalid service key'); - } - - return true; - } -} diff --git a/apps/calendar/apps/backend/src/app.module.ts b/apps/calendar/apps/backend/src/app.module.ts deleted file mode 100644 index 6668f5985..000000000 --- a/apps/calendar/apps/backend/src/app.module.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Module } from '@nestjs/common'; -import { APP_FILTER, APP_GUARD } from '@nestjs/core'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { ScheduleModule } from '@nestjs/schedule'; -import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; -import { MetricsModule } from '@manacore/shared-nestjs-metrics'; -import { ManaCoreModule } from '@manacore/nestjs-integration'; -import { DatabaseModule } from './db/database.module'; -import { HealthModule } from '@manacore/shared-nestjs-health'; -import { CalendarModule } from './calendar/calendar.module'; -import { EventModule } from './event/event.module'; -import { EventTagModule } from './event-tag/event-tag.module'; -import { EventTagGroupModule } from './event-tag-group/event-tag-group.module'; -import { ReminderModule } from './reminder/reminder.module'; -import { ShareModule } from './share/share.module'; -import { SyncModule } from './sync/sync.module'; -import { NetworkModule } from './network/network.module'; -import { EmailModule } from './email/email.module'; -import { NotificationModule } from './notification/notification.module'; -import { AdminModule } from './admin/admin.module'; -import { HttpExceptionFilter } from './common/http-exception.filter'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - envFilePath: '.env', - }), - ScheduleModule.forRoot(), - ThrottlerModule.forRoot([ - { - ttl: 60000, // 60 seconds - limit: 100, // 100 requests per minute - }, - ]), - MetricsModule.register({ - prefix: 'calendar_', - excludePaths: ['/health'], - }), - ManaCoreModule.forRootAsync({ - imports: [ConfigModule], - useFactory: (configService: ConfigService) => ({ - appId: configService.get('APP_ID', 'calendar'), - serviceKey: configService.get('MANA_CORE_SERVICE_KEY', ''), - debug: configService.get('NODE_ENV') === 'development', - }), - inject: [ConfigService], - }), - DatabaseModule, - HealthModule.forRoot({ serviceName: 'calendar-backend' }), - EmailModule, - NotificationModule, - CalendarModule, - EventModule, - EventTagModule, - EventTagGroupModule, - ReminderModule, - ShareModule, - SyncModule, - NetworkModule, - AdminModule, - ], - providers: [ - { - provide: APP_FILTER, - useClass: HttpExceptionFilter, - }, - { - provide: APP_GUARD, - useClass: ThrottlerGuard, - }, - ], -}) -export class AppModule {} diff --git a/apps/calendar/apps/backend/src/calendar/calendar.controller.spec.ts b/apps/calendar/apps/backend/src/calendar/calendar.controller.spec.ts deleted file mode 100644 index d533e096f..000000000 --- a/apps/calendar/apps/backend/src/calendar/calendar.controller.spec.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { CalendarController } from './calendar.controller'; -import { createMockCalendar, TEST_USER_ID } from '../__tests__/utils/mock-factories'; - -const mockUser = { userId: TEST_USER_ID, email: 'test@example.com' }; - -describe('CalendarController', () => { - let controller: CalendarController; - let service: any; - - beforeEach(() => { - service = { - findAll: jest.fn(), - findByIdOrThrow: jest.fn(), - create: jest.fn(), - update: jest.fn(), - delete: jest.fn(), - getOrCreateDefaultCalendar: jest.fn(), - }; - controller = new CalendarController(service); - }); - - afterEach(() => jest.clearAllMocks()); - - describe('findAll', () => { - it('should return user calendars', async () => { - const calendars = [createMockCalendar(), createMockCalendar()]; - service.findAll.mockResolvedValue(calendars); - - const result = await controller.findAll(mockUser as any); - - expect(result).toEqual({ calendars }); - expect(service.findAll).toHaveBeenCalledWith(TEST_USER_ID); - }); - - it('should lazy-create default calendar when none exist', async () => { - const defaultCal = createMockCalendar({ isDefault: true }); - service.findAll.mockResolvedValue([]); - service.getOrCreateDefaultCalendar.mockResolvedValue(defaultCal); - - const result = await controller.findAll(mockUser as any); - - expect(result).toEqual({ calendars: [defaultCal] }); - expect(service.getOrCreateDefaultCalendar).toHaveBeenCalledWith(TEST_USER_ID); - }); - }); - - describe('findOne', () => { - it('should return calendar by id', async () => { - const calendar = createMockCalendar(); - service.findByIdOrThrow.mockResolvedValue(calendar); - - const result = await controller.findOne(mockUser as any, calendar.id); - - expect(result).toEqual({ calendar }); - }); - }); - - describe('create', () => { - it('should create and return calendar', async () => { - const calendar = createMockCalendar({ name: 'New Cal' }); - service.create.mockResolvedValue(calendar); - - const result = await controller.create(mockUser as any, { name: 'New Cal' } as any); - - expect(result).toEqual({ calendar }); - }); - }); - - describe('update', () => { - it('should update and return calendar', async () => { - const calendar = createMockCalendar({ name: 'Updated' }); - service.update.mockResolvedValue(calendar); - - const result = await controller.update(mockUser as any, calendar.id, { - name: 'Updated', - } as any); - - expect(result).toEqual({ calendar }); - }); - }); - - describe('delete', () => { - it('should delete and return success', async () => { - service.delete.mockResolvedValue(undefined); - - const result = await controller.delete(mockUser as any, 'cal-id'); - - expect(result).toEqual({ success: true }); - }); - }); -}); diff --git a/apps/calendar/apps/backend/src/calendar/calendar.controller.ts b/apps/calendar/apps/backend/src/calendar/calendar.controller.ts deleted file mode 100644 index 8a6b9b562..000000000 --- a/apps/calendar/apps/backend/src/calendar/calendar.controller.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common'; -import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; -import { UseCredits } from '@manacore/nestjs-integration'; -import { CreditOperationType } from '@manacore/credit-operations'; -import { CalendarService } from './calendar.service'; -import { CreateCalendarDto, UpdateCalendarDto } from './dto'; - -@Controller('calendars') -@UseGuards(JwtAuthGuard) -export class CalendarController { - constructor(private readonly calendarService: CalendarService) {} - - @Get() - async findAll(@CurrentUser() user: CurrentUserData) { - let calendars = await this.calendarService.findAll(user.userId); - - // Lazy creation: if no calendars exist, create a default one - if (calendars.length === 0) { - const defaultCalendar = await this.calendarService.getOrCreateDefaultCalendar(user.userId); - calendars = [defaultCalendar]; - } - - return { calendars }; - } - - @Get(':id') - async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - const calendar = await this.calendarService.findByIdOrThrow(id, user.userId); - return { calendar }; - } - - @Post() - @UseCredits(CreditOperationType.CALENDAR_CREATE) - async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateCalendarDto) { - const calendar = await this.calendarService.create(user.userId, dto); - return { calendar }; - } - - @Put(':id') - async update( - @CurrentUser() user: CurrentUserData, - @Param('id') id: string, - @Body() dto: UpdateCalendarDto - ) { - const calendar = await this.calendarService.update(id, user.userId, dto); - return { calendar }; - } - - @Delete(':id') - async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - await this.calendarService.delete(id, user.userId); - return { success: true }; - } -} diff --git a/apps/calendar/apps/backend/src/calendar/calendar.module.ts b/apps/calendar/apps/backend/src/calendar/calendar.module.ts deleted file mode 100644 index 485b221d9..000000000 --- a/apps/calendar/apps/backend/src/calendar/calendar.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { CalendarController } from './calendar.controller'; -import { CalendarService } from './calendar.service'; - -@Module({ - controllers: [CalendarController], - providers: [CalendarService], - exports: [CalendarService], -}) -export class CalendarModule {} diff --git a/apps/calendar/apps/backend/src/calendar/calendar.service.spec.ts b/apps/calendar/apps/backend/src/calendar/calendar.service.spec.ts deleted file mode 100644 index 8d51529cb..000000000 --- a/apps/calendar/apps/backend/src/calendar/calendar.service.spec.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { NotFoundException } from '@nestjs/common'; -import { CalendarService } from './calendar.service'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { createMockCalendar, TEST_USER_ID } from '../__tests__/utils/mock-factories'; - -describe('CalendarService', () => { - let service: CalendarService; - let mockDb: any; - - beforeEach(async () => { - // Create mock database with chainable methods - mockDb = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - returning: jest.fn(), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - delete: jest.fn().mockReturnThis(), - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - CalendarService, - { - provide: DATABASE_CONNECTION, - useValue: mockDb, - }, - ], - }).compile(); - - service = module.get(CalendarService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('findAll', () => { - it('should return all calendars for a user', async () => { - const calendars = [ - createMockCalendar({ name: 'Calendar 1' }), - createMockCalendar({ name: 'Calendar 2' }), - ]; - mockDb.where.mockResolvedValueOnce(calendars); - - const result = await service.findAll(TEST_USER_ID); - - expect(result).toEqual(calendars); - expect(mockDb.select).toHaveBeenCalled(); - expect(mockDb.from).toHaveBeenCalled(); - expect(mockDb.where).toHaveBeenCalled(); - }); - - it('should return empty array when user has no calendars', async () => { - mockDb.where.mockResolvedValueOnce([]); - - const result = await service.findAll(TEST_USER_ID); - - expect(result).toEqual([]); - }); - }); - - describe('findById', () => { - it('should return calendar when found', async () => { - const calendar = createMockCalendar(); - mockDb.where.mockResolvedValueOnce([calendar]); - - const result = await service.findById(calendar.id, TEST_USER_ID); - - expect(result).toEqual(calendar); - }); - - it('should return null when calendar not found', async () => { - mockDb.where.mockResolvedValueOnce([]); - - const result = await service.findById('non-existent-id', TEST_USER_ID); - - expect(result).toBeNull(); - }); - }); - - describe('findByIdOrThrow', () => { - it('should return calendar when found', async () => { - const calendar = createMockCalendar(); - mockDb.where.mockResolvedValueOnce([calendar]); - - const result = await service.findByIdOrThrow(calendar.id, TEST_USER_ID); - - expect(result).toEqual(calendar); - }); - - it('should throw NotFoundException when calendar not found', async () => { - mockDb.where.mockResolvedValueOnce([]); - - await expect(service.findByIdOrThrow('non-existent-id', TEST_USER_ID)).rejects.toThrow( - NotFoundException - ); - }); - }); - - describe('create', () => { - it('should create a new calendar', async () => { - const newCalendar = createMockCalendar({ name: 'New Calendar' }); - mockDb.returning.mockResolvedValueOnce([newCalendar]); - - const result = await service.create(TEST_USER_ID, { - name: 'New Calendar', - color: '#3B82F6', - }); - - expect(result).toEqual(newCalendar); - expect(mockDb.insert).toHaveBeenCalled(); - expect(mockDb.values).toHaveBeenCalled(); - }); - - it('should clear other defaults when creating default calendar', async () => { - const newCalendar = createMockCalendar({ name: 'Default', isDefault: true }); - mockDb.returning.mockResolvedValueOnce([newCalendar]); - - await service.create(TEST_USER_ID, { - name: 'Default', - isDefault: true, - }); - - // Should have called update to clear other defaults - expect(mockDb.update).toHaveBeenCalled(); - }); - }); - - describe('update', () => { - it('should update calendar', async () => { - const calendar = createMockCalendar(); - const updatedCalendar = { ...calendar, name: 'Updated Name' }; - - // Mock findByIdOrThrow - mockDb.where.mockResolvedValueOnce([calendar]); - // Mock update returning - mockDb.returning.mockResolvedValueOnce([updatedCalendar]); - - const result = await service.update(calendar.id, TEST_USER_ID, { - name: 'Updated Name', - }); - - expect(result.name).toBe('Updated Name'); - }); - - it('should throw NotFoundException when updating non-existent calendar', async () => { - mockDb.where.mockResolvedValueOnce([]); - - await expect( - service.update('non-existent-id', TEST_USER_ID, { name: 'New Name' }) - ).rejects.toThrow(NotFoundException); - }); - }); - - describe('delete', () => { - it('should delete calendar', async () => { - const calendar = createMockCalendar({ isDefault: false }); - mockDb.where.mockResolvedValueOnce([calendar]); - - await service.delete(calendar.id, TEST_USER_ID); - - expect(mockDb.delete).toHaveBeenCalled(); - }); - - it('should throw NotFoundException when deleting non-existent calendar', async () => { - mockDb.where.mockResolvedValueOnce([]); - - await expect(service.delete('non-existent-id', TEST_USER_ID)).rejects.toThrow( - NotFoundException - ); - }); - - it('should throw error when deleting only calendar that is default', async () => { - const calendar = createMockCalendar({ isDefault: true }); - // First call returns the calendar - mockDb.where.mockResolvedValueOnce([calendar]); - // Second call (findAll) returns only this calendar - mockDb.where.mockResolvedValueOnce([calendar]); - - await expect(service.delete(calendar.id, TEST_USER_ID)).rejects.toThrow( - 'Cannot delete the only calendar' - ); - }); - }); - - describe('getOrCreateDefaultCalendar', () => { - it('should return existing default calendar', async () => { - const defaultCalendar = createMockCalendar({ isDefault: true }); - mockDb.where.mockResolvedValueOnce([defaultCalendar]); - - const result = await service.getOrCreateDefaultCalendar(TEST_USER_ID); - - expect(result).toEqual(defaultCalendar); - }); - - it('should make first calendar default if no default exists', async () => { - const calendar = createMockCalendar({ isDefault: false }); - const updatedCalendar = { ...calendar, isDefault: true }; - - // No default calendar - mockDb.where.mockResolvedValueOnce([]); - // Limit returns one calendar - mockDb.limit.mockResolvedValueOnce([calendar]); - // Update returns updated calendar - mockDb.returning.mockResolvedValueOnce([updatedCalendar]); - - const result = await service.getOrCreateDefaultCalendar(TEST_USER_ID); - - expect(result.isDefault).toBe(true); - }); - }); -}); diff --git a/apps/calendar/apps/backend/src/calendar/calendar.service.ts b/apps/calendar/apps/backend/src/calendar/calendar.service.ts deleted file mode 100644 index bdd8ec060..000000000 --- a/apps/calendar/apps/backend/src/calendar/calendar.service.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { Injectable, Inject, NotFoundException } from '@nestjs/common'; -import { eq, and } from 'drizzle-orm'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { Database } from '../db/connection'; -import { calendars } from '../db/schema/calendars.schema'; -import type { Calendar, NewCalendar } from '../db/schema/calendars.schema'; -import { CreateCalendarDto, UpdateCalendarDto } from './dto'; - -@Injectable() -export class CalendarService { - constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} - - async findAll(userId: string): Promise { - return this.db.select().from(calendars).where(eq(calendars.userId, userId)); - } - - async findById(id: string, userId: string): Promise { - const result = await this.db - .select() - .from(calendars) - .where(and(eq(calendars.id, id), eq(calendars.userId, userId))); - return result[0] || null; - } - - async findByIdOrThrow(id: string, userId: string): Promise { - const calendar = await this.findById(id, userId); - if (!calendar) { - throw new NotFoundException(`Calendar with id ${id} not found`); - } - return calendar; - } - - async create(userId: string, dto: CreateCalendarDto): Promise { - // If this is the first calendar or marked as default, handle default logic - if (dto.isDefault) { - await this.clearDefaultCalendar(userId); - } - - const newCalendar: NewCalendar = { - userId, - name: dto.name, - description: dto.description, - color: dto.color || '#3B82F6', - isDefault: dto.isDefault ?? false, - isVisible: dto.isVisible ?? true, - timezone: dto.timezone || 'Europe/Berlin', - settings: dto.settings, - }; - - const [created] = await this.db.insert(calendars).values(newCalendar).returning(); - return created; - } - - async update(id: string, userId: string, dto: UpdateCalendarDto): Promise { - await this.findByIdOrThrow(id, userId); - - // If setting as default, clear other defaults - if (dto.isDefault) { - await this.clearDefaultCalendar(userId); - } - - const [updated] = await this.db - .update(calendars) - .set({ - ...dto, - updatedAt: new Date(), - }) - .where(and(eq(calendars.id, id), eq(calendars.userId, userId))) - .returning(); - - return updated; - } - - async delete(id: string, userId: string): Promise { - const calendar = await this.findByIdOrThrow(id, userId); - - // Don't allow deleting the default calendar if it's the only one - if (calendar.isDefault) { - const allCalendars = await this.findAll(userId); - if (allCalendars.length === 1) { - throw new Error('Cannot delete the only calendar'); - } - } - - await this.db.delete(calendars).where(and(eq(calendars.id, id), eq(calendars.userId, userId))); - } - - async getOrCreateDefaultCalendar(userId: string): Promise { - // Try to find existing default calendar - const existing = await this.db - .select() - .from(calendars) - .where(and(eq(calendars.userId, userId), eq(calendars.isDefault, true))); - - if (existing.length > 0) { - return existing[0]; - } - - // Try to find any calendar - const anyCalendar = await this.db - .select() - .from(calendars) - .where(eq(calendars.userId, userId)) - .limit(1); - - if (anyCalendar.length > 0) { - // Make it the default — unique partial index prevents duplicates - try { - const [updated] = await this.db - .update(calendars) - .set({ isDefault: true, updatedAt: new Date() }) - .where(eq(calendars.id, anyCalendar[0].id)) - .returning(); - return updated; - } catch (error: any) { - // Unique constraint violation — another request already set a default - if (error?.code === '23505') { - const [defaultCal] = await this.db - .select() - .from(calendars) - .where(and(eq(calendars.userId, userId), eq(calendars.isDefault, true))); - return defaultCal; - } - throw error; - } - } - - // Create default calendars for new user — unique partial index prevents - // concurrent requests from creating duplicate defaults - try { - await this.createDefaultCalendars(userId); - } catch (error: any) { - // Unique constraint violation — another request already created defaults - if (error?.code === '23505') { - const [defaultCal] = await this.db - .select() - .from(calendars) - .where(and(eq(calendars.userId, userId), eq(calendars.isDefault, true))); - if (defaultCal) { - return defaultCal; - } - } - throw error; - } - - // Return the default one - const defaultCal = await this.db - .select() - .from(calendars) - .where(and(eq(calendars.userId, userId), eq(calendars.isDefault, true))); - - return defaultCal[0]; - } - - /** - * Create default calendars for a new user - */ - async createDefaultCalendars(userId: string): Promise { - const defaultCalendars = [ - { - name: 'Persönlich', - color: '#3B82F6', // Blue - isDefault: true, - description: 'Private Termine', - }, - { - name: 'Beruf', - color: '#10B981', // Green - isDefault: false, - description: 'Arbeit, Meetings, Projekte', - }, - { - name: 'Familie', - color: '#F97316', // Orange - isDefault: false, - description: 'Familientermine, Geburtstage', - }, - { - name: 'Freizeit', - color: '#8B5CF6', // Violet - isDefault: false, - description: 'Hobbies, Sport, Events', - }, - ]; - - const created: Calendar[] = []; - for (const cal of defaultCalendars) { - const calendar = await this.create(userId, cal); - created.push(calendar); - } - - return created; - } - - private async clearDefaultCalendar(userId: string): Promise { - await this.db - .update(calendars) - .set({ isDefault: false, updatedAt: new Date() }) - .where(and(eq(calendars.userId, userId), eq(calendars.isDefault, true))); - } -} diff --git a/apps/calendar/apps/backend/src/calendar/dto/create-calendar.dto.ts b/apps/calendar/apps/backend/src/calendar/dto/create-calendar.dto.ts deleted file mode 100644 index 4e31b4967..000000000 --- a/apps/calendar/apps/backend/src/calendar/dto/create-calendar.dto.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { IsString, IsOptional, IsBoolean, IsObject, MaxLength } from 'class-validator'; -import type { CalendarSettings } from '../../db/schema/calendars.schema'; - -export class CreateCalendarDto { - @IsString() - @MaxLength(255) - name: string; - - @IsOptional() - @IsString() - description?: string; - - @IsOptional() - @IsString() - @MaxLength(7) - color?: string; - - @IsOptional() - @IsBoolean() - isDefault?: boolean; - - @IsOptional() - @IsBoolean() - isVisible?: boolean; - - @IsOptional() - @IsString() - @MaxLength(100) - timezone?: string; - - @IsOptional() - @IsObject() - settings?: CalendarSettings; -} diff --git a/apps/calendar/apps/backend/src/calendar/dto/index.ts b/apps/calendar/apps/backend/src/calendar/dto/index.ts deleted file mode 100644 index 148a8248a..000000000 --- a/apps/calendar/apps/backend/src/calendar/dto/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './create-calendar.dto'; -export * from './update-calendar.dto'; diff --git a/apps/calendar/apps/backend/src/calendar/dto/update-calendar.dto.ts b/apps/calendar/apps/backend/src/calendar/dto/update-calendar.dto.ts deleted file mode 100644 index 48c962867..000000000 --- a/apps/calendar/apps/backend/src/calendar/dto/update-calendar.dto.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { IsString, IsOptional, IsBoolean, IsObject, MaxLength } from 'class-validator'; -import type { CalendarSettings } from '../../db/schema/calendars.schema'; - -export class UpdateCalendarDto { - @IsOptional() - @IsString() - @MaxLength(255) - name?: string; - - @IsOptional() - @IsString() - description?: string | null; - - @IsOptional() - @IsString() - @MaxLength(7) - color?: string; - - @IsOptional() - @IsBoolean() - isDefault?: boolean; - - @IsOptional() - @IsBoolean() - isVisible?: boolean; - - @IsOptional() - @IsString() - @MaxLength(100) - timezone?: string; - - @IsOptional() - @IsObject() - settings?: CalendarSettings; -} diff --git a/apps/calendar/apps/backend/src/common/encryption.service.ts b/apps/calendar/apps/backend/src/common/encryption.service.ts deleted file mode 100644 index 9a2a0e886..000000000 --- a/apps/calendar/apps/backend/src/common/encryption.service.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto'; - -@Injectable() -export class EncryptionService { - private readonly algorithm = 'aes-256-gcm'; - private readonly key: Buffer; - - constructor(private readonly configService: ConfigService) { - const secret = this.configService.get( - 'ENCRYPTION_KEY', - 'calendar-dev-encryption-key-change-in-prod' - ); - this.key = scryptSync(secret, 'salt', 32); - } - - encrypt(text: string): string { - const iv = randomBytes(16); - const cipher = createCipheriv(this.algorithm, this.key, iv); - const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]); - const authTag = cipher.getAuthTag(); - // Format: iv:authTag:encrypted (all base64) - return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted.toString('base64')}`; - } - - decrypt(encryptedText: string): string { - const [ivBase64, authTagBase64, dataBase64] = encryptedText.split(':'); - if (!ivBase64 || !authTagBase64 || !dataBase64) { - throw new Error('Invalid encrypted text format'); - } - const iv = Buffer.from(ivBase64, 'base64'); - const authTag = Buffer.from(authTagBase64, 'base64'); - const encrypted = Buffer.from(dataBase64, 'base64'); - const decipher = createDecipheriv(this.algorithm, this.key, iv); - decipher.setAuthTag(authTag); - return decipher.update(encrypted) + decipher.final('utf8'); - } -} diff --git a/apps/calendar/apps/backend/src/common/http-exception.filter.ts b/apps/calendar/apps/backend/src/common/http-exception.filter.ts deleted file mode 100644 index e73f40d46..000000000 --- a/apps/calendar/apps/backend/src/common/http-exception.filter.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { - ExceptionFilter, - Catch, - ArgumentsHost, - HttpException, - HttpStatus, - Logger, -} from '@nestjs/common'; -import { Response, Request } from 'express'; - -@Catch() -export class HttpExceptionFilter implements ExceptionFilter { - private readonly logger = new Logger(HttpExceptionFilter.name); - - catch(exception: unknown, host: ArgumentsHost) { - const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - const request = ctx.getRequest(); - - let status = HttpStatus.INTERNAL_SERVER_ERROR; - let message = 'Internal server error'; - let error = 'Internal Server Error'; - - if (exception instanceof HttpException) { - status = exception.getStatus(); - const exceptionResponse = exception.getResponse(); - - if (typeof exceptionResponse === 'string') { - message = exceptionResponse; - error = exceptionResponse; - } else if (typeof exceptionResponse === 'object') { - const res = exceptionResponse as Record; - message = res.message || message; - error = res.error || error; - } - } else if (exception instanceof Error) { - message = exception.message; - this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack); - } else { - this.logger.error('Unknown exception', exception); - } - - if (status >= 500) { - this.logger.error( - `[${request.method}] ${request.url} - ${status}: ${message}`, - exception instanceof Error ? exception.stack : undefined - ); - } else { - this.logger.warn(`[${request.method}] ${request.url} - ${status}: ${message}`); - } - - response.status(status).json({ - statusCode: status, - message, - error, - timestamp: new Date().toISOString(), - path: request.url, - }); - } -} diff --git a/apps/calendar/apps/backend/src/db/connection.ts b/apps/calendar/apps/backend/src/db/connection.ts deleted file mode 100644 index fccc63f4a..000000000 --- a/apps/calendar/apps/backend/src/db/connection.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { drizzle } from 'drizzle-orm/postgres-js'; -import * as schema from './schema'; - -// Use require for postgres to avoid ESM/CommonJS interop issues -// eslint-disable-next-line @typescript-eslint/no-var-requires -const postgres = require('postgres'); - -let connection: ReturnType | null = null; -let db: ReturnType | null = null; - -export function getConnection(databaseUrl: string) { - if (!connection) { - connection = postgres(databaseUrl, { - max: 10, - idle_timeout: 20, - connect_timeout: 10, - }); - } - return connection; -} - -export function getDb(databaseUrl: string) { - if (!db) { - const conn = getConnection(databaseUrl); - db = drizzle(conn, { schema }); - } - return db; -} - -export async function closeConnection() { - if (connection) { - await connection.end(); - connection = null; - db = null; - } -} - -export type Database = ReturnType; diff --git a/apps/calendar/apps/backend/src/db/database.module.ts b/apps/calendar/apps/backend/src/db/database.module.ts deleted file mode 100644 index 5a0a033b3..000000000 --- a/apps/calendar/apps/backend/src/db/database.module.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Module, Global, OnModuleDestroy } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { getDb, closeConnection } from './connection'; -import type { Database } from './connection'; - -export const DATABASE_CONNECTION = 'DATABASE_CONNECTION'; - -@Global() -@Module({ - providers: [ - { - provide: DATABASE_CONNECTION, - useFactory: (configService: ConfigService): Database => { - const databaseUrl = configService.get('DATABASE_URL'); - if (!databaseUrl) { - throw new Error('DATABASE_URL environment variable is not set'); - } - return getDb(databaseUrl); - }, - inject: [ConfigService], - }, - ], - exports: [DATABASE_CONNECTION], -}) -export class DatabaseModule implements OnModuleDestroy { - async onModuleDestroy() { - await closeConnection(); - } -} diff --git a/apps/calendar/apps/backend/src/db/migrate.ts b/apps/calendar/apps/backend/src/db/migrate.ts deleted file mode 100644 index d938d8351..000000000 --- a/apps/calendar/apps/backend/src/db/migrate.ts +++ /dev/null @@ -1,222 +0,0 @@ -/** - * Database Migration Script with Advisory Locks - * - * This script safely runs database migrations with the following features: - * - Advisory locks to prevent concurrent migrations - * - Retry logic for transient network failures - * - Timeout protection - * - Proper cleanup on exit - * - Graceful handling when no migrations exist - * - * Usage: - * pnpm migration:run # Run migrations - * MIGRATION_TIMEOUT=600 pnpm migration:run # With custom timeout (seconds) - */ - -import { drizzle } from 'drizzle-orm/postgres-js'; -import { migrate } from 'drizzle-orm/postgres-js/migrator'; -import { sql } from 'drizzle-orm'; -import postgres from 'postgres'; -import * as dotenv from 'dotenv'; -import * as fs from 'fs'; -import * as path from 'path'; - -// Load environment variables -dotenv.config(); - -// Configuration -const MIGRATION_LOCK_ID = 314159265; // Unique lock ID for calendar migrations (pi digits) -const MAX_LOCK_WAIT_MS = parseInt(process.env.MIGRATION_TIMEOUT || '300', 10) * 1000; // Default 5 minutes -const MAX_RETRIES = 3; -const RETRY_DELAY_MS = 2000; - -/** - * Retry wrapper for transient errors - */ -async function withRetry( - operation: () => Promise, - operationName: string, - maxRetries = MAX_RETRIES -): Promise { - let lastError: Error | undefined; - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - return await operation(); - } catch (error) { - lastError = error as Error; - - // Check if error is transient (network-related) - const isTransient = - lastError.message?.includes('ECONNREFUSED') || - lastError.message?.includes('ETIMEDOUT') || - lastError.message?.includes('ENOTFOUND') || - lastError.message?.includes('connection') || - (lastError as any).code === '57P03'; // PostgreSQL: cannot connect now - - if (!isTransient || attempt === maxRetries) { - throw error; - } - - const delay = RETRY_DELAY_MS * Math.pow(2, attempt - 1); // Exponential backoff - console.log( - `\u26a0\ufe0f [${operationName}] Transient error, retrying in ${delay}ms... (attempt ${attempt}/${maxRetries})` - ); - console.log(` Error: ${lastError.message}`); - await new Promise((resolve) => setTimeout(resolve, delay)); - } - } - - throw lastError!; -} - -/** - * Acquire PostgreSQL advisory lock - */ -async function acquireLock(db: ReturnType): Promise { - const result = await db.execute( - sql`SELECT pg_try_advisory_lock(${MIGRATION_LOCK_ID}) as acquired` - ); - return (result as any)[0]?.acquired === true; -} - -/** - * Release PostgreSQL advisory lock - */ -async function releaseLock(db: ReturnType): Promise { - await db.execute(sql`SELECT pg_advisory_unlock(${MIGRATION_LOCK_ID})`); -} - -/** - * Wait for migration lock with timeout - */ -async function waitForLock(db: ReturnType): Promise { - const startTime = Date.now(); - - while (Date.now() - startTime < MAX_LOCK_WAIT_MS) { - const acquired = await acquireLock(db); - if (acquired) { - return true; - } - - const elapsed = Math.round((Date.now() - startTime) / 1000); - console.log(`\u23f3 Waiting for migration lock... (${elapsed}s / ${MAX_LOCK_WAIT_MS / 1000}s)`); - await new Promise((resolve) => setTimeout(resolve, 5000)); - } - - return false; -} - -/** - * Main migration function - */ -async function runMigrations(): Promise { - const databaseUrl = process.env.DATABASE_URL; - - if (!databaseUrl) { - throw new Error('DATABASE_URL environment variable is not set'); - } - - console.log('\n\ud83d\udd04 Starting Calendar database migration process...'); - console.log(` Lock ID: ${MIGRATION_LOCK_ID}`); - console.log(` Timeout: ${MAX_LOCK_WAIT_MS / 1000}s`); - console.log(''); - - // Create connection with single connection for migrations - const connection = postgres(databaseUrl, { - max: 1, - idle_timeout: 20, - connect_timeout: 30, - }); - - const db = drizzle(connection); - let lockAcquired = false; - - try { - // Test database connection - console.log('\ud83d\udd0c Testing database connection...'); - await withRetry(async () => { - await db.execute(sql`SELECT 1`); - }, 'Database connection'); - console.log('\u2705 Database connection successful\n'); - - // Attempt to acquire advisory lock - console.log('\ud83d\udd12 Attempting to acquire migration lock...'); - - lockAcquired = await withRetry(() => acquireLock(db), 'Acquire lock'); - - if (!lockAcquired) { - console.log('\u23f3 Another instance is running migrations. Waiting for lock...'); - - lockAcquired = await waitForLock(db); - - if (!lockAcquired) { - throw new Error( - `Migration lock timeout after ${MAX_LOCK_WAIT_MS / 1000}s - another migration may be stuck` - ); - } - } - - console.log('\u2705 Migration lock acquired\n'); - - // Check if migration files exist - const migrationsFolder = './src/db/migrations'; - const journalPath = path.join(migrationsFolder, 'meta', '_journal.json'); - - if (!fs.existsSync(journalPath)) { - console.log('\u26a0\ufe0f No migration files found (meta/_journal.json missing)'); - console.log(' This is normal if you have not generated any migrations yet.'); - console.log(' To generate migrations, run: pnpm migration:generate'); - console.log(' For development, you can use: pnpm db:push'); - console.log('\n\u2705 No migrations to run\n'); - return; - } - - // Run migrations - console.log('\ud83d\udce6 Running database migrations...'); - - await withRetry( - async () => { - await migrate(db, { - migrationsFolder, - }); - }, - 'Run migrations', - 1 // Only 1 attempt for actual migrations (they should be idempotent) - ); - - console.log('\u2705 Migrations completed successfully\n'); - } catch (error) { - console.error('\n\u274c Migration failed:', error); - throw error; - } finally { - // Always attempt to release lock - if (lockAcquired) { - try { - await releaseLock(db); - console.log('\ud83d\udd13 Migration lock released'); - } catch (unlockError) { - console.error('\u26a0\ufe0f Failed to release lock:', unlockError); - } - } - - // Close connection - try { - await connection.end(); - console.log('\ud83d\udd0c Database connection closed\n'); - } catch (closeError) { - console.error('\u26a0\ufe0f Failed to close connection:', closeError); - } - } -} - -// Run migrations -runMigrations() - .then(() => { - console.log('\ud83c\udf89 Calendar migration process completed successfully'); - process.exit(0); - }) - .catch((error) => { - console.error('\n\ud83d\udca5 Migration process failed:', error.message); - process.exit(1); - }); diff --git a/apps/calendar/apps/backend/src/db/schema/calendar-shares.schema.ts b/apps/calendar/apps/backend/src/db/schema/calendar-shares.schema.ts deleted file mode 100644 index bda4a67e3..000000000 --- a/apps/calendar/apps/backend/src/db/schema/calendar-shares.schema.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { pgTable, uuid, timestamp, varchar, unique, text, index } from 'drizzle-orm/pg-core'; -import { calendars } from './calendars.schema'; - -/** - * Calendar shares table - stores calendar sharing information - */ -export const calendarShares = pgTable( - 'calendar_shares', - { - id: uuid('id').primaryKey().defaultRandom(), - calendarId: uuid('calendar_id') - .notNull() - .references(() => calendars.id, { onDelete: 'cascade' }), - sharedWithUserId: text('shared_with_user_id'), - sharedWithEmail: varchar('shared_with_email', { length: 255 }), - - // Permission level: read, write, admin - permission: varchar('permission', { length: 20 }).notNull().default('read'), - - // Share link (for public/link sharing) - shareToken: varchar('share_token', { length: 64 }), - shareUrl: varchar('share_url', { length: 500 }), - - // Status: pending, accepted, declined - status: varchar('status', { length: 20 }).default('pending'), - - // Metadata - invitedBy: text('invited_by').notNull(), - acceptedAt: timestamp('accepted_at', { withTimezone: true }), - expiresAt: timestamp('expires_at', { withTimezone: true }), - - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => ({ - uniqueUserShare: unique().on(table.calendarId, table.sharedWithUserId), - uniqueEmailShare: unique().on(table.calendarId, table.sharedWithEmail), - calendarIdx: index('calendar_shares_calendar_idx').on(table.calendarId), - sharedWithUserIdx: index('calendar_shares_shared_with_user_idx').on(table.sharedWithUserId), - shareTokenIdx: index('calendar_shares_token_idx').on(table.shareToken), - }) -); - -export type CalendarShare = typeof calendarShares.$inferSelect; -export type NewCalendarShare = typeof calendarShares.$inferInsert; diff --git a/apps/calendar/apps/backend/src/db/schema/calendars.schema.ts b/apps/calendar/apps/backend/src/db/schema/calendars.schema.ts deleted file mode 100644 index 79e792610..000000000 --- a/apps/calendar/apps/backend/src/db/schema/calendars.schema.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { - pgTable, - uuid, - text, - timestamp, - varchar, - boolean, - jsonb, - index, - uniqueIndex, -} from 'drizzle-orm/pg-core'; -import { sql } from 'drizzle-orm'; - -/** - * Calendar settings stored in JSONB - */ -export interface CalendarSettings { - defaultView?: 'day' | 'week' | 'month' | 'year' | 'agenda'; - weekStartsOn?: 0 | 1; - showWeekNumbers?: boolean; - defaultEventDuration?: number; - defaultReminder?: number; -} - -/** - * Calendars table - stores user calendars - */ -export const calendars = pgTable( - 'calendars', - { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id').notNull(), - name: varchar('name', { length: 255 }).notNull(), - description: text('description'), - color: varchar('color', { length: 7 }).default('#3B82F6'), - isDefault: boolean('is_default').default(false), - isVisible: boolean('is_visible').default(true), - timezone: varchar('timezone', { length: 100 }).default('Europe/Berlin'), - settings: jsonb('settings').$type(), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => ({ - userIdx: index('calendars_user_idx').on(table.userId), - uniqueDefaultPerUser: uniqueIndex('calendars_unique_default_per_user') - .on(table.userId) - .where(sql`${table.isDefault} = true`), - }) -); - -export type Calendar = typeof calendars.$inferSelect; -export type NewCalendar = typeof calendars.$inferInsert; diff --git a/apps/calendar/apps/backend/src/db/schema/device-tokens.schema.ts b/apps/calendar/apps/backend/src/db/schema/device-tokens.schema.ts deleted file mode 100644 index 31d733ff8..000000000 --- a/apps/calendar/apps/backend/src/db/schema/device-tokens.schema.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { pgTable, uuid, text, timestamp, varchar, boolean, index } from 'drizzle-orm/pg-core'; - -/** - * Device tokens table - stores Expo push tokens for mobile notifications - */ -export const deviceTokens = pgTable( - 'device_tokens', - { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id').notNull(), - pushToken: text('push_token').notNull().unique(), - platform: varchar('platform', { length: 20 }).notNull(), // 'ios' | 'android' - deviceName: varchar('device_name', { length: 255 }), - isActive: boolean('is_active').default(true), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => ({ - userIdx: index('device_tokens_user_idx').on(table.userId), - tokenIdx: index('device_tokens_token_idx').on(table.pushToken), - activeIdx: index('device_tokens_active_idx').on(table.userId, table.isActive), - }) -); - -export type DeviceToken = typeof deviceTokens.$inferSelect; -export type NewDeviceToken = typeof deviceTokens.$inferInsert; diff --git a/apps/calendar/apps/backend/src/db/schema/event-tag-groups.schema.ts b/apps/calendar/apps/backend/src/db/schema/event-tag-groups.schema.ts deleted file mode 100644 index 3eb442921..000000000 --- a/apps/calendar/apps/backend/src/db/schema/event-tag-groups.schema.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { pgTable, uuid, text, timestamp, varchar, integer, index } from 'drizzle-orm/pg-core'; - -/** - * Event tag groups table - stores user-defined tag groups (e.g., Persons, Locations) - */ -export const eventTagGroups = pgTable( - 'event_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'), - sortOrder: integer('sort_order').default(0), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => ({ - userIdx: index('event_tag_groups_user_idx').on(table.userId), - }) -); - -export type EventTagGroup = typeof eventTagGroups.$inferSelect; -export type NewEventTagGroup = typeof eventTagGroups.$inferInsert; diff --git a/apps/calendar/apps/backend/src/db/schema/event-tags.schema.ts b/apps/calendar/apps/backend/src/db/schema/event-tags.schema.ts deleted file mode 100644 index 8964ca468..000000000 --- a/apps/calendar/apps/backend/src/db/schema/event-tags.schema.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { - pgTable, - uuid, - text, - timestamp, - varchar, - primaryKey, - index, - integer, -} from 'drizzle-orm/pg-core'; -import { events } from './events.schema'; -import { eventTagGroups } from './event-tag-groups.schema'; - -/** - * Event tags table - stores user-defined tags with colors - */ -export const eventTags = pgTable( - 'event_tags', - { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id').notNull(), - name: varchar('name', { length: 100 }).notNull(), - color: varchar('color', { length: 7 }).default('#3B82F6'), - groupId: uuid('group_id').references(() => eventTagGroups.id, { onDelete: 'set null' }), - sortOrder: integer('sort_order').default(0), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => ({ - userIdx: index('event_tags_user_idx').on(table.userId), - groupIdx: index('event_tags_group_idx').on(table.groupId), - }) -); - -/** - * Event to tags junction table - many-to-many relationship - */ -export const eventToTags = pgTable( - 'event_to_tags', - { - eventId: uuid('event_id') - .notNull() - .references(() => events.id, { onDelete: 'cascade' }), - tagId: uuid('tag_id') - .notNull() - .references(() => eventTags.id, { onDelete: 'cascade' }), - }, - (table) => ({ - pk: primaryKey({ columns: [table.eventId, table.tagId] }), - eventIdx: index('event_to_tags_event_idx').on(table.eventId), - tagIdx: index('event_to_tags_tag_idx').on(table.tagId), - }) -); - -export type EventTag = typeof eventTags.$inferSelect; -export type NewEventTag = typeof eventTags.$inferInsert; -export type EventToTag = typeof eventToTags.$inferSelect; -export type NewEventToTag = typeof eventToTags.$inferInsert; diff --git a/apps/calendar/apps/backend/src/db/schema/events.schema.ts b/apps/calendar/apps/backend/src/db/schema/events.schema.ts deleted file mode 100644 index 746e18397..000000000 --- a/apps/calendar/apps/backend/src/db/schema/events.schema.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { - pgTable, - uuid, - text, - timestamp, - varchar, - boolean, - jsonb, - index, -} from 'drizzle-orm/pg-core'; -import { calendars } from './calendars.schema'; - -/** - * Event attendee information - */ -export interface EventAttendee { - email: string; - name?: string; - status?: 'accepted' | 'declined' | 'tentative' | 'pending'; -} - -/** - * Event metadata stored in JSONB - */ -export interface EventMetadata { - url?: string; - conferenceUrl?: string; - attendees?: EventAttendee[]; - organizer?: string; - priority?: 'low' | 'normal' | 'high'; - tags?: string[]; -} - -/** - * Events table - stores calendar events - */ -export const events = pgTable( - 'events', - { - id: uuid('id').primaryKey().defaultRandom(), - calendarId: uuid('calendar_id') - .notNull() - .references(() => calendars.id, { onDelete: 'cascade' }), - userId: text('user_id').notNull(), - - // Basic info - title: varchar('title', { length: 500 }).notNull(), - description: text('description'), - location: varchar('location', { length: 500 }), - - // Timing - startTime: timestamp('start_time', { withTimezone: true }).notNull(), - endTime: timestamp('end_time', { withTimezone: true }).notNull(), - isAllDay: boolean('is_all_day').default(false), - timezone: varchar('timezone', { length: 100 }).default('Europe/Berlin'), - - // Recurrence (RFC 5545 RRULE format) - recurrenceRule: varchar('recurrence_rule', { length: 500 }), - recurrenceEndDate: timestamp('recurrence_end_date', { withTimezone: true }), - recurrenceExceptions: jsonb('recurrence_exceptions').$type(), - parentEventId: uuid('parent_event_id'), - - // Appearance - color: varchar('color', { length: 7 }), - - // Status - status: varchar('status', { length: 20 }).default('confirmed'), - - // External sync - externalId: varchar('external_id', { length: 255 }), - externalCalendarId: uuid('external_calendar_id'), - lastSyncedAt: timestamp('last_synced_at', { withTimezone: true }), - - // Metadata - metadata: jsonb('metadata').$type(), - - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => ({ - calendarIdx: index('events_calendar_idx').on(table.calendarId), - userIdx: index('events_user_idx').on(table.userId), - timeRangeIdx: index('events_time_range_idx').on(table.startTime, table.endTime), - externalIdx: index('events_external_idx').on(table.externalId, table.externalCalendarId), - }) -); - -export type Event = typeof events.$inferSelect; -export type NewEvent = typeof events.$inferInsert; diff --git a/apps/calendar/apps/backend/src/db/schema/external-calendars.schema.ts b/apps/calendar/apps/backend/src/db/schema/external-calendars.schema.ts deleted file mode 100644 index a96ecaee0..000000000 --- a/apps/calendar/apps/backend/src/db/schema/external-calendars.schema.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { - pgTable, - uuid, - text, - timestamp, - varchar, - boolean, - jsonb, - integer, - index, -} from 'drizzle-orm/pg-core'; - -/** - * Provider-specific metadata - */ -export interface ExternalCalendarProviderData { - googleCalendarId?: string; - appleCalendarId?: string; - caldavCalendarId?: string; - caldavEtag?: string; - caldavCtag?: string; - icalLastModified?: string; - icalEtag?: string; -} - -/** - * External calendars table - stores CalDAV/iCal connections - */ -export const externalCalendars = pgTable( - 'external_calendars', - { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id').notNull(), - - // Calendar identification - name: varchar('name', { length: 255 }).notNull(), - provider: varchar('provider', { length: 50 }).notNull(), // google, apple, caldav, ical_url - - // Connection details - calendarUrl: text('calendar_url').notNull(), - username: varchar('username', { length: 255 }), - encryptedPassword: text('encrypted_password'), - - // OAuth tokens (for Google, etc.) - accessToken: text('access_token'), - refreshToken: text('refresh_token'), - tokenExpiresAt: timestamp('token_expires_at', { withTimezone: true }), - - // Sync settings - syncEnabled: boolean('sync_enabled').default(true), - syncDirection: varchar('sync_direction', { length: 20 }).default('both'), // import, export, both - syncInterval: integer('sync_interval').default(15), // Minutes between syncs - lastSyncAt: timestamp('last_sync_at', { withTimezone: true }), - lastSyncError: text('last_sync_error'), - - // Display settings - color: varchar('color', { length: 7 }).default('#6B7280'), - isVisible: boolean('is_visible').default(true), - - // Provider-specific metadata - providerData: jsonb('provider_data').$type(), - - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => ({ - userIdx: index('external_calendars_user_idx').on(table.userId), - providerIdx: index('external_calendars_provider_idx').on(table.provider, table.userId), - syncEnabledIdx: index('external_calendars_sync_enabled_idx').on( - table.syncEnabled, - table.lastSyncAt - ), - }) -); - -export type ExternalCalendar = typeof externalCalendars.$inferSelect; -export type NewExternalCalendar = typeof externalCalendars.$inferInsert; diff --git a/apps/calendar/apps/backend/src/db/schema/index.ts b/apps/calendar/apps/backend/src/db/schema/index.ts deleted file mode 100644 index f9e77b1fa..000000000 --- a/apps/calendar/apps/backend/src/db/schema/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Calendar Database Schemas -export * from './calendars.schema'; -export * from './events.schema'; -export * from './event-tags.schema'; -export * from './event-tag-groups.schema'; -export * from './calendar-shares.schema'; -export * from './reminders.schema'; -export * from './external-calendars.schema'; -export * from './device-tokens.schema'; diff --git a/apps/calendar/apps/backend/src/db/schema/reminders.schema.ts b/apps/calendar/apps/backend/src/db/schema/reminders.schema.ts deleted file mode 100644 index 41130967a..000000000 --- a/apps/calendar/apps/backend/src/db/schema/reminders.schema.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { - pgTable, - uuid, - text, - timestamp, - varchar, - integer, - boolean, - index, -} from 'drizzle-orm/pg-core'; -import { events } from './events.schema'; - -/** - * Reminders table - stores event reminders - */ -export const reminders = pgTable( - 'reminders', - { - id: uuid('id').primaryKey().defaultRandom(), - eventId: uuid('event_id') - .notNull() - .references(() => events.id, { onDelete: 'cascade' }), - userId: text('user_id').notNull(), - - // User email for email notifications (stored at creation time) - userEmail: varchar('user_email', { length: 255 }), - - // Timing - minutesBefore: integer('minutes_before').notNull(), - reminderTime: timestamp('reminder_time', { withTimezone: true }).notNull(), - - // Notification channels - notifyPush: boolean('notify_push').default(true), - notifyEmail: boolean('notify_email').default(false), - - // Status: pending, sent, failed, cancelled - status: varchar('status', { length: 20 }).default('pending'), - sentAt: timestamp('sent_at', { withTimezone: true }), - - // For recurring events - which instance this reminder is for - eventInstanceDate: timestamp('event_instance_date', { withTimezone: true }), - - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => ({ - eventIdx: index('reminders_event_idx').on(table.eventId), - userIdx: index('reminders_user_idx').on(table.userId), - pendingIdx: index('reminders_pending_idx').on(table.status, table.reminderTime), - }) -); - -export type Reminder = typeof reminders.$inferSelect; -export type NewReminder = typeof reminders.$inferInsert; diff --git a/apps/calendar/apps/backend/src/email/email.module.ts b/apps/calendar/apps/backend/src/email/email.module.ts deleted file mode 100644 index 43cc0d651..000000000 --- a/apps/calendar/apps/backend/src/email/email.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module, Global } from '@nestjs/common'; -import { EmailService } from './email.service'; - -@Global() -@Module({ - providers: [EmailService], - exports: [EmailService], -}) -export class EmailModule {} diff --git a/apps/calendar/apps/backend/src/email/email.service.ts b/apps/calendar/apps/backend/src/email/email.service.ts deleted file mode 100644 index e4053c22a..000000000 --- a/apps/calendar/apps/backend/src/email/email.service.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * Calendar Email Service - * - * Sends transactional emails via Brevo SMTP for: - * - Event reminders - * - Calendar share invitations - */ - -import { Injectable, Logger } from '@nestjs/common'; -import * as nodemailer from 'nodemailer'; - -interface EmailOptions { - to: string; - subject: string; - html: string; - text?: string; -} - -@Injectable() -export class EmailService { - private readonly logger = new Logger(EmailService.name); - private transporter: nodemailer.Transporter | null = null; - - private getTransporter(): nodemailer.Transporter | null { - if (this.transporter) { - return this.transporter; - } - - const host = process.env.SMTP_HOST || 'smtp-relay.brevo.com'; - const port = parseInt(process.env.SMTP_PORT || '587', 10); - const user = process.env.SMTP_USER; - const pass = process.env.SMTP_PASSWORD; - - if (!user || !pass) { - this.logger.warn('SMTP credentials not configured, emails will be logged only'); - return null; - } - - this.transporter = nodemailer.createTransport({ - host, - port, - secure: port === 465, - auth: { - user, - pass, - }, - }); - - return this.transporter; - } - - /** - * Send an email via Brevo SMTP - */ - async sendEmail(options: EmailOptions): Promise { - const { to, subject, html, text } = options; - const from = process.env.SMTP_FROM || 'ManaCore Calendar '; - - this.logger.log(`Sending email to: ${to}, subject: ${subject}`); - - const transport = this.getTransporter(); - - if (!transport) { - this.logger.log('No SMTP configured, logging email content:'); - this.logger.log(` To: ${to}`); - this.logger.log(` Subject: ${subject}`); - this.logger.debug(` HTML: ${html.substring(0, 200)}...`); - return false; - } - - try { - const result = await transport.sendMail({ - from, - to, - subject, - html, - text: text || html.replace(/<[^>]*>/g, ''), - }); - - this.logger.log(`Sent successfully, messageId: ${result.messageId}`); - return true; - } catch (error) { - this.logger.error('Failed to send email:', error); - return false; - } - } - - /** - * Send event reminder email - */ - async sendReminderEmail( - email: string, - eventTitle: string, - eventTime: Date, - minutesBefore: number - ): Promise { - const formattedTime = eventTime.toLocaleString('de-DE', { - dateStyle: 'full', - timeStyle: 'short', - timeZone: 'Europe/Berlin', - }); - - const timeLabel = this.formatMinutesBefore(minutesBefore); - - return this.sendEmail({ - to: email, - subject: `Erinnerung: ${eventTitle} - ${timeLabel}`, - html: ` - - - - - - - -
-

ManaCore Kalender

-
- -
-

${eventTitle}

-

${formattedTime}

-
- -

Dein Termin beginnt ${timeLabel}.

- -
- -

- Diese Erinnerung wurde automatisch von ManaCore Kalender gesendet. -

- - -`, - }); - } - - /** - * Send calendar share invitation email - */ - async sendCalendarInvitationEmail( - email: string, - calendarName: string, - inviterName: string, - permission: string, - acceptUrl: string - ): Promise { - const permissionLabel = this.formatPermission(permission); - - return this.sendEmail({ - to: email, - subject: `Kalender-Einladung: ${calendarName}`, - html: ` - - - - - - - -
-

ManaCore Kalender

-
- -

Hallo,

- -

${inviterName} hat den Kalender ${calendarName} mit dir geteilt.

- -
-

Berechtigung: ${permissionLabel}

-
- - - -

Diese Einladung ist 7 Tage gültig.

- -
- -

- Diese E-Mail wurde automatisch von ManaCore Kalender gesendet.
- Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
- ${acceptUrl} -

- - -`, - }); - } - - private formatMinutesBefore(minutes: number): string { - if (minutes === 0) { - return 'jetzt'; - } - if (minutes < 60) { - return `in ${minutes} Minuten`; - } - if (minutes < 1440) { - const hours = Math.round(minutes / 60); - return `in ${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}`; - } - const days = Math.round(minutes / 1440); - return `in ${days} ${days === 1 ? 'Tag' : 'Tagen'}`; - } - - private formatPermission(permission: string): string { - switch (permission) { - case 'read': - return 'Nur Lesen'; - case 'write': - return 'Bearbeiten'; - case 'admin': - return 'Vollzugriff'; - default: - return permission; - } - } -} diff --git a/apps/calendar/apps/backend/src/event-tag-group/dto/create-event-tag-group.dto.ts b/apps/calendar/apps/backend/src/event-tag-group/dto/create-event-tag-group.dto.ts deleted file mode 100644 index 36c902392..000000000 --- a/apps/calendar/apps/backend/src/event-tag-group/dto/create-event-tag-group.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { IsString, IsOptional, MaxLength } from 'class-validator'; - -export class CreateEventTagGroupDto { - @IsString() - @MaxLength(100) - name!: string; - - @IsString() - @IsOptional() - @MaxLength(7) - color?: string; -} diff --git a/apps/calendar/apps/backend/src/event-tag-group/dto/index.ts b/apps/calendar/apps/backend/src/event-tag-group/dto/index.ts deleted file mode 100644 index 8d61d64fd..000000000 --- a/apps/calendar/apps/backend/src/event-tag-group/dto/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './create-event-tag-group.dto'; -export * from './update-event-tag-group.dto'; -export * from './reorder-event-tag-groups.dto'; diff --git a/apps/calendar/apps/backend/src/event-tag-group/dto/reorder-event-tag-groups.dto.ts b/apps/calendar/apps/backend/src/event-tag-group/dto/reorder-event-tag-groups.dto.ts deleted file mode 100644 index 5877d7515..000000000 --- a/apps/calendar/apps/backend/src/event-tag-group/dto/reorder-event-tag-groups.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IsArray, IsUUID } from 'class-validator'; - -export class ReorderEventTagGroupsDto { - @IsArray() - @IsUUID('4', { each: true }) - groupIds!: string[]; -} diff --git a/apps/calendar/apps/backend/src/event-tag-group/dto/update-event-tag-group.dto.ts b/apps/calendar/apps/backend/src/event-tag-group/dto/update-event-tag-group.dto.ts deleted file mode 100644 index ba5697c38..000000000 --- a/apps/calendar/apps/backend/src/event-tag-group/dto/update-event-tag-group.dto.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { IsString, IsOptional, MaxLength } from 'class-validator'; - -export class UpdateEventTagGroupDto { - @IsString() - @IsOptional() - @MaxLength(100) - name?: string; - - @IsString() - @IsOptional() - @MaxLength(7) - color?: string; -} diff --git a/apps/calendar/apps/backend/src/event-tag-group/event-tag-group.controller.ts b/apps/calendar/apps/backend/src/event-tag-group/event-tag-group.controller.ts deleted file mode 100644 index 6ee3b2b98..000000000 --- a/apps/calendar/apps/backend/src/event-tag-group/event-tag-group.controller.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { - Controller, - Get, - Post, - Put, - Delete, - Body, - Param, - UseGuards, - ParseUUIDPipe, - NotFoundException, -} from '@nestjs/common'; -import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; -import { EventTagGroupService } from './event-tag-group.service'; -import { CreateEventTagGroupDto, UpdateEventTagGroupDto, ReorderEventTagGroupsDto } from './dto'; - -@Controller('event-tag-groups') -@UseGuards(JwtAuthGuard) -export class EventTagGroupController { - constructor(private readonly eventTagGroupService: EventTagGroupService) {} - - @Get() - async findAll(@CurrentUser() user: CurrentUserData) { - const groups = await this.eventTagGroupService.findByUserId(user.userId); - const tagCounts = await this.eventTagGroupService.getTagCountsForUser(user.userId); - - // Add tag count to each group - const groupsWithCounts = groups.map((group) => ({ - ...group, - tagCount: tagCounts.get(group.id) ?? 0, - })); - - return { - groups: groupsWithCounts, - ungroupedTagCount: tagCounts.get(null) ?? 0, - }; - } - - @Put('reorder') - async reorder(@CurrentUser() user: CurrentUserData, @Body() dto: ReorderEventTagGroupsDto) { - const groups = await this.eventTagGroupService.reorder(user.userId, dto.groupIds); - const tagCounts = await this.eventTagGroupService.getTagCountsForUser(user.userId); - - const groupsWithCounts = groups.map((group) => ({ - ...group, - tagCount: tagCounts.get(group.id) ?? 0, - })); - - return { - groups: groupsWithCounts, - ungroupedTagCount: tagCounts.get(null) ?? 0, - }; - } - - @Get(':id') - async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { - const group = await this.eventTagGroupService.findById(id, user.userId); - if (!group) { - throw new NotFoundException('Tag group not found'); - } - - const tagCount = await this.eventTagGroupService.getTagCountByGroup(id); - return { group: { ...group, tagCount } }; - } - - @Post() - async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateEventTagGroupDto) { - const group = await this.eventTagGroupService.create({ - ...dto, - userId: user.userId, - }); - return { group: { ...group, tagCount: 0 } }; - } - - @Put(':id') - async update( - @CurrentUser() user: CurrentUserData, - @Param('id', ParseUUIDPipe) id: string, - @Body() dto: UpdateEventTagGroupDto - ) { - const group = await this.eventTagGroupService.update(id, user.userId, dto); - const tagCount = await this.eventTagGroupService.getTagCountByGroup(id); - return { group: { ...group, tagCount } }; - } - - @Delete(':id') - async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { - await this.eventTagGroupService.delete(id, user.userId); - return { success: true }; - } -} diff --git a/apps/calendar/apps/backend/src/event-tag-group/event-tag-group.module.ts b/apps/calendar/apps/backend/src/event-tag-group/event-tag-group.module.ts deleted file mode 100644 index 1bcb448c1..000000000 --- a/apps/calendar/apps/backend/src/event-tag-group/event-tag-group.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { EventTagGroupController } from './event-tag-group.controller'; -import { EventTagGroupService } from './event-tag-group.service'; - -@Module({ - controllers: [EventTagGroupController], - providers: [EventTagGroupService], - exports: [EventTagGroupService], -}) -export class EventTagGroupModule {} diff --git a/apps/calendar/apps/backend/src/event-tag-group/event-tag-group.service.spec.ts b/apps/calendar/apps/backend/src/event-tag-group/event-tag-group.service.spec.ts deleted file mode 100644 index b49397087..000000000 --- a/apps/calendar/apps/backend/src/event-tag-group/event-tag-group.service.spec.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { NotFoundException } from '@nestjs/common'; -import { EventTagGroupService } from './event-tag-group.service'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { TEST_USER_ID } from '../__tests__/utils/mock-factories'; -import { v4 as uuidv4 } from 'uuid'; - -function createMockEventTagGroup(overrides: Record = {}) { - return { - id: uuidv4(), - userId: TEST_USER_ID, - name: 'Test Group', - color: '#3B82F6', - sortOrder: 0, - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }; -} - -describe('EventTagGroupService', () => { - let service: EventTagGroupService; - let mockDb: any; - - beforeEach(async () => { - mockDb = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - returning: jest.fn().mockResolvedValue([]), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - delete: jest.fn().mockReturnThis(), - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - EventTagGroupService, - { - provide: DATABASE_CONNECTION, - useValue: mockDb, - }, - ], - }).compile(); - - service = module.get(EventTagGroupService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('findByUserId', () => { - it('should return all groups for a user ordered by sortOrder', async () => { - const groups = [ - createMockEventTagGroup({ name: 'Group 1', sortOrder: 0 }), - createMockEventTagGroup({ name: 'Group 2', sortOrder: 1 }), - ]; - mockDb.orderBy.mockResolvedValueOnce(groups); - - const result = await service.findByUserId(TEST_USER_ID); - - expect(result).toEqual(groups); - expect(mockDb.select).toHaveBeenCalled(); - expect(mockDb.from).toHaveBeenCalled(); - expect(mockDb.where).toHaveBeenCalled(); - expect(mockDb.orderBy).toHaveBeenCalled(); - }); - - it('should create default groups when user has no groups', async () => { - const defaultGroups = [ - createMockEventTagGroup({ name: 'Personen', color: '#ec4899', sortOrder: 0 }), - createMockEventTagGroup({ name: 'Orte', color: '#14b8a6', sortOrder: 1 }), - createMockEventTagGroup({ name: 'Allgemein', color: '#3b82f6', sortOrder: 2 }), - ]; - // First call returns empty (no groups yet) - mockDb.orderBy.mockResolvedValueOnce([]); - // createDefaultGroups calls insert().values().returning() - mockDb.returning.mockResolvedValueOnce(defaultGroups); - - const result = await service.findByUserId(TEST_USER_ID); - - expect(result).toEqual(defaultGroups); - expect(result).toHaveLength(3); - expect(mockDb.insert).toHaveBeenCalled(); - expect(mockDb.values).toHaveBeenCalled(); - }); - }); - - describe('findById', () => { - it('should return group when found', async () => { - const group = createMockEventTagGroup(); - mockDb.where.mockResolvedValueOnce([group]); - - const result = await service.findById(group.id, TEST_USER_ID); - - expect(result).toEqual(group); - }); - - it('should return null when group not found', async () => { - mockDb.where.mockResolvedValueOnce([]); - - const result = await service.findById('non-existent-id', TEST_USER_ID); - - expect(result).toBeNull(); - }); - }); - - describe('create', () => { - it('should create a new group with correct sortOrder', async () => { - const existingGroups = [ - createMockEventTagGroup({ sortOrder: 0 }), - createMockEventTagGroup({ sortOrder: 1 }), - ]; - const newGroup = createMockEventTagGroup({ name: 'New Group', sortOrder: 2 }); - - // First call: get existing groups to determine sortOrder - mockDb.where.mockResolvedValueOnce(existingGroups); - // Second call: insert returning - mockDb.returning.mockResolvedValueOnce([newGroup]); - - const result = await service.create({ - userId: TEST_USER_ID, - name: 'New Group', - color: '#FF0000', - }); - - expect(result).toEqual(newGroup); - expect(result.sortOrder).toBe(2); - expect(mockDb.insert).toHaveBeenCalled(); - }); - - it('should start with sortOrder 0 when no groups exist', async () => { - const newGroup = createMockEventTagGroup({ name: 'First Group', sortOrder: 0 }); - - // No existing groups - mockDb.where.mockResolvedValueOnce([]); - mockDb.returning.mockResolvedValueOnce([newGroup]); - - const result = await service.create({ - userId: TEST_USER_ID, - name: 'First Group', - color: '#FF0000', - }); - - expect(result).toEqual(newGroup); - expect(mockDb.insert).toHaveBeenCalled(); - }); - }); - - describe('update', () => { - it('should update a group', async () => { - const updatedGroup = createMockEventTagGroup({ name: 'Updated Group' }); - mockDb.returning.mockResolvedValueOnce([updatedGroup]); - - const result = await service.update(updatedGroup.id, TEST_USER_ID, { - name: 'Updated Group', - }); - - expect(result.name).toBe('Updated Group'); - expect(mockDb.update).toHaveBeenCalled(); - expect(mockDb.set).toHaveBeenCalled(); - }); - - it('should throw NotFoundException when group not found', async () => { - mockDb.returning.mockResolvedValueOnce([]); - - await expect( - service.update('non-existent-id', TEST_USER_ID, { name: 'New Name' }) - ).rejects.toThrow(NotFoundException); - }); - }); - - describe('delete', () => { - it('should unassign tags and delete the group', async () => { - const groupId = uuidv4(); - - await service.delete(groupId, TEST_USER_ID); - - // Should update tags to unassign from group first - expect(mockDb.update).toHaveBeenCalled(); - expect(mockDb.set).toHaveBeenCalledWith({ groupId: null }); - // Should then delete the group - expect(mockDb.delete).toHaveBeenCalled(); - }); - }); - - describe('getTagCountByGroup', () => { - it('should return count of tags in a group', async () => { - const groupId = uuidv4(); - const tags = [ - { id: uuidv4(), groupId }, - { id: uuidv4(), groupId }, - { id: uuidv4(), groupId }, - ]; - mockDb.where.mockResolvedValueOnce(tags); - - const result = await service.getTagCountByGroup(groupId); - - expect(result).toBe(3); - }); - - it('should return 0 when group has no tags', async () => { - mockDb.where.mockResolvedValueOnce([]); - - const result = await service.getTagCountByGroup(uuidv4()); - - expect(result).toBe(0); - }); - }); - - describe('getTagCountsForUser', () => { - it('should return tag counts grouped by groupId', async () => { - const groupId1 = uuidv4(); - const groupId2 = uuidv4(); - const tags = [ - { id: uuidv4(), groupId: groupId1 }, - { id: uuidv4(), groupId: groupId1 }, - { id: uuidv4(), groupId: groupId2 }, - { id: uuidv4(), groupId: null }, - ]; - mockDb.where.mockResolvedValueOnce(tags); - - const result = await service.getTagCountsForUser(TEST_USER_ID); - - expect(result.get(groupId1)).toBe(2); - expect(result.get(groupId2)).toBe(1); - expect(result.get(null)).toBe(1); - }); - - it('should return empty map when user has no tags', async () => { - mockDb.where.mockResolvedValueOnce([]); - - const result = await service.getTagCountsForUser(TEST_USER_ID); - - expect(result.size).toBe(0); - }); - }); - - describe('reorder', () => { - it('should update sortOrder for each group and return updated list', async () => { - const groupId1 = uuidv4(); - const groupId2 = uuidv4(); - const groupId3 = uuidv4(); - - const reorderedGroups = [ - createMockEventTagGroup({ id: groupId2, sortOrder: 0 }), - createMockEventTagGroup({ id: groupId3, sortOrder: 1 }), - createMockEventTagGroup({ id: groupId1, sortOrder: 2 }), - ]; - - // The reorder method calls findByUserId at the end, which calls orderBy - mockDb.orderBy.mockResolvedValueOnce(reorderedGroups); - - const result = await service.reorder(TEST_USER_ID, [groupId2, groupId3, groupId1]); - - expect(result).toEqual(reorderedGroups); - // Should have called update for each group - expect(mockDb.update).toHaveBeenCalledTimes(3); - expect(mockDb.set).toHaveBeenCalledTimes(3); - }); - - it('should handle empty groupIds array', async () => { - const groups = [createMockEventTagGroup()]; - // findByUserId is called at the end - mockDb.orderBy.mockResolvedValueOnce(groups); - - const result = await service.reorder(TEST_USER_ID, []); - - expect(result).toEqual(groups); - // No update calls for empty array - expect(mockDb.update).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/apps/calendar/apps/backend/src/event-tag-group/event-tag-group.service.ts b/apps/calendar/apps/backend/src/event-tag-group/event-tag-group.service.ts deleted file mode 100644 index 12e68be07..000000000 --- a/apps/calendar/apps/backend/src/event-tag-group/event-tag-group.service.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { Injectable, Inject, NotFoundException } from '@nestjs/common'; -import { eq, and, asc } from 'drizzle-orm'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { Database } from '../db/connection'; -import { eventTagGroups, eventTags } from '../db/schema'; -import type { EventTagGroup, NewEventTagGroup } from '../db/schema'; - -const DEFAULT_TAG_GROUPS = [ - { name: 'Personen', color: '#ec4899' }, // pink - { name: 'Orte', color: '#14b8a6' }, // teal - { name: 'Allgemein', color: '#3b82f6' }, // blue -] as const; - -@Injectable() -export class EventTagGroupService { - constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} - - async findByUserId(userId: string): Promise { - const groups = await this.db - .select() - .from(eventTagGroups) - .where(eq(eventTagGroups.userId, userId)) - .orderBy(asc(eventTagGroups.sortOrder), asc(eventTagGroups.name)); - - // Create default groups on first access (when user has no groups yet) - if (groups.length === 0) { - return this.createDefaultGroups(userId); - } - - return groups; - } - - async createDefaultGroups(userId: string): Promise { - const groupsToCreate = DEFAULT_TAG_GROUPS.map((group, index) => ({ - userId, - name: group.name, - color: group.color, - sortOrder: index, - })); - - return this.db.insert(eventTagGroups).values(groupsToCreate).returning(); - } - - async findById(id: string, userId: string): Promise { - const [group] = await this.db - .select() - .from(eventTagGroups) - .where(and(eq(eventTagGroups.id, id), eq(eventTagGroups.userId, userId))); - return group || null; - } - - async create(data: NewEventTagGroup): Promise { - // Get highest sortOrder for user - const existing = await this.db - .select() - .from(eventTagGroups) - .where(eq(eventTagGroups.userId, data.userId)); - - const maxSortOrder = existing.reduce((max, g) => Math.max(max, g.sortOrder ?? 0), -1); - - const [group] = await this.db - .insert(eventTagGroups) - .values({ ...data, sortOrder: maxSortOrder + 1 }) - .returning(); - return group; - } - - async update( - id: string, - userId: string, - data: Partial> - ): Promise { - const [group] = await this.db - .update(eventTagGroups) - .set({ ...data, updatedAt: new Date() }) - .where(and(eq(eventTagGroups.id, id), eq(eventTagGroups.userId, userId))) - .returning(); - - if (!group) { - throw new NotFoundException('Tag group not found'); - } - - return group; - } - - async delete(id: string, userId: string): Promise { - // First, unassign all tags from this group (set groupId to null) - await this.db.update(eventTags).set({ groupId: null }).where(eq(eventTags.groupId, id)); - - // Then delete the group - await this.db - .delete(eventTagGroups) - .where(and(eq(eventTagGroups.id, id), eq(eventTagGroups.userId, userId))); - } - - async getTagCountByGroup(groupId: string): Promise { - const tags = await this.db.select().from(eventTags).where(eq(eventTags.groupId, groupId)); - return tags.length; - } - - async getTagCountsForUser(userId: string): Promise> { - const tags = await this.db.select().from(eventTags).where(eq(eventTags.userId, userId)); - - const counts = new Map(); - for (const tag of tags) { - const groupId = tag.groupId; - counts.set(groupId, (counts.get(groupId) ?? 0) + 1); - } - return counts; - } - - async reorder(userId: string, groupIds: string[]): Promise { - // Update sortOrder for each group based on array position - await Promise.all( - groupIds.map((id, index) => - this.db - .update(eventTagGroups) - .set({ sortOrder: index, updatedAt: new Date() }) - .where(and(eq(eventTagGroups.id, id), eq(eventTagGroups.userId, userId))) - ) - ); - - return this.findByUserId(userId); - } -} diff --git a/apps/calendar/apps/backend/src/event-tag-group/index.ts b/apps/calendar/apps/backend/src/event-tag-group/index.ts deleted file mode 100644 index a610b0f60..000000000 --- a/apps/calendar/apps/backend/src/event-tag-group/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './event-tag-group.module'; -export * from './event-tag-group.service'; -export * from './event-tag-group.controller'; -export * from './dto'; diff --git a/apps/calendar/apps/backend/src/event-tag/dto/create-event-tag.dto.ts b/apps/calendar/apps/backend/src/event-tag/dto/create-event-tag.dto.ts deleted file mode 100644 index aa9a398be..000000000 --- a/apps/calendar/apps/backend/src/event-tag/dto/create-event-tag.dto.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { IsString, IsOptional, MaxLength, IsUUID } from 'class-validator'; - -export class CreateEventTagDto { - @IsString() - @MaxLength(100) - name!: string; - - @IsString() - @IsOptional() - @MaxLength(7) - color?: string; - - @IsUUID() - @IsOptional() - groupId?: string; -} diff --git a/apps/calendar/apps/backend/src/event-tag/dto/update-event-tag.dto.ts b/apps/calendar/apps/backend/src/event-tag/dto/update-event-tag.dto.ts deleted file mode 100644 index f3355afd3..000000000 --- a/apps/calendar/apps/backend/src/event-tag/dto/update-event-tag.dto.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { IsString, IsOptional, MaxLength, IsUUID } from 'class-validator'; - -export class UpdateEventTagDto { - @IsString() - @IsOptional() - @MaxLength(100) - name?: string; - - @IsString() - @IsOptional() - @MaxLength(7) - color?: string; - - @IsUUID() - @IsOptional() - groupId?: string | null; -} diff --git a/apps/calendar/apps/backend/src/event-tag/event-tag.controller.spec.ts b/apps/calendar/apps/backend/src/event-tag/event-tag.controller.spec.ts deleted file mode 100644 index 138253332..000000000 --- a/apps/calendar/apps/backend/src/event-tag/event-tag.controller.spec.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { NotFoundException } from '@nestjs/common'; -import { EventTagController } from './event-tag.controller'; -import { TEST_USER_ID } from '../__tests__/utils/mock-factories'; -import { v4 as uuidv4 } from 'uuid'; - -const mockUser = { userId: TEST_USER_ID, email: 'test@example.com' }; - -function createMockTag(overrides: Record = {}) { - return { id: uuidv4(), userId: TEST_USER_ID, name: 'Test', color: '#3B82F6', ...overrides }; -} - -describe('EventTagController', () => { - let controller: EventTagController; - let service: any; - - beforeEach(() => { - service = { - findByUserId: jest.fn(), - findById: jest.fn(), - create: jest.fn(), - update: jest.fn(), - delete: jest.fn(), - }; - controller = new EventTagController(service); - }); - - afterEach(() => jest.clearAllMocks()); - - describe('findAll', () => { - it('should return all tags', async () => { - const tags = [createMockTag(), createMockTag()]; - service.findByUserId.mockResolvedValue(tags); - const result = await controller.findAll(mockUser as any); - expect(result).toEqual({ tags }); - }); - }); - - describe('findOne', () => { - it('should return tag by id', async () => { - const tag = createMockTag(); - service.findById.mockResolvedValue(tag); - const result = await controller.findOne(mockUser as any, tag.id); - expect(result).toEqual({ tag }); - }); - - it('should throw NotFoundException when not found', async () => { - service.findById.mockResolvedValue(null); - await expect(controller.findOne(mockUser as any, 'bad-id')).rejects.toThrow( - NotFoundException - ); - }); - }); - - describe('create', () => { - it('should create tag with userId', async () => { - const tag = createMockTag({ name: 'Work' }); - service.create.mockResolvedValue(tag); - const result = await controller.create( - mockUser as any, - { name: 'Work', color: '#3B82F6' } as any - ); - expect(result).toEqual({ tag }); - expect(service.create).toHaveBeenCalledWith({ - name: 'Work', - color: '#3B82F6', - userId: TEST_USER_ID, - }); - }); - }); - - describe('update', () => { - it('should update tag', async () => { - const tag = createMockTag({ name: 'Updated' }); - service.update.mockResolvedValue(tag); - const result = await controller.update(mockUser as any, tag.id, { name: 'Updated' } as any); - expect(result).toEqual({ tag }); - }); - }); - - describe('delete', () => { - it('should delete tag', async () => { - service.delete.mockResolvedValue(undefined); - const result = await controller.delete(mockUser as any, 'tag-id'); - expect(result).toEqual({ success: true }); - }); - }); -}); diff --git a/apps/calendar/apps/backend/src/event-tag/event-tag.controller.ts b/apps/calendar/apps/backend/src/event-tag/event-tag.controller.ts deleted file mode 100644 index 30cab5b42..000000000 --- a/apps/calendar/apps/backend/src/event-tag/event-tag.controller.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { - Controller, - Get, - Post, - Put, - Delete, - Body, - Param, - UseGuards, - ParseUUIDPipe, - NotFoundException, -} from '@nestjs/common'; -import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; -import { EventTagService } from './event-tag.service'; -import { CreateEventTagDto } from './dto/create-event-tag.dto'; -import { UpdateEventTagDto } from './dto/update-event-tag.dto'; - -@Controller('event-tags') -@UseGuards(JwtAuthGuard) -export class EventTagController { - constructor(private readonly eventTagService: EventTagService) {} - - @Get() - async findAll(@CurrentUser() user: CurrentUserData) { - const tags = await this.eventTagService.findByUserId(user.userId); - return { tags }; - } - - @Get(':id') - async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { - const tag = await this.eventTagService.findById(id, user.userId); - if (!tag) { - throw new NotFoundException('Tag not found'); - } - return { tag }; - } - - @Post() - async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateEventTagDto) { - const tag = await this.eventTagService.create({ - ...dto, - userId: user.userId, - }); - return { tag }; - } - - @Put(':id') - async update( - @CurrentUser() user: CurrentUserData, - @Param('id', ParseUUIDPipe) id: string, - @Body() dto: UpdateEventTagDto - ) { - const tag = await this.eventTagService.update(id, user.userId, dto); - return { tag }; - } - - @Delete(':id') - async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { - await this.eventTagService.delete(id, user.userId); - return { success: true }; - } -} diff --git a/apps/calendar/apps/backend/src/event-tag/event-tag.module.ts b/apps/calendar/apps/backend/src/event-tag/event-tag.module.ts deleted file mode 100644 index 42ee2efbc..000000000 --- a/apps/calendar/apps/backend/src/event-tag/event-tag.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { EventTagController } from './event-tag.controller'; -import { EventTagService } from './event-tag.service'; - -@Module({ - controllers: [EventTagController], - providers: [EventTagService], - exports: [EventTagService], -}) -export class EventTagModule {} diff --git a/apps/calendar/apps/backend/src/event-tag/event-tag.service.spec.ts b/apps/calendar/apps/backend/src/event-tag/event-tag.service.spec.ts deleted file mode 100644 index 29a5d80ea..000000000 --- a/apps/calendar/apps/backend/src/event-tag/event-tag.service.spec.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { NotFoundException } from '@nestjs/common'; -import { EventTagService } from './event-tag.service'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { TEST_USER_ID } from '../__tests__/utils/mock-factories'; -import { v4 as uuidv4 } from 'uuid'; - -function createMockEventTag(overrides: Record = {}) { - return { - id: uuidv4(), - userId: TEST_USER_ID, - name: 'Test Tag', - color: '#3B82F6', - groupId: null, - sortOrder: 0, - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }; -} - -describe('EventTagService', () => { - let service: EventTagService; - let mockDb: any; - - beforeEach(async () => { - mockDb = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - innerJoin: jest.fn().mockReturnThis(), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - returning: jest.fn().mockResolvedValue([]), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - delete: jest.fn().mockReturnThis(), - onConflictDoNothing: jest.fn().mockResolvedValue(undefined), - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - EventTagService, - { - provide: DATABASE_CONNECTION, - useValue: mockDb, - }, - ], - }).compile(); - - service = module.get(EventTagService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('findByUserId', () => { - it('should return all tags for a user', async () => { - const tags = [createMockEventTag({ name: 'Work' }), createMockEventTag({ name: 'Personal' })]; - mockDb.where.mockResolvedValueOnce(tags); - - const result = await service.findByUserId(TEST_USER_ID); - - expect(result).toEqual(tags); - expect(mockDb.select).toHaveBeenCalled(); - expect(mockDb.from).toHaveBeenCalled(); - expect(mockDb.where).toHaveBeenCalled(); - }); - - it('should create default tags when user has no tags', async () => { - const defaultTags = [ - createMockEventTag({ name: 'Arbeit', color: '#3b82f6' }), - createMockEventTag({ name: 'Persönlich', color: '#22c55e' }), - createMockEventTag({ name: 'Familie', color: '#ec4899' }), - createMockEventTag({ name: 'Wichtig', color: '#ef4444' }), - ]; - // First call returns empty (no tags yet) - mockDb.where.mockResolvedValueOnce([]); - // createDefaultTags calls insert().values().returning() - mockDb.returning.mockResolvedValueOnce(defaultTags); - - const result = await service.findByUserId(TEST_USER_ID); - - expect(result).toEqual(defaultTags); - expect(mockDb.insert).toHaveBeenCalled(); - expect(mockDb.values).toHaveBeenCalled(); - expect(mockDb.returning).toHaveBeenCalled(); - }); - }); - - describe('findById', () => { - it('should return tag when found', async () => { - const tag = createMockEventTag(); - mockDb.where.mockResolvedValueOnce([tag]); - - const result = await service.findById(tag.id, TEST_USER_ID); - - expect(result).toEqual(tag); - }); - - it('should return null when tag not found', async () => { - mockDb.where.mockResolvedValueOnce([]); - - const result = await service.findById('non-existent-id', TEST_USER_ID); - - expect(result).toBeNull(); - }); - }); - - describe('create', () => { - it('should create a new tag', async () => { - const newTag = createMockEventTag({ name: 'New Tag', color: '#FF0000' }); - mockDb.returning.mockResolvedValueOnce([newTag]); - - const result = await service.create({ - userId: TEST_USER_ID, - name: 'New Tag', - color: '#FF0000', - }); - - expect(result).toEqual(newTag); - expect(mockDb.insert).toHaveBeenCalled(); - expect(mockDb.values).toHaveBeenCalled(); - }); - }); - - describe('update', () => { - it('should update a tag', async () => { - const updatedTag = createMockEventTag({ name: 'Updated Tag' }); - mockDb.returning.mockResolvedValueOnce([updatedTag]); - - const result = await service.update(updatedTag.id, TEST_USER_ID, { - name: 'Updated Tag', - }); - - expect(result.name).toBe('Updated Tag'); - expect(mockDb.update).toHaveBeenCalled(); - expect(mockDb.set).toHaveBeenCalled(); - }); - - it('should throw NotFoundException when tag not found', async () => { - mockDb.returning.mockResolvedValueOnce([]); - - await expect( - service.update('non-existent-id', TEST_USER_ID, { name: 'New Name' }) - ).rejects.toThrow(NotFoundException); - }); - }); - - describe('delete', () => { - it('should delete a tag', async () => { - const tag = createMockEventTag(); - - await service.delete(tag.id, TEST_USER_ID); - - expect(mockDb.delete).toHaveBeenCalled(); - expect(mockDb.where).toHaveBeenCalled(); - }); - }); - - describe('getTagsForEvent', () => { - it('should return tags for an event', async () => { - const tag1 = createMockEventTag({ name: 'Tag 1' }); - const tag2 = createMockEventTag({ name: 'Tag 2' }); - mockDb.where.mockResolvedValueOnce([{ tag: tag1 }, { tag: tag2 }]); - - const result = await service.getTagsForEvent('event-id'); - - expect(result).toEqual([tag1, tag2]); - }); - - it('should return empty array when event has no tags', async () => { - mockDb.where.mockResolvedValueOnce([]); - - const result = await service.getTagsForEvent('event-id'); - - expect(result).toEqual([]); - }); - }); - - describe('getTagsForEvents', () => { - it('should return empty map for empty eventIds', async () => { - const result = await service.getTagsForEvents([]); - - expect(result).toEqual(new Map()); - expect(mockDb.select).not.toHaveBeenCalled(); - }); - - it('should return tags grouped by event id', async () => { - const tag1 = createMockEventTag({ name: 'Tag 1' }); - const tag2 = createMockEventTag({ name: 'Tag 2' }); - mockDb.where.mockResolvedValueOnce([ - { eventId: 'event-1', tag: tag1 }, - { eventId: 'event-1', tag: tag2 }, - { eventId: 'event-2', tag: tag1 }, - ]); - - const result = await service.getTagsForEvents(['event-1', 'event-2']); - - expect(result.get('event-1')).toEqual([tag1, tag2]); - expect(result.get('event-2')).toEqual([tag1]); - }); - }); - - describe('getTagIdsForEvent', () => { - it('should return tag ids for an event', async () => { - const tagId1 = uuidv4(); - const tagId2 = uuidv4(); - mockDb.where.mockResolvedValueOnce([{ tagId: tagId1 }, { tagId: tagId2 }]); - - const result = await service.getTagIdsForEvent('event-id'); - - expect(result).toEqual([tagId1, tagId2]); - }); - }); - - describe('setEventTags', () => { - it('should remove existing tags and add new ones', async () => { - const tagId1 = uuidv4(); - const tagId2 = uuidv4(); - - await service.setEventTags('event-id', [tagId1, tagId2]); - - // Should delete existing tags first - expect(mockDb.delete).toHaveBeenCalled(); - // Should insert new tags - expect(mockDb.insert).toHaveBeenCalled(); - expect(mockDb.values).toHaveBeenCalled(); - }); - - it('should only remove tags when tagIds is empty', async () => { - await service.setEventTags('event-id', []); - - expect(mockDb.delete).toHaveBeenCalled(); - // insert should not be called for empty tagIds - expect(mockDb.insert).not.toHaveBeenCalled(); - }); - }); - - describe('addTagToEvent', () => { - it('should add a tag to an event', async () => { - await service.addTagToEvent('event-id', 'tag-id'); - - expect(mockDb.insert).toHaveBeenCalled(); - expect(mockDb.values).toHaveBeenCalledWith({ eventId: 'event-id', tagId: 'tag-id' }); - }); - }); - - describe('removeTagFromEvent', () => { - it('should remove a tag from an event', async () => { - await service.removeTagFromEvent('event-id', 'tag-id'); - - expect(mockDb.delete).toHaveBeenCalled(); - expect(mockDb.where).toHaveBeenCalled(); - }); - }); - - describe('getTagsByIds', () => { - it('should return empty array for empty ids', async () => { - const result = await service.getTagsByIds([], TEST_USER_ID); - - expect(result).toEqual([]); - expect(mockDb.select).not.toHaveBeenCalled(); - }); - - it('should return tags matching ids', async () => { - const tag1 = createMockEventTag({ name: 'Tag 1' }); - const tag2 = createMockEventTag({ name: 'Tag 2' }); - mockDb.where.mockResolvedValueOnce([tag1, tag2]); - - const result = await service.getTagsByIds([tag1.id, tag2.id], TEST_USER_ID); - - expect(result).toEqual([tag1, tag2]); - }); - }); - - describe('findByGroupId', () => { - it('should return tags for a specific group', async () => { - const groupId = uuidv4(); - const tags = [ - createMockEventTag({ name: 'Tag 1', groupId }), - createMockEventTag({ name: 'Tag 2', groupId }), - ]; - mockDb.orderBy.mockResolvedValueOnce(tags); - - const result = await service.findByGroupId(groupId, TEST_USER_ID); - - expect(result).toEqual(tags); - }); - - it('should return ungrouped tags when groupId is null', async () => { - const tags = [createMockEventTag({ name: 'Ungrouped', groupId: null })]; - mockDb.orderBy.mockResolvedValueOnce(tags); - - const result = await service.findByGroupId(null, TEST_USER_ID); - - expect(result).toEqual(tags); - }); - }); - - describe('updateTagGroup', () => { - it('should update the group of a tag', async () => { - const groupId = uuidv4(); - const updatedTag = createMockEventTag({ groupId }); - mockDb.returning.mockResolvedValueOnce([updatedTag]); - - const result = await service.updateTagGroup(updatedTag.id, TEST_USER_ID, groupId); - - expect(result.groupId).toBe(groupId); - expect(mockDb.update).toHaveBeenCalled(); - expect(mockDb.set).toHaveBeenCalled(); - }); - - it('should throw NotFoundException when tag not found', async () => { - mockDb.returning.mockResolvedValueOnce([]); - - await expect(service.updateTagGroup('non-existent-id', TEST_USER_ID, null)).rejects.toThrow( - NotFoundException - ); - }); - }); -}); diff --git a/apps/calendar/apps/backend/src/event-tag/event-tag.service.ts b/apps/calendar/apps/backend/src/event-tag/event-tag.service.ts deleted file mode 100644 index 80618b1c3..000000000 --- a/apps/calendar/apps/backend/src/event-tag/event-tag.service.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { Injectable, Inject, NotFoundException } from '@nestjs/common'; -import { eq, and, inArray, isNull, asc } from 'drizzle-orm'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { Database } from '../db/connection'; -import { eventTags, eventToTags } from '../db/schema'; -import type { EventTag, NewEventTag } from '../db/schema'; - -const DEFAULT_TAGS = [ - { name: 'Arbeit', color: '#3b82f6' }, // blue - { name: 'Persönlich', color: '#22c55e' }, // green - { name: 'Familie', color: '#ec4899' }, // pink - { name: 'Wichtig', color: '#ef4444' }, // red -] as const; - -@Injectable() -export class EventTagService { - constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} - - async findByUserId(userId: string): Promise { - const tags = await this.db.select().from(eventTags).where(eq(eventTags.userId, userId)); - - // Create default tags on first access (when user has no tags yet) - if (tags.length === 0) { - return this.createDefaultTags(userId); - } - - return tags; - } - - private async createDefaultTags(userId: string): Promise { - const tagsToCreate = DEFAULT_TAGS.map((tag) => ({ - userId, - name: tag.name, - color: tag.color, - })); - - return this.db.insert(eventTags).values(tagsToCreate).returning(); - } - - async findById(id: string, userId: string): Promise { - const [tag] = await this.db - .select() - .from(eventTags) - .where(and(eq(eventTags.id, id), eq(eventTags.userId, userId))); - return tag || null; - } - - async create(data: NewEventTag): Promise { - const [tag] = await this.db.insert(eventTags).values(data).returning(); - return tag; - } - - async update(id: string, userId: string, data: Partial): Promise { - const [tag] = await this.db - .update(eventTags) - .set({ ...data, updatedAt: new Date() }) - .where(and(eq(eventTags.id, id), eq(eventTags.userId, userId))) - .returning(); - - if (!tag) { - throw new NotFoundException('Tag not found'); - } - - return tag; - } - - async delete(id: string, userId: string): Promise { - await this.db.delete(eventTags).where(and(eq(eventTags.id, id), eq(eventTags.userId, userId))); - } - - async getTagsForEvent(eventId: string): Promise { - const results = await this.db - .select({ tag: eventTags }) - .from(eventToTags) - .innerJoin(eventTags, eq(eventToTags.tagId, eventTags.id)) - .where(eq(eventToTags.eventId, eventId)); - - return results.map((r) => r.tag); - } - - async getTagsForEvents(eventIds: string[]): Promise> { - const tagMap = new Map(); - if (eventIds.length === 0) return tagMap; - - const results = await this.db - .select({ eventId: eventToTags.eventId, tag: eventTags }) - .from(eventToTags) - .innerJoin(eventTags, eq(eventToTags.tagId, eventTags.id)) - .where(inArray(eventToTags.eventId, eventIds)); - - for (const r of results) { - const existing = tagMap.get(r.eventId) || []; - existing.push(r.tag); - tagMap.set(r.eventId, existing); - } - - return tagMap; - } - - async getTagIdsForEvent(eventId: string): Promise { - const results = await this.db - .select({ tagId: eventToTags.tagId }) - .from(eventToTags) - .where(eq(eventToTags.eventId, eventId)); - - return results.map((r) => r.tagId); - } - - async setEventTags(eventId: string, tagIds: string[]): Promise { - // Remove existing tags - await this.db.delete(eventToTags).where(eq(eventToTags.eventId, eventId)); - - // Add new tags - if (tagIds.length > 0) { - const values = tagIds.map((tagId) => ({ eventId, tagId })); - await this.db.insert(eventToTags).values(values).onConflictDoNothing(); - } - } - - async addTagToEvent(eventId: string, tagId: string): Promise { - await this.db.insert(eventToTags).values({ eventId, tagId }).onConflictDoNothing(); - } - - async removeTagFromEvent(eventId: string, tagId: string): Promise { - await this.db - .delete(eventToTags) - .where(and(eq(eventToTags.eventId, eventId), eq(eventToTags.tagId, tagId))); - } - - async getTagsByIds(ids: string[], userId: string): Promise { - if (ids.length === 0) return []; - - return this.db - .select() - .from(eventTags) - .where(and(inArray(eventTags.id, ids), eq(eventTags.userId, userId))); - } - - async findByGroupId(groupId: string | null, userId: string): Promise { - const condition = - groupId === null - ? and(isNull(eventTags.groupId), eq(eventTags.userId, userId)) - : and(eq(eventTags.groupId, groupId), eq(eventTags.userId, userId)); - - return this.db - .select() - .from(eventTags) - .where(condition) - .orderBy(asc(eventTags.sortOrder), asc(eventTags.name)); - } - - async updateTagGroup(tagId: string, userId: string, groupId: string | null): Promise { - const [tag] = await this.db - .update(eventTags) - .set({ groupId, updatedAt: new Date() }) - .where(and(eq(eventTags.id, tagId), eq(eventTags.userId, userId))) - .returning(); - - if (!tag) { - throw new NotFoundException('Tag not found'); - } - - return tag; - } -} diff --git a/apps/calendar/apps/backend/src/event/dto/create-event.dto.ts b/apps/calendar/apps/backend/src/event/dto/create-event.dto.ts deleted file mode 100644 index f1778fb84..000000000 --- a/apps/calendar/apps/backend/src/event/dto/create-event.dto.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { - IsString, - IsOptional, - IsBoolean, - IsObject, - IsDateString, - IsUUID, - IsIn, - IsArray, - MaxLength, -} from 'class-validator'; -import type { EventMetadata } from '../../db/schema/events.schema'; - -export class CreateEventDto { - @IsOptional() - @IsUUID() - calendarId?: string; - - @IsString() - @MaxLength(500) - title: string; - - @IsOptional() - @IsString() - description?: string; - - @IsOptional() - @IsString() - @MaxLength(500) - location?: string; - - @IsDateString() - startTime: string; - - @IsDateString() - endTime: string; - - @IsOptional() - @IsBoolean() - isAllDay?: boolean; - - @IsOptional() - @IsString() - @MaxLength(100) - timezone?: string; - - @IsOptional() - @IsString() - @MaxLength(500) - recurrenceRule?: string; - - @IsOptional() - @IsDateString() - recurrenceEndDate?: string; - - @IsOptional() - @IsString() - @MaxLength(7) - color?: string; - - @IsOptional() - @IsIn(['confirmed', 'tentative', 'cancelled']) - status?: 'confirmed' | 'tentative' | 'cancelled'; - - @IsOptional() - @IsObject() - metadata?: EventMetadata; - - @IsOptional() - @IsArray() - @IsUUID('4', { each: true }) - tagIds?: string[]; -} diff --git a/apps/calendar/apps/backend/src/event/dto/index.ts b/apps/calendar/apps/backend/src/event/dto/index.ts deleted file mode 100644 index ed3180c2d..000000000 --- a/apps/calendar/apps/backend/src/event/dto/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './create-event.dto'; -export * from './update-event.dto'; -export * from './query-events.dto'; diff --git a/apps/calendar/apps/backend/src/event/dto/query-events.dto.ts b/apps/calendar/apps/backend/src/event/dto/query-events.dto.ts deleted file mode 100644 index 543cdf954..000000000 --- a/apps/calendar/apps/backend/src/event/dto/query-events.dto.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - IsOptional, - IsDateString, - IsArray, - IsBoolean, - IsString, - IsInt, - Min, - Max, - MaxLength, -} from 'class-validator'; -import { Transform, Type } from 'class-transformer'; - -export class QueryEventsDto { - @IsOptional() - @IsDateString() - startDate?: string; - - @IsOptional() - @IsDateString() - endDate?: string; - - @IsOptional() - @IsArray() - @IsString({ each: true }) - @Transform(({ value }) => (typeof value === 'string' ? value.split(',') : value)) - calendarIds?: string[]; - - @IsOptional() - @IsBoolean() - @Transform(({ value }) => value === 'true' || value === true) - includeCancelled?: boolean; - - @IsOptional() - @IsString() - @MaxLength(500) - search?: string; - - @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - @Max(500) - limit?: number; - - @IsOptional() - @Type(() => Number) - @IsInt() - @Min(0) - offset?: number; -} diff --git a/apps/calendar/apps/backend/src/event/dto/update-event.dto.ts b/apps/calendar/apps/backend/src/event/dto/update-event.dto.ts deleted file mode 100644 index ac3b82150..000000000 --- a/apps/calendar/apps/backend/src/event/dto/update-event.dto.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - IsString, - IsOptional, - IsBoolean, - IsObject, - IsDateString, - IsUUID, - IsIn, - IsArray, - MaxLength, -} from 'class-validator'; -import type { EventMetadata } from '../../db/schema/events.schema'; - -export class UpdateEventDto { - @IsOptional() - @IsUUID() - calendarId?: string; - - @IsOptional() - @IsString() - @MaxLength(500) - title?: string; - - @IsOptional() - @IsString() - description?: string | null; - - @IsOptional() - @IsString() - @MaxLength(500) - location?: string | null; - - @IsOptional() - @IsDateString() - startTime?: string; - - @IsOptional() - @IsDateString() - endTime?: string; - - @IsOptional() - @IsBoolean() - isAllDay?: boolean; - - @IsOptional() - @IsString() - @MaxLength(100) - timezone?: string; - - @IsOptional() - @IsString() - @MaxLength(500) - recurrenceRule?: string | null; - - @IsOptional() - @IsDateString() - recurrenceEndDate?: string | null; - - @IsOptional() - @IsArray() - @IsString({ each: true }) - recurrenceExceptions?: string[]; - - @IsOptional() - @IsString() - @MaxLength(7) - color?: string | null; - - @IsOptional() - @IsIn(['confirmed', 'tentative', 'cancelled']) - status?: 'confirmed' | 'tentative' | 'cancelled'; - - @IsOptional() - @IsObject() - metadata?: EventMetadata; - - @IsOptional() - @IsArray() - @IsUUID('4', { each: true }) - tagIds?: string[]; -} diff --git a/apps/calendar/apps/backend/src/event/event.controller.spec.ts b/apps/calendar/apps/backend/src/event/event.controller.spec.ts deleted file mode 100644 index ef68ab518..000000000 --- a/apps/calendar/apps/backend/src/event/event.controller.spec.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { EventController } from './event.controller'; -import { createMockEvent, TEST_USER_ID } from '../__tests__/utils/mock-factories'; - -const mockUser = { userId: TEST_USER_ID, email: 'test@example.com' }; - -describe('EventController', () => { - let controller: EventController; - let service: any; - - beforeEach(() => { - service = { - getEventsWithCalendar: jest.fn(), - findByIdOrThrow: jest.fn(), - findByCalendar: jest.fn(), - create: jest.fn(), - update: jest.fn(), - delete: jest.fn(), - }; - controller = new EventController(service); - }); - - afterEach(() => jest.clearAllMocks()); - - describe('queryEvents', () => { - it('should return events with pagination', async () => { - const events = [createMockEvent(), createMockEvent()]; - service.getEventsWithCalendar.mockResolvedValue(events); - const query = { limit: 50, offset: 0 } as any; - const result = await controller.queryEvents(mockUser as any, query); - expect(result.events).toEqual(events); - expect(result.pagination).toEqual({ limit: 50, offset: 0, count: 2 }); - }); - - it('should default offset to 0', async () => { - service.getEventsWithCalendar.mockResolvedValue([]); - const result = await controller.queryEvents(mockUser as any, { limit: 50 } as any); - expect(result.pagination.offset).toBe(0); - }); - }); - - describe('findOne', () => { - it('should return event by id', async () => { - const event = createMockEvent(); - service.findByIdOrThrow.mockResolvedValue(event); - const result = await controller.findOne(mockUser as any, event.id); - expect(result).toEqual({ event }); - }); - }); - - describe('findByCalendar', () => { - it('should return events for calendar', async () => { - const events = [createMockEvent()]; - service.findByCalendar.mockResolvedValue(events); - const result = await controller.findByCalendar(mockUser as any, 'cal-id', {} as any); - expect(result).toEqual({ events }); - }); - }); - - describe('create', () => { - it('should create event', async () => { - const event = createMockEvent({ title: 'Meeting' }); - service.create.mockResolvedValue(event); - const result = await controller.create(mockUser as any, { title: 'Meeting' } as any); - expect(result).toEqual({ event }); - }); - }); - - describe('update', () => { - it('should update event', async () => { - const event = createMockEvent({ title: 'Updated' }); - service.update.mockResolvedValue(event); - const result = await controller.update(mockUser as any, event.id, { - title: 'Updated', - } as any); - expect(result).toEqual({ event }); - }); - }); - - describe('delete', () => { - it('should delete and return success', async () => { - service.delete.mockResolvedValue(undefined); - const result = await controller.delete(mockUser as any, 'event-id'); - expect(result).toEqual({ success: true }); - }); - }); -}); diff --git a/apps/calendar/apps/backend/src/event/event.controller.ts b/apps/calendar/apps/backend/src/event/event.controller.ts deleted file mode 100644 index 9a722972c..000000000 --- a/apps/calendar/apps/backend/src/event/event.controller.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common'; -import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; -import { UseCredits } from '@manacore/nestjs-integration'; -import { CreditOperationType } from '@manacore/credit-operations'; -import { EventService } from './event.service'; -import { CreateEventDto, UpdateEventDto, QueryEventsDto } from './dto'; - -@Controller('events') -@UseGuards(JwtAuthGuard) -export class EventController { - constructor(private readonly eventService: EventService) {} - - @Get() - async queryEvents(@CurrentUser() user: CurrentUserData, @Query() query: QueryEventsDto) { - const events = await this.eventService.getEventsWithCalendar(user.userId, query); - return { - events, - pagination: { - limit: query.limit, - offset: query.offset ?? 0, - count: events.length, - }, - }; - } - - @Get(':id') - async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - const event = await this.eventService.findByIdOrThrow(id, user.userId); - return { event }; - } - - @Get('calendar/:calendarId') - async findByCalendar( - @CurrentUser() user: CurrentUserData, - @Param('calendarId') calendarId: string, - @Query() query: QueryEventsDto - ) { - const events = await this.eventService.findByCalendar(calendarId, user.userId, query); - return { events }; - } - - @Post() - @UseCredits(CreditOperationType.EVENT_CREATE) - async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateEventDto) { - const event = await this.eventService.create(user.userId, dto); - return { event }; - } - - @Put(':id') - async update( - @CurrentUser() user: CurrentUserData, - @Param('id') id: string, - @Body() dto: UpdateEventDto - ) { - const event = await this.eventService.update(id, user.userId, dto); - return { event }; - } - - @Delete(':id') - async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - await this.eventService.delete(id, user.userId); - return { success: true }; - } -} diff --git a/apps/calendar/apps/backend/src/event/event.module.ts b/apps/calendar/apps/backend/src/event/event.module.ts deleted file mode 100644 index 522607d5c..000000000 --- a/apps/calendar/apps/backend/src/event/event.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from '@nestjs/common'; -import { EventController } from './event.controller'; -import { EventService } from './event.service'; -import { CalendarModule } from '../calendar/calendar.module'; -import { EventTagModule } from '../event-tag/event-tag.module'; - -@Module({ - imports: [CalendarModule, EventTagModule], - controllers: [EventController], - providers: [EventService], - exports: [EventService], -}) -export class EventModule {} diff --git a/apps/calendar/apps/backend/src/event/event.service.spec.ts b/apps/calendar/apps/backend/src/event/event.service.spec.ts deleted file mode 100644 index 188ca0f74..000000000 --- a/apps/calendar/apps/backend/src/event/event.service.spec.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { NotFoundException } from '@nestjs/common'; -import { EventService } from './event.service'; -import { CalendarService } from '../calendar/calendar.service'; -import { EventTagService } from '../event-tag/event-tag.service'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { - createMockEvent, - createMockCalendar, - TEST_USER_ID, -} from '../__tests__/utils/mock-factories'; - -describe('EventService', () => { - let service: EventService; - let mockDb: any; - let mockCalendarService: jest.Mocked; - let mockEventTagService: jest.Mocked; - - beforeEach(async () => { - mockDb = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - leftJoin: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - returning: jest.fn(), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - delete: jest.fn().mockReturnThis(), - }; - - mockCalendarService = { - findByIdOrThrow: jest.fn(), - getOrCreateDefaultCalendar: jest.fn(), - } as unknown as jest.Mocked; - - mockEventTagService = { - setEventTags: jest.fn(), - getTagsForEvent: jest.fn().mockResolvedValue([]), - } as unknown as jest.Mocked; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - EventService, - { - provide: DATABASE_CONNECTION, - useValue: mockDb, - }, - { - provide: CalendarService, - useValue: mockCalendarService, - }, - { - provide: EventTagService, - useValue: mockEventTagService, - }, - ], - }).compile(); - - service = module.get(EventService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('queryEvents', () => { - it('should return events within date range', async () => { - const events = [createMockEvent({ title: 'Event 1' }), createMockEvent({ title: 'Event 2' })]; - mockDb.orderBy.mockResolvedValueOnce(events); - - const result = await service.queryEvents(TEST_USER_ID, { - startDate: '2024-01-01', - endDate: '2024-12-31', - }); - - expect(result).toEqual(events); - expect(mockDb.select).toHaveBeenCalled(); - }); - - it('should filter by calendar IDs', async () => { - const calendarId = 'calendar-123'; - const events = [createMockEvent({ calendarId })]; - mockDb.orderBy.mockResolvedValueOnce(events); - - const result = await service.queryEvents(TEST_USER_ID, { - calendarIds: [calendarId], - }); - - expect(result).toEqual(events); - }); - - it('should return empty array when no events match', async () => { - mockDb.orderBy.mockResolvedValueOnce([]); - - const result = await service.queryEvents(TEST_USER_ID, {}); - - expect(result).toEqual([]); - }); - }); - - describe('findById', () => { - it('should return event when found', async () => { - const event = createMockEvent(); - mockDb.where.mockResolvedValueOnce([event]); - - const result = await service.findById(event.id, TEST_USER_ID); - - expect(result).toEqual(event); - }); - - it('should return null when event not found', async () => { - mockDb.where.mockResolvedValueOnce([]); - - const result = await service.findById('non-existent-id', TEST_USER_ID); - - expect(result).toBeNull(); - }); - }); - - describe('findByIdOrThrow', () => { - it('should return event when found', async () => { - const event = createMockEvent(); - mockDb.where.mockResolvedValueOnce([event]); - - const result = await service.findByIdOrThrow(event.id, TEST_USER_ID); - - expect(result).toEqual(event); - }); - - it('should throw NotFoundException when event not found', async () => { - mockDb.where.mockResolvedValueOnce([]); - - await expect(service.findByIdOrThrow('non-existent-id', TEST_USER_ID)).rejects.toThrow( - NotFoundException - ); - }); - }); - - describe('create', () => { - it('should create event with provided calendarId', async () => { - const calendar = createMockCalendar(); - const newEvent = createMockEvent({ calendarId: calendar.id }); - - mockCalendarService.findByIdOrThrow.mockResolvedValueOnce(calendar); - mockDb.returning.mockResolvedValueOnce([newEvent]); - - const result = await service.create(TEST_USER_ID, { - calendarId: calendar.id, - title: 'New Event', - startTime: new Date().toISOString(), - endTime: new Date(Date.now() + 3600000).toISOString(), - }); - - expect(result).toEqual(newEvent); - expect(mockCalendarService.findByIdOrThrow).toHaveBeenCalledWith(calendar.id, TEST_USER_ID); - }); - - it('should create event using default calendar when no calendarId provided', async () => { - const defaultCalendar = createMockCalendar({ isDefault: true }); - const newEvent = createMockEvent({ calendarId: defaultCalendar.id }); - - mockCalendarService.getOrCreateDefaultCalendar.mockResolvedValueOnce(defaultCalendar); - mockDb.returning.mockResolvedValueOnce([newEvent]); - - const result = await service.create(TEST_USER_ID, { - title: 'New Event', - startTime: new Date().toISOString(), - endTime: new Date(Date.now() + 3600000).toISOString(), - }); - - expect(result).toEqual(newEvent); - expect(mockCalendarService.getOrCreateDefaultCalendar).toHaveBeenCalledWith(TEST_USER_ID); - }); - - it('should set tags when tagIds provided', async () => { - const calendar = createMockCalendar(); - const newEvent = createMockEvent({ calendarId: calendar.id }); - const tagIds = ['tag-1', 'tag-2']; - - mockCalendarService.findByIdOrThrow.mockResolvedValueOnce(calendar); - mockDb.returning.mockResolvedValueOnce([newEvent]); - - await service.create(TEST_USER_ID, { - calendarId: calendar.id, - title: 'New Event', - startTime: new Date().toISOString(), - endTime: new Date(Date.now() + 3600000).toISOString(), - tagIds, - }); - - expect(mockEventTagService.setEventTags).toHaveBeenCalledWith(newEvent.id, tagIds); - }); - }); - - describe('update', () => { - it('should update event', async () => { - const event = createMockEvent(); - const updatedEvent = { ...event, title: 'Updated Title' }; - - mockDb.where.mockResolvedValueOnce([event]); - mockDb.returning.mockResolvedValueOnce([updatedEvent]); - - const result = await service.update(event.id, TEST_USER_ID, { - title: 'Updated Title', - }); - - expect(result.title).toBe('Updated Title'); - }); - - it('should throw NotFoundException when updating non-existent event', async () => { - mockDb.where.mockResolvedValueOnce([]); - - await expect( - service.update('non-existent-id', TEST_USER_ID, { title: 'New Title' }) - ).rejects.toThrow(NotFoundException); - }); - - it('should verify calendar ownership when changing calendar', async () => { - const event = createMockEvent(); - const newCalendar = createMockCalendar({ id: 'new-calendar-id' }); - const updatedEvent = { ...event, calendarId: newCalendar.id }; - - mockDb.where.mockResolvedValueOnce([event]); - mockCalendarService.findByIdOrThrow.mockResolvedValueOnce(newCalendar); - mockDb.returning.mockResolvedValueOnce([updatedEvent]); - - await service.update(event.id, TEST_USER_ID, { - calendarId: newCalendar.id, - }); - - expect(mockCalendarService.findByIdOrThrow).toHaveBeenCalledWith( - newCalendar.id, - TEST_USER_ID - ); - }); - }); - - describe('delete', () => { - it('should delete event', async () => { - const event = createMockEvent(); - mockDb.where.mockResolvedValueOnce([event]); - - await service.delete(event.id, TEST_USER_ID); - - expect(mockDb.delete).toHaveBeenCalled(); - }); - - it('should throw NotFoundException when deleting non-existent event', async () => { - mockDb.where.mockResolvedValueOnce([]); - - await expect(service.delete('non-existent-id', TEST_USER_ID)).rejects.toThrow( - NotFoundException - ); - }); - }); - - describe('findByCalendar', () => { - it('should return events for specific calendar', async () => { - const calendar = createMockCalendar(); - const events = [createMockEvent({ calendarId: calendar.id })]; - - mockCalendarService.findByIdOrThrow.mockResolvedValueOnce(calendar); - mockDb.orderBy.mockResolvedValueOnce(events); - - const result = await service.findByCalendar(calendar.id, TEST_USER_ID, {}); - - expect(result).toEqual(events); - expect(mockCalendarService.findByIdOrThrow).toHaveBeenCalledWith(calendar.id, TEST_USER_ID); - }); - }); -}); diff --git a/apps/calendar/apps/backend/src/event/event.service.ts b/apps/calendar/apps/backend/src/event/event.service.ts deleted file mode 100644 index 62fbf9afd..000000000 --- a/apps/calendar/apps/backend/src/event/event.service.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { - Injectable, - Inject, - NotFoundException, - ForbiddenException, - BadRequestException, -} from '@nestjs/common'; -import { eq, and, gte, lte, inArray, or, ilike } from 'drizzle-orm'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { Database } from '../db/connection'; -import { events, Event, NewEvent } from '../db/schema/events.schema'; -import { calendars } from '../db/schema/calendars.schema'; -import { CalendarService } from '../calendar/calendar.service'; -import { EventTagService } from '../event-tag/event-tag.service'; -import { CreateEventDto, UpdateEventDto, QueryEventsDto } from './dto'; - -@Injectable() -export class EventService { - constructor( - @Inject(DATABASE_CONNECTION) private db: Database, - private calendarService: CalendarService, - private eventTagService: EventTagService - ) {} - - private escapeLikePattern(input: string): string { - return input.replace(/[%_\\]/g, '\\$&'); - } - - async queryEvents(userId: string, query: QueryEventsDto): Promise { - const conditions = [eq(events.userId, userId)]; - - // Date range filter - if (query.startDate) { - conditions.push(gte(events.endTime, new Date(query.startDate))); - } - if (query.endDate) { - conditions.push(lte(events.startTime, new Date(query.endDate))); - } - - // Calendar filter - if (query.calendarIds && query.calendarIds.length > 0) { - conditions.push(inArray(events.calendarId, query.calendarIds)); - } - - // Exclude cancelled unless requested - if (!query.includeCancelled) { - conditions.push(or(eq(events.status, 'confirmed'), eq(events.status, 'tentative')) as any); - } - - // Search filter (escaped to prevent LIKE pattern injection) - if (query.search) { - const escaped = this.escapeLikePattern(query.search); - conditions.push( - or(ilike(events.title, `%${escaped}%`), ilike(events.description, `%${escaped}%`)) as any - ); - } - - const qb = this.db - .select() - .from(events) - .where(and(...conditions)) - .orderBy(events.startTime); - - if (query.limit) { - qb.limit(query.limit); - } - if (query.offset) { - qb.offset(query.offset); - } - - return qb; - } - - async findById(id: string, userId: string): Promise { - const result = await this.db - .select() - .from(events) - .where(and(eq(events.id, id), eq(events.userId, userId))); - return result[0] || null; - } - - async findByIdOrThrow(id: string, userId: string): Promise { - const event = await this.findById(id, userId); - if (!event) { - throw new NotFoundException(`Event with id ${id} not found`); - } - return event; - } - - async findByCalendar( - calendarId: string, - userId: string, - query: QueryEventsDto - ): Promise { - // Verify user owns the calendar - await this.calendarService.findByIdOrThrow(calendarId, userId); - - return this.queryEvents(userId, { - ...query, - calendarIds: [calendarId], - }); - } - - private validateTimeRange(startTime: string, endTime: string): void { - if (new Date(startTime) >= new Date(endTime)) { - throw new BadRequestException('startTime must be before endTime'); - } - } - - async create(userId: string, dto: CreateEventDto): Promise { - this.validateTimeRange(dto.startTime, dto.endTime); - - let calendarId = dto.calendarId; - let calendar; - - // If no calendarId provided, get or create default calendar - if (!calendarId) { - calendar = await this.calendarService.getOrCreateDefaultCalendar(userId); - calendarId = calendar.id; - } else { - // Verify user owns the specified calendar - calendar = await this.calendarService.findByIdOrThrow(calendarId, userId); - } - - const newEvent: NewEvent = { - calendarId, - userId, - title: dto.title, - description: dto.description, - location: dto.location, - startTime: new Date(dto.startTime), - endTime: new Date(dto.endTime), - isAllDay: dto.isAllDay ?? false, - timezone: dto.timezone || calendar.timezone || 'Europe/Berlin', - recurrenceRule: dto.recurrenceRule, - recurrenceEndDate: dto.recurrenceEndDate ? new Date(dto.recurrenceEndDate) : undefined, - color: dto.color, - status: dto.status || 'confirmed', - metadata: dto.metadata, - }; - - const [created] = await this.db.insert(events).values(newEvent).returning(); - - // Set tags if provided - if (dto.tagIds && dto.tagIds.length > 0) { - await this.eventTagService.setEventTags(created.id, dto.tagIds); - } - - return created; - } - - async update(id: string, userId: string, dto: UpdateEventDto): Promise { - const existingEvent = await this.findByIdOrThrow(id, userId); - - // Validate time range when either startTime or endTime is provided - const effectiveStart = dto.startTime ?? existingEvent.startTime.toISOString(); - const effectiveEnd = dto.endTime ?? existingEvent.endTime.toISOString(); - if (dto.startTime || dto.endTime) { - this.validateTimeRange(effectiveStart, effectiveEnd); - } - - // If changing calendar, verify user owns the new calendar - if (dto.calendarId && dto.calendarId !== existingEvent.calendarId) { - await this.calendarService.findByIdOrThrow(dto.calendarId, userId); - } - - // Handle tags separately - const { tagIds, ...eventData } = dto; - - const updateData: Partial = { - ...eventData, - startTime: dto.startTime ? new Date(dto.startTime) : undefined, - endTime: dto.endTime ? new Date(dto.endTime) : undefined, - recurrenceEndDate: dto.recurrenceEndDate ? new Date(dto.recurrenceEndDate) : undefined, - updatedAt: new Date(), - }; - - // Remove undefined values - Object.keys(updateData).forEach((key) => { - if (updateData[key as keyof typeof updateData] === undefined) { - delete updateData[key as keyof typeof updateData]; - } - }); - - const [updated] = await this.db - .update(events) - .set(updateData) - .where(and(eq(events.id, id), eq(events.userId, userId))) - .returning(); - - // Update tags if provided - if (tagIds !== undefined) { - await this.eventTagService.setEventTags(id, tagIds); - } - - return updated; - } - - async delete(id: string, userId: string): Promise { - await this.findByIdOrThrow(id, userId); - - await this.db.delete(events).where(and(eq(events.id, id), eq(events.userId, userId))); - } - - async getEventsWithCalendar(userId: string, query: QueryEventsDto) { - const conditions = [eq(events.userId, userId)]; - - if (query.startDate) { - conditions.push(gte(events.endTime, new Date(query.startDate))); - } - if (query.endDate) { - conditions.push(lte(events.startTime, new Date(query.endDate))); - } - - if (query.calendarIds && query.calendarIds.length > 0) { - conditions.push(inArray(events.calendarId, query.calendarIds)); - } - - // Search filter (escaped to prevent LIKE pattern injection) - if (query.search) { - const escaped = this.escapeLikePattern(query.search); - conditions.push( - or(ilike(events.title, `%${escaped}%`), ilike(events.description, `%${escaped}%`)) as any - ); - } - - const qb = this.db - .select({ - event: events, - calendar: { - id: calendars.id, - name: calendars.name, - color: calendars.color, - }, - }) - .from(events) - .leftJoin(calendars, eq(events.calendarId, calendars.id)) - .where(and(...conditions)) - .orderBy(events.startTime); - - if (query.limit) { - qb.limit(query.limit); - } - if (query.offset) { - qb.offset(query.offset); - } - - const result = await qb; - - // Load tags for all events in a single batch query - const eventIds = result.map((r) => r.event.id); - const tagMap = await this.eventTagService.getTagsForEvents(eventIds); - - const eventsWithCalendar = result.map((r) => ({ - ...r.event, - calendar: r.calendar, - tags: tagMap.get(r.event.id) || [], - })); - - return eventsWithCalendar; - } - - async getEventWithTags(id: string, userId: string) { - const event = await this.findByIdOrThrow(id, userId); - const tags = await this.eventTagService.getTagsForEvent(id); - return { ...event, tags }; - } -} diff --git a/apps/calendar/apps/backend/src/instrument.ts b/apps/calendar/apps/backend/src/instrument.ts deleted file mode 100644 index 7f25474c8..000000000 --- a/apps/calendar/apps/backend/src/instrument.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { initErrorTracking } from '@manacore/shared-error-tracking'; - -initErrorTracking({ - serviceName: 'calendar-backend', - environment: process.env.NODE_ENV, - release: process.env.APP_VERSION, - debug: process.env.NODE_ENV === 'development', -}); diff --git a/apps/calendar/apps/backend/src/main.ts b/apps/calendar/apps/backend/src/main.ts deleted file mode 100644 index cc892e583..000000000 --- a/apps/calendar/apps/backend/src/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -import './instrument'; -import { bootstrapApp } from '@manacore/shared-nestjs-setup'; -import { AppModule } from './app.module'; - -bootstrapApp(AppModule, { - defaultPort: 3014, - serviceName: 'Calendar', - additionalCorsOrigins: ['http://localhost:5179'], - swagger: true, -}); diff --git a/apps/calendar/apps/backend/src/network/network.controller.ts b/apps/calendar/apps/backend/src/network/network.controller.ts deleted file mode 100644 index 8bdb3940e..000000000 --- a/apps/calendar/apps/backend/src/network/network.controller.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Controller, Get, UseGuards, Headers } from '@nestjs/common'; -import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; -import { NetworkService } from './network.service'; - -@Controller('network') -@UseGuards(JwtAuthGuard) -export class NetworkController { - constructor(private readonly networkService: NetworkService) {} - - @Get('graph') - async getGraph( - @CurrentUser() user: CurrentUserData, - @Headers('authorization') authorization?: string - ) { - const accessToken = authorization?.replace('Bearer ', ''); - return this.networkService.getGraph(user.userId, accessToken); - } -} diff --git a/apps/calendar/apps/backend/src/network/network.module.ts b/apps/calendar/apps/backend/src/network/network.module.ts deleted file mode 100644 index 719a19f0d..000000000 --- a/apps/calendar/apps/backend/src/network/network.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { NetworkController } from './network.controller'; -import { NetworkService } from './network.service'; - -@Module({ - controllers: [NetworkController], - providers: [NetworkService], - exports: [NetworkService], -}) -export class NetworkModule {} diff --git a/apps/calendar/apps/backend/src/network/network.service.ts b/apps/calendar/apps/backend/src/network/network.service.ts deleted file mode 100644 index 83ce8e701..000000000 --- a/apps/calendar/apps/backend/src/network/network.service.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { Injectable, Inject, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { eq } from 'drizzle-orm'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { Database } from '../db/connection'; -import { events, eventToTags } from '../db/schema'; - -interface Tag { - id: string; - name: string; - color: string | null; -} - -export interface NetworkNode { - id: string; - name: string; - photoUrl: string | null; - company: string | null; - isFavorite: boolean; - tags: Tag[]; - connectionCount: number; -} - -export interface NetworkLink { - source: string; - target: string; - type: 'tag' | 'calendar' | 'date' | 'location'; - strength: number; - sharedTags: string[]; -} - -export interface NetworkGraphResponse { - nodes: NetworkNode[]; - links: NetworkLink[]; -} - -@Injectable() -export class NetworkService { - private readonly logger = new Logger(NetworkService.name); - private authUrl: string; - - constructor( - @Inject(DATABASE_CONNECTION) private db: Database, - private configService: ConfigService - ) { - this.authUrl = this.configService.get('MANA_CORE_AUTH_URL') || 'http://localhost:3001'; - } - - /** - * Fetch tags from central Tags API - */ - private async fetchTagsByIds(tagIds: string[], accessToken: string): Promise> { - if (tagIds.length === 0) return new Map(); - - try { - const response = await fetch(`${this.authUrl}/api/v1/tags/by-ids?ids=${tagIds.join(',')}`, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); - - if (!response.ok) { - this.logger.warn(`Failed to fetch tags from central API: ${response.status}`); - return new Map(); - } - - const tags: Tag[] = await response.json(); - return new Map(tags.map((t) => [t.id, t])); - } catch (error) { - this.logger.error('Error fetching tags from central API', error); - return new Map(); - } - } - - /** - * Build a network graph of events connected by shared tags - */ - async getGraph(userId: string, accessToken?: string): Promise { - // 1. Get all events for user - const eventsData = await this.db - .select({ - event: events, - }) - .from(events) - .where(eq(events.userId, userId)); - - // 2. Get tag IDs for each event from junction table - const eventTagIdsMap = new Map(); - const allTagIds = new Set(); - - for (const { event } of eventsData) { - const tagRelations = await this.db - .select({ - tagId: eventToTags.tagId, - }) - .from(eventToTags) - .where(eq(eventToTags.eventId, event.id)); - - const tagIds = tagRelations.map((r) => r.tagId); - eventTagIdsMap.set(event.id, tagIds); - tagIds.forEach((id) => allTagIds.add(id)); - } - - // 3. Fetch tag details from central Tags API - let tagsMap = new Map(); - if (accessToken && allTagIds.size > 0) { - tagsMap = await this.fetchTagsByIds(Array.from(allTagIds), accessToken); - } - - // 4. Build tags for each event - const eventTagsMap = new Map(); - for (const { event } of eventsData) { - const tagIds = eventTagIdsMap.get(event.id) || []; - const tags = tagIds.map((id) => tagsMap.get(id)).filter((t): t is Tag => t !== undefined); - eventTagsMap.set(event.id, tags); - } - - // 5. Build nodes from ALL events (not just those with tags) - const nodes: NetworkNode[] = eventsData.map(({ event }) => { - const tags = eventTagsMap.get(event.id) || []; - return { - id: event.id, - name: event.title, - photoUrl: null, // Events don't have photos - company: event.location || null, // Use location as subtitle - isFavorite: false, - tags, - connectionCount: 0, // Will be calculated below - }; - }); - - // 6. Build links based on multiple criteria - const links: NetworkLink[] = []; - const connectionCounts = new Map(); - const linkSet = new Set(); // Track unique links to avoid duplicates - - for (let i = 0; i < eventsData.length; i++) { - for (let j = i + 1; j < eventsData.length; j++) { - const event1 = eventsData[i].event; - const event2 = eventsData[j].event; - const node1 = nodes[i]; - const node2 = nodes[j]; - const linkKey = `${event1.id}-${event2.id}`; - - // Skip if link already exists - if (linkSet.has(linkKey)) continue; - - let linked = false; - let linkType: 'tag' | 'calendar' | 'date' | 'location' = 'tag'; - let strength = 0; - const sharedTags: string[] = []; - - // 6a. Check for shared tags (highest priority) - const tags1 = eventTagsMap.get(event1.id) || []; - const tags2 = eventTagsMap.get(event2.id) || []; - const commonTags = tags1.filter((t1) => tags2.some((t2) => t2.id === t1.id)); - - if (commonTags.length > 0) { - linked = true; - linkType = 'tag'; - const maxTags = Math.max(tags1.length, tags2.length); - strength = Math.round((commonTags.length / maxTags) * 100); - sharedTags.push(...commonTags.map((t) => t.name)); - } - - // 6b. Check for same calendar (if not already linked) - if (!linked && event1.calendarId === event2.calendarId) { - linked = true; - linkType = 'calendar'; - strength = 50; - } - - // 6c. Check for same date (if not already linked) - if (!linked) { - const date1 = new Date(event1.startTime).toDateString(); - const date2 = new Date(event2.startTime).toDateString(); - if (date1 === date2) { - linked = true; - linkType = 'date'; - strength = 40; - } - } - - // 6d. Check for same location (if not already linked and both have location) - if ( - !linked && - event1.location && - event2.location && - event1.location.toLowerCase() === event2.location.toLowerCase() - ) { - linked = true; - linkType = 'location'; - strength = 60; - } - - if (linked) { - links.push({ - source: event1.id, - target: event2.id, - type: linkType, - strength, - sharedTags, - }); - linkSet.add(linkKey); - - // Update connection counts - connectionCounts.set(event1.id, (connectionCounts.get(event1.id) || 0) + 1); - connectionCounts.set(event2.id, (connectionCounts.get(event2.id) || 0) + 1); - } - } - } - - // 7. Update connection counts in nodes - for (const node of nodes) { - node.connectionCount = connectionCounts.get(node.id) || 0; - } - - return { nodes, links }; - } -} diff --git a/apps/calendar/apps/backend/src/notification/dto/index.ts b/apps/calendar/apps/backend/src/notification/dto/index.ts deleted file mode 100644 index 56b4c0554..000000000 --- a/apps/calendar/apps/backend/src/notification/dto/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './register-token.dto'; diff --git a/apps/calendar/apps/backend/src/notification/dto/register-token.dto.ts b/apps/calendar/apps/backend/src/notification/dto/register-token.dto.ts deleted file mode 100644 index 4de7cdfbe..000000000 --- a/apps/calendar/apps/backend/src/notification/dto/register-token.dto.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { IsString, IsNotEmpty, IsIn, IsOptional, Matches } from 'class-validator'; - -export class RegisterTokenDto { - @IsString() - @IsNotEmpty() - @Matches(/^ExponentPushToken\[.+\]$/, { - message: 'pushToken must be a valid Expo push token', - }) - pushToken: string; - - @IsString() - @IsIn(['ios', 'android']) - platform: 'ios' | 'android'; - - @IsOptional() - @IsString() - deviceName?: string; -} diff --git a/apps/calendar/apps/backend/src/notification/notification.controller.ts b/apps/calendar/apps/backend/src/notification/notification.controller.ts deleted file mode 100644 index 900a4189e..000000000 --- a/apps/calendar/apps/backend/src/notification/notification.controller.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Controller, Post, Delete, Body, Param, UseGuards, Get } from '@nestjs/common'; -import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; -import { NotificationService } from './notification.service'; -import { RegisterTokenDto } from './dto'; - -@Controller('api/v1/notifications') -@UseGuards(JwtAuthGuard) -export class NotificationController { - constructor(private notificationService: NotificationService) {} - - /** - * Register a push token for the current user - */ - @Post('register-token') - async registerToken(@CurrentUser() user: CurrentUserData, @Body() dto: RegisterTokenDto) { - const token = await this.notificationService.registerToken(user.userId, dto); - return { - success: true, - token: { - id: token.id, - platform: token.platform, - deviceName: token.deviceName, - createdAt: token.createdAt, - }, - }; - } - - /** - * Remove a push token - */ - @Delete('token/:token') - async removeToken(@CurrentUser() user: CurrentUserData, @Param('token') token: string) { - await this.notificationService.removeToken(decodeURIComponent(token)); - return { success: true }; - } - - /** - * Get the number of registered devices for the current user - */ - @Get('devices/count') - async getDeviceCount(@CurrentUser() user: CurrentUserData) { - const count = await this.notificationService.getTokenCount(user.userId); - return { count }; - } -} diff --git a/apps/calendar/apps/backend/src/notification/notification.module.ts b/apps/calendar/apps/backend/src/notification/notification.module.ts deleted file mode 100644 index b73839a2b..000000000 --- a/apps/calendar/apps/backend/src/notification/notification.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from '@nestjs/common'; -import { NotificationController } from './notification.controller'; -import { NotificationService } from './notification.service'; -import { PushService } from './push.service'; - -@Module({ - controllers: [NotificationController], - providers: [NotificationService, PushService], - exports: [NotificationService, PushService], -}) -export class NotificationModule {} diff --git a/apps/calendar/apps/backend/src/notification/notification.service.spec.ts b/apps/calendar/apps/backend/src/notification/notification.service.spec.ts deleted file mode 100644 index 57f287a9f..000000000 --- a/apps/calendar/apps/backend/src/notification/notification.service.spec.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { NotificationService } from './notification.service'; -import { PushService } from './push.service'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { createMockDeviceToken, TEST_USER_ID } from '../__tests__/utils/mock-factories'; - -describe('NotificationService', () => { - let service: NotificationService; - let mockDb: any; - let mockPushService: any; - - beforeEach(async () => { - mockDb = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - returning: jest.fn().mockResolvedValue([]), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - delete: jest.fn().mockReturnThis(), - }; - - mockPushService = { - sendToToken: jest.fn().mockResolvedValue(true), - sendToTokens: jest.fn().mockResolvedValue(new Map()), - isValidToken: jest.fn().mockReturnValue(true), - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - NotificationService, - { - provide: DATABASE_CONNECTION, - useValue: mockDb, - }, - { - provide: PushService, - useValue: mockPushService, - }, - ], - }).compile(); - - service = module.get(NotificationService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('registerToken', () => { - it('should create a new device token when it does not exist', async () => { - const newToken = createMockDeviceToken({ pushToken: 'ExponentPushToken[new-token]' }); - // Check for existing token - not found - mockDb.where.mockResolvedValueOnce([]); - // Insert returning - mockDb.returning.mockResolvedValueOnce([newToken]); - - const result = await service.registerToken(TEST_USER_ID, { - pushToken: 'ExponentPushToken[new-token]', - platform: 'ios', - deviceName: 'Test iPhone', - }); - - expect(result).toEqual(newToken); - expect(mockDb.insert).toHaveBeenCalled(); - expect(mockDb.values).toHaveBeenCalled(); - }); - - it('should update existing token when it already exists', async () => { - const existingToken = createMockDeviceToken({ - pushToken: 'ExponentPushToken[existing-token]', - }); - const updatedToken = { ...existingToken, userId: TEST_USER_ID, isActive: true }; - - // Check for existing token - found - mockDb.where.mockResolvedValueOnce([existingToken]); - // Update returning - mockDb.returning.mockResolvedValueOnce([updatedToken]); - - const result = await service.registerToken(TEST_USER_ID, { - pushToken: 'ExponentPushToken[existing-token]', - platform: 'ios', - deviceName: 'Test iPhone', - }); - - expect(result).toEqual(updatedToken); - expect(mockDb.update).toHaveBeenCalled(); - expect(mockDb.set).toHaveBeenCalled(); - // Should not have called insert - expect(mockDb.insert).not.toHaveBeenCalled(); - }); - }); - - describe('removeToken', () => { - it('should delete a device token', async () => { - await service.removeToken('ExponentPushToken[test-token]'); - - expect(mockDb.delete).toHaveBeenCalled(); - expect(mockDb.where).toHaveBeenCalled(); - }); - }); - - describe('deactivateToken', () => { - it('should set token isActive to false', async () => { - await service.deactivateToken('ExponentPushToken[test-token]'); - - expect(mockDb.update).toHaveBeenCalled(); - expect(mockDb.set).toHaveBeenCalledWith(expect.objectContaining({ isActive: false })); - }); - }); - - describe('getActiveTokensForUser', () => { - it('should return all active tokens for a user', async () => { - const tokens = [ - createMockDeviceToken({ isActive: true }), - createMockDeviceToken({ isActive: true }), - ]; - mockDb.where.mockResolvedValueOnce(tokens); - - const result = await service.getActiveTokensForUser(TEST_USER_ID); - - expect(result).toEqual(tokens); - expect(result).toHaveLength(2); - }); - - it('should return empty array when user has no active tokens', async () => { - mockDb.where.mockResolvedValueOnce([]); - - const result = await service.getActiveTokensForUser(TEST_USER_ID); - - expect(result).toEqual([]); - }); - }); - - describe('sendToUser', () => { - it('should return false when user has no active tokens', async () => { - // getActiveTokensForUser returns empty - mockDb.where.mockResolvedValueOnce([]); - - const result = await service.sendToUser(TEST_USER_ID, { - title: 'Test', - body: 'Test notification', - }); - - expect(result).toBe(false); - expect(mockPushService.sendToTokens).not.toHaveBeenCalled(); - }); - - it('should send notification to all active tokens', async () => { - const token1 = createMockDeviceToken({ pushToken: 'token-1' }); - const token2 = createMockDeviceToken({ pushToken: 'token-2' }); - - // getActiveTokensForUser returns tokens - mockDb.where.mockResolvedValueOnce([token1, token2]); - - const resultMap = new Map(); - resultMap.set('token-1', true); - resultMap.set('token-2', true); - mockPushService.sendToTokens!.mockResolvedValueOnce(resultMap); - - const result = await service.sendToUser(TEST_USER_ID, { - title: 'Test', - body: 'Test notification', - }); - - expect(result).toBe(true); - expect(mockPushService.sendToTokens).toHaveBeenCalledWith(['token-1', 'token-2'], { - title: 'Test', - body: 'Test notification', - }); - }); - - it('should deactivate tokens that failed', async () => { - const token1 = createMockDeviceToken({ pushToken: 'token-1' }); - const token2 = createMockDeviceToken({ pushToken: 'token-2' }); - - // getActiveTokensForUser returns tokens - mockDb.where.mockResolvedValueOnce([token1, token2]); - - const resultMap = new Map(); - resultMap.set('token-1', true); - resultMap.set('token-2', false); // This token failed - mockPushService.sendToTokens!.mockResolvedValueOnce(resultMap); - - const result = await service.sendToUser(TEST_USER_ID, { - title: 'Test', - body: 'Test notification', - }); - - expect(result).toBe(true); - // Should deactivate the failed token - expect(mockDb.update).toHaveBeenCalled(); - expect(mockDb.set).toHaveBeenCalledWith(expect.objectContaining({ isActive: false })); - }); - - it('should return false when all tokens fail', async () => { - const token1 = createMockDeviceToken({ pushToken: 'token-1' }); - - mockDb.where.mockResolvedValueOnce([token1]); - - const resultMap = new Map(); - resultMap.set('token-1', false); - mockPushService.sendToTokens!.mockResolvedValueOnce(resultMap); - - const result = await service.sendToUser(TEST_USER_ID, { - title: 'Test', - body: 'Test notification', - }); - - expect(result).toBe(false); - }); - }); - - describe('getTokenCount', () => { - it('should return count of active tokens', async () => { - const tokens = [ - createMockDeviceToken({ isActive: true }), - createMockDeviceToken({ isActive: true }), - createMockDeviceToken({ isActive: true }), - ]; - mockDb.where.mockResolvedValueOnce(tokens); - - const result = await service.getTokenCount(TEST_USER_ID); - - expect(result).toBe(3); - }); - - it('should return 0 when user has no active tokens', async () => { - mockDb.where.mockResolvedValueOnce([]); - - const result = await service.getTokenCount(TEST_USER_ID); - - expect(result).toBe(0); - }); - }); -}); diff --git a/apps/calendar/apps/backend/src/notification/notification.service.ts b/apps/calendar/apps/backend/src/notification/notification.service.ts deleted file mode 100644 index 9a02f1127..000000000 --- a/apps/calendar/apps/backend/src/notification/notification.service.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { Injectable, Inject, Logger } from '@nestjs/common'; -import { eq, and } from 'drizzle-orm'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { Database } from '../db/connection'; -import { - deviceTokens, - type DeviceToken, - type NewDeviceToken, -} from '../db/schema/device-tokens.schema'; -import { PushService, PushNotification } from './push.service'; -import { RegisterTokenDto } from './dto'; - -@Injectable() -export class NotificationService { - private readonly logger = new Logger(NotificationService.name); - - constructor( - @Inject(DATABASE_CONNECTION) private db: Database, - private pushService: PushService - ) {} - - /** - * Register or update a device token for a user - */ - async registerToken(userId: string, dto: RegisterTokenDto): Promise { - const { pushToken, platform, deviceName } = dto; - - // Check if token already exists - const existing = await this.db - .select() - .from(deviceTokens) - .where(eq(deviceTokens.pushToken, pushToken)); - - if (existing.length > 0) { - // Update existing token (might be a different user now) - const [updated] = await this.db - .update(deviceTokens) - .set({ - userId, - platform, - deviceName, - isActive: true, - updatedAt: new Date(), - }) - .where(eq(deviceTokens.pushToken, pushToken)) - .returning(); - - this.logger.log(`Updated device token for user ${userId}`); - return updated; - } - - // Create new token - const newToken: NewDeviceToken = { - userId, - pushToken, - platform, - deviceName, - isActive: true, - }; - - const [created] = await this.db.insert(deviceTokens).values(newToken).returning(); - this.logger.log(`Registered new device token for user ${userId}`); - return created; - } - - /** - * Remove a device token - */ - async removeToken(pushToken: string): Promise { - await this.db.delete(deviceTokens).where(eq(deviceTokens.pushToken, pushToken)); - this.logger.log(`Removed device token: ${pushToken.substring(0, 30)}...`); - } - - /** - * Deactivate a token (soft delete) - */ - async deactivateToken(pushToken: string): Promise { - await this.db - .update(deviceTokens) - .set({ isActive: false, updatedAt: new Date() }) - .where(eq(deviceTokens.pushToken, pushToken)); - } - - /** - * Get all active tokens for a user - */ - async getActiveTokensForUser(userId: string): Promise { - return this.db - .select() - .from(deviceTokens) - .where(and(eq(deviceTokens.userId, userId), eq(deviceTokens.isActive, true))); - } - - /** - * Send push notification to a user's devices - */ - async sendToUser(userId: string, notification: PushNotification): Promise { - const tokens = await this.getActiveTokensForUser(userId); - - if (tokens.length === 0) { - this.logger.debug(`No active push tokens for user ${userId}`); - return false; - } - - const pushTokens = tokens.map((t) => t.pushToken); - const results = await this.pushService.sendToTokens(pushTokens, notification); - - // Deactivate tokens that failed (might be invalid/unregistered) - for (const [token, success] of results.entries()) { - if (!success) { - await this.deactivateToken(token); - } - } - - return Array.from(results.values()).some((v) => v); - } - - /** - * Get count of active tokens for a user - */ - async getTokenCount(userId: string): Promise { - const tokens = await this.getActiveTokensForUser(userId); - return tokens.length; - } -} diff --git a/apps/calendar/apps/backend/src/notification/push.service.ts b/apps/calendar/apps/backend/src/notification/push.service.ts deleted file mode 100644 index 9a8474a4f..000000000 --- a/apps/calendar/apps/backend/src/notification/push.service.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import Expo, { ExpoPushMessage, ExpoPushTicket, ExpoPushReceipt } from 'expo-server-sdk'; - -export interface PushNotification { - title: string; - body: string; - data?: Record; - sound?: 'default' | null; - badge?: number; -} - -@Injectable() -export class PushService { - private readonly logger = new Logger(PushService.name); - private expo: Expo; - - constructor() { - this.expo = new Expo(); - } - - /** - * Validate if a token is a valid Expo push token - */ - isValidToken(token: string): boolean { - return Expo.isExpoPushToken(token); - } - - /** - * Send push notification to a single token - */ - async sendToToken(token: string, notification: PushNotification): Promise { - if (!this.isValidToken(token)) { - this.logger.warn(`Invalid Expo push token: ${token}`); - return false; - } - - const message: ExpoPushMessage = { - to: token, - title: notification.title, - body: notification.body, - data: notification.data, - sound: notification.sound ?? 'default', - badge: notification.badge, - }; - - try { - const tickets = await this.expo.sendPushNotificationsAsync([message]); - const ticket = tickets[0]; - - if (ticket.status === 'error') { - this.logger.error(`Push notification error: ${ticket.message}`, ticket.details); - return false; - } - - this.logger.log(`Push notification sent successfully to token: ${token.substring(0, 30)}...`); - return true; - } catch (error) { - this.logger.error('Failed to send push notification:', error); - return false; - } - } - - /** - * Send push notification to multiple tokens - */ - async sendToTokens( - tokens: string[], - notification: PushNotification - ): Promise> { - const results = new Map(); - const validTokens = tokens.filter((token) => { - const isValid = this.isValidToken(token); - if (!isValid) { - this.logger.warn(`Skipping invalid token: ${token}`); - results.set(token, false); - } - return isValid; - }); - - if (validTokens.length === 0) { - return results; - } - - const messages: ExpoPushMessage[] = validTokens.map((token) => ({ - to: token, - title: notification.title, - body: notification.body, - data: notification.data, - sound: notification.sound ?? 'default', - badge: notification.badge, - })); - - // Chunk messages (Expo has a limit of 100 per batch) - const chunks = this.expo.chunkPushNotifications(messages); - - for (const chunk of chunks) { - try { - const tickets = await this.expo.sendPushNotificationsAsync(chunk); - - tickets.forEach((ticket, index) => { - const token = (chunk[index] as ExpoPushMessage).to as string; - if (ticket.status === 'ok') { - results.set(token, true); - } else { - this.logger.error(`Push error for ${token}: ${ticket.message}`); - results.set(token, false); - } - }); - } catch (error) { - this.logger.error('Failed to send push notification batch:', error); - chunk.forEach((msg) => { - results.set(msg.to as string, false); - }); - } - } - - const successCount = Array.from(results.values()).filter((v) => v).length; - this.logger.log(`Push notifications sent: ${successCount}/${tokens.length} successful`); - - return results; - } - - /** - * Check receipts for sent notifications - * Call this after some time to verify delivery - */ - async checkReceipts(ticketIds: string[]): Promise> { - const results = new Map(); - const chunks = this.expo.chunkPushNotificationReceiptIds(ticketIds); - - for (const chunk of chunks) { - try { - const receipts = await this.expo.getPushNotificationReceiptsAsync(chunk); - - for (const [id, receipt] of Object.entries(receipts)) { - results.set(id, receipt); - - if (receipt.status === 'error') { - this.logger.error(`Receipt error for ${id}: ${receipt.message}`, receipt.details); - } - } - } catch (error) { - this.logger.error('Failed to get push notification receipts:', error); - } - } - - return results; - } -} diff --git a/apps/calendar/apps/backend/src/reminder/dto/create-reminder.dto.ts b/apps/calendar/apps/backend/src/reminder/dto/create-reminder.dto.ts deleted file mode 100644 index 7176cea80..000000000 --- a/apps/calendar/apps/backend/src/reminder/dto/create-reminder.dto.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { IsUUID, IsInt, IsOptional, IsBoolean, Min, Max } from 'class-validator'; - -export class CreateReminderDto { - @IsUUID() - eventId: string; - - @IsInt() - @Min(0) - @Max(10080) // Max 1 week in minutes - minutesBefore: number; - - @IsOptional() - @IsBoolean() - notifyPush?: boolean; - - @IsOptional() - @IsBoolean() - notifyEmail?: boolean; -} diff --git a/apps/calendar/apps/backend/src/reminder/dto/index.ts b/apps/calendar/apps/backend/src/reminder/dto/index.ts deleted file mode 100644 index 0f64a4857..000000000 --- a/apps/calendar/apps/backend/src/reminder/dto/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './create-reminder.dto'; diff --git a/apps/calendar/apps/backend/src/reminder/reminder.controller.ts b/apps/calendar/apps/backend/src/reminder/reminder.controller.ts deleted file mode 100644 index 72ae5561a..000000000 --- a/apps/calendar/apps/backend/src/reminder/reminder.controller.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Controller, Get, Post, Delete, Body, Param, UseGuards } from '@nestjs/common'; -import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; -import { ReminderService } from './reminder.service'; -import { CreateReminderDto } from './dto'; - -@Controller() -@UseGuards(JwtAuthGuard) -export class ReminderController { - constructor(private readonly reminderService: ReminderService) {} - - @Get('events/:eventId/reminders') - async findByEvent(@CurrentUser() user: CurrentUserData, @Param('eventId') eventId: string) { - const reminders = await this.reminderService.findByEvent(eventId, user.userId); - return { reminders }; - } - - @Post('events/:eventId/reminders') - async create( - @CurrentUser() user: CurrentUserData, - @Param('eventId') eventId: string, - @Body() dto: Omit - ) { - const reminder = await this.reminderService.create(user.userId, user.email, { - ...dto, - eventId, - }); - return { reminder }; - } - - @Delete('reminders/:id') - async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - await this.reminderService.delete(id, user.userId); - return { success: true }; - } -} diff --git a/apps/calendar/apps/backend/src/reminder/reminder.module.ts b/apps/calendar/apps/backend/src/reminder/reminder.module.ts deleted file mode 100644 index 1a82e5df5..000000000 --- a/apps/calendar/apps/backend/src/reminder/reminder.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ReminderController } from './reminder.controller'; -import { ReminderService } from './reminder.service'; -import { EventModule } from '../event/event.module'; -import { NotificationModule } from '../notification/notification.module'; - -@Module({ - imports: [EventModule, NotificationModule], - controllers: [ReminderController], - providers: [ReminderService], - exports: [ReminderService], -}) -export class ReminderModule {} diff --git a/apps/calendar/apps/backend/src/reminder/reminder.service.spec.ts b/apps/calendar/apps/backend/src/reminder/reminder.service.spec.ts deleted file mode 100644 index aef42b8b4..000000000 --- a/apps/calendar/apps/backend/src/reminder/reminder.service.spec.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { NotFoundException } from '@nestjs/common'; -import { ReminderService } from './reminder.service'; -import { EventService } from '../event/event.service'; -import { EmailService } from '../email/email.service'; -import { NotificationService } from '../notification/notification.service'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { - createMockReminder, - createMockEvent, - TEST_USER_ID, - TEST_USER_EMAIL, -} from '../__tests__/utils/mock-factories'; - -describe('ReminderService', () => { - let service: ReminderService; - let mockDb: any; - let mockEventService: jest.Mocked; - let mockEmailService: jest.Mocked; - let mockNotificationService: jest.Mocked; - - beforeEach(async () => { - mockDb = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - returning: jest.fn(), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - delete: jest.fn().mockReturnThis(), - }; - - mockEventService = { - findByIdOrThrow: jest.fn(), - } as unknown as jest.Mocked; - - mockEmailService = { - sendReminderEmail: jest.fn().mockResolvedValue(true), - } as unknown as jest.Mocked; - - mockNotificationService = { - sendToUser: jest.fn().mockResolvedValue(true), - } as unknown as jest.Mocked; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - ReminderService, - { - provide: DATABASE_CONNECTION, - useValue: mockDb, - }, - { - provide: EventService, - useValue: mockEventService, - }, - { - provide: EmailService, - useValue: mockEmailService, - }, - { - provide: NotificationService, - useValue: mockNotificationService, - }, - ], - }).compile(); - - service = module.get(ReminderService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('findByEvent', () => { - it('should return reminders for an event', async () => { - const event = createMockEvent(); - const reminders = [ - createMockReminder({ eventId: event.id }), - createMockReminder({ eventId: event.id }), - ]; - - mockEventService.findByIdOrThrow.mockResolvedValueOnce(event); - mockDb.where.mockResolvedValueOnce(reminders); - - const result = await service.findByEvent(event.id, TEST_USER_ID); - - expect(result).toEqual(reminders); - expect(mockEventService.findByIdOrThrow).toHaveBeenCalledWith(event.id, TEST_USER_ID); - }); - }); - - describe('findById', () => { - it('should return reminder when found', async () => { - const reminder = createMockReminder(); - mockDb.where.mockResolvedValueOnce([reminder]); - - const result = await service.findById(reminder.id, TEST_USER_ID); - - expect(result).toEqual(reminder); - }); - - it('should return null when reminder not found', async () => { - mockDb.where.mockResolvedValueOnce([]); - - const result = await service.findById('non-existent-id', TEST_USER_ID); - - expect(result).toBeNull(); - }); - }); - - describe('create', () => { - it('should create a new reminder', async () => { - const event = createMockEvent(); - const newReminder = createMockReminder({ eventId: event.id }); - - mockEventService.findByIdOrThrow.mockResolvedValueOnce(event); - mockDb.returning.mockResolvedValueOnce([newReminder]); - - const result = await service.create(TEST_USER_ID, TEST_USER_EMAIL, { - eventId: event.id, - minutesBefore: 15, - notifyPush: true, - notifyEmail: false, - }); - - expect(result).toEqual(newReminder); - expect(mockDb.insert).toHaveBeenCalled(); - }); - - it('should calculate reminder time based on event start time', async () => { - const startTime = new Date('2024-12-15T10:00:00Z'); - const event = createMockEvent({ startTime }); - const newReminder = createMockReminder({ - eventId: event.id, - minutesBefore: 30, - }); - - mockEventService.findByIdOrThrow.mockResolvedValueOnce(event); - mockDb.returning.mockResolvedValueOnce([newReminder]); - - await service.create(TEST_USER_ID, TEST_USER_EMAIL, { - eventId: event.id, - minutesBefore: 30, - }); - - expect(mockDb.values).toHaveBeenCalledWith( - expect.objectContaining({ - minutesBefore: 30, - userEmail: TEST_USER_EMAIL, - }) - ); - }); - }); - - describe('delete', () => { - it('should delete reminder', async () => { - const reminder = createMockReminder(); - mockDb.where.mockResolvedValueOnce([reminder]); - - await service.delete(reminder.id, TEST_USER_ID); - - expect(mockDb.delete).toHaveBeenCalled(); - }); - - it('should throw NotFoundException when deleting non-existent reminder', async () => { - mockDb.where.mockResolvedValueOnce([]); - - await expect(service.delete('non-existent-id', TEST_USER_ID)).rejects.toThrow( - NotFoundException - ); - }); - }); - - describe('getPendingReminders', () => { - it('should return pending reminders due within a minute', async () => { - const reminders = [ - createMockReminder({ status: 'pending' }), - createMockReminder({ status: 'pending' }), - ]; - mockDb.where.mockResolvedValueOnce(reminders); - - const result = await service.getPendingReminders(); - - expect(result).toEqual(reminders); - }); - }); - - describe('markAsSent', () => { - it('should mark reminder as sent with timestamp', async () => { - const reminder = createMockReminder(); - - await service.markAsSent(reminder.id); - - expect(mockDb.update).toHaveBeenCalled(); - expect(mockDb.set).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'sent', - }) - ); - }); - }); - - describe('markAsFailed', () => { - it('should mark reminder as failed', async () => { - const reminder = createMockReminder(); - - await service.markAsFailed(reminder.id, 'Error message'); - - expect(mockDb.update).toHaveBeenCalled(); - expect(mockDb.set).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'failed', - }) - ); - }); - }); - - describe('processReminders', () => { - it('should process pending reminders and send notifications', async () => { - const event = createMockEvent(); - const reminder = createMockReminder({ - eventId: event.id, - notifyPush: true, - notifyEmail: true, - userEmail: TEST_USER_EMAIL, - }); - - // Mock getPendingReminders - mockDb.where.mockResolvedValueOnce([reminder]); - // Mock event lookup - mockDb.where.mockResolvedValueOnce([event]); - - await service.processReminders(); - - expect(mockNotificationService.sendToUser).toHaveBeenCalledWith( - reminder.userId, - expect.objectContaining({ - title: expect.stringContaining('Erinnerung'), - body: expect.any(String), - }) - ); - expect(mockEmailService.sendReminderEmail).toHaveBeenCalledWith( - TEST_USER_EMAIL, - event.title, - expect.any(Date), - reminder.minutesBefore - ); - }); - - it('should mark reminder as failed when event not found', async () => { - const reminder = createMockReminder(); - - // Mock getPendingReminders - mockDb.where.mockResolvedValueOnce([reminder]); - // Mock event lookup - not found - mockDb.where.mockResolvedValueOnce([]); - - await service.processReminders(); - - expect(mockDb.update).toHaveBeenCalled(); - }); - - it('should skip email if userEmail not set', async () => { - const event = createMockEvent(); - const reminder = createMockReminder({ - eventId: event.id, - notifyPush: false, - notifyEmail: true, - userEmail: null, - }); - - mockDb.where.mockResolvedValueOnce([reminder]); - mockDb.where.mockResolvedValueOnce([event]); - - await service.processReminders(); - - expect(mockEmailService.sendReminderEmail).not.toHaveBeenCalled(); - }); - }); - - describe('updateRemindersForEvent', () => { - it('should update reminder times when event time changes', async () => { - const eventId = 'event-123'; - const newStartTime = new Date('2024-12-20T14:00:00Z'); - const reminders = [ - createMockReminder({ eventId, minutesBefore: 15 }), - createMockReminder({ eventId, minutesBefore: 60 }), - ]; - - mockDb.where.mockResolvedValueOnce(reminders); - - await service.updateRemindersForEvent(eventId, newStartTime); - - // Should have updated each reminder - expect(mockDb.update).toHaveBeenCalledTimes(reminders.length); - }); - }); -}); diff --git a/apps/calendar/apps/backend/src/reminder/reminder.service.ts b/apps/calendar/apps/backend/src/reminder/reminder.service.ts deleted file mode 100644 index 3459b95c3..000000000 --- a/apps/calendar/apps/backend/src/reminder/reminder.service.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { Injectable, Inject, NotFoundException, Logger } from '@nestjs/common'; -import { Cron, CronExpression } from '@nestjs/schedule'; -import { eq, and, lte } from 'drizzle-orm'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { Database } from '../db/connection'; -import { reminders } from '../db/schema/reminders.schema'; -import type { Reminder, NewReminder } from '../db/schema/reminders.schema'; -import { events } from '../db/schema/events.schema'; -import { EventService } from '../event/event.service'; -import { EmailService } from '../email/email.service'; -import { NotificationService } from '../notification/notification.service'; -import { CreateReminderDto } from './dto'; - -@Injectable() -export class ReminderService { - private readonly logger = new Logger(ReminderService.name); - - constructor( - @Inject(DATABASE_CONNECTION) private db: Database, - private eventService: EventService, - private emailService: EmailService, - private notificationService: NotificationService - ) {} - - async findByEvent(eventId: string, userId: string): Promise { - // Verify user owns the event - await this.eventService.findByIdOrThrow(eventId, userId); - - return this.db - .select() - .from(reminders) - .where(and(eq(reminders.eventId, eventId), eq(reminders.userId, userId))); - } - - async findById(id: string, userId: string): Promise { - const result = await this.db - .select() - .from(reminders) - .where(and(eq(reminders.id, id), eq(reminders.userId, userId))); - return result[0] || null; - } - - async create(userId: string, userEmail: string, dto: CreateReminderDto): Promise { - // Verify user owns the event and get event details - const event = await this.eventService.findByIdOrThrow(dto.eventId, userId); - - // Calculate reminder time - const eventStartTime = new Date(event.startTime); - const reminderTime = new Date(eventStartTime.getTime() - dto.minutesBefore * 60 * 1000); - - const newReminder: NewReminder = { - eventId: dto.eventId, - userId, - userEmail, - minutesBefore: dto.minutesBefore, - reminderTime, - notifyPush: dto.notifyPush ?? true, - notifyEmail: dto.notifyEmail ?? false, - status: 'pending', - }; - - const [created] = await this.db.insert(reminders).values(newReminder).returning(); - return created; - } - - async delete(id: string, userId: string): Promise { - const reminder = await this.findById(id, userId); - if (!reminder) { - throw new NotFoundException(`Reminder with id ${id} not found`); - } - - await this.db.delete(reminders).where(and(eq(reminders.id, id), eq(reminders.userId, userId))); - } - - async getPendingReminders(): Promise { - const now = new Date(); - // Get reminders that are due within the next minute - const oneMinuteFromNow = new Date(now.getTime() + 60 * 1000); - - return this.db - .select() - .from(reminders) - .where(and(eq(reminders.status, 'pending'), lte(reminders.reminderTime, oneMinuteFromNow))); - } - - async markAsSent(id: string): Promise { - await this.db - .update(reminders) - .set({ status: 'sent', sentAt: new Date() }) - .where(eq(reminders.id, id)); - } - - async markAsFailed(id: string, error: string): Promise { - await this.db.update(reminders).set({ status: 'failed' }).where(eq(reminders.id, id)); - this.logger.error(`Reminder ${id} failed: ${error}`); - } - - /** - * Process pending reminders every minute - * Sends push notifications and emails based on reminder settings - */ - @Cron(CronExpression.EVERY_MINUTE) - async processReminders() { - const pendingReminders = await this.getPendingReminders(); - - if (pendingReminders.length === 0) { - return; - } - - this.logger.log(`Processing ${pendingReminders.length} pending reminders`); - - for (const reminder of pendingReminders) { - try { - // Get event details for the notification - const eventResult = await this.db - .select() - .from(events) - .where(eq(events.id, reminder.eventId)); - - if (eventResult.length === 0) { - await this.markAsFailed(reminder.id, 'Event not found'); - continue; - } - - const event = eventResult[0]; - let pushSent = false; - let emailSent = false; - - // Send push notification - if (reminder.notifyPush) { - try { - pushSent = await this.notificationService.sendToUser(reminder.userId, { - title: `Erinnerung: ${event.title}`, - body: this.formatReminderBody(event.title, reminder.minutesBefore), - data: { - type: 'reminder', - eventId: event.id, - reminderId: reminder.id, - }, - }); - - if (pushSent) { - this.logger.log(`Push notification sent for reminder ${reminder.id}`); - } - } catch (error) { - this.logger.error(`Push notification failed for reminder ${reminder.id}:`, error); - } - } - - // Send email notification - if (reminder.notifyEmail && reminder.userEmail) { - try { - emailSent = await this.emailService.sendReminderEmail( - reminder.userEmail, - event.title, - new Date(event.startTime), - reminder.minutesBefore - ); - - if (emailSent) { - this.logger.log(`Email sent for reminder ${reminder.id}`); - } - } catch (error) { - this.logger.error(`Email notification failed for reminder ${reminder.id}:`, error); - } - } - - // Mark as sent if at least one notification was attempted - // (even if user has no push tokens, we consider it "sent" for push-only reminders) - if (!reminder.notifyPush || pushSent || !reminder.notifyEmail || emailSent) { - await this.markAsSent(reminder.id); - } else { - await this.markAsFailed(reminder.id, 'All notification channels failed'); - } - } catch (error) { - await this.markAsFailed(reminder.id, (error as Error).message); - } - } - } - - /** - * Update reminders when an event is updated - */ - async updateRemindersForEvent(eventId: string, newStartTime: Date): Promise { - const eventReminders = await this.db - .select() - .from(reminders) - .where(and(eq(reminders.eventId, eventId), eq(reminders.status, 'pending'))); - - for (const reminder of eventReminders) { - const newReminderTime = new Date(newStartTime.getTime() - reminder.minutesBefore * 60 * 1000); - - await this.db - .update(reminders) - .set({ reminderTime: newReminderTime }) - .where(eq(reminders.id, reminder.id)); - } - } - - private formatReminderBody(eventTitle: string, minutesBefore: number): string { - if (minutesBefore === 0) { - return `"${eventTitle}" beginnt jetzt`; - } - if (minutesBefore < 60) { - return `"${eventTitle}" beginnt in ${minutesBefore} Minuten`; - } - if (minutesBefore < 1440) { - const hours = Math.round(minutesBefore / 60); - return `"${eventTitle}" beginnt in ${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}`; - } - const days = Math.round(minutesBefore / 1440); - return `"${eventTitle}" beginnt in ${days} ${days === 1 ? 'Tag' : 'Tagen'}`; - } -} diff --git a/apps/calendar/apps/backend/src/share/dto/create-share.dto.ts b/apps/calendar/apps/backend/src/share/dto/create-share.dto.ts deleted file mode 100644 index 2ef3d78f8..000000000 --- a/apps/calendar/apps/backend/src/share/dto/create-share.dto.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { - IsString, - IsOptional, - IsBoolean, - IsIn, - IsEmail, - IsDateString, - IsUUID, -} from 'class-validator'; - -export class CreateShareDto { - @IsUUID() - calendarId: string; - - @IsOptional() - @IsEmail() - email?: string; - - @IsIn(['read', 'write', 'admin']) - permission: 'read' | 'write' | 'admin'; - - @IsOptional() - @IsBoolean() - createLink?: boolean; - - @IsOptional() - @IsDateString() - expiresAt?: string; -} diff --git a/apps/calendar/apps/backend/src/share/dto/index.ts b/apps/calendar/apps/backend/src/share/dto/index.ts deleted file mode 100644 index 88199ac65..000000000 --- a/apps/calendar/apps/backend/src/share/dto/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './create-share.dto'; -export * from './update-share.dto'; diff --git a/apps/calendar/apps/backend/src/share/dto/update-share.dto.ts b/apps/calendar/apps/backend/src/share/dto/update-share.dto.ts deleted file mode 100644 index 017b36d11..000000000 --- a/apps/calendar/apps/backend/src/share/dto/update-share.dto.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IsOptional, IsIn, IsDateString } from 'class-validator'; - -export class UpdateShareDto { - @IsOptional() - @IsIn(['read', 'write', 'admin']) - permission?: 'read' | 'write' | 'admin'; - - @IsOptional() - @IsDateString() - expiresAt?: string | null; -} diff --git a/apps/calendar/apps/backend/src/share/share.controller.ts b/apps/calendar/apps/backend/src/share/share.controller.ts deleted file mode 100644 index 9e01b29cc..000000000 --- a/apps/calendar/apps/backend/src/share/share.controller.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common'; -import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; -import { ShareService } from './share.service'; -import { CreateShareDto, UpdateShareDto } from './dto'; - -@Controller() -@UseGuards(JwtAuthGuard) -export class ShareController { - constructor(private readonly shareService: ShareService) {} - - @Get('calendars/:calendarId/shares') - async findByCalendar( - @CurrentUser() user: CurrentUserData, - @Param('calendarId') calendarId: string - ) { - const shares = await this.shareService.findByCalendar(calendarId, user.userId); - return { shares }; - } - - @Post('calendars/:calendarId/shares') - async create( - @CurrentUser() user: CurrentUserData, - @Param('calendarId') calendarId: string, - @Body() dto: Omit - ) { - const share = await this.shareService.create(user.userId, user.email, { - ...dto, - calendarId, - }); - return { share }; - } - - @Put('calendars/:calendarId/shares/:shareId') - async update( - @CurrentUser() user: CurrentUserData, - @Param('shareId') shareId: string, - @Body() dto: UpdateShareDto - ) { - const share = await this.shareService.update(shareId, user.userId, dto); - return { share }; - } - - @Delete('calendars/:calendarId/shares/:shareId') - async delete(@CurrentUser() user: CurrentUserData, @Param('shareId') shareId: string) { - await this.shareService.delete(shareId, user.userId); - return { success: true }; - } - - @Get('shares/invitations') - async getInvitations(@CurrentUser() user: CurrentUserData) { - const invitations = await this.shareService.findPendingInvitations( - user.userId, - user.email || '' - ); - return { invitations }; - } - - @Post('shares/:shareId/accept') - async acceptInvitation(@CurrentUser() user: CurrentUserData, @Param('shareId') shareId: string) { - const share = await this.shareService.acceptInvitation(shareId, user.userId, user.email); - return { share }; - } - - @Post('shares/:shareId/decline') - async declineInvitation(@CurrentUser() user: CurrentUserData, @Param('shareId') shareId: string) { - const share = await this.shareService.declineInvitation(shareId, user.userId); - return { share }; - } - - @Get('shares/shared-with-me') - async getSharedCalendars(@CurrentUser() user: CurrentUserData) { - const shares = await this.shareService.getSharedCalendarsForUser(user.userId); - return { shares }; - } -} diff --git a/apps/calendar/apps/backend/src/share/share.module.ts b/apps/calendar/apps/backend/src/share/share.module.ts deleted file mode 100644 index 0f38780af..000000000 --- a/apps/calendar/apps/backend/src/share/share.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ShareController } from './share.controller'; -import { ShareService } from './share.service'; -import { CalendarModule } from '../calendar/calendar.module'; - -@Module({ - imports: [CalendarModule], - controllers: [ShareController], - providers: [ShareService], - exports: [ShareService], -}) -export class ShareModule {} diff --git a/apps/calendar/apps/backend/src/share/share.service.spec.ts b/apps/calendar/apps/backend/src/share/share.service.spec.ts deleted file mode 100644 index 6e9902d08..000000000 --- a/apps/calendar/apps/backend/src/share/share.service.spec.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { NotFoundException, ForbiddenException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { ShareService } from './share.service'; -import { CalendarService } from '../calendar/calendar.service'; -import { EmailService } from '../email/email.service'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { - createMockCalendarShare, - createMockCalendar, - TEST_USER_ID, - TEST_USER_EMAIL, -} from '../__tests__/utils/mock-factories'; - -describe('ShareService', () => { - let service: ShareService; - let mockDb: any; - let mockCalendarService: jest.Mocked; - let mockEmailService: jest.Mocked; - - beforeEach(async () => { - mockDb = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - returning: jest.fn(), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - delete: jest.fn().mockReturnThis(), - }; - - mockCalendarService = { - findByIdOrThrow: jest.fn(), - } as unknown as jest.Mocked; - - mockEmailService = { - sendCalendarInvitationEmail: jest.fn().mockResolvedValue(true), - } as unknown as jest.Mocked; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - ShareService, - { - provide: DATABASE_CONNECTION, - useValue: mockDb, - }, - { - provide: CalendarService, - useValue: mockCalendarService, - }, - { - provide: EmailService, - useValue: mockEmailService, - }, - { - provide: ConfigService, - useValue: { get: jest.fn().mockReturnValue('http://localhost:5179') }, - }, - ], - }).compile(); - - service = module.get(ShareService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('findByCalendar', () => { - it('should return shares for a calendar', async () => { - const calendar = createMockCalendar(); - const shares = [ - createMockCalendarShare({ calendarId: calendar.id }), - createMockCalendarShare({ calendarId: calendar.id }), - ]; - - mockCalendarService.findByIdOrThrow.mockResolvedValueOnce(calendar); - mockDb.where.mockResolvedValueOnce(shares); - - const result = await service.findByCalendar(calendar.id, TEST_USER_ID); - - expect(result).toEqual(shares); - expect(mockCalendarService.findByIdOrThrow).toHaveBeenCalledWith(calendar.id, TEST_USER_ID); - }); - }); - - describe('findById', () => { - it('should return share when found', async () => { - const share = createMockCalendarShare(); - mockDb.where.mockResolvedValueOnce([share]); - - const result = await service.findById(share.id); - - expect(result).toEqual(share); - }); - - it('should return null when share not found', async () => { - mockDb.where.mockResolvedValueOnce([]); - - const result = await service.findById('non-existent-id'); - - expect(result).toBeNull(); - }); - }); - - describe('create', () => { - it('should create share with email and send invitation', async () => { - const calendar = createMockCalendar(); - const newShare = createMockCalendarShare({ - calendarId: calendar.id, - sharedWithEmail: 'invited@example.com', - }); - - mockCalendarService.findByIdOrThrow.mockResolvedValueOnce(calendar); - mockDb.returning.mockResolvedValueOnce([newShare]); - - const result = await service.create(TEST_USER_ID, TEST_USER_EMAIL, { - calendarId: calendar.id, - email: 'invited@example.com', - permission: 'read', - }); - - expect(result).toEqual(newShare); - expect(mockEmailService.sendCalendarInvitationEmail).toHaveBeenCalledWith( - 'invited@example.com', - calendar.name, - expect.any(String), - 'read', - expect.stringContaining('/shares/') - ); - }); - - it('should create shareable link when createLink is true', async () => { - const calendar = createMockCalendar(); - const newShare = createMockCalendarShare({ - calendarId: calendar.id, - shareToken: expect.any(String), - shareUrl: expect.stringContaining('/share/'), - }); - - mockCalendarService.findByIdOrThrow.mockResolvedValueOnce(calendar); - mockDb.returning.mockResolvedValueOnce([newShare]); - - const result = await service.create(TEST_USER_ID, TEST_USER_EMAIL, { - calendarId: calendar.id, - createLink: true, - permission: 'read', - }); - - expect(result).toEqual(newShare); - expect(mockDb.values).toHaveBeenCalledWith( - expect.objectContaining({ - shareToken: expect.any(String), - }) - ); - // Should not send email for link sharing - expect(mockEmailService.sendCalendarInvitationEmail).not.toHaveBeenCalled(); - }); - - it('should set expiration date when provided', async () => { - const calendar = createMockCalendar(); - const expiresAt = '2024-12-31T23:59:59Z'; - const newShare = createMockCalendarShare({ - calendarId: calendar.id, - expiresAt: new Date(expiresAt), - }); - - mockCalendarService.findByIdOrThrow.mockResolvedValueOnce(calendar); - mockDb.returning.mockResolvedValueOnce([newShare]); - - await service.create(TEST_USER_ID, TEST_USER_EMAIL, { - calendarId: calendar.id, - email: 'invited@example.com', - permission: 'read', - expiresAt, - }); - - expect(mockDb.values).toHaveBeenCalledWith( - expect.objectContaining({ - expiresAt: expect.any(Date), - }) - ); - }); - }); - - describe('update', () => { - it('should update share permissions', async () => { - const share = createMockCalendarShare({ permission: 'read' }); - const calendar = createMockCalendar({ id: share.calendarId }); - const updatedShare = { ...share, permission: 'write' }; - - mockDb.where.mockResolvedValueOnce([share]); - mockCalendarService.findByIdOrThrow.mockResolvedValueOnce(calendar); - mockDb.returning.mockResolvedValueOnce([updatedShare]); - - const result = await service.update(share.id, TEST_USER_ID, { - permission: 'write', - }); - - expect(result.permission).toBe('write'); - }); - - it('should throw NotFoundException when share not found', async () => { - mockDb.where.mockResolvedValueOnce([]); - - await expect( - service.update('non-existent-id', TEST_USER_ID, { permission: 'write' }) - ).rejects.toThrow(NotFoundException); - }); - }); - - describe('delete', () => { - it('should delete share', async () => { - const share = createMockCalendarShare(); - const calendar = createMockCalendar({ id: share.calendarId }); - - mockDb.where.mockResolvedValueOnce([share]); - mockCalendarService.findByIdOrThrow.mockResolvedValueOnce(calendar); - - await service.delete(share.id, TEST_USER_ID); - - expect(mockDb.delete).toHaveBeenCalled(); - }); - - it('should throw NotFoundException when deleting non-existent share', async () => { - mockDb.where.mockResolvedValueOnce([]); - - await expect(service.delete('non-existent-id', TEST_USER_ID)).rejects.toThrow( - NotFoundException - ); - }); - }); - - describe('acceptInvitation', () => { - it('should accept pending invitation', async () => { - const share = createMockCalendarShare({ status: 'pending' }); - const acceptedShare = { - ...share, - status: 'accepted', - sharedWithUserId: TEST_USER_ID, - acceptedAt: new Date(), - }; - - mockDb.where.mockResolvedValueOnce([share]); - mockDb.returning.mockResolvedValueOnce([acceptedShare]); - - const result = await service.acceptInvitation(share.id, TEST_USER_ID, 'shared@example.com'); - - expect(result.status).toBe('accepted'); - expect(result.sharedWithUserId).toBe(TEST_USER_ID); - }); - - it('should throw NotFoundException when invitation not found', async () => { - mockDb.where.mockResolvedValueOnce([]); - - await expect( - service.acceptInvitation('non-existent-id', TEST_USER_ID, 'shared@example.com') - ).rejects.toThrow(NotFoundException); - }); - - it('should throw ForbiddenException when invitation already processed', async () => { - const share = createMockCalendarShare({ status: 'accepted' }); - mockDb.where.mockResolvedValueOnce([share]); - - await expect( - service.acceptInvitation(share.id, TEST_USER_ID, 'shared@example.com') - ).rejects.toThrow(ForbiddenException); - }); - }); - - describe('declineInvitation', () => { - it('should decline pending invitation', async () => { - const share = createMockCalendarShare({ status: 'pending' }); - const declinedShare = { ...share, status: 'declined' }; - - mockDb.where.mockResolvedValueOnce([share]); - mockDb.returning.mockResolvedValueOnce([declinedShare]); - - const result = await service.declineInvitation(share.id, TEST_USER_ID); - - expect(result.status).toBe('declined'); - }); - - it('should throw ForbiddenException when invitation already processed', async () => { - const share = createMockCalendarShare({ status: 'declined' }); - mockDb.where.mockResolvedValueOnce([share]); - - await expect(service.declineInvitation(share.id, TEST_USER_ID)).rejects.toThrow( - ForbiddenException - ); - }); - }); - - describe('findByShareToken', () => { - it('should return share for valid token', async () => { - const share = createMockCalendarShare({ - shareToken: 'valid-token-123', - }); - mockDb.where.mockResolvedValueOnce([share]); - - const result = await service.findByShareToken('valid-token-123'); - - expect(result).toEqual(share); - }); - - it('should return null for invalid token', async () => { - mockDb.where.mockResolvedValueOnce([]); - - const result = await service.findByShareToken('invalid-token'); - - expect(result).toBeNull(); - }); - }); - - describe('getSharedCalendarsForUser', () => { - it('should return accepted shares for user', async () => { - const shares = [ - createMockCalendarShare({ sharedWithUserId: TEST_USER_ID, status: 'accepted' }), - createMockCalendarShare({ sharedWithUserId: TEST_USER_ID, status: 'accepted' }), - ]; - mockDb.where.mockResolvedValueOnce(shares); - - const result = await service.getSharedCalendarsForUser(TEST_USER_ID); - - expect(result).toEqual(shares); - expect(result.every((s) => s.status === 'accepted')).toBe(true); - }); - }); -}); diff --git a/apps/calendar/apps/backend/src/share/share.service.ts b/apps/calendar/apps/backend/src/share/share.service.ts deleted file mode 100644 index de5afbd35..000000000 --- a/apps/calendar/apps/backend/src/share/share.service.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { Injectable, Inject, NotFoundException, ForbiddenException, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { eq, and, or } from 'drizzle-orm'; -import { randomBytes } from 'crypto'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { Database } from '../db/connection'; -import { - calendarShares, - type CalendarShare, - type NewCalendarShare, -} from '../db/schema/calendar-shares.schema'; -import { CalendarService } from '../calendar/calendar.service'; -import { EmailService } from '../email/email.service'; -import { CreateShareDto, UpdateShareDto } from './dto'; - -@Injectable() -export class ShareService { - private readonly logger = new Logger(ShareService.name); - - constructor( - @Inject(DATABASE_CONNECTION) private db: Database, - private calendarService: CalendarService, - private emailService: EmailService, - private configService: ConfigService - ) {} - - async findByCalendar(calendarId: string, userId: string): Promise { - // Verify user owns the calendar - await this.calendarService.findByIdOrThrow(calendarId, userId); - - return this.db.select().from(calendarShares).where(eq(calendarShares.calendarId, calendarId)); - } - - async findById(id: string): Promise { - const result = await this.db.select().from(calendarShares).where(eq(calendarShares.id, id)); - return result[0] || null; - } - - async findPendingInvitations(userId: string, email: string): Promise { - return this.db - .select() - .from(calendarShares) - .where( - and( - eq(calendarShares.status, 'pending'), - or(eq(calendarShares.sharedWithUserId, userId), eq(calendarShares.sharedWithEmail, email)) - ) - ); - } - - async create(userId: string, inviterEmail: string, dto: CreateShareDto): Promise { - // Verify user owns the calendar - const calendar = await this.calendarService.findByIdOrThrow(dto.calendarId, userId); - - const newShare: NewCalendarShare = { - calendarId: dto.calendarId, - permission: dto.permission, - invitedBy: userId, - status: 'pending', - }; - - if (dto.createLink) { - // Create a shareable link - const token = randomBytes(32).toString('hex'); - newShare.shareToken = token; - newShare.shareUrl = `/share/${token}`; - } else if (dto.email) { - // Share with specific email - newShare.sharedWithEmail = dto.email; - } - - if (dto.expiresAt) { - newShare.expiresAt = new Date(dto.expiresAt); - } - - const [created] = await this.db.insert(calendarShares).values(newShare).returning(); - - // Send invitation email if sharing with specific email - if (dto.email && !dto.createLink) { - const inviterName = inviterEmail.split('@')[0]; - const baseUrl = this.configService.get('FRONTEND_URL', 'http://localhost:5179'); - const acceptUrl = `${baseUrl}/shares/${created.id}/accept`; - - try { - await this.emailService.sendCalendarInvitationEmail( - dto.email, - calendar.name, - inviterName, - dto.permission, - acceptUrl - ); - this.logger.log(`Invitation email sent to ${dto.email} for calendar ${calendar.name}`); - } catch (error) { - this.logger.error(`Failed to send invitation email to ${dto.email}:`, error); - // Don't fail the share creation if email fails - } - } - - return created; - } - - async update(id: string, userId: string, dto: UpdateShareDto): Promise { - const share = await this.findById(id); - if (!share) { - throw new NotFoundException(`Share with id ${id} not found`); - } - - // Verify user owns the calendar - await this.calendarService.findByIdOrThrow(share.calendarId, userId); - - const [updated] = await this.db - .update(calendarShares) - .set({ - permission: dto.permission, - expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : undefined, - updatedAt: new Date(), - }) - .where(eq(calendarShares.id, id)) - .returning(); - - return updated; - } - - async delete(id: string, userId: string): Promise { - const share = await this.findById(id); - if (!share) { - throw new NotFoundException(`Share with id ${id} not found`); - } - - // Verify user owns the calendar - await this.calendarService.findByIdOrThrow(share.calendarId, userId); - - await this.db.delete(calendarShares).where(eq(calendarShares.id, id)); - } - - async acceptInvitation(shareId: string, userId: string, email: string): Promise { - const share = await this.findById(shareId); - if (!share) { - throw new NotFoundException(`Invitation not found`); - } - - if (share.status !== 'pending') { - throw new ForbiddenException('Invitation has already been processed'); - } - - // Validate that the accepting user matches the invitation target - const matchesUserId = share.sharedWithUserId && share.sharedWithUserId === userId; - const matchesEmail = share.sharedWithEmail && share.sharedWithEmail === email; - if (share.sharedWithUserId || share.sharedWithEmail) { - if (!matchesUserId && !matchesEmail) { - throw new ForbiddenException('You are not the intended recipient of this invitation'); - } - } - - const [updated] = await this.db - .update(calendarShares) - .set({ - status: 'accepted', - sharedWithUserId: userId, - acceptedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(calendarShares.id, shareId)) - .returning(); - - return updated; - } - - async declineInvitation(shareId: string, userId: string): Promise { - const share = await this.findById(shareId); - if (!share) { - throw new NotFoundException(`Invitation not found`); - } - - if (share.status !== 'pending') { - throw new ForbiddenException('Invitation has already been processed'); - } - - const [updated] = await this.db - .update(calendarShares) - .set({ - status: 'declined', - sharedWithUserId: userId, - updatedAt: new Date(), - }) - .where(eq(calendarShares.id, shareId)) - .returning(); - - return updated; - } - - async findByShareToken(token: string): Promise { - const result = await this.db - .select() - .from(calendarShares) - .where(eq(calendarShares.shareToken, token)); - return result[0] || null; - } - - async getSharedCalendarsForUser(userId: string): Promise { - return this.db - .select() - .from(calendarShares) - .where( - and(eq(calendarShares.sharedWithUserId, userId), eq(calendarShares.status, 'accepted')) - ); - } -} diff --git a/apps/calendar/apps/backend/src/sync/caldav.service.ts b/apps/calendar/apps/backend/src/sync/caldav.service.ts deleted file mode 100644 index a7a8425a0..000000000 --- a/apps/calendar/apps/backend/src/sync/caldav.service.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { DAVClient, DAVCalendar, DAVCalendarObject } from 'tsdav'; -import { ICalService, ParsedEvent } from './ical.service'; - -export interface CalDavCalendar { - url: string; - displayName: string; - description?: string; - color?: string; - ctag?: string; -} - -export interface CalDavSyncResult { - events: ParsedEvent[]; - ctag?: string; - etag?: string; -} - -@Injectable() -export class CalDavService { - private readonly logger = new Logger(CalDavService.name); - - constructor(private readonly icalService: ICalService) {} - - /** - * Create a DAVClient for CalDAV operations - */ - private async createClient( - serverUrl: string, - username: string, - password: string - ): Promise { - const client = new DAVClient({ - serverUrl, - credentials: { - username, - password, - }, - authMethod: 'Basic', - defaultAccountType: 'caldav', - }); - - await client.login(); - return client; - } - - /** - * Discover available calendars on a CalDAV server - */ - async discoverCalendars( - serverUrl: string, - username: string, - password: string - ): Promise { - try { - const client = await this.createClient(serverUrl, username, password); - const calendars = await client.fetchCalendars(); - - return calendars.map((cal: DAVCalendar) => ({ - url: cal.url, - displayName: String(cal.displayName || 'Unnamed Calendar'), - description: cal.description as string | undefined, - color: this.extractColor(cal), - ctag: cal.ctag, - })); - } catch (error) { - this.logger.error(`Failed to discover calendars: ${error}`); - throw new Error( - `CalDAV discovery failed: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } - } - - /** - * Fetch all events from a CalDAV calendar - */ - async fetchEvents( - serverUrl: string, - calendarUrl: string, - username: string, - password: string, - startDate?: Date, - endDate?: Date - ): Promise { - try { - const client = await this.createClient(serverUrl, username, password); - - // Get calendar metadata - const calendars = await client.fetchCalendars(); - const calendar = calendars.find((c: DAVCalendar) => c.url === calendarUrl); - - if (!calendar) { - throw new Error('Calendar not found'); - } - - // Fetch calendar objects - const objects = await client.fetchCalendarObjects({ - calendar, - timeRange: - startDate && endDate - ? { - start: startDate.toISOString(), - end: endDate.toISOString(), - } - : undefined, - }); - - // Parse all events - const events: ParsedEvent[] = []; - for (const obj of objects) { - if (obj.data) { - try { - const parsed = this.icalService.parseICalData(obj.data); - events.push(...parsed); - } catch (error) { - this.logger.warn(`Failed to parse calendar object: ${error}`); - } - } - } - - return { - events, - ctag: calendar.ctag, - }; - } catch (error) { - this.logger.error(`Failed to fetch CalDAV events: ${error}`); - throw error; - } - } - - /** - * Create or update an event on a CalDAV server - */ - async upsertEvent( - serverUrl: string, - calendarUrl: string, - username: string, - password: string, - event: { - uid: string; - title: string; - description?: string; - location?: string; - startTime: Date; - endTime: Date; - isAllDay?: boolean; - recurrenceRule?: string; - } - ): Promise<{ etag?: string }> { - try { - const client = await this.createClient(serverUrl, username, password); - - const calendars = await client.fetchCalendars(); - const calendar = calendars.find((c: DAVCalendar) => c.url === calendarUrl); - - if (!calendar) { - throw new Error('Calendar not found'); - } - - // Generate iCal data for this single event - const icalData = this.icalService.generateICalData('Event', [ - { - id: event.uid, - title: event.title, - description: event.description, - location: event.location, - startTime: event.startTime, - endTime: event.endTime, - isAllDay: event.isAllDay, - recurrenceRule: event.recurrenceRule, - }, - ]); - - // Create or update the event - const eventUrl = `${calendarUrl}${event.uid}.ics`; - - const result = await client.createCalendarObject({ - calendar, - filename: `${event.uid}.ics`, - iCalString: icalData, - }); - - return { etag: (result as { etag?: string } | undefined)?.etag }; - } catch (error) { - this.logger.error(`Failed to upsert CalDAV event: ${error}`); - throw error; - } - } - - /** - * Delete an event from a CalDAV server - */ - async deleteEvent( - serverUrl: string, - calendarUrl: string, - username: string, - password: string, - uid: string - ): Promise { - try { - const client = await this.createClient(serverUrl, username, password); - - const calendars = await client.fetchCalendars(); - const calendar = calendars.find((c: DAVCalendar) => c.url === calendarUrl); - - if (!calendar) { - throw new Error('Calendar not found'); - } - - // Find the calendar object - const objects = await client.fetchCalendarObjects({ calendar }); - const calendarObject = objects.find((obj: DAVCalendarObject) => { - if (!obj.data) return false; - try { - const parsed = this.icalService.parseICalData(obj.data); - return parsed.some((e) => e.uid === uid); - } catch { - return false; - } - }); - - if (calendarObject) { - await client.deleteCalendarObject({ - calendarObject, - }); - } - } catch (error) { - this.logger.error(`Failed to delete CalDAV event: ${error}`); - throw error; - } - } - - /** - * Check if calendar has changes (using ctag) - */ - async hasChanges( - serverUrl: string, - calendarUrl: string, - username: string, - password: string, - lastCtag?: string - ): Promise<{ hasChanges: boolean; ctag?: string }> { - try { - const client = await this.createClient(serverUrl, username, password); - const calendars = await client.fetchCalendars(); - const calendar = calendars.find((c: DAVCalendar) => c.url === calendarUrl); - - if (!calendar) { - throw new Error('Calendar not found'); - } - - const currentCtag = calendar.ctag; - const hasChanges = !lastCtag || lastCtag !== currentCtag; - - return { hasChanges, ctag: currentCtag }; - } catch (error) { - this.logger.error(`Failed to check CalDAV changes: ${error}`); - throw error; - } - } - - /** - * Get Apple CalDAV server URL - */ - getAppleCalDavUrl(): string { - return 'https://caldav.icloud.com'; - } - - /** - * Get Google CalDAV server URL - */ - getGoogleCalDavUrl(): string { - return 'https://apidata.googleusercontent.com/caldav/v2'; - } - - /** - * Extract color from CalDAV calendar properties - */ - private extractColor(calendar: DAVCalendar): string | undefined { - // CalDAV calendar-color is typically in the props - const calWithProps = calendar as DAVCalendar & { props?: Record }; - if (calWithProps.props && typeof calWithProps.props['calendar-color'] === 'string') { - return calWithProps.props['calendar-color']; - } - return undefined; - } -} diff --git a/apps/calendar/apps/backend/src/sync/dto/index.ts b/apps/calendar/apps/backend/src/sync/dto/index.ts deleted file mode 100644 index f6931d51d..000000000 --- a/apps/calendar/apps/backend/src/sync/dto/index.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { IsString, IsOptional, IsEnum, IsInt, Min, Max, IsUrl, IsBoolean } from 'class-validator'; - -export class ConnectCalendarDto { - @IsString() - name!: string; - - @IsEnum(['google', 'apple', 'caldav', 'ical_url']) - provider!: 'google' | 'apple' | 'caldav' | 'ical_url'; - - @IsUrl() - calendarUrl!: string; - - @IsOptional() - @IsString() - username?: string; - - @IsOptional() - @IsString() - password?: string; - - @IsOptional() - @IsString() - accessToken?: string; - - @IsOptional() - @IsString() - refreshToken?: string; - - @IsOptional() - @IsEnum(['import', 'export', 'both']) - syncDirection?: 'import' | 'export' | 'both'; - - @IsOptional() - @IsInt() - @Min(5) - @Max(1440) - syncInterval?: number; - - @IsOptional() - @IsString() - color?: string; -} - -export class UpdateExternalCalendarDto { - @IsOptional() - @IsString() - name?: string; - - @IsOptional() - @IsEnum(['import', 'export', 'both']) - syncDirection?: 'import' | 'export' | 'both'; - - @IsOptional() - @IsInt() - @Min(5) - @Max(1440) - syncInterval?: number; - - @IsOptional() - @IsBoolean() - syncEnabled?: boolean; - - @IsOptional() - @IsString() - color?: string; - - @IsOptional() - @IsBoolean() - isVisible?: boolean; -} - -export class DiscoverCalDavDto { - @IsUrl() - serverUrl!: string; - - @IsString() - username!: string; - - @IsString() - password!: string; -} - -export class GoogleOAuthCallbackDto { - @IsString() - code!: string; - - @IsOptional() - @IsString() - state?: string; -} diff --git a/apps/calendar/apps/backend/src/sync/google-calendar.service.ts b/apps/calendar/apps/backend/src/sync/google-calendar.service.ts deleted file mode 100644 index 5b9bfb204..000000000 --- a/apps/calendar/apps/backend/src/sync/google-calendar.service.ts +++ /dev/null @@ -1,418 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { ParsedEvent } from './ical.service'; - -interface GoogleCalendarEvent { - id: string; - summary: string; - description?: string; - location?: string; - start: { - dateTime?: string; - date?: string; - timeZone?: string; - }; - end: { - dateTime?: string; - date?: string; - timeZone?: string; - }; - recurrence?: string[]; - status?: string; - organizer?: { - email: string; - displayName?: string; - }; - attendees?: Array<{ - email: string; - displayName?: string; - responseStatus?: string; - }>; - updated?: string; - created?: string; -} - -interface GoogleCalendarList { - id: string; - summary: string; - description?: string; - backgroundColor?: string; - primary?: boolean; -} - -interface TokenResponse { - access_token: string; - refresh_token?: string; - expires_in: number; - token_type: string; -} - -@Injectable() -export class GoogleCalendarService { - private readonly logger = new Logger(GoogleCalendarService.name); - private readonly clientId: string; - private readonly clientSecret: string; - private readonly redirectUri: string; - - constructor(private readonly configService: ConfigService) { - this.clientId = this.configService.get('GOOGLE_CLIENT_ID') || ''; - this.clientSecret = this.configService.get('GOOGLE_CLIENT_SECRET') || ''; - this.redirectUri = this.configService.get('GOOGLE_REDIRECT_URI') || ''; - } - - /** - * Check if Google Calendar is configured - */ - isConfigured(): boolean { - return !!(this.clientId && this.clientSecret && this.redirectUri); - } - - /** - * Get OAuth2 authorization URL - */ - getAuthUrl(state?: string): string { - const params = new URLSearchParams({ - client_id: this.clientId, - redirect_uri: this.redirectUri, - response_type: 'code', - scope: [ - 'https://www.googleapis.com/auth/calendar.readonly', - 'https://www.googleapis.com/auth/calendar.events', - ].join(' '), - access_type: 'offline', - prompt: 'consent', - }); - - if (state) { - params.set('state', state); - } - - return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`; - } - - /** - * Exchange authorization code for tokens - */ - async exchangeCodeForTokens(code: string): Promise { - const response = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - client_id: this.clientId, - client_secret: this.clientSecret, - code, - grant_type: 'authorization_code', - redirect_uri: this.redirectUri, - }), - }); - - if (!response.ok) { - const error = await response.text(); - this.logger.error(`Token exchange failed: ${error}`); - throw new Error('Failed to exchange authorization code'); - } - - return response.json(); - } - - /** - * Refresh access token - */ - async refreshAccessToken(refreshToken: string): Promise { - const response = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - client_id: this.clientId, - client_secret: this.clientSecret, - refresh_token: refreshToken, - grant_type: 'refresh_token', - }), - }); - - if (!response.ok) { - const error = await response.text(); - this.logger.error(`Token refresh failed: ${error}`); - throw new Error('Failed to refresh access token'); - } - - return response.json(); - } - - /** - * List available calendars - */ - async listCalendars(accessToken: string): Promise { - const response = await fetch('https://www.googleapis.com/calendar/v3/users/me/calendarList', { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); - - if (!response.ok) { - throw new Error(`Failed to list calendars: ${response.status}`); - } - - const data = await response.json(); - return data.items || []; - } - - /** - * Fetch events from a calendar - */ - async fetchEvents( - accessToken: string, - calendarId: string, - startDate?: Date, - endDate?: Date - ): Promise { - const params = new URLSearchParams({ - singleEvents: 'true', - orderBy: 'startTime', - maxResults: '2500', - }); - - if (startDate) { - params.set('timeMin', startDate.toISOString()); - } - if (endDate) { - params.set('timeMax', endDate.toISOString()); - } - - const response = await fetch( - `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?${params}`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - } - ); - - if (!response.ok) { - throw new Error(`Failed to fetch events: ${response.status}`); - } - - const data = await response.json(); - const googleEvents: GoogleCalendarEvent[] = data.items || []; - - return googleEvents.map((event) => this.convertGoogleEvent(event)); - } - - /** - * Create an event in Google Calendar - */ - async createEvent( - accessToken: string, - calendarId: string, - event: { - title: string; - description?: string; - location?: string; - startTime: Date; - endTime: Date; - isAllDay?: boolean; - recurrenceRule?: string; - } - ): Promise<{ id: string }> { - const googleEvent: Partial = { - summary: event.title, - description: event.description, - location: event.location, - }; - - if (event.isAllDay) { - googleEvent.start = { date: event.startTime.toISOString().split('T')[0] }; - googleEvent.end = { date: event.endTime.toISOString().split('T')[0] }; - } else { - googleEvent.start = { dateTime: event.startTime.toISOString() }; - googleEvent.end = { dateTime: event.endTime.toISOString() }; - } - - if (event.recurrenceRule) { - googleEvent.recurrence = [`RRULE:${event.recurrenceRule}`]; - } - - const response = await fetch( - `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(googleEvent), - } - ); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Failed to create event: ${response.status} ${error}`); - } - - const created = await response.json(); - return { id: created.id }; - } - - /** - * Update an event in Google Calendar - */ - async updateEvent( - accessToken: string, - calendarId: string, - eventId: string, - event: { - title?: string; - description?: string; - location?: string; - startTime?: Date; - endTime?: Date; - isAllDay?: boolean; - } - ): Promise { - // First fetch the existing event - const getResponse = await fetch( - `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - } - ); - - if (!getResponse.ok) { - throw new Error(`Failed to fetch event for update: ${getResponse.status}`); - } - - const existingEvent = await getResponse.json(); - - // Merge updates - const updatedEvent: Partial = { - ...existingEvent, - summary: event.title ?? existingEvent.summary, - description: event.description ?? existingEvent.description, - location: event.location ?? existingEvent.location, - }; - - if (event.startTime && event.endTime) { - if (event.isAllDay) { - updatedEvent.start = { date: event.startTime.toISOString().split('T')[0] }; - updatedEvent.end = { date: event.endTime.toISOString().split('T')[0] }; - } else { - updatedEvent.start = { dateTime: event.startTime.toISOString() }; - updatedEvent.end = { dateTime: event.endTime.toISOString() }; - } - } - - const response = await fetch( - `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`, - { - method: 'PUT', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(updatedEvent), - } - ); - - if (!response.ok) { - throw new Error(`Failed to update event: ${response.status}`); - } - } - - /** - * Delete an event from Google Calendar - */ - async deleteEvent(accessToken: string, calendarId: string, eventId: string): Promise { - const response = await fetch( - `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`, - { - method: 'DELETE', - headers: { - Authorization: `Bearer ${accessToken}`, - }, - } - ); - - if (!response.ok && response.status !== 404) { - throw new Error(`Failed to delete event: ${response.status}`); - } - } - - /** - * Convert Google Calendar event to our ParsedEvent format - */ - private convertGoogleEvent(event: GoogleCalendarEvent): ParsedEvent { - const isAllDay = !!event.start.date; - - let startDate: Date; - let endDate: Date; - - if (isAllDay) { - startDate = new Date(event.start.date!); - endDate = new Date(event.end.date!); - } else { - startDate = new Date(event.start.dateTime!); - endDate = new Date(event.end.dateTime!); - } - - // Extract RRULE from recurrence array - let rrule: string | undefined; - if (event.recurrence?.length) { - const rruleLine = event.recurrence.find((r) => r.startsWith('RRULE:')); - if (rruleLine) { - rrule = rruleLine.replace('RRULE:', ''); - } - } - - return { - uid: event.id, - summary: event.summary, - description: event.description, - location: event.location, - dtstart: startDate, - dtend: endDate, - isAllDay, - rrule, - status: this.mapGoogleStatus(event.status), - organizer: event.organizer?.email, - attendees: event.attendees?.map((a) => ({ - email: a.email, - name: a.displayName, - status: this.mapGoogleResponseStatus(a.responseStatus), - })), - lastModified: event.updated ? new Date(event.updated) : undefined, - created: event.created ? new Date(event.created) : undefined, - }; - } - - /** - * Map Google event status to our status - */ - private mapGoogleStatus(status?: string): string { - const mapping: Record = { - confirmed: 'confirmed', - tentative: 'tentative', - cancelled: 'cancelled', - }; - return mapping[status || 'confirmed'] || 'confirmed'; - } - - /** - * Map Google response status to our attendee status - */ - private mapGoogleResponseStatus(status?: string): string | undefined { - if (!status) return undefined; - const mapping: Record = { - accepted: 'accepted', - declined: 'declined', - tentative: 'tentative', - needsAction: 'pending', - }; - return mapping[status] || 'pending'; - } -} diff --git a/apps/calendar/apps/backend/src/sync/ical.service.ts b/apps/calendar/apps/backend/src/sync/ical.service.ts deleted file mode 100644 index ae869e4fd..000000000 --- a/apps/calendar/apps/backend/src/sync/ical.service.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import ICAL from 'ical.js'; - -export interface ParsedEvent { - uid: string; - summary: string; - description?: string; - location?: string; - dtstart: Date; - dtend: Date; - isAllDay: boolean; - rrule?: string; - status?: string; - organizer?: string; - attendees?: Array<{ email: string; name?: string; status?: string }>; - lastModified?: Date; - created?: Date; - sequence?: number; -} - -export interface CalendarExport { - name: string; - events: ParsedEvent[]; -} - -@Injectable() -export class ICalService { - private readonly logger = new Logger(ICalService.name); - - /** - * Parse iCal/ICS data into structured events - */ - parseICalData(icalData: string): ParsedEvent[] { - const events: ParsedEvent[] = []; - - try { - const jcalData = ICAL.parse(icalData); - const vcalendar = new ICAL.Component(jcalData); - const vevents = vcalendar.getAllSubcomponents('vevent'); - - for (const vevent of vevents) { - try { - const event = this.parseVEvent(vevent); - if (event) { - events.push(event); - } - } catch (error) { - this.logger.warn(`Failed to parse event: ${error}`); - } - } - } catch (error) { - this.logger.error(`Failed to parse iCal data: ${error}`); - throw new Error('Invalid iCal data format'); - } - - return events; - } - - /** - * Parse a single VEVENT component - */ - private parseVEvent(vevent: ICAL.Component): ParsedEvent | null { - const event = new ICAL.Event(vevent); - - const uid = event.uid; - const summary = event.summary; - - if (!uid || !summary) { - return null; - } - - const dtstart = event.startDate; - const dtend = event.endDate; - - if (!dtstart || !dtend) { - return null; - } - - // Check if all-day event (DATE vs DATE-TIME) - const isAllDay = dtstart.isDate; - - // Extract RRULE if present - let rrule: string | undefined; - const rruleProp = vevent.getFirstProperty('rrule'); - if (rruleProp) { - rrule = rruleProp.toICALString().replace('RRULE:', ''); - } - - // Extract attendees - const attendees: Array<{ email: string; name?: string; status?: string }> = []; - const attendeeProps = vevent.getAllProperties('attendee'); - for (const attendee of attendeeProps) { - const email = attendee.getFirstValue()?.toString().replace('mailto:', '') || ''; - const cn = attendee.getParameter('cn'); - const partstat = attendee.getParameter('partstat'); - if (email) { - attendees.push({ - email, - name: cn?.toString(), - status: this.mapPartstat(partstat?.toString()), - }); - } - } - - // Extract organizer - let organizer: string | undefined; - const organizerProp = vevent.getFirstProperty('organizer'); - if (organizerProp) { - organizer = organizerProp.getFirstValue()?.toString().replace('mailto:', ''); - } - - // Extract timestamps - const lastModifiedProp = vevent.getFirstProperty('last-modified'); - const createdProp = vevent.getFirstProperty('created'); - const sequenceProp = vevent.getFirstProperty('sequence'); - - return { - uid, - summary, - description: event.description || undefined, - location: event.location || undefined, - dtstart: dtstart.toJSDate(), - dtend: dtend.toJSDate(), - isAllDay, - rrule, - status: this.mapStatus(vevent.getFirstPropertyValue('status')?.toString()), - organizer, - attendees: attendees.length > 0 ? attendees : undefined, - lastModified: lastModifiedProp?.getFirstValue() - ? new Date(lastModifiedProp.getFirstValue()?.toString() || '') - : undefined, - created: createdProp?.getFirstValue() - ? new Date(createdProp.getFirstValue()?.toString() || '') - : undefined, - sequence: sequenceProp - ? parseInt(sequenceProp.getFirstValue()?.toString() || '0', 10) - : undefined, - }; - } - - /** - * Map iCal PARTSTAT to our status - */ - private mapPartstat(partstat?: string): string | undefined { - if (!partstat) return undefined; - const mapping: Record = { - ACCEPTED: 'accepted', - DECLINED: 'declined', - TENTATIVE: 'tentative', - 'NEEDS-ACTION': 'pending', - }; - return mapping[partstat.toUpperCase()] || 'pending'; - } - - /** - * Map iCal STATUS to our status - */ - private mapStatus(status?: string): string | undefined { - if (!status) return 'confirmed'; - const mapping: Record = { - CONFIRMED: 'confirmed', - TENTATIVE: 'tentative', - CANCELLED: 'cancelled', - }; - return mapping[status.toUpperCase()] || 'confirmed'; - } - - /** - * Generate iCal/ICS data from events - */ - generateICalData( - calendarName: string, - events: Array<{ - id: string; - title: string; - description?: string | null; - location?: string | null; - startTime: Date; - endTime: Date; - isAllDay?: boolean | null; - recurrenceRule?: string | null; - status?: string | null; - }> - ): string { - const vcalendar = new ICAL.Component(['vcalendar', [], []]); - - // Set calendar properties - vcalendar.addPropertyWithValue('version', '2.0'); - vcalendar.addPropertyWithValue('prodid', '-//ManaCore Calendar//EN'); - vcalendar.addPropertyWithValue('calscale', 'GREGORIAN'); - vcalendar.addPropertyWithValue('method', 'PUBLISH'); - vcalendar.addPropertyWithValue('x-wr-calname', calendarName); - - for (const event of events) { - const vevent = new ICAL.Component('vevent'); - - // Required properties - vevent.addPropertyWithValue('uid', `${event.id}@mana.how`); - vevent.addPropertyWithValue('summary', event.title); - - // Timestamps - const dtstart = ICAL.Time.fromJSDate(event.startTime, false); - const dtend = ICAL.Time.fromJSDate(event.endTime, false); - - if (event.isAllDay) { - dtstart.isDate = true; - dtend.isDate = true; - } - - vevent.addPropertyWithValue('dtstart', dtstart); - vevent.addPropertyWithValue('dtend', dtend); - - // Optional properties - if (event.description) { - vevent.addPropertyWithValue('description', event.description); - } - if (event.location) { - vevent.addPropertyWithValue('location', event.location); - } - if (event.recurrenceRule) { - const rruleProp = new ICAL.Property('rrule'); - rruleProp.setValue(ICAL.Recur.fromString(event.recurrenceRule)); - vevent.addProperty(rruleProp); - } - - // Status - const status = (event.status || 'confirmed').toUpperCase(); - vevent.addPropertyWithValue('status', status); - - // Metadata - vevent.addPropertyWithValue('dtstamp', ICAL.Time.now()); - vevent.addPropertyWithValue('created', ICAL.Time.fromJSDate(new Date(), false)); - - vcalendar.addSubcomponent(vevent); - } - - return vcalendar.toString(); - } - - /** - * Fetch and parse iCal from URL - */ - async fetchAndParseICalUrl(url: string): Promise { - try { - const response = await fetch(url, { - headers: { - Accept: 'text/calendar', - 'User-Agent': 'ManaCore Calendar/1.0', - }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch iCal: ${response.status} ${response.statusText}`); - } - - const icalData = await response.text(); - return this.parseICalData(icalData); - } catch (error) { - this.logger.error(`Failed to fetch iCal from ${url}: ${error}`); - throw error; - } - } -} diff --git a/apps/calendar/apps/backend/src/sync/index.ts b/apps/calendar/apps/backend/src/sync/index.ts deleted file mode 100644 index 6467f4ed8..000000000 --- a/apps/calendar/apps/backend/src/sync/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './sync.module'; -export * from './sync.service'; -export * from './sync.controller'; -export * from './ical.service'; -export * from './caldav.service'; -export * from './google-calendar.service'; -export * from './dto'; diff --git a/apps/calendar/apps/backend/src/sync/sync.controller.ts b/apps/calendar/apps/backend/src/sync/sync.controller.ts deleted file mode 100644 index d53bc3b72..000000000 --- a/apps/calendar/apps/backend/src/sync/sync.controller.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { - Controller, - Get, - Post, - Put, - Delete, - Body, - Param, - Query, - Res, - UseGuards, - HttpCode, - HttpStatus, -} from '@nestjs/common'; -import { Response } from 'express'; -import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; -import { SyncService } from './sync.service'; -import { ConnectCalendarDto, UpdateExternalCalendarDto, DiscoverCalDavDto } from './dto'; - -@Controller() -@UseGuards(JwtAuthGuard) -export class SyncController { - constructor(private readonly syncService: SyncService) {} - - /** - * List all external calendars for the current user - */ - @Get('sync/external') - async listExternalCalendars(@CurrentUser() user: CurrentUserData) { - const calendars = await this.syncService.findByUser(user.userId); - return { calendars }; - } - - /** - * Connect a new external calendar - */ - @Post('sync/external') - async connectCalendar(@CurrentUser() user: CurrentUserData, @Body() dto: ConnectCalendarDto) { - const calendar = await this.syncService.connect(user.userId, dto); - return { calendar }; - } - - /** - * Get a specific external calendar - */ - @Get('sync/external/:id') - async getExternalCalendar(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - const calendar = await this.syncService.findOne(id, user.userId); - return { calendar }; - } - - /** - * Update external calendar settings - */ - @Put('sync/external/:id') - async updateExternalCalendar( - @CurrentUser() user: CurrentUserData, - @Param('id') id: string, - @Body() dto: UpdateExternalCalendarDto - ) { - const calendar = await this.syncService.update(id, user.userId, dto); - return { calendar }; - } - - /** - * Disconnect an external calendar - */ - @Delete('sync/external/:id') - @HttpCode(HttpStatus.NO_CONTENT) - async disconnectCalendar(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - await this.syncService.disconnect(id, user.userId); - } - - /** - * Trigger manual sync for an external calendar - */ - @Post('sync/external/:id/sync') - async triggerSync(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - // Verify ownership - await this.syncService.findOne(id, user.userId); - - const result = await this.syncService.syncCalendar(id); - return { - success: result.success, - eventsImported: result.eventsImported, - eventsExported: result.eventsExported, - errors: result.errors, - }; - } - - /** - * Discover CalDAV calendars on a server - */ - @Post('sync/caldav/discover') - async discoverCalDav(@Body() dto: DiscoverCalDavDto) { - return this.syncService.discoverCalDav(dto); - } - - /** - * Get Google OAuth authorization URL - */ - @Get('sync/google/auth-url') - async getGoogleAuthUrl(@Query('state') state?: string) { - const url = this.syncService.getGoogleAuthUrl(state); - return { url }; - } - - /** - * Handle Google OAuth callback - */ - @Get('sync/google/callback') - async handleGoogleCallback( - @CurrentUser() user: CurrentUserData, - @Query('code') code: string, - @Query('state') state?: string - ) { - const result = await this.syncService.handleGoogleCallback(code, user.userId); - return { - ...result, - state, - }; - } - - /** - * Export a local calendar as iCal file - */ - @Get('calendars/:calendarId/export.ics') - async exportCalendar( - @CurrentUser() user: CurrentUserData, - @Param('calendarId') calendarId: string, - @Res() res: Response - ) { - const icalData = await this.syncService.exportCalendarAsIcal(calendarId, user.userId); - - res.set({ - 'Content-Type': 'text/calendar; charset=utf-8', - 'Content-Disposition': `attachment; filename="calendar-${calendarId}.ics"`, - }); - res.send(icalData); - } -} diff --git a/apps/calendar/apps/backend/src/sync/sync.module.ts b/apps/calendar/apps/backend/src/sync/sync.module.ts deleted file mode 100644 index c4cf37745..000000000 --- a/apps/calendar/apps/backend/src/sync/sync.module.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Module } from '@nestjs/common'; -import { DatabaseModule } from '../db/database.module'; -import { SyncController } from './sync.controller'; -import { SyncService } from './sync.service'; -import { ICalService } from './ical.service'; -import { CalDavService } from './caldav.service'; -import { GoogleCalendarService } from './google-calendar.service'; -import { EncryptionService } from '../common/encryption.service'; - -@Module({ - imports: [DatabaseModule], - controllers: [SyncController], - providers: [SyncService, ICalService, CalDavService, GoogleCalendarService, EncryptionService], - exports: [SyncService, ICalService], -}) -export class SyncModule {} diff --git a/apps/calendar/apps/backend/src/sync/sync.service.spec.ts b/apps/calendar/apps/backend/src/sync/sync.service.spec.ts deleted file mode 100644 index d08ce7cc9..000000000 --- a/apps/calendar/apps/backend/src/sync/sync.service.spec.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { NotFoundException, BadRequestException } from '@nestjs/common'; -import { SyncService } from './sync.service'; -import { ICalService } from './ical.service'; -import { CalDavService } from './caldav.service'; -import { GoogleCalendarService } from './google-calendar.service'; -import { EncryptionService } from '../common/encryption.service'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { TEST_USER_ID } from '../__tests__/utils/mock-factories'; -import { v4 as uuidv4 } from 'uuid'; - -function createMockExternalCalendar(overrides: Record = {}) { - return { - id: uuidv4(), - userId: TEST_USER_ID, - name: 'External Calendar', - provider: 'ical_url', - calendarUrl: 'https://example.com/calendar.ics', - username: null, - encryptedPassword: null, - accessToken: null, - refreshToken: null, - tokenExpiresAt: null, - syncEnabled: true, - syncDirection: 'both', - syncInterval: 15, - lastSyncAt: null, - lastSyncError: null, - color: '#6B7280', - isVisible: true, - providerData: null, - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }; -} - -describe('SyncService', () => { - let service: SyncService; - let mockDb: any; - let mockICalService: any; - let mockCalDavService: any; - let mockGoogleCalendarService: any; - let mockEncryptionService: any; - - beforeEach(async () => { - mockDb = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - returning: jest.fn().mockResolvedValue([]), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - delete: jest.fn().mockReturnThis(), - }; - - mockICalService = { - fetchAndParseICalUrl: jest.fn().mockResolvedValue([]), - generateICalData: jest.fn().mockReturnValue('BEGIN:VCALENDAR\nEND:VCALENDAR'), - }; - - mockCalDavService = { - discoverCalendars: jest.fn().mockResolvedValue([]), - getAppleCalDavUrl: jest.fn().mockReturnValue('https://caldav.icloud.com'), - fetchEvents: jest.fn().mockResolvedValue({ events: [], ctag: null }), - upsertEvent: jest.fn().mockResolvedValue(undefined), - }; - - mockGoogleCalendarService = { - isConfigured: jest.fn().mockReturnValue(false), - getAuthUrl: jest.fn().mockReturnValue('https://google.com/auth'), - exchangeCodeForTokens: jest.fn(), - listCalendars: jest.fn(), - refreshAccessToken: jest.fn(), - fetchEvents: jest.fn().mockResolvedValue([]), - createEvent: jest.fn(), - updateEvent: jest.fn(), - }; - - mockEncryptionService = { - encrypt: jest.fn().mockReturnValue('encrypted-password'), - decrypt: jest.fn().mockReturnValue('decrypted-password'), - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - SyncService, - { - provide: DATABASE_CONNECTION, - useValue: mockDb, - }, - { - provide: ICalService, - useValue: mockICalService, - }, - { - provide: CalDavService, - useValue: mockCalDavService, - }, - { - provide: GoogleCalendarService, - useValue: mockGoogleCalendarService, - }, - { - provide: EncryptionService, - useValue: mockEncryptionService, - }, - ], - }).compile(); - - service = module.get(SyncService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('findByUser', () => { - it('should return all external calendars for a user', async () => { - const calendars = [ - createMockExternalCalendar({ name: 'Calendar 1' }), - createMockExternalCalendar({ name: 'Calendar 2' }), - ]; - mockDb.where.mockResolvedValueOnce(calendars); - - const result = await service.findByUser(TEST_USER_ID); - - expect(result).toEqual(calendars); - expect(mockDb.select).toHaveBeenCalled(); - expect(mockDb.from).toHaveBeenCalled(); - }); - - it('should return empty array when user has no external calendars', async () => { - mockDb.where.mockResolvedValueOnce([]); - - const result = await service.findByUser(TEST_USER_ID); - - expect(result).toEqual([]); - }); - }); - - describe('findOne', () => { - it('should return an external calendar when found', async () => { - const calendar = createMockExternalCalendar(); - mockDb.where.mockResolvedValueOnce([calendar]); - - const result = await service.findOne(calendar.id as string, TEST_USER_ID); - - expect(result).toEqual(calendar); - }); - - it('should throw NotFoundException when calendar not found', async () => { - mockDb.where.mockResolvedValueOnce([]); - - await expect(service.findOne('non-existent-id', TEST_USER_ID)).rejects.toThrow( - NotFoundException - ); - }); - }); - - describe('connect', () => { - it('should connect an iCal URL calendar', async () => { - const newCalendar = createMockExternalCalendar({ provider: 'ical_url' }); - mockDb.returning.mockResolvedValueOnce([newCalendar]); - - const result = await service.connect(TEST_USER_ID, { - name: 'External Calendar', - provider: 'ical_url', - calendarUrl: 'https://example.com/calendar.ics', - }); - - expect(result).toEqual(newCalendar); - expect(mockICalService.fetchAndParseICalUrl).toHaveBeenCalledWith( - 'https://example.com/calendar.ics' - ); - expect(mockDb.insert).toHaveBeenCalled(); - }); - - it('should throw BadRequestException for CalDAV without credentials', async () => { - await expect( - service.connect(TEST_USER_ID, { - name: 'CalDAV Calendar', - provider: 'caldav', - calendarUrl: 'https://caldav.example.com/cal', - }) - ).rejects.toThrow(BadRequestException); - }); - - it('should connect a CalDAV calendar with credentials', async () => { - const newCalendar = createMockExternalCalendar({ provider: 'caldav' }); - mockDb.returning.mockResolvedValueOnce([newCalendar]); - - const result = await service.connect(TEST_USER_ID, { - name: 'CalDAV Calendar', - provider: 'caldav', - calendarUrl: 'https://caldav.example.com/cal', - username: 'user', - password: 'pass', - }); - - expect(result).toEqual(newCalendar); - expect(mockCalDavService.discoverCalendars).toHaveBeenCalled(); - expect(mockEncryptionService.encrypt).toHaveBeenCalledWith('pass'); - }); - - it('should throw BadRequestException for Google without access token', async () => { - await expect( - service.connect(TEST_USER_ID, { - name: 'Google Calendar', - provider: 'google', - calendarUrl: 'https://google.com/calendar', - }) - ).rejects.toThrow(BadRequestException); - }); - }); - - describe('disconnect', () => { - it('should disconnect an external calendar and delete synced events', async () => { - const calendar = createMockExternalCalendar(); - mockDb.where.mockResolvedValueOnce([calendar]); - - await service.disconnect(calendar.id as string, TEST_USER_ID); - - // Should delete synced events and the calendar - expect(mockDb.delete).toHaveBeenCalledTimes(2); - }); - - it('should throw NotFoundException when calendar not found', async () => { - mockDb.where.mockResolvedValueOnce([]); - - await expect(service.disconnect('non-existent-id', TEST_USER_ID)).rejects.toThrow( - NotFoundException - ); - }); - }); - - describe('update', () => { - it('should update external calendar settings', async () => { - const calendar = createMockExternalCalendar(); - const updatedCalendar = { ...calendar, name: 'Updated Name' }; - - // findOne - mockDb.where.mockResolvedValueOnce([calendar]); - // update returning - mockDb.returning.mockResolvedValueOnce([updatedCalendar]); - - const result = await service.update(calendar.id as string, TEST_USER_ID, { - name: 'Updated Name', - }); - - expect(result.name).toBe('Updated Name'); - expect(mockDb.update).toHaveBeenCalled(); - }); - - it('should throw NotFoundException when updating non-existent calendar', async () => { - mockDb.where.mockResolvedValueOnce([]); - - await expect( - service.update('non-existent-id', TEST_USER_ID, { name: 'New Name' }) - ).rejects.toThrow(NotFoundException); - }); - }); - - describe('syncCalendar', () => { - it('should return early when sync is disabled', async () => { - const calendar = createMockExternalCalendar({ syncEnabled: false }); - mockDb.where.mockResolvedValueOnce([calendar]); - - const result = await service.syncCalendar(calendar.id as string); - - expect(result.success).toBe(true); - expect(result.eventsImported).toBe(0); - expect(result.eventsExported).toBe(0); - expect(result.errors).toContain('Sync is disabled'); - }); - - it('should throw NotFoundException when calendar not found', async () => { - mockDb.where.mockResolvedValueOnce([]); - - await expect(service.syncCalendar('non-existent-id')).rejects.toThrow(NotFoundException); - }); - }); - - describe('discoverCalDav', () => { - it('should discover CalDAV calendars', async () => { - mockCalDavService.discoverCalendars!.mockResolvedValueOnce([ - { - url: 'https://caldav.example.com/cal/1', - displayName: 'My Calendar', - color: '#3B82F6', - description: 'Test calendar', - ctag: null, - }, - ]); - - const result = await service.discoverCalDav({ - serverUrl: 'https://caldav.example.com', - username: 'user', - password: 'pass', - }); - - expect(result.calendars).toHaveLength(1); - expect(result.calendars[0].name).toBe('My Calendar'); - expect(result.calendars[0].url).toBe('https://caldav.example.com/cal/1'); - }); - }); - - describe('getGoogleAuthUrl', () => { - it('should throw BadRequestException when Google is not configured', () => { - mockGoogleCalendarService.isConfigured!.mockReturnValue(false); - - expect(() => service.getGoogleAuthUrl()).toThrow(BadRequestException); - }); - - it('should return auth URL when Google is configured', () => { - mockGoogleCalendarService.isConfigured!.mockReturnValue(true); - mockGoogleCalendarService.getAuthUrl!.mockReturnValue('https://google.com/auth?state=test'); - - const result = service.getGoogleAuthUrl('test'); - - expect(result).toBe('https://google.com/auth?state=test'); - }); - }); - - describe('exportCalendarAsIcal', () => { - it('should export a calendar as iCal data', async () => { - const calendar = { - id: uuidv4(), - userId: TEST_USER_ID, - name: 'My Calendar', - }; - const calendarEvents = [{ id: uuidv4(), title: 'Event 1' }]; - - // Find calendar - mockDb.where.mockResolvedValueOnce([calendar]); - // Find events - mockDb.where.mockResolvedValueOnce(calendarEvents); - - const result = await service.exportCalendarAsIcal(calendar.id, TEST_USER_ID); - - expect(result).toBe('BEGIN:VCALENDAR\nEND:VCALENDAR'); - expect(mockICalService.generateICalData).toHaveBeenCalledWith('My Calendar', calendarEvents); - }); - - it('should throw NotFoundException when calendar not found', async () => { - mockDb.where.mockResolvedValueOnce([]); - - await expect(service.exportCalendarAsIcal('non-existent-id', TEST_USER_ID)).rejects.toThrow( - NotFoundException - ); - }); - }); -}); diff --git a/apps/calendar/apps/backend/src/sync/sync.service.ts b/apps/calendar/apps/backend/src/sync/sync.service.ts deleted file mode 100644 index f5753362c..000000000 --- a/apps/calendar/apps/backend/src/sync/sync.service.ts +++ /dev/null @@ -1,638 +0,0 @@ -import { Injectable, Inject, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; -import { Cron, CronExpression } from '@nestjs/schedule'; -import { eq, and, lte, isNull, or } from 'drizzle-orm'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { Database } from '../db/connection'; -import { externalCalendars, ExternalCalendar, events, calendars } from '../db/schema'; -import { ICalService, ParsedEvent } from './ical.service'; -import { CalDavService } from './caldav.service'; -import { GoogleCalendarService } from './google-calendar.service'; -import { ConnectCalendarDto, UpdateExternalCalendarDto, DiscoverCalDavDto } from './dto'; -import { EncryptionService } from '../common/encryption.service'; - -interface SyncResult { - success: boolean; - eventsImported: number; - eventsExported: number; - errors: string[]; -} - -@Injectable() -export class SyncService { - private readonly logger = new Logger(SyncService.name); - - constructor( - @Inject(DATABASE_CONNECTION) private readonly db: Database, - private readonly icalService: ICalService, - private readonly caldavService: CalDavService, - private readonly googleCalendarService: GoogleCalendarService, - private readonly encryptionService: EncryptionService - ) {} - - /** - * Connect an external calendar - */ - async connect(userId: string, dto: ConnectCalendarDto): Promise { - // Validate connection based on provider - if (dto.provider === 'caldav' || dto.provider === 'apple') { - if (!dto.username || !dto.password) { - throw new BadRequestException('CalDAV requires username and password'); - } - // Test connection - await this.caldavService.discoverCalendars( - dto.provider === 'apple' ? this.caldavService.getAppleCalDavUrl() : dto.calendarUrl, - dto.username, - dto.password - ); - } else if (dto.provider === 'google') { - if (!dto.accessToken) { - throw new BadRequestException('Google Calendar requires OAuth tokens'); - } - } else if (dto.provider === 'ical_url') { - // Test that we can fetch the URL - await this.icalService.fetchAndParseICalUrl(dto.calendarUrl); - } - - const [externalCalendar] = await this.db - .insert(externalCalendars) - .values({ - userId, - name: dto.name, - provider: dto.provider, - calendarUrl: dto.calendarUrl, - username: dto.username, - encryptedPassword: dto.password ? this.encryptionService.encrypt(dto.password) : null, - accessToken: dto.accessToken, - refreshToken: dto.refreshToken, - tokenExpiresAt: dto.accessToken ? new Date(Date.now() + 3600 * 1000) : null, - syncDirection: dto.syncDirection || 'both', - syncInterval: dto.syncInterval || 15, - color: dto.color || '#6B7280', - }) - .returning(); - - // Trigger initial sync - this.syncCalendar(externalCalendar.id).catch((err) => { - this.logger.error(`Initial sync failed for ${externalCalendar.id}: ${err}`); - }); - - return externalCalendar; - } - - /** - * Disconnect an external calendar - */ - async disconnect(id: string, userId: string): Promise { - const [externalCalendar] = await this.db - .select() - .from(externalCalendars) - .where(and(eq(externalCalendars.id, id), eq(externalCalendars.userId, userId))); - - if (!externalCalendar) { - throw new NotFoundException('External calendar not found'); - } - - // Delete synced events first - await this.db.delete(events).where(eq(events.externalCalendarId, id)); - - // Delete the external calendar - await this.db.delete(externalCalendars).where(eq(externalCalendars.id, id)); - } - - /** - * Update external calendar settings - */ - async update( - id: string, - userId: string, - dto: UpdateExternalCalendarDto - ): Promise { - const [existing] = await this.db - .select() - .from(externalCalendars) - .where(and(eq(externalCalendars.id, id), eq(externalCalendars.userId, userId))); - - if (!existing) { - throw new NotFoundException('External calendar not found'); - } - - const [updated] = await this.db - .update(externalCalendars) - .set({ - ...dto, - updatedAt: new Date(), - }) - .where(eq(externalCalendars.id, id)) - .returning(); - - return updated; - } - - /** - * Get all external calendars for a user - */ - async findByUser(userId: string): Promise { - return this.db.select().from(externalCalendars).where(eq(externalCalendars.userId, userId)); - } - - /** - * Get single external calendar - */ - async findOne(id: string, userId: string): Promise { - const [externalCalendar] = await this.db - .select() - .from(externalCalendars) - .where(and(eq(externalCalendars.id, id), eq(externalCalendars.userId, userId))); - - if (!externalCalendar) { - throw new NotFoundException('External calendar not found'); - } - - return externalCalendar; - } - - /** - * Discover CalDAV calendars - */ - async discoverCalDav(dto: DiscoverCalDavDto) { - const discovered = await this.caldavService.discoverCalendars( - dto.serverUrl, - dto.username, - dto.password - ); - - return { - calendars: discovered.map((cal) => ({ - url: cal.url, - name: cal.displayName, - color: cal.color, - description: cal.description, - })), - }; - } - - /** - * Get Google OAuth URL - */ - getGoogleAuthUrl(state?: string): string { - if (!this.googleCalendarService.isConfigured()) { - throw new BadRequestException('Google Calendar is not configured'); - } - return this.googleCalendarService.getAuthUrl(state); - } - - /** - * Handle Google OAuth callback - */ - async handleGoogleCallback(code: string, userId: string) { - const tokens = await this.googleCalendarService.exchangeCodeForTokens(code); - - // List available calendars - const calendarList = await this.googleCalendarService.listCalendars(tokens.access_token); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - calendars: calendarList.map((cal) => ({ - id: cal.id, - name: cal.summary, - description: cal.description, - color: cal.backgroundColor, - primary: cal.primary, - })), - }; - } - - /** - * Sync a specific external calendar - */ - async syncCalendar(externalCalendarId: string): Promise { - const [externalCalendar] = await this.db - .select() - .from(externalCalendars) - .where(eq(externalCalendars.id, externalCalendarId)); - - if (!externalCalendar) { - throw new NotFoundException('External calendar not found'); - } - - if (!externalCalendar.syncEnabled) { - return { success: true, eventsImported: 0, eventsExported: 0, errors: ['Sync is disabled'] }; - } - - const result: SyncResult = { - success: true, - eventsImported: 0, - eventsExported: 0, - errors: [], - }; - - try { - // Import events - if ( - externalCalendar.syncDirection === 'import' || - externalCalendar.syncDirection === 'both' - ) { - const imported = await this.importEvents(externalCalendar); - result.eventsImported = imported; - } - - // Export events - if ( - externalCalendar.syncDirection === 'export' || - externalCalendar.syncDirection === 'both' - ) { - const exported = await this.exportEvents(externalCalendar); - result.eventsExported = exported; - } - - // Update last sync time - await this.db - .update(externalCalendars) - .set({ - lastSyncAt: new Date(), - lastSyncError: null, - updatedAt: new Date(), - }) - .where(eq(externalCalendars.id, externalCalendarId)); - } catch (error) { - result.success = false; - result.errors.push(error instanceof Error ? error.message : 'Unknown error'); - - // Update error status - await this.db - .update(externalCalendars) - .set({ - lastSyncError: error instanceof Error ? error.message : 'Unknown error', - updatedAt: new Date(), - }) - .where(eq(externalCalendars.id, externalCalendarId)); - } - - return result; - } - - /** - * Import events from external calendar - */ - private async importEvents(externalCalendar: ExternalCalendar): Promise { - let parsedEvents: ParsedEvent[] = []; - - // Calculate time range (sync last 30 days to next 365 days) - const startDate = new Date(); - startDate.setDate(startDate.getDate() - 30); - const endDate = new Date(); - endDate.setFullYear(endDate.getFullYear() + 1); - - switch (externalCalendar.provider) { - case 'ical_url': - parsedEvents = await this.icalService.fetchAndParseICalUrl(externalCalendar.calendarUrl); - break; - - case 'caldav': - case 'apple': { - const serverUrl = - externalCalendar.provider === 'apple' - ? this.caldavService.getAppleCalDavUrl() - : new URL(externalCalendar.calendarUrl).origin; - - const result = await this.caldavService.fetchEvents( - serverUrl, - externalCalendar.calendarUrl, - externalCalendar.username || '', - externalCalendar.encryptedPassword - ? this.encryptionService.decrypt(externalCalendar.encryptedPassword) - : '', - startDate, - endDate - ); - parsedEvents = result.events; - - // Store ctag for change detection - if (result.ctag) { - await this.db - .update(externalCalendars) - .set({ - providerData: { - ...externalCalendar.providerData, - caldavCtag: result.ctag, - }, - }) - .where(eq(externalCalendars.id, externalCalendar.id)); - } - break; - } - - case 'google': { - // Refresh token if needed - let accessToken = externalCalendar.accessToken; - if ( - externalCalendar.tokenExpiresAt && - new Date(externalCalendar.tokenExpiresAt) < new Date() - ) { - if (!externalCalendar.refreshToken) { - throw new Error('Token expired and no refresh token available'); - } - const tokens = await this.googleCalendarService.refreshAccessToken( - externalCalendar.refreshToken - ); - accessToken = tokens.access_token; - - await this.db - .update(externalCalendars) - .set({ - accessToken: tokens.access_token, - tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000), - }) - .where(eq(externalCalendars.id, externalCalendar.id)); - } - - const googleCalendarId = externalCalendar.providerData?.googleCalendarId || 'primary'; - parsedEvents = await this.googleCalendarService.fetchEvents( - accessToken!, - googleCalendarId, - startDate, - endDate - ); - break; - } - } - - // Get or create a local calendar for imported events - let [localCalendar] = await this.db - .select() - .from(calendars) - .where( - and( - eq(calendars.userId, externalCalendar.userId), - eq(calendars.name, `${externalCalendar.name} (Sync)`) - ) - ); - - if (!localCalendar) { - [localCalendar] = await this.db - .insert(calendars) - .values({ - userId: externalCalendar.userId, - name: `${externalCalendar.name} (Sync)`, - color: externalCalendar.color || '#6B7280', - isDefault: false, - }) - .returning(); - } - - // Upsert events - let importedCount = 0; - for (const parsedEvent of parsedEvents) { - try { - // Check if event already exists - const [existingEvent] = await this.db - .select() - .from(events) - .where( - and( - eq(events.externalId, parsedEvent.uid), - eq(events.externalCalendarId, externalCalendar.id) - ) - ); - - if (existingEvent) { - // Update existing event - await this.db - .update(events) - .set({ - title: parsedEvent.summary, - description: parsedEvent.description, - location: parsedEvent.location, - startTime: parsedEvent.dtstart, - endTime: parsedEvent.dtend, - isAllDay: parsedEvent.isAllDay, - recurrenceRule: parsedEvent.rrule, - status: parsedEvent.status, - lastSyncedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(events.id, existingEvent.id)); - } else { - // Create new event - await this.db.insert(events).values({ - calendarId: localCalendar.id, - userId: externalCalendar.userId, - title: parsedEvent.summary, - description: parsedEvent.description, - location: parsedEvent.location, - startTime: parsedEvent.dtstart, - endTime: parsedEvent.dtend, - isAllDay: parsedEvent.isAllDay, - recurrenceRule: parsedEvent.rrule, - status: parsedEvent.status, - externalId: parsedEvent.uid, - externalCalendarId: externalCalendar.id, - lastSyncedAt: new Date(), - metadata: parsedEvent.attendees - ? { - attendees: parsedEvent.attendees.map((a) => ({ - email: a.email, - name: a.name, - status: - (a.status as 'accepted' | 'declined' | 'tentative' | 'pending') || undefined, - })), - } - : undefined, - }); - importedCount++; - } - } catch (error) { - this.logger.warn(`Failed to import event ${parsedEvent.uid}: ${error}`); - } - } - - return importedCount; - } - - /** - * Export events to external calendar - */ - private async exportEvents(externalCalendar: ExternalCalendar): Promise { - // Only CalDAV and Google support export - if (externalCalendar.provider === 'ical_url') { - return 0; // iCal URLs are read-only - } - - // Get local events that haven't been synced yet - const [localCalendar] = await this.db - .select() - .from(calendars) - .where(and(eq(calendars.userId, externalCalendar.userId), eq(calendars.isDefault, true))); - - if (!localCalendar) { - return 0; - } - - // Get events from default calendar that need to be exported - const localEvents = await this.db - .select() - .from(events) - .where( - and( - eq(events.calendarId, localCalendar.id), - or(isNull(events.lastSyncedAt), lte(events.lastSyncedAt, events.updatedAt)) - ) - ); - - let exportedCount = 0; - - for (const event of localEvents) { - try { - switch (externalCalendar.provider) { - case 'caldav': - case 'apple': { - const serverUrl = - externalCalendar.provider === 'apple' - ? this.caldavService.getAppleCalDavUrl() - : new URL(externalCalendar.calendarUrl).origin; - - await this.caldavService.upsertEvent( - serverUrl, - externalCalendar.calendarUrl, - externalCalendar.username || '', - externalCalendar.encryptedPassword - ? this.encryptionService.decrypt(externalCalendar.encryptedPassword) - : '', - { - uid: event.externalId || event.id, - title: event.title, - description: event.description ?? undefined, - location: event.location ?? undefined, - startTime: event.startTime, - endTime: event.endTime, - isAllDay: event.isAllDay ?? false, - recurrenceRule: event.recurrenceRule ?? undefined, - } - ); - break; - } - - case 'google': { - let accessToken = externalCalendar.accessToken; - if ( - externalCalendar.tokenExpiresAt && - new Date(externalCalendar.tokenExpiresAt) < new Date() - ) { - if (!externalCalendar.refreshToken) { - throw new Error('Token expired'); - } - const tokens = await this.googleCalendarService.refreshAccessToken( - externalCalendar.refreshToken - ); - accessToken = tokens.access_token; - } - - const googleCalendarId = externalCalendar.providerData?.googleCalendarId || 'primary'; - - if (event.externalId) { - await this.googleCalendarService.updateEvent( - accessToken!, - googleCalendarId, - event.externalId, - { - title: event.title, - description: event.description ?? undefined, - location: event.location ?? undefined, - startTime: event.startTime, - endTime: event.endTime, - isAllDay: event.isAllDay ?? false, - } - ); - } else { - const result = await this.googleCalendarService.createEvent( - accessToken!, - googleCalendarId, - { - title: event.title, - description: event.description ?? undefined, - location: event.location ?? undefined, - startTime: event.startTime, - endTime: event.endTime, - isAllDay: event.isAllDay ?? false, - recurrenceRule: event.recurrenceRule ?? undefined, - } - ); - - // Update the local event with the external ID - await this.db - .update(events) - .set({ externalId: result.id }) - .where(eq(events.id, event.id)); - } - break; - } - } - - // Mark as synced - await this.db - .update(events) - .set({ lastSyncedAt: new Date() }) - .where(eq(events.id, event.id)); - - exportedCount++; - } catch (error) { - this.logger.warn(`Failed to export event ${event.id}: ${error}`); - } - } - - return exportedCount; - } - - /** - * Export a local calendar to iCal format - */ - async exportCalendarAsIcal(calendarId: string, userId: string): Promise { - const [calendar] = await this.db - .select() - .from(calendars) - .where(and(eq(calendars.id, calendarId), eq(calendars.userId, userId))); - - if (!calendar) { - throw new NotFoundException('Calendar not found'); - } - - const calendarEvents = await this.db - .select() - .from(events) - .where(eq(events.calendarId, calendarId)); - - return this.icalService.generateICalData(calendar.name, calendarEvents); - } - - /** - * Scheduled sync job - runs every 5 minutes - */ - @Cron(CronExpression.EVERY_5_MINUTES) - async scheduledSync() { - this.logger.log('Running scheduled calendar sync...'); - - // Get calendars that need syncing - const now = new Date(); - const calendarsToSync = await this.db - .select() - .from(externalCalendars) - .where(eq(externalCalendars.syncEnabled, true)); - - for (const calendar of calendarsToSync) { - // Check if enough time has passed since last sync - const lastSync = calendar.lastSyncAt ? new Date(calendar.lastSyncAt) : null; - const intervalMs = (calendar.syncInterval || 15) * 60 * 1000; - - if (!lastSync || now.getTime() - lastSync.getTime() >= intervalMs) { - try { - await this.syncCalendar(calendar.id); - this.logger.log(`Synced calendar: ${calendar.name} (${calendar.id})`); - } catch (error) { - this.logger.error(`Failed to sync calendar ${calendar.id}: ${error}`); - } - } - } - } -} diff --git a/apps/calendar/apps/backend/tsconfig.json b/apps/calendar/apps/backend/tsconfig.json deleted file mode 100644 index 27971033a..000000000 --- a/apps/calendar/apps/backend/tsconfig.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2021", - "module": "commonjs", - "moduleResolution": "node", - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "outDir": "./dist", - "baseUrl": "./", - "rootDir": "./src", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": true, - "noImplicitAny": true, - "strictBindCallApply": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true, - "esModuleInterop": true, - "resolveJsonModule": true, - "sourceMap": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/apps/calendar/apps/web/package.json b/apps/calendar/apps/web/package.json index 3d68fc541..e1c55883b 100644 --- a/apps/calendar/apps/web/package.json +++ b/apps/calendar/apps/web/package.json @@ -51,8 +51,7 @@ "@manacore/shared-error-tracking": "workspace:*", "@manacore/feedback": "workspace:*", "@manacore/shared-i18n": "workspace:*", - "@manacore/shared-help-types": "workspace:*", - "@manacore/shared-help-ui": "workspace:*", + "@manacore/help": "workspace:*", "@manacore/shared-icons": "workspace:*", "@manacore/local-store": "workspace:*", "@manacore/shared-profile-ui": "workspace:*", diff --git a/apps/calendar/apps/web/src/lib/content/help/index.ts b/apps/calendar/apps/web/src/lib/content/help/index.ts index 906656f8f..4dfe7b2ee 100644 --- a/apps/calendar/apps/web/src/lib/content/help/index.ts +++ b/apps/calendar/apps/web/src/lib/content/help/index.ts @@ -2,8 +2,8 @@ * Help content for Calendar app */ -import type { HelpContent } from '@manacore/shared-help-types'; -import { getPrivacyFAQs } from '@manacore/shared-help-types'; +import type { HelpContent } from '@manacore/help'; +import { getPrivacyFAQs } from '@manacore/help'; export function getCalendarHelpContent(locale: string): HelpContent { const isDE = locale === 'de'; diff --git a/apps/calendar/apps/web/src/routes/(app)/help/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/help/+page.svelte index a87bbd7f9..ba8073a43 100644 --- a/apps/calendar/apps/web/src/routes/(app)/help/+page.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/help/+page.svelte @@ -1,7 +1,7 @@ - - goto('/')} - showGettingStarted={false} - showChangelog={false} -/> -``` - -## HelpPage Props - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `content` | `HelpContent` | required | Help content data | -| `appName` | `string` | required | Display name of the app | -| `appId` | `string` | required | App identifier | -| `translations` | `HelpPageTranslations` | required | UI translations | -| `searchEnabled` | `boolean` | `true` | Show search bar | -| `showFAQ` | `boolean` | `true` | Show FAQ section | -| `showFeatures` | `boolean` | `true` | Show Features section | -| `showShortcuts` | `boolean` | `true` | Show Shortcuts section | -| `showGettingStarted` | `boolean` | `true` | Show Getting Started section | -| `showChangelog` | `boolean` | `true` | Show Changelog section | -| `showContact` | `boolean` | `true` | Show Contact section | -| `defaultSection` | `HelpSection` | `'faq'` | Initially active section | -| `showBackButton` | `boolean` | `false` | Show back navigation | -| `onBack` | `() => void` | - | Back button callback | - -Sections with empty content are automatically hidden. - -## Translations Template - -```typescript -const translations: HelpPageTranslations = { - title: 'Help & Support', - subtitle: 'Find answers and learn how to use the app', - searchPlaceholder: 'Search help...', - sections: { - faq: 'FAQ', - features: 'Features', - shortcuts: 'Shortcuts', - gettingStarted: 'Getting Started', - changelog: 'Changelog', - contact: 'Contact', - }, - search: { - noResults: 'No results for "{query}"', - resultsCount: '{count} results', - searching: 'Searching...', - }, - faq: { - noItems: 'No FAQs available.', - allCategories: 'All', - categories: { - general: 'General', - account: 'Account', - billing: 'Billing', - features: 'Features', - technical: 'Technical', - privacy: 'Privacy', - }, - }, - features: { - noItems: 'No features available.', - comingSoon: 'Coming Soon', - learnMore: 'Learn More', - }, - shortcuts: { - noItems: 'No shortcuts available.', - columns: { - shortcut: 'Shortcut', - action: 'Action', - description: 'Description', - }, - }, - gettingStarted: { - noItems: 'No guides available.', - estimatedTime: 'Estimated time', - difficulty: { - beginner: 'Beginner', - intermediate: 'Intermediate', - advanced: 'Advanced', - }, - }, - changelog: { - noItems: 'No changelog available.', - showAll: 'Show all releases', - types: { major: 'Major', minor: 'Minor', patch: 'Patch', beta: 'Beta' }, - labels: { - features: 'New Features', - improvements: 'Improvements', - bugFixes: 'Bug Fixes', - }, - }, - contact: { - noInfo: 'No contact info available.', - email: 'Send email', - responseTime: 'Response time', - }, - common: { - back: 'Back', - showMore: 'Show more', - showLess: 'Show less', - }, -}; -``` - -## Content Types - -### FAQ - -```typescript -{ - id: string; // Unique ID - question: string; // The question - answer: string; // HTML answer (auto-sanitized) - category: 'general' | 'account' | 'billing' | 'features' | 'technical' | 'privacy'; - order: number; - language: 'en' | 'de' | 'fr' | 'it' | 'es'; - featured?: boolean; - tags?: string[]; -} -``` - -### Shortcuts - -```typescript -{ - id: string; - category: 'navigation' | 'editing' | 'general' | 'app-specific'; - language: string; - order: number; - shortcuts: Array<{ - shortcut: string; // e.g. "Cmd/Ctrl + K" - action: string; // e.g. "Open search" - description?: string; - }>; -} -``` - -### Contact - -```typescript -{ - id: string; - title: string; - content: string; // HTML (auto-sanitized) - language: string; - order: number; - supportEmail?: string; - supportUrl?: string; - discordUrl?: string; - documentationUrl?: string; - responseTime?: string; -} -``` - -## Security - -All HTML content is automatically sanitized via `isomorphic-dompurify` in the parser layer. -Content passed through `{@html}` in components is safe against XSS. - -## Reference Implementation - -See `apps/contacts/apps/web/src/routes/(app)/help/+page.svelte` for a complete working example. diff --git a/packages/shared-help-ui/package.json b/packages/shared-help-ui/package.json deleted file mode 100644 index 9828cd4e8..000000000 --- a/packages/shared-help-ui/package.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "name": "@manacore/shared-help-ui", - "version": "1.0.0", - "private": true, - "type": "module", - "svelte": "./src/index.ts", - "main": "./src/index.ts", - "types": "./src/index.ts", - "exports": { - ".": { - "svelte": "./src/index.ts", - "types": "./src/index.ts", - "default": "./src/index.ts" - }, - "./HelpPage.svelte": { - "svelte": "./src/pages/HelpPage.svelte", - "default": "./src/pages/HelpPage.svelte" - }, - "./FAQSection.svelte": { - "svelte": "./src/components/FAQSection.svelte", - "default": "./src/components/FAQSection.svelte" - }, - "./FeaturesOverview.svelte": { - "svelte": "./src/components/FeaturesOverview.svelte", - "default": "./src/components/FeaturesOverview.svelte" - }, - "./KeyboardShortcuts.svelte": { - "svelte": "./src/components/KeyboardShortcuts.svelte", - "default": "./src/components/KeyboardShortcuts.svelte" - }, - "./GettingStartedGuide.svelte": { - "svelte": "./src/components/GettingStartedGuide.svelte", - "default": "./src/components/GettingStartedGuide.svelte" - }, - "./ChangelogSection.svelte": { - "svelte": "./src/components/ChangelogSection.svelte", - "default": "./src/components/ChangelogSection.svelte" - }, - "./ContactSection.svelte": { - "svelte": "./src/components/ContactSection.svelte", - "default": "./src/components/ContactSection.svelte" - }, - "./HelpSearch.svelte": { - "svelte": "./src/components/HelpSearch.svelte", - "default": "./src/components/HelpSearch.svelte" - } - }, - "scripts": { - "check": "svelte-check --tsconfig ./tsconfig.json", - "lint": "eslint ." - }, - "dependencies": { - "@manacore/shared-help-types": "workspace:*", - "@manacore/shared-help-content": "workspace:*", - "@manacore/shared-icons": "workspace:*" - }, - "devDependencies": { - "svelte": "^5.0.0", - "svelte-check": "^4.0.0", - "typescript": "^5.7.3" - }, - "peerDependencies": { - "svelte": "^5.0.0" - } -} diff --git a/packages/shared-help-ui/src/components/ChangelogEntry.svelte b/packages/shared-help-ui/src/components/ChangelogEntry.svelte deleted file mode 100644 index 642faf8f7..000000000 --- a/packages/shared-help-ui/src/components/ChangelogEntry.svelte +++ /dev/null @@ -1,152 +0,0 @@ - - -
- - - {#if expanded} -
- {#if item.summary} -

{item.summary}

- {/if} - - {#if item.changes} - {#if item.changes.features && item.changes.features.length > 0} -
-
- {changeLabels.features} -
-
    - {#each item.changes.features as change} -
  • - + - - {change.title} - {#if change.description} - - {change.description} - {/if} - -
  • - {/each} -
-
- {/if} - - {#if item.changes.improvements && item.changes.improvements.length > 0} -
-
- {changeLabels.improvements} -
-
    - {#each item.changes.improvements as change} -
  • - - - {change.title} - {#if change.description} - - {change.description} - {/if} - -
  • - {/each} -
-
- {/if} - - {#if item.changes.bugfixes && item.changes.bugfixes.length > 0} -
-
- {changeLabels.bugFixes} -
-
    - {#each item.changes.bugfixes as change} -
  • - × - - {change.title} - {#if change.description} - - {change.description} - {/if} - -
  • - {/each} -
-
- {/if} - {/if} - - {#if item.content} -
- {@html item.content} -
- {/if} -
- {/if} -
diff --git a/packages/shared-help-ui/src/components/ChangelogSection.svelte b/packages/shared-help-ui/src/components/ChangelogSection.svelte deleted file mode 100644 index 2bd921053..000000000 --- a/packages/shared-help-ui/src/components/ChangelogSection.svelte +++ /dev/null @@ -1,45 +0,0 @@ - - -{#if items.length === 0} -

- {translations.changelog.noItems} -

-{:else} -
- {#each displayedItems() as item (item.id)} - - {/each} - - {#if hasMore} -
- -
- {/if} -
-{/if} diff --git a/packages/shared-help-ui/src/components/ContactSection.svelte b/packages/shared-help-ui/src/components/ContactSection.svelte deleted file mode 100644 index d22bbf656..000000000 --- a/packages/shared-help-ui/src/components/ContactSection.svelte +++ /dev/null @@ -1,123 +0,0 @@ - - -{#if !contact} -

- {translations.contact.noInfo} -

-{:else} -
-
- {@html contact.content} -
- -
- {#if contact.supportEmail} - -
- - - -
-
-

- {translations.contact.email} -

-

- {contact.supportEmail} -

-
-
- {/if} - - {#if contact.responseTime} -
-
- - - -
-
-

- {translations.contact.responseTime} -

-

- {contact.responseTime} -

-
-
- {/if} - - {#if contact.discordUrl} - -
- - - -
-
-

Discord

-

Join our community

-
-
- {/if} - - {#if contact.documentationUrl} - -
- - - -
-
-

Documentation

-

Read the docs

-
-
- {/if} -
-
-{/if} diff --git a/packages/shared-help-ui/src/components/FAQItem.svelte b/packages/shared-help-ui/src/components/FAQItem.svelte deleted file mode 100644 index 2f563282c..000000000 --- a/packages/shared-help-ui/src/components/FAQItem.svelte +++ /dev/null @@ -1,46 +0,0 @@ - - -
- - - {#if expanded} -
- {@html item.answer} -
- {/if} -
diff --git a/packages/shared-help-ui/src/components/FAQSection.svelte b/packages/shared-help-ui/src/components/FAQSection.svelte deleted file mode 100644 index 5bf0d30a2..000000000 --- a/packages/shared-help-ui/src/components/FAQSection.svelte +++ /dev/null @@ -1,108 +0,0 @@ - - -
- {#if showCategories && items.length > 0} -
- - {#each categories as category} - - {/each} -
- {/if} - - {#if filteredItems().length === 0} -

- {translations.faq.noItems} -

- {:else} -
- {#each filteredItems() as item (item.id)} - toggleItem(item.id)} - /> - {/each} -
- {/if} - - {#if hasMore} -
- -
- {/if} -
diff --git a/packages/shared-help-ui/src/components/FeatureCard.svelte b/packages/shared-help-ui/src/components/FeatureCard.svelte deleted file mode 100644 index 54c702373..000000000 --- a/packages/shared-help-ui/src/components/FeatureCard.svelte +++ /dev/null @@ -1,68 +0,0 @@ - - -
- {#if item.comingSoon} - - {comingSoonLabel} - - {/if} - -
- {#if item.icon} - {item.icon} - {/if} -

- {item.title} -

-
- -

- {item.description} -

- - {#if item.highlights && item.highlights.length > 0} -
    - {#each item.highlights as highlight} -
  • - - - - {highlight} -
  • - {/each} -
- {/if} - - {#if item.learnMoreUrl} - - {learnMoreLabel} → - - {/if} -
diff --git a/packages/shared-help-ui/src/components/FeaturesOverview.svelte b/packages/shared-help-ui/src/components/FeaturesOverview.svelte deleted file mode 100644 index c45d4570a..000000000 --- a/packages/shared-help-ui/src/components/FeaturesOverview.svelte +++ /dev/null @@ -1,50 +0,0 @@ - - -{#if !hasItems} -

- {translations.features.noItems} -

-{:else} -
- {#each Object.entries(groupedItems()) as [_category, categoryItems]} - {#if categoryItems.length > 0} -
- {#each categoryItems as item (item.id)} - - {/each} -
- {/if} - {/each} -
-{/if} diff --git a/packages/shared-help-ui/src/components/GettingStartedGuide.svelte b/packages/shared-help-ui/src/components/GettingStartedGuide.svelte deleted file mode 100644 index f4a046071..000000000 --- a/packages/shared-help-ui/src/components/GettingStartedGuide.svelte +++ /dev/null @@ -1,108 +0,0 @@ - - -{#if items.length === 0} -

- {translations.gettingStarted.noItems} -

-{:else} -
- -
- {#each items as item (item.id)} - - {/each} -
- - -
- {#if guide} -
-

- {guide.title} -

-

- {guide.description} -

- - {#if guide.steps && guide.steps.length > 0} -
- {#each guide.steps as step, index} -
-
- {index + 1} -
-
-

- {step.title} -

-
- {step.content} -
-
-
- {/each} -
- {:else} -
- {@html guide.content} -
- {/if} -
- {/if} -
-
-{/if} diff --git a/packages/shared-help-ui/src/components/HelpSearch.svelte b/packages/shared-help-ui/src/components/HelpSearch.svelte deleted file mode 100644 index 7a9fe56fb..000000000 --- a/packages/shared-help-ui/src/components/HelpSearch.svelte +++ /dev/null @@ -1,218 +0,0 @@ - - -
-
- query.length >= 2 && (showResults = true)} - onblur={handleBlur} - placeholder={placeholder ?? translations.search.noResults} - class="w-full rounded-lg border border-gray-300 bg-white py-2.5 pl-10 pr-4 text-sm text-gray-900 placeholder-gray-500 transition-colors focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400" - aria-label={placeholder ?? translations.search.noResults} - role="combobox" - aria-expanded={showResults} - aria-haspopup="listbox" - /> -
- {#if isSearching} - - {:else} - - {/if} -
-
- - {#if showResults} -
- {#if results.length === 0} -
- {translations.search.noResults.replace('{query}', query)} -
- {:else} -
    - {#each results as result, index (result.id)} -
  • - -
  • - {/each} -
-
- {translations.search.resultsCount.replace('{count}', String(results.length))} -
- {/if} -
- {/if} -
diff --git a/packages/shared-help-ui/src/components/KeyboardShortcuts.svelte b/packages/shared-help-ui/src/components/KeyboardShortcuts.svelte deleted file mode 100644 index f56e041cd..000000000 --- a/packages/shared-help-ui/src/components/KeyboardShortcuts.svelte +++ /dev/null @@ -1,60 +0,0 @@ - - -{#if !hasItems} -

- {translations.shortcuts.noItems} -

-{:else} -
- - - - - - - - - - {#each allShortcuts() as shortcut} - - - - - - {/each} - -
{translations.shortcuts.columns.shortcut}{translations.shortcuts.columns.action}{translations.shortcuts.columns.description}
- - {shortcut.shortcut} - - - {shortcut.action} - - {shortcut.description || '-'} -
-
-{/if} diff --git a/packages/shared-help-ui/src/index.ts b/packages/shared-help-ui/src/index.ts deleted file mode 100644 index cc565e012..000000000 --- a/packages/shared-help-ui/src/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * @manacore/shared-help-ui - * Svelte 5 components for the Help page system - */ - -// Main page component -export { default as HelpPage } from './pages/HelpPage.svelte'; - -// Section components -export { default as FAQSection } from './components/FAQSection.svelte'; -export { default as FAQItem } from './components/FAQItem.svelte'; -export { default as FeaturesOverview } from './components/FeaturesOverview.svelte'; -export { default as FeatureCard } from './components/FeatureCard.svelte'; -export { default as KeyboardShortcuts } from './components/KeyboardShortcuts.svelte'; -export { default as GettingStartedGuide } from './components/GettingStartedGuide.svelte'; -export { default as ChangelogSection } from './components/ChangelogSection.svelte'; -export { default as ChangelogEntry } from './components/ChangelogEntry.svelte'; -export { default as ContactSection } from './components/ContactSection.svelte'; -export { default as HelpSearch } from './components/HelpSearch.svelte'; - -// Default translations -export { - defaultTranslationsDE, - defaultTranslationsEN, - getHelpTranslations, -} from './translations.js'; - -// Types -export type { - HelpPageProps, - HelpPageTranslations, - HelpSection, - FAQSectionProps, - FeaturesOverviewProps, - KeyboardShortcutsProps, - GettingStartedGuideProps, - ChangelogEntryProps, - ChangelogSectionProps, - ContactSectionProps, - HelpSearchProps, -} from './types.js'; diff --git a/packages/shared-help-ui/src/pages/HelpPage.svelte b/packages/shared-help-ui/src/pages/HelpPage.svelte deleted file mode 100644 index 703f5ab97..000000000 --- a/packages/shared-help-ui/src/pages/HelpPage.svelte +++ /dev/null @@ -1,173 +0,0 @@ - - -
- -
- {#if showBackButton} - - {/if} - -

- {translations.title} -

- {#if translations.subtitle} -

- {translations.subtitle} - {appName} -

- {/if} -
- - - {#if searchEnabled} -
- -
- {/if} - - - {#if visibleSections.length > 1} -
- -
- {/if} - - -
- {#if activeSection === 'faq' && showFAQ} - - {:else if activeSection === 'features' && showFeatures} - - {:else if activeSection === 'shortcuts' && showShortcuts} - - {:else if activeSection === 'getting-started' && showGettingStarted} - - {:else if activeSection === 'changelog' && showChangelog} - - {:else if activeSection === 'contact' && showContact} - - {/if} -
-
diff --git a/packages/shared-help-ui/src/translations.ts b/packages/shared-help-ui/src/translations.ts deleted file mode 100644 index e6c116852..000000000 --- a/packages/shared-help-ui/src/translations.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * Default translations for the HelpPage component. - * Apps can use these directly or override individual fields. - */ - -import type { HelpPageTranslations } from './types.js'; - -export const defaultTranslationsDE: HelpPageTranslations = { - title: 'Hilfe & Support', - subtitle: 'Finde Antworten und lerne die App kennen', - searchPlaceholder: 'Hilfe durchsuchen...', - sections: { - faq: 'FAQ', - features: 'Features', - shortcuts: 'Tastenkürzel', - gettingStarted: 'Erste Schritte', - changelog: 'Änderungen', - contact: 'Kontakt', - }, - search: { - noResults: 'Keine Ergebnisse für "{query}"', - resultsCount: '{count} Ergebnisse', - searching: 'Suche...', - }, - faq: { - noItems: 'Keine häufigen Fragen verfügbar.', - allCategories: 'Alle', - categories: { - general: 'Allgemein', - account: 'Konto', - billing: 'Abrechnung', - features: 'Funktionen', - technical: 'Technisch', - privacy: 'Datenschutz', - }, - }, - features: { - noItems: 'Keine Features verfügbar.', - comingSoon: 'Demnächst', - learnMore: 'Mehr erfahren', - }, - shortcuts: { - noItems: 'Keine Tastenkürzel verfügbar.', - columns: { - shortcut: 'Kürzel', - action: 'Aktion', - description: 'Beschreibung', - }, - }, - gettingStarted: { - noItems: 'Keine Anleitungen verfügbar.', - estimatedTime: 'Geschätzte Zeit', - difficulty: { - beginner: 'Einsteiger', - intermediate: 'Fortgeschritten', - advanced: 'Experte', - }, - }, - changelog: { - noItems: 'Keine Änderungen verfügbar.', - showAll: 'Alle Versionen anzeigen', - types: { - major: 'Hauptversion', - minor: 'Nebenversion', - patch: 'Patch', - beta: 'Beta', - }, - labels: { - features: 'Neue Funktionen', - improvements: 'Verbesserungen', - bugFixes: 'Fehlerbehebungen', - }, - }, - contact: { - noInfo: 'Keine Kontaktinformationen verfügbar.', - email: 'E-Mail senden', - responseTime: 'Antwortzeit', - }, - common: { - back: 'Zurück', - showMore: 'Mehr anzeigen', - showLess: 'Weniger anzeigen', - }, -}; - -export const defaultTranslationsEN: HelpPageTranslations = { - title: 'Help & Support', - subtitle: 'Find answers and learn how to use the app', - searchPlaceholder: 'Search help...', - sections: { - faq: 'FAQ', - features: 'Features', - shortcuts: 'Shortcuts', - gettingStarted: 'Getting Started', - changelog: 'Changelog', - contact: 'Contact', - }, - search: { - noResults: 'No results for "{query}"', - resultsCount: '{count} results', - searching: 'Searching...', - }, - faq: { - noItems: 'No frequently asked questions available.', - allCategories: 'All', - categories: { - general: 'General', - account: 'Account', - billing: 'Billing', - features: 'Features', - technical: 'Technical', - privacy: 'Privacy', - }, - }, - features: { - noItems: 'No features available.', - comingSoon: 'Coming Soon', - learnMore: 'Learn More', - }, - shortcuts: { - noItems: 'No keyboard shortcuts available.', - columns: { - shortcut: 'Shortcut', - action: 'Action', - description: 'Description', - }, - }, - gettingStarted: { - noItems: 'No guides available.', - estimatedTime: 'Estimated time', - difficulty: { - beginner: 'Beginner', - intermediate: 'Intermediate', - advanced: 'Advanced', - }, - }, - changelog: { - noItems: 'No changelog available.', - showAll: 'Show all releases', - types: { - major: 'Major', - minor: 'Minor', - patch: 'Patch', - beta: 'Beta', - }, - labels: { - features: 'New Features', - improvements: 'Improvements', - bugFixes: 'Bug Fixes', - }, - }, - contact: { - noInfo: 'No contact information available.', - email: 'Send email', - responseTime: 'Response time', - }, - common: { - back: 'Back', - showMore: 'Show more', - showLess: 'Show less', - }, -}; - -/** - * Get default translations for a locale, with optional overrides. - * Use this to customize only the fields that differ per app (e.g. subtitle). - */ -export function getHelpTranslations( - locale: string, - overrides?: Partial -): HelpPageTranslations { - const base = locale === 'de' ? defaultTranslationsDE : defaultTranslationsEN; - if (!overrides) return base; - return { ...base, ...overrides }; -} diff --git a/packages/shared-help-ui/src/types.ts b/packages/shared-help-ui/src/types.ts deleted file mode 100644 index 1a77f75bf..000000000 --- a/packages/shared-help-ui/src/types.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * Component Props and Translation Types - */ - -import type { HelpContent, SearchResult } from '@manacore/shared-help-types'; - -// ============================================================================ -// Translation Types -// ============================================================================ - -export interface HelpPageTranslations { - title: string; - subtitle?: string; - searchPlaceholder: string; - sections: { - faq: string; - features: string; - shortcuts: string; - gettingStarted: string; - changelog: string; - contact: string; - }; - search: { - noResults: string; - resultsCount: string; - searching: string; - }; - faq: { - noItems: string; - allCategories: string; - categories: { - general: string; - account: string; - billing: string; - features: string; - technical: string; - privacy: string; - }; - }; - features: { - noItems: string; - comingSoon: string; - learnMore: string; - }; - shortcuts: { - noItems: string; - columns: { - shortcut: string; - action: string; - description: string; - }; - }; - gettingStarted: { - noItems: string; - estimatedTime: string; - difficulty: { - beginner: string; - intermediate: string; - advanced: string; - }; - }; - changelog: { - noItems: string; - showAll: string; - types: { - major: string; - minor: string; - patch: string; - beta: string; - }; - labels: { - features: string; - improvements: string; - bugFixes: string; - }; - }; - contact: { - noInfo: string; - email: string; - responseTime: string; - }; - common: { - back: string; - showMore: string; - showLess: string; - }; -} - -// ============================================================================ -// Component Props -// ============================================================================ - -export type HelpSection = - | 'faq' - | 'features' - | 'shortcuts' - | 'getting-started' - | 'changelog' - | 'contact'; - -export interface HelpPageProps { - content: HelpContent; - appName: string; - appId: string; - translations: HelpPageTranslations; - searchEnabled?: boolean; - showFAQ?: boolean; - showFeatures?: boolean; - showShortcuts?: boolean; - showGettingStarted?: boolean; - showChangelog?: boolean; - showContact?: boolean; - defaultSection?: HelpSection; - showBackButton?: boolean; - onBack?: () => void; - onSectionChange?: (section: HelpSection) => void; - onSearch?: (query: string, results: SearchResult[]) => void; -} - -export interface FAQSectionProps { - items: HelpContent['faq']; - translations: Pick; - showCategories?: boolean; - maxItems?: number; - expandFirst?: boolean; -} - -export interface ChangelogEntryProps { - item: HelpContent['changelog'][number]; - translations: Pick; -} - -export interface FeaturesOverviewProps { - items: HelpContent['features']; - translations: Pick; -} - -export interface KeyboardShortcutsProps { - items: HelpContent['shortcuts']; - translations: Pick; -} - -export interface GettingStartedGuideProps { - items: HelpContent['gettingStarted']; - translations: Pick; -} - -export interface ChangelogSectionProps { - items: HelpContent['changelog']; - translations: Pick; - maxItems?: number; -} - -export interface ContactSectionProps { - contact: HelpContent['contact']; - translations: Pick; -} - -export interface HelpSearchProps { - content: HelpContent; - translations: Pick; - placeholder?: string; - onResultSelect: (result: SearchResult) => void; -} diff --git a/packages/shared-help-ui/tsconfig.json b/packages/shared-help-ui/tsconfig.json deleted file mode 100644 index 1b9470b61..000000000 --- a/packages/shared-help-ui/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "verbatimModuleSyntax": true, - "noEmit": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules"] -} diff --git a/packages/shared-nestjs-auth/README.md b/packages/shared-nestjs-auth/README.md deleted file mode 100644 index 4b6801a61..000000000 --- a/packages/shared-nestjs-auth/README.md +++ /dev/null @@ -1,142 +0,0 @@ -# @manacore/shared-nestjs-auth - -Shared authentication utilities for NestJS backends in the Mana Core ecosystem. - -## Installation - -```bash -pnpm add @manacore/shared-nestjs-auth -``` - -## Usage - -### 1. Configure Environment Variables - -```env -# Required: Mana Core Auth service URL -MANA_CORE_AUTH_URL=http://localhost:3001 - -# Optional: Development mode auth bypass -NODE_ENV=development -DEV_BYPASS_AUTH=true -DEV_USER_ID=your-test-user-id -``` - -### 2. Use in Controllers - -```typescript -import { Controller, Get, UseGuards } from '@nestjs/common'; -import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; - -@Controller('api') -export class MyController { - // Public endpoint - @Get('health') - health() { - return { status: 'ok' }; - } - - // Protected endpoint - @Get('profile') - @UseGuards(JwtAuthGuard) - getProfile(@CurrentUser() user: CurrentUserData) { - return { - userId: user.userId, - email: user.email, - role: user.role, - }; - } -} -``` - -### 3. Apply Guard Globally (Optional) - -```typescript -// app.module.ts -import { Module } from '@nestjs/common'; -import { APP_GUARD } from '@nestjs/core'; -import { JwtAuthGuard } from '@manacore/shared-nestjs-auth'; - -@Module({ - providers: [ - { - provide: APP_GUARD, - useClass: JwtAuthGuard, - }, - ], -}) -export class AppModule {} -``` - -## API - -### JwtAuthGuard - -A NestJS guard that validates JWT tokens via the Mana Core Auth service. - -- Extracts Bearer token from `Authorization` header -- Calls `POST /api/v1/auth/validate` on auth service -- Attaches user data to request object -- Supports `DEV_BYPASS_AUTH=true` for development - -### CurrentUser Decorator - -Parameter decorator to extract the authenticated user from the request. - -```typescript -@Get('me') -@UseGuards(JwtAuthGuard) -getMe(@CurrentUser() user: CurrentUserData) { - return user; -} -``` - -### CurrentUserData Interface - -```typescript -interface CurrentUserData { - userId: string; // User ID (from JWT sub claim) - email: string; // User email - role: string; // User role (user, admin, service) - sessionId?: string; // Session ID (optional) -} -``` - -## How It Works - -1. Client sends request with `Authorization: Bearer ` header -2. JwtAuthGuard extracts the token -3. Guard calls Mana Core Auth service to validate token -4. On success, user data is attached to `request.user` -5. Controller receives user via `@CurrentUser()` decorator - -``` -┌─────────────┐ ┌─────────────┐ ┌────────────────┐ -│ Client │────>│ Your API │────>│ mana-core-auth │ -│ │ │ (NestJS) │ │ (port 3001) │ -└─────────────┘ └─────────────┘ └────────────────┘ - │ │ │ - │ Bearer token │ POST /validate │ - │ │ {token} │ - │ │<────────────────────│ - │ │ {valid, payload} │ - │<──────────────────│ │ - │ Response │ │ -``` - -## Development Mode - -Set `DEV_BYPASS_AUTH=true` in development to skip token validation: - -```env -NODE_ENV=development -DEV_BYPASS_AUTH=true -DEV_USER_ID=17cb0be7-058a-4964-9e18-1fe7055fd014 -``` - -This will use a mock user for all authenticated requests. - -## Related Packages - -- `@manacore/shared-auth` - Client-side auth for web/mobile apps -- `@manacore/shared-types` - Shared TypeScript types diff --git a/packages/shared-nestjs-auth/package.json b/packages/shared-nestjs-auth/package.json deleted file mode 100644 index 18cabf84d..000000000 --- a/packages/shared-nestjs-auth/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@manacore/shared-nestjs-auth", - "version": "1.0.0", - "description": "Shared authentication utilities for NestJS backends - JWT validation via Mana Core Auth", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "scripts": { - "build": "tsc", - "clean": "rm -rf dist", - "prepublishOnly": "pnpm build", - "lint": "eslint ." - }, - "files": [ - "dist" - ], - "dependencies": { - "jose": "^5.0.0" - }, - "peerDependencies": { - "@nestjs/common": "^10.0.0 || ^11.0.0", - "@nestjs/config": "^3.0.0 || ^4.0.0" - }, - "devDependencies": { - "@nestjs/common": "^10.0.0", - "@nestjs/config": "^3.0.0", - "@types/node": "^20.0.0", - "typescript": "^5.0.0" - }, - "keywords": [ - "nestjs", - "auth", - "jwt", - "manacore" - ], - "author": "Mana Core Team", - "license": "MIT" -} diff --git a/packages/shared-nestjs-auth/src/decorators/current-user.decorator.ts b/packages/shared-nestjs-auth/src/decorators/current-user.decorator.ts deleted file mode 100644 index 784686d6a..000000000 --- a/packages/shared-nestjs-auth/src/decorators/current-user.decorator.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createParamDecorator } from '@nestjs/common'; -import type { ExecutionContext } from '@nestjs/common'; -import { type CurrentUserData } from '../types'; - -/** - * Parameter decorator to extract the current user from the request. - * - * @example - * ```typescript - * @Get('profile') - * @UseGuards(JwtAuthGuard) - * getProfile(@CurrentUser() user: CurrentUserData) { - * return { userId: user.userId }; - * } - * ``` - */ -export const CurrentUser = createParamDecorator( - (data: unknown, ctx: ExecutionContext): CurrentUserData => { - const request = ctx.switchToHttp().getRequest(); - return request.user; - } -); diff --git a/packages/shared-nestjs-auth/src/guards/jwt-auth.guard.ts b/packages/shared-nestjs-auth/src/guards/jwt-auth.guard.ts deleted file mode 100644 index 79918ed83..000000000 --- a/packages/shared-nestjs-auth/src/guards/jwt-auth.guard.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'; -import { CurrentUserData } from '../types'; - -// Default development test user ID -const DEFAULT_DEV_USER_ID = '00000000-0000-0000-0000-000000000000'; - -/** Cached JWKS instance - shared across all guard instances within the same process */ -let cachedJWKS: ReturnType | null = null; -let cachedJWKSUrl: string | null = null; - -/** - * JWT Authentication Guard for NestJS backends. - * - * Verifies JWT tokens locally using JWKS (JSON Web Key Set) fetched from - * the Mana Core Auth service. The JWKS is cached automatically by the - * jose library (~10 min cooldown between refetches). - * - * This eliminates the need for an HTTP call per request - tokens are - * verified locally using the public keys from the JWKS endpoint. - * - * @example - * ```typescript - * // In your controller - * @Controller('api') - * @UseGuards(JwtAuthGuard) - * export class MyController { - * @Get('protected') - * getProtected(@CurrentUser() user: CurrentUserData) { - * return { userId: user.userId }; - * } - * } - * ``` - * - * @example - * ```typescript - * // Environment variables - * MANA_CORE_AUTH_URL=http://localhost:3001 - * DEV_BYPASS_AUTH=true // Optional: for development - * DEV_USER_ID=your-test-user-id // Optional: custom dev user - * JWT_ISSUER=http://localhost:3001 // Optional: defaults to MANA_CORE_AUTH_URL - * JWT_AUDIENCE=manacore // Optional: defaults to 'manacore' - * ``` - */ -@Injectable() -export class JwtAuthGuard implements CanActivate { - constructor(private configService: ConfigService) {} - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - - // Development mode: bypass auth if DEV_BYPASS_AUTH is set - if (this.shouldBypassAuth()) { - request.user = this.getDevUser(); - return true; - } - - const token = this.extractTokenFromHeader(request); - - if (!token) { - throw new UnauthorizedException('No token provided'); - } - - try { - const userData = await this.verifyToken(token); - request.user = userData; - return true; - } catch (error) { - if (error instanceof UnauthorizedException) { - throw error; - } - console.error( - '[JwtAuthGuard] Token verification failed:', - error instanceof Error ? error.message : error - ); - throw new UnauthorizedException('Token validation failed'); - } - } - - /** - * Check if auth should be bypassed (development mode) - */ - private shouldBypassAuth(): boolean { - const isDev = this.configService.get('NODE_ENV') === 'development'; - const bypassAuth = this.configService.get('DEV_BYPASS_AUTH') === 'true'; - return isDev && bypassAuth; - } - - /** - * Get development user data - */ - private getDevUser(): CurrentUserData { - return { - userId: this.configService.get('DEV_USER_ID') || DEFAULT_DEV_USER_ID, - email: 'dev@example.com', - role: 'user', - sessionId: 'dev-session', - }; - } - - /** - * Get or create the cached JWKS key set. - * The jose library's createRemoteJWKSet handles caching internally - * with a ~10 minute cooldown between refetches. - */ - private getJWKS(): ReturnType { - const authUrl = this.configService.get('MANA_CORE_AUTH_URL') || 'http://localhost:3001'; - const jwksUrl = `${authUrl}/api/v1/auth/jwks`; - - // Reuse cached JWKS if the URL hasn't changed - if (cachedJWKS && cachedJWKSUrl === jwksUrl) { - return cachedJWKS; - } - - cachedJWKS = createRemoteJWKSet(new URL(jwksUrl)); - cachedJWKSUrl = jwksUrl; - return cachedJWKS; - } - - /** - * Verify JWT token locally using JWKS - */ - private async verifyToken(token: string): Promise { - const audience = this.configService.get('JWT_AUDIENCE') || 'manacore'; - - // Build issuer allowlist: explicit JWT_ISSUER, MANA_CORE_AUTH_URL, and BASE_URL may all differ - // (e.g. internal Docker URL vs public URL). Accept any of them. - const issuerCandidates = new Set(); - const jwtIssuer = this.configService.get('JWT_ISSUER'); - const authUrl = this.configService.get('MANA_CORE_AUTH_URL'); - if (jwtIssuer) issuerCandidates.add(jwtIssuer); - if (authUrl) issuerCandidates.add(authUrl); - // Always accept the well-known production issuer - issuerCandidates.add('https://auth.mana.how'); - issuerCandidates.add('http://localhost:3001'); - - const jwks = this.getJWKS(); - - const { payload } = await jwtVerify(token, jwks, { - issuer: [...issuerCandidates], - audience, - }); - - return this.extractUserData(payload); - } - - /** - * Extract user data from verified JWT payload - */ - private extractUserData(payload: JWTPayload): CurrentUserData { - if (!payload.sub) { - throw new UnauthorizedException('Token missing subject claim'); - } - - return { - userId: payload.sub, - email: (payload as any).email || '', - role: (payload as any).role || 'user', - sessionId: (payload as any).sid || (payload as any).sessionId, - }; - } - - /** - * Extract Bearer token from Authorization header - */ - private extractTokenFromHeader(request: any): string | undefined { - const authHeader = request.headers.authorization; - if (!authHeader) { - return undefined; - } - - const [type, token] = authHeader.split(' '); - return type === 'Bearer' ? token : undefined; - } -} diff --git a/packages/shared-nestjs-auth/src/index.ts b/packages/shared-nestjs-auth/src/index.ts deleted file mode 100644 index 75b4541e7..000000000 --- a/packages/shared-nestjs-auth/src/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * @manacore/shared-nestjs-auth - * - * Shared authentication utilities for NestJS backends. - * Verifies JWT tokens locally using JWKS from the Mana Core Auth service. - * - * @example - * ```typescript - * import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; - * - * @Controller('api') - * @UseGuards(JwtAuthGuard) - * export class MyController { - * @Get('profile') - * getProfile(@CurrentUser() user: CurrentUserData) { - * return { userId: user.userId, email: user.email }; - * } - * } - * ``` - */ - -// Guards -export { JwtAuthGuard } from './guards/jwt-auth.guard'; - -// Decorators -export { CurrentUser } from './decorators/current-user.decorator'; - -// Types -export type { CurrentUserData, AuthModuleConfig, TokenValidationResponse } from './types'; diff --git a/packages/shared-nestjs-auth/src/types/index.ts b/packages/shared-nestjs-auth/src/types/index.ts deleted file mode 100644 index 62e39dc5a..000000000 --- a/packages/shared-nestjs-auth/src/types/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * User data extracted from JWT token - */ -export interface CurrentUserData { - userId: string; - email: string; - role: string; - sessionId?: string; -} - -/** - * Configuration for the auth module - */ -export interface AuthModuleConfig { - /** URL of the Mana Core Auth service (default: http://localhost:3001) */ - authServiceUrl?: string; - /** Whether to bypass auth in development mode (default: false) */ - devBypassAuth?: boolean; - /** Test user ID for development mode */ - devUserId?: string; -} - -/** - * Response from token validation endpoint - */ -export interface TokenValidationResponse { - valid: boolean; - payload?: { - sub: string; - email: string; - role: string; - sessionId?: string; - sid?: string; - iat?: number; - exp?: number; - iss?: string; - aud?: string; - }; - error?: string; -} diff --git a/packages/shared-nestjs-auth/tsconfig.json b/packages/shared-nestjs-auth/tsconfig.json deleted file mode 100644 index 71e4820d5..000000000 --- a/packages/shared-nestjs-auth/tsconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "target": "ES2021", - "sourceMap": true, - "outDir": "./dist", - "rootDir": "./src", - "baseUrl": "./", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": true, - "noImplicitAny": true, - "strictBindCallApply": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/packages/shared-nestjs-health/package.json b/packages/shared-nestjs-health/package.json deleted file mode 100644 index b6e5ba332..000000000 --- a/packages/shared-nestjs-health/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "@manacore/shared-nestjs-health", - "version": "1.0.0", - "description": "Shared NestJS health check module for ManaCore backends", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - } - }, - "scripts": { - "build": "tsc", - "clean": "rm -rf dist", - "type-check": "tsc --noEmit" - }, - "files": [ - "dist" - ], - "peerDependencies": { - "@nestjs/common": "^10.0.0 || ^11.0.0" - }, - "devDependencies": { - "@nestjs/common": "^10.0.0", - "@types/node": "^22.10.2", - "typescript": "^5.0.0" - } -} diff --git a/packages/shared-nestjs-health/src/index.ts b/packages/shared-nestjs-health/src/index.ts deleted file mode 100644 index 036002735..000000000 --- a/packages/shared-nestjs-health/src/index.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { Controller, DynamicModule, Get, Module } from '@nestjs/common'; - -/** - * Health check response - */ -export interface HealthCheckResponse { - status: 'ok' | 'error'; - service: string; - timestamp: string; - version?: string; - uptime?: number; -} - -/** - * Options for configuring the health module - */ -export interface HealthModuleOptions { - /** - * Service name to include in health response - * @example 'chat-backend' - */ - serviceName: string; - - /** - * Optional version string - * @example '1.0.0' - */ - version?: string; - - /** - * Include uptime in response (default: false) - */ - includeUptime?: boolean; - - /** - * Custom route path (default: 'health') - */ - route?: string; -} - -/** - * Create a health controller with the given options - */ -function createHealthController(options: HealthModuleOptions) { - const route = options.route || 'health'; - - @Controller(route) - class HealthController { - @Get() - check(): HealthCheckResponse { - const response: HealthCheckResponse = { - status: 'ok', - service: options.serviceName, - timestamp: new Date().toISOString(), - }; - - if (options.version) { - response.version = options.version; - } - - if (options.includeUptime) { - response.uptime = process.uptime(); - } - - return response; - } - } - - return HealthController; -} - -/** - * Shared health check module for NestJS backends - * - * Provides a simple health endpoint that returns: - * - status: 'ok' - * - service: configured service name - * - timestamp: ISO timestamp - * - version: (optional) service version - * - uptime: (optional) process uptime in seconds - * - * @example - * ```typescript - * // Basic usage - * @Module({ - * imports: [HealthModule.forRoot({ serviceName: 'chat-backend' })], - * }) - * export class AppModule {} - * - * // With all options - * @Module({ - * imports: [ - * HealthModule.forRoot({ - * serviceName: 'chat-backend', - * version: '1.0.0', - * includeUptime: true, - * route: 'health', // default - * }), - * ], - * }) - * export class AppModule {} - * ``` - */ -@Module({}) -export class HealthModule { - static forRoot(options: HealthModuleOptions): DynamicModule { - const HealthController = createHealthController(options); - - return { - module: HealthModule, - controllers: [HealthController], - }; - } -} diff --git a/packages/shared-nestjs-health/tsconfig.json b/packages/shared-nestjs-health/tsconfig.json deleted file mode 100644 index 71e4820d5..000000000 --- a/packages/shared-nestjs-health/tsconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "target": "ES2021", - "sourceMap": true, - "outDir": "./dist", - "rootDir": "./src", - "baseUrl": "./", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": true, - "noImplicitAny": true, - "strictBindCallApply": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/packages/shared-nestjs-metrics/package.json b/packages/shared-nestjs-metrics/package.json deleted file mode 100644 index b24aab614..000000000 --- a/packages/shared-nestjs-metrics/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@manacore/shared-nestjs-metrics", - "version": "1.0.0", - "description": "Prometheus metrics module for NestJS backends", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "scripts": { - "build": "tsc", - "clean": "rm -rf dist", - "prepublishOnly": "pnpm build", - "lint": "eslint ." - }, - "files": [ - "dist" - ], - "dependencies": { - "prom-client": "^15.1.0" - }, - "peerDependencies": { - "@nestjs/common": "^10.0.0 || ^11.0.0", - "@nestjs/core": "^10.0.0 || ^11.0.0" - }, - "devDependencies": { - "@nestjs/common": "^10.0.0", - "@nestjs/core": "^10.0.0", - "@types/express": "^4.17.21", - "@types/node": "^20.0.0", - "typescript": "^5.0.0" - }, - "keywords": [ - "nestjs", - "metrics", - "prometheus", - "monitoring", - "manacore" - ], - "author": "Mana Core Team", - "license": "MIT" -} diff --git a/packages/shared-nestjs-metrics/src/index.ts b/packages/shared-nestjs-metrics/src/index.ts deleted file mode 100644 index 4ac2e7b09..000000000 --- a/packages/shared-nestjs-metrics/src/index.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * @manacore/shared-nestjs-metrics - * - * Prometheus metrics module for NestJS backends. - * Automatically tracks HTTP requests, duration, and provides custom metrics. - * - * @example - * ```typescript - * import { MetricsModule } from '@manacore/shared-nestjs-metrics'; - * - * @Module({ - * imports: [ - * MetricsModule.register({ - * prefix: 'myapp_', - * defaultLabels: { app: 'my-backend' }, - * }), - * ], - * }) - * export class AppModule {} - * ``` - * - * The module automatically: - * - Exposes a `/metrics` endpoint for Prometheus scraping - * - Tracks HTTP request count, duration, and in-flight requests - * - Collects default Node.js metrics (CPU, memory, event loop) - * - * Custom metrics can be created via the MetricsService: - * ```typescript - * @Injectable() - * export class MyService { - * private readonly aiRequestCounter: Counter; - * - * constructor(private readonly metricsService: MetricsService) { - * this.aiRequestCounter = metricsService.createCounter( - * 'ai_requests_total', - * 'Total AI requests', - * ['model'] - * ); - * } - * - * async processAI(model: string) { - * this.aiRequestCounter.inc({ model }); - * // ... process - * } - * } - * ``` - */ - -// Module -export { MetricsModule, MetricsModuleOptions } from './metrics.module'; - -// Service -export { MetricsService, MetricsServiceOptions } from './metrics.service'; - -// Middleware -export { MetricsMiddleware } from './metrics.middleware'; - -// Controller -export { MetricsController } from './metrics.controller'; - -// Re-export prom-client types for convenience -export { Counter, Histogram, Gauge, Summary } from 'prom-client'; diff --git a/packages/shared-nestjs-metrics/src/metrics.controller.ts b/packages/shared-nestjs-metrics/src/metrics.controller.ts deleted file mode 100644 index f92dd6969..000000000 --- a/packages/shared-nestjs-metrics/src/metrics.controller.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Controller, Get, Res } from '@nestjs/common'; -import { Response } from 'express'; -import { MetricsService } from './metrics.service'; - -@Controller() -export class MetricsController { - constructor(private readonly metricsService: MetricsService) {} - - @Get('metrics') - async getMetrics(@Res() res: Response): Promise { - const metrics = await this.metricsService.getMetrics(); - res.set('Content-Type', this.metricsService.getContentType()); - res.send(metrics); - } -} diff --git a/packages/shared-nestjs-metrics/src/metrics.middleware.ts b/packages/shared-nestjs-metrics/src/metrics.middleware.ts deleted file mode 100644 index 5f253440d..000000000 --- a/packages/shared-nestjs-metrics/src/metrics.middleware.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Injectable, NestMiddleware } from '@nestjs/common'; -import { Request, Response, NextFunction } from 'express'; -import { MetricsService } from './metrics.service'; - -@Injectable() -export class MetricsMiddleware implements NestMiddleware { - constructor(private readonly metricsService: MetricsService) {} - - use(req: Request, res: Response, next: NextFunction): void { - // Skip metrics endpoint itself to avoid recursion - if (req.path === '/metrics') { - return next(); - } - - const startTime = Date.now(); - const method = req.method; - - // Track in-flight requests - this.metricsService.incrementInFlight(method); - - // Hook into response finish - res.on('finish', () => { - const duration = Date.now() - startTime; - const status = res.statusCode; - const path = req.route?.path || req.path; - - // Record metrics - this.metricsService.incrementHttpRequests(method, path, status); - this.metricsService.observeHttpDuration(method, path, status, duration); - this.metricsService.decrementInFlight(method); - }); - - next(); - } -} diff --git a/packages/shared-nestjs-metrics/src/metrics.module.ts b/packages/shared-nestjs-metrics/src/metrics.module.ts deleted file mode 100644 index 2e3db4504..000000000 --- a/packages/shared-nestjs-metrics/src/metrics.module.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Module, DynamicModule, MiddlewareConsumer, NestModule } from '@nestjs/common'; -import { MetricsService, MetricsServiceOptions } from './metrics.service'; -import { MetricsMiddleware } from './metrics.middleware'; -import { MetricsController } from './metrics.controller'; - -export interface MetricsModuleOptions extends MetricsServiceOptions { - /** - * Path for metrics endpoint (default: '/metrics') - */ - path?: string; - - /** - * Paths to exclude from metrics collection - */ - excludePaths?: string[]; - - /** - * Whether to register the metrics endpoint controller (default: true) - */ - registerController?: boolean; -} - -@Module({}) -export class MetricsModule implements NestModule { - private static options: MetricsModuleOptions = {}; - - /** - * Register the metrics module with options - * - * @example - * ```typescript - * import { MetricsModule } from '@manacore/shared-nestjs-metrics'; - * - * @Module({ - * imports: [ - * MetricsModule.register({ - * prefix: 'myapp_', - * defaultLabels: { app: 'my-backend' }, - * }), - * ], - * }) - * export class AppModule {} - * ``` - */ - static register(options: MetricsModuleOptions = {}): DynamicModule { - MetricsModule.options = options; - - const providers = [ - { - provide: MetricsService, - useFactory: () => new MetricsService(options), - }, - MetricsMiddleware, - ]; - - const controllers = options.registerController !== false ? [MetricsController] : []; - - return { - module: MetricsModule, - controllers, - providers, - exports: [MetricsService], - global: true, - }; - } - - configure(consumer: MiddlewareConsumer) { - const excludePaths = MetricsModule.options.excludePaths || []; - const metricsPath = MetricsModule.options.path || '/metrics'; - - // Always exclude the metrics endpoint itself - const allExcludePaths = [...excludePaths, metricsPath]; - - consumer - .apply(MetricsMiddleware) - .exclude(...allExcludePaths) - .forRoutes('*'); - } -} diff --git a/packages/shared-nestjs-metrics/src/metrics.service.ts b/packages/shared-nestjs-metrics/src/metrics.service.ts deleted file mode 100644 index 6201cdba0..000000000 --- a/packages/shared-nestjs-metrics/src/metrics.service.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Injectable, OnModuleInit } from '@nestjs/common'; -import { Registry, Counter, Histogram, Gauge, collectDefaultMetrics, register } from 'prom-client'; - -export interface MetricsServiceOptions { - prefix?: string; - defaultLabels?: Record; - collectDefaultMetrics?: boolean; -} - -@Injectable() -export class MetricsService implements OnModuleInit { - private readonly registry: Registry; - private readonly httpRequestsTotal: Counter; - private readonly httpRequestDuration: Histogram; - private readonly httpRequestsInFlight: Gauge; - - constructor(private readonly options: MetricsServiceOptions = {}) { - this.registry = register; - - const prefix = options.prefix || ''; - - // Set default labels if provided - if (options.defaultLabels) { - this.registry.setDefaultLabels(options.defaultLabels); - } - - // HTTP Request Counter - this.httpRequestsTotal = new Counter({ - name: `${prefix}http_requests_total`, - help: 'Total number of HTTP requests', - labelNames: ['method', 'path', 'status'], - registers: [this.registry], - }); - - // HTTP Request Duration Histogram - this.httpRequestDuration = new Histogram({ - name: `${prefix}http_request_duration_seconds`, - help: 'HTTP request duration in seconds', - labelNames: ['method', 'path', 'status'], - buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], - registers: [this.registry], - }); - - // HTTP Requests In Flight - this.httpRequestsInFlight = new Gauge({ - name: `${prefix}http_requests_in_flight`, - help: 'Number of HTTP requests currently being processed', - labelNames: ['method'], - registers: [this.registry], - }); - } - - onModuleInit() { - // Collect default Node.js metrics (CPU, memory, event loop, etc.) - if (this.options.collectDefaultMetrics !== false) { - collectDefaultMetrics({ - register: this.registry, - prefix: this.options.prefix || '', - }); - } - } - - /** - * Increment HTTP request counter - */ - incrementHttpRequests(method: string, path: string, status: number): void { - this.httpRequestsTotal.inc({ - method, - path: this.normalizePath(path), - status: String(status), - }); - } - - /** - * Observe HTTP request duration - */ - observeHttpDuration(method: string, path: string, status: number, durationMs: number): void { - this.httpRequestDuration.observe( - { - method, - path: this.normalizePath(path), - status: String(status), - }, - durationMs / 1000 // Convert to seconds - ); - } - - /** - * Increment in-flight requests - */ - incrementInFlight(method: string): void { - this.httpRequestsInFlight.inc({ method }); - } - - /** - * Decrement in-flight requests - */ - decrementInFlight(method: string): void { - this.httpRequestsInFlight.dec({ method }); - } - - /** - * Get all metrics as Prometheus text format - */ - async getMetrics(): Promise { - return this.registry.metrics(); - } - - /** - * Get content type for metrics endpoint - */ - getContentType(): string { - return this.registry.contentType; - } - - /** - * Create a custom counter - */ - createCounter(name: string, help: string, labelNames: string[] = []): Counter { - return new Counter({ - name: this.options.prefix ? `${this.options.prefix}${name}` : name, - help, - labelNames, - registers: [this.registry], - }); - } - - /** - * Create a custom histogram - */ - createHistogram( - name: string, - help: string, - labelNames: string[] = [], - buckets?: number[] - ): Histogram { - return new Histogram({ - name: this.options.prefix ? `${this.options.prefix}${name}` : name, - help, - labelNames, - buckets, - registers: [this.registry], - }); - } - - /** - * Create a custom gauge - */ - createGauge(name: string, help: string, labelNames: string[] = []): Gauge { - return new Gauge({ - name: this.options.prefix ? `${this.options.prefix}${name}` : name, - help, - labelNames, - registers: [this.registry], - }); - } - - /** - * Normalize path to prevent high cardinality - * Replaces UUIDs and numeric IDs with placeholders - */ - private normalizePath(path: string): string { - return ( - path - // Replace UUIDs - .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':id') - // Replace numeric IDs - .replace(/\/\d+/g, '/:id') - // Remove query strings - .split('?')[0] - ); - } -} diff --git a/packages/shared-nestjs-metrics/tsconfig.json b/packages/shared-nestjs-metrics/tsconfig.json deleted file mode 100644 index 9b4b43f08..000000000 --- a/packages/shared-nestjs-metrics/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2021", - "module": "CommonJS", - "lib": ["ES2021"], - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "moduleResolution": "node", - "resolveJsonModule": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/packages/shared-nestjs-setup/package.json b/packages/shared-nestjs-setup/package.json deleted file mode 100644 index 5bbb6abab..000000000 --- a/packages/shared-nestjs-setup/package.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "@manacore/shared-nestjs-setup", - "version": "1.0.0", - "description": "Shared NestJS bootstrap utilities for ManaCore backends", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - } - }, - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "type-check": "tsc --noEmit" - }, - "dependencies": { - "@nestjs/common": "^10.0.0 || ^11.0.0", - "@nestjs/core": "^10.0.0 || ^11.0.0", - "express": "^4.21.0" - }, - "devDependencies": { - "@manacore/shared-tsconfig": "workspace:*", - "@types/express": "^4.17.21", - "@types/node": "^22.10.2", - "typescript": "^5.0.0" - }, - "peerDependencies": { - "@nestjs/common": "^10.0.0 || ^11.0.0", - "@nestjs/core": "^10.0.0 || ^11.0.0", - "@nestjs/swagger": "^8.0.0 || ^7.0.0" - }, - "peerDependenciesMeta": { - "@nestjs/swagger": { - "optional": true - } - } -} diff --git a/packages/shared-nestjs-setup/src/index.ts b/packages/shared-nestjs-setup/src/index.ts deleted file mode 100644 index a6dae04e8..000000000 --- a/packages/shared-nestjs-setup/src/index.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * Shared NestJS Bootstrap Utilities for ManaCore Backends - * - * Provides a consistent setup for CORS, validation, global prefix, - * and optional Swagger/OpenAPI documentation across all backend applications. - */ - -import { type INestApplication, ValidationPipe, type Type } from '@nestjs/common'; -import { NestFactory } from '@nestjs/core'; -import { json, urlencoded } from 'express'; - -/** - * Default CORS origins for local development - * These ports cover most common dev scenarios - */ -export const DEFAULT_CORS_ORIGINS = [ - 'http://localhost:3000', // Common web dev port - 'http://localhost:3001', // Mana-core-auth - 'http://localhost:5173', // Vite default - 'http://localhost:8081', // Expo - 'exp://localhost:8081', // Expo native -]; - -/** - * Swagger/OpenAPI configuration - */ -export interface SwaggerOptions { - /** API title (default: serviceName + ' API') */ - title?: string; - /** API description */ - description?: string; - /** API version (default: '1.0') */ - version?: string; - /** Path to serve docs at (default: 'api/docs') */ - path?: string; -} - -/** - * Configuration options for the bootstrap utility - */ -export interface BootstrapOptions { - /** Default port if PORT env is not set */ - defaultPort: number; - /** Service name for console log message */ - serviceName: string; - /** Additional CORS origins beyond defaults (app-specific web port) */ - additionalCorsOrigins?: string[]; - /** API prefix (default: 'api/v1') */ - apiPrefix?: string; - /** Routes to exclude from global prefix (default: ['metrics', 'health']) */ - excludeFromPrefix?: string[]; - /** HTTP methods for CORS (default: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) */ - corsMethods?: string[]; - /** Body size limit for JSON/urlencoded payloads (default: '100kb'). Use '50mb' for image uploads. */ - bodyLimit?: string; - /** Enable Swagger/OpenAPI docs. Pass true for defaults or SwaggerOptions for custom config. */ - swagger?: boolean | SwaggerOptions; -} - -/** - * Parse CORS origins from environment or use defaults - */ -function getCorsOrigins(additionalOrigins: string[] = []): string[] { - const envOrigins = process.env.CORS_ORIGINS?.split(',').map((origin) => origin.trim()); - if (envOrigins && envOrigins.length > 0) { - return envOrigins; - } - return [...DEFAULT_CORS_ORIGINS, ...additionalOrigins]; -} - -/** - * Configure standard CORS settings for the application - */ -export function configureCors( - app: INestApplication, - options: { additionalOrigins?: string[]; methods?: string[] } = {} -): void { - const corsOrigins = getCorsOrigins(options.additionalOrigins); - const methods = options.methods || ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']; - - app.enableCors({ - origin: corsOrigins, - methods, - credentials: true, - }); -} - -/** - * Configure standard validation pipe settings - */ -export function configureValidation(app: INestApplication): void { - app.useGlobalPipes( - new ValidationPipe({ - whitelist: true, - transform: true, - forbidNonWhitelisted: true, - }) - ); -} - -/** - * Configure global API prefix with standard exclusions - */ -export function configurePrefix( - app: INestApplication, - prefix = 'api/v1', - exclude: string[] = ['metrics', 'health'] -): void { - app.setGlobalPrefix(prefix, { exclude }); -} - -/** - * Setup Swagger/OpenAPI documentation if enabled and @nestjs/swagger is installed - */ -async function setupSwagger( - app: INestApplication, - serviceName: string, - port: string | number, - swaggerConfig: boolean | SwaggerOptions -): Promise { - try { - const { DocumentBuilder, SwaggerModule } = await import('@nestjs/swagger'); - const opts: SwaggerOptions = typeof swaggerConfig === 'object' ? swaggerConfig : {}; - - const config = new DocumentBuilder() - .setTitle(opts.title || `${serviceName} API`) - .setDescription(opts.description || `API documentation for ${serviceName}`) - .setVersion(opts.version || '1.0') - .addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, 'JWT-auth') - .build(); - - const document = SwaggerModule.createDocument(app, config); - const docsPath = opts.path || 'api/docs'; - SwaggerModule.setup(docsPath, app, document, { - swaggerOptions: { persistAuthorization: true }, - }); - - console.log(`${serviceName} API docs at http://localhost:${port}/${docsPath}`); - } catch { - // @nestjs/swagger not installed - skip silently - } -} - -/** - * Bootstrap a NestJS application with standard configuration - * - * @example - * ```typescript - * import { bootstrapApp } from '@manacore/shared-nestjs-setup'; - * import { AppModule } from './app.module'; - * - * bootstrapApp(AppModule, { - * defaultPort: 3002, - * serviceName: 'Chat', - * additionalCorsOrigins: ['http://localhost:5178'], - * swagger: true, - * }); - * ``` - */ -export async function bootstrapApp( - AppModule: Type, - options: BootstrapOptions -): Promise { - const app = await NestFactory.create(AppModule); - - // Configure body size limit (important for services handling image uploads) - const bodyLimit = options.bodyLimit ?? '100kb'; - app.use(json({ limit: bodyLimit })); - app.use(urlencoded({ extended: true, limit: bodyLimit })); - - // Configure CORS - configureCors(app, { - additionalOrigins: options.additionalCorsOrigins, - methods: options.corsMethods, - }); - - // Configure validation - configureValidation(app); - - // Configure global prefix - configurePrefix( - app, - options.apiPrefix ?? 'api/v1', - options.excludeFromPrefix ?? ['metrics', 'health'] - ); - - // Setup Swagger/OpenAPI docs if enabled - const port = process.env.PORT || options.defaultPort; - if (options.swagger) { - await setupSwagger(app, options.serviceName, port, options.swagger); - } - - // Start listening - await app.listen(port); - - console.log(`${options.serviceName} backend running on http://localhost:${port}`); - - return app; -} diff --git a/packages/shared-nestjs-setup/tsconfig.json b/packages/shared-nestjs-setup/tsconfig.json deleted file mode 100644 index 23e078ced..000000000 --- a/packages/shared-nestjs-setup/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "@manacore/shared-tsconfig/nestjs", - "compilerOptions": { - "outDir": "./dist", - "baseUrl": "./", - "rootDir": "./src" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b03e738d3..82c6e1cec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -276,11 +276,27 @@ importers: specifier: ^3.4.0 version: 3.4.18(tsx@4.21.0)(yaml@2.8.1) + apps/calendar/apps/server: + dependencies: + '@manacore/shared-hono': + specifier: workspace:* + version: link:../../../../packages/shared-hono + hono: + specifier: ^4.7.0 + version: 4.12.9 + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + apps/calendar/apps/web: dependencies: '@calendar/shared': specifier: workspace:* version: link:../../packages/shared + '@manacore/feedback': + specifier: workspace:* + version: link:../../../../packages/feedback '@manacore/local-store': specifier: workspace:* version: link:../../../../packages/local-store @@ -302,12 +318,6 @@ importers: '@manacore/shared-error-tracking': specifier: workspace:* version: link:../../../../packages/shared-error-tracking - '@manacore/shared-feedback-service': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-service - '@manacore/shared-feedback-ui': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-ui '@manacore/shared-help-types': specifier: workspace:* version: link:../../../../packages/shared-help-types @@ -726,11 +736,33 @@ importers: specifier: ~5.3.3 version: 5.3.3 + apps/chat/apps/server: + dependencies: + '@manacore/shared-hono': + specifier: workspace:* + version: link:../../../../packages/shared-hono + drizzle-orm: + specifier: ^0.38.3 + version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4) + hono: + specifier: ^4.7.0 + version: 4.12.9 + postgres: + specifier: ^3.4.5 + version: 3.4.7 + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + apps/chat/apps/web: dependencies: '@chat/types': specifier: workspace:* version: link:../../packages/chat-types + '@manacore/feedback': + specifier: workspace:* + version: link:../../../../packages/feedback '@manacore/local-store': specifier: workspace:* version: link:../../../../packages/local-store @@ -752,12 +784,6 @@ importers: '@manacore/shared-error-tracking': specifier: workspace:* version: link:../../../../packages/shared-error-tracking - '@manacore/shared-feedback-service': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-service - '@manacore/shared-feedback-ui': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-ui '@manacore/shared-help-types': specifier: workspace:* version: link:../../../../packages/shared-help-types @@ -878,6 +904,9 @@ importers: apps/citycorners/apps/web: dependencies: + '@manacore/feedback': + specifier: workspace:* + version: link:../../../../packages/feedback '@manacore/local-store': specifier: workspace:* version: link:../../../../packages/local-store @@ -893,12 +922,6 @@ importers: '@manacore/shared-error-tracking': specifier: workspace:* version: link:../../../../packages/shared-error-tracking - '@manacore/shared-feedback-service': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-service - '@manacore/shared-feedback-ui': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-ui '@manacore/shared-help-types': specifier: workspace:* version: link:../../../../packages/shared-help-types @@ -1045,6 +1068,9 @@ importers: '@clock/shared': specifier: workspace:* version: link:../../packages/shared + '@manacore/feedback': + specifier: workspace:* + version: link:../../../../packages/feedback '@manacore/local-store': specifier: workspace:* version: link:../../../../packages/local-store @@ -1066,12 +1092,6 @@ importers: '@manacore/shared-error-tracking': specifier: workspace:* version: link:../../../../packages/shared-error-tracking - '@manacore/shared-feedback-service': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-service - '@manacore/shared-feedback-ui': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-ui '@manacore/shared-help-types': specifier: workspace:* version: link:../../../../packages/shared-help-types @@ -1336,8 +1356,27 @@ importers: specifier: ^5.7.2 version: 5.9.3 + apps/contacts/apps/server: + dependencies: + '@manacore/shared-hono': + specifier: workspace:* + version: link:../../../../packages/shared-hono + '@manacore/shared-storage': + specifier: workspace:* + version: link:../../../../packages/shared-storage + hono: + specifier: ^4.7.0 + version: 4.12.9 + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + apps/contacts/apps/web: dependencies: + '@manacore/feedback': + specifier: workspace:* + version: link:../../../../packages/feedback '@manacore/local-store': specifier: workspace:* version: link:../../../../packages/local-store @@ -1359,12 +1398,6 @@ importers: '@manacore/shared-error-tracking': specifier: workspace:* version: link:../../../../packages/shared-error-tracking - '@manacore/shared-feedback-service': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-service - '@manacore/shared-feedback-ui': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-ui '@manacore/shared-help-content': specifier: workspace:* version: link:../../../../packages/shared-help-content @@ -1734,8 +1767,30 @@ importers: specifier: ~5.3.3 version: 5.3.3 + apps/context/apps/server: + dependencies: + '@manacore/shared-hono': + specifier: workspace:* + version: link:../../../../packages/shared-hono + drizzle-orm: + specifier: ^0.38.3 + version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4) + hono: + specifier: ^4.7.0 + version: 4.12.9 + postgres: + specifier: ^3.4.5 + version: 3.4.7 + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + apps/context/apps/web: dependencies: + '@manacore/feedback': + specifier: workspace:* + version: link:../../../../packages/feedback '@manacore/local-store': specifier: workspace:* version: link:../../../../packages/local-store @@ -1757,12 +1812,6 @@ importers: '@manacore/shared-error-tracking': specifier: workspace:* version: link:../../../../packages/shared-error-tracking - '@manacore/shared-feedback-service': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-service - '@manacore/shared-feedback-ui': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-ui '@manacore/shared-help-types': specifier: workspace:* version: link:../../../../packages/shared-help-types @@ -1915,9 +1964,6 @@ importers: '@manacore/shared-error-tracking': specifier: workspace:* version: link:../../../../packages/shared-error-tracking - '@manacore/shared-feedback-service': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-service '@manacore/shared-icons': specifier: workspace:* version: link:../../../../packages/shared-icons @@ -2168,6 +2214,9 @@ importers: '@manacore/credit-operations': specifier: workspace:^ version: link:../../../../packages/credit-operations + '@manacore/feedback': + specifier: workspace:* + version: link:../../../../packages/feedback '@manacore/qr-export': specifier: workspace:* version: link:../../../../packages/qr-export @@ -2186,12 +2235,6 @@ importers: '@manacore/shared-error-tracking': specifier: workspace:* version: link:../../../../packages/shared-error-tracking - '@manacore/shared-feedback-service': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-service - '@manacore/shared-feedback-ui': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-ui '@manacore/shared-help-types': specifier: workspace:* version: link:../../../../packages/shared-help-types @@ -2641,8 +2684,24 @@ importers: specifier: ~5.9.3 version: 5.9.3 + apps/manadeck/apps/server: + dependencies: + '@manacore/shared-hono': + specifier: workspace:* + version: link:../../../../packages/shared-hono + hono: + specifier: ^4.7.0 + version: 4.12.9 + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + apps/manadeck/apps/web: dependencies: + '@manacore/feedback': + specifier: workspace:* + version: link:../../../../packages/feedback '@manacore/local-store': specifier: workspace:* version: link:../../../../packages/local-store @@ -2664,12 +2723,6 @@ importers: '@manacore/shared-error-tracking': specifier: workspace:* version: link:../../../../packages/shared-error-tracking - '@manacore/shared-feedback-service': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-service - '@manacore/shared-feedback-ui': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-ui '@manacore/shared-help-types': specifier: workspace:* version: link:../../../../packages/shared-help-types @@ -2902,6 +2955,9 @@ importers: apps/matrix/apps/web: dependencies: + '@manacore/feedback': + specifier: workspace:* + version: link:../../../../packages/feedback '@manacore/shared-auth': specifier: workspace:* version: link:../../../../packages/shared-auth @@ -2911,12 +2967,6 @@ importers: '@manacore/shared-error-tracking': specifier: workspace:* version: link:../../../../packages/shared-error-tracking - '@manacore/shared-feedback-service': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-service - '@manacore/shared-feedback-ui': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-ui '@manacore/shared-help-types': specifier: workspace:* version: link:../../../../packages/shared-help-types @@ -3180,8 +3230,27 @@ importers: specifier: ^5.7.2 version: 5.9.3 + apps/mukke/apps/server: + dependencies: + '@manacore/shared-hono': + specifier: workspace:* + version: link:../../../../packages/shared-hono + '@manacore/shared-storage': + specifier: workspace:* + version: link:../../../../packages/shared-storage + hono: + specifier: ^4.7.0 + version: 4.12.9 + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + apps/mukke/apps/web: dependencies: + '@manacore/feedback': + specifier: workspace:^ + version: link:../../../../packages/feedback '@manacore/local-store': specifier: workspace:* version: link:../../../../packages/local-store @@ -3203,12 +3272,6 @@ importers: '@manacore/shared-error-tracking': specifier: workspace:* version: link:../../../../packages/shared-error-tracking - '@manacore/shared-feedback-service': - specifier: workspace:^ - version: link:../../../../packages/shared-feedback-service - '@manacore/shared-feedback-ui': - specifier: workspace:^ - version: link:../../../../packages/shared-feedback-ui '@manacore/shared-help-types': specifier: workspace:* version: link:../../../../packages/shared-help-types @@ -3504,8 +3567,24 @@ importers: specifier: ^3.4.0 version: 3.4.18(tsx@4.21.0)(yaml@2.8.1) + apps/nutriphi/apps/server: + dependencies: + '@manacore/shared-hono': + specifier: workspace:* + version: link:../../../../packages/shared-hono + hono: + specifier: ^4.7.0 + version: 4.12.9 + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + apps/nutriphi/apps/web: dependencies: + '@manacore/feedback': + specifier: workspace:* + version: link:../../../../packages/feedback '@manacore/local-store': specifier: workspace:* version: link:../../../../packages/local-store @@ -3524,12 +3603,6 @@ importers: '@manacore/shared-error-tracking': specifier: workspace:* version: link:../../../../packages/shared-error-tracking - '@manacore/shared-feedback-service': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-service - '@manacore/shared-feedback-ui': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-ui '@manacore/shared-help-types': specifier: workspace:* version: link:../../../../packages/shared-help-types @@ -3651,6 +3724,9 @@ importers: apps/photos/apps/web: dependencies: + '@manacore/feedback': + specifier: workspace:* + version: link:../../../../packages/feedback '@manacore/local-store': specifier: workspace:* version: link:../../../../packages/local-store @@ -3672,12 +3748,6 @@ importers: '@manacore/shared-error-tracking': specifier: workspace:* version: link:../../../../packages/shared-error-tracking - '@manacore/shared-feedback-service': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-service - '@manacore/shared-feedback-ui': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-ui '@manacore/shared-help-content': specifier: workspace:* version: link:../../../../packages/shared-help-content @@ -4179,8 +4249,27 @@ importers: specifier: ~5.8.3 version: 5.8.3 + apps/picture/apps/server: + dependencies: + '@manacore/shared-hono': + specifier: workspace:* + version: link:../../../../packages/shared-hono + '@manacore/shared-storage': + specifier: workspace:* + version: link:../../../../packages/shared-storage + hono: + specifier: ^4.7.0 + version: 4.12.9 + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + apps/picture/apps/web: dependencies: + '@manacore/feedback': + specifier: workspace:* + version: link:../../../../packages/feedback '@manacore/local-store': specifier: workspace:* version: link:../../../../packages/local-store @@ -4202,12 +4291,6 @@ importers: '@manacore/shared-error-tracking': specifier: workspace:* version: link:../../../../packages/shared-error-tracking - '@manacore/shared-feedback-service': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-service - '@manacore/shared-feedback-ui': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-ui '@manacore/shared-help-types': specifier: workspace:* version: link:../../../../packages/shared-help-types @@ -4522,8 +4605,33 @@ importers: specifier: ^5.7.2 version: 5.9.3 + apps/planta/apps/server: + dependencies: + '@manacore/shared-hono': + specifier: workspace:* + version: link:../../../../packages/shared-hono + '@manacore/shared-storage': + specifier: workspace:* + version: link:../../../../packages/shared-storage + drizzle-orm: + specifier: ^0.38.3 + version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4) + hono: + specifier: ^4.7.0 + version: 4.12.9 + postgres: + specifier: ^3.4.5 + version: 3.4.7 + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + apps/planta/apps/web: dependencies: + '@manacore/feedback': + specifier: workspace:* + version: link:../../../../packages/feedback '@manacore/local-store': specifier: workspace:* version: link:../../../../packages/local-store @@ -4545,12 +4653,6 @@ importers: '@manacore/shared-error-tracking': specifier: workspace:* version: link:../../../../packages/shared-error-tracking - '@manacore/shared-feedback-service': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-service - '@manacore/shared-feedback-ui': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-ui '@manacore/shared-help-types': specifier: workspace:* version: link:../../../../packages/shared-help-types @@ -4761,6 +4863,9 @@ importers: apps/presi/apps/web: dependencies: + '@manacore/feedback': + specifier: workspace:* + version: link:../../../../packages/feedback '@manacore/local-store': specifier: workspace:* version: link:../../../../packages/local-store @@ -4779,12 +4884,6 @@ importers: '@manacore/shared-error-tracking': specifier: workspace:* version: link:../../../../packages/shared-error-tracking - '@manacore/shared-feedback-service': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-service - '@manacore/shared-feedback-ui': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-ui '@manacore/shared-help-types': specifier: workspace:* version: link:../../../../packages/shared-help-types @@ -4985,8 +5084,24 @@ importers: specifier: ^5.7.2 version: 5.9.3 + apps/questions/apps/server: + dependencies: + '@manacore/shared-hono': + specifier: workspace:* + version: link:../../../../packages/shared-hono + hono: + specifier: ^4.7.0 + version: 4.12.9 + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + apps/questions/apps/web: dependencies: + '@manacore/feedback': + specifier: workspace:* + version: link:../../../../packages/feedback '@manacore/local-store': specifier: workspace:* version: link:../../../../packages/local-store @@ -5008,12 +5123,6 @@ importers: '@manacore/shared-error-tracking': specifier: workspace:* version: link:../../../../packages/shared-error-tracking - '@manacore/shared-feedback-service': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-service - '@manacore/shared-feedback-ui': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-ui '@manacore/shared-help-types': specifier: workspace:* version: link:../../../../packages/shared-help-types @@ -5356,8 +5465,27 @@ importers: specifier: ^5.7.2 version: 5.9.3 + apps/storage/apps/server: + dependencies: + '@manacore/shared-hono': + specifier: workspace:* + version: link:../../../../packages/shared-hono + '@manacore/shared-storage': + specifier: workspace:* + version: link:../../../../packages/shared-storage + hono: + specifier: ^4.7.0 + version: 4.12.9 + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + apps/storage/apps/web: dependencies: + '@manacore/feedback': + specifier: workspace:* + version: link:../../../../packages/feedback '@manacore/local-store': specifier: workspace:* version: link:../../../../packages/local-store @@ -5379,12 +5507,6 @@ importers: '@manacore/shared-error-tracking': specifier: workspace:* version: link:../../../../packages/shared-error-tracking - '@manacore/shared-feedback-service': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-service - '@manacore/shared-feedback-ui': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-ui '@manacore/shared-help-types': specifier: workspace:* version: link:../../../../packages/shared-help-types @@ -5677,6 +5799,9 @@ importers: apps/todo/apps/web: dependencies: + '@manacore/feedback': + specifier: workspace:* + version: link:../../../../packages/feedback '@manacore/local-store': specifier: workspace:* version: link:../../../../packages/local-store @@ -5698,12 +5823,6 @@ importers: '@manacore/shared-error-tracking': specifier: workspace:* version: link:../../../../packages/shared-error-tracking - '@manacore/shared-feedback-service': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-service - '@manacore/shared-feedback-ui': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-ui '@manacore/shared-help-types': specifier: workspace:* version: link:../../../../packages/shared-help-types @@ -6050,6 +6169,25 @@ importers: specifier: ~5.9.2 version: 5.9.3 + apps/traces/apps/server: + dependencies: + '@manacore/shared-hono': + specifier: workspace:* + version: link:../../../../packages/shared-hono + drizzle-orm: + specifier: ^0.38.3 + version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4) + hono: + specifier: ^4.7.0 + version: 4.12.9 + postgres: + specifier: ^3.4.5 + version: 3.4.7 + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + apps/traces/packages/traces-types: devDependencies: typescript: @@ -6058,6 +6196,9 @@ importers: apps/zitare/apps/web: dependencies: + '@manacore/feedback': + specifier: workspace:* + version: link:../../../../packages/feedback '@manacore/local-store': specifier: workspace:* version: link:../../../../packages/local-store @@ -6079,12 +6220,6 @@ importers: '@manacore/shared-error-tracking': specifier: workspace:* version: link:../../../../packages/shared-error-tracking - '@manacore/shared-feedback-service': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-service - '@manacore/shared-feedback-ui': - specifier: workspace:* - version: link:../../../../packages/shared-feedback-ui '@manacore/shared-help-types': specifier: workspace:* version: link:../../../../packages/shared-help-types @@ -6243,6 +6378,37 @@ importers: specifier: ^8.48.1 version: 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + packages/feedback: + devDependencies: + svelte: + specifier: ^5.0.0 + version: 5.44.0 + svelte-check: + specifier: ^4.0.0 + version: 4.3.4(picomatch@4.0.3)(svelte@5.44.0)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + packages/help: + dependencies: + zod: + specifier: ^3.24.1 + version: 3.25.76 + devDependencies: + svelte: + specifier: ^5.0.0 + version: 5.44.0 + svelte-check: + specifier: ^4.0.0 + version: 4.3.4(picomatch@4.0.3)(svelte@5.44.0)(typescript@5.9.3) + svelte-i18n: + specifier: ^4.0.0 + version: 4.0.1(svelte@5.44.0) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + packages/local-store: dependencies: dexie: @@ -6330,31 +6496,6 @@ importers: specifier: ^5.7.2 version: 5.9.3 - packages/nutriphi-database: - dependencies: - drizzle-orm: - specifier: ^0.36.0 - version: 0.36.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4) - postgres: - specifier: ^3.4.5 - version: 3.4.7 - devDependencies: - '@types/node': - specifier: ^22.10.0 - version: 22.19.1 - dotenv-cli: - specifier: ^7.4.0 - version: 7.4.4 - drizzle-kit: - specifier: ^0.28.0 - version: 0.28.1 - tsx: - specifier: ^4.19.0 - version: 4.20.6 - typescript: - specifier: ^5.7.3 - version: 5.9.3 - packages/qr-export: dependencies: '@manacore/wallpaper-generator': @@ -6597,50 +6738,6 @@ importers: specifier: ^5.9.3 version: 5.9.3 - packages/shared-feedback-service: - dependencies: - '@manacore/shared-feedback-types': - specifier: workspace:* - version: link:../shared-feedback-types - devDependencies: - typescript: - specifier: ^5.7.3 - version: 5.9.3 - - packages/shared-feedback-types: - devDependencies: - typescript: - specifier: ^5.7.3 - version: 5.9.3 - - packages/shared-feedback-ui: - dependencies: - '@manacore/shared-feedback-service': - specifier: workspace:* - version: link:../shared-feedback-service - '@manacore/shared-feedback-types': - specifier: workspace:* - version: link:../shared-feedback-types - devDependencies: - svelte: - specifier: ^5.0.0 - version: 5.44.0 - svelte-check: - specifier: ^4.0.0 - version: 4.3.4(picomatch@4.0.3)(svelte@5.44.0)(typescript@5.9.3) - typescript: - specifier: ^5.7.3 - version: 5.9.3 - - packages/shared-gpu: - devDependencies: - '@types/node': - specifier: ^20.0.0 - version: 20.19.25 - typescript: - specifier: ^5.0.0 - version: 5.9.3 - packages/shared-help-content: dependencies: '@manacore/shared-help-types':