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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
309
apps/api/prisma/migrate-env-to-db.ts
Normal file
309
apps/api/prisma/migrate-env-to-db.ts
Normal file
@@ -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<string, string>, 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<string, string>;
|
||||
}> = [
|
||||
{
|
||||
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<string, string> = {};
|
||||
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);
|
||||
});
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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!');
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string>('JWT_SECRET');
|
||||
if (!secret) {
|
||||
throw new Error('JWT_SECRET is not defined');
|
||||
}
|
||||
const keycloakUrl = configService.get<string>('KEYCLOAK_URL');
|
||||
const keycloakRealm = configService.get<string>('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<JwtPayload> {
|
||||
// Optionally validate that the user still exists and is active
|
||||
async validate(payload: Record<string, unknown>): Promise<JwtPayload> {
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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<string>('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<string>('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')
|
||||
|
||||
@@ -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<BaseConnectorConfig>;
|
||||
protected config: Required<BaseConnectorConfig>;
|
||||
|
||||
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<never> {
|
||||
protected handleResponseError(error: AxiosError): Promise<never> {
|
||||
const status = error.response?.status;
|
||||
const message = this.extractErrorMessage(error);
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string>('ECODMS_API_URL', '');
|
||||
this.username = this.configService.get<string>('ECODMS_USERNAME', '');
|
||||
this.password = this.configService.get<string>('ECODMS_PASSWORD', '');
|
||||
this.apiVersion = this.configService.get<string>('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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<ConnectorHealth> {
|
||||
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<EcoDmsDocument> {
|
||||
await this.ensureInitialized();
|
||||
this.ensureConfigured();
|
||||
|
||||
return this.executeWithRetry(async () => {
|
||||
@@ -574,6 +575,7 @@ export class EcoDmsConnector {
|
||||
* Search documents
|
||||
*/
|
||||
async searchDocuments(params: SearchDocumentsDto): Promise<EcoDmsSearchResult> {
|
||||
await this.ensureInitialized();
|
||||
this.ensureConfigured();
|
||||
|
||||
return this.executeWithRetry(async () => {
|
||||
@@ -632,6 +634,7 @@ export class EcoDmsConnector {
|
||||
mimeType: string,
|
||||
metadata: UploadDocumentDto,
|
||||
): Promise<EcoDmsDocument> {
|
||||
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<EcoDmsDocument> {
|
||||
await this.ensureInitialized();
|
||||
this.ensureConfigured();
|
||||
|
||||
return this.executeWithRetry(async () => {
|
||||
@@ -708,6 +712,7 @@ export class EcoDmsConnector {
|
||||
* Delete a document
|
||||
*/
|
||||
async deleteDocument(id: number): Promise<void> {
|
||||
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<Buffer> {
|
||||
await this.ensureInitialized();
|
||||
this.ensureConfigured();
|
||||
|
||||
return this.executeWithRetry(async () => {
|
||||
@@ -770,6 +777,7 @@ export class EcoDmsConnector {
|
||||
* List folders
|
||||
*/
|
||||
async listFolders(parentId?: number): Promise<EcoDmsFolder[]> {
|
||||
await this.ensureInitialized();
|
||||
this.ensureConfigured();
|
||||
|
||||
return this.executeWithRetry(async () => {
|
||||
@@ -798,6 +806,7 @@ export class EcoDmsConnector {
|
||||
* Get folder tree
|
||||
*/
|
||||
async getFolderTree(): Promise<EcoDmsFolder[]> {
|
||||
await this.ensureInitialized();
|
||||
this.ensureConfigured();
|
||||
|
||||
return this.executeWithRetry(async () => {
|
||||
@@ -820,6 +829,7 @@ export class EcoDmsConnector {
|
||||
* Create a folder
|
||||
*/
|
||||
async createFolder(data: CreateFolderDto): Promise<EcoDmsFolder> {
|
||||
await this.ensureInitialized();
|
||||
this.ensureConfigured();
|
||||
|
||||
return this.executeWithRetry(async () => {
|
||||
@@ -843,6 +853,7 @@ export class EcoDmsConnector {
|
||||
* Delete a folder
|
||||
*/
|
||||
async deleteFolder(id: number): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
this.ensureConfigured();
|
||||
|
||||
return this.executeWithRetry(async () => {
|
||||
@@ -865,6 +876,7 @@ export class EcoDmsConnector {
|
||||
* List classifications (document types/categories)
|
||||
*/
|
||||
async listClassifications(): Promise<EcoDmsClassification[]> {
|
||||
await this.ensureInitialized();
|
||||
this.ensureConfigured();
|
||||
|
||||
return this.executeWithRetry(async () => {
|
||||
@@ -887,6 +899,7 @@ export class EcoDmsConnector {
|
||||
* Get classification details
|
||||
*/
|
||||
async getClassification(id: number): Promise<EcoDmsClassification> {
|
||||
await this.ensureInitialized();
|
||||
this.ensureConfigured();
|
||||
|
||||
return this.executeWithRetry(async () => {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<string>('FREESCOUT_API_URL', '');
|
||||
this.apiKey = this.configService.get<string>('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<void> {
|
||||
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<void> {
|
||||
this.initialized = false;
|
||||
this.configuredState = false;
|
||||
await this.ensureInitialized();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle credential change events
|
||||
*/
|
||||
@OnEvent('credentials.changed')
|
||||
async onCredentialsChanged(payload: { type: string }): Promise<void> {
|
||||
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<ConnectorHealth> {
|
||||
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<FreeScoutConversation> {
|
||||
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<FreeScoutConversation> {
|
||||
await this.ensureInitialized();
|
||||
this.ensureConfigured();
|
||||
|
||||
return this.executeWithRetry(async () => {
|
||||
@@ -454,6 +473,7 @@ export class FreeScoutConnector {
|
||||
conversationId: number,
|
||||
data: ReplyToConversationDto,
|
||||
): Promise<FreeScoutConversation> {
|
||||
await this.ensureInitialized();
|
||||
this.ensureConfigured();
|
||||
|
||||
return this.executeWithRetry(async () => {
|
||||
@@ -482,6 +502,7 @@ export class FreeScoutConnector {
|
||||
conversationId: number,
|
||||
status: string,
|
||||
): Promise<FreeScoutConversation> {
|
||||
await this.ensureInitialized();
|
||||
this.ensureConfigured();
|
||||
|
||||
return this.executeWithRetry(async () => {
|
||||
@@ -499,6 +520,7 @@ export class FreeScoutConnector {
|
||||
* List all mailboxes
|
||||
*/
|
||||
async listMailboxes(): Promise<FreeScoutMailbox[]> {
|
||||
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<FreeScoutMailbox> {
|
||||
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<FreeScoutCustomer> {
|
||||
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<FreeScoutCustomer> {
|
||||
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<FreeScoutCustomer | null> {
|
||||
await this.ensureInitialized();
|
||||
this.ensureConfigured();
|
||||
|
||||
const result = await this.listCustomers({ email });
|
||||
@@ -602,6 +629,7 @@ export class FreeScoutConnector {
|
||||
* List all tags
|
||||
*/
|
||||
async listTags(): Promise<FreeScoutTag[]> {
|
||||
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<void> {
|
||||
await this.ensureInitialized();
|
||||
this.ensureConfigured();
|
||||
|
||||
return this.executeWithRetry(async () => {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<string>('GEMBADOCS_API_URL', '');
|
||||
this.apiKey = this.configService.get<string>('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<void> {
|
||||
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<void> {
|
||||
this.initialized = false;
|
||||
this.isConfiguredFlag = false;
|
||||
await this.ensureInitialized();
|
||||
}
|
||||
|
||||
@OnEvent('credentials.changed')
|
||||
async onCredentialsChanged(payload: { type: string }): Promise<void> {
|
||||
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<ConnectorHealth> {
|
||||
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<GembaAudit> {
|
||||
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<GembaAudit[]> {
|
||||
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<GembaAudit[]> {
|
||||
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<GembaAudit> {
|
||||
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<GembaAudit> {
|
||||
await this.ensureInitialized();
|
||||
this.ensureConfigured();
|
||||
|
||||
return this.executeWithRetry(async () => {
|
||||
@@ -582,6 +590,7 @@ export class GembaDocsConnector {
|
||||
* List all checklist templates
|
||||
*/
|
||||
async listChecklists(): Promise<GembaChecklist[]> {
|
||||
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<GembaChecklist> {
|
||||
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<GembaFinding[]> {
|
||||
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<GembaFinding> {
|
||||
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<GembaFinding> {
|
||||
await this.ensureInitialized();
|
||||
this.ensureConfigured();
|
||||
|
||||
return this.executeWithRetry(async () => {
|
||||
@@ -754,6 +768,7 @@ export class GembaDocsConnector {
|
||||
* Get audit statistics
|
||||
*/
|
||||
async getStatistics(department?: string): Promise<GembaStatistics> {
|
||||
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<GembaTrendData> {
|
||||
await this.ensureInitialized();
|
||||
this.ensureConfigured();
|
||||
|
||||
return this.executeWithRetry(async () => {
|
||||
|
||||
@@ -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],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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],
|
||||
|
||||
@@ -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<void> | null = null;
|
||||
private initialized = false;
|
||||
private configuredState = false;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
const baseUrl = configService.get<string>('PLENTYONE_BASE_URL') || '';
|
||||
|
||||
super({
|
||||
baseUrl: baseUrl ? `${baseUrl}/rest` : '',
|
||||
timeout: 60000, // PlentyONE can be slow
|
||||
maxRetries: 3,
|
||||
});
|
||||
|
||||
this.authConfig = {
|
||||
baseUrl,
|
||||
clientId: configService.get<string>('PLENTYONE_CLIENT_ID') || '',
|
||||
clientSecret: configService.get<string>('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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<ConnectionTestResult> {
|
||||
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<Record<string, string>> {
|
||||
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<void> {
|
||||
// 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<PlentyonePaginatedResponse<PlentyoneOrder>> {
|
||||
await this.ensureInitialized();
|
||||
const params = this.buildQueryParams(query);
|
||||
return this.get<PlentyonePaginatedResponse<PlentyoneOrder>>(
|
||||
'/orders',
|
||||
@@ -231,6 +276,7 @@ export class PlentyoneConnector extends BaseConnector {
|
||||
orderId: number,
|
||||
withRelations?: string[],
|
||||
): Promise<PlentyoneOrder> {
|
||||
await this.ensureInitialized();
|
||||
const params: Record<string, string> = {};
|
||||
if (withRelations?.length) {
|
||||
params.with = withRelations.join(',');
|
||||
@@ -285,6 +331,7 @@ export class PlentyoneConnector extends BaseConnector {
|
||||
async getStock(
|
||||
query?: PlentyoneStockQuery,
|
||||
): Promise<PlentyonePaginatedResponse<PlentyoneStockItem>> {
|
||||
await this.ensureInitialized();
|
||||
const params = this.buildQueryParams(query);
|
||||
return this.get<PlentyonePaginatedResponse<PlentyoneStockItem>>(
|
||||
'/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<PlentyoneOrderStats> {
|
||||
// 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<number, number> = {};
|
||||
const ordersByReferrer: Record<number, number> = {};
|
||||
|
||||
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<string, PlentyoneRevenueStats>();
|
||||
|
||||
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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<string>('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<void> {
|
||||
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<void> {
|
||||
this.initialized = false;
|
||||
await this.ensureInitialized();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle credential change events
|
||||
*/
|
||||
@OnEvent('credentials.changed')
|
||||
async onCredentialsChanged(payload: { type: string }): Promise<void> {
|
||||
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<ConnectionTestResult> {
|
||||
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<Record<string, string>> {
|
||||
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<TodoistTask[]> {
|
||||
await this.ensureInitialized();
|
||||
const params = this.buildTasksParams(request);
|
||||
return this.get<TodoistTask[]>('/tasks', { params });
|
||||
}
|
||||
@@ -125,6 +173,7 @@ export class TodoistConnector extends BaseConnector {
|
||||
* Get a single task by ID
|
||||
*/
|
||||
async getTask(taskId: string): Promise<TodoistTask> {
|
||||
await this.ensureInitialized();
|
||||
return this.get<TodoistTask>(`/tasks/${taskId}`);
|
||||
}
|
||||
|
||||
@@ -132,7 +181,7 @@ export class TodoistConnector extends BaseConnector {
|
||||
* Create a new task
|
||||
*/
|
||||
async createTask(request: TodoistCreateTaskRequest): Promise<TodoistTask> {
|
||||
// Generate a unique request ID for idempotency
|
||||
await this.ensureInitialized();
|
||||
const requestId = this.generateRequestId();
|
||||
|
||||
return this.post<TodoistTask>('/tasks', request, {
|
||||
@@ -149,7 +198,7 @@ export class TodoistConnector extends BaseConnector {
|
||||
taskId: string,
|
||||
request: TodoistUpdateTaskRequest,
|
||||
): Promise<TodoistTask> {
|
||||
// Generate a unique request ID for idempotency
|
||||
await this.ensureInitialized();
|
||||
const requestId = this.generateRequestId();
|
||||
|
||||
return this.post<TodoistTask>(`/tasks/${taskId}`, request, {
|
||||
@@ -163,6 +212,7 @@ export class TodoistConnector extends BaseConnector {
|
||||
* Complete a task
|
||||
*/
|
||||
async completeTask(taskId: string): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
await this.post<void>(`/tasks/${taskId}/close`, null);
|
||||
}
|
||||
|
||||
@@ -170,6 +220,7 @@ export class TodoistConnector extends BaseConnector {
|
||||
* Reopen a task
|
||||
*/
|
||||
async reopenTask(taskId: string): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
await this.post<void>(`/tasks/${taskId}/reopen`, null);
|
||||
}
|
||||
|
||||
@@ -177,6 +228,7 @@ export class TodoistConnector extends BaseConnector {
|
||||
* Delete a task
|
||||
*/
|
||||
async deleteTask(taskId: string): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
await this.delete<void>(`/tasks/${taskId}`);
|
||||
}
|
||||
|
||||
@@ -188,6 +240,7 @@ export class TodoistConnector extends BaseConnector {
|
||||
* Get all projects
|
||||
*/
|
||||
async getProjects(): Promise<TodoistProject[]> {
|
||||
await this.ensureInitialized();
|
||||
return this.get<TodoistProject[]>('/projects');
|
||||
}
|
||||
|
||||
@@ -195,6 +248,7 @@ export class TodoistConnector extends BaseConnector {
|
||||
* Get a single project by ID
|
||||
*/
|
||||
async getProject(projectId: string): Promise<TodoistProject> {
|
||||
await this.ensureInitialized();
|
||||
return this.get<TodoistProject>(`/projects/${projectId}`);
|
||||
}
|
||||
|
||||
@@ -204,6 +258,7 @@ export class TodoistConnector extends BaseConnector {
|
||||
async createProject(
|
||||
request: TodoistCreateProjectRequest,
|
||||
): Promise<TodoistProject> {
|
||||
await this.ensureInitialized();
|
||||
const requestId = this.generateRequestId();
|
||||
|
||||
return this.post<TodoistProject>('/projects', request, {
|
||||
@@ -220,6 +275,7 @@ export class TodoistConnector extends BaseConnector {
|
||||
projectId: string,
|
||||
request: TodoistUpdateProjectRequest,
|
||||
): Promise<TodoistProject> {
|
||||
await this.ensureInitialized();
|
||||
const requestId = this.generateRequestId();
|
||||
|
||||
return this.post<TodoistProject>(`/projects/${projectId}`, request, {
|
||||
@@ -233,6 +289,7 @@ export class TodoistConnector extends BaseConnector {
|
||||
* Delete a project
|
||||
*/
|
||||
async deleteProject(projectId: string): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
await this.delete<void>(`/projects/${projectId}`);
|
||||
}
|
||||
|
||||
@@ -244,6 +301,7 @@ export class TodoistConnector extends BaseConnector {
|
||||
* Get all sections (optionally filtered by project)
|
||||
*/
|
||||
async getSections(projectId?: string): Promise<TodoistSection[]> {
|
||||
await this.ensureInitialized();
|
||||
const params = projectId ? { project_id: projectId } : {};
|
||||
return this.get<TodoistSection[]>('/sections', { params });
|
||||
}
|
||||
@@ -252,6 +310,7 @@ export class TodoistConnector extends BaseConnector {
|
||||
* Get a single section by ID
|
||||
*/
|
||||
async getSection(sectionId: string): Promise<TodoistSection> {
|
||||
await this.ensureInitialized();
|
||||
return this.get<TodoistSection>(`/sections/${sectionId}`);
|
||||
}
|
||||
|
||||
@@ -261,6 +320,7 @@ export class TodoistConnector extends BaseConnector {
|
||||
async createSection(
|
||||
request: TodoistCreateSectionRequest,
|
||||
): Promise<TodoistSection> {
|
||||
await this.ensureInitialized();
|
||||
const requestId = this.generateRequestId();
|
||||
|
||||
return this.post<TodoistSection>('/sections', request, {
|
||||
@@ -274,6 +334,7 @@ export class TodoistConnector extends BaseConnector {
|
||||
* Delete a section
|
||||
*/
|
||||
async deleteSection(sectionId: string): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
await this.delete<void>(`/sections/${sectionId}`);
|
||||
}
|
||||
|
||||
@@ -285,6 +346,7 @@ export class TodoistConnector extends BaseConnector {
|
||||
* Get all personal labels
|
||||
*/
|
||||
async getLabels(): Promise<TodoistLabel[]> {
|
||||
await this.ensureInitialized();
|
||||
return this.get<TodoistLabel[]>('/labels');
|
||||
}
|
||||
|
||||
@@ -292,6 +354,7 @@ export class TodoistConnector extends BaseConnector {
|
||||
* Get a single label by ID
|
||||
*/
|
||||
async getLabel(labelId: string): Promise<TodoistLabel> {
|
||||
await this.ensureInitialized();
|
||||
return this.get<TodoistLabel>(`/labels/${labelId}`);
|
||||
}
|
||||
|
||||
@@ -299,6 +362,7 @@ export class TodoistConnector extends BaseConnector {
|
||||
* Create a new label
|
||||
*/
|
||||
async createLabel(request: TodoistCreateLabelRequest): Promise<TodoistLabel> {
|
||||
await this.ensureInitialized();
|
||||
const requestId = this.generateRequestId();
|
||||
|
||||
return this.post<TodoistLabel>('/labels', request, {
|
||||
@@ -312,6 +376,7 @@ export class TodoistConnector extends BaseConnector {
|
||||
* Delete a label
|
||||
*/
|
||||
async deleteLabel(labelId: string): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
await this.delete<void>(`/labels/${labelId}`);
|
||||
}
|
||||
|
||||
@@ -326,6 +391,7 @@ export class TodoistConnector extends BaseConnector {
|
||||
taskId?: string,
|
||||
projectId?: string,
|
||||
): Promise<TodoistComment[]> {
|
||||
await this.ensureInitialized();
|
||||
const params: Record<string, string> = {};
|
||||
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<TodoistComment> {
|
||||
await this.ensureInitialized();
|
||||
const requestId = this.generateRequestId();
|
||||
|
||||
return this.post<TodoistComment>('/comments', request, {
|
||||
@@ -352,6 +419,7 @@ export class TodoistConnector extends BaseConnector {
|
||||
* Delete a comment
|
||||
*/
|
||||
async deleteComment(commentId: string): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
await this.delete<void>(`/comments/${commentId}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<string>('ZULIP_BASE_URL') || '';
|
||||
|
||||
super({
|
||||
baseUrl: baseUrl ? `${baseUrl}/api/v1` : '',
|
||||
timeout: 30000,
|
||||
maxRetries: 3,
|
||||
});
|
||||
|
||||
this.authConfig = {
|
||||
baseUrl,
|
||||
email: configService.get<string>('ZULIP_EMAIL') || '',
|
||||
apiKey: configService.get<string>('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<void> {
|
||||
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<void> {
|
||||
this.initialized = false;
|
||||
await this.ensureInitialized();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle credential change events
|
||||
*/
|
||||
@OnEvent('credentials.changed')
|
||||
async onCredentialsChanged(payload: { type: string }): Promise<void> {
|
||||
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<ConnectionTestResult> {
|
||||
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<Record<string, string>> {
|
||||
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<ZulipGetMessagesResponse> {
|
||||
await this.ensureInitialized();
|
||||
const params = this.buildMessagesParams(request);
|
||||
return this.get<ZulipGetMessagesResponse>('/messages', { params });
|
||||
}
|
||||
@@ -233,6 +281,7 @@ export class ZulipConnector extends BaseConnector {
|
||||
async sendMessage(
|
||||
request: ZulipSendMessageRequest,
|
||||
): Promise<ZulipSendMessageResponse> {
|
||||
await this.ensureInitialized();
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('type', request.type);
|
||||
|
||||
@@ -309,6 +358,7 @@ export class ZulipConnector extends BaseConnector {
|
||||
includeOwnerSubscribed?: boolean;
|
||||
} = {},
|
||||
): Promise<ZulipStream[]> {
|
||||
await this.ensureInitialized();
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
if (options.includePublic !== undefined) {
|
||||
@@ -338,6 +388,7 @@ export class ZulipConnector extends BaseConnector {
|
||||
* Get subscribed streams
|
||||
*/
|
||||
async getSubscriptions(): Promise<ZulipStreamSubscription[]> {
|
||||
await this.ensureInitialized();
|
||||
const response = await this.get<ZulipGetSubscriptionsResponse>(
|
||||
'/users/me/subscriptions',
|
||||
);
|
||||
@@ -350,6 +401,7 @@ export class ZulipConnector extends BaseConnector {
|
||||
async subscribe(
|
||||
request: ZulipSubscribeRequest,
|
||||
): Promise<ZulipSubscribeResponse> {
|
||||
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<ZulipUser[]> {
|
||||
await this.ensureInitialized();
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
if (options.clientGravatar !== undefined) {
|
||||
@@ -428,14 +482,15 @@ export class ZulipConnector extends BaseConnector {
|
||||
* Get current user profile
|
||||
*/
|
||||
async getCurrentUser(): Promise<ZulipUser> {
|
||||
const response = await this.get<ZulipUser>('/users/me');
|
||||
return response;
|
||||
await this.ensureInitialized();
|
||||
return this.get<ZulipUser>('/users/me');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific user by ID
|
||||
*/
|
||||
async getUser(userId: number): Promise<ZulipUser> {
|
||||
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<ZulipUser> {
|
||||
await this.ensureInitialized();
|
||||
const response = await this.get<{ user: ZulipUser }>(
|
||||
`/users/${encodeURIComponent(email)}`,
|
||||
);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<string, string> } | 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<string, string> };
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to decrypt credentials for type ${type}: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the sync status of a credential
|
||||
*/
|
||||
|
||||
@@ -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<string, unknown> }> };
|
||||
connector: { getMissingConfig?: () => string[] };
|
||||
connector: { ensureInitialized: () => Promise<void>; 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<void>; getMissingConfig?: () => string[] },
|
||||
};
|
||||
case 'nextcloud':
|
||||
return {
|
||||
service: this.nextcloudService,
|
||||
connector: this.nextcloudConnector as { getMissingConfig?: () => string[] },
|
||||
connector: this.nextcloudConnector as { ensureInitialized: () => Promise<void>; getMissingConfig?: () => string[] },
|
||||
};
|
||||
case 'ecodms':
|
||||
return {
|
||||
service: this.ecodmsService,
|
||||
connector: this.ecodmsConnector as { getMissingConfig?: () => string[] },
|
||||
connector: this.ecodmsConnector as { ensureInitialized: () => Promise<void>; getMissingConfig?: () => string[] },
|
||||
};
|
||||
case 'gembadocs':
|
||||
return {
|
||||
|
||||
1
apps/api/src/modules/system-settings/dto/index.ts
Normal file
1
apps/api/src/modules/system-settings/dto/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { UpdateSettingDto, BulkUpdateSettingsDto } from './update-setting.dto';
|
||||
@@ -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;
|
||||
}>;
|
||||
}
|
||||
3
apps/api/src/modules/system-settings/index.ts
Normal file
3
apps/api/src/modules/system-settings/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { SystemSettingsModule } from './system-settings.module';
|
||||
export { SystemSettingsService } from './system-settings.service';
|
||||
export { UpdateSettingDto, BulkUpdateSettingsDto } from './dto';
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
269
apps/api/src/modules/system-settings/system-settings.service.ts
Normal file
269
apps/api/src/modules/system-settings/system-settings.service.ts
Normal file
@@ -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<SystemSetting, 'value'> {
|
||||
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<string, CacheEntry>();
|
||||
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<SettingResponse> {
|
||||
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<string | null> {
|
||||
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<T>(key: string, defaultValue: T): Promise<T> {
|
||||
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<SettingResponse> {
|
||||
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<SettingResponse[]> {
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user