From 6a8265d3dcdc6f1cb710cc3b698c504c8832c8a3 Mon Sep 17 00:00:00 2001 From: Flexomatic81 Date: Mon, 23 Feb 2026 20:07:39 +0100 Subject: [PATCH] feat: move configuration from .env to DB with Admin UI management Replace hardcoded .env configuration with database-backed settings manageable through the Admin web interface. This reduces .env to bootstrap-only variables (DB, Keycloak, encryption keys). Backend: - Add SystemSetting Prisma model with category, valueType, isSecret - Add system-settings NestJS module (CRUD, 60s cache, encryption) - Refactor all 7 connectors to lazy-load credentials from DB via CredentialsService.findActiveByType() instead of ConfigService - Add event-driven credential reload (@nestjs/event-emitter) - Dynamic CORS origins and conditional Swagger from DB settings - Fix JWT strategy: use Keycloak JWKS (RS256) instead of symmetric secret - Add SYSTEM_SETTINGS_VIEW/MANAGE permissions - Seed 13 default settings (sync intervals, features, branding, CORS) - Add env-to-db migration script (prisma/migrate-env-to-db.ts) Frontend: - Add use-credentials hook (full CRUD for integration credentials) - Add use-system-settings hook (read/update system settings) - Wire admin-integrations page to real API (create/update/test/toggle) - Add admin system-settings page with 4 tabs (Branding, CORS, Sync, Features) - Fix sidebar double-highlighting with exactMatch flag - Fix integration detail fallback when API unavailable - Fix API client to unwrap backend's {success, data} envelope - Update NEXT_PUBLIC_API_URL to include /v1 version prefix - Fix activity-widget hydration error - Add i18n keys for systemSettings (de + en) Co-Authored-By: Claude Opus 4.6 --- apps/api/.env.example | 78 +- apps/api/package.json | 3 +- apps/api/prisma/migrate-env-to-db.ts | 309 ++++++++ apps/api/prisma/schema.prisma | 18 + apps/api/prisma/seed.ts | 39 + apps/api/src/app.module.ts | 10 + .../src/auth/permissions/permissions.enum.ts | 4 + apps/api/src/auth/strategies/jwt.strategy.ts | 51 +- apps/api/src/config/config.validation.ts | 52 +- apps/api/src/main.ts | 26 +- .../integrations/connectors/base-connector.ts | 30 +- .../connectors/ecodms/ecodms.connector.ts | 145 ++-- .../connectors/ecodms/ecodms.module.ts | 22 +- .../freescout/freescout.connector.ts | 145 ++-- .../connectors/freescout/freescout.module.ts | 9 +- .../gembadocs/gembadocs.connector.ts | 120 ++-- .../connectors/gembadocs/gembadocs.module.ts | 9 +- .../nextcloud/nextcloud.connector.ts | 674 +++++------------- .../connectors/nextcloud/nextcloud.module.ts | 16 +- .../plentyone/plentyone.connector.ts | 118 +-- .../connectors/plentyone/plentyone.module.ts | 2 + .../connectors/todoist/todoist.connector.ts | 98 ++- .../connectors/todoist/todoist.module.ts | 2 + .../connectors/zulip/zulip.connector.ts | 112 ++- .../connectors/zulip/zulip.module.ts | 2 + .../credentials/credentials.service.ts | 31 + .../integrations/integrations.service.ts | 16 +- .../src/modules/system-settings/dto/index.ts | 1 + .../system-settings/dto/update-setting.dto.ts | 53 ++ apps/api/src/modules/system-settings/index.ts | 3 + .../system-settings.controller.ts | 117 +++ .../system-settings/system-settings.module.ts | 19 + .../system-settings.service.ts | 269 +++++++ apps/web/messages/de.json | 27 +- apps/web/messages/en.json | 27 +- .../admin-integrations-content.tsx | 585 ++++++++++----- .../[locale]/(auth)/admin/settings/page.tsx | 20 + .../settings/system-settings-content.tsx | 470 ++++++++++++ .../[type]/integration-detail-content.tsx | 56 +- .../dashboard/widgets/activity-widget.tsx | 16 +- apps/web/src/components/layout/sidebar.tsx | 20 +- apps/web/src/hooks/integrations/index.ts | 18 + .../src/hooks/integrations/use-credentials.ts | 147 ++++ apps/web/src/hooks/use-system-settings.ts | 105 +++ apps/web/src/lib/api.ts | 7 +- pnpm-lock.yaml | 20 + 46 files changed, 2972 insertions(+), 1149 deletions(-) create mode 100644 apps/api/prisma/migrate-env-to-db.ts create mode 100644 apps/api/src/modules/system-settings/dto/index.ts create mode 100644 apps/api/src/modules/system-settings/dto/update-setting.dto.ts create mode 100644 apps/api/src/modules/system-settings/index.ts create mode 100644 apps/api/src/modules/system-settings/system-settings.controller.ts create mode 100644 apps/api/src/modules/system-settings/system-settings.module.ts create mode 100644 apps/api/src/modules/system-settings/system-settings.service.ts create mode 100644 apps/web/src/app/[locale]/(auth)/admin/settings/page.tsx create mode 100644 apps/web/src/app/[locale]/(auth)/admin/settings/system-settings-content.tsx create mode 100644 apps/web/src/hooks/integrations/use-credentials.ts create mode 100644 apps/web/src/hooks/use-system-settings.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index 2592eae..bedc7d0 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -16,65 +16,37 @@ KEYCLOAK_REALM=tOS KEYCLOAK_CLIENT_ID=tos-backend KEYCLOAK_CLIENT_SECRET=your-keycloak-backend-client-secret -# CORS -CORS_ORIGINS=http://localhost:3000,http://localhost:5173 - -# Swagger -SWAGGER_ENABLED=true - -# ============================================================================= -# Phase 3: Integrations & Sync Jobs -# ============================================================================= - # Encryption # IMPORTANT: Generate a secure 32+ character key for production! # You can generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" ENCRYPTION_KEY=your-32-byte-encryption-key-change-in-production # Redis (required for BullMQ in production) -REDIS_HOST=localhost -REDIS_PORT=6379 - -# Sync Jobs -# Set to 'true' to enable automatic background sync jobs -ENABLE_SYNC_JOBS=false - -# Sync Intervals (in minutes) -SYNC_INTERVAL_PLENTYONE=15 -SYNC_INTERVAL_ZULIP=5 -SYNC_INTERVAL_TODOIST=10 -SYNC_INTERVAL_FREESCOUT=10 -SYNC_INTERVAL_NEXTCLOUD=30 -SYNC_INTERVAL_ECODMS=60 -SYNC_INTERVAL_GEMBADOCS=30 +# REDIS_HOST=localhost +# REDIS_PORT=6379 # ============================================================================= -# Phase 3: API Connector Credentials +# Settings moved to the database (SystemSettings table) # ============================================================================= - -# PlentyONE (OAuth2 Client Credentials) -PLENTYONE_BASE_URL= -PLENTYONE_CLIENT_ID= -PLENTYONE_CLIENT_SECRET= - -# ZULIP (Basic Auth with API Key) -ZULIP_BASE_URL= -ZULIP_EMAIL= -ZULIP_API_KEY= - -# Todoist (Bearer Token) -TODOIST_API_TOKEN= - -# FreeScout (API Key) -FREESCOUT_API_URL= -FREESCOUT_API_KEY= - -# Nextcloud (Basic Auth / App Password) -NEXTCLOUD_URL= -NEXTCLOUD_USERNAME= -NEXTCLOUD_PASSWORD= - -# ecoDMS (Session-based Auth) -ECODMS_API_URL= -ECODMS_USERNAME= -ECODMS_PASSWORD= +# The following env vars are no longer read by the application. +# Their values are stored in the database and can be managed via the +# admin UI at /admin/system-settings or via the API at PUT /api/v1/system-settings/:key. +# +# To seed initial values from a .env file, run the migration script: +# npx ts-node prisma/migrate-env-to-db.ts +# +# Keys and their DB equivalents: +# CORS_ORIGINS -> cors.origins (cors category) +# SWAGGER_ENABLED -> feature.swagger.enabled (feature category) +# ENABLE_SYNC_JOBS -> feature.syncJobs.enabled (feature category) +# SYNC_INTERVAL_PLENTYONE -> sync.interval.plentyone (sync category) +# SYNC_INTERVAL_ZULIP -> sync.interval.zulip (sync category) +# SYNC_INTERVAL_TODOIST -> sync.interval.todoist (sync category) +# SYNC_INTERVAL_FREESCOUT -> sync.interval.freescout (sync category) +# SYNC_INTERVAL_NEXTCLOUD -> sync.interval.nextcloud (sync category) +# SYNC_INTERVAL_ECODMS -> sync.interval.ecodms (sync category) +# SYNC_INTERVAL_GEMBADOCS -> sync.interval.gembadocs (sync category) +# +# Integration credentials (PLENTYONE_*, ZULIP_*, TODOIST_*, FREESCOUT_*, +# NEXTCLOUD_*, ECODMS_*, GEMBADOCS_*) are stored encrypted in the +# IntegrationCredential table and managed via /admin/integrations. diff --git a/apps/api/package.json b/apps/api/package.json index 3e64c44..1355224 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -26,16 +26,17 @@ "db:seed": "prisma db seed" }, "dependencies": { - "@tos/shared": "workspace:*", "@nestjs/common": "^10.3.0", "@nestjs/config": "^3.1.1", "@nestjs/core": "^10.3.0", + "@nestjs/event-emitter": "^3.0.1", "@nestjs/jwt": "^10.2.0", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.3.0", "@nestjs/swagger": "^7.2.0", "@nestjs/terminus": "^10.2.0", "@prisma/client": "^5.8.0", + "@tos/shared": "workspace:*", "axios": "^1.6.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", diff --git a/apps/api/prisma/migrate-env-to-db.ts b/apps/api/prisma/migrate-env-to-db.ts new file mode 100644 index 0000000..73c8bcd --- /dev/null +++ b/apps/api/prisma/migrate-env-to-db.ts @@ -0,0 +1,309 @@ +/** + * Migration script: moves integration credentials and configurable settings + * from environment variables into the database. + * + * Run with: + * npx ts-node -P tsconfig.json -e "require('./prisma/migrate-env-to-db.ts')" + * + * Or (with dotenv): + * node -r dotenv/config -r ts-node/register prisma/migrate-env-to-db.ts + * + * The script is idempotent - safe to run multiple times. + */ + +import { PrismaClient, IntegrationType } from '@prisma/client'; +import * as crypto from 'crypto'; + +const prisma = new PrismaClient(); + +// --------------------------------------------------------------------------- +// Encryption helpers - MUST match EncryptionService exactly +// --------------------------------------------------------------------------- + +const IV_LENGTH = 16; // 128 bits +const TAG_LENGTH = 16; // 128 bits +const KEY_LENGTH = 32; // 256 bits + +/** + * Derives a 256-bit key using PBKDF2 - matches EncryptionService.deriveKey() + */ +function deriveKey(password: string): Buffer { + const salt = 'tos-encryption-salt-v1'; + return crypto.pbkdf2Sync(password, salt, 100000, KEY_LENGTH, 'sha256'); +} + +/** + * Builds the encryption key buffer - matches EncryptionService.onModuleInit() + */ +function buildEncryptionKey(encryptionKey: string): Buffer { + if (encryptionKey.length < KEY_LENGTH) { + return deriveKey(encryptionKey); + } + // Use first 32 UTF-8 bytes when key is long enough + return Buffer.from(encryptionKey.slice(0, KEY_LENGTH), 'utf-8'); +} + +/** + * Encrypts a JSON object - matches EncryptionService.encryptObject() + * + * Format: base64( IV[16] || ciphertext || authTag[16] ) + */ +function encryptObject(data: Record, encryptionKeyRaw: string): string { + const json = JSON.stringify(data); + const key = buildEncryptionKey(encryptionKeyRaw); + const iv = crypto.randomBytes(IV_LENGTH); + + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv, { + authTagLength: TAG_LENGTH, + }); + + const encrypted = Buffer.concat([ + cipher.update(json, 'utf8'), + cipher.final(), + ]); + + const authTag = cipher.getAuthTag(); + + // Combine IV + ciphertext + authTag, encode as base64 + const combined = Buffer.concat([iv, encrypted, authTag]); + return combined.toString('base64'); +} + +// --------------------------------------------------------------------------- +// Migration +// --------------------------------------------------------------------------- + +async function migrate() { + console.log('Starting .env to DB migration...\n'); + + // 1. Migrate system settings + const settingsMap: Record< + string, + { key: string; category: string; valueType: string; description: string } + > = { + ENABLE_SYNC_JOBS: { + key: 'feature.syncJobs.enabled', + category: 'feature', + valueType: 'boolean', + description: 'Hintergrund-Sync-Jobs aktivieren', + }, + SWAGGER_ENABLED: { + key: 'feature.swagger.enabled', + category: 'feature', + valueType: 'boolean', + description: 'Swagger API-Dokumentation aktivieren', + }, + CORS_ORIGINS: { + key: 'cors.origins', + category: 'cors', + valueType: 'string', + description: 'Erlaubte CORS Origins (kommagetrennt)', + }, + SYNC_INTERVAL_PLENTYONE: { + key: 'sync.interval.plentyone', + category: 'sync', + valueType: 'number', + description: 'PlentyONE Sync-Intervall (Minuten)', + }, + SYNC_INTERVAL_ZULIP: { + key: 'sync.interval.zulip', + category: 'sync', + valueType: 'number', + description: 'Zulip Sync-Intervall (Minuten)', + }, + SYNC_INTERVAL_TODOIST: { + key: 'sync.interval.todoist', + category: 'sync', + valueType: 'number', + description: 'Todoist Sync-Intervall (Minuten)', + }, + SYNC_INTERVAL_FREESCOUT: { + key: 'sync.interval.freescout', + category: 'sync', + valueType: 'number', + description: 'FreeScout Sync-Intervall (Minuten)', + }, + SYNC_INTERVAL_NEXTCLOUD: { + key: 'sync.interval.nextcloud', + category: 'sync', + valueType: 'number', + description: 'Nextcloud Sync-Intervall (Minuten)', + }, + SYNC_INTERVAL_ECODMS: { + key: 'sync.interval.ecodms', + category: 'sync', + valueType: 'number', + description: 'ecoDMS Sync-Intervall (Minuten)', + }, + SYNC_INTERVAL_GEMBADOCS: { + key: 'sync.interval.gembadocs', + category: 'sync', + valueType: 'number', + description: 'GembaDocs Sync-Intervall (Minuten)', + }, + }; + + console.log(' Migrating system settings...'); + for (const [envKey, setting] of Object.entries(settingsMap)) { + const envValue = process.env[envKey]; + if (envValue !== undefined) { + await prisma.systemSetting.upsert({ + where: { key: setting.key }, + update: { value: envValue }, + create: { + key: setting.key, + value: envValue, + category: setting.category, + valueType: setting.valueType, + description: setting.description, + }, + }); + console.log(` Migrated ${envKey} -> ${setting.key} = ${envValue}`); + } else { + console.log(` Skipped ${envKey} (not set in environment)`); + } + } + + // 2. Migrate integration credentials + const encryptionKey = process.env.ENCRYPTION_KEY; + if (!encryptionKey) { + console.warn( + '\n WARNING: ENCRYPTION_KEY not set. Skipping credential migration.', + ); + console.log('\nDone (settings only).'); + await prisma.$disconnect(); + return; + } + + // Find admin user for createdById + const adminUser = await prisma.user.findFirst({ + where: { roles: { some: { role: { name: 'admin' } } } }, + }); + + if (!adminUser) { + console.warn( + '\n WARNING: No admin user found. Skipping credential migration.', + ); + console.log('\nDone (settings only).'); + await prisma.$disconnect(); + return; + } + + const integrations: Array<{ + type: IntegrationType; + name: string; + envMap: Record; + }> = [ + { + type: IntegrationType.PLENTYONE, + name: 'PlentyONE Default', + envMap: { + baseUrl: 'PLENTYONE_BASE_URL', + clientId: 'PLENTYONE_CLIENT_ID', + clientSecret: 'PLENTYONE_CLIENT_SECRET', + }, + }, + { + type: IntegrationType.ZULIP, + name: 'Zulip Default', + envMap: { + baseUrl: 'ZULIP_BASE_URL', + email: 'ZULIP_EMAIL', + apiKey: 'ZULIP_API_KEY', + }, + }, + { + type: IntegrationType.TODOIST, + name: 'Todoist Default', + envMap: { + apiToken: 'TODOIST_API_TOKEN', + }, + }, + { + type: IntegrationType.FREESCOUT, + name: 'FreeScout Default', + envMap: { + apiUrl: 'FREESCOUT_API_URL', + apiKey: 'FREESCOUT_API_KEY', + }, + }, + { + type: IntegrationType.NEXTCLOUD, + name: 'Nextcloud Default', + envMap: { + serverUrl: 'NEXTCLOUD_URL', + username: 'NEXTCLOUD_USERNAME', + password: 'NEXTCLOUD_PASSWORD', + }, + }, + { + type: IntegrationType.ECODMS, + name: 'ecoDMS Default', + envMap: { + apiUrl: 'ECODMS_API_URL', + username: 'ECODMS_USERNAME', + password: 'ECODMS_PASSWORD', + }, + }, + { + type: IntegrationType.GEMBADOCS, + name: 'GembaDocs Default', + envMap: { + apiUrl: 'GEMBADOCS_API_URL', + apiKey: 'GEMBADOCS_API_KEY', + }, + }, + ]; + + console.log('\n Migrating integration credentials...'); + + for (const integration of integrations) { + const creds: Record = {}; + let hasValues = false; + + for (const [credKey, envKey] of Object.entries(integration.envMap)) { + const val = process.env[envKey]; + if (val) { + creds[credKey] = val; + hasValues = true; + } + } + + if (hasValues) { + const encrypted = encryptObject(creds, encryptionKey); + + const existing = await prisma.integrationCredential.findFirst({ + where: { type: integration.type, name: integration.name }, + }); + + if (existing) { + await prisma.integrationCredential.update({ + where: { id: existing.id }, + data: { credentials: encrypted }, + }); + console.log(` Updated ${integration.type} credentials`); + } else { + await prisma.integrationCredential.create({ + data: { + type: integration.type, + name: integration.name, + credentials: encrypted, + createdById: adminUser.id, + }, + }); + console.log(` Created ${integration.type} credentials`); + } + } else { + console.log(` Skipped ${integration.type} (no env vars set)`); + } + } + + console.log('\nMigration complete!'); + await prisma.$disconnect(); +} + +migrate().catch((error) => { + console.error('Migration failed:', error); + prisma.$disconnect(); + process.exit(1); +}); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f9eaf5a..7657e03 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -654,3 +654,21 @@ model AuditLog { @@index([action]) @@index([createdAt]) } + +// ============================================================================= +// SYSTEM SETTINGS +// ============================================================================= + +model SystemSetting { + id String @id @default(cuid()) + key String @unique + value String + category String + description String? + valueType String @default("string") + isSecret Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([category]) +} diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 31f16ff..75b8caa 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -202,6 +202,45 @@ async function main() { console.log(`Created ${skills.length} skills`); + // Seed default system settings + const defaultSettings = [ + // Sync intervals + { key: 'sync.interval.plentyone', value: '15', category: 'sync', valueType: 'number', description: 'PlentyONE Sync-Intervall (Minuten)' }, + { key: 'sync.interval.zulip', value: '5', category: 'sync', valueType: 'number', description: 'Zulip Sync-Intervall (Minuten)' }, + { key: 'sync.interval.todoist', value: '10', category: 'sync', valueType: 'number', description: 'Todoist Sync-Intervall (Minuten)' }, + { key: 'sync.interval.freescout', value: '10', category: 'sync', valueType: 'number', description: 'FreeScout Sync-Intervall (Minuten)' }, + { key: 'sync.interval.nextcloud', value: '30', category: 'sync', valueType: 'number', description: 'Nextcloud Sync-Intervall (Minuten)' }, + { key: 'sync.interval.ecodms', value: '60', category: 'sync', valueType: 'number', description: 'ecoDMS Sync-Intervall (Minuten)' }, + { key: 'sync.interval.gembadocs', value: '30', category: 'sync', valueType: 'number', description: 'GembaDocs Sync-Intervall (Minuten)' }, + // Feature flags + { key: 'feature.syncJobs.enabled', value: 'false', category: 'feature', valueType: 'boolean', description: 'Hintergrund-Sync-Jobs aktivieren' }, + { key: 'feature.swagger.enabled', value: 'true', category: 'feature', valueType: 'boolean', description: 'Swagger API-Dokumentation aktivieren' }, + // CORS + { key: 'cors.origins', value: 'http://localhost:3000', category: 'cors', valueType: 'string', description: 'Erlaubte CORS Origins (kommagetrennt)' }, + // Branding + { key: 'branding.appName', value: 'tOS', category: 'branding', valueType: 'string', description: 'Anwendungsname' }, + { key: 'branding.companyName', value: '', category: 'branding', valueType: 'string', description: 'Firmenname' }, + { key: 'branding.logoUrl', value: '', category: 'branding', valueType: 'string', description: 'Logo-URL' }, + ]; + + await Promise.all( + defaultSettings.map((setting) => + prisma.systemSetting.upsert({ + where: { key: setting.key }, + update: {}, + create: { + key: setting.key, + value: setting.value, + category: setting.category, + valueType: setting.valueType, + description: setting.description, + }, + }), + ), + ); + + console.log(`Seeded ${defaultSettings.length} system settings`); + console.log('Database seeding completed!'); } diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 0ff351f..53ffec9 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { EventEmitterModule } from '@nestjs/event-emitter'; import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { PrismaModule } from './prisma/prisma.module'; import { AuthModule } from './auth/auth.module'; @@ -18,6 +19,9 @@ import { DashboardModule } from './modules/dashboard/dashboard.module'; import { DepartmentsModule } from './modules/departments/departments.module'; import { UserPreferencesModule } from './modules/user-preferences/user-preferences.module'; +// Phase 1 modules - System Settings +import { SystemSettingsModule } from './modules/system-settings/system-settings.module'; + // Phase 4 modules - LEAN import { LeanModule } from './modules/lean/lean.module'; @@ -40,6 +44,9 @@ import { IntegrationsModule } from './modules/integrations/integrations.module'; }, }), + // Event emitter for decoupled inter-module communication + EventEmitterModule.forRoot(), + // Database PrismaModule, @@ -51,6 +58,9 @@ import { IntegrationsModule } from './modules/integrations/integrations.module'; UsersModule, HealthModule, + // Phase 1 - System Settings (database-backed configuration) + SystemSettingsModule, + // Phase 2 modules AuditModule, DashboardModule, diff --git a/apps/api/src/auth/permissions/permissions.enum.ts b/apps/api/src/auth/permissions/permissions.enum.ts index c7d280b..af62c53 100644 --- a/apps/api/src/auth/permissions/permissions.enum.ts +++ b/apps/api/src/auth/permissions/permissions.enum.ts @@ -84,6 +84,10 @@ export enum Permission { MEETING_CREATE = 'meeting:create', MEETING_UPDATE = 'meeting:update', MEETING_DELETE = 'meeting:delete', + + // System Settings + SYSTEM_SETTINGS_VIEW = 'system_settings:view', + SYSTEM_SETTINGS_MANAGE = 'system_settings:manage', } /** diff --git a/apps/api/src/auth/strategies/jwt.strategy.ts b/apps/api/src/auth/strategies/jwt.strategy.ts index cce1634..2b9acab 100644 --- a/apps/api/src/auth/strategies/jwt.strategy.ts +++ b/apps/api/src/auth/strategies/jwt.strategy.ts @@ -1,44 +1,61 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { Injectable, UnauthorizedException, Logger } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { ConfigService } from '@nestjs/config'; +import { passportJwtSecret } from 'jwks-rsa'; import { JwtPayload } from '../interfaces/jwt-payload.interface'; import { UsersService } from '../../users/users.service'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { + private readonly logger = new Logger(JwtStrategy.name); + constructor( private readonly configService: ConfigService, private readonly usersService: UsersService, ) { - const secret = configService.get('JWT_SECRET'); - if (!secret) { - throw new Error('JWT_SECRET is not defined'); - } + const keycloakUrl = configService.get('KEYCLOAK_URL'); + const keycloakRealm = configService.get('KEYCLOAK_REALM'); + const issuer = `${keycloakUrl}/realms/${keycloakRealm}`; super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secretOrKey: secret, + // Validate RS256 tokens via Keycloak's JWKS endpoint + secretOrKeyProvider: passportJwtSecret({ + cache: true, + rateLimit: true, + jwksRequestsPerMinute: 10, + jwksUri: `${issuer}/protocol/openid-connect/certs`, + }), + issuer, + algorithms: ['RS256'], }); } - async validate(payload: JwtPayload): Promise { - // Optionally validate that the user still exists and is active + async validate(payload: Record): Promise { + // Extract Keycloak-specific fields + const sub = payload.sub as string; + const email = payload.email as string; + const realmAccess = payload.realm_access as { roles?: string[] } | undefined; + const roles = realmAccess?.roles || []; + + if (!sub) { + throw new UnauthorizedException('Invalid token: missing sub'); + } + + // Try to validate against local user DB; if user doesn't exist locally, allow anyway + // (Keycloak is the source of truth for identity) try { - const user = await this.usersService.findOne(payload.sub); + const user = await this.usersService.findOne(sub); if (!user.isActive) { throw new UnauthorizedException('User account is deactivated'); } - - // Return the payload to be attached to the request - return { - sub: payload.sub, - email: payload.email, - roles: payload.roles, - }; } catch { - throw new UnauthorizedException('Invalid token'); + // User not in local DB yet - that's OK, Keycloak token is valid + this.logger.debug(`User ${sub} not found in local DB, proceeding with Keycloak token`); } + + return { sub, email, roles }; } } diff --git a/apps/api/src/config/config.validation.ts b/apps/api/src/config/config.validation.ts index 42dc8b4..8f5471a 100644 --- a/apps/api/src/config/config.validation.ts +++ b/apps/api/src/config/config.validation.ts @@ -20,62 +20,14 @@ export const configValidationSchema = Joi.object({ KEYCLOAK_CLIENT_ID: Joi.string().optional(), KEYCLOAK_CLIENT_SECRET: Joi.string().optional(), - // CORS - CORS_ORIGINS: Joi.string().default('http://localhost:3000'), - - // Swagger - SWAGGER_ENABLED: Joi.string().valid('true', 'false').default('true'), - - // Encryption (Phase 3) + // Encryption ENCRYPTION_KEY: Joi.string().min(32).when('NODE_ENV', { is: 'production', then: Joi.required(), otherwise: Joi.optional(), }), - // Redis (Phase 3 - for BullMQ) + // Redis (optional - for BullMQ in production) REDIS_HOST: Joi.string().optional(), REDIS_PORT: Joi.number().optional(), - - // Sync Jobs (Phase 3) - ENABLE_SYNC_JOBS: Joi.string().valid('true', 'false').default('false'), - SYNC_INTERVAL_PLENTYONE: Joi.number().min(1).default(15), - SYNC_INTERVAL_ZULIP: Joi.number().min(1).default(5), - SYNC_INTERVAL_TODOIST: Joi.number().min(1).default(10), - SYNC_INTERVAL_FREESCOUT: Joi.number().min(1).default(10), - SYNC_INTERVAL_NEXTCLOUD: Joi.number().min(1).default(30), - SYNC_INTERVAL_ECODMS: Joi.number().min(1).default(60), - SYNC_INTERVAL_GEMBADOCS: Joi.number().min(1).default(30), - - // ============================================================================ - // Integration Credentials (Phase 3 - API Connectors) - // ============================================================================ - - // PlentyONE (OAuth2 Client Credentials) - PLENTYONE_BASE_URL: Joi.string().uri().optional(), - PLENTYONE_CLIENT_ID: Joi.string().optional(), - PLENTYONE_CLIENT_SECRET: Joi.string().optional(), - - // ZULIP (Basic Auth with API Key) - ZULIP_BASE_URL: Joi.string().uri().optional(), - ZULIP_EMAIL: Joi.string().email().optional(), - ZULIP_API_KEY: Joi.string().optional(), - - // Todoist (Bearer Token) - TODOIST_API_TOKEN: Joi.string().optional(), - - // FreeScout (API Key) - FREESCOUT_API_URL: Joi.string().uri().optional(), - FREESCOUT_API_KEY: Joi.string().optional(), - - // Nextcloud (Basic Auth / App Password) - NEXTCLOUD_URL: Joi.string().uri().optional(), - NEXTCLOUD_USERNAME: Joi.string().optional(), - NEXTCLOUD_PASSWORD: Joi.string().optional(), - - // ecoDMS (Session-based Auth) - ECODMS_API_URL: Joi.string().uri().optional(), - ECODMS_USERNAME: Joi.string().optional(), - ECODMS_PASSWORD: Joi.string().optional(), - ECODMS_API_VERSION: Joi.string().default('v1'), }); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index da5e7ff..311d89e 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -6,23 +6,33 @@ import helmet from 'helmet'; import { AppModule } from './app.module'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { TransformInterceptor } from './common/interceptors/transform.interceptor'; +import { SystemSettingsService } from './modules/system-settings/system-settings.service'; async function bootstrap() { const logger = new Logger('Bootstrap'); const app = await NestFactory.create(AppModule); const configService = app.get(ConfigService); + const systemSettings = app.get(SystemSettingsService); // Security app.use(helmet()); - // CORS - const corsOrigins = configService.get('CORS_ORIGINS')?.split(',').map((origin) => origin.trim()) || [ - 'http://localhost:3000', - ]; + // CORS - origins are read dynamically from the database on each request app.enableCors({ - origin: corsOrigins, - methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS', + origin: async (origin, callback) => { + const allowed = await systemSettings.getValue('cors.origins'); + const origins = allowed + ? allowed.split(',').map((s) => s.trim()) + : ['http://localhost:3000']; + if (!origin || origins.includes(origin) || origins.includes('*')) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); + } + }, credentials: true, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'Accept'], }); // API Prefix @@ -53,8 +63,8 @@ async function bootstrap() { // Global Interceptors app.useGlobalInterceptors(new TransformInterceptor()); - // Swagger Documentation - const swaggerEnabled = configService.get('SWAGGER_ENABLED') === 'true'; + // Swagger Documentation - enabled/disabled via DB setting + const swaggerEnabled = await systemSettings.getTypedValue('feature.swagger.enabled', true); if (swaggerEnabled) { const config = new DocumentBuilder() .setTitle('tOS API') diff --git a/apps/api/src/modules/integrations/connectors/base-connector.ts b/apps/api/src/modules/integrations/connectors/base-connector.ts index 9b72b94..38b67bf 100644 --- a/apps/api/src/modules/integrations/connectors/base-connector.ts +++ b/apps/api/src/modules/integrations/connectors/base-connector.ts @@ -72,12 +72,25 @@ export abstract class BaseConnector { protected readonly logger: Logger; /** Axios HTTP client instance */ - protected readonly httpClient: AxiosInstance; + protected httpClient: AxiosInstance; /** Connector configuration */ - protected readonly config: Required; + protected config: Required; - constructor(config: BaseConnectorConfig) { + constructor(config?: BaseConnectorConfig) { + // Logger will be initialized with the concrete class name + this.logger = new Logger(this.constructor.name); + + if (config?.baseUrl) { + this.reconfigure(config); + } + } + + /** + * Create or recreate the HTTP client with the given configuration. + * Call this when credentials are loaded from DB or updated. + */ + protected reconfigure(config: BaseConnectorConfig): void { this.config = { timeout: 30000, maxRetries: 3, @@ -87,9 +100,6 @@ export abstract class BaseConnector { ...config, }; - // Logger will be initialized with the concrete class name - this.logger = new Logger(this.constructor.name); - // Create axios instance with base configuration this.httpClient = axios.create({ baseURL: this.config.baseUrl, @@ -100,7 +110,7 @@ export abstract class BaseConnector { }, }); - // Setup interceptors + // Setup interceptors on the new client this.setupRequestInterceptor(); this.setupResponseInterceptor(); } @@ -125,7 +135,7 @@ export abstract class BaseConnector { /** * Setup request interceptor for logging and authentication */ - private setupRequestInterceptor(): void { + protected setupRequestInterceptor(): void { this.httpClient.interceptors.request.use( async (config: InternalAxiosRequestConfig) => { // Add authentication headers @@ -161,7 +171,7 @@ export abstract class BaseConnector { /** * Setup response interceptor for logging and error transformation */ - private setupResponseInterceptor(): void { + protected setupResponseInterceptor(): void { this.httpClient.interceptors.response.use( (response: AxiosResponse) => { const config = response.config as InternalAxiosRequestConfig & { metadata?: { startTime: number } }; @@ -186,7 +196,7 @@ export abstract class BaseConnector { /** * Transform axios errors into integration-specific errors */ - private handleResponseError(error: AxiosError): Promise { + protected handleResponseError(error: AxiosError): Promise { const status = error.response?.status; const message = this.extractErrorMessage(error); diff --git a/apps/api/src/modules/integrations/connectors/ecodms/ecodms.connector.ts b/apps/api/src/modules/integrations/connectors/ecodms/ecodms.connector.ts index 2dac7ac..da4a18f 100644 --- a/apps/api/src/modules/integrations/connectors/ecodms/ecodms.connector.ts +++ b/apps/api/src/modules/integrations/connectors/ecodms/ecodms.connector.ts @@ -1,7 +1,8 @@ import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { OnEvent } from '@nestjs/event-emitter'; import axios, { AxiosInstance, AxiosError } from 'axios'; import FormData from 'form-data'; +import { CredentialsService } from '../../credentials/credentials.service'; import { IntegrationConnectionError, IntegrationAuthError, @@ -21,9 +22,6 @@ export interface ConnectorHealth { details?: Record; } -/** - * Retry configuration - */ interface RetryConfig { maxRetries: number; baseDelayMs: number; @@ -73,70 +71,76 @@ import { export class EcoDmsConnector { private readonly logger = new Logger(EcoDmsConnector.name); private readonly integrationName = 'ecoDMS'; - private readonly httpClient: AxiosInstance; + private httpClient: AxiosInstance; private readonly retryConfig: RetryConfig; - private isConfigured: boolean = false; + private configuredState: boolean = false; + private initialized = false; - private readonly baseUrl: string; - private readonly username: string; - private readonly password: string; - private readonly apiVersion: string; + private baseUrl: string = ''; + private username: string = ''; + private password: string = ''; + private readonly apiVersion: string = 'v1'; // Session management private session: EcoDmsSession | null = null; private sessionRefreshTimer: NodeJS.Timeout | null = null; - constructor(private readonly configService: ConfigService) { + constructor(private readonly credentialsService: CredentialsService) { this.retryConfig = { ...DEFAULT_RETRY_CONFIG }; - - // Load configuration from environment - this.baseUrl = this.configService.get('ECODMS_API_URL', ''); - this.username = this.configService.get('ECODMS_USERNAME', ''); - this.password = this.configService.get('ECODMS_PASSWORD', ''); - this.apiVersion = this.configService.get('ECODMS_API_VERSION', 'v1'); - - // Validate configuration - this.validateConfiguration(); - - // Initialize HTTP client - this.httpClient = axios.create({ - baseURL: this.baseUrl ? `${this.baseUrl}/api/${this.apiVersion}` : undefined, - timeout: DEFAULT_TIMEOUT_MS, - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - }); - - this.setupInterceptors(); - - if (this.isConfigured) { - this.logger.log(`ecoDMS connector initialized with base URL: ${this.baseUrl}`); - } else { - this.logger.warn('ecoDMS connector not configured - missing credentials'); - } + this.httpClient = axios.create(); } - /** - * Validate required configuration - */ - private validateConfiguration(): void { - const missing: string[] = []; + async ensureInitialized(): Promise { + if (this.initialized) return; - if (!this.baseUrl) { - missing.push('ECODMS_API_URL'); - } - if (!this.username) { - missing.push('ECODMS_USERNAME'); - } - if (!this.password) { - missing.push('ECODMS_PASSWORD'); + const result = await this.credentialsService.findActiveByType('ECODMS'); + + if (!result) { + this.configuredState = false; + this.initialized = true; + this.logger.warn('ecoDMS connector not configured - no active credentials found'); + return; } - this.isConfigured = missing.length === 0; + this.baseUrl = result.credentials.apiUrl || ''; + this.username = result.credentials.username || ''; + this.password = result.credentials.password || ''; + this.configuredState = !!(this.baseUrl && this.username && this.password); - if (!this.isConfigured) { - this.logger.warn(`ecoDMS configuration incomplete. Missing: ${missing.join(', ')}`); + if (this.configuredState) { + this.httpClient = axios.create({ + baseURL: `${this.baseUrl}/api/${this.apiVersion}`, + timeout: DEFAULT_TIMEOUT_MS, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }); + this.setupInterceptors(); + this.logger.log(`ecoDMS connector initialized with base URL: ${this.baseUrl}`); + } else { + this.logger.warn('ecoDMS connector not configured - missing apiUrl, username, or password'); + } + + this.initialized = true; + } + + async reload(): Promise { + this.initialized = false; + this.configuredState = false; + this.session = null; + if (this.sessionRefreshTimer) { + clearTimeout(this.sessionRefreshTimer); + this.sessionRefreshTimer = null; + } + await this.ensureInitialized(); + } + + @OnEvent('credentials.changed') + async onCredentialsChanged(payload: { type: string }): Promise { + if (payload.type === 'ECODMS') { + this.logger.log('ecoDMS credentials changed, reloading'); + await this.reload(); } } @@ -194,16 +198,9 @@ export class EcoDmsConnector { ); } - /** - * Ensure the connector is configured - */ private ensureConfigured(): void { - if (!this.isConfigured) { - throw new IntegrationConfigError(this.integrationName, [ - 'ECODMS_API_URL', - 'ECODMS_USERNAME', - 'ECODMS_PASSWORD', - ]); + if (!this.configuredState) { + throw new IntegrationConfigError(this.integrationName, ['apiUrl', 'username', 'password']); } } @@ -431,7 +428,8 @@ export class EcoDmsConnector { * Check connector health */ async checkHealth(): Promise { - if (!this.isConfigured) { + await this.ensureInitialized(); + if (!this.configuredState) { return { status: 'not_configured', lastCheck: new Date(), @@ -466,6 +464,7 @@ export class EcoDmsConnector { * Test connection */ async testConnection(): Promise<{ success: boolean; message: string; latency?: number }> { + await this.ensureInitialized(); this.ensureConfigured(); const startTime = Date.now(); @@ -484,10 +483,10 @@ export class EcoDmsConnector { } /** - * Check if connector is configured + * Check if connector is configured (last known state) */ getIsConfigured(): boolean { - return this.isConfigured; + return this.configuredState; } // ============ Documents API ============ @@ -499,6 +498,7 @@ export class EcoDmsConnector { documents: EcoDmsDocument[]; pagination: { page: number; pageSize: number; total: number; totalPages: number }; }> { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -551,6 +551,7 @@ export class EcoDmsConnector { * Get a single document by ID */ async getDocument(id: number): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -574,6 +575,7 @@ export class EcoDmsConnector { * Search documents */ async searchDocuments(params: SearchDocumentsDto): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -632,6 +634,7 @@ export class EcoDmsConnector { mimeType: string, metadata: UploadDocumentDto, ): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -685,6 +688,7 @@ export class EcoDmsConnector { * Update document metadata */ async updateDocument(id: number, data: UpdateDocumentDto): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -708,6 +712,7 @@ export class EcoDmsConnector { * Delete a document */ async deleteDocument(id: number): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -728,6 +733,7 @@ export class EcoDmsConnector { * Download document content */ async downloadDocument(id: number): Promise<{ content: Buffer; fileName: string; mimeType: string }> { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -752,6 +758,7 @@ export class EcoDmsConnector { * Get document preview (thumbnail or PDF preview) */ async getDocumentPreview(id: number, page: number = 1): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -770,6 +777,7 @@ export class EcoDmsConnector { * List folders */ async listFolders(parentId?: number): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -798,6 +806,7 @@ export class EcoDmsConnector { * Get folder tree */ async getFolderTree(): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -820,6 +829,7 @@ export class EcoDmsConnector { * Create a folder */ async createFolder(data: CreateFolderDto): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -843,6 +853,7 @@ export class EcoDmsConnector { * Delete a folder */ async deleteFolder(id: number): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -865,6 +876,7 @@ export class EcoDmsConnector { * List classifications (document types/categories) */ async listClassifications(): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -887,6 +899,7 @@ export class EcoDmsConnector { * Get classification details */ async getClassification(id: number): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { diff --git a/apps/api/src/modules/integrations/connectors/ecodms/ecodms.module.ts b/apps/api/src/modules/integrations/connectors/ecodms/ecodms.module.ts index fb086f5..5489a31 100644 --- a/apps/api/src/modules/integrations/connectors/ecodms/ecodms.module.ts +++ b/apps/api/src/modules/integrations/connectors/ecodms/ecodms.module.ts @@ -1,33 +1,17 @@ import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; import { EcoDmsConnector } from './ecodms.connector'; import { EcoDmsService } from './ecodms.service'; import { EcoDmsController } from './ecodms.controller'; +import { CredentialsModule } from '../../credentials/credentials.module'; /** * ecoDMS Integration Module * * Provides integration with ecoDMS document management system. - * - * Required environment variables: - * - ECODMS_API_URL: Base URL of ecoDMS API (e.g., https://ecodms.example.com) - * - ECODMS_USERNAME: Username for authentication - * - ECODMS_PASSWORD: Password for authentication - * - * Optional environment variables: - * - ECODMS_API_VERSION: API version (default: v1) - * - * Features: - * - Session-based authentication with automatic refresh - * - Document CRUD operations - * - Full-text search with attribute filters - * - Folder management - * - Classification/Category management - * - Document download and preview - * - OCR support + * Credentials are stored in the DB and loaded via CredentialsService. */ @Module({ - imports: [ConfigModule], + imports: [CredentialsModule], controllers: [EcoDmsController], providers: [EcoDmsConnector, EcoDmsService], exports: [EcoDmsService, EcoDmsConnector], diff --git a/apps/api/src/modules/integrations/connectors/freescout/freescout.connector.ts b/apps/api/src/modules/integrations/connectors/freescout/freescout.connector.ts index bdf0cd3..3c05548 100644 --- a/apps/api/src/modules/integrations/connectors/freescout/freescout.connector.ts +++ b/apps/api/src/modules/integrations/connectors/freescout/freescout.connector.ts @@ -1,5 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { OnEvent } from '@nestjs/event-emitter'; import axios, { AxiosInstance, AxiosError } from 'axios'; import { IntegrationConnectionError, @@ -8,6 +8,7 @@ import { IntegrationApiError, IntegrationConfigError, } from '../../errors'; +import { CredentialsService } from '../../credentials/credentials.service'; /** * Health status of a connector @@ -49,7 +50,6 @@ import { ReplyToConversationDto, CreateCustomerDto, ListCustomersDto, - ConversationType, ThreadType, } from './freescout.types'; @@ -57,12 +57,7 @@ import { * FreeScout API Connector * * Provides integration with FreeScout helpdesk system. - * Features: - * - API Key authentication - * - Conversations/Tickets management - * - Mailboxes API - * - Customers API - * - Tags API + * Credentials are loaded lazily from the DB via CredentialsService. * * API Documentation: https://github.com/freescout-helpdesk/freescout/wiki/API */ @@ -70,60 +65,78 @@ import { export class FreeScoutConnector { private readonly logger = new Logger(FreeScoutConnector.name); private readonly integrationName = 'FreeScout'; - private readonly httpClient: AxiosInstance; + private httpClient: AxiosInstance; private readonly retryConfig: RetryConfig; - private isConfigured: boolean = false; + private configuredState: boolean = false; + private initialized = false; - private readonly baseUrl: string; - private readonly apiKey: string; + private baseUrl: string = ''; + private apiKey: string = ''; - constructor(private readonly configService: ConfigService) { + constructor(private readonly credentialsService: CredentialsService) { this.retryConfig = { ...DEFAULT_RETRY_CONFIG }; - - // Load configuration from environment - this.baseUrl = this.configService.get('FREESCOUT_API_URL', ''); - this.apiKey = this.configService.get('FREESCOUT_API_KEY', ''); - - // Validate configuration - this.validateConfiguration(); - - // Initialize HTTP client - this.httpClient = axios.create({ - baseURL: this.baseUrl ? `${this.baseUrl}/api` : undefined, - timeout: DEFAULT_TIMEOUT_MS, - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'X-FreeScout-API-Key': this.apiKey, - }, - }); - - this.setupInterceptors(); - - if (this.isConfigured) { - this.logger.log(`FreeScout connector initialized with base URL: ${this.baseUrl}`); - } else { - this.logger.warn('FreeScout connector not configured - missing API URL or API Key'); - } + // httpClient will be initialized lazily on first ensureInitialized() call + this.httpClient = axios.create(); // placeholder, replaced on init } /** - * Validate required configuration + * Load credentials from DB and configure the connector. + * Idempotent - returns immediately if already initialized. */ - private validateConfiguration(): void { - const missing: string[] = []; + async ensureInitialized(): Promise { + if (this.initialized) return; - if (!this.baseUrl) { - missing.push('FREESCOUT_API_URL'); - } - if (!this.apiKey) { - missing.push('FREESCOUT_API_KEY'); + const result = await this.credentialsService.findActiveByType('FREESCOUT'); + + if (!result) { + this.configuredState = false; + this.initialized = true; + this.logger.warn('FreeScout connector not configured - no active credentials found'); + return; } - this.isConfigured = missing.length === 0; + this.baseUrl = result.credentials.apiUrl || ''; + this.apiKey = result.credentials.apiKey || ''; - if (!this.isConfigured) { - this.logger.warn(`FreeScout configuration incomplete. Missing: ${missing.join(', ')}`); + this.configuredState = !!(this.baseUrl && this.apiKey); + + if (this.configuredState) { + this.httpClient = axios.create({ + baseURL: `${this.baseUrl}/api`, + timeout: DEFAULT_TIMEOUT_MS, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-FreeScout-API-Key': this.apiKey, + }, + }); + + this.setupInterceptors(); + this.logger.log(`FreeScout connector initialized with base URL: ${this.baseUrl}`); + } else { + this.logger.warn('FreeScout connector not configured - missing apiUrl or apiKey in credentials'); + } + + this.initialized = true; + } + + /** + * Reload credentials from DB (called when credentials change) + */ + async reload(): Promise { + this.initialized = false; + this.configuredState = false; + await this.ensureInitialized(); + } + + /** + * Handle credential change events + */ + @OnEvent('credentials.changed') + async onCredentialsChanged(payload: { type: string }): Promise { + if (payload.type === 'FREESCOUT') { + this.logger.log('FreeScout credentials changed, reloading'); + await this.reload(); } } @@ -162,8 +175,8 @@ export class FreeScoutConnector { * Ensure the connector is configured before making API calls */ private ensureConfigured(): void { - if (!this.isConfigured) { - throw new IntegrationConfigError(this.integrationName, ['FREESCOUT_API_URL', 'FREESCOUT_API_KEY']); + if (!this.configuredState) { + throw new IntegrationConfigError(this.integrationName, ['apiUrl', 'apiKey']); } } @@ -210,7 +223,7 @@ export class FreeScoutConnector { } if (!error.response) { - return true; // Network errors are retryable + return true; } const status = error.response.status; @@ -265,7 +278,7 @@ export class FreeScoutConnector { `Access forbidden: ${message}`, error, ); - case 429: + case 429: { const retryAfter = parseInt(error.response.headers['retry-after'] || '60', 10); return new IntegrationRateLimitError( this.integrationName, @@ -273,6 +286,7 @@ export class FreeScoutConnector { retryAfter, error, ); + } default: return new IntegrationApiError( this.integrationName, @@ -297,7 +311,9 @@ export class FreeScoutConnector { * Check connector health */ async checkHealth(): Promise { - if (!this.isConfigured) { + await this.ensureInitialized(); + + if (!this.configuredState) { return { status: 'not_configured', lastCheck: new Date(), @@ -327,6 +343,7 @@ export class FreeScoutConnector { * Test connection to FreeScout API */ async testConnection(): Promise<{ success: boolean; message: string; latency?: number }> { + await this.ensureInitialized(); this.ensureConfigured(); const startTime = Date.now(); @@ -342,16 +359,15 @@ export class FreeScoutConnector { latency, }; } catch (error) { - const latency = Date.now() - startTime; throw this.mapError(error as AxiosError, 'testConnection'); } } /** - * Check if connector is configured + * Check if connector is configured (last known state) */ getIsConfigured(): boolean { - return this.isConfigured; + return this.configuredState; } // ============ Conversations API ============ @@ -363,6 +379,7 @@ export class FreeScoutConnector { conversations: FreeScoutConversation[]; pagination: { page: number; pageSize: number; total: number; totalPages: number }; }> { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -407,6 +424,7 @@ export class FreeScoutConnector { * Get a single conversation by ID */ async getConversation(id: number): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -419,6 +437,7 @@ export class FreeScoutConnector { * Create a new conversation */ async createConversation(data: CreateConversationDto): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -454,6 +473,7 @@ export class FreeScoutConnector { conversationId: number, data: ReplyToConversationDto, ): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -482,6 +502,7 @@ export class FreeScoutConnector { conversationId: number, status: string, ): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -499,6 +520,7 @@ export class FreeScoutConnector { * List all mailboxes */ async listMailboxes(): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -511,6 +533,7 @@ export class FreeScoutConnector { * Get a single mailbox by ID */ async getMailbox(id: number): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -528,6 +551,7 @@ export class FreeScoutConnector { customers: FreeScoutCustomer[]; pagination: { page: number; pageSize: number; total: number; totalPages: number }; }> { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -566,6 +590,7 @@ export class FreeScoutConnector { * Get a single customer by ID */ async getCustomer(id: number): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -578,6 +603,7 @@ export class FreeScoutConnector { * Create a new customer */ async createCustomer(data: CreateCustomerDto): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -590,6 +616,7 @@ export class FreeScoutConnector { * Find customer by email */ async findCustomerByEmail(email: string): Promise { + await this.ensureInitialized(); this.ensureConfigured(); const result = await this.listCustomers({ email }); @@ -602,6 +629,7 @@ export class FreeScoutConnector { * List all tags */ async listTags(): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -614,6 +642,7 @@ export class FreeScoutConnector { * Add tags to a conversation */ async addTagsToConversation(conversationId: number, tags: string[]): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { diff --git a/apps/api/src/modules/integrations/connectors/freescout/freescout.module.ts b/apps/api/src/modules/integrations/connectors/freescout/freescout.module.ts index fb796b4..11bb6f2 100644 --- a/apps/api/src/modules/integrations/connectors/freescout/freescout.module.ts +++ b/apps/api/src/modules/integrations/connectors/freescout/freescout.module.ts @@ -1,20 +1,17 @@ import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; import { FreeScoutConnector } from './freescout.connector'; import { FreeScoutService } from './freescout.service'; import { FreeScoutController } from './freescout.controller'; +import { CredentialsModule } from '../../credentials/credentials.module'; /** * FreeScout Integration Module * * Provides integration with FreeScout helpdesk system. - * - * Required environment variables: - * - FREESCOUT_API_URL: Base URL of FreeScout instance (e.g., https://support.example.com) - * - FREESCOUT_API_KEY: API key for authentication + * Credentials are stored in the DB and loaded via CredentialsService. */ @Module({ - imports: [ConfigModule], + imports: [CredentialsModule], controllers: [FreeScoutController], providers: [FreeScoutConnector, FreeScoutService], exports: [FreeScoutService, FreeScoutConnector], diff --git a/apps/api/src/modules/integrations/connectors/gembadocs/gembadocs.connector.ts b/apps/api/src/modules/integrations/connectors/gembadocs/gembadocs.connector.ts index 77fd625..1ceaf88 100644 --- a/apps/api/src/modules/integrations/connectors/gembadocs/gembadocs.connector.ts +++ b/apps/api/src/modules/integrations/connectors/gembadocs/gembadocs.connector.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { OnEvent } from '@nestjs/event-emitter'; import axios, { AxiosInstance, AxiosError } from 'axios'; +import { CredentialsService } from '../../credentials/credentials.service'; import { IntegrationConnectionError, IntegrationAuthError, @@ -73,59 +74,64 @@ const DEFAULT_TIMEOUT_MS = 30000; export class GembaDocsConnector { private readonly logger = new Logger(GembaDocsConnector.name); private readonly integrationName = 'GembaDocs'; - private readonly httpClient: AxiosInstance; + private httpClient: AxiosInstance; private readonly retryConfig: RetryConfig; private isConfiguredFlag: boolean = false; + private initialized = false; - private readonly baseUrl: string; - private readonly apiKey: string; + private baseUrl: string = ''; + private apiKey: string = ''; - constructor(private readonly configService: ConfigService) { + constructor(private readonly credentialsService: CredentialsService) { this.retryConfig = { ...DEFAULT_RETRY_CONFIG }; - - // Load configuration from environment - this.baseUrl = this.configService.get('GEMBADOCS_API_URL', ''); - this.apiKey = this.configService.get('GEMBADOCS_API_KEY', ''); - - // Validate configuration - this.validateConfiguration(); - - // Initialize HTTP client - this.httpClient = axios.create({ - baseURL: this.baseUrl ? `${this.baseUrl}/api/v1` : undefined, - timeout: DEFAULT_TIMEOUT_MS, - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - }); - - this.setupInterceptors(); - - if (this.isConfiguredFlag) { - this.logger.log(`GembaDocs connector initialized with base URL: ${this.baseUrl}`); - } else { - this.logger.warn('GembaDocs connector not configured - missing credentials'); - } + this.httpClient = axios.create(); } - /** - * Validate required configuration - */ - private validateConfiguration(): void { - const missing: string[] = []; + async ensureInitialized(): Promise { + if (this.initialized) return; - if (!this.baseUrl) { - missing.push('GEMBADOCS_API_URL'); - } - if (!this.apiKey) { - missing.push('GEMBADOCS_API_KEY'); + const result = await this.credentialsService.findActiveByType('GEMBADOCS'); + + if (!result) { + this.isConfiguredFlag = false; + this.initialized = true; + this.logger.warn('GembaDocs connector not configured - no active credentials found'); + return; } - this.isConfiguredFlag = missing.length === 0; + this.baseUrl = result.credentials.apiUrl || ''; + this.apiKey = result.credentials.apiKey || ''; + this.isConfiguredFlag = !!(this.baseUrl && this.apiKey); - if (!this.isConfiguredFlag) { - this.logger.warn(`GembaDocs configuration incomplete. Missing: ${missing.join(', ')}`); + if (this.isConfiguredFlag) { + this.httpClient = axios.create({ + baseURL: `${this.baseUrl}/api/v1`, + timeout: DEFAULT_TIMEOUT_MS, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }); + this.setupInterceptors(); + this.logger.log(`GembaDocs connector initialized with base URL: ${this.baseUrl}`); + } else { + this.logger.warn('GembaDocs connector not configured - missing apiUrl or apiKey'); + } + + this.initialized = true; + } + + async reload(): Promise { + this.initialized = false; + this.isConfiguredFlag = false; + await this.ensureInitialized(); + } + + @OnEvent('credentials.changed') + async onCredentialsChanged(payload: { type: string }): Promise { + if (payload.type === 'GEMBADOCS') { + this.logger.log('GembaDocs credentials changed, reloading'); + await this.reload(); } } @@ -163,15 +169,9 @@ export class GembaDocsConnector { ); } - /** - * Ensure the connector is configured - */ private ensureConfigured(): void { if (!this.isConfiguredFlag) { - throw new IntegrationConfigError(this.integrationName, [ - 'GEMBADOCS_API_URL', - 'GEMBADOCS_API_KEY', - ]); + throw new IntegrationConfigError(this.integrationName, ['apiUrl', 'apiKey']); } } @@ -309,6 +309,7 @@ export class GembaDocsConnector { * Check connector health */ async checkHealth(): Promise { + await this.ensureInitialized(); if (!this.isConfiguredFlag) { return { status: 'not_configured', @@ -340,6 +341,7 @@ export class GembaDocsConnector { * Test connection */ async testConnection(): Promise<{ success: boolean; message: string; latency?: number }> { + await this.ensureInitialized(); this.ensureConfigured(); const startTime = Date.now(); @@ -379,8 +381,8 @@ export class GembaDocsConnector { */ getMissingConfig(): string[] { const missing: string[] = []; - if (!this.baseUrl) missing.push('GEMBADOCS_API_URL'); - if (!this.apiKey) missing.push('GEMBADOCS_API_KEY'); + if (!this.baseUrl) missing.push('apiUrl'); + if (!this.apiKey) missing.push('apiKey'); return missing; } @@ -393,6 +395,7 @@ export class GembaDocsConnector { audits: GembaAudit[]; pagination: { page: number; pageSize: number; total: number; totalPages: number }; }> { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -440,6 +443,7 @@ export class GembaDocsConnector { * Get a single audit by ID */ async getAudit(id: string): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -463,6 +467,7 @@ export class GembaDocsConnector { * Get upcoming audits (scheduled for the future) */ async getUpcomingAudits(days: number = 7, limit: number = 10): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -498,6 +503,7 @@ export class GembaDocsConnector { * Get overdue audits (scheduled in the past but not completed) */ async getOverdueAudits(limit: number = 10): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -534,6 +540,7 @@ export class GembaDocsConnector { * Create a new audit */ async createAudit(data: CreateAuditDto): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -557,6 +564,7 @@ export class GembaDocsConnector { * Update an audit */ async updateAudit(id: string, data: UpdateAuditDto): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -582,6 +590,7 @@ export class GembaDocsConnector { * List all checklist templates */ async listChecklists(): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -604,6 +613,7 @@ export class GembaDocsConnector { * Get a single checklist by ID */ async getChecklist(id: string): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -632,6 +642,7 @@ export class GembaDocsConnector { findings: GembaFinding[]; pagination: { page: number; pageSize: number; total: number; totalPages: number }; }> { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -677,6 +688,7 @@ export class GembaDocsConnector { * Get open findings */ async getOpenFindings(limit: number = 20): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -706,6 +718,7 @@ export class GembaDocsConnector { * Update a finding */ async updateFinding(id: string, data: UpdateFindingDto): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -729,6 +742,7 @@ export class GembaDocsConnector { * Resolve a finding */ async resolveFinding(id: string, data: ResolveFindingDto): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -754,6 +768,7 @@ export class GembaDocsConnector { * Get audit statistics */ async getStatistics(department?: string): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { @@ -780,6 +795,7 @@ export class GembaDocsConnector { * Get trend data for charts */ async getTrends(params: GetTrendsDto = {}): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { diff --git a/apps/api/src/modules/integrations/connectors/gembadocs/gembadocs.module.ts b/apps/api/src/modules/integrations/connectors/gembadocs/gembadocs.module.ts index 405cfd7..08730e8 100644 --- a/apps/api/src/modules/integrations/connectors/gembadocs/gembadocs.module.ts +++ b/apps/api/src/modules/integrations/connectors/gembadocs/gembadocs.module.ts @@ -1,17 +1,16 @@ import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; import { GembaDocsConnector } from './gembadocs.connector'; import { GembaDocsService } from './gembadocs.service'; import { GembaDocsController } from './gembadocs.controller'; +import { CredentialsModule } from '../../credentials/credentials.module'; /** * GembaDocs Integration Module * * Provides integration with GembaDocs audit and checklist management system. * - * Required environment variables: - * - GEMBADOCS_API_URL: Base URL of GembaDocs API (e.g., https://api.gembadocs.com) - * - GEMBADOCS_API_KEY: API key for authentication + * Credentials are stored in the DB and loaded via CredentialsService. + * Required credential keys: apiUrl, apiKey * * Features: * - API Key authentication @@ -57,7 +56,7 @@ import { GembaDocsController } from './gembadocs.controller'; * - GET /integrations/gembadocs/compliance-score - Compliance score */ @Module({ - imports: [ConfigModule], + imports: [CredentialsModule], controllers: [GembaDocsController], providers: [GembaDocsConnector, GembaDocsService], exports: [GembaDocsService, GembaDocsConnector], diff --git a/apps/api/src/modules/integrations/connectors/nextcloud/nextcloud.connector.ts b/apps/api/src/modules/integrations/connectors/nextcloud/nextcloud.connector.ts index c372725..f5a0fbc 100644 --- a/apps/api/src/modules/integrations/connectors/nextcloud/nextcloud.connector.ts +++ b/apps/api/src/modules/integrations/connectors/nextcloud/nextcloud.connector.ts @@ -1,5 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { OnEvent } from '@nestjs/event-emitter'; import axios, { AxiosInstance, AxiosError } from 'axios'; import { IntegrationConnectionError, @@ -8,10 +8,20 @@ import { IntegrationApiError, IntegrationConfigError, } from '../../errors'; +import { CredentialsService } from '../../credentials/credentials.service'; +import { + NextcloudFile, + NextcloudUser, + NextcloudShare, + NextcloudCalendarEvent, + NextcloudCalendar, + OCSResponse, + FileType, + ListFilesDto, + CreateShareDto, + ListEventsDto, +} from './nextcloud.types'; -/** - * Health status of a connector - */ export interface ConnectorHealth { status: 'connected' | 'error' | 'not_configured'; lastCheck: Date; @@ -20,9 +30,6 @@ export interface ConnectorHealth { details?: Record; } -/** - * Retry configuration - */ interface RetryConfig { maxRetries: number; baseDelayMs: number; @@ -38,124 +45,98 @@ const DEFAULT_RETRY_CONFIG: RetryConfig = { }; const DEFAULT_TIMEOUT_MS = 30000; -import { - NextcloudFile, - NextcloudUser, - NextcloudShare, - NextcloudCalendarEvent, - NextcloudCalendar, - OCSResponse, - FileType, - ShareType, - SharePermission, - ListFilesDto, - CreateShareDto, - ListEventsDto, - CreateEventDto, -} from './nextcloud.types'; /** * Nextcloud API Connector * * Provides integration with Nextcloud cloud platform. - * Features: - * - Basic Auth / App Password authentication - * - WebDAV Files API (list, upload, download, delete, move, copy) - * - OCS Share API - * - User API - * - CalDAV Calendar API (basic support) - * - * API Documentation: - * - WebDAV: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/WebDAV/index.html - * - OCS: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/index.html + * Credentials are loaded lazily from the DB via CredentialsService. */ @Injectable() export class NextcloudConnector { private readonly logger = new Logger(NextcloudConnector.name); private readonly integrationName = 'Nextcloud'; - private readonly httpClient: AxiosInstance; - private readonly webdavClient: AxiosInstance; + private httpClient: AxiosInstance; + private webdavClient: AxiosInstance; private readonly retryConfig: RetryConfig; - private isConfigured: boolean = false; + private configuredState: boolean = false; + private initialized = false; - private readonly baseUrl: string; - private readonly username: string; - private readonly password: string; + private baseUrl: string = ''; + private username: string = ''; + private password: string = ''; - constructor(private readonly configService: ConfigService) { + constructor(private readonly credentialsService: CredentialsService) { this.retryConfig = { ...DEFAULT_RETRY_CONFIG }; + this.httpClient = axios.create(); + this.webdavClient = axios.create(); + } - // Load configuration from environment - this.baseUrl = this.configService.get('NEXTCLOUD_URL', ''); - this.username = this.configService.get('NEXTCLOUD_USERNAME', ''); - this.password = this.configService.get('NEXTCLOUD_PASSWORD', ''); + async ensureInitialized(): Promise { + if (this.initialized) return; - // Validate configuration - this.validateConfiguration(); + const result = await this.credentialsService.findActiveByType('NEXTCLOUD'); - const authHeader = Buffer.from(`${this.username}:${this.password}`).toString('base64'); + if (!result) { + this.configuredState = false; + this.initialized = true; + this.logger.warn('Nextcloud connector not configured - no active credentials found'); + return; + } - // Initialize OCS API client - this.httpClient = axios.create({ - baseURL: this.baseUrl ? `${this.baseUrl}/ocs/v2.php` : undefined, - timeout: DEFAULT_TIMEOUT_MS, - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'OCS-APIRequest': 'true', - 'Authorization': `Basic ${authHeader}`, - }, - }); + this.baseUrl = result.credentials.serverUrl || ''; + this.username = result.credentials.username || ''; + this.password = result.credentials.password || ''; + this.configuredState = !!(this.baseUrl && this.username && this.password); - // Initialize WebDAV client - this.webdavClient = axios.create({ - baseURL: this.baseUrl ? `${this.baseUrl}/remote.php/dav/files/${this.username}` : undefined, - timeout: DEFAULT_TIMEOUT_MS, - headers: { - 'Authorization': `Basic ${authHeader}`, - }, - }); + if (this.configuredState) { + const authHeader = Buffer.from(`${this.username}:${this.password}`).toString('base64'); - this.setupInterceptors(); + this.httpClient = axios.create({ + baseURL: `${this.baseUrl}/ocs/v2.php`, + timeout: DEFAULT_TIMEOUT_MS, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'OCS-APIRequest': 'true', + Authorization: `Basic ${authHeader}`, + }, + }); - if (this.isConfigured) { + this.webdavClient = axios.create({ + baseURL: `${this.baseUrl}/remote.php/dav/files/${this.username}`, + timeout: DEFAULT_TIMEOUT_MS, + headers: { Authorization: `Basic ${authHeader}` }, + }); + + this.setupInterceptors(); this.logger.log(`Nextcloud connector initialized with base URL: ${this.baseUrl}`); } else { - this.logger.warn('Nextcloud connector not configured - missing credentials'); + this.logger.warn('Nextcloud connector not configured - missing serverUrl, username, or password'); + } + + this.initialized = true; + } + + async reload(): Promise { + this.initialized = false; + this.configuredState = false; + await this.ensureInitialized(); + } + + @OnEvent('credentials.changed') + async onCredentialsChanged(payload: { type: string }): Promise { + if (payload.type === 'NEXTCLOUD') { + this.logger.log('Nextcloud credentials changed, reloading'); + await this.reload(); } } - /** - * Validate required configuration - */ - private validateConfiguration(): void { - const missing: string[] = []; - - if (!this.baseUrl) { - missing.push('NEXTCLOUD_URL'); - } - if (!this.username) { - missing.push('NEXTCLOUD_USERNAME'); - } - if (!this.password) { - missing.push('NEXTCLOUD_PASSWORD'); - } - - this.isConfigured = missing.length === 0; - - if (!this.isConfigured) { - this.logger.warn(`Nextcloud configuration incomplete. Missing: ${missing.join(', ')}`); - } - } - - /** - * Setup axios interceptors for logging - */ private setupInterceptors(): void { - const setupForClient = (client: AxiosInstance, name: string) => { + const setup = (client: AxiosInstance, name: string) => { client.interceptors.request.use( (config) => { - this.logger.debug(`[${this.integrationName}:${name}] Request: ${config.method?.toUpperCase()} ${config.url}`); + this.logger.debug(`[${this.integrationName}:${name}] ${config.method?.toUpperCase()} ${config.url}`); return config; }, (error) => { @@ -163,7 +144,6 @@ export class NextcloudConnector { return Promise.reject(error); }, ); - client.interceptors.response.use( (response) => { this.logger.debug(`[${this.integrationName}:${name}] Response: ${response.status}`); @@ -171,39 +151,24 @@ export class NextcloudConnector { }, (error) => { if (error.response) { - this.logger.warn( - `[${this.integrationName}:${name}] Response error: ${error.response.status}`, - ); + this.logger.warn(`[${this.integrationName}:${name}] Error: ${error.response.status}`); } return Promise.reject(error); }, ); }; - setupForClient(this.httpClient, 'OCS'); - setupForClient(this.webdavClient, 'WebDAV'); + setup(this.httpClient, 'OCS'); + setup(this.webdavClient, 'WebDAV'); } - /** - * Ensure the connector is configured before making API calls - */ private ensureConfigured(): void { - if (!this.isConfigured) { - throw new IntegrationConfigError(this.integrationName, [ - 'NEXTCLOUD_URL', - 'NEXTCLOUD_USERNAME', - 'NEXTCLOUD_PASSWORD', - ]); + if (!this.configuredState) { + throw new IntegrationConfigError(this.integrationName, ['serverUrl', 'username', 'password']); } } - /** - * Execute request with retry logic - */ - private async executeWithRetry( - requestFn: () => Promise, - operation: string, - ): Promise { + private async executeWithRetry(requestFn: () => Promise, operation: string): Promise { let lastError: Error | undefined; let attempt = 0; @@ -213,17 +178,9 @@ export class NextcloudConnector { } catch (error) { lastError = error as Error; attempt++; - - const shouldRetry = this.shouldRetry(error as AxiosError, attempt); - if (!shouldRetry) { - break; - } - + if (!this.shouldRetry(error as AxiosError, attempt)) break; const delay = this.calculateDelay(attempt); - this.logger.warn( - `[${this.integrationName}] ${operation} failed (attempt ${attempt}/${this.retryConfig.maxRetries}), retrying in ${delay}ms`, - ); - + this.logger.warn(`[${this.integrationName}] ${operation} failed (attempt ${attempt}/${this.retryConfig.maxRetries}), retrying in ${delay}ms`); await this.sleep(delay); } } @@ -231,123 +188,57 @@ export class NextcloudConnector { throw this.mapError(lastError as AxiosError, operation); } - /** - * Determine if request should be retried - */ private shouldRetry(error: AxiosError, attempt: number): boolean { - if (attempt > this.retryConfig.maxRetries) { - return false; - } - - if (!error.response) { - return true; - } - + if (attempt > this.retryConfig.maxRetries) return false; + if (!error.response) return true; const status = error.response.status; return status >= 500 || status === 429; } - /** - * Calculate delay with exponential backoff and jitter - */ private calculateDelay(attempt: number): number { - const delay = Math.min( - this.retryConfig.baseDelayMs * Math.pow(2, attempt - 1), - this.retryConfig.maxDelayMs, - ); - const jitter = delay * this.retryConfig.jitterFactor * Math.random(); - return Math.floor(delay + jitter); + const delay = Math.min(this.retryConfig.baseDelayMs * Math.pow(2, attempt - 1), this.retryConfig.maxDelayMs); + return Math.floor(delay + delay * this.retryConfig.jitterFactor * Math.random()); } - /** - * Map axios errors to integration errors - */ private mapError(error: AxiosError, operation: string): Error { if (!error.response) { if (error.code === 'ECONNABORTED' || error.message.includes('timeout')) { - return new IntegrationConnectionError( - this.integrationName, - `Request timeout during ${operation}`, - error, - ); + return new IntegrationConnectionError(this.integrationName, `Request timeout during ${operation}`, error); } - return new IntegrationConnectionError( - this.integrationName, - `Connection failed during ${operation}: ${error.message}`, - error, - ); + return new IntegrationConnectionError(this.integrationName, `Connection failed during ${operation}: ${error.message}`, error); } - const status = error.response.status; const responseData = error.response.data as Record; const message = (responseData?.message as string) || error.message; - switch (status) { - case 401: - return new IntegrationAuthError( - this.integrationName, - `Authentication failed: Invalid credentials`, - error, - ); - case 403: - return new IntegrationAuthError( - this.integrationName, - `Access forbidden: ${message}`, - error, - ); - case 429: + case 401: return new IntegrationAuthError(this.integrationName, `Authentication failed: Invalid credentials`, error); + case 403: return new IntegrationAuthError(this.integrationName, `Access forbidden: ${message}`, error); + case 429: { const retryAfter = parseInt(error.response.headers['retry-after'] || '60', 10); - return new IntegrationRateLimitError( - this.integrationName, - `Rate limit exceeded`, - retryAfter, - error, - ); - default: - return new IntegrationApiError( - this.integrationName, - `${operation} failed: ${message}`, - status, - undefined, - error, - ); + return new IntegrationRateLimitError(this.integrationName, `Rate limit exceeded`, retryAfter, error); + } + default: return new IntegrationApiError(this.integrationName, `${operation} failed: ${message}`, status, undefined, error); } } - /** - * Sleep helper - */ private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } - /** - * Parse WebDAV PROPFIND response - */ private parseWebDAVResponse(xmlData: string, basePath: string): NextcloudFile[] { - // Simple XML parsing for WebDAV responses - // In production, consider using a proper XML parser like fast-xml-parser const files: NextcloudFile[] = []; const responseRegex = /([\s\S]*?)<\/d:response>/g; let match; while ((match = responseRegex.exec(xmlData)) !== null) { const response = match[1]; - const hrefMatch = /(.*?)<\/d:href>/.exec(response); const href = hrefMatch ? decodeURIComponent(hrefMatch[1]) : ''; - - // Extract path relative to user files const pathPrefix = `/remote.php/dav/files/${this.username}`; let path = href.replace(pathPrefix, '') || '/'; - if (path !== '/' && path.endsWith('/')) { - path = path.slice(0, -1); - } - - // Skip the root if listing root - if (path === basePath && basePath !== '/') { - continue; - } + if (path !== '/' && path.endsWith('/')) path = path.slice(0, -1); + if (path === basePath && basePath !== '/') continue; + if (path === basePath) continue; const isDirectory = response.includes(']*>([^<]*)<\/${tag}>`, 'i'); const match = regex.exec(xml); return match ? match[1].trim() : null; } + private buildPropfindBody(): string { + return [ + '', + '', + '', + '', + '', + '', + '', + '', + ].join(''); + } + // ============ Health & Connection ============ - /** - * Check connector health - */ async checkHealth(): Promise { - if (!this.isConfigured) { - return { - status: 'not_configured', - lastCheck: new Date(), - error: 'Nextcloud connector is not configured', - }; + await this.ensureInitialized(); + if (!this.configuredState) { + return { status: 'not_configured', lastCheck: new Date(), error: 'Nextcloud connector is not configured' }; } - const startTime = Date.now(); try { await this.httpClient.get('/cloud/user?format=json'); - return { - status: 'connected', - lastCheck: new Date(), - latency: Date.now() - startTime, - }; + return { status: 'connected', lastCheck: new Date(), latency: Date.now() - startTime }; } catch (error) { - return { - status: 'error', - lastCheck: new Date(), - latency: Date.now() - startTime, - error: (error as Error).message, - }; + return { status: 'error', lastCheck: new Date(), latency: Date.now() - startTime, error: (error as Error).message }; } } - /** - * Test connection to Nextcloud - */ async testConnection(): Promise<{ success: boolean; message: string; latency?: number }> { + await this.ensureInitialized(); this.ensureConfigured(); - const startTime = Date.now(); try { const response = await this.httpClient.get>('/cloud/user?format=json'); const latency = Date.now() - startTime; - const user = response.data.ocs.data; - - return { - success: true, - message: `Successfully connected as ${user.displayName || user.id}`, - latency, - }; + return { success: true, message: `Successfully connected as ${user.displayName || user.id}`, latency }; } catch (error) { throw this.mapError(error as AxiosError, 'testConnection'); } } - /** - * Check if connector is configured - */ getIsConfigured(): boolean { - return this.isConfigured; + return this.configuredState; } // ============ Files API (WebDAV) ============ - /** - * List files in a directory - */ async listFiles(params: ListFilesDto = {}): Promise { + await this.ensureInitialized(); this.ensureConfigured(); - const path = params.path || '/'; return this.executeWithRetry(async () => { - const propfindBody = ` - - - - - - - - - - - - - - `; - const response = await this.webdavClient.request({ method: 'PROPFIND', url: path, - headers: { - 'Depth': (params.depth ?? 1).toString(), - 'Content-Type': 'application/xml', - }, - data: propfindBody, + headers: { Depth: (params.depth ?? 1).toString(), 'Content-Type': 'application/xml' }, + data: this.buildPropfindBody(), }); - let files = this.parseWebDAVResponse(response.data, path); - - // Apply filters - if (params.filesOnly) { - files = files.filter((f) => f.type === FileType.FILE); - } - if (params.dirsOnly) { - files = files.filter((f) => f.type === FileType.DIRECTORY); - } - + if (params.filesOnly) files = files.filter((f) => f.type === FileType.FILE); + if (params.dirsOnly) files = files.filter((f) => f.type === FileType.DIRECTORY); return files; }, 'listFiles'); } - /** - * Get file info - */ async getFileInfo(path: string): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { - const propfindBody = ` - - - - - - - - - - - - - - `; - const response = await this.webdavClient.request({ method: 'PROPFIND', url: path, - headers: { - 'Depth': '0', - 'Content-Type': 'application/xml', - }, - data: propfindBody, + headers: { Depth: '0', 'Content-Type': 'application/xml' }, + data: this.buildPropfindBody(), }); - const files = this.parseWebDAVResponse(response.data, path); if (files.length === 0) { - // The requested file itself is returned when depth is 0 const allFiles = this.parseWebDAVResponse(response.data, ''); - if (allFiles.length > 0) { - return allFiles[0]; - } + if (allFiles.length > 0) return allFiles[0]; throw new IntegrationApiError(this.integrationName, `File not found: ${path}`, 404); } return files[0]; }, 'getFileInfo'); } - /** - * Upload a file - */ - async uploadFile( - path: string, - content: Buffer | string, - contentType?: string, - ): Promise { + async uploadFile(path: string, content: Buffer | string, contentType?: string): Promise { + await this.ensureInitialized(); this.ensureConfigured(); - return this.executeWithRetry(async () => { await this.webdavClient.put(path, content, { - headers: { - 'Content-Type': contentType || 'application/octet-stream', - }, + headers: { 'Content-Type': contentType || 'application/octet-stream' }, }); - - // Return file info after upload return this.getFileInfo(path); }, 'uploadFile'); } - /** - * Download a file - */ async downloadFile(path: string): Promise { + await this.ensureInitialized(); this.ensureConfigured(); - return this.executeWithRetry(async () => { - const response = await this.webdavClient.get(path, { - responseType: 'arraybuffer', - }); + const response = await this.webdavClient.get(path, { responseType: 'arraybuffer' }); return Buffer.from(response.data); }, 'downloadFile'); } - /** - * Delete a file or folder - */ async deleteFile(path: string): Promise { + await this.ensureInitialized(); this.ensureConfigured(); - return this.executeWithRetry(async () => { await this.webdavClient.delete(path); }, 'deleteFile'); } - /** - * Create a folder - */ async createFolder(path: string): Promise { + await this.ensureInitialized(); this.ensureConfigured(); - return this.executeWithRetry(async () => { - await this.webdavClient.request({ - method: 'MKCOL', - url: path, - }); - + await this.webdavClient.request({ method: 'MKCOL', url: path }); return this.getFileInfo(path); }, 'createFolder'); } - /** - * Move a file or folder - */ async moveFile(source: string, destination: string, overwrite: boolean = false): Promise { + await this.ensureInitialized(); this.ensureConfigured(); - return this.executeWithRetry(async () => { await this.webdavClient.request({ method: 'MOVE', url: source, headers: { - 'Destination': `${this.baseUrl}/remote.php/dav/files/${this.username}${destination}`, - 'Overwrite': overwrite ? 'T' : 'F', + Destination: `${this.baseUrl}/remote.php/dav/files/${this.username}${destination}`, + Overwrite: overwrite ? 'T' : 'F', }, }); }, 'moveFile'); } - /** - * Copy a file or folder - */ async copyFile(source: string, destination: string, overwrite: boolean = false): Promise { + await this.ensureInitialized(); this.ensureConfigured(); - return this.executeWithRetry(async () => { await this.webdavClient.request({ method: 'COPY', url: source, headers: { - 'Destination': `${this.baseUrl}/remote.php/dav/files/${this.username}${destination}`, - 'Overwrite': overwrite ? 'T' : 'F', + Destination: `${this.baseUrl}/remote.php/dav/files/${this.username}${destination}`, + Overwrite: overwrite ? 'T' : 'F', }, }); }, 'copyFile'); @@ -652,74 +432,44 @@ export class NextcloudConnector { // ============ Share API ============ - /** - * Get shares for a path - */ async getShares(path?: string): Promise { + await this.ensureInitialized(); this.ensureConfigured(); - return this.executeWithRetry(async () => { const params: Record = { format: 'json' }; - if (path) { - params.path = path; - } - + if (path) params.path = path; const response = await this.httpClient.get>( '/apps/files_sharing/api/v1/shares', { params }, ); - return response.data.ocs.data; }, 'getShares'); } - /** - * Create a share - */ async createShare(data: CreateShareDto): Promise { + await this.ensureInitialized(); this.ensureConfigured(); - return this.executeWithRetry(async () => { const formData = new URLSearchParams(); formData.append('path', data.path); formData.append('shareType', data.shareType.toString()); - - if (data.shareWith) { - formData.append('shareWith', data.shareWith); - } - if (data.permissions !== undefined) { - formData.append('permissions', data.permissions.toString()); - } - if (data.password) { - formData.append('password', data.password); - } - if (data.expireDate) { - formData.append('expireDate', data.expireDate); - } - if (data.note) { - formData.append('note', data.note); - } - + if (data.shareWith) formData.append('shareWith', data.shareWith); + if (data.permissions !== undefined) formData.append('permissions', data.permissions.toString()); + if (data.password) formData.append('password', data.password); + if (data.expireDate) formData.append('expireDate', data.expireDate); + if (data.note) formData.append('note', data.note); const response = await this.httpClient.post>( '/apps/files_sharing/api/v1/shares?format=json', formData.toString(), - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }, + { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }, ); - return response.data.ocs.data; }, 'createShare'); } - /** - * Delete a share - */ async deleteShare(shareId: number): Promise { + await this.ensureInitialized(); this.ensureConfigured(); - return this.executeWithRetry(async () => { await this.httpClient.delete(`/apps/files_sharing/api/v1/shares/${shareId}?format=json`); }, 'deleteShare'); @@ -727,79 +477,57 @@ export class NextcloudConnector { // ============ User API ============ - /** - * Get current user info - */ async getCurrentUser(): Promise { + await this.ensureInitialized(); this.ensureConfigured(); - return this.executeWithRetry(async () => { const response = await this.httpClient.get>('/cloud/user?format=json'); return response.data.ocs.data; }, 'getCurrentUser'); } - /** - * Get user quota - */ async getUserQuota(): Promise<{ used: number; total: number; free: number; relative: number }> { + await this.ensureInitialized(); this.ensureConfigured(); - const user = await this.getCurrentUser(); return user.quota; } // ============ Calendar API (CalDAV) ============ - /** - * List calendars - * Note: This is a simplified implementation. Full CalDAV support would require - * a dedicated CalDAV library. - */ async listCalendars(): Promise { + await this.ensureInitialized(); this.ensureConfigured(); return this.executeWithRetry(async () => { + const authB64 = Buffer.from(`${this.username}:${this.password}`).toString('base64'); const calDavClient = axios.create({ baseURL: `${this.baseUrl}/remote.php/dav/calendars/${this.username}`, timeout: DEFAULT_TIMEOUT_MS, - headers: { - 'Authorization': `Basic ${Buffer.from(`${this.username}:${this.password}`).toString('base64')}`, - }, + headers: { Authorization: `Basic ${authB64}` }, }); - const propfindBody = ` - - - - - - - - `; + const propfindBodyParts = [ + '', + '', + '', + '', + ]; const response = await calDavClient.request({ method: 'PROPFIND', url: '/', - headers: { - 'Depth': '1', - 'Content-Type': 'application/xml', - }, - data: propfindBody, + headers: { Depth: '1', 'Content-Type': 'application/xml' }, + data: propfindBodyParts.join(''), }); - // Parse calendars from response const calendars: NextcloudCalendar[] = []; const responseRegex = /([\s\S]*?)<\/d:response>/g; let match; while ((match = responseRegex.exec(response.data)) !== null) { const resp = match[1]; - - // Only include calendar collections - if (!resp.includes('(.*?)<\/d:href>/.exec(resp); const href = hrefMatch ? decodeURIComponent(hrefMatch[1]) : ''; @@ -822,77 +550,57 @@ export class NextcloudConnector { }, 'listCalendars'); } - /** - * List calendar events - * Note: Simplified implementation returning basic event data - */ async listEvents(params: ListEventsDto = {}): Promise { + await this.ensureInitialized(); this.ensureConfigured(); const calendarId = params.calendarId || 'personal'; return this.executeWithRetry(async () => { + const authB64 = Buffer.from(`${this.username}:${this.password}`).toString('base64'); const calDavClient = axios.create({ baseURL: `${this.baseUrl}/remote.php/dav/calendars/${this.username}/${calendarId}`, timeout: DEFAULT_TIMEOUT_MS, - headers: { - 'Authorization': `Basic ${Buffer.from(`${this.username}:${this.password}`).toString('base64')}`, - }, + headers: { Authorization: `Basic ${authB64}` }, }); - // Build time-range filter if dates provided let timeRangeFilter = ''; if (params.start || params.end) { - const start = params.start || '19700101T000000Z'; - const end = params.end || '20991231T235959Z'; - timeRangeFilter = ``; + const start = (params.start || '19700101T000000Z').replace(/[-:]/g, ''); + const end = (params.end || '20991231T235959Z').replace(/[-:]/g, ''); + timeRangeFilter = ``; } - const reportBody = ` - - - - - - - - - ${timeRangeFilter} - - - - `; + const reportBodyParts = [ + '', + '', + '', + '', + `${timeRangeFilter}`, + '', + '', + ]; const response = await calDavClient.request({ method: 'REPORT', url: '/', - headers: { - 'Depth': '1', - 'Content-Type': 'application/xml', - }, - data: reportBody, + headers: { Depth: '1', 'Content-Type': 'application/xml' }, + data: reportBodyParts.join(''), }); - // Parse events from iCalendar data const events: NextcloudCalendarEvent[] = []; const calDataRegex = /]*>([\s\S]*?)<\/cal:calendar-data>/g; let match; while ((match = calDataRegex.exec(response.data)) !== null) { - const icalData = match[1]; - const event = this.parseICalEvent(icalData, calendarId); - if (event) { - events.push(event); - } + const event = this.parseICalEvent(match[1], calendarId); + if (event) events.push(event); } return events; }, 'listEvents'); } - /** - * Parse iCalendar event data - */ private parseICalEvent(icalData: string, calendarId: string): NextcloudCalendarEvent | null { const uidMatch = /UID:(.+)/i.exec(icalData); const summaryMatch = /SUMMARY:(.+)/i.exec(icalData); @@ -903,11 +611,7 @@ export class NextcloudConnector { const createdMatch = /CREATED:(.+)/i.exec(icalData); const lastModMatch = /LAST-MODIFIED:(.+)/i.exec(icalData); - if (!uidMatch || !summaryMatch || !dtStartMatch) { - return null; - } - - const isAllDay = icalData.includes('VALUE=DATE'); + if (!uidMatch || !summaryMatch || !dtStartMatch) return null; return { id: uidMatch[1].trim(), @@ -917,7 +621,7 @@ export class NextcloudConnector { location: locationMatch ? locationMatch[1].trim() : null, start: dtStartMatch[1].trim(), end: dtEndMatch ? dtEndMatch[1].trim() : dtStartMatch[1].trim(), - allDay: isAllDay, + allDay: icalData.includes('VALUE=DATE'), status: 'CONFIRMED', rrule: null, attendees: [], diff --git a/apps/api/src/modules/integrations/connectors/nextcloud/nextcloud.module.ts b/apps/api/src/modules/integrations/connectors/nextcloud/nextcloud.module.ts index 85e675a..dc3a47f 100644 --- a/apps/api/src/modules/integrations/connectors/nextcloud/nextcloud.module.ts +++ b/apps/api/src/modules/integrations/connectors/nextcloud/nextcloud.module.ts @@ -1,27 +1,17 @@ import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; import { NextcloudConnector } from './nextcloud.connector'; import { NextcloudService } from './nextcloud.service'; import { NextcloudController } from './nextcloud.controller'; +import { CredentialsModule } from '../../credentials/credentials.module'; /** * Nextcloud Integration Module * * Provides integration with Nextcloud cloud storage. - * - * Required environment variables: - * - NEXTCLOUD_URL: Base URL of Nextcloud instance (e.g., https://cloud.example.com) - * - NEXTCLOUD_USERNAME: Username for authentication - * - NEXTCLOUD_PASSWORD: Password or App Password for authentication - * - * Features: - * - WebDAV file operations (list, upload, download, delete, move, copy) - * - OCS Share API (create public links, share with users) - * - User info and quota - * - Calendar events (CalDAV) + * Credentials are stored in the DB and loaded via CredentialsService. */ @Module({ - imports: [ConfigModule], + imports: [CredentialsModule], controllers: [NextcloudController], providers: [NextcloudConnector, NextcloudService], exports: [NextcloudService, NextcloudConnector], diff --git a/apps/api/src/modules/integrations/connectors/plentyone/plentyone.connector.ts b/apps/api/src/modules/integrations/connectors/plentyone/plentyone.connector.ts index c7a58b3..2a8879e 100644 --- a/apps/api/src/modules/integrations/connectors/plentyone/plentyone.connector.ts +++ b/apps/api/src/modules/integrations/connectors/plentyone/plentyone.connector.ts @@ -1,15 +1,15 @@ import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { OnEvent } from '@nestjs/event-emitter'; import axios from 'axios'; import { BaseConnector, - BaseConnectorConfig, ConnectionTestResult, } from '../base-connector'; import { IntegrationAuthError, IntegrationConfigError, } from '../../errors'; +import { CredentialsService } from '../../credentials/credentials.service'; import { PlentyoneAuthConfig, PlentyoneTokenInfo, @@ -29,41 +29,88 @@ import { * * Provides integration with PlentyONE e-commerce platform. * Handles OAuth2 authentication, token management, and API calls. + * Credentials are loaded lazily from the DB via CredentialsService. * * @see https://developers.plentymarkets.com/ */ @Injectable() export class PlentyoneConnector extends BaseConnector { protected readonly name = 'PlentyONE'; - private readonly authConfig: PlentyoneAuthConfig; + private authConfig: PlentyoneAuthConfig = { baseUrl: '', clientId: '', clientSecret: '' }; private tokenInfo: PlentyoneTokenInfo | null = null; private tokenRefreshPromise: Promise | null = null; + private initialized = false; + private configuredState = false; - constructor(private readonly configService: ConfigService) { - const baseUrl = configService.get('PLENTYONE_BASE_URL') || ''; - - super({ - baseUrl: baseUrl ? `${baseUrl}/rest` : '', - timeout: 60000, // PlentyONE can be slow - maxRetries: 3, - }); - - this.authConfig = { - baseUrl, - clientId: configService.get('PLENTYONE_CLIENT_ID') || '', - clientSecret: configService.get('PLENTYONE_CLIENT_SECRET') || '', - }; + constructor(private readonly credentialsService: CredentialsService) { + super(); } /** - * Check if the connector is properly configured + * Load credentials from DB and configure the connector. + * Idempotent - returns immediately if already initialized. */ - isConfigured(): boolean { - return !!( + async ensureInitialized(): Promise { + if (this.initialized) return; + + const result = await this.credentialsService.findActiveByType('PLENTYONE'); + + if (!result) { + this.configuredState = false; + this.initialized = true; + return; + } + + const { baseUrl, clientId, clientSecret } = result.credentials; + + this.authConfig = { + baseUrl: baseUrl || '', + clientId: clientId || '', + clientSecret: clientSecret || '', + }; + + if (this.authConfig.baseUrl) { + this.reconfigure({ + baseUrl: `${this.authConfig.baseUrl}/rest`, + timeout: 60000, + maxRetries: 3, + }); + } + + this.configuredState = !!( this.authConfig.baseUrl && this.authConfig.clientId && this.authConfig.clientSecret ); + this.initialized = true; + } + + /** + * Reload credentials from DB (called when credentials change) + */ + async reload(): Promise { + this.initialized = false; + this.tokenInfo = null; + this.tokenRefreshPromise = null; + await this.ensureInitialized(); + } + + /** + * Handle credential change events + */ + @OnEvent('credentials.changed') + async onCredentialsChanged(payload: { type: string }): Promise { + if (payload.type === 'PLENTYONE') { + this.logger.log('PlentyONE credentials changed, reloading'); + await this.reload(); + } + } + + /** + * Check if the connector is properly configured (last known state) + */ + isConfigured(): boolean { + return this.configuredState; } /** @@ -71,9 +118,9 @@ export class PlentyoneConnector extends BaseConnector { */ getMissingConfig(): string[] { const missing: string[] = []; - if (!this.authConfig.baseUrl) missing.push('PLENTYONE_BASE_URL'); - if (!this.authConfig.clientId) missing.push('PLENTYONE_CLIENT_ID'); - if (!this.authConfig.clientSecret) missing.push('PLENTYONE_CLIENT_SECRET'); + if (!this.authConfig.baseUrl) missing.push('baseUrl'); + if (!this.authConfig.clientId) missing.push('clientId'); + if (!this.authConfig.clientSecret) missing.push('clientSecret'); return missing; } @@ -81,6 +128,8 @@ export class PlentyoneConnector extends BaseConnector { * Test the connection to PlentyONE */ async testConnection(): Promise { + await this.ensureInitialized(); + if (!this.isConfigured()) { return { success: false, @@ -91,10 +140,7 @@ export class PlentyoneConnector extends BaseConnector { const startTime = Date.now(); try { - // Try to authenticate await this.ensureAuthenticated(); - - // Make a simple API call to verify the token works await this.get<{ version: string }>('/'); return { @@ -119,6 +165,8 @@ export class PlentyoneConnector extends BaseConnector { * Get authentication headers for requests */ async getAuthHeaders(): Promise> { + await this.ensureInitialized(); + if (!this.isConfigured()) { throw new IntegrationConfigError(this.name, this.getMissingConfig()); } @@ -134,18 +182,15 @@ export class PlentyoneConnector extends BaseConnector { * Ensure we have a valid access token */ private async ensureAuthenticated(): Promise { - // If token is valid, return immediately if (this.isTokenValid()) { return; } - // If a refresh is already in progress, wait for it if (this.tokenRefreshPromise) { await this.tokenRefreshPromise; return; } - // Start token refresh this.tokenRefreshPromise = this.refreshAccessToken(); try { @@ -161,7 +206,6 @@ export class PlentyoneConnector extends BaseConnector { private isTokenValid(): boolean { if (!this.tokenInfo) return false; - // Consider token invalid if it expires in less than 5 minutes const bufferMs = 5 * 60 * 1000; return this.tokenInfo.expiresAt.getTime() > Date.now() + bufferMs; } @@ -217,6 +261,7 @@ export class PlentyoneConnector extends BaseConnector { async getOrders( query?: PlentyoneOrdersQuery, ): Promise> { + await this.ensureInitialized(); const params = this.buildQueryParams(query); return this.get>( '/orders', @@ -231,6 +276,7 @@ export class PlentyoneConnector extends BaseConnector { orderId: number, withRelations?: string[], ): Promise { + await this.ensureInitialized(); const params: Record = {}; if (withRelations?.length) { params.with = withRelations.join(','); @@ -285,6 +331,7 @@ export class PlentyoneConnector extends BaseConnector { async getStock( query?: PlentyoneStockQuery, ): Promise> { + await this.ensureInitialized(); const params = this.buildQueryParams(query); return this.get>( '/stockmanagement/stock', @@ -336,7 +383,6 @@ export class PlentyoneConnector extends BaseConnector { hasMore = !response.isLastPage; page++; - // Safety limit if (page > 100) break; } @@ -351,21 +397,18 @@ export class PlentyoneConnector extends BaseConnector { * Get order statistics */ async getOrderStats(query: PlentyoneStatsQuery): Promise { - // Get orders within the date range const orders = await this.getAllOrdersInRange( new Date(query.dateFrom), new Date(query.dateTo), query.statusId, ); - // Calculate statistics let totalRevenue = 0; let totalRevenueNet = 0; const ordersByStatus: Record = {}; const ordersByReferrer: Record = {}; for (const order of orders) { - // Sum up amounts if (order.amounts?.length) { const primaryAmount = order.amounts.find((a) => a.isSystemCurrency); if (primaryAmount) { @@ -374,10 +417,7 @@ export class PlentyoneConnector extends BaseConnector { } } - // Count by status ordersByStatus[order.statusId] = (ordersByStatus[order.statusId] || 0) + 1; - - // Count by referrer ordersByReferrer[order.referrerId] = (ordersByReferrer[order.referrerId] || 0) + 1; } @@ -387,7 +427,7 @@ export class PlentyoneConnector extends BaseConnector { totalRevenue, totalRevenueNet, averageOrderValue: orders.length > 0 ? totalRevenue / orders.length : 0, - currency: 'EUR', // Default currency + currency: 'EUR', ordersByStatus, ordersByReferrer, }; @@ -405,7 +445,6 @@ export class PlentyoneConnector extends BaseConnector { query.statusId, ); - // Group by date const statsByDate = new Map(); for (const order of orders) { @@ -477,7 +516,6 @@ export class PlentyoneConnector extends BaseConnector { hasMore = !response.isLastPage; page++; - // Safety limit to prevent infinite loops if (page > 1000) { this.logger.warn('Reached maximum page limit for order retrieval'); break; diff --git a/apps/api/src/modules/integrations/connectors/plentyone/plentyone.module.ts b/apps/api/src/modules/integrations/connectors/plentyone/plentyone.module.ts index 867e62c..36c5d73 100644 --- a/apps/api/src/modules/integrations/connectors/plentyone/plentyone.module.ts +++ b/apps/api/src/modules/integrations/connectors/plentyone/plentyone.module.ts @@ -2,8 +2,10 @@ import { Module } from '@nestjs/common'; import { PlentyoneConnector } from './plentyone.connector'; import { PlentyoneService } from './plentyone.service'; import { PlentyoneController } from './plentyone.controller'; +import { CredentialsModule } from '../../credentials/credentials.module'; @Module({ + imports: [CredentialsModule], controllers: [PlentyoneController], providers: [PlentyoneConnector, PlentyoneService], exports: [PlentyoneService, PlentyoneConnector], diff --git a/apps/api/src/modules/integrations/connectors/todoist/todoist.connector.ts b/apps/api/src/modules/integrations/connectors/todoist/todoist.connector.ts index 9e285c3..cb63b8e 100644 --- a/apps/api/src/modules/integrations/connectors/todoist/todoist.connector.ts +++ b/apps/api/src/modules/integrations/connectors/todoist/todoist.connector.ts @@ -1,11 +1,11 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; import { BaseConnector, - BaseConnectorConfig, ConnectionTestResult, } from '../base-connector'; import { IntegrationConfigError } from '../../errors'; +import { CredentialsService } from '../../credentials/credentials.service'; import { TodoistAuthConfig, TodoistTask, @@ -28,31 +28,75 @@ import { * * Provides integration with Todoist task management platform. * Uses Bearer Token authentication. + * Credentials are loaded lazily from the DB via CredentialsService. * * @see https://developer.todoist.com/rest/v2/ */ @Injectable() export class TodoistConnector extends BaseConnector { protected readonly name = 'Todoist'; - private readonly authConfig: TodoistAuthConfig; + private authConfig: TodoistAuthConfig = { apiToken: '' }; + private initialized = false; + private configuredState = false; - constructor(private readonly configService: ConfigService) { + constructor(private readonly credentialsService: CredentialsService) { super({ baseUrl: 'https://api.todoist.com/rest/v2', timeout: 30000, maxRetries: 3, }); - - this.authConfig = { - apiToken: configService.get('TODOIST_API_TOKEN') || '', - }; } /** - * Check if the connector is properly configured + * Load credentials from DB and configure the connector. + * Idempotent - returns immediately if already initialized. + * Note: Todoist uses a fixed base URL; only the API token changes. + */ + async ensureInitialized(): Promise { + if (this.initialized) return; + + const result = await this.credentialsService.findActiveByType('TODOIST'); + + if (!result) { + this.configuredState = false; + this.initialized = true; + return; + } + + const { apiToken } = result.credentials; + + this.authConfig = { + apiToken: apiToken || '', + }; + + this.configuredState = !!this.authConfig.apiToken; + this.initialized = true; + } + + /** + * Reload credentials from DB (called when credentials change) + */ + async reload(): Promise { + this.initialized = false; + await this.ensureInitialized(); + } + + /** + * Handle credential change events + */ + @OnEvent('credentials.changed') + async onCredentialsChanged(payload: { type: string }): Promise { + if (payload.type === 'TODOIST') { + this.logger.log('Todoist credentials changed, reloading'); + await this.reload(); + } + } + + /** + * Check if the connector is properly configured (last known state) */ isConfigured(): boolean { - return !!this.authConfig.apiToken; + return this.configuredState; } /** @@ -60,7 +104,7 @@ export class TodoistConnector extends BaseConnector { */ getMissingConfig(): string[] { const missing: string[] = []; - if (!this.authConfig.apiToken) missing.push('TODOIST_API_TOKEN'); + if (!this.authConfig.apiToken) missing.push('apiToken'); return missing; } @@ -68,6 +112,8 @@ export class TodoistConnector extends BaseConnector { * Test the connection to Todoist */ async testConnection(): Promise { + await this.ensureInitialized(); + if (!this.isConfigured()) { return { success: false, @@ -78,7 +124,6 @@ export class TodoistConnector extends BaseConnector { const startTime = Date.now(); try { - // Try to get projects (simple call to verify auth) await this.getProjects(); return { @@ -100,6 +145,8 @@ export class TodoistConnector extends BaseConnector { * Get authentication headers for requests (Bearer token) */ async getAuthHeaders(): Promise> { + await this.ensureInitialized(); + if (!this.isConfigured()) { throw new IntegrationConfigError(this.name, this.getMissingConfig()); } @@ -117,6 +164,7 @@ export class TodoistConnector extends BaseConnector { * Get active tasks */ async getTasks(request?: TodoistGetTasksRequest): Promise { + await this.ensureInitialized(); const params = this.buildTasksParams(request); return this.get('/tasks', { params }); } @@ -125,6 +173,7 @@ export class TodoistConnector extends BaseConnector { * Get a single task by ID */ async getTask(taskId: string): Promise { + await this.ensureInitialized(); return this.get(`/tasks/${taskId}`); } @@ -132,7 +181,7 @@ export class TodoistConnector extends BaseConnector { * Create a new task */ async createTask(request: TodoistCreateTaskRequest): Promise { - // Generate a unique request ID for idempotency + await this.ensureInitialized(); const requestId = this.generateRequestId(); return this.post('/tasks', request, { @@ -149,7 +198,7 @@ export class TodoistConnector extends BaseConnector { taskId: string, request: TodoistUpdateTaskRequest, ): Promise { - // Generate a unique request ID for idempotency + await this.ensureInitialized(); const requestId = this.generateRequestId(); return this.post(`/tasks/${taskId}`, request, { @@ -163,6 +212,7 @@ export class TodoistConnector extends BaseConnector { * Complete a task */ async completeTask(taskId: string): Promise { + await this.ensureInitialized(); await this.post(`/tasks/${taskId}/close`, null); } @@ -170,6 +220,7 @@ export class TodoistConnector extends BaseConnector { * Reopen a task */ async reopenTask(taskId: string): Promise { + await this.ensureInitialized(); await this.post(`/tasks/${taskId}/reopen`, null); } @@ -177,6 +228,7 @@ export class TodoistConnector extends BaseConnector { * Delete a task */ async deleteTask(taskId: string): Promise { + await this.ensureInitialized(); await this.delete(`/tasks/${taskId}`); } @@ -188,6 +240,7 @@ export class TodoistConnector extends BaseConnector { * Get all projects */ async getProjects(): Promise { + await this.ensureInitialized(); return this.get('/projects'); } @@ -195,6 +248,7 @@ export class TodoistConnector extends BaseConnector { * Get a single project by ID */ async getProject(projectId: string): Promise { + await this.ensureInitialized(); return this.get(`/projects/${projectId}`); } @@ -204,6 +258,7 @@ export class TodoistConnector extends BaseConnector { async createProject( request: TodoistCreateProjectRequest, ): Promise { + await this.ensureInitialized(); const requestId = this.generateRequestId(); return this.post('/projects', request, { @@ -220,6 +275,7 @@ export class TodoistConnector extends BaseConnector { projectId: string, request: TodoistUpdateProjectRequest, ): Promise { + await this.ensureInitialized(); const requestId = this.generateRequestId(); return this.post(`/projects/${projectId}`, request, { @@ -233,6 +289,7 @@ export class TodoistConnector extends BaseConnector { * Delete a project */ async deleteProject(projectId: string): Promise { + await this.ensureInitialized(); await this.delete(`/projects/${projectId}`); } @@ -244,6 +301,7 @@ export class TodoistConnector extends BaseConnector { * Get all sections (optionally filtered by project) */ async getSections(projectId?: string): Promise { + await this.ensureInitialized(); const params = projectId ? { project_id: projectId } : {}; return this.get('/sections', { params }); } @@ -252,6 +310,7 @@ export class TodoistConnector extends BaseConnector { * Get a single section by ID */ async getSection(sectionId: string): Promise { + await this.ensureInitialized(); return this.get(`/sections/${sectionId}`); } @@ -261,6 +320,7 @@ export class TodoistConnector extends BaseConnector { async createSection( request: TodoistCreateSectionRequest, ): Promise { + await this.ensureInitialized(); const requestId = this.generateRequestId(); return this.post('/sections', request, { @@ -274,6 +334,7 @@ export class TodoistConnector extends BaseConnector { * Delete a section */ async deleteSection(sectionId: string): Promise { + await this.ensureInitialized(); await this.delete(`/sections/${sectionId}`); } @@ -285,6 +346,7 @@ export class TodoistConnector extends BaseConnector { * Get all personal labels */ async getLabels(): Promise { + await this.ensureInitialized(); return this.get('/labels'); } @@ -292,6 +354,7 @@ export class TodoistConnector extends BaseConnector { * Get a single label by ID */ async getLabel(labelId: string): Promise { + await this.ensureInitialized(); return this.get(`/labels/${labelId}`); } @@ -299,6 +362,7 @@ export class TodoistConnector extends BaseConnector { * Create a new label */ async createLabel(request: TodoistCreateLabelRequest): Promise { + await this.ensureInitialized(); const requestId = this.generateRequestId(); return this.post('/labels', request, { @@ -312,6 +376,7 @@ export class TodoistConnector extends BaseConnector { * Delete a label */ async deleteLabel(labelId: string): Promise { + await this.ensureInitialized(); await this.delete(`/labels/${labelId}`); } @@ -326,6 +391,7 @@ export class TodoistConnector extends BaseConnector { taskId?: string, projectId?: string, ): Promise { + await this.ensureInitialized(); const params: Record = {}; if (taskId) params.task_id = taskId; if (projectId) params.project_id = projectId; @@ -339,6 +405,7 @@ export class TodoistConnector extends BaseConnector { async createComment( request: TodoistCreateCommentRequest, ): Promise { + await this.ensureInitialized(); const requestId = this.generateRequestId(); return this.post('/comments', request, { @@ -352,6 +419,7 @@ export class TodoistConnector extends BaseConnector { * Delete a comment */ async deleteComment(commentId: string): Promise { + await this.ensureInitialized(); await this.delete(`/comments/${commentId}`); } diff --git a/apps/api/src/modules/integrations/connectors/todoist/todoist.module.ts b/apps/api/src/modules/integrations/connectors/todoist/todoist.module.ts index 353a2b2..991ab9d 100644 --- a/apps/api/src/modules/integrations/connectors/todoist/todoist.module.ts +++ b/apps/api/src/modules/integrations/connectors/todoist/todoist.module.ts @@ -2,8 +2,10 @@ import { Module } from '@nestjs/common'; import { TodoistConnector } from './todoist.connector'; import { TodoistService } from './todoist.service'; import { TodoistController } from './todoist.controller'; +import { CredentialsModule } from '../../credentials/credentials.module'; @Module({ + imports: [CredentialsModule], controllers: [TodoistController], providers: [TodoistConnector, TodoistService], exports: [TodoistService, TodoistConnector], diff --git a/apps/api/src/modules/integrations/connectors/zulip/zulip.connector.ts b/apps/api/src/modules/integrations/connectors/zulip/zulip.connector.ts index f7b9b65..c2e40f5 100644 --- a/apps/api/src/modules/integrations/connectors/zulip/zulip.connector.ts +++ b/apps/api/src/modules/integrations/connectors/zulip/zulip.connector.ts @@ -1,11 +1,11 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; import { BaseConnector, - BaseConnectorConfig, ConnectionTestResult, } from '../base-connector'; import { IntegrationConfigError } from '../../errors'; +import { CredentialsService } from '../../credentials/credentials.service'; import { ZulipAuthConfig, ZulipMessage, @@ -29,39 +29,84 @@ import { * * Provides integration with ZULIP team chat platform. * Uses Basic Authentication with email and API key. + * Credentials are loaded lazily from the DB via CredentialsService. * * @see https://zulip.com/api/ */ @Injectable() export class ZulipConnector extends BaseConnector { protected readonly name = 'ZULIP'; - private readonly authConfig: ZulipAuthConfig; + private authConfig: ZulipAuthConfig = { baseUrl: '', email: '', apiKey: '' }; + private initialized = false; + private configuredState = false; - constructor(private readonly configService: ConfigService) { - const baseUrl = configService.get('ZULIP_BASE_URL') || ''; - - super({ - baseUrl: baseUrl ? `${baseUrl}/api/v1` : '', - timeout: 30000, - maxRetries: 3, - }); - - this.authConfig = { - baseUrl, - email: configService.get('ZULIP_EMAIL') || '', - apiKey: configService.get('ZULIP_API_KEY') || '', - }; + constructor(private readonly credentialsService: CredentialsService) { + super(); } /** - * Check if the connector is properly configured + * Load credentials from DB and configure the connector. + * Idempotent - returns immediately if already initialized. */ - isConfigured(): boolean { - return !!( + async ensureInitialized(): Promise { + if (this.initialized) return; + + const result = await this.credentialsService.findActiveByType('ZULIP'); + + if (!result) { + this.configuredState = false; + this.initialized = true; + return; + } + + const { baseUrl, email, apiKey } = result.credentials; + + this.authConfig = { + baseUrl: baseUrl || '', + email: email || '', + apiKey: apiKey || '', + }; + + if (this.authConfig.baseUrl) { + this.reconfigure({ + baseUrl: `${this.authConfig.baseUrl}/api/v1`, + timeout: 30000, + maxRetries: 3, + }); + } + + this.configuredState = !!( this.authConfig.baseUrl && this.authConfig.email && this.authConfig.apiKey ); + this.initialized = true; + } + + /** + * Reload credentials from DB (called when credentials change) + */ + async reload(): Promise { + this.initialized = false; + await this.ensureInitialized(); + } + + /** + * Handle credential change events + */ + @OnEvent('credentials.changed') + async onCredentialsChanged(payload: { type: string }): Promise { + if (payload.type === 'ZULIP') { + this.logger.log('ZULIP credentials changed, reloading'); + await this.reload(); + } + } + + /** + * Check if the connector is properly configured (last known state) + */ + isConfigured(): boolean { + return this.configuredState; } /** @@ -69,9 +114,9 @@ export class ZulipConnector extends BaseConnector { */ getMissingConfig(): string[] { const missing: string[] = []; - if (!this.authConfig.baseUrl) missing.push('ZULIP_BASE_URL'); - if (!this.authConfig.email) missing.push('ZULIP_EMAIL'); - if (!this.authConfig.apiKey) missing.push('ZULIP_API_KEY'); + if (!this.authConfig.baseUrl) missing.push('baseUrl'); + if (!this.authConfig.email) missing.push('email'); + if (!this.authConfig.apiKey) missing.push('apiKey'); return missing; } @@ -79,6 +124,8 @@ export class ZulipConnector extends BaseConnector { * Test the connection to ZULIP */ async testConnection(): Promise { + await this.ensureInitialized(); + if (!this.isConfigured()) { return { success: false, @@ -89,7 +136,6 @@ export class ZulipConnector extends BaseConnector { const startTime = Date.now(); try { - // Try to get server settings (doesn't require auth but verifies the server) const response = await this.get<{ result: string; zulip_version: string; @@ -100,7 +146,6 @@ export class ZulipConnector extends BaseConnector { throw new Error('Server returned non-success result'); } - // Verify credentials by fetching the authenticated user await this.get<{ result: string }>('/users/me'); return { @@ -127,6 +172,8 @@ export class ZulipConnector extends BaseConnector { * Get authentication headers for requests (Basic Auth) */ async getAuthHeaders(): Promise> { + await this.ensureInitialized(); + if (!this.isConfigured()) { throw new IntegrationConfigError(this.name, this.getMissingConfig()); } @@ -150,6 +197,7 @@ export class ZulipConnector extends BaseConnector { async getMessages( request: ZulipGetMessagesRequest = {}, ): Promise { + await this.ensureInitialized(); const params = this.buildMessagesParams(request); return this.get('/messages', { params }); } @@ -233,6 +281,7 @@ export class ZulipConnector extends BaseConnector { async sendMessage( request: ZulipSendMessageRequest, ): Promise { + await this.ensureInitialized(); const formData = new URLSearchParams(); formData.append('type', request.type); @@ -309,6 +358,7 @@ export class ZulipConnector extends BaseConnector { includeOwnerSubscribed?: boolean; } = {}, ): Promise { + await this.ensureInitialized(); const params: Record = {}; if (options.includePublic !== undefined) { @@ -338,6 +388,7 @@ export class ZulipConnector extends BaseConnector { * Get subscribed streams */ async getSubscriptions(): Promise { + await this.ensureInitialized(); const response = await this.get( '/users/me/subscriptions', ); @@ -350,6 +401,7 @@ export class ZulipConnector extends BaseConnector { async subscribe( request: ZulipSubscribeRequest, ): Promise { + await this.ensureInitialized(); const formData = new URLSearchParams(); formData.append('subscriptions', JSON.stringify(request.subscriptions)); @@ -387,6 +439,7 @@ export class ZulipConnector extends BaseConnector { * Unsubscribe from streams */ async unsubscribe(streamNames: string[]): Promise<{ result: string }> { + await this.ensureInitialized(); const formData = new URLSearchParams(); formData.append('subscriptions', JSON.stringify(streamNames)); @@ -408,6 +461,7 @@ export class ZulipConnector extends BaseConnector { includeCustomProfileFields?: boolean; } = {}, ): Promise { + await this.ensureInitialized(); const params: Record = {}; if (options.clientGravatar !== undefined) { @@ -428,14 +482,15 @@ export class ZulipConnector extends BaseConnector { * Get current user profile */ async getCurrentUser(): Promise { - const response = await this.get('/users/me'); - return response; + await this.ensureInitialized(); + return this.get('/users/me'); } /** * Get a specific user by ID */ async getUser(userId: number): Promise { + await this.ensureInitialized(); const response = await this.get<{ user: ZulipUser }>(`/users/${userId}`); return response.user; } @@ -444,6 +499,7 @@ export class ZulipConnector extends BaseConnector { * Get a specific user by email */ async getUserByEmail(email: string): Promise { + await this.ensureInitialized(); const response = await this.get<{ user: ZulipUser }>( `/users/${encodeURIComponent(email)}`, ); diff --git a/apps/api/src/modules/integrations/connectors/zulip/zulip.module.ts b/apps/api/src/modules/integrations/connectors/zulip/zulip.module.ts index 2d30001..144fab8 100644 --- a/apps/api/src/modules/integrations/connectors/zulip/zulip.module.ts +++ b/apps/api/src/modules/integrations/connectors/zulip/zulip.module.ts @@ -2,8 +2,10 @@ import { Module } from '@nestjs/common'; import { ZulipConnector } from './zulip.connector'; import { ZulipService } from './zulip.service'; import { ZulipController } from './zulip.controller'; +import { CredentialsModule } from '../../credentials/credentials.module'; @Module({ + imports: [CredentialsModule], controllers: [ZulipController], providers: [ZulipConnector, ZulipService], exports: [ZulipService, ZulipConnector], diff --git a/apps/api/src/modules/integrations/credentials/credentials.service.ts b/apps/api/src/modules/integrations/credentials/credentials.service.ts index 50548c5..611d497 100644 --- a/apps/api/src/modules/integrations/credentials/credentials.service.ts +++ b/apps/api/src/modules/integrations/credentials/credentials.service.ts @@ -5,6 +5,7 @@ import { BadRequestException, Logger, } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { PrismaService } from '../../../prisma/prisma.service'; import { EncryptionService } from '../../../common/services/encryption.service'; import { CreateCredentialDto } from './dto/create-credential.dto'; @@ -61,6 +62,7 @@ export class CredentialsService { constructor( private readonly prisma: PrismaService, private readonly encryptionService: EncryptionService, + private readonly eventEmitter: EventEmitter2, ) {} /** @@ -114,6 +116,8 @@ export class CredentialsService { `Created credential ${credential.id} (${createDto.type}:${createDto.name}) by user ${userId}`, ); + this.eventEmitter.emit('credentials.changed', { type: credential.type }); + return this.mapToListItem(credential); } @@ -290,6 +294,8 @@ export class CredentialsService { this.logger.log(`Updated credential ${id} by user ${userId}`); + this.eventEmitter.emit('credentials.changed', { type: updated.type }); + return this.mapToListItem(updated); } @@ -322,6 +328,8 @@ export class CredentialsService { this.logger.log(`Deactivated credential ${id} by user ${userId}`); + this.eventEmitter.emit('credentials.changed', { type: updated.type }); + return this.mapToListItem(updated); } @@ -403,6 +411,29 @@ export class CredentialsService { }; } + /** + * Find the first active credential for a given integration type. + * Returns decrypted credentials. Used internally by connectors. + */ + async findActiveByType( + type: string, + ): Promise<{ id: string; credentials: Record } | null> { + const credential = await this.prisma.integrationCredential.findFirst({ + where: { type: type as IntegrationType, isActive: true }, + orderBy: { updatedAt: 'desc' }, + }); + + if (!credential) return null; + + try { + const decrypted = this.encryptionService.decryptObject(credential.credentials); + return { id: credential.id, credentials: decrypted as Record }; + } catch (error) { + this.logger.error(`Failed to decrypt credentials for type ${type}: ${error}`); + return null; + } + } + /** * Updates the sync status of a credential */ diff --git a/apps/api/src/modules/integrations/integrations.service.ts b/apps/api/src/modules/integrations/integrations.service.ts index c2da982..37a5b9d 100644 --- a/apps/api/src/modules/integrations/integrations.service.ts +++ b/apps/api/src/modules/integrations/integrations.service.ts @@ -160,6 +160,9 @@ export class IntegrationsService { try { const { service, connector } = this.getServiceAndConnector(type); + // Ensure credentials are loaded from DB before checking configured state + await connector.ensureInitialized(); + status.configured = service.isConfigured(); if (!status.configured) { @@ -199,7 +202,10 @@ export class IntegrationsService { this.logger.log(`Checking health for ${meta.name}`); try { - const { service } = this.getServiceAndConnector(type); + const { service, connector } = this.getServiceAndConnector(type); + + // Ensure credentials are loaded from DB before checking configured state + await connector.ensureInitialized(); if (!service.isConfigured()) { return { @@ -242,7 +248,7 @@ export class IntegrationsService { */ private getServiceAndConnector(type: IntegrationType): { service: { isConfigured: () => boolean; testConnection: () => Promise<{ success: boolean; message: string; latencyMs?: number; details?: Record }> }; - connector: { getMissingConfig?: () => string[] }; + connector: { ensureInitialized: () => Promise; getMissingConfig?: () => string[] }; } { switch (type) { case 'plentyone': @@ -263,17 +269,17 @@ export class IntegrationsService { case 'freescout': return { service: this.freescoutService, - connector: this.freescoutConnector as { getMissingConfig?: () => string[] }, + connector: this.freescoutConnector as { ensureInitialized: () => Promise; getMissingConfig?: () => string[] }, }; case 'nextcloud': return { service: this.nextcloudService, - connector: this.nextcloudConnector as { getMissingConfig?: () => string[] }, + connector: this.nextcloudConnector as { ensureInitialized: () => Promise; getMissingConfig?: () => string[] }, }; case 'ecodms': return { service: this.ecodmsService, - connector: this.ecodmsConnector as { getMissingConfig?: () => string[] }, + connector: this.ecodmsConnector as { ensureInitialized: () => Promise; getMissingConfig?: () => string[] }, }; case 'gembadocs': return { diff --git a/apps/api/src/modules/system-settings/dto/index.ts b/apps/api/src/modules/system-settings/dto/index.ts new file mode 100644 index 0000000..1d3f51f --- /dev/null +++ b/apps/api/src/modules/system-settings/dto/index.ts @@ -0,0 +1 @@ +export { UpdateSettingDto, BulkUpdateSettingsDto } from './update-setting.dto'; diff --git a/apps/api/src/modules/system-settings/dto/update-setting.dto.ts b/apps/api/src/modules/system-settings/dto/update-setting.dto.ts new file mode 100644 index 0000000..90bdfb7 --- /dev/null +++ b/apps/api/src/modules/system-settings/dto/update-setting.dto.ts @@ -0,0 +1,53 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsBoolean } from 'class-validator'; + +export class UpdateSettingDto { + @ApiProperty({ description: 'The setting value (always stored as string)' }) + @IsString() + value: string; + + @ApiPropertyOptional({ description: 'Human-readable description of the setting' }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional({ + description: 'Value type for parsing: string, number, boolean, json', + enum: ['string', 'number', 'boolean', 'json'], + }) + @IsString() + @IsOptional() + valueType?: string; + + @ApiPropertyOptional({ description: 'Whether the value should be encrypted at rest' }) + @IsBoolean() + @IsOptional() + isSecret?: boolean; +} + +export class BulkUpdateSettingsDto { + @ApiProperty({ + type: 'array', + items: { + type: 'object', + properties: { + key: { type: 'string' }, + value: { type: 'string' }, + category: { type: 'string' }, + description: { type: 'string' }, + valueType: { type: 'string' }, + isSecret: { type: 'boolean' }, + }, + required: ['key', 'value'], + }, + description: 'Array of settings to upsert in one transaction', + }) + settings: Array<{ + key: string; + value: string; + category?: string; + description?: string; + valueType?: string; + isSecret?: boolean; + }>; +} diff --git a/apps/api/src/modules/system-settings/index.ts b/apps/api/src/modules/system-settings/index.ts new file mode 100644 index 0000000..b9e42e5 --- /dev/null +++ b/apps/api/src/modules/system-settings/index.ts @@ -0,0 +1,3 @@ +export { SystemSettingsModule } from './system-settings.module'; +export { SystemSettingsService } from './system-settings.service'; +export { UpdateSettingDto, BulkUpdateSettingsDto } from './dto'; diff --git a/apps/api/src/modules/system-settings/system-settings.controller.ts b/apps/api/src/modules/system-settings/system-settings.controller.ts new file mode 100644 index 0000000..10ff8f2 --- /dev/null +++ b/apps/api/src/modules/system-settings/system-settings.controller.ts @@ -0,0 +1,117 @@ +import { + Controller, + Get, + Put, + Body, + Param, + Query, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiParam, + ApiQuery, +} from '@nestjs/swagger'; +import { SystemSettingsService } from './system-settings.service'; +import { UpdateSettingDto, BulkUpdateSettingsDto } from './dto'; +import { RequirePermissions } from '../../auth/permissions/permissions.decorator'; +import { Permission } from '../../auth/permissions/permissions.enum'; + +@ApiTags('System Settings') +@ApiBearerAuth('JWT-auth') +@Controller('system-settings') +export class SystemSettingsController { + constructor(private readonly systemSettingsService: SystemSettingsService) {} + + @Get() + @RequirePermissions(Permission.SYSTEM_SETTINGS_VIEW) + @ApiOperation({ summary: 'Get all system settings, optionally filtered by category' }) + @ApiQuery({ name: 'category', required: false, description: 'Filter settings by category' }) + @ApiResponse({ + status: 200, + description: 'Settings grouped by category. Secret values are masked.', + schema: { + type: 'object', + properties: { + settings: { type: 'array', items: { type: 'object' } }, + categories: { type: 'array', items: { type: 'string' } }, + }, + }, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' }) + findAll(@Query('category') category?: string) { + return this.systemSettingsService.findAll(category); + } + + // IMPORTANT: This route must be declared BEFORE :key to prevent NestJS from + // matching the literal string "bulk" as a :key parameter value. + @Put('bulk') + @RequirePermissions(Permission.SYSTEM_SETTINGS_MANAGE) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Bulk upsert multiple settings in one transaction' }) + @ApiResponse({ + status: 200, + description: 'All settings upserted successfully', + schema: { + type: 'array', + items: { type: 'object' }, + }, + }) + @ApiResponse({ status: 400, description: 'Bad request - invalid data' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' }) + bulkUpdate(@Body() dto: BulkUpdateSettingsDto) { + return this.systemSettingsService.bulkUpsert(dto.settings.map((s) => ({ + key: s.key, + value: s.value, + category: s.category ?? 'general', + description: s.description, + valueType: s.valueType, + isSecret: s.isSecret, + }))); + } + + @Get(':key') + @RequirePermissions(Permission.SYSTEM_SETTINGS_VIEW) + @ApiOperation({ summary: 'Get a single setting by key' }) + @ApiParam({ name: 'key', description: 'Setting key (e.g. sync.interval.plentyone)' }) + @ApiResponse({ + status: 200, + description: 'The setting. Secret value is masked.', + schema: { type: 'object' }, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' }) + @ApiResponse({ status: 404, description: 'Setting not found' }) + findOne(@Param('key') key: string) { + return this.systemSettingsService.findByKey(key); + } + + @Put(':key') + @RequirePermissions(Permission.SYSTEM_SETTINGS_MANAGE) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Create or update a setting by key' }) + @ApiParam({ name: 'key', description: 'Setting key (e.g. branding.appName)' }) + @ApiResponse({ + status: 200, + description: 'Setting upserted successfully. Secret value is masked in the response.', + schema: { type: 'object' }, + }) + @ApiResponse({ status: 400, description: 'Bad request - invalid data' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' }) + update(@Param('key') key: string, @Body() dto: UpdateSettingDto) { + return this.systemSettingsService.upsert(key, { + value: dto.value, + category: 'general', // category is derived from the key prefix or defaults to 'general' + description: dto.description, + valueType: dto.valueType, + isSecret: dto.isSecret, + }); + } +} diff --git a/apps/api/src/modules/system-settings/system-settings.module.ts b/apps/api/src/modules/system-settings/system-settings.module.ts new file mode 100644 index 0000000..c9bf364 --- /dev/null +++ b/apps/api/src/modules/system-settings/system-settings.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { SystemSettingsController } from './system-settings.controller'; +import { SystemSettingsService } from './system-settings.service'; + +/** + * SystemSettingsModule provides database-backed configuration management. + * + * - PrismaModule and CommonModule (which exports EncryptionService) are + * registered as @Global() in the root AppModule, so they are available + * here without explicit imports. + * - SystemSettingsService is exported so other feature modules can inject + * it and call getValue() / getTypedValue() for runtime configuration. + */ +@Module({ + controllers: [SystemSettingsController], + providers: [SystemSettingsService], + exports: [SystemSettingsService], +}) +export class SystemSettingsModule {} diff --git a/apps/api/src/modules/system-settings/system-settings.service.ts b/apps/api/src/modules/system-settings/system-settings.service.ts new file mode 100644 index 0000000..fa2cb23 --- /dev/null +++ b/apps/api/src/modules/system-settings/system-settings.service.ts @@ -0,0 +1,269 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { EncryptionService } from '../../common/services/encryption.service'; +import { SystemSetting } from '@prisma/client'; + +interface CacheEntry { + value: string; + expiresAt: number; +} + +export interface UpsertData { + value: string; + category: string; + description?: string; + valueType?: string; + isSecret?: boolean; +} + +export interface BulkUpsertItem extends UpsertData { + key: string; +} + +export interface SettingResponse extends Omit { + value: string; +} + +/** + * Service for managing system-wide settings stored in the database. + * + * Features: + * - In-memory cache with 60s TTL to reduce database reads + * - AES-256-GCM encryption for secrets via EncryptionService + * - Type-safe value parsing for number, boolean, json, and string types + * - Idempotent upsert operations (safe to call multiple times) + */ +@Injectable() +export class SystemSettingsService { + private readonly logger = new Logger(SystemSettingsService.name); + private readonly cache = new Map(); + private readonly CACHE_TTL_MS = 60_000; // 60 seconds + private readonly SECRET_MASK = '••••••••'; + + constructor( + private readonly prisma: PrismaService, + private readonly encryptionService: EncryptionService, + ) {} + + /** + * Returns all settings, grouped by category. + * Secret values are masked in the response. + */ + async findAll(category?: string): Promise<{ settings: SettingResponse[]; categories: string[] }> { + const where = category ? { category } : undefined; + + const settings = await this.prisma.systemSetting.findMany({ + where, + orderBy: [{ category: 'asc' }, { key: 'asc' }], + }); + + const masked = settings.map((s) => this.maskSecret(s)); + + const categories = await this.prisma.systemSetting + .findMany({ distinct: ['category'], select: { category: true }, orderBy: { category: 'asc' } }) + .then((rows) => rows.map((r) => r.category)); + + return { settings: masked, categories }; + } + + /** + * Returns a single setting by key. Secret values are masked in the response. + */ + async findByKey(key: string): Promise { + const setting = await this.prisma.systemSetting.findUnique({ where: { key } }); + + if (!setting) { + throw new NotFoundException(`Setting with key "${key}" not found`); + } + + return this.maskSecret(setting); + } + + /** + * Internal method: returns the actual decrypted value for use by other services. + * Uses in-memory cache to avoid repeated database reads. + */ + async getValue(key: string): Promise { + const now = Date.now(); + const cached = this.cache.get(key); + + if (cached && cached.expiresAt > now) { + return cached.value; + } + + const setting = await this.prisma.systemSetting.findUnique({ where: { key } }); + + if (!setting) { + return null; + } + + const value = setting.isSecret + ? this.encryptionService.decrypt(setting.value) + : setting.value; + + this.cache.set(key, { value, expiresAt: now + this.CACHE_TTL_MS }); + + return value; + } + + /** + * Returns a typed value parsed according to the setting's valueType. + * Falls back to defaultValue when the key is not found or parsing fails. + */ + async getTypedValue(key: string, defaultValue: T): Promise { + const raw = await this.getValue(key); + + if (raw === null) { + return defaultValue; + } + + const setting = await this.prisma.systemSetting.findUnique({ + where: { key }, + select: { valueType: true }, + }); + + const valueType = setting?.valueType ?? 'string'; + + try { + switch (valueType) { + case 'number': + return Number(raw) as unknown as T; + case 'boolean': + return (raw === 'true' || raw === '1') as unknown as T; + case 'json': + return JSON.parse(raw) as T; + default: + return raw as unknown as T; + } + } catch (error) { + this.logger.warn(`Failed to parse setting "${key}" as ${valueType}, using default value`); + return defaultValue; + } + } + + /** + * Creates or updates a setting. Encrypts the value if isSecret is true. + * Invalidates the cache entry for the key. + */ + async upsert(key: string, data: UpsertData): Promise { + const storedValue = data.isSecret + ? this.encryptionService.encrypt(data.value) + : data.value; + + const setting = await this.prisma.systemSetting.upsert({ + where: { key }, + create: { + key, + value: storedValue, + category: data.category, + description: data.description, + valueType: data.valueType ?? 'string', + isSecret: data.isSecret ?? false, + }, + update: { + value: storedValue, + category: data.category, + description: data.description, + ...(data.valueType !== undefined && { valueType: data.valueType }), + ...(data.isSecret !== undefined && { isSecret: data.isSecret }), + }, + }); + + this.invalidateCache(key); + + return this.maskSecret(setting); + } + + /** + * Upserts multiple settings in a single database transaction. + */ + async bulkUpsert(settings: BulkUpsertItem[]): Promise { + const results: SettingResponse[] = []; + + await this.prisma.$transaction(async (tx) => { + for (const item of settings) { + const storedValue = item.isSecret + ? this.encryptionService.encrypt(item.value) + : item.value; + + const setting = await tx.systemSetting.upsert({ + where: { key: item.key }, + create: { + key: item.key, + value: storedValue, + category: item.category ?? 'general', + description: item.description, + valueType: item.valueType ?? 'string', + isSecret: item.isSecret ?? false, + }, + update: { + value: storedValue, + category: item.category ?? 'general', + description: item.description, + ...(item.valueType !== undefined && { valueType: item.valueType }), + ...(item.isSecret !== undefined && { isSecret: item.isSecret }), + }, + }); + + results.push(this.maskSecret(setting)); + } + }); + + // Invalidate all affected cache entries + settings.forEach((s) => this.invalidateCache(s.key)); + + return results; + } + + /** + * Seeds default system settings. Uses upsert so it is idempotent + * and safe to call on every application startup or in the seed script. + */ + async seedDefaults(): Promise { + const defaultSettings: BulkUpsertItem[] = [ + // Sync intervals + { key: 'sync.interval.plentyone', value: '15', category: 'sync', valueType: 'number', description: 'PlentyONE Sync-Intervall (Minuten)' }, + { key: 'sync.interval.zulip', value: '5', category: 'sync', valueType: 'number', description: 'Zulip Sync-Intervall (Minuten)' }, + { key: 'sync.interval.todoist', value: '10', category: 'sync', valueType: 'number', description: 'Todoist Sync-Intervall (Minuten)' }, + { key: 'sync.interval.freescout', value: '10', category: 'sync', valueType: 'number', description: 'FreeScout Sync-Intervall (Minuten)' }, + { key: 'sync.interval.nextcloud', value: '30', category: 'sync', valueType: 'number', description: 'Nextcloud Sync-Intervall (Minuten)' }, + { key: 'sync.interval.ecodms', value: '60', category: 'sync', valueType: 'number', description: 'ecoDMS Sync-Intervall (Minuten)' }, + { key: 'sync.interval.gembadocs', value: '30', category: 'sync', valueType: 'number', description: 'GembaDocs Sync-Intervall (Minuten)' }, + // Feature flags + { key: 'feature.syncJobs.enabled', value: 'false', category: 'feature', valueType: 'boolean', description: 'Hintergrund-Sync-Jobs aktivieren' }, + { key: 'feature.swagger.enabled', value: 'true', category: 'feature', valueType: 'boolean', description: 'Swagger API-Dokumentation aktivieren' }, + // CORS + { key: 'cors.origins', value: 'http://localhost:3000', category: 'cors', valueType: 'string', description: 'Erlaubte CORS Origins (kommagetrennt)' }, + // Branding + { key: 'branding.appName', value: 'tOS', category: 'branding', valueType: 'string', description: 'Anwendungsname' }, + { key: 'branding.companyName', value: '', category: 'branding', valueType: 'string', description: 'Firmenname' }, + { key: 'branding.logoUrl', value: '', category: 'branding', valueType: 'string', description: 'Logo-URL' }, + ]; + + await this.bulkUpsert(defaultSettings); + this.logger.log(`Seeded ${defaultSettings.length} default system settings`); + } + + /** + * Clears the in-memory cache. Pass a key to clear only that entry, + * or call without arguments to clear the entire cache. + */ + invalidateCache(key?: string): void { + if (key) { + this.cache.delete(key); + } else { + this.cache.clear(); + } + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private maskSecret(setting: SystemSetting): SettingResponse { + return { + ...setting, + value: setting.isSecret ? this.SECRET_MASK : setting.value, + }; + } +} diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 47a0f26..0166d58 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -77,7 +77,8 @@ "departments": "Abteilungen", "overview": "Uebersicht", "plentyOne": "PlentyONE", - "zulip": "ZULIP" + "zulip": "ZULIP", + "systemSettings": "Einstellungen" }, "dashboard": { "title": "Dashboard", @@ -1048,5 +1049,29 @@ "minutes": "Minuten", "seconds": "Sekunden", "days": "Tage" + }, + "systemSettings": { + "title": "Systemeinstellungen", + "description": "Globale Anwendungskonfiguration verwalten", + "general": "Allgemein", + "cors": "CORS", + "sync": "Synchronisation", + "features": "Features", + "branding": "Branding", + "appName": "Anwendungsname", + "companyName": "Firmenname", + "logoUrl": "Logo-URL", + "corsOrigins": "Erlaubte Origins", + "corsOriginsDesc": "Kommagetrennte Liste von erlaubten Origins fuer Cross-Origin-Anfragen", + "syncInterval": "Sync-Intervall", + "minutes": "Minuten", + "enableSyncJobs": "Hintergrund-Sync-Jobs", + "enableSyncJobsDesc": "Automatische Synchronisation der Integrationen im Hintergrund", + "enableSwagger": "Swagger API-Dokumentation", + "enableSwaggerDesc": "Interaktive API-Dokumentation unter /api/docs verfuegbar (Neustart erforderlich)", + "saved": "Einstellungen gespeichert", + "saveError": "Fehler beim Speichern der Einstellungen", + "requiresRestart": "Aenderung erfordert einen Neustart des Backends", + "save": "Speichern" } } diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index e13e3e3..b38aff7 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -77,7 +77,8 @@ "departments": "Departments", "overview": "Overview", "plentyOne": "PlentyONE", - "zulip": "ZULIP" + "zulip": "ZULIP", + "systemSettings": "Settings" }, "dashboard": { "title": "Dashboard", @@ -1048,5 +1049,29 @@ "minutes": "minutes", "seconds": "seconds", "days": "days" + }, + "systemSettings": { + "title": "System Settings", + "description": "Manage global application configuration", + "general": "General", + "cors": "CORS", + "sync": "Synchronization", + "features": "Features", + "branding": "Branding", + "appName": "Application Name", + "companyName": "Company Name", + "logoUrl": "Logo URL", + "corsOrigins": "Allowed Origins", + "corsOriginsDesc": "Comma-separated list of allowed origins for cross-origin requests", + "syncInterval": "Sync Interval", + "minutes": "minutes", + "enableSyncJobs": "Background Sync Jobs", + "enableSyncJobsDesc": "Automatic synchronization of integrations in the background", + "enableSwagger": "Swagger API Documentation", + "enableSwaggerDesc": "Interactive API documentation available at /api/docs (restart required)", + "saved": "Settings saved", + "saveError": "Failed to save settings", + "requiresRestart": "Change requires a backend restart", + "save": "Save" } } diff --git a/apps/web/src/app/[locale]/(auth)/admin/integrations/admin-integrations-content.tsx b/apps/web/src/app/[locale]/(auth)/admin/integrations/admin-integrations-content.tsx index d178e52..b3daad3 100644 --- a/apps/web/src/app/[locale]/(auth)/admin/integrations/admin-integrations-content.tsx +++ b/apps/web/src/app/[locale]/(auth)/admin/integrations/admin-integrations-content.tsx @@ -1,10 +1,9 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useTranslations } from 'next-intl'; import { motion } from 'framer-motion'; import { - Plug, Shield, Building2, MessageSquare, @@ -16,6 +15,7 @@ import { Settings, Eye, EyeOff, + Loader2, type LucideIcon, } from 'lucide-react'; @@ -41,8 +41,16 @@ import { } from '@/components/ui/select'; import { Skeleton } from '@/components/ui/skeleton'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { IntegrationStatusBadge, ConnectionTestButton } from '@/components/integrations'; +import { IntegrationStatusBadge } from '@/components/integrations'; import { useAllIntegrationStatuses } from '@/hooks/integrations'; +import { + useCredentials, + useCredentialDetail, + useCreateCredential, + useUpdateCredential, + useTestCredentialConnection, +} from '@/hooks/integrations/use-credentials'; +import { useUpdateSetting } from '@/hooks/use-system-settings'; import { useToast } from '@/hooks/use-toast'; import type { IntegrationType } from '@/types/integrations'; @@ -50,7 +58,11 @@ interface AdminIntegrationsContentProps { locale: string; } -/** Integration metadata */ +// ============================================================ +// Static configuration +// ============================================================ + +/** Display metadata per integration */ const integrationMeta: Record< IntegrationType, { icon: LucideIcon; nameKey: string; descKey: string } @@ -64,77 +76,394 @@ const integrationMeta: Record< gembadocs: { icon: ClipboardCheck, nameKey: 'gembaDocs', descKey: 'gembaDocsDesc' }, }; -/** Credential field configuration per integration */ -const credentialFields: Record = { +/** Credential field definitions per integration. + * Keys must match what the backend validates (credentials.service.ts). */ +const credentialFields: Record< + IntegrationType, + Array<{ key: string; label: string; type: 'text' | 'password' | 'url'; placeholder?: string }> +> = { plentyone: [ - { key: 'apiUrl', labelKey: 'apiUrl', type: 'url' }, - { key: 'username', labelKey: 'username', type: 'text' }, - { key: 'password', labelKey: 'password', type: 'password' }, + { key: 'apiUrl', label: 'API URL', type: 'url', placeholder: 'https://your-shop.plentymarkets-cloud01.com' }, + { key: 'apiKey', label: 'API Schluessel', type: 'password' }, ], zulip: [ - { key: 'apiUrl', labelKey: 'apiUrl', type: 'url' }, - { key: 'email', labelKey: 'username', type: 'text' }, - { key: 'apiKey', labelKey: 'apiKey', type: 'password' }, + { key: 'zulipUrl', label: 'Server URL', type: 'url', placeholder: 'https://your-org.zulipchat.com' }, + { key: 'botEmail', label: 'Bot E-Mail', type: 'text' }, + { key: 'apiKey', label: 'API Schluessel', type: 'password' }, ], todoist: [ - { key: 'apiKey', labelKey: 'apiKey', type: 'password' }, + { key: 'apiToken', label: 'API Token', type: 'password' }, ], freescout: [ - { key: 'apiUrl', labelKey: 'apiUrl', type: 'url' }, - { key: 'apiKey', labelKey: 'apiKey', type: 'password' }, + { key: 'apiUrl', label: 'API URL', type: 'url', placeholder: 'https://helpdesk.example.com' }, + { key: 'apiKey', label: 'API Schluessel', type: 'password' }, ], nextcloud: [ - { key: 'apiUrl', labelKey: 'apiUrl', type: 'url' }, - { key: 'username', labelKey: 'username', type: 'text' }, - { key: 'password', labelKey: 'password', type: 'password' }, + { key: 'serverUrl', label: 'Server URL', type: 'url', placeholder: 'https://cloud.example.com' }, + { key: 'username', label: 'Benutzername', type: 'text' }, + { key: 'password', label: 'Passwort', type: 'password' }, ], ecodms: [ - { key: 'apiUrl', labelKey: 'apiUrl', type: 'url' }, - { key: 'apiKey', labelKey: 'apiKey', type: 'password' }, + { key: 'apiUrl', label: 'API URL', type: 'url', placeholder: 'https://ecodms.example.com' }, + { key: 'username', label: 'Benutzername', type: 'text' }, + { key: 'password', label: 'Passwort', type: 'password' }, ], gembadocs: [ - { key: 'apiUrl', labelKey: 'apiUrl', type: 'url' }, - { key: 'apiKey', labelKey: 'apiKey', type: 'password' }, + { key: 'apiUrl', label: 'API URL', type: 'url', placeholder: 'https://gembadocs.example.com' }, + { key: 'apiKey', label: 'API Schluessel', type: 'password' }, ], }; +// ============================================================ +// Sub-component: single integration tab panel +// ============================================================ + +interface IntegrationPanelProps { + integrationType: IntegrationType; +} + +function IntegrationPanel({ integrationType }: IntegrationPanelProps) { + const t = useTranslations('integrations'); + const { toast } = useToast(); + + const meta = integrationMeta[integrationType]; + const Icon = meta.icon; + const fields = credentialFields[integrationType]; + + // ---- API queries / mutations ---- + const { data: allCredentials } = useCredentials(); + const createCredential = useCreateCredential(); + const updateCredential = useUpdateCredential(); + const testConnection = useTestCredentialConnection(); + const updateSetting = useUpdateSetting(); + + // Find the saved credential for this integration type (UPPERCASE match) + const savedCredential = allCredentials?.data.find( + (c) => c.type === integrationType.toUpperCase(), + ) ?? null; + + // Fetch decrypted values only when a credential exists + const { data: credentialDetail, isLoading: isLoadingDetail } = + useCredentialDetail(savedCredential?.id ?? null); + + // ---- Local UI state ---- + const [formData, setFormData] = useState>({}); + const [isEnabled, setIsEnabled] = useState( + savedCredential?.isActive ?? false, + ); + const [visiblePasswords, setVisiblePasswords] = useState>({}); + const [syncInterval, setSyncInterval] = useState('15'); + + // Pre-fill form fields whenever decrypted credential values arrive + useEffect(() => { + if (credentialDetail?.credentials) { + setFormData( + Object.fromEntries( + Object.entries(credentialDetail.credentials).map(([k, v]) => [k, String(v)]), + ), + ); + } + }, [credentialDetail]); + + // Keep enabled toggle in sync with stored value + useEffect(() => { + if (savedCredential !== null) { + setIsEnabled(savedCredential.isActive); + } + }, [savedCredential]); + + // ---- Handlers ---- + const handleFieldChange = (key: string, value: string) => { + setFormData((prev) => ({ ...prev, [key]: value })); + }; + + const togglePassword = (fieldId: string) => { + setVisiblePasswords((prev) => ({ ...prev, [fieldId]: !prev[fieldId] })); + }; + + const handleSave = async () => { + try { + const integrationName = t(meta.nameKey as never); + + if (!savedCredential) { + // Create new credential + await createCredential.mutateAsync({ + type: integrationType.toUpperCase(), + name: 'Default', + credentials: formData, + }); + } else { + // Update existing credential + await updateCredential.mutateAsync({ + id: savedCredential.id, + credentials: formData, + }); + } + + toast({ + title: t('saveSettings'), + description: t('settingsSaved' as never, { name: integrationName }), + }); + } catch (error) { + toast({ + title: 'Fehler beim Speichern', + description: error instanceof Error ? error.message : 'Unbekannter Fehler', + variant: 'destructive', + }); + } + }; + + const handleToggleEnabled = async (checked: boolean) => { + setIsEnabled(checked); + + if (!savedCredential) return; // Nothing to update yet + + try { + await updateCredential.mutateAsync({ + id: savedCredential.id, + isActive: checked, + }); + toast({ + title: checked ? 'Integration aktiviert' : 'Integration deaktiviert', + description: `${t(meta.nameKey as never)} wurde ${checked ? 'aktiviert' : 'deaktiviert'}.`, + }); + } catch (error) { + // Revert on failure + setIsEnabled(!checked); + toast({ + title: 'Fehler', + description: error instanceof Error ? error.message : 'Unbekannter Fehler', + variant: 'destructive', + }); + } + }; + + const handleTestConnection = async () => { + if (!savedCredential) return; + + try { + const result = await testConnection.mutateAsync(savedCredential.id); + toast({ + title: result.success ? t('testSuccess') : t('testFailed'), + description: result.message, + variant: result.success ? 'default' : 'destructive', + }); + } catch (error) { + toast({ + title: t('testFailed'), + description: error instanceof Error ? error.message : t('testError'), + variant: 'destructive', + }); + } + }; + + const handleSyncIntervalChange = async (value: string) => { + setSyncInterval(value); + + try { + await updateSetting.mutateAsync({ + key: `sync.interval.${integrationType}`, + value, + }); + } catch { + // Non-critical; sync interval is a best-effort setting + } + }; + + // ---- Loading skeleton while fetching decrypted values ---- + if (savedCredential && isLoadingDetail) { + return ( + + + + + + +
+ {fields.map((_, i) => ( + + ))} +
+
+
+ ); + } + + const isSaving = + createCredential.isPending || updateCredential.isPending; + const isTesting = testConnection.isPending; + const hasCredential = !!savedCredential; + + return ( + + +
+ {/* Left: icon + title */} +
+
+ +
+
+ + {t(meta.nameKey as never)} + {/* Show status badge only when a credential is saved */} + {hasCredential && ( + + )} + + {t(meta.descKey as never)} +
+
+ + {/* Right: enable/disable toggle */} +
+ + +
+
+
+ + + {/* Credentials section */} +
+

{t('credentials')}

+
+ {fields.map((field) => { + const fieldId = `${integrationType}-${field.key}`; + const isPassword = field.type === 'password'; + const isVisible = visiblePasswords[fieldId]; + + return ( +
+ +
+ handleFieldChange(field.key, e.target.value)} + className={cn(isPassword && 'pr-10')} + /> + {isPassword && ( + + )} +
+
+ ); + })} +
+
+ + {/* Sync settings section */} +
+

