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_ID=tos-backend
|
||||||
KEYCLOAK_CLIENT_SECRET=your-keycloak-backend-client-secret
|
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
|
# Encryption
|
||||||
# IMPORTANT: Generate a secure 32+ character key for production!
|
# IMPORTANT: Generate a secure 32+ character key for production!
|
||||||
# You can generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
# 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
|
ENCRYPTION_KEY=your-32-byte-encryption-key-change-in-production
|
||||||
|
|
||||||
# Redis (required for BullMQ in production)
|
# Redis (required for BullMQ in production)
|
||||||
REDIS_HOST=localhost
|
# REDIS_HOST=localhost
|
||||||
REDIS_PORT=6379
|
# 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
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Phase 3: API Connector Credentials
|
# Settings moved to the database (SystemSettings table)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
# The following env vars are no longer read by the application.
|
||||||
# PlentyONE (OAuth2 Client Credentials)
|
# Their values are stored in the database and can be managed via the
|
||||||
PLENTYONE_BASE_URL=
|
# admin UI at /admin/system-settings or via the API at PUT /api/v1/system-settings/:key.
|
||||||
PLENTYONE_CLIENT_ID=
|
#
|
||||||
PLENTYONE_CLIENT_SECRET=
|
# To seed initial values from a .env file, run the migration script:
|
||||||
|
# npx ts-node prisma/migrate-env-to-db.ts
|
||||||
# ZULIP (Basic Auth with API Key)
|
#
|
||||||
ZULIP_BASE_URL=
|
# Keys and their DB equivalents:
|
||||||
ZULIP_EMAIL=
|
# CORS_ORIGINS -> cors.origins (cors category)
|
||||||
ZULIP_API_KEY=
|
# SWAGGER_ENABLED -> feature.swagger.enabled (feature category)
|
||||||
|
# ENABLE_SYNC_JOBS -> feature.syncJobs.enabled (feature category)
|
||||||
# Todoist (Bearer Token)
|
# SYNC_INTERVAL_PLENTYONE -> sync.interval.plentyone (sync category)
|
||||||
TODOIST_API_TOKEN=
|
# SYNC_INTERVAL_ZULIP -> sync.interval.zulip (sync category)
|
||||||
|
# SYNC_INTERVAL_TODOIST -> sync.interval.todoist (sync category)
|
||||||
# FreeScout (API Key)
|
# SYNC_INTERVAL_FREESCOUT -> sync.interval.freescout (sync category)
|
||||||
FREESCOUT_API_URL=
|
# SYNC_INTERVAL_NEXTCLOUD -> sync.interval.nextcloud (sync category)
|
||||||
FREESCOUT_API_KEY=
|
# SYNC_INTERVAL_ECODMS -> sync.interval.ecodms (sync category)
|
||||||
|
# SYNC_INTERVAL_GEMBADOCS -> sync.interval.gembadocs (sync category)
|
||||||
# Nextcloud (Basic Auth / App Password)
|
#
|
||||||
NEXTCLOUD_URL=
|
# Integration credentials (PLENTYONE_*, ZULIP_*, TODOIST_*, FREESCOUT_*,
|
||||||
NEXTCLOUD_USERNAME=
|
# NEXTCLOUD_*, ECODMS_*, GEMBADOCS_*) are stored encrypted in the
|
||||||
NEXTCLOUD_PASSWORD=
|
# IntegrationCredential table and managed via /admin/integrations.
|
||||||
|
|
||||||
# ecoDMS (Session-based Auth)
|
|
||||||
ECODMS_API_URL=
|
|
||||||
ECODMS_USERNAME=
|
|
||||||
ECODMS_PASSWORD=
|
|
||||||
|
|||||||
@@ -26,16 +26,17 @@
|
|||||||
"db:seed": "prisma db seed"
|
"db:seed": "prisma db seed"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tos/shared": "workspace:*",
|
|
||||||
"@nestjs/common": "^10.3.0",
|
"@nestjs/common": "^10.3.0",
|
||||||
"@nestjs/config": "^3.1.1",
|
"@nestjs/config": "^3.1.1",
|
||||||
"@nestjs/core": "^10.3.0",
|
"@nestjs/core": "^10.3.0",
|
||||||
|
"@nestjs/event-emitter": "^3.0.1",
|
||||||
"@nestjs/jwt": "^10.2.0",
|
"@nestjs/jwt": "^10.2.0",
|
||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-express": "^10.3.0",
|
"@nestjs/platform-express": "^10.3.0",
|
||||||
"@nestjs/swagger": "^7.2.0",
|
"@nestjs/swagger": "^7.2.0",
|
||||||
"@nestjs/terminus": "^10.2.0",
|
"@nestjs/terminus": "^10.2.0",
|
||||||
"@prisma/client": "^5.8.0",
|
"@prisma/client": "^5.8.0",
|
||||||
|
"@tos/shared": "workspace:*",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.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([action])
|
||||||
@@index([createdAt])
|
@@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`);
|
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!');
|
console.log('Database seeding completed!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { AuthModule } from './auth/auth.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 { DepartmentsModule } from './modules/departments/departments.module';
|
||||||
import { UserPreferencesModule } from './modules/user-preferences/user-preferences.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
|
// Phase 4 modules - LEAN
|
||||||
import { LeanModule } from './modules/lean/lean.module';
|
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
|
// Database
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
|
||||||
@@ -51,6 +58,9 @@ import { IntegrationsModule } from './modules/integrations/integrations.module';
|
|||||||
UsersModule,
|
UsersModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
|
|
||||||
|
// Phase 1 - System Settings (database-backed configuration)
|
||||||
|
SystemSettingsModule,
|
||||||
|
|
||||||
// Phase 2 modules
|
// Phase 2 modules
|
||||||
AuditModule,
|
AuditModule,
|
||||||
DashboardModule,
|
DashboardModule,
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ export enum Permission {
|
|||||||
MEETING_CREATE = 'meeting:create',
|
MEETING_CREATE = 'meeting:create',
|
||||||
MEETING_UPDATE = 'meeting:update',
|
MEETING_UPDATE = 'meeting:update',
|
||||||
MEETING_DELETE = 'meeting:delete',
|
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 { PassportStrategy } from '@nestjs/passport';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { passportJwtSecret } from 'jwks-rsa';
|
||||||
import { JwtPayload } from '../interfaces/jwt-payload.interface';
|
import { JwtPayload } from '../interfaces/jwt-payload.interface';
|
||||||
import { UsersService } from '../../users/users.service';
|
import { UsersService } from '../../users/users.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
|
private readonly logger = new Logger(JwtStrategy.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly usersService: UsersService,
|
private readonly usersService: UsersService,
|
||||||
) {
|
) {
|
||||||
const secret = configService.get<string>('JWT_SECRET');
|
const keycloakUrl = configService.get<string>('KEYCLOAK_URL');
|
||||||
if (!secret) {
|
const keycloakRealm = configService.get<string>('KEYCLOAK_REALM');
|
||||||
throw new Error('JWT_SECRET is not defined');
|
const issuer = `${keycloakUrl}/realms/${keycloakRealm}`;
|
||||||
}
|
|
||||||
|
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
ignoreExpiration: false,
|
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> {
|
async validate(payload: Record<string, unknown>): Promise<JwtPayload> {
|
||||||
// Optionally validate that the user still exists and is active
|
// 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 {
|
try {
|
||||||
const user = await this.usersService.findOne(payload.sub);
|
const user = await this.usersService.findOne(sub);
|
||||||
if (!user.isActive) {
|
if (!user.isActive) {
|
||||||
throw new UnauthorizedException('User account is deactivated');
|
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 {
|
} 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_ID: Joi.string().optional(),
|
||||||
KEYCLOAK_CLIENT_SECRET: Joi.string().optional(),
|
KEYCLOAK_CLIENT_SECRET: Joi.string().optional(),
|
||||||
|
|
||||||
// CORS
|
// Encryption
|
||||||
CORS_ORIGINS: Joi.string().default('http://localhost:3000'),
|
|
||||||
|
|
||||||
// Swagger
|
|
||||||
SWAGGER_ENABLED: Joi.string().valid('true', 'false').default('true'),
|
|
||||||
|
|
||||||
// Encryption (Phase 3)
|
|
||||||
ENCRYPTION_KEY: Joi.string().min(32).when('NODE_ENV', {
|
ENCRYPTION_KEY: Joi.string().min(32).when('NODE_ENV', {
|
||||||
is: 'production',
|
is: 'production',
|
||||||
then: Joi.required(),
|
then: Joi.required(),
|
||||||
otherwise: Joi.optional(),
|
otherwise: Joi.optional(),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Redis (Phase 3 - for BullMQ)
|
// Redis (optional - for BullMQ in production)
|
||||||
REDIS_HOST: Joi.string().optional(),
|
REDIS_HOST: Joi.string().optional(),
|
||||||
REDIS_PORT: Joi.number().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 { AppModule } from './app.module';
|
||||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||||||
|
import { SystemSettingsService } from './modules/system-settings/system-settings.service';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const logger = new Logger('Bootstrap');
|
const logger = new Logger('Bootstrap');
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
const configService = app.get(ConfigService);
|
const configService = app.get(ConfigService);
|
||||||
|
const systemSettings = app.get(SystemSettingsService);
|
||||||
|
|
||||||
// Security
|
// Security
|
||||||
app.use(helmet());
|
app.use(helmet());
|
||||||
|
|
||||||
// CORS
|
// CORS - origins are read dynamically from the database on each request
|
||||||
const corsOrigins = configService.get<string>('CORS_ORIGINS')?.split(',').map((origin) => origin.trim()) || [
|
|
||||||
'http://localhost:3000',
|
|
||||||
];
|
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: corsOrigins,
|
origin: async (origin, callback) => {
|
||||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
|
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,
|
credentials: true,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
|
||||||
});
|
});
|
||||||
|
|
||||||
// API Prefix
|
// API Prefix
|
||||||
@@ -53,8 +63,8 @@ async function bootstrap() {
|
|||||||
// Global Interceptors
|
// Global Interceptors
|
||||||
app.useGlobalInterceptors(new TransformInterceptor());
|
app.useGlobalInterceptors(new TransformInterceptor());
|
||||||
|
|
||||||
// Swagger Documentation
|
// Swagger Documentation - enabled/disabled via DB setting
|
||||||
const swaggerEnabled = configService.get<string>('SWAGGER_ENABLED') === 'true';
|
const swaggerEnabled = await systemSettings.getTypedValue('feature.swagger.enabled', true);
|
||||||
if (swaggerEnabled) {
|
if (swaggerEnabled) {
|
||||||
const config = new DocumentBuilder()
|
const config = new DocumentBuilder()
|
||||||
.setTitle('tOS API')
|
.setTitle('tOS API')
|
||||||
|
|||||||
@@ -72,12 +72,25 @@ export abstract class BaseConnector {
|
|||||||
protected readonly logger: Logger;
|
protected readonly logger: Logger;
|
||||||
|
|
||||||
/** Axios HTTP client instance */
|
/** Axios HTTP client instance */
|
||||||
protected readonly httpClient: AxiosInstance;
|
protected httpClient: AxiosInstance;
|
||||||
|
|
||||||
/** Connector configuration */
|
/** 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 = {
|
this.config = {
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
maxRetries: 3,
|
maxRetries: 3,
|
||||||
@@ -87,9 +100,6 @@ export abstract class BaseConnector {
|
|||||||
...config,
|
...config,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Logger will be initialized with the concrete class name
|
|
||||||
this.logger = new Logger(this.constructor.name);
|
|
||||||
|
|
||||||
// Create axios instance with base configuration
|
// Create axios instance with base configuration
|
||||||
this.httpClient = axios.create({
|
this.httpClient = axios.create({
|
||||||
baseURL: this.config.baseUrl,
|
baseURL: this.config.baseUrl,
|
||||||
@@ -100,7 +110,7 @@ export abstract class BaseConnector {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Setup interceptors
|
// Setup interceptors on the new client
|
||||||
this.setupRequestInterceptor();
|
this.setupRequestInterceptor();
|
||||||
this.setupResponseInterceptor();
|
this.setupResponseInterceptor();
|
||||||
}
|
}
|
||||||
@@ -125,7 +135,7 @@ export abstract class BaseConnector {
|
|||||||
/**
|
/**
|
||||||
* Setup request interceptor for logging and authentication
|
* Setup request interceptor for logging and authentication
|
||||||
*/
|
*/
|
||||||
private setupRequestInterceptor(): void {
|
protected setupRequestInterceptor(): void {
|
||||||
this.httpClient.interceptors.request.use(
|
this.httpClient.interceptors.request.use(
|
||||||
async (config: InternalAxiosRequestConfig) => {
|
async (config: InternalAxiosRequestConfig) => {
|
||||||
// Add authentication headers
|
// Add authentication headers
|
||||||
@@ -161,7 +171,7 @@ export abstract class BaseConnector {
|
|||||||
/**
|
/**
|
||||||
* Setup response interceptor for logging and error transformation
|
* Setup response interceptor for logging and error transformation
|
||||||
*/
|
*/
|
||||||
private setupResponseInterceptor(): void {
|
protected setupResponseInterceptor(): void {
|
||||||
this.httpClient.interceptors.response.use(
|
this.httpClient.interceptors.response.use(
|
||||||
(response: AxiosResponse) => {
|
(response: AxiosResponse) => {
|
||||||
const config = response.config as InternalAxiosRequestConfig & { metadata?: { startTime: number } };
|
const config = response.config as InternalAxiosRequestConfig & { metadata?: { startTime: number } };
|
||||||
@@ -186,7 +196,7 @@ export abstract class BaseConnector {
|
|||||||
/**
|
/**
|
||||||
* Transform axios errors into integration-specific errors
|
* 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 status = error.response?.status;
|
||||||
const message = this.extractErrorMessage(error);
|
const message = this.extractErrorMessage(error);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import axios, { AxiosInstance, AxiosError } from 'axios';
|
import axios, { AxiosInstance, AxiosError } from 'axios';
|
||||||
import FormData from 'form-data';
|
import FormData from 'form-data';
|
||||||
|
import { CredentialsService } from '../../credentials/credentials.service';
|
||||||
import {
|
import {
|
||||||
IntegrationConnectionError,
|
IntegrationConnectionError,
|
||||||
IntegrationAuthError,
|
IntegrationAuthError,
|
||||||
@@ -21,9 +22,6 @@ export interface ConnectorHealth {
|
|||||||
details?: Record<string, unknown>;
|
details?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Retry configuration
|
|
||||||
*/
|
|
||||||
interface RetryConfig {
|
interface RetryConfig {
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
baseDelayMs: number;
|
baseDelayMs: number;
|
||||||
@@ -73,70 +71,76 @@ import {
|
|||||||
export class EcoDmsConnector {
|
export class EcoDmsConnector {
|
||||||
private readonly logger = new Logger(EcoDmsConnector.name);
|
private readonly logger = new Logger(EcoDmsConnector.name);
|
||||||
private readonly integrationName = 'ecoDMS';
|
private readonly integrationName = 'ecoDMS';
|
||||||
private readonly httpClient: AxiosInstance;
|
private httpClient: AxiosInstance;
|
||||||
private readonly retryConfig: RetryConfig;
|
private readonly retryConfig: RetryConfig;
|
||||||
private isConfigured: boolean = false;
|
private configuredState: boolean = false;
|
||||||
|
private initialized = false;
|
||||||
|
|
||||||
private readonly baseUrl: string;
|
private baseUrl: string = '';
|
||||||
private readonly username: string;
|
private username: string = '';
|
||||||
private readonly password: string;
|
private password: string = '';
|
||||||
private readonly apiVersion: string;
|
private readonly apiVersion: string = 'v1';
|
||||||
|
|
||||||
// Session management
|
// Session management
|
||||||
private session: EcoDmsSession | null = null;
|
private session: EcoDmsSession | null = null;
|
||||||
private sessionRefreshTimer: NodeJS.Timeout | null = null;
|
private sessionRefreshTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(private readonly credentialsService: CredentialsService) {
|
||||||
this.retryConfig = { ...DEFAULT_RETRY_CONFIG };
|
this.retryConfig = { ...DEFAULT_RETRY_CONFIG };
|
||||||
|
this.httpClient = axios.create();
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async ensureInitialized(): Promise<void> {
|
||||||
* Validate required configuration
|
if (this.initialized) return;
|
||||||
*/
|
|
||||||
private validateConfiguration(): void {
|
|
||||||
const missing: string[] = [];
|
|
||||||
|
|
||||||
if (!this.baseUrl) {
|
const result = await this.credentialsService.findActiveByType('ECODMS');
|
||||||
missing.push('ECODMS_API_URL');
|
|
||||||
}
|
if (!result) {
|
||||||
if (!this.username) {
|
this.configuredState = false;
|
||||||
missing.push('ECODMS_USERNAME');
|
this.initialized = true;
|
||||||
}
|
this.logger.warn('ecoDMS connector not configured - no active credentials found');
|
||||||
if (!this.password) {
|
return;
|
||||||
missing.push('ECODMS_PASSWORD');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
if (this.configuredState) {
|
||||||
this.logger.warn(`ecoDMS configuration incomplete. Missing: ${missing.join(', ')}`);
|
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 {
|
private ensureConfigured(): void {
|
||||||
if (!this.isConfigured) {
|
if (!this.configuredState) {
|
||||||
throw new IntegrationConfigError(this.integrationName, [
|
throw new IntegrationConfigError(this.integrationName, ['apiUrl', 'username', 'password']);
|
||||||
'ECODMS_API_URL',
|
|
||||||
'ECODMS_USERNAME',
|
|
||||||
'ECODMS_PASSWORD',
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,7 +428,8 @@ export class EcoDmsConnector {
|
|||||||
* Check connector health
|
* Check connector health
|
||||||
*/
|
*/
|
||||||
async checkHealth(): Promise<ConnectorHealth> {
|
async checkHealth(): Promise<ConnectorHealth> {
|
||||||
if (!this.isConfigured) {
|
await this.ensureInitialized();
|
||||||
|
if (!this.configuredState) {
|
||||||
return {
|
return {
|
||||||
status: 'not_configured',
|
status: 'not_configured',
|
||||||
lastCheck: new Date(),
|
lastCheck: new Date(),
|
||||||
@@ -466,6 +464,7 @@ export class EcoDmsConnector {
|
|||||||
* Test connection
|
* Test connection
|
||||||
*/
|
*/
|
||||||
async testConnection(): Promise<{ success: boolean; message: string; latency?: number }> {
|
async testConnection(): Promise<{ success: boolean; message: string; latency?: number }> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
const startTime = Date.now();
|
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 {
|
getIsConfigured(): boolean {
|
||||||
return this.isConfigured;
|
return this.configuredState;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Documents API ============
|
// ============ Documents API ============
|
||||||
@@ -499,6 +498,7 @@ export class EcoDmsConnector {
|
|||||||
documents: EcoDmsDocument[];
|
documents: EcoDmsDocument[];
|
||||||
pagination: { page: number; pageSize: number; total: number; totalPages: number };
|
pagination: { page: number; pageSize: number; total: number; totalPages: number };
|
||||||
}> {
|
}> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -551,6 +551,7 @@ export class EcoDmsConnector {
|
|||||||
* Get a single document by ID
|
* Get a single document by ID
|
||||||
*/
|
*/
|
||||||
async getDocument(id: number): Promise<EcoDmsDocument> {
|
async getDocument(id: number): Promise<EcoDmsDocument> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -574,6 +575,7 @@ export class EcoDmsConnector {
|
|||||||
* Search documents
|
* Search documents
|
||||||
*/
|
*/
|
||||||
async searchDocuments(params: SearchDocumentsDto): Promise<EcoDmsSearchResult> {
|
async searchDocuments(params: SearchDocumentsDto): Promise<EcoDmsSearchResult> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -632,6 +634,7 @@ export class EcoDmsConnector {
|
|||||||
mimeType: string,
|
mimeType: string,
|
||||||
metadata: UploadDocumentDto,
|
metadata: UploadDocumentDto,
|
||||||
): Promise<EcoDmsDocument> {
|
): Promise<EcoDmsDocument> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -685,6 +688,7 @@ export class EcoDmsConnector {
|
|||||||
* Update document metadata
|
* Update document metadata
|
||||||
*/
|
*/
|
||||||
async updateDocument(id: number, data: UpdateDocumentDto): Promise<EcoDmsDocument> {
|
async updateDocument(id: number, data: UpdateDocumentDto): Promise<EcoDmsDocument> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -708,6 +712,7 @@ export class EcoDmsConnector {
|
|||||||
* Delete a document
|
* Delete a document
|
||||||
*/
|
*/
|
||||||
async deleteDocument(id: number): Promise<void> {
|
async deleteDocument(id: number): Promise<void> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -728,6 +733,7 @@ export class EcoDmsConnector {
|
|||||||
* Download document content
|
* Download document content
|
||||||
*/
|
*/
|
||||||
async downloadDocument(id: number): Promise<{ content: Buffer; fileName: string; mimeType: string }> {
|
async downloadDocument(id: number): Promise<{ content: Buffer; fileName: string; mimeType: string }> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -752,6 +758,7 @@ export class EcoDmsConnector {
|
|||||||
* Get document preview (thumbnail or PDF preview)
|
* Get document preview (thumbnail or PDF preview)
|
||||||
*/
|
*/
|
||||||
async getDocumentPreview(id: number, page: number = 1): Promise<Buffer> {
|
async getDocumentPreview(id: number, page: number = 1): Promise<Buffer> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -770,6 +777,7 @@ export class EcoDmsConnector {
|
|||||||
* List folders
|
* List folders
|
||||||
*/
|
*/
|
||||||
async listFolders(parentId?: number): Promise<EcoDmsFolder[]> {
|
async listFolders(parentId?: number): Promise<EcoDmsFolder[]> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -798,6 +806,7 @@ export class EcoDmsConnector {
|
|||||||
* Get folder tree
|
* Get folder tree
|
||||||
*/
|
*/
|
||||||
async getFolderTree(): Promise<EcoDmsFolder[]> {
|
async getFolderTree(): Promise<EcoDmsFolder[]> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -820,6 +829,7 @@ export class EcoDmsConnector {
|
|||||||
* Create a folder
|
* Create a folder
|
||||||
*/
|
*/
|
||||||
async createFolder(data: CreateFolderDto): Promise<EcoDmsFolder> {
|
async createFolder(data: CreateFolderDto): Promise<EcoDmsFolder> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -843,6 +853,7 @@ export class EcoDmsConnector {
|
|||||||
* Delete a folder
|
* Delete a folder
|
||||||
*/
|
*/
|
||||||
async deleteFolder(id: number): Promise<void> {
|
async deleteFolder(id: number): Promise<void> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -865,6 +876,7 @@ export class EcoDmsConnector {
|
|||||||
* List classifications (document types/categories)
|
* List classifications (document types/categories)
|
||||||
*/
|
*/
|
||||||
async listClassifications(): Promise<EcoDmsClassification[]> {
|
async listClassifications(): Promise<EcoDmsClassification[]> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -887,6 +899,7 @@ export class EcoDmsConnector {
|
|||||||
* Get classification details
|
* Get classification details
|
||||||
*/
|
*/
|
||||||
async getClassification(id: number): Promise<EcoDmsClassification> {
|
async getClassification(id: number): Promise<EcoDmsClassification> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
|
|||||||
@@ -1,33 +1,17 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
|
||||||
import { EcoDmsConnector } from './ecodms.connector';
|
import { EcoDmsConnector } from './ecodms.connector';
|
||||||
import { EcoDmsService } from './ecodms.service';
|
import { EcoDmsService } from './ecodms.service';
|
||||||
import { EcoDmsController } from './ecodms.controller';
|
import { EcoDmsController } from './ecodms.controller';
|
||||||
|
import { CredentialsModule } from '../../credentials/credentials.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ecoDMS Integration Module
|
* ecoDMS Integration Module
|
||||||
*
|
*
|
||||||
* Provides integration with ecoDMS document management system.
|
* Provides integration with ecoDMS document management system.
|
||||||
*
|
* Credentials are stored in the DB and loaded via CredentialsService.
|
||||||
* 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
|
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule],
|
imports: [CredentialsModule],
|
||||||
controllers: [EcoDmsController],
|
controllers: [EcoDmsController],
|
||||||
providers: [EcoDmsConnector, EcoDmsService],
|
providers: [EcoDmsConnector, EcoDmsService],
|
||||||
exports: [EcoDmsService, EcoDmsConnector],
|
exports: [EcoDmsService, EcoDmsConnector],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import axios, { AxiosInstance, AxiosError } from 'axios';
|
import axios, { AxiosInstance, AxiosError } from 'axios';
|
||||||
import {
|
import {
|
||||||
IntegrationConnectionError,
|
IntegrationConnectionError,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
IntegrationApiError,
|
IntegrationApiError,
|
||||||
IntegrationConfigError,
|
IntegrationConfigError,
|
||||||
} from '../../errors';
|
} from '../../errors';
|
||||||
|
import { CredentialsService } from '../../credentials/credentials.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Health status of a connector
|
* Health status of a connector
|
||||||
@@ -49,7 +50,6 @@ import {
|
|||||||
ReplyToConversationDto,
|
ReplyToConversationDto,
|
||||||
CreateCustomerDto,
|
CreateCustomerDto,
|
||||||
ListCustomersDto,
|
ListCustomersDto,
|
||||||
ConversationType,
|
|
||||||
ThreadType,
|
ThreadType,
|
||||||
} from './freescout.types';
|
} from './freescout.types';
|
||||||
|
|
||||||
@@ -57,12 +57,7 @@ import {
|
|||||||
* FreeScout API Connector
|
* FreeScout API Connector
|
||||||
*
|
*
|
||||||
* Provides integration with FreeScout helpdesk system.
|
* Provides integration with FreeScout helpdesk system.
|
||||||
* Features:
|
* Credentials are loaded lazily from the DB via CredentialsService.
|
||||||
* - API Key authentication
|
|
||||||
* - Conversations/Tickets management
|
|
||||||
* - Mailboxes API
|
|
||||||
* - Customers API
|
|
||||||
* - Tags API
|
|
||||||
*
|
*
|
||||||
* API Documentation: https://github.com/freescout-helpdesk/freescout/wiki/API
|
* API Documentation: https://github.com/freescout-helpdesk/freescout/wiki/API
|
||||||
*/
|
*/
|
||||||
@@ -70,60 +65,78 @@ import {
|
|||||||
export class FreeScoutConnector {
|
export class FreeScoutConnector {
|
||||||
private readonly logger = new Logger(FreeScoutConnector.name);
|
private readonly logger = new Logger(FreeScoutConnector.name);
|
||||||
private readonly integrationName = 'FreeScout';
|
private readonly integrationName = 'FreeScout';
|
||||||
private readonly httpClient: AxiosInstance;
|
private httpClient: AxiosInstance;
|
||||||
private readonly retryConfig: RetryConfig;
|
private readonly retryConfig: RetryConfig;
|
||||||
private isConfigured: boolean = false;
|
private configuredState: boolean = false;
|
||||||
|
private initialized = false;
|
||||||
|
|
||||||
private readonly baseUrl: string;
|
private baseUrl: string = '';
|
||||||
private readonly apiKey: string;
|
private apiKey: string = '';
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(private readonly credentialsService: CredentialsService) {
|
||||||
this.retryConfig = { ...DEFAULT_RETRY_CONFIG };
|
this.retryConfig = { ...DEFAULT_RETRY_CONFIG };
|
||||||
|
// httpClient will be initialized lazily on first ensureInitialized() call
|
||||||
// Load configuration from environment
|
this.httpClient = axios.create(); // placeholder, replaced on init
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate required configuration
|
* Load credentials from DB and configure the connector.
|
||||||
|
* Idempotent - returns immediately if already initialized.
|
||||||
*/
|
*/
|
||||||
private validateConfiguration(): void {
|
async ensureInitialized(): Promise<void> {
|
||||||
const missing: string[] = [];
|
if (this.initialized) return;
|
||||||
|
|
||||||
if (!this.baseUrl) {
|
const result = await this.credentialsService.findActiveByType('FREESCOUT');
|
||||||
missing.push('FREESCOUT_API_URL');
|
|
||||||
}
|
if (!result) {
|
||||||
if (!this.apiKey) {
|
this.configuredState = false;
|
||||||
missing.push('FREESCOUT_API_KEY');
|
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.configuredState = !!(this.baseUrl && this.apiKey);
|
||||||
this.logger.warn(`FreeScout configuration incomplete. Missing: ${missing.join(', ')}`);
|
|
||||||
|
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
|
* Ensure the connector is configured before making API calls
|
||||||
*/
|
*/
|
||||||
private ensureConfigured(): void {
|
private ensureConfigured(): void {
|
||||||
if (!this.isConfigured) {
|
if (!this.configuredState) {
|
||||||
throw new IntegrationConfigError(this.integrationName, ['FREESCOUT_API_URL', 'FREESCOUT_API_KEY']);
|
throw new IntegrationConfigError(this.integrationName, ['apiUrl', 'apiKey']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +223,7 @@ export class FreeScoutConnector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!error.response) {
|
if (!error.response) {
|
||||||
return true; // Network errors are retryable
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = error.response.status;
|
const status = error.response.status;
|
||||||
@@ -265,7 +278,7 @@ export class FreeScoutConnector {
|
|||||||
`Access forbidden: ${message}`,
|
`Access forbidden: ${message}`,
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
case 429:
|
case 429: {
|
||||||
const retryAfter = parseInt(error.response.headers['retry-after'] || '60', 10);
|
const retryAfter = parseInt(error.response.headers['retry-after'] || '60', 10);
|
||||||
return new IntegrationRateLimitError(
|
return new IntegrationRateLimitError(
|
||||||
this.integrationName,
|
this.integrationName,
|
||||||
@@ -273,6 +286,7 @@ export class FreeScoutConnector {
|
|||||||
retryAfter,
|
retryAfter,
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return new IntegrationApiError(
|
return new IntegrationApiError(
|
||||||
this.integrationName,
|
this.integrationName,
|
||||||
@@ -297,7 +311,9 @@ export class FreeScoutConnector {
|
|||||||
* Check connector health
|
* Check connector health
|
||||||
*/
|
*/
|
||||||
async checkHealth(): Promise<ConnectorHealth> {
|
async checkHealth(): Promise<ConnectorHealth> {
|
||||||
if (!this.isConfigured) {
|
await this.ensureInitialized();
|
||||||
|
|
||||||
|
if (!this.configuredState) {
|
||||||
return {
|
return {
|
||||||
status: 'not_configured',
|
status: 'not_configured',
|
||||||
lastCheck: new Date(),
|
lastCheck: new Date(),
|
||||||
@@ -327,6 +343,7 @@ export class FreeScoutConnector {
|
|||||||
* Test connection to FreeScout API
|
* Test connection to FreeScout API
|
||||||
*/
|
*/
|
||||||
async testConnection(): Promise<{ success: boolean; message: string; latency?: number }> {
|
async testConnection(): Promise<{ success: boolean; message: string; latency?: number }> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
@@ -342,16 +359,15 @@ export class FreeScoutConnector {
|
|||||||
latency,
|
latency,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const latency = Date.now() - startTime;
|
|
||||||
throw this.mapError(error as AxiosError, 'testConnection');
|
throw this.mapError(error as AxiosError, 'testConnection');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if connector is configured
|
* Check if connector is configured (last known state)
|
||||||
*/
|
*/
|
||||||
getIsConfigured(): boolean {
|
getIsConfigured(): boolean {
|
||||||
return this.isConfigured;
|
return this.configuredState;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Conversations API ============
|
// ============ Conversations API ============
|
||||||
@@ -363,6 +379,7 @@ export class FreeScoutConnector {
|
|||||||
conversations: FreeScoutConversation[];
|
conversations: FreeScoutConversation[];
|
||||||
pagination: { page: number; pageSize: number; total: number; totalPages: number };
|
pagination: { page: number; pageSize: number; total: number; totalPages: number };
|
||||||
}> {
|
}> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -407,6 +424,7 @@ export class FreeScoutConnector {
|
|||||||
* Get a single conversation by ID
|
* Get a single conversation by ID
|
||||||
*/
|
*/
|
||||||
async getConversation(id: number): Promise<FreeScoutConversation> {
|
async getConversation(id: number): Promise<FreeScoutConversation> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -419,6 +437,7 @@ export class FreeScoutConnector {
|
|||||||
* Create a new conversation
|
* Create a new conversation
|
||||||
*/
|
*/
|
||||||
async createConversation(data: CreateConversationDto): Promise<FreeScoutConversation> {
|
async createConversation(data: CreateConversationDto): Promise<FreeScoutConversation> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -454,6 +473,7 @@ export class FreeScoutConnector {
|
|||||||
conversationId: number,
|
conversationId: number,
|
||||||
data: ReplyToConversationDto,
|
data: ReplyToConversationDto,
|
||||||
): Promise<FreeScoutConversation> {
|
): Promise<FreeScoutConversation> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -482,6 +502,7 @@ export class FreeScoutConnector {
|
|||||||
conversationId: number,
|
conversationId: number,
|
||||||
status: string,
|
status: string,
|
||||||
): Promise<FreeScoutConversation> {
|
): Promise<FreeScoutConversation> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -499,6 +520,7 @@ export class FreeScoutConnector {
|
|||||||
* List all mailboxes
|
* List all mailboxes
|
||||||
*/
|
*/
|
||||||
async listMailboxes(): Promise<FreeScoutMailbox[]> {
|
async listMailboxes(): Promise<FreeScoutMailbox[]> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -511,6 +533,7 @@ export class FreeScoutConnector {
|
|||||||
* Get a single mailbox by ID
|
* Get a single mailbox by ID
|
||||||
*/
|
*/
|
||||||
async getMailbox(id: number): Promise<FreeScoutMailbox> {
|
async getMailbox(id: number): Promise<FreeScoutMailbox> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -528,6 +551,7 @@ export class FreeScoutConnector {
|
|||||||
customers: FreeScoutCustomer[];
|
customers: FreeScoutCustomer[];
|
||||||
pagination: { page: number; pageSize: number; total: number; totalPages: number };
|
pagination: { page: number; pageSize: number; total: number; totalPages: number };
|
||||||
}> {
|
}> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -566,6 +590,7 @@ export class FreeScoutConnector {
|
|||||||
* Get a single customer by ID
|
* Get a single customer by ID
|
||||||
*/
|
*/
|
||||||
async getCustomer(id: number): Promise<FreeScoutCustomer> {
|
async getCustomer(id: number): Promise<FreeScoutCustomer> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -578,6 +603,7 @@ export class FreeScoutConnector {
|
|||||||
* Create a new customer
|
* Create a new customer
|
||||||
*/
|
*/
|
||||||
async createCustomer(data: CreateCustomerDto): Promise<FreeScoutCustomer> {
|
async createCustomer(data: CreateCustomerDto): Promise<FreeScoutCustomer> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -590,6 +616,7 @@ export class FreeScoutConnector {
|
|||||||
* Find customer by email
|
* Find customer by email
|
||||||
*/
|
*/
|
||||||
async findCustomerByEmail(email: string): Promise<FreeScoutCustomer | null> {
|
async findCustomerByEmail(email: string): Promise<FreeScoutCustomer | null> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
const result = await this.listCustomers({ email });
|
const result = await this.listCustomers({ email });
|
||||||
@@ -602,6 +629,7 @@ export class FreeScoutConnector {
|
|||||||
* List all tags
|
* List all tags
|
||||||
*/
|
*/
|
||||||
async listTags(): Promise<FreeScoutTag[]> {
|
async listTags(): Promise<FreeScoutTag[]> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -614,6 +642,7 @@ export class FreeScoutConnector {
|
|||||||
* Add tags to a conversation
|
* Add tags to a conversation
|
||||||
*/
|
*/
|
||||||
async addTagsToConversation(conversationId: number, tags: string[]): Promise<void> {
|
async addTagsToConversation(conversationId: number, tags: string[]): Promise<void> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
|
||||||
import { FreeScoutConnector } from './freescout.connector';
|
import { FreeScoutConnector } from './freescout.connector';
|
||||||
import { FreeScoutService } from './freescout.service';
|
import { FreeScoutService } from './freescout.service';
|
||||||
import { FreeScoutController } from './freescout.controller';
|
import { FreeScoutController } from './freescout.controller';
|
||||||
|
import { CredentialsModule } from '../../credentials/credentials.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FreeScout Integration Module
|
* FreeScout Integration Module
|
||||||
*
|
*
|
||||||
* Provides integration with FreeScout helpdesk system.
|
* Provides integration with FreeScout helpdesk system.
|
||||||
*
|
* Credentials are stored in the DB and loaded via CredentialsService.
|
||||||
* Required environment variables:
|
|
||||||
* - FREESCOUT_API_URL: Base URL of FreeScout instance (e.g., https://support.example.com)
|
|
||||||
* - FREESCOUT_API_KEY: API key for authentication
|
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule],
|
imports: [CredentialsModule],
|
||||||
controllers: [FreeScoutController],
|
controllers: [FreeScoutController],
|
||||||
providers: [FreeScoutConnector, FreeScoutService],
|
providers: [FreeScoutConnector, FreeScoutService],
|
||||||
exports: [FreeScoutService, FreeScoutConnector],
|
exports: [FreeScoutService, FreeScoutConnector],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import axios, { AxiosInstance, AxiosError } from 'axios';
|
import axios, { AxiosInstance, AxiosError } from 'axios';
|
||||||
|
import { CredentialsService } from '../../credentials/credentials.service';
|
||||||
import {
|
import {
|
||||||
IntegrationConnectionError,
|
IntegrationConnectionError,
|
||||||
IntegrationAuthError,
|
IntegrationAuthError,
|
||||||
@@ -73,59 +74,64 @@ const DEFAULT_TIMEOUT_MS = 30000;
|
|||||||
export class GembaDocsConnector {
|
export class GembaDocsConnector {
|
||||||
private readonly logger = new Logger(GembaDocsConnector.name);
|
private readonly logger = new Logger(GembaDocsConnector.name);
|
||||||
private readonly integrationName = 'GembaDocs';
|
private readonly integrationName = 'GembaDocs';
|
||||||
private readonly httpClient: AxiosInstance;
|
private httpClient: AxiosInstance;
|
||||||
private readonly retryConfig: RetryConfig;
|
private readonly retryConfig: RetryConfig;
|
||||||
private isConfiguredFlag: boolean = false;
|
private isConfiguredFlag: boolean = false;
|
||||||
|
private initialized = false;
|
||||||
|
|
||||||
private readonly baseUrl: string;
|
private baseUrl: string = '';
|
||||||
private readonly apiKey: string;
|
private apiKey: string = '';
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(private readonly credentialsService: CredentialsService) {
|
||||||
this.retryConfig = { ...DEFAULT_RETRY_CONFIG };
|
this.retryConfig = { ...DEFAULT_RETRY_CONFIG };
|
||||||
|
this.httpClient = axios.create();
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async ensureInitialized(): Promise<void> {
|
||||||
* Validate required configuration
|
if (this.initialized) return;
|
||||||
*/
|
|
||||||
private validateConfiguration(): void {
|
|
||||||
const missing: string[] = [];
|
|
||||||
|
|
||||||
if (!this.baseUrl) {
|
const result = await this.credentialsService.findActiveByType('GEMBADOCS');
|
||||||
missing.push('GEMBADOCS_API_URL');
|
|
||||||
}
|
if (!result) {
|
||||||
if (!this.apiKey) {
|
this.isConfiguredFlag = false;
|
||||||
missing.push('GEMBADOCS_API_KEY');
|
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) {
|
if (this.isConfiguredFlag) {
|
||||||
this.logger.warn(`GembaDocs configuration incomplete. Missing: ${missing.join(', ')}`);
|
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 {
|
private ensureConfigured(): void {
|
||||||
if (!this.isConfiguredFlag) {
|
if (!this.isConfiguredFlag) {
|
||||||
throw new IntegrationConfigError(this.integrationName, [
|
throw new IntegrationConfigError(this.integrationName, ['apiUrl', 'apiKey']);
|
||||||
'GEMBADOCS_API_URL',
|
|
||||||
'GEMBADOCS_API_KEY',
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,6 +309,7 @@ export class GembaDocsConnector {
|
|||||||
* Check connector health
|
* Check connector health
|
||||||
*/
|
*/
|
||||||
async checkHealth(): Promise<ConnectorHealth> {
|
async checkHealth(): Promise<ConnectorHealth> {
|
||||||
|
await this.ensureInitialized();
|
||||||
if (!this.isConfiguredFlag) {
|
if (!this.isConfiguredFlag) {
|
||||||
return {
|
return {
|
||||||
status: 'not_configured',
|
status: 'not_configured',
|
||||||
@@ -340,6 +341,7 @@ export class GembaDocsConnector {
|
|||||||
* Test connection
|
* Test connection
|
||||||
*/
|
*/
|
||||||
async testConnection(): Promise<{ success: boolean; message: string; latency?: number }> {
|
async testConnection(): Promise<{ success: boolean; message: string; latency?: number }> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
@@ -379,8 +381,8 @@ export class GembaDocsConnector {
|
|||||||
*/
|
*/
|
||||||
getMissingConfig(): string[] {
|
getMissingConfig(): string[] {
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
if (!this.baseUrl) missing.push('GEMBADOCS_API_URL');
|
if (!this.baseUrl) missing.push('apiUrl');
|
||||||
if (!this.apiKey) missing.push('GEMBADOCS_API_KEY');
|
if (!this.apiKey) missing.push('apiKey');
|
||||||
return missing;
|
return missing;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,6 +395,7 @@ export class GembaDocsConnector {
|
|||||||
audits: GembaAudit[];
|
audits: GembaAudit[];
|
||||||
pagination: { page: number; pageSize: number; total: number; totalPages: number };
|
pagination: { page: number; pageSize: number; total: number; totalPages: number };
|
||||||
}> {
|
}> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -440,6 +443,7 @@ export class GembaDocsConnector {
|
|||||||
* Get a single audit by ID
|
* Get a single audit by ID
|
||||||
*/
|
*/
|
||||||
async getAudit(id: string): Promise<GembaAudit> {
|
async getAudit(id: string): Promise<GembaAudit> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -463,6 +467,7 @@ export class GembaDocsConnector {
|
|||||||
* Get upcoming audits (scheduled for the future)
|
* Get upcoming audits (scheduled for the future)
|
||||||
*/
|
*/
|
||||||
async getUpcomingAudits(days: number = 7, limit: number = 10): Promise<GembaAudit[]> {
|
async getUpcomingAudits(days: number = 7, limit: number = 10): Promise<GembaAudit[]> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -498,6 +503,7 @@ export class GembaDocsConnector {
|
|||||||
* Get overdue audits (scheduled in the past but not completed)
|
* Get overdue audits (scheduled in the past but not completed)
|
||||||
*/
|
*/
|
||||||
async getOverdueAudits(limit: number = 10): Promise<GembaAudit[]> {
|
async getOverdueAudits(limit: number = 10): Promise<GembaAudit[]> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -534,6 +540,7 @@ export class GembaDocsConnector {
|
|||||||
* Create a new audit
|
* Create a new audit
|
||||||
*/
|
*/
|
||||||
async createAudit(data: CreateAuditDto): Promise<GembaAudit> {
|
async createAudit(data: CreateAuditDto): Promise<GembaAudit> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -557,6 +564,7 @@ export class GembaDocsConnector {
|
|||||||
* Update an audit
|
* Update an audit
|
||||||
*/
|
*/
|
||||||
async updateAudit(id: string, data: UpdateAuditDto): Promise<GembaAudit> {
|
async updateAudit(id: string, data: UpdateAuditDto): Promise<GembaAudit> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -582,6 +590,7 @@ export class GembaDocsConnector {
|
|||||||
* List all checklist templates
|
* List all checklist templates
|
||||||
*/
|
*/
|
||||||
async listChecklists(): Promise<GembaChecklist[]> {
|
async listChecklists(): Promise<GembaChecklist[]> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -604,6 +613,7 @@ export class GembaDocsConnector {
|
|||||||
* Get a single checklist by ID
|
* Get a single checklist by ID
|
||||||
*/
|
*/
|
||||||
async getChecklist(id: string): Promise<GembaChecklist> {
|
async getChecklist(id: string): Promise<GembaChecklist> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -632,6 +642,7 @@ export class GembaDocsConnector {
|
|||||||
findings: GembaFinding[];
|
findings: GembaFinding[];
|
||||||
pagination: { page: number; pageSize: number; total: number; totalPages: number };
|
pagination: { page: number; pageSize: number; total: number; totalPages: number };
|
||||||
}> {
|
}> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -677,6 +688,7 @@ export class GembaDocsConnector {
|
|||||||
* Get open findings
|
* Get open findings
|
||||||
*/
|
*/
|
||||||
async getOpenFindings(limit: number = 20): Promise<GembaFinding[]> {
|
async getOpenFindings(limit: number = 20): Promise<GembaFinding[]> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -706,6 +718,7 @@ export class GembaDocsConnector {
|
|||||||
* Update a finding
|
* Update a finding
|
||||||
*/
|
*/
|
||||||
async updateFinding(id: string, data: UpdateFindingDto): Promise<GembaFinding> {
|
async updateFinding(id: string, data: UpdateFindingDto): Promise<GembaFinding> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -729,6 +742,7 @@ export class GembaDocsConnector {
|
|||||||
* Resolve a finding
|
* Resolve a finding
|
||||||
*/
|
*/
|
||||||
async resolveFinding(id: string, data: ResolveFindingDto): Promise<GembaFinding> {
|
async resolveFinding(id: string, data: ResolveFindingDto): Promise<GembaFinding> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -754,6 +768,7 @@ export class GembaDocsConnector {
|
|||||||
* Get audit statistics
|
* Get audit statistics
|
||||||
*/
|
*/
|
||||||
async getStatistics(department?: string): Promise<GembaStatistics> {
|
async getStatistics(department?: string): Promise<GembaStatistics> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
@@ -780,6 +795,7 @@ export class GembaDocsConnector {
|
|||||||
* Get trend data for charts
|
* Get trend data for charts
|
||||||
*/
|
*/
|
||||||
async getTrends(params: GetTrendsDto = {}): Promise<GembaTrendData> {
|
async getTrends(params: GetTrendsDto = {}): Promise<GembaTrendData> {
|
||||||
|
await this.ensureInitialized();
|
||||||
this.ensureConfigured();
|
this.ensureConfigured();
|
||||||
|
|
||||||
return this.executeWithRetry(async () => {
|
return this.executeWithRetry(async () => {
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
|
||||||
import { GembaDocsConnector } from './gembadocs.connector';
|
import { GembaDocsConnector } from './gembadocs.connector';
|
||||||
import { GembaDocsService } from './gembadocs.service';
|
import { GembaDocsService } from './gembadocs.service';
|
||||||
import { GembaDocsController } from './gembadocs.controller';
|
import { GembaDocsController } from './gembadocs.controller';
|
||||||
|
import { CredentialsModule } from '../../credentials/credentials.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GembaDocs Integration Module
|
* GembaDocs Integration Module
|
||||||
*
|
*
|
||||||
* Provides integration with GembaDocs audit and checklist management system.
|
* Provides integration with GembaDocs audit and checklist management system.
|
||||||
*
|
*
|
||||||
* Required environment variables:
|
* Credentials are stored in the DB and loaded via CredentialsService.
|
||||||
* - GEMBADOCS_API_URL: Base URL of GembaDocs API (e.g., https://api.gembadocs.com)
|
* Required credential keys: apiUrl, apiKey
|
||||||
* - GEMBADOCS_API_KEY: API key for authentication
|
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - API Key authentication
|
* - API Key authentication
|
||||||
@@ -57,7 +56,7 @@ import { GembaDocsController } from './gembadocs.controller';
|
|||||||
* - GET /integrations/gembadocs/compliance-score - Compliance score
|
* - GET /integrations/gembadocs/compliance-score - Compliance score
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule],
|
imports: [CredentialsModule],
|
||||||
controllers: [GembaDocsController],
|
controllers: [GembaDocsController],
|
||||||
providers: [GembaDocsConnector, GembaDocsService],
|
providers: [GembaDocsConnector, GembaDocsService],
|
||||||
exports: [GembaDocsService, GembaDocsConnector],
|
exports: [GembaDocsService, GembaDocsConnector],
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,17 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
|
||||||
import { NextcloudConnector } from './nextcloud.connector';
|
import { NextcloudConnector } from './nextcloud.connector';
|
||||||
import { NextcloudService } from './nextcloud.service';
|
import { NextcloudService } from './nextcloud.service';
|
||||||
import { NextcloudController } from './nextcloud.controller';
|
import { NextcloudController } from './nextcloud.controller';
|
||||||
|
import { CredentialsModule } from '../../credentials/credentials.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nextcloud Integration Module
|
* Nextcloud Integration Module
|
||||||
*
|
*
|
||||||
* Provides integration with Nextcloud cloud storage.
|
* Provides integration with Nextcloud cloud storage.
|
||||||
*
|
* Credentials are stored in the DB and loaded via CredentialsService.
|
||||||
* 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)
|
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule],
|
imports: [CredentialsModule],
|
||||||
controllers: [NextcloudController],
|
controllers: [NextcloudController],
|
||||||
providers: [NextcloudConnector, NextcloudService],
|
providers: [NextcloudConnector, NextcloudService],
|
||||||
exports: [NextcloudService, NextcloudConnector],
|
exports: [NextcloudService, NextcloudConnector],
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import {
|
import {
|
||||||
BaseConnector,
|
BaseConnector,
|
||||||
BaseConnectorConfig,
|
|
||||||
ConnectionTestResult,
|
ConnectionTestResult,
|
||||||
} from '../base-connector';
|
} from '../base-connector';
|
||||||
import {
|
import {
|
||||||
IntegrationAuthError,
|
IntegrationAuthError,
|
||||||
IntegrationConfigError,
|
IntegrationConfigError,
|
||||||
} from '../../errors';
|
} from '../../errors';
|
||||||
|
import { CredentialsService } from '../../credentials/credentials.service';
|
||||||
import {
|
import {
|
||||||
PlentyoneAuthConfig,
|
PlentyoneAuthConfig,
|
||||||
PlentyoneTokenInfo,
|
PlentyoneTokenInfo,
|
||||||
@@ -29,41 +29,88 @@ import {
|
|||||||
*
|
*
|
||||||
* Provides integration with PlentyONE e-commerce platform.
|
* Provides integration with PlentyONE e-commerce platform.
|
||||||
* Handles OAuth2 authentication, token management, and API calls.
|
* Handles OAuth2 authentication, token management, and API calls.
|
||||||
|
* Credentials are loaded lazily from the DB via CredentialsService.
|
||||||
*
|
*
|
||||||
* @see https://developers.plentymarkets.com/
|
* @see https://developers.plentymarkets.com/
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PlentyoneConnector extends BaseConnector {
|
export class PlentyoneConnector extends BaseConnector {
|
||||||
protected readonly name = 'PlentyONE';
|
protected readonly name = 'PlentyONE';
|
||||||
private readonly authConfig: PlentyoneAuthConfig;
|
private authConfig: PlentyoneAuthConfig = { baseUrl: '', clientId: '', clientSecret: '' };
|
||||||
private tokenInfo: PlentyoneTokenInfo | null = null;
|
private tokenInfo: PlentyoneTokenInfo | null = null;
|
||||||
private tokenRefreshPromise: Promise<void> | null = null;
|
private tokenRefreshPromise: Promise<void> | null = null;
|
||||||
|
private initialized = false;
|
||||||
|
private configuredState = false;
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(private readonly credentialsService: CredentialsService) {
|
||||||
const baseUrl = configService.get<string>('PLENTYONE_BASE_URL') || '';
|
super();
|
||||||
|
|
||||||
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') || '',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the connector is properly configured
|
* Load credentials from DB and configure the connector.
|
||||||
|
* Idempotent - returns immediately if already initialized.
|
||||||
*/
|
*/
|
||||||
isConfigured(): boolean {
|
async ensureInitialized(): Promise<void> {
|
||||||
return !!(
|
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.baseUrl &&
|
||||||
this.authConfig.clientId &&
|
this.authConfig.clientId &&
|
||||||
this.authConfig.clientSecret
|
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[] {
|
getMissingConfig(): string[] {
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
if (!this.authConfig.baseUrl) missing.push('PLENTYONE_BASE_URL');
|
if (!this.authConfig.baseUrl) missing.push('baseUrl');
|
||||||
if (!this.authConfig.clientId) missing.push('PLENTYONE_CLIENT_ID');
|
if (!this.authConfig.clientId) missing.push('clientId');
|
||||||
if (!this.authConfig.clientSecret) missing.push('PLENTYONE_CLIENT_SECRET');
|
if (!this.authConfig.clientSecret) missing.push('clientSecret');
|
||||||
return missing;
|
return missing;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +128,8 @@ export class PlentyoneConnector extends BaseConnector {
|
|||||||
* Test the connection to PlentyONE
|
* Test the connection to PlentyONE
|
||||||
*/
|
*/
|
||||||
async testConnection(): Promise<ConnectionTestResult> {
|
async testConnection(): Promise<ConnectionTestResult> {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
|
||||||
if (!this.isConfigured()) {
|
if (!this.isConfigured()) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -91,10 +140,7 @@ export class PlentyoneConnector extends BaseConnector {
|
|||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to authenticate
|
|
||||||
await this.ensureAuthenticated();
|
await this.ensureAuthenticated();
|
||||||
|
|
||||||
// Make a simple API call to verify the token works
|
|
||||||
await this.get<{ version: string }>('/');
|
await this.get<{ version: string }>('/');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -119,6 +165,8 @@ export class PlentyoneConnector extends BaseConnector {
|
|||||||
* Get authentication headers for requests
|
* Get authentication headers for requests
|
||||||
*/
|
*/
|
||||||
async getAuthHeaders(): Promise<Record<string, string>> {
|
async getAuthHeaders(): Promise<Record<string, string>> {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
|
||||||
if (!this.isConfigured()) {
|
if (!this.isConfigured()) {
|
||||||
throw new IntegrationConfigError(this.name, this.getMissingConfig());
|
throw new IntegrationConfigError(this.name, this.getMissingConfig());
|
||||||
}
|
}
|
||||||
@@ -134,18 +182,15 @@ export class PlentyoneConnector extends BaseConnector {
|
|||||||
* Ensure we have a valid access token
|
* Ensure we have a valid access token
|
||||||
*/
|
*/
|
||||||
private async ensureAuthenticated(): Promise<void> {
|
private async ensureAuthenticated(): Promise<void> {
|
||||||
// If token is valid, return immediately
|
|
||||||
if (this.isTokenValid()) {
|
if (this.isTokenValid()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If a refresh is already in progress, wait for it
|
|
||||||
if (this.tokenRefreshPromise) {
|
if (this.tokenRefreshPromise) {
|
||||||
await this.tokenRefreshPromise;
|
await this.tokenRefreshPromise;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start token refresh
|
|
||||||
this.tokenRefreshPromise = this.refreshAccessToken();
|
this.tokenRefreshPromise = this.refreshAccessToken();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -161,7 +206,6 @@ export class PlentyoneConnector extends BaseConnector {
|
|||||||
private isTokenValid(): boolean {
|
private isTokenValid(): boolean {
|
||||||
if (!this.tokenInfo) return false;
|
if (!this.tokenInfo) return false;
|
||||||
|
|
||||||
// Consider token invalid if it expires in less than 5 minutes
|
|
||||||
const bufferMs = 5 * 60 * 1000;
|
const bufferMs = 5 * 60 * 1000;
|
||||||
return this.tokenInfo.expiresAt.getTime() > Date.now() + bufferMs;
|
return this.tokenInfo.expiresAt.getTime() > Date.now() + bufferMs;
|
||||||
}
|
}
|
||||||
@@ -217,6 +261,7 @@ export class PlentyoneConnector extends BaseConnector {
|
|||||||
async getOrders(
|
async getOrders(
|
||||||
query?: PlentyoneOrdersQuery,
|
query?: PlentyoneOrdersQuery,
|
||||||
): Promise<PlentyonePaginatedResponse<PlentyoneOrder>> {
|
): Promise<PlentyonePaginatedResponse<PlentyoneOrder>> {
|
||||||
|
await this.ensureInitialized();
|
||||||
const params = this.buildQueryParams(query);
|
const params = this.buildQueryParams(query);
|
||||||
return this.get<PlentyonePaginatedResponse<PlentyoneOrder>>(
|
return this.get<PlentyonePaginatedResponse<PlentyoneOrder>>(
|
||||||
'/orders',
|
'/orders',
|
||||||
@@ -231,6 +276,7 @@ export class PlentyoneConnector extends BaseConnector {
|
|||||||
orderId: number,
|
orderId: number,
|
||||||
withRelations?: string[],
|
withRelations?: string[],
|
||||||
): Promise<PlentyoneOrder> {
|
): Promise<PlentyoneOrder> {
|
||||||
|
await this.ensureInitialized();
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
if (withRelations?.length) {
|
if (withRelations?.length) {
|
||||||
params.with = withRelations.join(',');
|
params.with = withRelations.join(',');
|
||||||
@@ -285,6 +331,7 @@ export class PlentyoneConnector extends BaseConnector {
|
|||||||
async getStock(
|
async getStock(
|
||||||
query?: PlentyoneStockQuery,
|
query?: PlentyoneStockQuery,
|
||||||
): Promise<PlentyonePaginatedResponse<PlentyoneStockItem>> {
|
): Promise<PlentyonePaginatedResponse<PlentyoneStockItem>> {
|
||||||
|
await this.ensureInitialized();
|
||||||
const params = this.buildQueryParams(query);
|
const params = this.buildQueryParams(query);
|
||||||
return this.get<PlentyonePaginatedResponse<PlentyoneStockItem>>(
|
return this.get<PlentyonePaginatedResponse<PlentyoneStockItem>>(
|
||||||
'/stockmanagement/stock',
|
'/stockmanagement/stock',
|
||||||
@@ -336,7 +383,6 @@ export class PlentyoneConnector extends BaseConnector {
|
|||||||
hasMore = !response.isLastPage;
|
hasMore = !response.isLastPage;
|
||||||
page++;
|
page++;
|
||||||
|
|
||||||
// Safety limit
|
|
||||||
if (page > 100) break;
|
if (page > 100) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,21 +397,18 @@ export class PlentyoneConnector extends BaseConnector {
|
|||||||
* Get order statistics
|
* Get order statistics
|
||||||
*/
|
*/
|
||||||
async getOrderStats(query: PlentyoneStatsQuery): Promise<PlentyoneOrderStats> {
|
async getOrderStats(query: PlentyoneStatsQuery): Promise<PlentyoneOrderStats> {
|
||||||
// Get orders within the date range
|
|
||||||
const orders = await this.getAllOrdersInRange(
|
const orders = await this.getAllOrdersInRange(
|
||||||
new Date(query.dateFrom),
|
new Date(query.dateFrom),
|
||||||
new Date(query.dateTo),
|
new Date(query.dateTo),
|
||||||
query.statusId,
|
query.statusId,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate statistics
|
|
||||||
let totalRevenue = 0;
|
let totalRevenue = 0;
|
||||||
let totalRevenueNet = 0;
|
let totalRevenueNet = 0;
|
||||||
const ordersByStatus: Record<number, number> = {};
|
const ordersByStatus: Record<number, number> = {};
|
||||||
const ordersByReferrer: Record<number, number> = {};
|
const ordersByReferrer: Record<number, number> = {};
|
||||||
|
|
||||||
for (const order of orders) {
|
for (const order of orders) {
|
||||||
// Sum up amounts
|
|
||||||
if (order.amounts?.length) {
|
if (order.amounts?.length) {
|
||||||
const primaryAmount = order.amounts.find((a) => a.isSystemCurrency);
|
const primaryAmount = order.amounts.find((a) => a.isSystemCurrency);
|
||||||
if (primaryAmount) {
|
if (primaryAmount) {
|
||||||
@@ -374,10 +417,7 @@ export class PlentyoneConnector extends BaseConnector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count by status
|
|
||||||
ordersByStatus[order.statusId] = (ordersByStatus[order.statusId] || 0) + 1;
|
ordersByStatus[order.statusId] = (ordersByStatus[order.statusId] || 0) + 1;
|
||||||
|
|
||||||
// Count by referrer
|
|
||||||
ordersByReferrer[order.referrerId] =
|
ordersByReferrer[order.referrerId] =
|
||||||
(ordersByReferrer[order.referrerId] || 0) + 1;
|
(ordersByReferrer[order.referrerId] || 0) + 1;
|
||||||
}
|
}
|
||||||
@@ -387,7 +427,7 @@ export class PlentyoneConnector extends BaseConnector {
|
|||||||
totalRevenue,
|
totalRevenue,
|
||||||
totalRevenueNet,
|
totalRevenueNet,
|
||||||
averageOrderValue: orders.length > 0 ? totalRevenue / orders.length : 0,
|
averageOrderValue: orders.length > 0 ? totalRevenue / orders.length : 0,
|
||||||
currency: 'EUR', // Default currency
|
currency: 'EUR',
|
||||||
ordersByStatus,
|
ordersByStatus,
|
||||||
ordersByReferrer,
|
ordersByReferrer,
|
||||||
};
|
};
|
||||||
@@ -405,7 +445,6 @@ export class PlentyoneConnector extends BaseConnector {
|
|||||||
query.statusId,
|
query.statusId,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Group by date
|
|
||||||
const statsByDate = new Map<string, PlentyoneRevenueStats>();
|
const statsByDate = new Map<string, PlentyoneRevenueStats>();
|
||||||
|
|
||||||
for (const order of orders) {
|
for (const order of orders) {
|
||||||
@@ -477,7 +516,6 @@ export class PlentyoneConnector extends BaseConnector {
|
|||||||
hasMore = !response.isLastPage;
|
hasMore = !response.isLastPage;
|
||||||
page++;
|
page++;
|
||||||
|
|
||||||
// Safety limit to prevent infinite loops
|
|
||||||
if (page > 1000) {
|
if (page > 1000) {
|
||||||
this.logger.warn('Reached maximum page limit for order retrieval');
|
this.logger.warn('Reached maximum page limit for order retrieval');
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
|
|||||||
import { PlentyoneConnector } from './plentyone.connector';
|
import { PlentyoneConnector } from './plentyone.connector';
|
||||||
import { PlentyoneService } from './plentyone.service';
|
import { PlentyoneService } from './plentyone.service';
|
||||||
import { PlentyoneController } from './plentyone.controller';
|
import { PlentyoneController } from './plentyone.controller';
|
||||||
|
import { CredentialsModule } from '../../credentials/credentials.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [CredentialsModule],
|
||||||
controllers: [PlentyoneController],
|
controllers: [PlentyoneController],
|
||||||
providers: [PlentyoneConnector, PlentyoneService],
|
providers: [PlentyoneConnector, PlentyoneService],
|
||||||
exports: [PlentyoneService, PlentyoneConnector],
|
exports: [PlentyoneService, PlentyoneConnector],
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import {
|
import {
|
||||||
BaseConnector,
|
BaseConnector,
|
||||||
BaseConnectorConfig,
|
|
||||||
ConnectionTestResult,
|
ConnectionTestResult,
|
||||||
} from '../base-connector';
|
} from '../base-connector';
|
||||||
import { IntegrationConfigError } from '../../errors';
|
import { IntegrationConfigError } from '../../errors';
|
||||||
|
import { CredentialsService } from '../../credentials/credentials.service';
|
||||||
import {
|
import {
|
||||||
TodoistAuthConfig,
|
TodoistAuthConfig,
|
||||||
TodoistTask,
|
TodoistTask,
|
||||||
@@ -28,31 +28,75 @@ import {
|
|||||||
*
|
*
|
||||||
* Provides integration with Todoist task management platform.
|
* Provides integration with Todoist task management platform.
|
||||||
* Uses Bearer Token authentication.
|
* Uses Bearer Token authentication.
|
||||||
|
* Credentials are loaded lazily from the DB via CredentialsService.
|
||||||
*
|
*
|
||||||
* @see https://developer.todoist.com/rest/v2/
|
* @see https://developer.todoist.com/rest/v2/
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TodoistConnector extends BaseConnector {
|
export class TodoistConnector extends BaseConnector {
|
||||||
protected readonly name = 'Todoist';
|
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({
|
super({
|
||||||
baseUrl: 'https://api.todoist.com/rest/v2',
|
baseUrl: 'https://api.todoist.com/rest/v2',
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
maxRetries: 3,
|
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 {
|
isConfigured(): boolean {
|
||||||
return !!this.authConfig.apiToken;
|
return this.configuredState;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,7 +104,7 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
*/
|
*/
|
||||||
getMissingConfig(): string[] {
|
getMissingConfig(): string[] {
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
if (!this.authConfig.apiToken) missing.push('TODOIST_API_TOKEN');
|
if (!this.authConfig.apiToken) missing.push('apiToken');
|
||||||
return missing;
|
return missing;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +112,8 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
* Test the connection to Todoist
|
* Test the connection to Todoist
|
||||||
*/
|
*/
|
||||||
async testConnection(): Promise<ConnectionTestResult> {
|
async testConnection(): Promise<ConnectionTestResult> {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
|
||||||
if (!this.isConfigured()) {
|
if (!this.isConfigured()) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -78,7 +124,6 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to get projects (simple call to verify auth)
|
|
||||||
await this.getProjects();
|
await this.getProjects();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -100,6 +145,8 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
* Get authentication headers for requests (Bearer token)
|
* Get authentication headers for requests (Bearer token)
|
||||||
*/
|
*/
|
||||||
async getAuthHeaders(): Promise<Record<string, string>> {
|
async getAuthHeaders(): Promise<Record<string, string>> {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
|
||||||
if (!this.isConfigured()) {
|
if (!this.isConfigured()) {
|
||||||
throw new IntegrationConfigError(this.name, this.getMissingConfig());
|
throw new IntegrationConfigError(this.name, this.getMissingConfig());
|
||||||
}
|
}
|
||||||
@@ -117,6 +164,7 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
* Get active tasks
|
* Get active tasks
|
||||||
*/
|
*/
|
||||||
async getTasks(request?: TodoistGetTasksRequest): Promise<TodoistTask[]> {
|
async getTasks(request?: TodoistGetTasksRequest): Promise<TodoistTask[]> {
|
||||||
|
await this.ensureInitialized();
|
||||||
const params = this.buildTasksParams(request);
|
const params = this.buildTasksParams(request);
|
||||||
return this.get<TodoistTask[]>('/tasks', { params });
|
return this.get<TodoistTask[]>('/tasks', { params });
|
||||||
}
|
}
|
||||||
@@ -125,6 +173,7 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
* Get a single task by ID
|
* Get a single task by ID
|
||||||
*/
|
*/
|
||||||
async getTask(taskId: string): Promise<TodoistTask> {
|
async getTask(taskId: string): Promise<TodoistTask> {
|
||||||
|
await this.ensureInitialized();
|
||||||
return this.get<TodoistTask>(`/tasks/${taskId}`);
|
return this.get<TodoistTask>(`/tasks/${taskId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +181,7 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
* Create a new task
|
* Create a new task
|
||||||
*/
|
*/
|
||||||
async createTask(request: TodoistCreateTaskRequest): Promise<TodoistTask> {
|
async createTask(request: TodoistCreateTaskRequest): Promise<TodoistTask> {
|
||||||
// Generate a unique request ID for idempotency
|
await this.ensureInitialized();
|
||||||
const requestId = this.generateRequestId();
|
const requestId = this.generateRequestId();
|
||||||
|
|
||||||
return this.post<TodoistTask>('/tasks', request, {
|
return this.post<TodoistTask>('/tasks', request, {
|
||||||
@@ -149,7 +198,7 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
taskId: string,
|
taskId: string,
|
||||||
request: TodoistUpdateTaskRequest,
|
request: TodoistUpdateTaskRequest,
|
||||||
): Promise<TodoistTask> {
|
): Promise<TodoistTask> {
|
||||||
// Generate a unique request ID for idempotency
|
await this.ensureInitialized();
|
||||||
const requestId = this.generateRequestId();
|
const requestId = this.generateRequestId();
|
||||||
|
|
||||||
return this.post<TodoistTask>(`/tasks/${taskId}`, request, {
|
return this.post<TodoistTask>(`/tasks/${taskId}`, request, {
|
||||||
@@ -163,6 +212,7 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
* Complete a task
|
* Complete a task
|
||||||
*/
|
*/
|
||||||
async completeTask(taskId: string): Promise<void> {
|
async completeTask(taskId: string): Promise<void> {
|
||||||
|
await this.ensureInitialized();
|
||||||
await this.post<void>(`/tasks/${taskId}/close`, null);
|
await this.post<void>(`/tasks/${taskId}/close`, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +220,7 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
* Reopen a task
|
* Reopen a task
|
||||||
*/
|
*/
|
||||||
async reopenTask(taskId: string): Promise<void> {
|
async reopenTask(taskId: string): Promise<void> {
|
||||||
|
await this.ensureInitialized();
|
||||||
await this.post<void>(`/tasks/${taskId}/reopen`, null);
|
await this.post<void>(`/tasks/${taskId}/reopen`, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +228,7 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
* Delete a task
|
* Delete a task
|
||||||
*/
|
*/
|
||||||
async deleteTask(taskId: string): Promise<void> {
|
async deleteTask(taskId: string): Promise<void> {
|
||||||
|
await this.ensureInitialized();
|
||||||
await this.delete<void>(`/tasks/${taskId}`);
|
await this.delete<void>(`/tasks/${taskId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +240,7 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
* Get all projects
|
* Get all projects
|
||||||
*/
|
*/
|
||||||
async getProjects(): Promise<TodoistProject[]> {
|
async getProjects(): Promise<TodoistProject[]> {
|
||||||
|
await this.ensureInitialized();
|
||||||
return this.get<TodoistProject[]>('/projects');
|
return this.get<TodoistProject[]>('/projects');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +248,7 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
* Get a single project by ID
|
* Get a single project by ID
|
||||||
*/
|
*/
|
||||||
async getProject(projectId: string): Promise<TodoistProject> {
|
async getProject(projectId: string): Promise<TodoistProject> {
|
||||||
|
await this.ensureInitialized();
|
||||||
return this.get<TodoistProject>(`/projects/${projectId}`);
|
return this.get<TodoistProject>(`/projects/${projectId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +258,7 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
async createProject(
|
async createProject(
|
||||||
request: TodoistCreateProjectRequest,
|
request: TodoistCreateProjectRequest,
|
||||||
): Promise<TodoistProject> {
|
): Promise<TodoistProject> {
|
||||||
|
await this.ensureInitialized();
|
||||||
const requestId = this.generateRequestId();
|
const requestId = this.generateRequestId();
|
||||||
|
|
||||||
return this.post<TodoistProject>('/projects', request, {
|
return this.post<TodoistProject>('/projects', request, {
|
||||||
@@ -220,6 +275,7 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
request: TodoistUpdateProjectRequest,
|
request: TodoistUpdateProjectRequest,
|
||||||
): Promise<TodoistProject> {
|
): Promise<TodoistProject> {
|
||||||
|
await this.ensureInitialized();
|
||||||
const requestId = this.generateRequestId();
|
const requestId = this.generateRequestId();
|
||||||
|
|
||||||
return this.post<TodoistProject>(`/projects/${projectId}`, request, {
|
return this.post<TodoistProject>(`/projects/${projectId}`, request, {
|
||||||
@@ -233,6 +289,7 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
* Delete a project
|
* Delete a project
|
||||||
*/
|
*/
|
||||||
async deleteProject(projectId: string): Promise<void> {
|
async deleteProject(projectId: string): Promise<void> {
|
||||||
|
await this.ensureInitialized();
|
||||||
await this.delete<void>(`/projects/${projectId}`);
|
await this.delete<void>(`/projects/${projectId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +301,7 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
* Get all sections (optionally filtered by project)
|
* Get all sections (optionally filtered by project)
|
||||||
*/
|
*/
|
||||||
async getSections(projectId?: string): Promise<TodoistSection[]> {
|
async getSections(projectId?: string): Promise<TodoistSection[]> {
|
||||||
|
await this.ensureInitialized();
|
||||||
const params = projectId ? { project_id: projectId } : {};
|
const params = projectId ? { project_id: projectId } : {};
|
||||||
return this.get<TodoistSection[]>('/sections', { params });
|
return this.get<TodoistSection[]>('/sections', { params });
|
||||||
}
|
}
|
||||||
@@ -252,6 +310,7 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
* Get a single section by ID
|
* Get a single section by ID
|
||||||
*/
|
*/
|
||||||
async getSection(sectionId: string): Promise<TodoistSection> {
|
async getSection(sectionId: string): Promise<TodoistSection> {
|
||||||
|
await this.ensureInitialized();
|
||||||
return this.get<TodoistSection>(`/sections/${sectionId}`);
|
return this.get<TodoistSection>(`/sections/${sectionId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +320,7 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
async createSection(
|
async createSection(
|
||||||
request: TodoistCreateSectionRequest,
|
request: TodoistCreateSectionRequest,
|
||||||
): Promise<TodoistSection> {
|
): Promise<TodoistSection> {
|
||||||
|
await this.ensureInitialized();
|
||||||
const requestId = this.generateRequestId();
|
const requestId = this.generateRequestId();
|
||||||
|
|
||||||
return this.post<TodoistSection>('/sections', request, {
|
return this.post<TodoistSection>('/sections', request, {
|
||||||
@@ -274,6 +334,7 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
* Delete a section
|
* Delete a section
|
||||||
*/
|
*/
|
||||||
async deleteSection(sectionId: string): Promise<void> {
|
async deleteSection(sectionId: string): Promise<void> {
|
||||||
|
await this.ensureInitialized();
|
||||||
await this.delete<void>(`/sections/${sectionId}`);
|
await this.delete<void>(`/sections/${sectionId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,6 +346,7 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
* Get all personal labels
|
* Get all personal labels
|
||||||
*/
|
*/
|
||||||
async getLabels(): Promise<TodoistLabel[]> {
|
async getLabels(): Promise<TodoistLabel[]> {
|
||||||
|
await this.ensureInitialized();
|
||||||
return this.get<TodoistLabel[]>('/labels');
|
return this.get<TodoistLabel[]>('/labels');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,6 +354,7 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
* Get a single label by ID
|
* Get a single label by ID
|
||||||
*/
|
*/
|
||||||
async getLabel(labelId: string): Promise<TodoistLabel> {
|
async getLabel(labelId: string): Promise<TodoistLabel> {
|
||||||
|
await this.ensureInitialized();
|
||||||
return this.get<TodoistLabel>(`/labels/${labelId}`);
|
return this.get<TodoistLabel>(`/labels/${labelId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,6 +362,7 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
* Create a new label
|
* Create a new label
|
||||||
*/
|
*/
|
||||||
async createLabel(request: TodoistCreateLabelRequest): Promise<TodoistLabel> {
|
async createLabel(request: TodoistCreateLabelRequest): Promise<TodoistLabel> {
|
||||||
|
await this.ensureInitialized();
|
||||||
const requestId = this.generateRequestId();
|
const requestId = this.generateRequestId();
|
||||||
|
|
||||||
return this.post<TodoistLabel>('/labels', request, {
|
return this.post<TodoistLabel>('/labels', request, {
|
||||||
@@ -312,6 +376,7 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
* Delete a label
|
* Delete a label
|
||||||
*/
|
*/
|
||||||
async deleteLabel(labelId: string): Promise<void> {
|
async deleteLabel(labelId: string): Promise<void> {
|
||||||
|
await this.ensureInitialized();
|
||||||
await this.delete<void>(`/labels/${labelId}`);
|
await this.delete<void>(`/labels/${labelId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,6 +391,7 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
taskId?: string,
|
taskId?: string,
|
||||||
projectId?: string,
|
projectId?: string,
|
||||||
): Promise<TodoistComment[]> {
|
): Promise<TodoistComment[]> {
|
||||||
|
await this.ensureInitialized();
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
if (taskId) params.task_id = taskId;
|
if (taskId) params.task_id = taskId;
|
||||||
if (projectId) params.project_id = projectId;
|
if (projectId) params.project_id = projectId;
|
||||||
@@ -339,6 +405,7 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
async createComment(
|
async createComment(
|
||||||
request: TodoistCreateCommentRequest,
|
request: TodoistCreateCommentRequest,
|
||||||
): Promise<TodoistComment> {
|
): Promise<TodoistComment> {
|
||||||
|
await this.ensureInitialized();
|
||||||
const requestId = this.generateRequestId();
|
const requestId = this.generateRequestId();
|
||||||
|
|
||||||
return this.post<TodoistComment>('/comments', request, {
|
return this.post<TodoistComment>('/comments', request, {
|
||||||
@@ -352,6 +419,7 @@ export class TodoistConnector extends BaseConnector {
|
|||||||
* Delete a comment
|
* Delete a comment
|
||||||
*/
|
*/
|
||||||
async deleteComment(commentId: string): Promise<void> {
|
async deleteComment(commentId: string): Promise<void> {
|
||||||
|
await this.ensureInitialized();
|
||||||
await this.delete<void>(`/comments/${commentId}`);
|
await this.delete<void>(`/comments/${commentId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TodoistConnector } from './todoist.connector';
|
import { TodoistConnector } from './todoist.connector';
|
||||||
import { TodoistService } from './todoist.service';
|
import { TodoistService } from './todoist.service';
|
||||||
import { TodoistController } from './todoist.controller';
|
import { TodoistController } from './todoist.controller';
|
||||||
|
import { CredentialsModule } from '../../credentials/credentials.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [CredentialsModule],
|
||||||
controllers: [TodoistController],
|
controllers: [TodoistController],
|
||||||
providers: [TodoistConnector, TodoistService],
|
providers: [TodoistConnector, TodoistService],
|
||||||
exports: [TodoistService, TodoistConnector],
|
exports: [TodoistService, TodoistConnector],
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import {
|
import {
|
||||||
BaseConnector,
|
BaseConnector,
|
||||||
BaseConnectorConfig,
|
|
||||||
ConnectionTestResult,
|
ConnectionTestResult,
|
||||||
} from '../base-connector';
|
} from '../base-connector';
|
||||||
import { IntegrationConfigError } from '../../errors';
|
import { IntegrationConfigError } from '../../errors';
|
||||||
|
import { CredentialsService } from '../../credentials/credentials.service';
|
||||||
import {
|
import {
|
||||||
ZulipAuthConfig,
|
ZulipAuthConfig,
|
||||||
ZulipMessage,
|
ZulipMessage,
|
||||||
@@ -29,39 +29,84 @@ import {
|
|||||||
*
|
*
|
||||||
* Provides integration with ZULIP team chat platform.
|
* Provides integration with ZULIP team chat platform.
|
||||||
* Uses Basic Authentication with email and API key.
|
* Uses Basic Authentication with email and API key.
|
||||||
|
* Credentials are loaded lazily from the DB via CredentialsService.
|
||||||
*
|
*
|
||||||
* @see https://zulip.com/api/
|
* @see https://zulip.com/api/
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ZulipConnector extends BaseConnector {
|
export class ZulipConnector extends BaseConnector {
|
||||||
protected readonly name = 'ZULIP';
|
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) {
|
constructor(private readonly credentialsService: CredentialsService) {
|
||||||
const baseUrl = configService.get<string>('ZULIP_BASE_URL') || '';
|
super();
|
||||||
|
|
||||||
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') || '',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the connector is properly configured
|
* Load credentials from DB and configure the connector.
|
||||||
|
* Idempotent - returns immediately if already initialized.
|
||||||
*/
|
*/
|
||||||
isConfigured(): boolean {
|
async ensureInitialized(): Promise<void> {
|
||||||
return !!(
|
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.baseUrl &&
|
||||||
this.authConfig.email &&
|
this.authConfig.email &&
|
||||||
this.authConfig.apiKey
|
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[] {
|
getMissingConfig(): string[] {
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
if (!this.authConfig.baseUrl) missing.push('ZULIP_BASE_URL');
|
if (!this.authConfig.baseUrl) missing.push('baseUrl');
|
||||||
if (!this.authConfig.email) missing.push('ZULIP_EMAIL');
|
if (!this.authConfig.email) missing.push('email');
|
||||||
if (!this.authConfig.apiKey) missing.push('ZULIP_API_KEY');
|
if (!this.authConfig.apiKey) missing.push('apiKey');
|
||||||
return missing;
|
return missing;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +124,8 @@ export class ZulipConnector extends BaseConnector {
|
|||||||
* Test the connection to ZULIP
|
* Test the connection to ZULIP
|
||||||
*/
|
*/
|
||||||
async testConnection(): Promise<ConnectionTestResult> {
|
async testConnection(): Promise<ConnectionTestResult> {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
|
||||||
if (!this.isConfigured()) {
|
if (!this.isConfigured()) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -89,7 +136,6 @@ export class ZulipConnector extends BaseConnector {
|
|||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to get server settings (doesn't require auth but verifies the server)
|
|
||||||
const response = await this.get<{
|
const response = await this.get<{
|
||||||
result: string;
|
result: string;
|
||||||
zulip_version: string;
|
zulip_version: string;
|
||||||
@@ -100,7 +146,6 @@ export class ZulipConnector extends BaseConnector {
|
|||||||
throw new Error('Server returned non-success result');
|
throw new Error('Server returned non-success result');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify credentials by fetching the authenticated user
|
|
||||||
await this.get<{ result: string }>('/users/me');
|
await this.get<{ result: string }>('/users/me');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -127,6 +172,8 @@ export class ZulipConnector extends BaseConnector {
|
|||||||
* Get authentication headers for requests (Basic Auth)
|
* Get authentication headers for requests (Basic Auth)
|
||||||
*/
|
*/
|
||||||
async getAuthHeaders(): Promise<Record<string, string>> {
|
async getAuthHeaders(): Promise<Record<string, string>> {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
|
||||||
if (!this.isConfigured()) {
|
if (!this.isConfigured()) {
|
||||||
throw new IntegrationConfigError(this.name, this.getMissingConfig());
|
throw new IntegrationConfigError(this.name, this.getMissingConfig());
|
||||||
}
|
}
|
||||||
@@ -150,6 +197,7 @@ export class ZulipConnector extends BaseConnector {
|
|||||||
async getMessages(
|
async getMessages(
|
||||||
request: ZulipGetMessagesRequest = {},
|
request: ZulipGetMessagesRequest = {},
|
||||||
): Promise<ZulipGetMessagesResponse> {
|
): Promise<ZulipGetMessagesResponse> {
|
||||||
|
await this.ensureInitialized();
|
||||||
const params = this.buildMessagesParams(request);
|
const params = this.buildMessagesParams(request);
|
||||||
return this.get<ZulipGetMessagesResponse>('/messages', { params });
|
return this.get<ZulipGetMessagesResponse>('/messages', { params });
|
||||||
}
|
}
|
||||||
@@ -233,6 +281,7 @@ export class ZulipConnector extends BaseConnector {
|
|||||||
async sendMessage(
|
async sendMessage(
|
||||||
request: ZulipSendMessageRequest,
|
request: ZulipSendMessageRequest,
|
||||||
): Promise<ZulipSendMessageResponse> {
|
): Promise<ZulipSendMessageResponse> {
|
||||||
|
await this.ensureInitialized();
|
||||||
const formData = new URLSearchParams();
|
const formData = new URLSearchParams();
|
||||||
formData.append('type', request.type);
|
formData.append('type', request.type);
|
||||||
|
|
||||||
@@ -309,6 +358,7 @@ export class ZulipConnector extends BaseConnector {
|
|||||||
includeOwnerSubscribed?: boolean;
|
includeOwnerSubscribed?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<ZulipStream[]> {
|
): Promise<ZulipStream[]> {
|
||||||
|
await this.ensureInitialized();
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
|
|
||||||
if (options.includePublic !== undefined) {
|
if (options.includePublic !== undefined) {
|
||||||
@@ -338,6 +388,7 @@ export class ZulipConnector extends BaseConnector {
|
|||||||
* Get subscribed streams
|
* Get subscribed streams
|
||||||
*/
|
*/
|
||||||
async getSubscriptions(): Promise<ZulipStreamSubscription[]> {
|
async getSubscriptions(): Promise<ZulipStreamSubscription[]> {
|
||||||
|
await this.ensureInitialized();
|
||||||
const response = await this.get<ZulipGetSubscriptionsResponse>(
|
const response = await this.get<ZulipGetSubscriptionsResponse>(
|
||||||
'/users/me/subscriptions',
|
'/users/me/subscriptions',
|
||||||
);
|
);
|
||||||
@@ -350,6 +401,7 @@ export class ZulipConnector extends BaseConnector {
|
|||||||
async subscribe(
|
async subscribe(
|
||||||
request: ZulipSubscribeRequest,
|
request: ZulipSubscribeRequest,
|
||||||
): Promise<ZulipSubscribeResponse> {
|
): Promise<ZulipSubscribeResponse> {
|
||||||
|
await this.ensureInitialized();
|
||||||
const formData = new URLSearchParams();
|
const formData = new URLSearchParams();
|
||||||
formData.append('subscriptions', JSON.stringify(request.subscriptions));
|
formData.append('subscriptions', JSON.stringify(request.subscriptions));
|
||||||
|
|
||||||
@@ -387,6 +439,7 @@ export class ZulipConnector extends BaseConnector {
|
|||||||
* Unsubscribe from streams
|
* Unsubscribe from streams
|
||||||
*/
|
*/
|
||||||
async unsubscribe(streamNames: string[]): Promise<{ result: string }> {
|
async unsubscribe(streamNames: string[]): Promise<{ result: string }> {
|
||||||
|
await this.ensureInitialized();
|
||||||
const formData = new URLSearchParams();
|
const formData = new URLSearchParams();
|
||||||
formData.append('subscriptions', JSON.stringify(streamNames));
|
formData.append('subscriptions', JSON.stringify(streamNames));
|
||||||
|
|
||||||
@@ -408,6 +461,7 @@ export class ZulipConnector extends BaseConnector {
|
|||||||
includeCustomProfileFields?: boolean;
|
includeCustomProfileFields?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<ZulipUser[]> {
|
): Promise<ZulipUser[]> {
|
||||||
|
await this.ensureInitialized();
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
|
|
||||||
if (options.clientGravatar !== undefined) {
|
if (options.clientGravatar !== undefined) {
|
||||||
@@ -428,14 +482,15 @@ export class ZulipConnector extends BaseConnector {
|
|||||||
* Get current user profile
|
* Get current user profile
|
||||||
*/
|
*/
|
||||||
async getCurrentUser(): Promise<ZulipUser> {
|
async getCurrentUser(): Promise<ZulipUser> {
|
||||||
const response = await this.get<ZulipUser>('/users/me');
|
await this.ensureInitialized();
|
||||||
return response;
|
return this.get<ZulipUser>('/users/me');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a specific user by ID
|
* Get a specific user by ID
|
||||||
*/
|
*/
|
||||||
async getUser(userId: number): Promise<ZulipUser> {
|
async getUser(userId: number): Promise<ZulipUser> {
|
||||||
|
await this.ensureInitialized();
|
||||||
const response = await this.get<{ user: ZulipUser }>(`/users/${userId}`);
|
const response = await this.get<{ user: ZulipUser }>(`/users/${userId}`);
|
||||||
return response.user;
|
return response.user;
|
||||||
}
|
}
|
||||||
@@ -444,6 +499,7 @@ export class ZulipConnector extends BaseConnector {
|
|||||||
* Get a specific user by email
|
* Get a specific user by email
|
||||||
*/
|
*/
|
||||||
async getUserByEmail(email: string): Promise<ZulipUser> {
|
async getUserByEmail(email: string): Promise<ZulipUser> {
|
||||||
|
await this.ensureInitialized();
|
||||||
const response = await this.get<{ user: ZulipUser }>(
|
const response = await this.get<{ user: ZulipUser }>(
|
||||||
`/users/${encodeURIComponent(email)}`,
|
`/users/${encodeURIComponent(email)}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
|
|||||||
import { ZulipConnector } from './zulip.connector';
|
import { ZulipConnector } from './zulip.connector';
|
||||||
import { ZulipService } from './zulip.service';
|
import { ZulipService } from './zulip.service';
|
||||||
import { ZulipController } from './zulip.controller';
|
import { ZulipController } from './zulip.controller';
|
||||||
|
import { CredentialsModule } from '../../credentials/credentials.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [CredentialsModule],
|
||||||
controllers: [ZulipController],
|
controllers: [ZulipController],
|
||||||
providers: [ZulipConnector, ZulipService],
|
providers: [ZulipConnector, ZulipService],
|
||||||
exports: [ZulipService, ZulipConnector],
|
exports: [ZulipService, ZulipConnector],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
Logger,
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
import { EncryptionService } from '../../../common/services/encryption.service';
|
import { EncryptionService } from '../../../common/services/encryption.service';
|
||||||
import { CreateCredentialDto } from './dto/create-credential.dto';
|
import { CreateCredentialDto } from './dto/create-credential.dto';
|
||||||
@@ -61,6 +62,7 @@ export class CredentialsService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly encryptionService: EncryptionService,
|
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}`,
|
`Created credential ${credential.id} (${createDto.type}:${createDto.name}) by user ${userId}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.eventEmitter.emit('credentials.changed', { type: credential.type });
|
||||||
|
|
||||||
return this.mapToListItem(credential);
|
return this.mapToListItem(credential);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +294,8 @@ export class CredentialsService {
|
|||||||
|
|
||||||
this.logger.log(`Updated credential ${id} by user ${userId}`);
|
this.logger.log(`Updated credential ${id} by user ${userId}`);
|
||||||
|
|
||||||
|
this.eventEmitter.emit('credentials.changed', { type: updated.type });
|
||||||
|
|
||||||
return this.mapToListItem(updated);
|
return this.mapToListItem(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,6 +328,8 @@ export class CredentialsService {
|
|||||||
|
|
||||||
this.logger.log(`Deactivated credential ${id} by user ${userId}`);
|
this.logger.log(`Deactivated credential ${id} by user ${userId}`);
|
||||||
|
|
||||||
|
this.eventEmitter.emit('credentials.changed', { type: updated.type });
|
||||||
|
|
||||||
return this.mapToListItem(updated);
|
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
|
* Updates the sync status of a credential
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -160,6 +160,9 @@ export class IntegrationsService {
|
|||||||
try {
|
try {
|
||||||
const { service, connector } = this.getServiceAndConnector(type);
|
const { service, connector } = this.getServiceAndConnector(type);
|
||||||
|
|
||||||
|
// Ensure credentials are loaded from DB before checking configured state
|
||||||
|
await connector.ensureInitialized();
|
||||||
|
|
||||||
status.configured = service.isConfigured();
|
status.configured = service.isConfigured();
|
||||||
|
|
||||||
if (!status.configured) {
|
if (!status.configured) {
|
||||||
@@ -199,7 +202,10 @@ export class IntegrationsService {
|
|||||||
this.logger.log(`Checking health for ${meta.name}`);
|
this.logger.log(`Checking health for ${meta.name}`);
|
||||||
|
|
||||||
try {
|
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()) {
|
if (!service.isConfigured()) {
|
||||||
return {
|
return {
|
||||||
@@ -242,7 +248,7 @@ export class IntegrationsService {
|
|||||||
*/
|
*/
|
||||||
private getServiceAndConnector(type: IntegrationType): {
|
private getServiceAndConnector(type: IntegrationType): {
|
||||||
service: { isConfigured: () => boolean; testConnection: () => Promise<{ success: boolean; message: string; latencyMs?: number; details?: Record<string, unknown> }> };
|
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) {
|
switch (type) {
|
||||||
case 'plentyone':
|
case 'plentyone':
|
||||||
@@ -263,17 +269,17 @@ export class IntegrationsService {
|
|||||||
case 'freescout':
|
case 'freescout':
|
||||||
return {
|
return {
|
||||||
service: this.freescoutService,
|
service: this.freescoutService,
|
||||||
connector: this.freescoutConnector as { getMissingConfig?: () => string[] },
|
connector: this.freescoutConnector as { ensureInitialized: () => Promise<void>; getMissingConfig?: () => string[] },
|
||||||
};
|
};
|
||||||
case 'nextcloud':
|
case 'nextcloud':
|
||||||
return {
|
return {
|
||||||
service: this.nextcloudService,
|
service: this.nextcloudService,
|
||||||
connector: this.nextcloudConnector as { getMissingConfig?: () => string[] },
|
connector: this.nextcloudConnector as { ensureInitialized: () => Promise<void>; getMissingConfig?: () => string[] },
|
||||||
};
|
};
|
||||||
case 'ecodms':
|
case 'ecodms':
|
||||||
return {
|
return {
|
||||||
service: this.ecodmsService,
|
service: this.ecodmsService,
|
||||||
connector: this.ecodmsConnector as { getMissingConfig?: () => string[] },
|
connector: this.ecodmsConnector as { ensureInitialized: () => Promise<void>; getMissingConfig?: () => string[] },
|
||||||
};
|
};
|
||||||
case 'gembadocs':
|
case 'gembadocs':
|
||||||
return {
|
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",
|
"departments": "Abteilungen",
|
||||||
"overview": "Uebersicht",
|
"overview": "Uebersicht",
|
||||||
"plentyOne": "PlentyONE",
|
"plentyOne": "PlentyONE",
|
||||||
"zulip": "ZULIP"
|
"zulip": "ZULIP",
|
||||||
|
"systemSettings": "Einstellungen"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -1048,5 +1049,29 @@
|
|||||||
"minutes": "Minuten",
|
"minutes": "Minuten",
|
||||||
"seconds": "Sekunden",
|
"seconds": "Sekunden",
|
||||||
"days": "Tage"
|
"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",
|
"departments": "Departments",
|
||||||
"overview": "Overview",
|
"overview": "Overview",
|
||||||
"plentyOne": "PlentyONE",
|
"plentyOne": "PlentyONE",
|
||||||
"zulip": "ZULIP"
|
"zulip": "ZULIP",
|
||||||
|
"systemSettings": "Settings"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -1048,5 +1049,29 @@
|
|||||||
"minutes": "minutes",
|
"minutes": "minutes",
|
||||||
"seconds": "seconds",
|
"seconds": "seconds",
|
||||||
"days": "days"
|
"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';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
Plug,
|
|
||||||
Shield,
|
Shield,
|
||||||
Building2,
|
Building2,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
@@ -16,6 +15,7 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
Loader2,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@@ -41,8 +41,16 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
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 { 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 { useToast } from '@/hooks/use-toast';
|
||||||
import type { IntegrationType } from '@/types/integrations';
|
import type { IntegrationType } from '@/types/integrations';
|
||||||
|
|
||||||
@@ -50,7 +58,11 @@ interface AdminIntegrationsContentProps {
|
|||||||
locale: string;
|
locale: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Integration metadata */
|
// ============================================================
|
||||||
|
// Static configuration
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** Display metadata per integration */
|
||||||
const integrationMeta: Record<
|
const integrationMeta: Record<
|
||||||
IntegrationType,
|
IntegrationType,
|
||||||
{ icon: LucideIcon; nameKey: string; descKey: string }
|
{ icon: LucideIcon; nameKey: string; descKey: string }
|
||||||
@@ -64,77 +76,394 @@ const integrationMeta: Record<
|
|||||||
gembadocs: { icon: ClipboardCheck, nameKey: 'gembaDocs', descKey: 'gembaDocsDesc' },
|
gembadocs: { icon: ClipboardCheck, nameKey: 'gembaDocs', descKey: 'gembaDocsDesc' },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Credential field configuration per integration */
|
/** Credential field definitions per integration.
|
||||||
const credentialFields: Record<IntegrationType, { key: string; labelKey: string; type: 'text' | 'password' | 'url' }[]> = {
|
* 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: [
|
plentyone: [
|
||||||
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
|
{ key: 'apiUrl', label: 'API URL', type: 'url', placeholder: 'https://your-shop.plentymarkets-cloud01.com' },
|
||||||
{ key: 'username', labelKey: 'username', type: 'text' },
|
{ key: 'apiKey', label: 'API Schluessel', type: 'password' },
|
||||||
{ key: 'password', labelKey: 'password', type: 'password' },
|
|
||||||
],
|
],
|
||||||
zulip: [
|
zulip: [
|
||||||
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
|
{ key: 'zulipUrl', label: 'Server URL', type: 'url', placeholder: 'https://your-org.zulipchat.com' },
|
||||||
{ key: 'email', labelKey: 'username', type: 'text' },
|
{ key: 'botEmail', label: 'Bot E-Mail', type: 'text' },
|
||||||
{ key: 'apiKey', labelKey: 'apiKey', type: 'password' },
|
{ key: 'apiKey', label: 'API Schluessel', type: 'password' },
|
||||||
],
|
],
|
||||||
todoist: [
|
todoist: [
|
||||||
{ key: 'apiKey', labelKey: 'apiKey', type: 'password' },
|
{ key: 'apiToken', label: 'API Token', type: 'password' },
|
||||||
],
|
],
|
||||||
freescout: [
|
freescout: [
|
||||||
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
|
{ key: 'apiUrl', label: 'API URL', type: 'url', placeholder: 'https://helpdesk.example.com' },
|
||||||
{ key: 'apiKey', labelKey: 'apiKey', type: 'password' },
|
{ key: 'apiKey', label: 'API Schluessel', type: 'password' },
|
||||||
],
|
],
|
||||||
nextcloud: [
|
nextcloud: [
|
||||||
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
|
{ key: 'serverUrl', label: 'Server URL', type: 'url', placeholder: 'https://cloud.example.com' },
|
||||||
{ key: 'username', labelKey: 'username', type: 'text' },
|
{ key: 'username', label: 'Benutzername', type: 'text' },
|
||||||
{ key: 'password', labelKey: 'password', type: 'password' },
|
{ key: 'password', label: 'Passwort', type: 'password' },
|
||||||
],
|
],
|
||||||
ecodms: [
|
ecodms: [
|
||||||
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
|
{ key: 'apiUrl', label: 'API URL', type: 'url', placeholder: 'https://ecodms.example.com' },
|
||||||
{ key: 'apiKey', labelKey: 'apiKey', type: 'password' },
|
{ key: 'username', label: 'Benutzername', type: 'text' },
|
||||||
|
{ key: 'password', label: 'Passwort', type: 'password' },
|
||||||
],
|
],
|
||||||
gembadocs: [
|
gembadocs: [
|
||||||
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
|
{ key: 'apiUrl', label: 'API URL', type: 'url', placeholder: 'https://gembadocs.example.com' },
|
||||||
{ key: 'apiKey', labelKey: 'apiKey', type: 'password' },
|
{ 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 t = useTranslations('integrations');
|
||||||
const tAdmin = useTranslations('admin');
|
const tAdmin = useTranslations('admin');
|
||||||
const { toast } = useToast();
|
|
||||||
const { data: integrations, isLoading } = useAllIntegrationStatuses();
|
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 (
|
return (
|
||||||
<div className="container mx-auto space-y-8 py-6">
|
<div className="container mx-auto space-y-8 py-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -143,14 +472,16 @@ export function AdminIntegrationsContent({ locale }: AdminIntegrationsContentPro
|
|||||||
<Shield className="h-5 w-5 text-primary" />
|
<Shield className="h-5 w-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<p className="text-muted-foreground">
|
||||||
{tAdmin('integrationManagementDesc')}
|
{tAdmin('integrationManagementDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Integration Tabs */}
|
{/* Loading state */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
@@ -184,139 +515,17 @@ export function AdminIntegrationsContent({ locale }: AdminIntegrationsContentPro
|
|||||||
})}
|
})}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{integrations?.map((config) => {
|
{integrations?.map((config) => (
|
||||||
const meta = integrationMeta[config.type];
|
<TabsContent key={config.type} value={config.type}>
|
||||||
const Icon = meta.icon;
|
<motion.div
|
||||||
const fields = credentialFields[config.type];
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
return (
|
transition={{ duration: 0.3 }}
|
||||||
<TabsContent key={config.type} value={config.type}>
|
>
|
||||||
<motion.div
|
<IntegrationPanel integrationType={config.type} />
|
||||||
initial={{ opacity: 0, y: 20 }}
|
</motion.div>
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</TabsContent>
|
||||||
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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 dateLocale = locale === 'de' ? de : enUS;
|
||||||
|
|
||||||
const meta = integrationMeta[integrationType];
|
const meta = integrationMeta[integrationType];
|
||||||
const Icon = meta?.icon ?? Building2;
|
|
||||||
const ContentComponent = integrationContentMap[integrationType];
|
const ContentComponent = integrationContentMap[integrationType];
|
||||||
|
|
||||||
const handleSync = () => {
|
const handleSync = () => {
|
||||||
triggerSync.mutate(integrationType);
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto space-y-6 py-6">
|
<div className="container mx-auto space-y-6 py-6">
|
||||||
@@ -105,13 +121,19 @@ export function IntegrationDetailContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!config) {
|
// Use API data or fallback to disconnected state
|
||||||
return (
|
const displayConfig = config ?? {
|
||||||
<div className="container mx-auto py-6">
|
id: integrationType,
|
||||||
<p className="text-muted-foreground">Integration nicht gefunden.</p>
|
type: integrationType,
|
||||||
</div>
|
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 (
|
return (
|
||||||
<div className="container mx-auto space-y-6 py-6">
|
<div className="container mx-auto space-y-6 py-6">
|
||||||
@@ -136,7 +158,7 @@ export function IntegrationDetailContent({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-12 w-12 items-center justify-center rounded-lg',
|
'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" />
|
<Icon className="h-6 w-6" />
|
||||||
@@ -144,7 +166,7 @@ export function IntegrationDetailContent({
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CardTitle className="text-xl">{t(meta.nameKey as never)}</CardTitle>
|
<CardTitle className="text-xl">{t(meta.nameKey as never)}</CardTitle>
|
||||||
<IntegrationStatusBadge status={config.status} />
|
<IntegrationStatusBadge status={displayConfig.status} />
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>{t(meta.descKey as never)}</CardDescription>
|
<CardDescription>{t(meta.descKey as never)}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,7 +178,7 @@ export function IntegrationDetailContent({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleSync}
|
onClick={handleSync}
|
||||||
disabled={triggerSync.isPending || config.status !== 'connected'}
|
disabled={triggerSync.isPending || displayConfig.status !== 'connected'}
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
className={cn('mr-2 h-4 w-4', triggerSync.isPending && 'animate-spin')}
|
className={cn('mr-2 h-4 w-4', triggerSync.isPending && 'animate-spin')}
|
||||||
@@ -169,9 +191,9 @@ export function IntegrationDetailContent({
|
|||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<SyncStatus
|
<SyncStatus
|
||||||
lastSync={config.lastSync}
|
lastSync={displayConfig.lastSync}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
error={config.lastError}
|
error={displayConfig.lastError}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -227,15 +249,15 @@ export function IntegrationDetailContent({
|
|||||||
<div className="flex items-center justify-between border-b py-2">
|
<div className="flex items-center justify-between border-b py-2">
|
||||||
<span className="text-muted-foreground">{t('syncSuccessful')}</span>
|
<span className="text-muted-foreground">{t('syncSuccessful')}</span>
|
||||||
<span>
|
<span>
|
||||||
{config.lastSync
|
{displayConfig.lastSync
|
||||||
? formatDistanceToNow(config.lastSync, { addSuffix: true, locale: dateLocale })
|
? formatDistanceToNow(displayConfig.lastSync, { addSuffix: true, locale: dateLocale })
|
||||||
: '-'}
|
: '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{config.lastError && (
|
{displayConfig.lastError && (
|
||||||
<div className="rounded-lg bg-destructive/10 p-3 text-destructive">
|
<div className="rounded-lg bg-destructive/10 p-3 text-destructive">
|
||||||
<p className="font-medium">{t('lastError')}</p>
|
<p className="font-medium">{t('lastError')}</p>
|
||||||
<p>{config.lastError}</p>
|
<p>{displayConfig.lastError}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { de, enUS } from 'date-fns/locale';
|
import { de, enUS } from 'date-fns/locale';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
@@ -125,6 +126,11 @@ export function ActivityWidget({
|
|||||||
}: ActivityWidgetProps) {
|
}: ActivityWidgetProps) {
|
||||||
const t = useTranslations('widgets.activity');
|
const t = useTranslations('widgets.activity');
|
||||||
const dateLocale = locale === 'de' ? de : enUS;
|
const dateLocale = locale === 'de' ? de : enUS;
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const displayedActivities = activities.slice(0, maxItems);
|
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-sm text-muted-foreground">{activity.description}</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{formatDistanceToNow(activity.timestamp, {
|
{mounted
|
||||||
addSuffix: true,
|
? formatDistanceToNow(activity.timestamp, {
|
||||||
locale: dateLocale,
|
addSuffix: true,
|
||||||
})}
|
locale: dateLocale,
|
||||||
|
})
|
||||||
|
: '\u00A0'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ interface NavItem {
|
|||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
requiredRoles?: UserRole[];
|
requiredRoles?: UserRole[];
|
||||||
children?: NavItem[];
|
children?: NavItem[];
|
||||||
|
exactMatch?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Main navigation structure with role requirements */
|
/** Main navigation structure with role requirements */
|
||||||
@@ -115,6 +116,7 @@ const mainNavItems: NavItem[] = [
|
|||||||
key: 'overview',
|
key: 'overview',
|
||||||
href: '/integrations',
|
href: '/integrations',
|
||||||
icon: Plug,
|
icon: Plug,
|
||||||
|
exactMatch: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'plentyOne',
|
key: 'plentyOne',
|
||||||
@@ -152,6 +154,11 @@ const bottomNavItems: NavItem[] = [
|
|||||||
href: '/admin/integrations',
|
href: '/admin/integrations',
|
||||||
icon: Plug,
|
icon: Plug,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'systemSettings',
|
||||||
|
href: '/admin/settings',
|
||||||
|
icon: Settings,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -209,14 +216,15 @@ export function Sidebar({ locale }: SidebarProps) {
|
|||||||
const filteredMainNav = filterNavItems(mainNavItems, userRoles);
|
const filteredMainNav = filterNavItems(mainNavItems, userRoles);
|
||||||
const filteredBottomNav = filterNavItems(bottomNavItems, userRoles);
|
const filteredBottomNav = filterNavItems(bottomNavItems, userRoles);
|
||||||
|
|
||||||
const isActive = (href: string) => {
|
const isActive = (href: string, exactMatch?: boolean) => {
|
||||||
const localePath = `/${locale}${href}`;
|
const localePath = `/${locale}${href}`;
|
||||||
|
if (exactMatch) return pathname === localePath;
|
||||||
return pathname === localePath || pathname.startsWith(`${localePath}/`);
|
return pathname === localePath || pathname.startsWith(`${localePath}/`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isParentActive = (item: NavItem) => {
|
const isParentActive = (item: NavItem) => {
|
||||||
if (isActive(item.href)) return true;
|
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 (
|
return (
|
||||||
@@ -307,7 +315,7 @@ interface NavItemComponentProps {
|
|||||||
item: NavItem;
|
item: NavItem;
|
||||||
locale: string;
|
locale: string;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
isActive: (href: string) => boolean;
|
isActive: (href: string, exactMatch?: boolean) => boolean;
|
||||||
isParentActive: (item: NavItem) => boolean;
|
isParentActive: (item: NavItem) => boolean;
|
||||||
t: (key: string) => string;
|
t: (key: string) => string;
|
||||||
}
|
}
|
||||||
@@ -321,7 +329,7 @@ function NavItemComponent({
|
|||||||
t,
|
t,
|
||||||
}: NavItemComponentProps) {
|
}: NavItemComponentProps) {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const active = isActive(item.href);
|
const active = isActive(item.href, item.exactMatch);
|
||||||
const parentActive = isParentActive(item);
|
const parentActive = isParentActive(item);
|
||||||
const hasChildren = item.children && item.children.length > 0;
|
const hasChildren = item.children && item.children.length > 0;
|
||||||
|
|
||||||
@@ -383,7 +391,7 @@ function NavItemComponent({
|
|||||||
href={`/${locale}${child.href}`}
|
href={`/${locale}${child.href}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded px-2 py-1 text-sm transition-colors hover:bg-accent',
|
'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)}
|
{t(child.key)}
|
||||||
@@ -426,7 +434,7 @@ function NavItemComponent({
|
|||||||
>
|
>
|
||||||
{item.children?.map((child) => {
|
{item.children?.map((child) => {
|
||||||
const ChildIcon = child.icon;
|
const ChildIcon = child.icon;
|
||||||
const childActive = isActive(child.href);
|
const childActive = isActive(child.href, child.exactMatch);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -1,4 +1,22 @@
|
|||||||
// Integration hooks barrel export
|
// 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 {
|
export {
|
||||||
useIntegrationStatus,
|
useIntegrationStatus,
|
||||||
useAllIntegrationStatuses,
|
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
|
// Handle empty responses
|
||||||
const contentType = response.headers.get('content-type');
|
const contentType = response.headers.get('content-type');
|
||||||
if (contentType?.includes('application/json')) {
|
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;
|
return {} as T;
|
||||||
|
|||||||
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
@@ -32,6 +32,9 @@ importers:
|
|||||||
'@nestjs/core':
|
'@nestjs/core':
|
||||||
specifier: ^10.3.0
|
specifier: ^10.3.0
|
||||||
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/event-emitter':
|
||||||
|
specifier: ^3.0.1
|
||||||
|
version: 3.0.1(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
|
||||||
'@nestjs/jwt':
|
'@nestjs/jwt':
|
||||||
specifier: ^10.2.0
|
specifier: ^10.2.0
|
||||||
version: 10.2.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
version: 10.2.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||||
@@ -1157,6 +1160,12 @@ packages:
|
|||||||
'@nestjs/websockets':
|
'@nestjs/websockets':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@nestjs/event-emitter@3.0.1':
|
||||||
|
resolution: {integrity: sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@nestjs/common': ^10.0.0 || ^11.0.0
|
||||||
|
'@nestjs/core': ^10.0.0 || ^11.0.0
|
||||||
|
|
||||||
'@nestjs/jwt@10.2.0':
|
'@nestjs/jwt@10.2.0':
|
||||||
resolution: {integrity: sha512-x8cG90SURkEiLOehNaN2aRlotxT0KZESUliOPKKnjWiyJOcWurkF3w345WOX0P4MgFzUjGoZ1Sy0aZnxeihT0g==}
|
resolution: {integrity: sha512-x8cG90SURkEiLOehNaN2aRlotxT0KZESUliOPKKnjWiyJOcWurkF3w345WOX0P4MgFzUjGoZ1Sy0aZnxeihT0g==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3526,6 +3535,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
eventemitter2@6.4.9:
|
||||||
|
resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==}
|
||||||
|
|
||||||
eventemitter3@4.0.7:
|
eventemitter3@4.0.7:
|
||||||
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
|
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
|
||||||
|
|
||||||
@@ -7000,6 +7012,12 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- encoding
|
- encoding
|
||||||
|
|
||||||
|
'@nestjs/event-emitter@3.0.1(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)':
|
||||||
|
dependencies:
|
||||||
|
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
eventemitter2: 6.4.9
|
||||||
|
|
||||||
'@nestjs/jwt@10.2.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))':
|
'@nestjs/jwt@10.2.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
@@ -9528,6 +9546,8 @@ snapshots:
|
|||||||
|
|
||||||
etag@1.8.1: {}
|
etag@1.8.1: {}
|
||||||
|
|
||||||
|
eventemitter2@6.4.9: {}
|
||||||
|
|
||||||
eventemitter3@4.0.7: {}
|
eventemitter3@4.0.7: {}
|
||||||
|
|
||||||
events@3.3.0: {}
|
events@3.3.0: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user