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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,8 @@
|
||||
"departments": "Abteilungen",
|
||||
"overview": "Uebersicht",
|
||||
"plentyOne": "PlentyONE",
|
||||
"zulip": "ZULIP"
|
||||
"zulip": "ZULIP",
|
||||
"systemSettings": "Einstellungen"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -1048,5 +1049,29 @@
|
||||
"minutes": "Minuten",
|
||||
"seconds": "Sekunden",
|
||||
"days": "Tage"
|
||||
},
|
||||
"systemSettings": {
|
||||
"title": "Systemeinstellungen",
|
||||
"description": "Globale Anwendungskonfiguration verwalten",
|
||||
"general": "Allgemein",
|
||||
"cors": "CORS",
|
||||
"sync": "Synchronisation",
|
||||
"features": "Features",
|
||||
"branding": "Branding",
|
||||
"appName": "Anwendungsname",
|
||||
"companyName": "Firmenname",
|
||||
"logoUrl": "Logo-URL",
|
||||
"corsOrigins": "Erlaubte Origins",
|
||||
"corsOriginsDesc": "Kommagetrennte Liste von erlaubten Origins fuer Cross-Origin-Anfragen",
|
||||
"syncInterval": "Sync-Intervall",
|
||||
"minutes": "Minuten",
|
||||
"enableSyncJobs": "Hintergrund-Sync-Jobs",
|
||||
"enableSyncJobsDesc": "Automatische Synchronisation der Integrationen im Hintergrund",
|
||||
"enableSwagger": "Swagger API-Dokumentation",
|
||||
"enableSwaggerDesc": "Interaktive API-Dokumentation unter /api/docs verfuegbar (Neustart erforderlich)",
|
||||
"saved": "Einstellungen gespeichert",
|
||||
"saveError": "Fehler beim Speichern der Einstellungen",
|
||||
"requiresRestart": "Aenderung erfordert einen Neustart des Backends",
|
||||
"save": "Speichern"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,8 @@
|
||||
"departments": "Departments",
|
||||
"overview": "Overview",
|
||||
"plentyOne": "PlentyONE",
|
||||
"zulip": "ZULIP"
|
||||
"zulip": "ZULIP",
|
||||
"systemSettings": "Settings"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -1048,5 +1049,29 @@
|
||||
"minutes": "minutes",
|
||||
"seconds": "seconds",
|
||||
"days": "days"
|
||||
},
|
||||
"systemSettings": {
|
||||
"title": "System Settings",
|
||||
"description": "Manage global application configuration",
|
||||
"general": "General",
|
||||
"cors": "CORS",
|
||||
"sync": "Synchronization",
|
||||
"features": "Features",
|
||||
"branding": "Branding",
|
||||
"appName": "Application Name",
|
||||
"companyName": "Company Name",
|
||||
"logoUrl": "Logo URL",
|
||||
"corsOrigins": "Allowed Origins",
|
||||
"corsOriginsDesc": "Comma-separated list of allowed origins for cross-origin requests",
|
||||
"syncInterval": "Sync Interval",
|
||||
"minutes": "minutes",
|
||||
"enableSyncJobs": "Background Sync Jobs",
|
||||
"enableSyncJobsDesc": "Automatic synchronization of integrations in the background",
|
||||
"enableSwagger": "Swagger API Documentation",
|
||||
"enableSwaggerDesc": "Interactive API documentation available at /api/docs (restart required)",
|
||||
"saved": "Settings saved",
|
||||
"saveError": "Failed to save settings",
|
||||
"requiresRestart": "Change requires a backend restart",
|
||||
"save": "Save"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Plug,
|
||||
Shield,
|
||||
Building2,
|
||||
MessageSquare,
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
Settings,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Loader2,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -41,8 +41,16 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { IntegrationStatusBadge, ConnectionTestButton } from '@/components/integrations';
|
||||
import { IntegrationStatusBadge } from '@/components/integrations';
|
||||
import { useAllIntegrationStatuses } from '@/hooks/integrations';
|
||||
import {
|
||||
useCredentials,
|
||||
useCredentialDetail,
|
||||
useCreateCredential,
|
||||
useUpdateCredential,
|
||||
useTestCredentialConnection,
|
||||
} from '@/hooks/integrations/use-credentials';
|
||||
import { useUpdateSetting } from '@/hooks/use-system-settings';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { IntegrationType } from '@/types/integrations';
|
||||
|
||||
@@ -50,7 +58,11 @@ interface AdminIntegrationsContentProps {
|
||||
locale: string;
|
||||
}
|
||||
|
||||
/** Integration metadata */
|
||||
// ============================================================
|
||||
// Static configuration
|
||||
// ============================================================
|
||||
|
||||
/** Display metadata per integration */
|
||||
const integrationMeta: Record<
|
||||
IntegrationType,
|
||||
{ icon: LucideIcon; nameKey: string; descKey: string }
|
||||
@@ -64,77 +76,394 @@ const integrationMeta: Record<
|
||||
gembadocs: { icon: ClipboardCheck, nameKey: 'gembaDocs', descKey: 'gembaDocsDesc' },
|
||||
};
|
||||
|
||||
/** Credential field configuration per integration */
|
||||
const credentialFields: Record<IntegrationType, { key: string; labelKey: string; type: 'text' | 'password' | 'url' }[]> = {
|
||||
/** Credential field definitions per integration.
|
||||
* Keys must match what the backend validates (credentials.service.ts). */
|
||||
const credentialFields: Record<
|
||||
IntegrationType,
|
||||
Array<{ key: string; label: string; type: 'text' | 'password' | 'url'; placeholder?: string }>
|
||||
> = {
|
||||
plentyone: [
|
||||
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
|
||||
{ key: 'username', labelKey: 'username', type: 'text' },
|
||||
{ key: 'password', labelKey: 'password', type: 'password' },
|
||||
{ key: 'apiUrl', label: 'API URL', type: 'url', placeholder: 'https://your-shop.plentymarkets-cloud01.com' },
|
||||
{ key: 'apiKey', label: 'API Schluessel', type: 'password' },
|
||||
],
|
||||
zulip: [
|
||||
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
|
||||
{ key: 'email', labelKey: 'username', type: 'text' },
|
||||
{ key: 'apiKey', labelKey: 'apiKey', type: 'password' },
|
||||
{ key: 'zulipUrl', label: 'Server URL', type: 'url', placeholder: 'https://your-org.zulipchat.com' },
|
||||
{ key: 'botEmail', label: 'Bot E-Mail', type: 'text' },
|
||||
{ key: 'apiKey', label: 'API Schluessel', type: 'password' },
|
||||
],
|
||||
todoist: [
|
||||
{ key: 'apiKey', labelKey: 'apiKey', type: 'password' },
|
||||
{ key: 'apiToken', label: 'API Token', type: 'password' },
|
||||
],
|
||||
freescout: [
|
||||
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
|
||||
{ key: 'apiKey', labelKey: 'apiKey', type: 'password' },
|
||||
{ key: 'apiUrl', label: 'API URL', type: 'url', placeholder: 'https://helpdesk.example.com' },
|
||||
{ key: 'apiKey', label: 'API Schluessel', type: 'password' },
|
||||
],
|
||||
nextcloud: [
|
||||
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
|
||||
{ key: 'username', labelKey: 'username', type: 'text' },
|
||||
{ key: 'password', labelKey: 'password', type: 'password' },
|
||||
{ key: 'serverUrl', label: 'Server URL', type: 'url', placeholder: 'https://cloud.example.com' },
|
||||
{ key: 'username', label: 'Benutzername', type: 'text' },
|
||||
{ key: 'password', label: 'Passwort', type: 'password' },
|
||||
],
|
||||
ecodms: [
|
||||
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
|
||||
{ key: 'apiKey', labelKey: 'apiKey', type: 'password' },
|
||||
{ key: 'apiUrl', label: 'API URL', type: 'url', placeholder: 'https://ecodms.example.com' },
|
||||
{ key: 'username', label: 'Benutzername', type: 'text' },
|
||||
{ key: 'password', label: 'Passwort', type: 'password' },
|
||||
],
|
||||
gembadocs: [
|
||||
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
|
||||
{ key: 'apiKey', labelKey: 'apiKey', type: 'password' },
|
||||
{ key: 'apiUrl', label: 'API URL', type: 'url', placeholder: 'https://gembadocs.example.com' },
|
||||
{ key: 'apiKey', label: 'API Schluessel', type: 'password' },
|
||||
],
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Sub-component: single integration tab panel
|
||||
// ============================================================
|
||||
|
||||
interface IntegrationPanelProps {
|
||||
integrationType: IntegrationType;
|
||||
}
|
||||
|
||||
function IntegrationPanel({ integrationType }: IntegrationPanelProps) {
|
||||
const t = useTranslations('integrations');
|
||||
const { toast } = useToast();
|
||||
|
||||
const meta = integrationMeta[integrationType];
|
||||
const Icon = meta.icon;
|
||||
const fields = credentialFields[integrationType];
|
||||
|
||||
// ---- API queries / mutations ----
|
||||
const { data: allCredentials } = useCredentials();
|
||||
const createCredential = useCreateCredential();
|
||||
const updateCredential = useUpdateCredential();
|
||||
const testConnection = useTestCredentialConnection();
|
||||
const updateSetting = useUpdateSetting();
|
||||
|
||||
// Find the saved credential for this integration type (UPPERCASE match)
|
||||
const savedCredential = allCredentials?.data.find(
|
||||
(c) => c.type === integrationType.toUpperCase(),
|
||||
) ?? null;
|
||||
|
||||
// Fetch decrypted values only when a credential exists
|
||||
const { data: credentialDetail, isLoading: isLoadingDetail } =
|
||||
useCredentialDetail(savedCredential?.id ?? null);
|
||||
|
||||
// ---- Local UI state ----
|
||||
const [formData, setFormData] = useState<Record<string, string>>({});
|
||||
const [isEnabled, setIsEnabled] = useState<boolean>(
|
||||
savedCredential?.isActive ?? false,
|
||||
);
|
||||
const [visiblePasswords, setVisiblePasswords] = useState<Record<string, boolean>>({});
|
||||
const [syncInterval, setSyncInterval] = useState<string>('15');
|
||||
|
||||
// Pre-fill form fields whenever decrypted credential values arrive
|
||||
useEffect(() => {
|
||||
if (credentialDetail?.credentials) {
|
||||
setFormData(
|
||||
Object.fromEntries(
|
||||
Object.entries(credentialDetail.credentials).map(([k, v]) => [k, String(v)]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [credentialDetail]);
|
||||
|
||||
// Keep enabled toggle in sync with stored value
|
||||
useEffect(() => {
|
||||
if (savedCredential !== null) {
|
||||
setIsEnabled(savedCredential.isActive);
|
||||
}
|
||||
}, [savedCredential]);
|
||||
|
||||
// ---- Handlers ----
|
||||
const handleFieldChange = (key: string, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const togglePassword = (fieldId: string) => {
|
||||
setVisiblePasswords((prev) => ({ ...prev, [fieldId]: !prev[fieldId] }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const integrationName = t(meta.nameKey as never);
|
||||
|
||||
if (!savedCredential) {
|
||||
// Create new credential
|
||||
await createCredential.mutateAsync({
|
||||
type: integrationType.toUpperCase(),
|
||||
name: 'Default',
|
||||
credentials: formData,
|
||||
});
|
||||
} else {
|
||||
// Update existing credential
|
||||
await updateCredential.mutateAsync({
|
||||
id: savedCredential.id,
|
||||
credentials: formData,
|
||||
});
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t('saveSettings'),
|
||||
description: t('settingsSaved' as never, { name: integrationName }),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fehler beim Speichern',
|
||||
description: error instanceof Error ? error.message : 'Unbekannter Fehler',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (checked: boolean) => {
|
||||
setIsEnabled(checked);
|
||||
|
||||
if (!savedCredential) return; // Nothing to update yet
|
||||
|
||||
try {
|
||||
await updateCredential.mutateAsync({
|
||||
id: savedCredential.id,
|
||||
isActive: checked,
|
||||
});
|
||||
toast({
|
||||
title: checked ? 'Integration aktiviert' : 'Integration deaktiviert',
|
||||
description: `${t(meta.nameKey as never)} wurde ${checked ? 'aktiviert' : 'deaktiviert'}.`,
|
||||
});
|
||||
} catch (error) {
|
||||
// Revert on failure
|
||||
setIsEnabled(!checked);
|
||||
toast({
|
||||
title: 'Fehler',
|
||||
description: error instanceof Error ? error.message : 'Unbekannter Fehler',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!savedCredential) return;
|
||||
|
||||
try {
|
||||
const result = await testConnection.mutateAsync(savedCredential.id);
|
||||
toast({
|
||||
title: result.success ? t('testSuccess') : t('testFailed'),
|
||||
description: result.message,
|
||||
variant: result.success ? 'default' : 'destructive',
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('testFailed'),
|
||||
description: error instanceof Error ? error.message : t('testError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncIntervalChange = async (value: string) => {
|
||||
setSyncInterval(value);
|
||||
|
||||
try {
|
||||
await updateSetting.mutateAsync({
|
||||
key: `sync.interval.${integrationType}`,
|
||||
value,
|
||||
});
|
||||
} catch {
|
||||
// Non-critical; sync interval is a best-effort setting
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Loading skeleton while fetching decrypted values ----
|
||||
if (savedCredential && isLoadingDetail) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{fields.map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const isSaving =
|
||||
createCredential.isPending || updateCredential.isPending;
|
||||
const isTesting = testConnection.isPending;
|
||||
const hasCredential = !!savedCredential;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
{/* Left: icon + title */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-10 w-10 items-center justify-center rounded-lg',
|
||||
isEnabled
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{t(meta.nameKey as never)}
|
||||
{/* Show status badge only when a credential is saved */}
|
||||
{hasCredential && (
|
||||
<IntegrationStatusBadge
|
||||
status={
|
||||
savedCredential.syncStatus === 'SUCCESS'
|
||||
? 'connected'
|
||||
: savedCredential.syncStatus === 'ERROR'
|
||||
? 'error'
|
||||
: 'disconnected'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>{t(meta.descKey as never)}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: enable/disable toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`enable-${integrationType}`}>
|
||||
{isEnabled ? t('disable') : t('enable')}
|
||||
</Label>
|
||||
<Switch
|
||||
id={`enable-${integrationType}`}
|
||||
checked={isEnabled}
|
||||
onCheckedChange={handleToggleEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Credentials section */}
|
||||
<div>
|
||||
<h3 className="mb-4 text-lg font-medium">{t('credentials')}</h3>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{fields.map((field) => {
|
||||
const fieldId = `${integrationType}-${field.key}`;
|
||||
const isPassword = field.type === 'password';
|
||||
const isVisible = visiblePasswords[fieldId];
|
||||
|
||||
return (
|
||||
<div key={field.key} className="space-y-2">
|
||||
<Label htmlFor={fieldId}>{field.label}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={fieldId}
|
||||
type={isPassword && !isVisible ? 'password' : 'text'}
|
||||
placeholder={field.placeholder ?? (isPassword ? '••••••••' : '')}
|
||||
value={formData[field.key] ?? ''}
|
||||
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||
className={cn(isPassword && 'pr-10')}
|
||||
/>
|
||||
{isPassword && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full px-3"
|
||||
onClick={() => togglePassword(fieldId)}
|
||||
>
|
||||
{isVisible ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync settings section */}
|
||||
<div>
|
||||
<h3 className="mb-4 text-lg font-medium">{t('synchronization' as never)}</h3>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`sync-interval-${integrationType}`}>
|
||||
{t('syncInterval')}
|
||||
</Label>
|
||||
<Select value={syncInterval} onValueChange={handleSyncIntervalChange}>
|
||||
<SelectTrigger id={`sync-interval-${integrationType}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1 {t('minutes')}</SelectItem>
|
||||
<SelectItem value="5">5 {t('minutes')}</SelectItem>
|
||||
<SelectItem value="15">15 {t('minutes')}</SelectItem>
|
||||
<SelectItem value="30">30 {t('minutes')}</SelectItem>
|
||||
<SelectItem value="60">60 {t('minutes')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex justify-between border-t pt-6">
|
||||
{/* Connection test button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTestConnection}
|
||||
disabled={!hasCredential || isTesting}
|
||||
>
|
||||
{isTesting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('testing')}
|
||||
</>
|
||||
) : (
|
||||
t('test')
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Save button */}
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Speichern...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
{t('saveSettings')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Main page component
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Admin integrations management content
|
||||
* Admin integrations management page.
|
||||
* Lists each integration as a tab; each tab loads its own credential
|
||||
* state so queries are isolated and only triggered when the tab is visited.
|
||||
*/
|
||||
export function AdminIntegrationsContent({ locale }: AdminIntegrationsContentProps) {
|
||||
export function AdminIntegrationsContent({ locale: _locale }: AdminIntegrationsContentProps) {
|
||||
const t = useTranslations('integrations');
|
||||
const tAdmin = useTranslations('admin');
|
||||
const { toast } = useToast();
|
||||
const { data: integrations, isLoading } = useAllIntegrationStatuses();
|
||||
|
||||
const [visiblePasswords, setVisiblePasswords] = useState<Record<string, boolean>>({});
|
||||
const [enabledState, setEnabledState] = useState<Record<string, boolean>>({});
|
||||
const [formData, setFormData] = useState<Record<string, Record<string, string>>>({});
|
||||
|
||||
const togglePasswordVisibility = (fieldKey: string) => {
|
||||
setVisiblePasswords((prev) => ({
|
||||
...prev,
|
||||
[fieldKey]: !prev[fieldKey],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleFieldChange = (integrationType: IntegrationType, fieldKey: string, value: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[integrationType]: {
|
||||
...prev[integrationType],
|
||||
[fieldKey]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = (integrationType: IntegrationType) => {
|
||||
toast({
|
||||
title: t('saveSettings'),
|
||||
description: t('settingsSaved' as never, { name: t(integrationMeta[integrationType].nameKey as never) }),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-8 py-6">
|
||||
{/* Header */}
|
||||
@@ -143,14 +472,16 @@ export function AdminIntegrationsContent({ locale }: AdminIntegrationsContentPro
|
||||
<Shield className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{tAdmin('integrationManagement')}</h1>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{tAdmin('integrationManagement')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{tAdmin('integrationManagementDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Integration Tabs */}
|
||||
{/* Loading state */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
@@ -184,139 +515,17 @@ export function AdminIntegrationsContent({ locale }: AdminIntegrationsContentPro
|
||||
})}
|
||||
</TabsList>
|
||||
|
||||
{integrations?.map((config) => {
|
||||
const meta = integrationMeta[config.type];
|
||||
const Icon = meta.icon;
|
||||
const fields = credentialFields[config.type];
|
||||
|
||||
return (
|
||||
<TabsContent key={config.type} value={config.type}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-10 w-10 items-center justify-center rounded-lg',
|
||||
(enabledState[config.type] ?? config.enabled) ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{t(meta.nameKey as never)}
|
||||
<IntegrationStatusBadge status={config.status} />
|
||||
</CardTitle>
|
||||
<CardDescription>{t(meta.descKey as never)}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`enable-${config.type}`}>
|
||||
{(enabledState[config.type] ?? config.enabled) ? t('disable') : t('enable')}
|
||||
</Label>
|
||||
<Switch
|
||||
id={`enable-${config.type}`}
|
||||
checked={enabledState[config.type] ?? config.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setEnabledState((prev) => ({ ...prev, [config.type]: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Credentials */}
|
||||
<div>
|
||||
<h3 className="mb-4 text-lg font-medium">{t('credentials')}</h3>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{fields.map((field) => {
|
||||
const fieldId = `${config.type}-${field.key}`;
|
||||
const isPassword = field.type === 'password';
|
||||
const isVisible = visiblePasswords[fieldId];
|
||||
|
||||
return (
|
||||
<div key={field.key} className="space-y-2">
|
||||
<Label htmlFor={fieldId}>{t(field.labelKey as never)}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={fieldId}
|
||||
type={isPassword && !isVisible ? 'password' : 'text'}
|
||||
placeholder={isPassword ? '********' : ''}
|
||||
value={formData[config.type]?.[field.key] ?? ''}
|
||||
onChange={(e) =>
|
||||
handleFieldChange(config.type, field.key, e.target.value)
|
||||
}
|
||||
className={cn(isPassword && 'pr-10')}
|
||||
/>
|
||||
{isPassword && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full px-3"
|
||||
onClick={() => togglePasswordVisibility(fieldId)}
|
||||
>
|
||||
{isVisible ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync Settings */}
|
||||
<div>
|
||||
<h3 className="mb-4 text-lg font-medium">{t('synchronization' as never)}</h3>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`sync-interval-${config.type}`}>
|
||||
{t('syncInterval')}
|
||||
</Label>
|
||||
<Select defaultValue={config.syncInterval.toString()}>
|
||||
<SelectTrigger id={`sync-interval-${config.type}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1 {t('minutes')}</SelectItem>
|
||||
<SelectItem value="5">5 {t('minutes')}</SelectItem>
|
||||
<SelectItem value="15">15 {t('minutes')}</SelectItem>
|
||||
<SelectItem value="30">30 {t('minutes')}</SelectItem>
|
||||
<SelectItem value="60">60 {t('minutes')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex justify-between border-t pt-6">
|
||||
<ConnectionTestButton integrationType={config.type} />
|
||||
<Button onClick={() => handleSave(config.type)}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
{t('saveSettings')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
{integrations?.map((config) => (
|
||||
<TabsContent key={config.type} value={config.type}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<IntegrationPanel integrationType={config.type} />
|
||||
</motion.div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
|
||||
20
apps/web/src/app/[locale]/(auth)/admin/settings/page.tsx
Normal file
20
apps/web/src/app/[locale]/(auth)/admin/settings/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { SystemSettingsContent } from './system-settings-content';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Systemeinstellungen | Admin | tOS',
|
||||
description: 'Globale Anwendungskonfiguration verwalten',
|
||||
};
|
||||
|
||||
interface SystemSettingsPageProps {
|
||||
params: {
|
||||
locale: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin page for managing global system settings
|
||||
*/
|
||||
export default function SystemSettingsPage({ params }: SystemSettingsPageProps) {
|
||||
return <SystemSettingsContent locale={params.locale} />;
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Settings,
|
||||
Globe,
|
||||
RefreshCw,
|
||||
Zap,
|
||||
Building2,
|
||||
Save,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
useSystemSettings,
|
||||
useUpdateSetting,
|
||||
useBulkUpdateSettings,
|
||||
type SystemSetting,
|
||||
} from '@/hooks/use-system-settings';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SystemSettingsContentProps {
|
||||
locale: string;
|
||||
}
|
||||
|
||||
/** Integration types that appear in sync settings */
|
||||
const INTEGRATION_TYPES = [
|
||||
'plentyone',
|
||||
'zulip',
|
||||
'todoist',
|
||||
'freescout',
|
||||
'nextcloud',
|
||||
'ecodms',
|
||||
'gembadocs',
|
||||
] as const;
|
||||
|
||||
const SYNC_INTERVAL_OPTIONS = ['1', '5', '10', '15', '30', '60'] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: build a lookup map from the flat settings array
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildSettingsMap(settings: SystemSetting[]): Record<string, string> {
|
||||
return settings.reduce<Record<string, string>>((acc, s) => {
|
||||
acc[s.key] = s.value;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loading skeleton
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SettingsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-10 w-96" />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Admin page for managing global system settings.
|
||||
* Groups settings by category into tabs: Branding, CORS, Sync, Features.
|
||||
*/
|
||||
export function SystemSettingsContent({ locale: _locale }: SystemSettingsContentProps) {
|
||||
const t = useTranslations('systemSettings');
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data, isLoading } = useSystemSettings();
|
||||
const updateSetting = useUpdateSetting();
|
||||
const bulkUpdate = useBulkUpdateSettings();
|
||||
|
||||
// Local form state keyed by setting key
|
||||
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
||||
|
||||
// Populate form values once data is loaded
|
||||
useEffect(() => {
|
||||
if (data?.settings) {
|
||||
setFormValues(buildSettingsMap(data.settings));
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Generic helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function handleChange(key: string, value: string) {
|
||||
setFormValues((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
async function handleBulkSave(keys: string[]) {
|
||||
const settings = keys.map((key) => ({ key, value: formValues[key] ?? '' }));
|
||||
|
||||
try {
|
||||
await bulkUpdate.mutateAsync({ settings });
|
||||
toast({ title: t('saved') });
|
||||
} catch {
|
||||
toast({ title: t('saveError'), variant: 'destructive' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSingleToggle(key: string, checked: boolean) {
|
||||
const value = checked ? 'true' : 'false';
|
||||
handleChange(key, value);
|
||||
|
||||
try {
|
||||
await updateSetting.mutateAsync({ key, value });
|
||||
toast({ title: t('saved') });
|
||||
} catch {
|
||||
toast({ title: t('saveError'), variant: 'destructive' });
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Render
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-8 py-6">
|
||||
{/* Page header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Settings className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t('title')}</h1>
|
||||
<p className="text-muted-foreground">{t('description')}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<SettingsSkeleton />
|
||||
) : (
|
||||
<Tabs defaultValue="branding" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="branding" className="gap-2">
|
||||
<Building2 className="h-4 w-4" />
|
||||
{t('branding')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="cors" className="gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
{t('cors')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sync" className="gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{t('sync')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="features" className="gap-2">
|
||||
<Zap className="h-4 w-4" />
|
||||
{t('features')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Branding tab */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
<TabsContent value="branding">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('branding')}</CardTitle>
|
||||
<CardDescription>{t('description')}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* App name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="branding-appName">{t('appName')}</Label>
|
||||
<Input
|
||||
id="branding-appName"
|
||||
value={formValues['branding.appName'] ?? ''}
|
||||
onChange={(e) => handleChange('branding.appName', e.target.value)}
|
||||
placeholder="tOS"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Company name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="branding-companyName">{t('companyName')}</Label>
|
||||
<Input
|
||||
id="branding-companyName"
|
||||
value={formValues['branding.companyName'] ?? ''}
|
||||
onChange={(e) => handleChange('branding.companyName', e.target.value)}
|
||||
placeholder="Mein Unternehmen GmbH"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Logo URL */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="branding-logoUrl">{t('logoUrl')}</Label>
|
||||
<Input
|
||||
id="branding-logoUrl"
|
||||
type="url"
|
||||
value={formValues['branding.logoUrl'] ?? ''}
|
||||
onChange={(e) => handleChange('branding.logoUrl', e.target.value)}
|
||||
placeholder="https://example.com/logo.png"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleBulkSave([
|
||||
'branding.appName',
|
||||
'branding.companyName',
|
||||
'branding.logoUrl',
|
||||
])
|
||||
}
|
||||
disabled={bulkUpdate.isPending}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* CORS tab */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
<TabsContent value="cors">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('cors')}</CardTitle>
|
||||
<CardDescription>{t('corsOriginsDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cors-origins">{t('corsOrigins')}</Label>
|
||||
<Textarea
|
||||
id="cors-origins"
|
||||
rows={6}
|
||||
value={(formValues['cors.origins'] ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.join('\n')}
|
||||
onChange={(e) => {
|
||||
// Store as comma-separated
|
||||
const joined = e.target.value
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
handleChange('cors.origins', joined);
|
||||
}}
|
||||
placeholder="https://app.example.com http://localhost:3000"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Einen Origin pro Zeile eingeben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Restart notice */}
|
||||
<div className="flex items-center gap-2 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-700 dark:bg-amber-950/30 dark:text-amber-400">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
<span>{t('requiresRestart')}</span>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => handleBulkSave(['cors.origins'])}
|
||||
disabled={bulkUpdate.isPending}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Sync tab */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
<TabsContent value="sync">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('sync')}</CardTitle>
|
||||
<CardDescription>{t('enableSyncJobsDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{INTEGRATION_TYPES.map((type) => {
|
||||
const settingKey = `sync.interval.${type}`;
|
||||
const currentValue = formValues[settingKey] ?? '15';
|
||||
|
||||
return (
|
||||
<div key={type} className="space-y-2">
|
||||
<Label htmlFor={`sync-${type}`} className="capitalize">
|
||||
{type}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={currentValue}
|
||||
onValueChange={(val) => handleChange(settingKey, val)}
|
||||
>
|
||||
<SelectTrigger id={`sync-${type}`} className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SYNC_INTERVAL_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt} {t('minutes')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleBulkSave(
|
||||
INTEGRATION_TYPES.map((type) => `sync.interval.${type}`),
|
||||
)
|
||||
}
|
||||
disabled={bulkUpdate.isPending}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Features tab */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
<TabsContent value="features">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('features')}</CardTitle>
|
||||
<CardDescription>{t('description')}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Sync jobs toggle */}
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="feature-syncJobs" className="text-sm font-medium">
|
||||
{t('enableSyncJobs')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">{t('enableSyncJobsDesc')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="feature-syncJobs"
|
||||
checked={formValues['feature.syncJobs.enabled'] === 'true'}
|
||||
onCheckedChange={(checked) =>
|
||||
handleSingleToggle('feature.syncJobs.enabled', checked)
|
||||
}
|
||||
disabled={updateSetting.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Swagger toggle */}
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="feature-swagger" className="text-sm font-medium">
|
||||
{t('enableSwagger')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">{t('enableSwaggerDesc')}</p>
|
||||
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
<span>{t('requiresRestart')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="feature-swagger"
|
||||
checked={formValues['feature.swagger.enabled'] === 'true'}
|
||||
onCheckedChange={(checked) =>
|
||||
handleSingleToggle('feature.swagger.enabled', checked)
|
||||
}
|
||||
disabled={updateSetting.isPending}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -80,13 +80,29 @@ export function IntegrationDetailContent({
|
||||
const dateLocale = locale === 'de' ? de : enUS;
|
||||
|
||||
const meta = integrationMeta[integrationType];
|
||||
const Icon = meta?.icon ?? Building2;
|
||||
const ContentComponent = integrationContentMap[integrationType];
|
||||
|
||||
const handleSync = () => {
|
||||
triggerSync.mutate(integrationType);
|
||||
};
|
||||
|
||||
// Integration type not supported
|
||||
if (!meta) {
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 py-6">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/${locale}/integrations`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t('overview')}
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-muted-foreground">{t('notFound')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = meta.icon;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 py-6">
|
||||
@@ -105,13 +121,19 @@ export function IntegrationDetailContent({
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="container mx-auto py-6">
|
||||
<p className="text-muted-foreground">Integration nicht gefunden.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Use API data or fallback to disconnected state
|
||||
const displayConfig = config ?? {
|
||||
id: integrationType,
|
||||
type: integrationType,
|
||||
name: t(meta.nameKey as never),
|
||||
enabled: false,
|
||||
status: 'disconnected' as const,
|
||||
lastSync: undefined,
|
||||
lastError: undefined,
|
||||
syncInterval: 15,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 py-6">
|
||||
@@ -136,7 +158,7 @@ export function IntegrationDetailContent({
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-12 w-12 items-center justify-center rounded-lg',
|
||||
config.enabled ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
|
||||
displayConfig.enabled ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-6 w-6" />
|
||||
@@ -144,7 +166,7 @@ export function IntegrationDetailContent({
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-xl">{t(meta.nameKey as never)}</CardTitle>
|
||||
<IntegrationStatusBadge status={config.status} />
|
||||
<IntegrationStatusBadge status={displayConfig.status} />
|
||||
</div>
|
||||
<CardDescription>{t(meta.descKey as never)}</CardDescription>
|
||||
</div>
|
||||
@@ -156,7 +178,7 @@ export function IntegrationDetailContent({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSync}
|
||||
disabled={triggerSync.isPending || config.status !== 'connected'}
|
||||
disabled={triggerSync.isPending || displayConfig.status !== 'connected'}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn('mr-2 h-4 w-4', triggerSync.isPending && 'animate-spin')}
|
||||
@@ -169,9 +191,9 @@ export function IntegrationDetailContent({
|
||||
|
||||
<CardContent>
|
||||
<SyncStatus
|
||||
lastSync={config.lastSync}
|
||||
lastSync={displayConfig.lastSync}
|
||||
locale={locale}
|
||||
error={config.lastError}
|
||||
error={displayConfig.lastError}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -227,15 +249,15 @@ export function IntegrationDetailContent({
|
||||
<div className="flex items-center justify-between border-b py-2">
|
||||
<span className="text-muted-foreground">{t('syncSuccessful')}</span>
|
||||
<span>
|
||||
{config.lastSync
|
||||
? formatDistanceToNow(config.lastSync, { addSuffix: true, locale: dateLocale })
|
||||
{displayConfig.lastSync
|
||||
? formatDistanceToNow(displayConfig.lastSync, { addSuffix: true, locale: dateLocale })
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
{config.lastError && (
|
||||
{displayConfig.lastError && (
|
||||
<div className="rounded-lg bg-destructive/10 p-3 text-destructive">
|
||||
<p className="font-medium">{t('lastError')}</p>
|
||||
<p>{config.lastError}</p>
|
||||
<p>{displayConfig.lastError}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { de, enUS } from 'date-fns/locale';
|
||||
import { motion } from 'framer-motion';
|
||||
@@ -125,6 +126,11 @@ export function ActivityWidget({
|
||||
}: ActivityWidgetProps) {
|
||||
const t = useTranslations('widgets.activity');
|
||||
const dateLocale = locale === 'de' ? de : enUS;
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const displayedActivities = activities.slice(0, maxItems);
|
||||
|
||||
@@ -196,10 +202,12 @@ export function ActivityWidget({
|
||||
<p className="text-sm text-muted-foreground">{activity.description}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(activity.timestamp, {
|
||||
addSuffix: true,
|
||||
locale: dateLocale,
|
||||
})}
|
||||
{mounted
|
||||
? formatDistanceToNow(activity.timestamp, {
|
||||
addSuffix: true,
|
||||
locale: dateLocale,
|
||||
})
|
||||
: '\u00A0'}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -44,6 +44,7 @@ interface NavItem {
|
||||
icon: LucideIcon;
|
||||
requiredRoles?: UserRole[];
|
||||
children?: NavItem[];
|
||||
exactMatch?: boolean;
|
||||
}
|
||||
|
||||
/** Main navigation structure with role requirements */
|
||||
@@ -115,6 +116,7 @@ const mainNavItems: NavItem[] = [
|
||||
key: 'overview',
|
||||
href: '/integrations',
|
||||
icon: Plug,
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
key: 'plentyOne',
|
||||
@@ -152,6 +154,11 @@ const bottomNavItems: NavItem[] = [
|
||||
href: '/admin/integrations',
|
||||
icon: Plug,
|
||||
},
|
||||
{
|
||||
key: 'systemSettings',
|
||||
href: '/admin/settings',
|
||||
icon: Settings,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -209,14 +216,15 @@ export function Sidebar({ locale }: SidebarProps) {
|
||||
const filteredMainNav = filterNavItems(mainNavItems, userRoles);
|
||||
const filteredBottomNav = filterNavItems(bottomNavItems, userRoles);
|
||||
|
||||
const isActive = (href: string) => {
|
||||
const isActive = (href: string, exactMatch?: boolean) => {
|
||||
const localePath = `/${locale}${href}`;
|
||||
if (exactMatch) return pathname === localePath;
|
||||
return pathname === localePath || pathname.startsWith(`${localePath}/`);
|
||||
};
|
||||
|
||||
const isParentActive = (item: NavItem) => {
|
||||
if (isActive(item.href)) return true;
|
||||
return item.children?.some((child) => isActive(child.href)) || false;
|
||||
return item.children?.some((child) => isActive(child.href, child.exactMatch)) || false;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -307,7 +315,7 @@ interface NavItemComponentProps {
|
||||
item: NavItem;
|
||||
locale: string;
|
||||
isExpanded: boolean;
|
||||
isActive: (href: string) => boolean;
|
||||
isActive: (href: string, exactMatch?: boolean) => boolean;
|
||||
isParentActive: (item: NavItem) => boolean;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
@@ -321,7 +329,7 @@ function NavItemComponent({
|
||||
t,
|
||||
}: NavItemComponentProps) {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.href);
|
||||
const active = isActive(item.href, item.exactMatch);
|
||||
const parentActive = isParentActive(item);
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
|
||||
@@ -383,7 +391,7 @@ function NavItemComponent({
|
||||
href={`/${locale}${child.href}`}
|
||||
className={cn(
|
||||
'rounded px-2 py-1 text-sm transition-colors hover:bg-accent',
|
||||
isActive(child.href) && 'bg-accent font-medium'
|
||||
isActive(child.href, child.exactMatch) && 'bg-accent font-medium'
|
||||
)}
|
||||
>
|
||||
{t(child.key)}
|
||||
@@ -426,7 +434,7 @@ function NavItemComponent({
|
||||
>
|
||||
{item.children?.map((child) => {
|
||||
const ChildIcon = child.icon;
|
||||
const childActive = isActive(child.href);
|
||||
const childActive = isActive(child.href, child.exactMatch);
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
// Integration hooks barrel export
|
||||
export {
|
||||
useCredentials,
|
||||
useCredentialDetail,
|
||||
useCreateCredential,
|
||||
useUpdateCredential,
|
||||
useDeleteCredential,
|
||||
useTestCredentialConnection,
|
||||
credentialKeys,
|
||||
} from './use-credentials';
|
||||
export type {
|
||||
CredentialListItem,
|
||||
CredentialDetail,
|
||||
PaginatedCredentials,
|
||||
CreateCredentialPayload,
|
||||
UpdateCredentialPayload,
|
||||
TestConnectionResult,
|
||||
} from './use-credentials';
|
||||
|
||||
export {
|
||||
useIntegrationStatus,
|
||||
useAllIntegrationStatuses,
|
||||
|
||||
147
apps/web/src/hooks/integrations/use-credentials.ts
Normal file
147
apps/web/src/hooks/integrations/use-credentials.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
// ============================================================
|
||||
// Types matching backend DTOs and service response shapes
|
||||
// ============================================================
|
||||
|
||||
export interface CredentialListItem {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
lastUsed: string | null;
|
||||
lastSync: string | null;
|
||||
syncStatus: string;
|
||||
syncError: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CredentialDetail extends CredentialListItem {
|
||||
credentials: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface PaginatedCredentials {
|
||||
data: CredentialListItem[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateCredentialPayload {
|
||||
type: string;
|
||||
name: string;
|
||||
credentials: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface UpdateCredentialPayload {
|
||||
name?: string;
|
||||
credentials?: Record<string, string>;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface TestConnectionResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
latency?: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Query key factory
|
||||
// ============================================================
|
||||
|
||||
export const credentialKeys = {
|
||||
all: ['credentials'] as const,
|
||||
list: (type?: string) => [...credentialKeys.all, 'list', type] as const,
|
||||
detail: (id: string) => [...credentialKeys.all, 'detail', id] as const,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Hooks
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Fetch all credentials, optionally filtered by integration type.
|
||||
* Does NOT include decrypted credential secrets.
|
||||
*/
|
||||
export function useCredentials(type?: string) {
|
||||
return useQuery({
|
||||
queryKey: credentialKeys.list(type),
|
||||
queryFn: () =>
|
||||
api.get<PaginatedCredentials>('/integrations/credentials', {
|
||||
params: type
|
||||
? { type: type.toUpperCase(), limit: 100 }
|
||||
: { limit: 100 },
|
||||
}),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single credential by ID with decrypted secrets.
|
||||
* staleTime is 0 so the decrypted values are always fresh.
|
||||
*/
|
||||
export function useCredentialDetail(id: string | null) {
|
||||
return useQuery({
|
||||
queryKey: credentialKeys.detail(id!),
|
||||
queryFn: () =>
|
||||
api.get<CredentialDetail>(`/integrations/credentials/${id}`),
|
||||
enabled: !!id,
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a new integration credential. */
|
||||
export function useCreateCredential() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateCredentialPayload) =>
|
||||
api.post<CredentialListItem>('/integrations/credentials', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: credentialKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Update name, credentials payload, or isActive flag. */
|
||||
export function useUpdateCredential() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...data }: UpdateCredentialPayload & { id: string }) =>
|
||||
api.put<CredentialListItem>(`/integrations/credentials/${id}`, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: credentialKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Soft-delete (deactivate) a credential. */
|
||||
export function useDeleteCredential() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
api.delete<CredentialListItem>(`/integrations/credentials/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: credentialKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Test the connection for a saved credential by its ID. */
|
||||
export function useTestCredentialConnection() {
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
api.post<TestConnectionResult>(
|
||||
`/integrations/credentials/${id}/test`,
|
||||
),
|
||||
});
|
||||
}
|
||||
105
apps/web/src/hooks/use-system-settings.ts
Normal file
105
apps/web/src/hooks/use-system-settings.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SettingValueType = 'string' | 'number' | 'boolean' | 'json';
|
||||
|
||||
export interface SystemSetting {
|
||||
key: string;
|
||||
value: string;
|
||||
category: string;
|
||||
description: string;
|
||||
valueType: SettingValueType;
|
||||
isSecret: boolean;
|
||||
}
|
||||
|
||||
export interface SystemSettingsResponse {
|
||||
settings: SystemSetting[];
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
export interface UpdateSettingPayload {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface BulkUpdatePayload {
|
||||
settings: { key: string; value: string }[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query key factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const systemSettingsKeys = {
|
||||
all: ['system-settings'] as const,
|
||||
list: (category?: string) =>
|
||||
[...systemSettingsKeys.all, 'list', category] as const,
|
||||
detail: (key: string) => [...systemSettingsKeys.all, 'detail', key] as const,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fetch functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function fetchSystemSettings(category?: string): Promise<SystemSettingsResponse> {
|
||||
const params: Record<string, string | undefined> = {};
|
||||
if (category) params.category = category;
|
||||
return api.get<SystemSettingsResponse>('/system-settings', { params });
|
||||
}
|
||||
|
||||
async function updateSetting(key: string, value: string): Promise<SystemSetting> {
|
||||
return api.put<SystemSetting>(`/system-settings/${encodeURIComponent(key)}`, { value });
|
||||
}
|
||||
|
||||
async function bulkUpdateSettings(payload: BulkUpdatePayload): Promise<SystemSetting[]> {
|
||||
return api.put<SystemSetting[]>('/system-settings/bulk', payload);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch all system settings, optionally filtered by category.
|
||||
*/
|
||||
export function useSystemSettings(category?: string) {
|
||||
return useQuery({
|
||||
queryKey: systemSettingsKeys.list(category),
|
||||
queryFn: () => fetchSystemSettings(category),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation: update a single setting by key.
|
||||
*/
|
||||
export function useUpdateSetting() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ key, value }: { key: string; value: string }) =>
|
||||
updateSetting(key, value),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: systemSettingsKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation: update multiple settings at once.
|
||||
*/
|
||||
export function useBulkUpdateSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: BulkUpdatePayload) => bulkUpdateSettings(payload),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: systemSettingsKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -59,7 +59,12 @@ async function request<T>(endpoint: string, config: RequestConfig = {}): Promise
|
||||
// Handle empty responses
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType?.includes('application/json')) {
|
||||
return response.json();
|
||||
const json = await response.json();
|
||||
// Unwrap backend's {success, data, timestamp} envelope
|
||||
if (json && typeof json === 'object' && 'success' in json && 'data' in json) {
|
||||
return json.data as T;
|
||||
}
|
||||
return json as T;
|
||||
}
|
||||
|
||||
return {} as T;
|
||||
|
||||
Reference in New Issue
Block a user