{t('synchronization' as never)}

+
+
+ + +
+
+
+
+ + + {/* Connection test button */} + + + {/* Save button */} + + +
+ ); +} + +// ============================================================ +// Main page component +// ============================================================ + /** - * Admin integrations management content + * Admin integrations management page. + * Lists each integration as a tab; each tab loads its own credential + * state so queries are isolated and only triggered when the tab is visited. */ -export function AdminIntegrationsContent({ locale }: AdminIntegrationsContentProps) { +export function AdminIntegrationsContent({ locale: _locale }: AdminIntegrationsContentProps) { const t = useTranslations('integrations'); const tAdmin = useTranslations('admin'); - const { toast } = useToast(); const { data: integrations, isLoading } = useAllIntegrationStatuses(); - const [visiblePasswords, setVisiblePasswords] = useState>({}); - const [enabledState, setEnabledState] = useState>({}); - const [formData, setFormData] = useState>>({}); - - const togglePasswordVisibility = (fieldKey: string) => { - setVisiblePasswords((prev) => ({ - ...prev, - [fieldKey]: !prev[fieldKey], - })); - }; - - const handleFieldChange = (integrationType: IntegrationType, fieldKey: string, value: string) => { - setFormData((prev) => ({ - ...prev, - [integrationType]: { - ...prev[integrationType], - [fieldKey]: value, - }, - })); - }; - - const handleSave = (integrationType: IntegrationType) => { - toast({ - title: t('saveSettings'), - description: t('settingsSaved' as never, { name: t(integrationMeta[integrationType].nameKey as never) }), - }); - }; - return (
{/* Header */} @@ -143,14 +472,16 @@ export function AdminIntegrationsContent({ locale }: AdminIntegrationsContentPro
-

{tAdmin('integrationManagement')}

+

+ {tAdmin('integrationManagement')} +

{tAdmin('integrationManagementDesc')}

- {/* Integration Tabs */} + {/* Loading state */} {isLoading ? (
{Array.from({ length: 3 }).map((_, i) => ( @@ -184,139 +515,17 @@ export function AdminIntegrationsContent({ locale }: AdminIntegrationsContentPro })} - {integrations?.map((config) => { - const meta = integrationMeta[config.type]; - const Icon = meta.icon; - const fields = credentialFields[config.type]; - - return ( - - - - -
-
-
- -
-
- - {t(meta.nameKey as never)} - - - {t(meta.descKey as never)} -
-
- -
-
- - - setEnabledState((prev) => ({ ...prev, [config.type]: checked })) - } - /> -
-
-
-
- - - {/* Credentials */} -
-

{t('credentials')}

-
- {fields.map((field) => { - const fieldId = `${config.type}-${field.key}`; - const isPassword = field.type === 'password'; - const isVisible = visiblePasswords[fieldId]; - - return ( -
- -
- - handleFieldChange(config.type, field.key, e.target.value) - } - className={cn(isPassword && 'pr-10')} - /> - {isPassword && ( - - )} -
-
- ); - })} -
-
- - {/* Sync Settings */} -
-

{t('synchronization' as never)}

-
-
- - -
-
-
-
- - - - - -
-
-
- ); - })} + {integrations?.map((config) => ( + + + + + + ))} )}
diff --git a/apps/web/src/app/[locale]/(auth)/admin/settings/page.tsx b/apps/web/src/app/[locale]/(auth)/admin/settings/page.tsx new file mode 100644 index 0000000..5ed40c3 --- /dev/null +++ b/apps/web/src/app/[locale]/(auth)/admin/settings/page.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from 'next'; +import { SystemSettingsContent } from './system-settings-content'; + +export const metadata: Metadata = { + title: 'Systemeinstellungen | Admin | tOS', + description: 'Globale Anwendungskonfiguration verwalten', +}; + +interface SystemSettingsPageProps { + params: { + locale: string; + }; +} + +/** + * Admin page for managing global system settings + */ +export default function SystemSettingsPage({ params }: SystemSettingsPageProps) { + return ; +} diff --git a/apps/web/src/app/[locale]/(auth)/admin/settings/system-settings-content.tsx b/apps/web/src/app/[locale]/(auth)/admin/settings/system-settings-content.tsx new file mode 100644 index 0000000..e3b8afe --- /dev/null +++ b/apps/web/src/app/[locale]/(auth)/admin/settings/system-settings-content.tsx @@ -0,0 +1,470 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useTranslations } from 'next-intl'; +import { motion } from 'framer-motion'; +import { + Settings, + Globe, + RefreshCw, + Zap, + Building2, + Save, + AlertTriangle, +} from 'lucide-react'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Separator } from '@/components/ui/separator'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useToast } from '@/hooks/use-toast'; +import { + useSystemSettings, + useUpdateSetting, + useBulkUpdateSettings, + type SystemSetting, +} from '@/hooks/use-system-settings'; + +// --------------------------------------------------------------------------- +// Types & constants +// --------------------------------------------------------------------------- + +interface SystemSettingsContentProps { + locale: string; +} + +/** Integration types that appear in sync settings */ +const INTEGRATION_TYPES = [ + 'plentyone', + 'zulip', + 'todoist', + 'freescout', + 'nextcloud', + 'ecodms', + 'gembadocs', +] as const; + +const SYNC_INTERVAL_OPTIONS = ['1', '5', '10', '15', '30', '60'] as const; + +// --------------------------------------------------------------------------- +// Helper: build a lookup map from the flat settings array +// --------------------------------------------------------------------------- + +function buildSettingsMap(settings: SystemSetting[]): Record { + return settings.reduce>((acc, s) => { + acc[s.key] = s.value; + return acc; + }, {}); +} + +// --------------------------------------------------------------------------- +// Loading skeleton +// --------------------------------------------------------------------------- + +function SettingsSkeleton() { + return ( +
+ + + + + + + + {Array.from({ length: 3 }).map((_, i) => ( +
+ + +
+ ))} +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +/** + * Admin page for managing global system settings. + * Groups settings by category into tabs: Branding, CORS, Sync, Features. + */ +export function SystemSettingsContent({ locale: _locale }: SystemSettingsContentProps) { + const t = useTranslations('systemSettings'); + const { toast } = useToast(); + + const { data, isLoading } = useSystemSettings(); + const updateSetting = useUpdateSetting(); + const bulkUpdate = useBulkUpdateSettings(); + + // Local form state keyed by setting key + const [formValues, setFormValues] = useState>({}); + + // Populate form values once data is loaded + useEffect(() => { + if (data?.settings) { + setFormValues(buildSettingsMap(data.settings)); + } + }, [data]); + + // ------------------------------------------------------------------------- + // Generic helpers + // ------------------------------------------------------------------------- + + function handleChange(key: string, value: string) { + setFormValues((prev) => ({ ...prev, [key]: value })); + } + + async function handleBulkSave(keys: string[]) { + const settings = keys.map((key) => ({ key, value: formValues[key] ?? '' })); + + try { + await bulkUpdate.mutateAsync({ settings }); + toast({ title: t('saved') }); + } catch { + toast({ title: t('saveError'), variant: 'destructive' }); + } + } + + async function handleSingleToggle(key: string, checked: boolean) { + const value = checked ? 'true' : 'false'; + handleChange(key, value); + + try { + await updateSetting.mutateAsync({ key, value }); + toast({ title: t('saved') }); + } catch { + toast({ title: t('saveError'), variant: 'destructive' }); + } + } + + // ------------------------------------------------------------------------- + // Render + // ------------------------------------------------------------------------- + + return ( +
+ {/* Page header */} + +
+ +
+
+

{t('title')}

+

{t('description')}

+
+
+ + {/* Content */} + {isLoading ? ( + + ) : ( + + + + + {t('branding')} + + + + {t('cors')} + + + + {t('sync')} + + + + {t('features')} + + + + {/* ---------------------------------------------------------------- */} + {/* Branding tab */} + {/* ---------------------------------------------------------------- */} + + + + + {t('branding')} + {t('description')} + + + +
+ {/* App name */} +
+ + handleChange('branding.appName', e.target.value)} + placeholder="tOS" + /> +
+ + {/* Company name */} +
+ + handleChange('branding.companyName', e.target.value)} + placeholder="Mein Unternehmen GmbH" + /> +
+ + {/* Logo URL */} +
+ + handleChange('branding.logoUrl', e.target.value)} + placeholder="https://example.com/logo.png" + /> +
+
+ + + +
+ +
+
+
+
+
+ + {/* ---------------------------------------------------------------- */} + {/* CORS tab */} + {/* ---------------------------------------------------------------- */} + + + + + {t('cors')} + {t('corsOriginsDesc')} + + + +
+ +