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:
2026-02-23 20:07:39 +01:00
parent 068446fbbf
commit 6a8265d3dc
46 changed files with 2972 additions and 1149 deletions

View File

@@ -16,65 +16,37 @@ KEYCLOAK_REALM=tOS
KEYCLOAK_CLIENT_ID=tos-backend
KEYCLOAK_CLIENT_SECRET=your-keycloak-backend-client-secret
# CORS
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
# Swagger
SWAGGER_ENABLED=true
# =============================================================================
# Phase 3: Integrations & Sync Jobs
# =============================================================================
# Encryption
# IMPORTANT: Generate a secure 32+ character key for production!
# You can generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
ENCRYPTION_KEY=your-32-byte-encryption-key-change-in-production
# Redis (required for BullMQ in production)
REDIS_HOST=localhost
REDIS_PORT=6379
# Sync Jobs
# Set to 'true' to enable automatic background sync jobs
ENABLE_SYNC_JOBS=false
# Sync Intervals (in minutes)
SYNC_INTERVAL_PLENTYONE=15
SYNC_INTERVAL_ZULIP=5
SYNC_INTERVAL_TODOIST=10
SYNC_INTERVAL_FREESCOUT=10
SYNC_INTERVAL_NEXTCLOUD=30
SYNC_INTERVAL_ECODMS=60
SYNC_INTERVAL_GEMBADOCS=30
# REDIS_HOST=localhost
# REDIS_PORT=6379
# =============================================================================
# Phase 3: API Connector Credentials
# Settings moved to the database (SystemSettings table)
# =============================================================================
# PlentyONE (OAuth2 Client Credentials)
PLENTYONE_BASE_URL=
PLENTYONE_CLIENT_ID=
PLENTYONE_CLIENT_SECRET=
# ZULIP (Basic Auth with API Key)
ZULIP_BASE_URL=
ZULIP_EMAIL=
ZULIP_API_KEY=
# Todoist (Bearer Token)
TODOIST_API_TOKEN=
# FreeScout (API Key)
FREESCOUT_API_URL=
FREESCOUT_API_KEY=
# Nextcloud (Basic Auth / App Password)
NEXTCLOUD_URL=
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
# ecoDMS (Session-based Auth)
ECODMS_API_URL=
ECODMS_USERNAME=
ECODMS_PASSWORD=
# The following env vars are no longer read by the application.
# Their values are stored in the database and can be managed via the
# admin UI at /admin/system-settings or via the API at PUT /api/v1/system-settings/:key.
#
# To seed initial values from a .env file, run the migration script:
# npx ts-node prisma/migrate-env-to-db.ts
#
# Keys and their DB equivalents:
# CORS_ORIGINS -> cors.origins (cors category)
# SWAGGER_ENABLED -> feature.swagger.enabled (feature category)
# ENABLE_SYNC_JOBS -> feature.syncJobs.enabled (feature category)
# SYNC_INTERVAL_PLENTYONE -> sync.interval.plentyone (sync category)
# SYNC_INTERVAL_ZULIP -> sync.interval.zulip (sync category)
# SYNC_INTERVAL_TODOIST -> sync.interval.todoist (sync category)
# SYNC_INTERVAL_FREESCOUT -> sync.interval.freescout (sync category)
# SYNC_INTERVAL_NEXTCLOUD -> sync.interval.nextcloud (sync category)
# SYNC_INTERVAL_ECODMS -> sync.interval.ecodms (sync category)
# SYNC_INTERVAL_GEMBADOCS -> sync.interval.gembadocs (sync category)
#
# Integration credentials (PLENTYONE_*, ZULIP_*, TODOIST_*, FREESCOUT_*,
# NEXTCLOUD_*, ECODMS_*, GEMBADOCS_*) are stored encrypted in the
# IntegrationCredential table and managed via /admin/integrations.

View File

@@ -26,16 +26,17 @@
"db:seed": "prisma db seed"
},
"dependencies": {
"@tos/shared": "workspace:*",
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/swagger": "^7.2.0",
"@nestjs/terminus": "^10.2.0",
"@prisma/client": "^5.8.0",
"@tos/shared": "workspace:*",
"axios": "^1.6.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",

View 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);
});

View File

@@ -654,3 +654,21 @@ model AuditLog {
@@index([action])
@@index([createdAt])
}
// =============================================================================
// SYSTEM SETTINGS
// =============================================================================
model SystemSetting {
id String @id @default(cuid())
key String @unique
value String
category String
description String?
valueType String @default("string")
isSecret Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([category])
}

View File

@@ -202,6 +202,45 @@ async function main() {
console.log(`Created ${skills.length} skills`);
// Seed default system settings
const defaultSettings = [
// Sync intervals
{ key: 'sync.interval.plentyone', value: '15', category: 'sync', valueType: 'number', description: 'PlentyONE Sync-Intervall (Minuten)' },
{ key: 'sync.interval.zulip', value: '5', category: 'sync', valueType: 'number', description: 'Zulip Sync-Intervall (Minuten)' },
{ key: 'sync.interval.todoist', value: '10', category: 'sync', valueType: 'number', description: 'Todoist Sync-Intervall (Minuten)' },
{ key: 'sync.interval.freescout', value: '10', category: 'sync', valueType: 'number', description: 'FreeScout Sync-Intervall (Minuten)' },
{ key: 'sync.interval.nextcloud', value: '30', category: 'sync', valueType: 'number', description: 'Nextcloud Sync-Intervall (Minuten)' },
{ key: 'sync.interval.ecodms', value: '60', category: 'sync', valueType: 'number', description: 'ecoDMS Sync-Intervall (Minuten)' },
{ key: 'sync.interval.gembadocs', value: '30', category: 'sync', valueType: 'number', description: 'GembaDocs Sync-Intervall (Minuten)' },
// Feature flags
{ key: 'feature.syncJobs.enabled', value: 'false', category: 'feature', valueType: 'boolean', description: 'Hintergrund-Sync-Jobs aktivieren' },
{ key: 'feature.swagger.enabled', value: 'true', category: 'feature', valueType: 'boolean', description: 'Swagger API-Dokumentation aktivieren' },
// CORS
{ key: 'cors.origins', value: 'http://localhost:3000', category: 'cors', valueType: 'string', description: 'Erlaubte CORS Origins (kommagetrennt)' },
// Branding
{ key: 'branding.appName', value: 'tOS', category: 'branding', valueType: 'string', description: 'Anwendungsname' },
{ key: 'branding.companyName', value: '', category: 'branding', valueType: 'string', description: 'Firmenname' },
{ key: 'branding.logoUrl', value: '', category: 'branding', valueType: 'string', description: 'Logo-URL' },
];
await Promise.all(
defaultSettings.map((setting) =>
prisma.systemSetting.upsert({
where: { key: setting.key },
update: {},
create: {
key: setting.key,
value: setting.value,
category: setting.category,
valueType: setting.valueType,
description: setting.description,
},
}),
),
);
console.log(`Seeded ${defaultSettings.length} system settings`);
console.log('Database seeding completed!');
}

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
@@ -18,6 +19,9 @@ import { DashboardModule } from './modules/dashboard/dashboard.module';
import { DepartmentsModule } from './modules/departments/departments.module';
import { UserPreferencesModule } from './modules/user-preferences/user-preferences.module';
// Phase 1 modules - System Settings
import { SystemSettingsModule } from './modules/system-settings/system-settings.module';
// Phase 4 modules - LEAN
import { LeanModule } from './modules/lean/lean.module';
@@ -40,6 +44,9 @@ import { IntegrationsModule } from './modules/integrations/integrations.module';
},
}),
// Event emitter for decoupled inter-module communication
EventEmitterModule.forRoot(),
// Database
PrismaModule,
@@ -51,6 +58,9 @@ import { IntegrationsModule } from './modules/integrations/integrations.module';
UsersModule,
HealthModule,
// Phase 1 - System Settings (database-backed configuration)
SystemSettingsModule,
// Phase 2 modules
AuditModule,
DashboardModule,

View File

@@ -84,6 +84,10 @@ export enum Permission {
MEETING_CREATE = 'meeting:create',
MEETING_UPDATE = 'meeting:update',
MEETING_DELETE = 'meeting:delete',
// System Settings
SYSTEM_SETTINGS_VIEW = 'system_settings:view',
SYSTEM_SETTINGS_MANAGE = 'system_settings:manage',
}
/**

View File

@@ -1,44 +1,61 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { Injectable, UnauthorizedException, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { passportJwtSecret } from 'jwks-rsa';
import { JwtPayload } from '../interfaces/jwt-payload.interface';
import { UsersService } from '../../users/users.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
private readonly logger = new Logger(JwtStrategy.name);
constructor(
private readonly configService: ConfigService,
private readonly usersService: UsersService,
) {
const secret = configService.get<string>('JWT_SECRET');
if (!secret) {
throw new Error('JWT_SECRET is not defined');
}
const keycloakUrl = configService.get<string>('KEYCLOAK_URL');
const keycloakRealm = configService.get<string>('KEYCLOAK_REALM');
const issuer = `${keycloakUrl}/realms/${keycloakRealm}`;
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: secret,
// Validate RS256 tokens via Keycloak's JWKS endpoint
secretOrKeyProvider: passportJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 10,
jwksUri: `${issuer}/protocol/openid-connect/certs`,
}),
issuer,
algorithms: ['RS256'],
});
}
async validate(payload: JwtPayload): Promise<JwtPayload> {
// Optionally validate that the user still exists and is active
async validate(payload: Record<string, unknown>): Promise<JwtPayload> {
// Extract Keycloak-specific fields
const sub = payload.sub as string;
const email = payload.email as string;
const realmAccess = payload.realm_access as { roles?: string[] } | undefined;
const roles = realmAccess?.roles || [];
if (!sub) {
throw new UnauthorizedException('Invalid token: missing sub');
}
// Try to validate against local user DB; if user doesn't exist locally, allow anyway
// (Keycloak is the source of truth for identity)
try {
const user = await this.usersService.findOne(payload.sub);
const user = await this.usersService.findOne(sub);
if (!user.isActive) {
throw new UnauthorizedException('User account is deactivated');
}
// Return the payload to be attached to the request
return {
sub: payload.sub,
email: payload.email,
roles: payload.roles,
};
} catch {
throw new UnauthorizedException('Invalid token');
// User not in local DB yet - that's OK, Keycloak token is valid
this.logger.debug(`User ${sub} not found in local DB, proceeding with Keycloak token`);
}
return { sub, email, roles };
}
}

View File

@@ -20,62 +20,14 @@ export const configValidationSchema = Joi.object({
KEYCLOAK_CLIENT_ID: Joi.string().optional(),
KEYCLOAK_CLIENT_SECRET: Joi.string().optional(),
// CORS
CORS_ORIGINS: Joi.string().default('http://localhost:3000'),
// Swagger
SWAGGER_ENABLED: Joi.string().valid('true', 'false').default('true'),
// Encryption (Phase 3)
// Encryption
ENCRYPTION_KEY: Joi.string().min(32).when('NODE_ENV', {
is: 'production',
then: Joi.required(),
otherwise: Joi.optional(),
}),
// Redis (Phase 3 - for BullMQ)
// Redis (optional - for BullMQ in production)
REDIS_HOST: Joi.string().optional(),
REDIS_PORT: Joi.number().optional(),
// Sync Jobs (Phase 3)
ENABLE_SYNC_JOBS: Joi.string().valid('true', 'false').default('false'),
SYNC_INTERVAL_PLENTYONE: Joi.number().min(1).default(15),
SYNC_INTERVAL_ZULIP: Joi.number().min(1).default(5),
SYNC_INTERVAL_TODOIST: Joi.number().min(1).default(10),
SYNC_INTERVAL_FREESCOUT: Joi.number().min(1).default(10),
SYNC_INTERVAL_NEXTCLOUD: Joi.number().min(1).default(30),
SYNC_INTERVAL_ECODMS: Joi.number().min(1).default(60),
SYNC_INTERVAL_GEMBADOCS: Joi.number().min(1).default(30),
// ============================================================================
// Integration Credentials (Phase 3 - API Connectors)
// ============================================================================
// PlentyONE (OAuth2 Client Credentials)
PLENTYONE_BASE_URL: Joi.string().uri().optional(),
PLENTYONE_CLIENT_ID: Joi.string().optional(),
PLENTYONE_CLIENT_SECRET: Joi.string().optional(),
// ZULIP (Basic Auth with API Key)
ZULIP_BASE_URL: Joi.string().uri().optional(),
ZULIP_EMAIL: Joi.string().email().optional(),
ZULIP_API_KEY: Joi.string().optional(),
// Todoist (Bearer Token)
TODOIST_API_TOKEN: Joi.string().optional(),
// FreeScout (API Key)
FREESCOUT_API_URL: Joi.string().uri().optional(),
FREESCOUT_API_KEY: Joi.string().optional(),
// Nextcloud (Basic Auth / App Password)
NEXTCLOUD_URL: Joi.string().uri().optional(),
NEXTCLOUD_USERNAME: Joi.string().optional(),
NEXTCLOUD_PASSWORD: Joi.string().optional(),
// ecoDMS (Session-based Auth)
ECODMS_API_URL: Joi.string().uri().optional(),
ECODMS_USERNAME: Joi.string().optional(),
ECODMS_PASSWORD: Joi.string().optional(),
ECODMS_API_VERSION: Joi.string().default('v1'),
});

View File

@@ -6,23 +6,33 @@ import helmet from 'helmet';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
import { SystemSettingsService } from './modules/system-settings/system-settings.service';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
const systemSettings = app.get(SystemSettingsService);
// Security
app.use(helmet());
// CORS
const corsOrigins = configService.get<string>('CORS_ORIGINS')?.split(',').map((origin) => origin.trim()) || [
'http://localhost:3000',
];
// CORS - origins are read dynamically from the database on each request
app.enableCors({
origin: corsOrigins,
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
origin: async (origin, callback) => {
const allowed = await systemSettings.getValue('cors.origins');
const origins = allowed
? allowed.split(',').map((s) => s.trim())
: ['http://localhost:3000'];
if (!origin || origins.includes(origin) || origins.includes('*')) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
});
// API Prefix
@@ -53,8 +63,8 @@ async function bootstrap() {
// Global Interceptors
app.useGlobalInterceptors(new TransformInterceptor());
// Swagger Documentation
const swaggerEnabled = configService.get<string>('SWAGGER_ENABLED') === 'true';
// Swagger Documentation - enabled/disabled via DB setting
const swaggerEnabled = await systemSettings.getTypedValue('feature.swagger.enabled', true);
if (swaggerEnabled) {
const config = new DocumentBuilder()
.setTitle('tOS API')

View File

@@ -72,12 +72,25 @@ export abstract class BaseConnector {
protected readonly logger: Logger;
/** Axios HTTP client instance */
protected readonly httpClient: AxiosInstance;
protected httpClient: AxiosInstance;
/** Connector configuration */
protected readonly config: Required<BaseConnectorConfig>;
protected config: Required<BaseConnectorConfig>;
constructor(config: BaseConnectorConfig) {
constructor(config?: BaseConnectorConfig) {
// Logger will be initialized with the concrete class name
this.logger = new Logger(this.constructor.name);
if (config?.baseUrl) {
this.reconfigure(config);
}
}
/**
* Create or recreate the HTTP client with the given configuration.
* Call this when credentials are loaded from DB or updated.
*/
protected reconfigure(config: BaseConnectorConfig): void {
this.config = {
timeout: 30000,
maxRetries: 3,
@@ -87,9 +100,6 @@ export abstract class BaseConnector {
...config,
};
// Logger will be initialized with the concrete class name
this.logger = new Logger(this.constructor.name);
// Create axios instance with base configuration
this.httpClient = axios.create({
baseURL: this.config.baseUrl,
@@ -100,7 +110,7 @@ export abstract class BaseConnector {
},
});
// Setup interceptors
// Setup interceptors on the new client
this.setupRequestInterceptor();
this.setupResponseInterceptor();
}
@@ -125,7 +135,7 @@ export abstract class BaseConnector {
/**
* Setup request interceptor for logging and authentication
*/
private setupRequestInterceptor(): void {
protected setupRequestInterceptor(): void {
this.httpClient.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
// Add authentication headers
@@ -161,7 +171,7 @@ export abstract class BaseConnector {
/**
* Setup response interceptor for logging and error transformation
*/
private setupResponseInterceptor(): void {
protected setupResponseInterceptor(): void {
this.httpClient.interceptors.response.use(
(response: AxiosResponse) => {
const config = response.config as InternalAxiosRequestConfig & { metadata?: { startTime: number } };
@@ -186,7 +196,7 @@ export abstract class BaseConnector {
/**
* Transform axios errors into integration-specific errors
*/
private handleResponseError(error: AxiosError): Promise<never> {
protected handleResponseError(error: AxiosError): Promise<never> {
const status = error.response?.status;
const message = this.extractErrorMessage(error);

View File

@@ -1,7 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OnEvent } from '@nestjs/event-emitter';
import axios, { AxiosInstance, AxiosError } from 'axios';
import FormData from 'form-data';
import { CredentialsService } from '../../credentials/credentials.service';
import {
IntegrationConnectionError,
IntegrationAuthError,
@@ -21,9 +22,6 @@ export interface ConnectorHealth {
details?: Record<string, unknown>;
}
/**
* Retry configuration
*/
interface RetryConfig {
maxRetries: number;
baseDelayMs: number;
@@ -73,70 +71,76 @@ import {
export class EcoDmsConnector {
private readonly logger = new Logger(EcoDmsConnector.name);
private readonly integrationName = 'ecoDMS';
private readonly httpClient: AxiosInstance;
private httpClient: AxiosInstance;
private readonly retryConfig: RetryConfig;
private isConfigured: boolean = false;
private configuredState: boolean = false;
private initialized = false;
private readonly baseUrl: string;
private readonly username: string;
private readonly password: string;
private readonly apiVersion: string;
private baseUrl: string = '';
private username: string = '';
private password: string = '';
private readonly apiVersion: string = 'v1';
// Session management
private session: EcoDmsSession | null = null;
private sessionRefreshTimer: NodeJS.Timeout | null = null;
constructor(private readonly configService: ConfigService) {
constructor(private readonly credentialsService: CredentialsService) {
this.retryConfig = { ...DEFAULT_RETRY_CONFIG };
// Load configuration from environment
this.baseUrl = this.configService.get<string>('ECODMS_API_URL', '');
this.username = this.configService.get<string>('ECODMS_USERNAME', '');
this.password = this.configService.get<string>('ECODMS_PASSWORD', '');
this.apiVersion = this.configService.get<string>('ECODMS_API_VERSION', 'v1');
// Validate configuration
this.validateConfiguration();
// Initialize HTTP client
this.httpClient = axios.create({
baseURL: this.baseUrl ? `${this.baseUrl}/api/${this.apiVersion}` : undefined,
timeout: DEFAULT_TIMEOUT_MS,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
this.setupInterceptors();
if (this.isConfigured) {
this.logger.log(`ecoDMS connector initialized with base URL: ${this.baseUrl}`);
} else {
this.logger.warn('ecoDMS connector not configured - missing credentials');
}
this.httpClient = axios.create();
}
/**
* Validate required configuration
*/
private validateConfiguration(): void {
const missing: string[] = [];
async ensureInitialized(): Promise<void> {
if (this.initialized) return;
if (!this.baseUrl) {
missing.push('ECODMS_API_URL');
}
if (!this.username) {
missing.push('ECODMS_USERNAME');
}
if (!this.password) {
missing.push('ECODMS_PASSWORD');
const result = await this.credentialsService.findActiveByType('ECODMS');
if (!result) {
this.configuredState = false;
this.initialized = true;
this.logger.warn('ecoDMS connector not configured - no active credentials found');
return;
}
this.isConfigured = missing.length === 0;
this.baseUrl = result.credentials.apiUrl || '';
this.username = result.credentials.username || '';
this.password = result.credentials.password || '';
this.configuredState = !!(this.baseUrl && this.username && this.password);
if (!this.isConfigured) {
this.logger.warn(`ecoDMS configuration incomplete. Missing: ${missing.join(', ')}`);
if (this.configuredState) {
this.httpClient = axios.create({
baseURL: `${this.baseUrl}/api/${this.apiVersion}`,
timeout: DEFAULT_TIMEOUT_MS,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
this.setupInterceptors();
this.logger.log(`ecoDMS connector initialized with base URL: ${this.baseUrl}`);
} else {
this.logger.warn('ecoDMS connector not configured - missing apiUrl, username, or password');
}
this.initialized = true;
}
async reload(): Promise<void> {
this.initialized = false;
this.configuredState = false;
this.session = null;
if (this.sessionRefreshTimer) {
clearTimeout(this.sessionRefreshTimer);
this.sessionRefreshTimer = null;
}
await this.ensureInitialized();
}
@OnEvent('credentials.changed')
async onCredentialsChanged(payload: { type: string }): Promise<void> {
if (payload.type === 'ECODMS') {
this.logger.log('ecoDMS credentials changed, reloading');
await this.reload();
}
}
@@ -194,16 +198,9 @@ export class EcoDmsConnector {
);
}
/**
* Ensure the connector is configured
*/
private ensureConfigured(): void {
if (!this.isConfigured) {
throw new IntegrationConfigError(this.integrationName, [
'ECODMS_API_URL',
'ECODMS_USERNAME',
'ECODMS_PASSWORD',
]);
if (!this.configuredState) {
throw new IntegrationConfigError(this.integrationName, ['apiUrl', 'username', 'password']);
}
}
@@ -431,7 +428,8 @@ export class EcoDmsConnector {
* Check connector health
*/
async checkHealth(): Promise<ConnectorHealth> {
if (!this.isConfigured) {
await this.ensureInitialized();
if (!this.configuredState) {
return {
status: 'not_configured',
lastCheck: new Date(),
@@ -466,6 +464,7 @@ export class EcoDmsConnector {
* Test connection
*/
async testConnection(): Promise<{ success: boolean; message: string; latency?: number }> {
await this.ensureInitialized();
this.ensureConfigured();
const startTime = Date.now();
@@ -484,10 +483,10 @@ export class EcoDmsConnector {
}
/**
* Check if connector is configured
* Check if connector is configured (last known state)
*/
getIsConfigured(): boolean {
return this.isConfigured;
return this.configuredState;
}
// ============ Documents API ============
@@ -499,6 +498,7 @@ export class EcoDmsConnector {
documents: EcoDmsDocument[];
pagination: { page: number; pageSize: number; total: number; totalPages: number };
}> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -551,6 +551,7 @@ export class EcoDmsConnector {
* Get a single document by ID
*/
async getDocument(id: number): Promise<EcoDmsDocument> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -574,6 +575,7 @@ export class EcoDmsConnector {
* Search documents
*/
async searchDocuments(params: SearchDocumentsDto): Promise<EcoDmsSearchResult> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -632,6 +634,7 @@ export class EcoDmsConnector {
mimeType: string,
metadata: UploadDocumentDto,
): Promise<EcoDmsDocument> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -685,6 +688,7 @@ export class EcoDmsConnector {
* Update document metadata
*/
async updateDocument(id: number, data: UpdateDocumentDto): Promise<EcoDmsDocument> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -708,6 +712,7 @@ export class EcoDmsConnector {
* Delete a document
*/
async deleteDocument(id: number): Promise<void> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -728,6 +733,7 @@ export class EcoDmsConnector {
* Download document content
*/
async downloadDocument(id: number): Promise<{ content: Buffer; fileName: string; mimeType: string }> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -752,6 +758,7 @@ export class EcoDmsConnector {
* Get document preview (thumbnail or PDF preview)
*/
async getDocumentPreview(id: number, page: number = 1): Promise<Buffer> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -770,6 +777,7 @@ export class EcoDmsConnector {
* List folders
*/
async listFolders(parentId?: number): Promise<EcoDmsFolder[]> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -798,6 +806,7 @@ export class EcoDmsConnector {
* Get folder tree
*/
async getFolderTree(): Promise<EcoDmsFolder[]> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -820,6 +829,7 @@ export class EcoDmsConnector {
* Create a folder
*/
async createFolder(data: CreateFolderDto): Promise<EcoDmsFolder> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -843,6 +853,7 @@ export class EcoDmsConnector {
* Delete a folder
*/
async deleteFolder(id: number): Promise<void> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -865,6 +876,7 @@ export class EcoDmsConnector {
* List classifications (document types/categories)
*/
async listClassifications(): Promise<EcoDmsClassification[]> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -887,6 +899,7 @@ export class EcoDmsConnector {
* Get classification details
*/
async getClassification(id: number): Promise<EcoDmsClassification> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {

View File

@@ -1,33 +1,17 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { EcoDmsConnector } from './ecodms.connector';
import { EcoDmsService } from './ecodms.service';
import { EcoDmsController } from './ecodms.controller';
import { CredentialsModule } from '../../credentials/credentials.module';
/**
* ecoDMS Integration Module
*
* Provides integration with ecoDMS document management system.
*
* Required environment variables:
* - ECODMS_API_URL: Base URL of ecoDMS API (e.g., https://ecodms.example.com)
* - ECODMS_USERNAME: Username for authentication
* - ECODMS_PASSWORD: Password for authentication
*
* Optional environment variables:
* - ECODMS_API_VERSION: API version (default: v1)
*
* Features:
* - Session-based authentication with automatic refresh
* - Document CRUD operations
* - Full-text search with attribute filters
* - Folder management
* - Classification/Category management
* - Document download and preview
* - OCR support
* Credentials are stored in the DB and loaded via CredentialsService.
*/
@Module({
imports: [ConfigModule],
imports: [CredentialsModule],
controllers: [EcoDmsController],
providers: [EcoDmsConnector, EcoDmsService],
exports: [EcoDmsService, EcoDmsConnector],

View File

@@ -1,5 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OnEvent } from '@nestjs/event-emitter';
import axios, { AxiosInstance, AxiosError } from 'axios';
import {
IntegrationConnectionError,
@@ -8,6 +8,7 @@ import {
IntegrationApiError,
IntegrationConfigError,
} from '../../errors';
import { CredentialsService } from '../../credentials/credentials.service';
/**
* Health status of a connector
@@ -49,7 +50,6 @@ import {
ReplyToConversationDto,
CreateCustomerDto,
ListCustomersDto,
ConversationType,
ThreadType,
} from './freescout.types';
@@ -57,12 +57,7 @@ import {
* FreeScout API Connector
*
* Provides integration with FreeScout helpdesk system.
* Features:
* - API Key authentication
* - Conversations/Tickets management
* - Mailboxes API
* - Customers API
* - Tags API
* Credentials are loaded lazily from the DB via CredentialsService.
*
* API Documentation: https://github.com/freescout-helpdesk/freescout/wiki/API
*/
@@ -70,60 +65,78 @@ import {
export class FreeScoutConnector {
private readonly logger = new Logger(FreeScoutConnector.name);
private readonly integrationName = 'FreeScout';
private readonly httpClient: AxiosInstance;
private httpClient: AxiosInstance;
private readonly retryConfig: RetryConfig;
private isConfigured: boolean = false;
private configuredState: boolean = false;
private initialized = false;
private readonly baseUrl: string;
private readonly apiKey: string;
private baseUrl: string = '';
private apiKey: string = '';
constructor(private readonly configService: ConfigService) {
constructor(private readonly credentialsService: CredentialsService) {
this.retryConfig = { ...DEFAULT_RETRY_CONFIG };
// Load configuration from environment
this.baseUrl = this.configService.get<string>('FREESCOUT_API_URL', '');
this.apiKey = this.configService.get<string>('FREESCOUT_API_KEY', '');
// Validate configuration
this.validateConfiguration();
// Initialize HTTP client
this.httpClient = axios.create({
baseURL: this.baseUrl ? `${this.baseUrl}/api` : undefined,
timeout: DEFAULT_TIMEOUT_MS,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-FreeScout-API-Key': this.apiKey,
},
});
this.setupInterceptors();
if (this.isConfigured) {
this.logger.log(`FreeScout connector initialized with base URL: ${this.baseUrl}`);
} else {
this.logger.warn('FreeScout connector not configured - missing API URL or API Key');
}
// httpClient will be initialized lazily on first ensureInitialized() call
this.httpClient = axios.create(); // placeholder, replaced on init
}
/**
* Validate required configuration
* Load credentials from DB and configure the connector.
* Idempotent - returns immediately if already initialized.
*/
private validateConfiguration(): void {
const missing: string[] = [];
async ensureInitialized(): Promise<void> {
if (this.initialized) return;
if (!this.baseUrl) {
missing.push('FREESCOUT_API_URL');
}
if (!this.apiKey) {
missing.push('FREESCOUT_API_KEY');
const result = await this.credentialsService.findActiveByType('FREESCOUT');
if (!result) {
this.configuredState = false;
this.initialized = true;
this.logger.warn('FreeScout connector not configured - no active credentials found');
return;
}
this.isConfigured = missing.length === 0;
this.baseUrl = result.credentials.apiUrl || '';
this.apiKey = result.credentials.apiKey || '';
if (!this.isConfigured) {
this.logger.warn(`FreeScout configuration incomplete. Missing: ${missing.join(', ')}`);
this.configuredState = !!(this.baseUrl && this.apiKey);
if (this.configuredState) {
this.httpClient = axios.create({
baseURL: `${this.baseUrl}/api`,
timeout: DEFAULT_TIMEOUT_MS,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-FreeScout-API-Key': this.apiKey,
},
});
this.setupInterceptors();
this.logger.log(`FreeScout connector initialized with base URL: ${this.baseUrl}`);
} else {
this.logger.warn('FreeScout connector not configured - missing apiUrl or apiKey in credentials');
}
this.initialized = true;
}
/**
* Reload credentials from DB (called when credentials change)
*/
async reload(): Promise<void> {
this.initialized = false;
this.configuredState = false;
await this.ensureInitialized();
}
/**
* Handle credential change events
*/
@OnEvent('credentials.changed')
async onCredentialsChanged(payload: { type: string }): Promise<void> {
if (payload.type === 'FREESCOUT') {
this.logger.log('FreeScout credentials changed, reloading');
await this.reload();
}
}
@@ -162,8 +175,8 @@ export class FreeScoutConnector {
* Ensure the connector is configured before making API calls
*/
private ensureConfigured(): void {
if (!this.isConfigured) {
throw new IntegrationConfigError(this.integrationName, ['FREESCOUT_API_URL', 'FREESCOUT_API_KEY']);
if (!this.configuredState) {
throw new IntegrationConfigError(this.integrationName, ['apiUrl', 'apiKey']);
}
}
@@ -210,7 +223,7 @@ export class FreeScoutConnector {
}
if (!error.response) {
return true; // Network errors are retryable
return true;
}
const status = error.response.status;
@@ -265,7 +278,7 @@ export class FreeScoutConnector {
`Access forbidden: ${message}`,
error,
);
case 429:
case 429: {
const retryAfter = parseInt(error.response.headers['retry-after'] || '60', 10);
return new IntegrationRateLimitError(
this.integrationName,
@@ -273,6 +286,7 @@ export class FreeScoutConnector {
retryAfter,
error,
);
}
default:
return new IntegrationApiError(
this.integrationName,
@@ -297,7 +311,9 @@ export class FreeScoutConnector {
* Check connector health
*/
async checkHealth(): Promise<ConnectorHealth> {
if (!this.isConfigured) {
await this.ensureInitialized();
if (!this.configuredState) {
return {
status: 'not_configured',
lastCheck: new Date(),
@@ -327,6 +343,7 @@ export class FreeScoutConnector {
* Test connection to FreeScout API
*/
async testConnection(): Promise<{ success: boolean; message: string; latency?: number }> {
await this.ensureInitialized();
this.ensureConfigured();
const startTime = Date.now();
@@ -342,16 +359,15 @@ export class FreeScoutConnector {
latency,
};
} catch (error) {
const latency = Date.now() - startTime;
throw this.mapError(error as AxiosError, 'testConnection');
}
}
/**
* Check if connector is configured
* Check if connector is configured (last known state)
*/
getIsConfigured(): boolean {
return this.isConfigured;
return this.configuredState;
}
// ============ Conversations API ============
@@ -363,6 +379,7 @@ export class FreeScoutConnector {
conversations: FreeScoutConversation[];
pagination: { page: number; pageSize: number; total: number; totalPages: number };
}> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -407,6 +424,7 @@ export class FreeScoutConnector {
* Get a single conversation by ID
*/
async getConversation(id: number): Promise<FreeScoutConversation> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -419,6 +437,7 @@ export class FreeScoutConnector {
* Create a new conversation
*/
async createConversation(data: CreateConversationDto): Promise<FreeScoutConversation> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -454,6 +473,7 @@ export class FreeScoutConnector {
conversationId: number,
data: ReplyToConversationDto,
): Promise<FreeScoutConversation> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -482,6 +502,7 @@ export class FreeScoutConnector {
conversationId: number,
status: string,
): Promise<FreeScoutConversation> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -499,6 +520,7 @@ export class FreeScoutConnector {
* List all mailboxes
*/
async listMailboxes(): Promise<FreeScoutMailbox[]> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -511,6 +533,7 @@ export class FreeScoutConnector {
* Get a single mailbox by ID
*/
async getMailbox(id: number): Promise<FreeScoutMailbox> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -528,6 +551,7 @@ export class FreeScoutConnector {
customers: FreeScoutCustomer[];
pagination: { page: number; pageSize: number; total: number; totalPages: number };
}> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -566,6 +590,7 @@ export class FreeScoutConnector {
* Get a single customer by ID
*/
async getCustomer(id: number): Promise<FreeScoutCustomer> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -578,6 +603,7 @@ export class FreeScoutConnector {
* Create a new customer
*/
async createCustomer(data: CreateCustomerDto): Promise<FreeScoutCustomer> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -590,6 +616,7 @@ export class FreeScoutConnector {
* Find customer by email
*/
async findCustomerByEmail(email: string): Promise<FreeScoutCustomer | null> {
await this.ensureInitialized();
this.ensureConfigured();
const result = await this.listCustomers({ email });
@@ -602,6 +629,7 @@ export class FreeScoutConnector {
* List all tags
*/
async listTags(): Promise<FreeScoutTag[]> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -614,6 +642,7 @@ export class FreeScoutConnector {
* Add tags to a conversation
*/
async addTagsToConversation(conversationId: number, tags: string[]): Promise<void> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {

View File

@@ -1,20 +1,17 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { FreeScoutConnector } from './freescout.connector';
import { FreeScoutService } from './freescout.service';
import { FreeScoutController } from './freescout.controller';
import { CredentialsModule } from '../../credentials/credentials.module';
/**
* FreeScout Integration Module
*
* Provides integration with FreeScout helpdesk system.
*
* Required environment variables:
* - FREESCOUT_API_URL: Base URL of FreeScout instance (e.g., https://support.example.com)
* - FREESCOUT_API_KEY: API key for authentication
* Credentials are stored in the DB and loaded via CredentialsService.
*/
@Module({
imports: [ConfigModule],
imports: [CredentialsModule],
controllers: [FreeScoutController],
providers: [FreeScoutConnector, FreeScoutService],
exports: [FreeScoutService, FreeScoutConnector],

View File

@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OnEvent } from '@nestjs/event-emitter';
import axios, { AxiosInstance, AxiosError } from 'axios';
import { CredentialsService } from '../../credentials/credentials.service';
import {
IntegrationConnectionError,
IntegrationAuthError,
@@ -73,59 +74,64 @@ const DEFAULT_TIMEOUT_MS = 30000;
export class GembaDocsConnector {
private readonly logger = new Logger(GembaDocsConnector.name);
private readonly integrationName = 'GembaDocs';
private readonly httpClient: AxiosInstance;
private httpClient: AxiosInstance;
private readonly retryConfig: RetryConfig;
private isConfiguredFlag: boolean = false;
private initialized = false;
private readonly baseUrl: string;
private readonly apiKey: string;
private baseUrl: string = '';
private apiKey: string = '';
constructor(private readonly configService: ConfigService) {
constructor(private readonly credentialsService: CredentialsService) {
this.retryConfig = { ...DEFAULT_RETRY_CONFIG };
// Load configuration from environment
this.baseUrl = this.configService.get<string>('GEMBADOCS_API_URL', '');
this.apiKey = this.configService.get<string>('GEMBADOCS_API_KEY', '');
// Validate configuration
this.validateConfiguration();
// Initialize HTTP client
this.httpClient = axios.create({
baseURL: this.baseUrl ? `${this.baseUrl}/api/v1` : undefined,
timeout: DEFAULT_TIMEOUT_MS,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
this.setupInterceptors();
if (this.isConfiguredFlag) {
this.logger.log(`GembaDocs connector initialized with base URL: ${this.baseUrl}`);
} else {
this.logger.warn('GembaDocs connector not configured - missing credentials');
}
this.httpClient = axios.create();
}
/**
* Validate required configuration
*/
private validateConfiguration(): void {
const missing: string[] = [];
async ensureInitialized(): Promise<void> {
if (this.initialized) return;
if (!this.baseUrl) {
missing.push('GEMBADOCS_API_URL');
}
if (!this.apiKey) {
missing.push('GEMBADOCS_API_KEY');
const result = await this.credentialsService.findActiveByType('GEMBADOCS');
if (!result) {
this.isConfiguredFlag = false;
this.initialized = true;
this.logger.warn('GembaDocs connector not configured - no active credentials found');
return;
}
this.isConfiguredFlag = missing.length === 0;
this.baseUrl = result.credentials.apiUrl || '';
this.apiKey = result.credentials.apiKey || '';
this.isConfiguredFlag = !!(this.baseUrl && this.apiKey);
if (!this.isConfiguredFlag) {
this.logger.warn(`GembaDocs configuration incomplete. Missing: ${missing.join(', ')}`);
if (this.isConfiguredFlag) {
this.httpClient = axios.create({
baseURL: `${this.baseUrl}/api/v1`,
timeout: DEFAULT_TIMEOUT_MS,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
this.setupInterceptors();
this.logger.log(`GembaDocs connector initialized with base URL: ${this.baseUrl}`);
} else {
this.logger.warn('GembaDocs connector not configured - missing apiUrl or apiKey');
}
this.initialized = true;
}
async reload(): Promise<void> {
this.initialized = false;
this.isConfiguredFlag = false;
await this.ensureInitialized();
}
@OnEvent('credentials.changed')
async onCredentialsChanged(payload: { type: string }): Promise<void> {
if (payload.type === 'GEMBADOCS') {
this.logger.log('GembaDocs credentials changed, reloading');
await this.reload();
}
}
@@ -163,15 +169,9 @@ export class GembaDocsConnector {
);
}
/**
* Ensure the connector is configured
*/
private ensureConfigured(): void {
if (!this.isConfiguredFlag) {
throw new IntegrationConfigError(this.integrationName, [
'GEMBADOCS_API_URL',
'GEMBADOCS_API_KEY',
]);
throw new IntegrationConfigError(this.integrationName, ['apiUrl', 'apiKey']);
}
}
@@ -309,6 +309,7 @@ export class GembaDocsConnector {
* Check connector health
*/
async checkHealth(): Promise<ConnectorHealth> {
await this.ensureInitialized();
if (!this.isConfiguredFlag) {
return {
status: 'not_configured',
@@ -340,6 +341,7 @@ export class GembaDocsConnector {
* Test connection
*/
async testConnection(): Promise<{ success: boolean; message: string; latency?: number }> {
await this.ensureInitialized();
this.ensureConfigured();
const startTime = Date.now();
@@ -379,8 +381,8 @@ export class GembaDocsConnector {
*/
getMissingConfig(): string[] {
const missing: string[] = [];
if (!this.baseUrl) missing.push('GEMBADOCS_API_URL');
if (!this.apiKey) missing.push('GEMBADOCS_API_KEY');
if (!this.baseUrl) missing.push('apiUrl');
if (!this.apiKey) missing.push('apiKey');
return missing;
}
@@ -393,6 +395,7 @@ export class GembaDocsConnector {
audits: GembaAudit[];
pagination: { page: number; pageSize: number; total: number; totalPages: number };
}> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -440,6 +443,7 @@ export class GembaDocsConnector {
* Get a single audit by ID
*/
async getAudit(id: string): Promise<GembaAudit> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -463,6 +467,7 @@ export class GembaDocsConnector {
* Get upcoming audits (scheduled for the future)
*/
async getUpcomingAudits(days: number = 7, limit: number = 10): Promise<GembaAudit[]> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -498,6 +503,7 @@ export class GembaDocsConnector {
* Get overdue audits (scheduled in the past but not completed)
*/
async getOverdueAudits(limit: number = 10): Promise<GembaAudit[]> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -534,6 +540,7 @@ export class GembaDocsConnector {
* Create a new audit
*/
async createAudit(data: CreateAuditDto): Promise<GembaAudit> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -557,6 +564,7 @@ export class GembaDocsConnector {
* Update an audit
*/
async updateAudit(id: string, data: UpdateAuditDto): Promise<GembaAudit> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -582,6 +590,7 @@ export class GembaDocsConnector {
* List all checklist templates
*/
async listChecklists(): Promise<GembaChecklist[]> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -604,6 +613,7 @@ export class GembaDocsConnector {
* Get a single checklist by ID
*/
async getChecklist(id: string): Promise<GembaChecklist> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -632,6 +642,7 @@ export class GembaDocsConnector {
findings: GembaFinding[];
pagination: { page: number; pageSize: number; total: number; totalPages: number };
}> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -677,6 +688,7 @@ export class GembaDocsConnector {
* Get open findings
*/
async getOpenFindings(limit: number = 20): Promise<GembaFinding[]> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -706,6 +718,7 @@ export class GembaDocsConnector {
* Update a finding
*/
async updateFinding(id: string, data: UpdateFindingDto): Promise<GembaFinding> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -729,6 +742,7 @@ export class GembaDocsConnector {
* Resolve a finding
*/
async resolveFinding(id: string, data: ResolveFindingDto): Promise<GembaFinding> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -754,6 +768,7 @@ export class GembaDocsConnector {
* Get audit statistics
*/
async getStatistics(department?: string): Promise<GembaStatistics> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {
@@ -780,6 +795,7 @@ export class GembaDocsConnector {
* Get trend data for charts
*/
async getTrends(params: GetTrendsDto = {}): Promise<GembaTrendData> {
await this.ensureInitialized();
this.ensureConfigured();
return this.executeWithRetry(async () => {

View File

@@ -1,17 +1,16 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { GembaDocsConnector } from './gembadocs.connector';
import { GembaDocsService } from './gembadocs.service';
import { GembaDocsController } from './gembadocs.controller';
import { CredentialsModule } from '../../credentials/credentials.module';
/**
* GembaDocs Integration Module
*
* Provides integration with GembaDocs audit and checklist management system.
*
* Required environment variables:
* - GEMBADOCS_API_URL: Base URL of GembaDocs API (e.g., https://api.gembadocs.com)
* - GEMBADOCS_API_KEY: API key for authentication
* Credentials are stored in the DB and loaded via CredentialsService.
* Required credential keys: apiUrl, apiKey
*
* Features:
* - API Key authentication
@@ -57,7 +56,7 @@ import { GembaDocsController } from './gembadocs.controller';
* - GET /integrations/gembadocs/compliance-score - Compliance score
*/
@Module({
imports: [ConfigModule],
imports: [CredentialsModule],
controllers: [GembaDocsController],
providers: [GembaDocsConnector, GembaDocsService],
exports: [GembaDocsService, GembaDocsConnector],

View File

@@ -1,27 +1,17 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { NextcloudConnector } from './nextcloud.connector';
import { NextcloudService } from './nextcloud.service';
import { NextcloudController } from './nextcloud.controller';
import { CredentialsModule } from '../../credentials/credentials.module';
/**
* Nextcloud Integration Module
*
* Provides integration with Nextcloud cloud storage.
*
* Required environment variables:
* - NEXTCLOUD_URL: Base URL of Nextcloud instance (e.g., https://cloud.example.com)
* - NEXTCLOUD_USERNAME: Username for authentication
* - NEXTCLOUD_PASSWORD: Password or App Password for authentication
*
* Features:
* - WebDAV file operations (list, upload, download, delete, move, copy)
* - OCS Share API (create public links, share with users)
* - User info and quota
* - Calendar events (CalDAV)
* Credentials are stored in the DB and loaded via CredentialsService.
*/
@Module({
imports: [ConfigModule],
imports: [CredentialsModule],
controllers: [NextcloudController],
providers: [NextcloudConnector, NextcloudService],
exports: [NextcloudService, NextcloudConnector],

View File

@@ -1,15 +1,15 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OnEvent } from '@nestjs/event-emitter';
import axios from 'axios';
import {
BaseConnector,
BaseConnectorConfig,
ConnectionTestResult,
} from '../base-connector';
import {
IntegrationAuthError,
IntegrationConfigError,
} from '../../errors';
import { CredentialsService } from '../../credentials/credentials.service';
import {
PlentyoneAuthConfig,
PlentyoneTokenInfo,
@@ -29,41 +29,88 @@ import {
*
* Provides integration with PlentyONE e-commerce platform.
* Handles OAuth2 authentication, token management, and API calls.
* Credentials are loaded lazily from the DB via CredentialsService.
*
* @see https://developers.plentymarkets.com/
*/
@Injectable()
export class PlentyoneConnector extends BaseConnector {
protected readonly name = 'PlentyONE';
private readonly authConfig: PlentyoneAuthConfig;
private authConfig: PlentyoneAuthConfig = { baseUrl: '', clientId: '', clientSecret: '' };
private tokenInfo: PlentyoneTokenInfo | null = null;
private tokenRefreshPromise: Promise<void> | null = null;
private initialized = false;
private configuredState = false;
constructor(private readonly configService: ConfigService) {
const baseUrl = configService.get<string>('PLENTYONE_BASE_URL') || '';
super({
baseUrl: baseUrl ? `${baseUrl}/rest` : '',
timeout: 60000, // PlentyONE can be slow
maxRetries: 3,
});
this.authConfig = {
baseUrl,
clientId: configService.get<string>('PLENTYONE_CLIENT_ID') || '',
clientSecret: configService.get<string>('PLENTYONE_CLIENT_SECRET') || '',
};
constructor(private readonly credentialsService: CredentialsService) {
super();
}
/**
* Check if the connector is properly configured
* Load credentials from DB and configure the connector.
* Idempotent - returns immediately if already initialized.
*/
isConfigured(): boolean {
return !!(
async ensureInitialized(): Promise<void> {
if (this.initialized) return;
const result = await this.credentialsService.findActiveByType('PLENTYONE');
if (!result) {
this.configuredState = false;
this.initialized = true;
return;
}
const { baseUrl, clientId, clientSecret } = result.credentials;
this.authConfig = {
baseUrl: baseUrl || '',
clientId: clientId || '',
clientSecret: clientSecret || '',
};
if (this.authConfig.baseUrl) {
this.reconfigure({
baseUrl: `${this.authConfig.baseUrl}/rest`,
timeout: 60000,
maxRetries: 3,
});
}
this.configuredState = !!(
this.authConfig.baseUrl &&
this.authConfig.clientId &&
this.authConfig.clientSecret
);
this.initialized = true;
}
/**
* Reload credentials from DB (called when credentials change)
*/
async reload(): Promise<void> {
this.initialized = false;
this.tokenInfo = null;
this.tokenRefreshPromise = null;
await this.ensureInitialized();
}
/**
* Handle credential change events
*/
@OnEvent('credentials.changed')
async onCredentialsChanged(payload: { type: string }): Promise<void> {
if (payload.type === 'PLENTYONE') {
this.logger.log('PlentyONE credentials changed, reloading');
await this.reload();
}
}
/**
* Check if the connector is properly configured (last known state)
*/
isConfigured(): boolean {
return this.configuredState;
}
/**
@@ -71,9 +118,9 @@ export class PlentyoneConnector extends BaseConnector {
*/
getMissingConfig(): string[] {
const missing: string[] = [];
if (!this.authConfig.baseUrl) missing.push('PLENTYONE_BASE_URL');
if (!this.authConfig.clientId) missing.push('PLENTYONE_CLIENT_ID');
if (!this.authConfig.clientSecret) missing.push('PLENTYONE_CLIENT_SECRET');
if (!this.authConfig.baseUrl) missing.push('baseUrl');
if (!this.authConfig.clientId) missing.push('clientId');
if (!this.authConfig.clientSecret) missing.push('clientSecret');
return missing;
}
@@ -81,6 +128,8 @@ export class PlentyoneConnector extends BaseConnector {
* Test the connection to PlentyONE
*/
async testConnection(): Promise<ConnectionTestResult> {
await this.ensureInitialized();
if (!this.isConfigured()) {
return {
success: false,
@@ -91,10 +140,7 @@ export class PlentyoneConnector extends BaseConnector {
const startTime = Date.now();
try {
// Try to authenticate
await this.ensureAuthenticated();
// Make a simple API call to verify the token works
await this.get<{ version: string }>('/');
return {
@@ -119,6 +165,8 @@ export class PlentyoneConnector extends BaseConnector {
* Get authentication headers for requests
*/
async getAuthHeaders(): Promise<Record<string, string>> {
await this.ensureInitialized();
if (!this.isConfigured()) {
throw new IntegrationConfigError(this.name, this.getMissingConfig());
}
@@ -134,18 +182,15 @@ export class PlentyoneConnector extends BaseConnector {
* Ensure we have a valid access token
*/
private async ensureAuthenticated(): Promise<void> {
// If token is valid, return immediately
if (this.isTokenValid()) {
return;
}
// If a refresh is already in progress, wait for it
if (this.tokenRefreshPromise) {
await this.tokenRefreshPromise;
return;
}
// Start token refresh
this.tokenRefreshPromise = this.refreshAccessToken();
try {
@@ -161,7 +206,6 @@ export class PlentyoneConnector extends BaseConnector {
private isTokenValid(): boolean {
if (!this.tokenInfo) return false;
// Consider token invalid if it expires in less than 5 minutes
const bufferMs = 5 * 60 * 1000;
return this.tokenInfo.expiresAt.getTime() > Date.now() + bufferMs;
}
@@ -217,6 +261,7 @@ export class PlentyoneConnector extends BaseConnector {
async getOrders(
query?: PlentyoneOrdersQuery,
): Promise<PlentyonePaginatedResponse<PlentyoneOrder>> {
await this.ensureInitialized();
const params = this.buildQueryParams(query);
return this.get<PlentyonePaginatedResponse<PlentyoneOrder>>(
'/orders',
@@ -231,6 +276,7 @@ export class PlentyoneConnector extends BaseConnector {
orderId: number,
withRelations?: string[],
): Promise<PlentyoneOrder> {
await this.ensureInitialized();
const params: Record<string, string> = {};
if (withRelations?.length) {
params.with = withRelations.join(',');
@@ -285,6 +331,7 @@ export class PlentyoneConnector extends BaseConnector {
async getStock(
query?: PlentyoneStockQuery,
): Promise<PlentyonePaginatedResponse<PlentyoneStockItem>> {
await this.ensureInitialized();
const params = this.buildQueryParams(query);
return this.get<PlentyonePaginatedResponse<PlentyoneStockItem>>(
'/stockmanagement/stock',
@@ -336,7 +383,6 @@ export class PlentyoneConnector extends BaseConnector {
hasMore = !response.isLastPage;
page++;
// Safety limit
if (page > 100) break;
}
@@ -351,21 +397,18 @@ export class PlentyoneConnector extends BaseConnector {
* Get order statistics
*/
async getOrderStats(query: PlentyoneStatsQuery): Promise<PlentyoneOrderStats> {
// Get orders within the date range
const orders = await this.getAllOrdersInRange(
new Date(query.dateFrom),
new Date(query.dateTo),
query.statusId,
);
// Calculate statistics
let totalRevenue = 0;
let totalRevenueNet = 0;
const ordersByStatus: Record<number, number> = {};
const ordersByReferrer: Record<number, number> = {};
for (const order of orders) {
// Sum up amounts
if (order.amounts?.length) {
const primaryAmount = order.amounts.find((a) => a.isSystemCurrency);
if (primaryAmount) {
@@ -374,10 +417,7 @@ export class PlentyoneConnector extends BaseConnector {
}
}
// Count by status
ordersByStatus[order.statusId] = (ordersByStatus[order.statusId] || 0) + 1;
// Count by referrer
ordersByReferrer[order.referrerId] =
(ordersByReferrer[order.referrerId] || 0) + 1;
}
@@ -387,7 +427,7 @@ export class PlentyoneConnector extends BaseConnector {
totalRevenue,
totalRevenueNet,
averageOrderValue: orders.length > 0 ? totalRevenue / orders.length : 0,
currency: 'EUR', // Default currency
currency: 'EUR',
ordersByStatus,
ordersByReferrer,
};
@@ -405,7 +445,6 @@ export class PlentyoneConnector extends BaseConnector {
query.statusId,
);
// Group by date
const statsByDate = new Map<string, PlentyoneRevenueStats>();
for (const order of orders) {
@@ -477,7 +516,6 @@ export class PlentyoneConnector extends BaseConnector {
hasMore = !response.isLastPage;
page++;
// Safety limit to prevent infinite loops
if (page > 1000) {
this.logger.warn('Reached maximum page limit for order retrieval');
break;

View File

@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
import { PlentyoneConnector } from './plentyone.connector';
import { PlentyoneService } from './plentyone.service';
import { PlentyoneController } from './plentyone.controller';
import { CredentialsModule } from '../../credentials/credentials.module';
@Module({
imports: [CredentialsModule],
controllers: [PlentyoneController],
providers: [PlentyoneConnector, PlentyoneService],
exports: [PlentyoneService, PlentyoneConnector],

View File

@@ -1,11 +1,11 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import {
BaseConnector,
BaseConnectorConfig,
ConnectionTestResult,
} from '../base-connector';
import { IntegrationConfigError } from '../../errors';
import { CredentialsService } from '../../credentials/credentials.service';
import {
TodoistAuthConfig,
TodoistTask,
@@ -28,31 +28,75 @@ import {
*
* Provides integration with Todoist task management platform.
* Uses Bearer Token authentication.
* Credentials are loaded lazily from the DB via CredentialsService.
*
* @see https://developer.todoist.com/rest/v2/
*/
@Injectable()
export class TodoistConnector extends BaseConnector {
protected readonly name = 'Todoist';
private readonly authConfig: TodoistAuthConfig;
private authConfig: TodoistAuthConfig = { apiToken: '' };
private initialized = false;
private configuredState = false;
constructor(private readonly configService: ConfigService) {
constructor(private readonly credentialsService: CredentialsService) {
super({
baseUrl: 'https://api.todoist.com/rest/v2',
timeout: 30000,
maxRetries: 3,
});
this.authConfig = {
apiToken: configService.get<string>('TODOIST_API_TOKEN') || '',
};
}
/**
* Check if the connector is properly configured
* Load credentials from DB and configure the connector.
* Idempotent - returns immediately if already initialized.
* Note: Todoist uses a fixed base URL; only the API token changes.
*/
async ensureInitialized(): Promise<void> {
if (this.initialized) return;
const result = await this.credentialsService.findActiveByType('TODOIST');
if (!result) {
this.configuredState = false;
this.initialized = true;
return;
}
const { apiToken } = result.credentials;
this.authConfig = {
apiToken: apiToken || '',
};
this.configuredState = !!this.authConfig.apiToken;
this.initialized = true;
}
/**
* Reload credentials from DB (called when credentials change)
*/
async reload(): Promise<void> {
this.initialized = false;
await this.ensureInitialized();
}
/**
* Handle credential change events
*/
@OnEvent('credentials.changed')
async onCredentialsChanged(payload: { type: string }): Promise<void> {
if (payload.type === 'TODOIST') {
this.logger.log('Todoist credentials changed, reloading');
await this.reload();
}
}
/**
* Check if the connector is properly configured (last known state)
*/
isConfigured(): boolean {
return !!this.authConfig.apiToken;
return this.configuredState;
}
/**
@@ -60,7 +104,7 @@ export class TodoistConnector extends BaseConnector {
*/
getMissingConfig(): string[] {
const missing: string[] = [];
if (!this.authConfig.apiToken) missing.push('TODOIST_API_TOKEN');
if (!this.authConfig.apiToken) missing.push('apiToken');
return missing;
}
@@ -68,6 +112,8 @@ export class TodoistConnector extends BaseConnector {
* Test the connection to Todoist
*/
async testConnection(): Promise<ConnectionTestResult> {
await this.ensureInitialized();
if (!this.isConfigured()) {
return {
success: false,
@@ -78,7 +124,6 @@ export class TodoistConnector extends BaseConnector {
const startTime = Date.now();
try {
// Try to get projects (simple call to verify auth)
await this.getProjects();
return {
@@ -100,6 +145,8 @@ export class TodoistConnector extends BaseConnector {
* Get authentication headers for requests (Bearer token)
*/
async getAuthHeaders(): Promise<Record<string, string>> {
await this.ensureInitialized();
if (!this.isConfigured()) {
throw new IntegrationConfigError(this.name, this.getMissingConfig());
}
@@ -117,6 +164,7 @@ export class TodoistConnector extends BaseConnector {
* Get active tasks
*/
async getTasks(request?: TodoistGetTasksRequest): Promise<TodoistTask[]> {
await this.ensureInitialized();
const params = this.buildTasksParams(request);
return this.get<TodoistTask[]>('/tasks', { params });
}
@@ -125,6 +173,7 @@ export class TodoistConnector extends BaseConnector {
* Get a single task by ID
*/
async getTask(taskId: string): Promise<TodoistTask> {
await this.ensureInitialized();
return this.get<TodoistTask>(`/tasks/${taskId}`);
}
@@ -132,7 +181,7 @@ export class TodoistConnector extends BaseConnector {
* Create a new task
*/
async createTask(request: TodoistCreateTaskRequest): Promise<TodoistTask> {
// Generate a unique request ID for idempotency
await this.ensureInitialized();
const requestId = this.generateRequestId();
return this.post<TodoistTask>('/tasks', request, {
@@ -149,7 +198,7 @@ export class TodoistConnector extends BaseConnector {
taskId: string,
request: TodoistUpdateTaskRequest,
): Promise<TodoistTask> {
// Generate a unique request ID for idempotency
await this.ensureInitialized();
const requestId = this.generateRequestId();
return this.post<TodoistTask>(`/tasks/${taskId}`, request, {
@@ -163,6 +212,7 @@ export class TodoistConnector extends BaseConnector {
* Complete a task
*/
async completeTask(taskId: string): Promise<void> {
await this.ensureInitialized();
await this.post<void>(`/tasks/${taskId}/close`, null);
}
@@ -170,6 +220,7 @@ export class TodoistConnector extends BaseConnector {
* Reopen a task
*/
async reopenTask(taskId: string): Promise<void> {
await this.ensureInitialized();
await this.post<void>(`/tasks/${taskId}/reopen`, null);
}
@@ -177,6 +228,7 @@ export class TodoistConnector extends BaseConnector {
* Delete a task
*/
async deleteTask(taskId: string): Promise<void> {
await this.ensureInitialized();
await this.delete<void>(`/tasks/${taskId}`);
}
@@ -188,6 +240,7 @@ export class TodoistConnector extends BaseConnector {
* Get all projects
*/
async getProjects(): Promise<TodoistProject[]> {
await this.ensureInitialized();
return this.get<TodoistProject[]>('/projects');
}
@@ -195,6 +248,7 @@ export class TodoistConnector extends BaseConnector {
* Get a single project by ID
*/
async getProject(projectId: string): Promise<TodoistProject> {
await this.ensureInitialized();
return this.get<TodoistProject>(`/projects/${projectId}`);
}
@@ -204,6 +258,7 @@ export class TodoistConnector extends BaseConnector {
async createProject(
request: TodoistCreateProjectRequest,
): Promise<TodoistProject> {
await this.ensureInitialized();
const requestId = this.generateRequestId();
return this.post<TodoistProject>('/projects', request, {
@@ -220,6 +275,7 @@ export class TodoistConnector extends BaseConnector {
projectId: string,
request: TodoistUpdateProjectRequest,
): Promise<TodoistProject> {
await this.ensureInitialized();
const requestId = this.generateRequestId();
return this.post<TodoistProject>(`/projects/${projectId}`, request, {
@@ -233,6 +289,7 @@ export class TodoistConnector extends BaseConnector {
* Delete a project
*/
async deleteProject(projectId: string): Promise<void> {
await this.ensureInitialized();
await this.delete<void>(`/projects/${projectId}`);
}
@@ -244,6 +301,7 @@ export class TodoistConnector extends BaseConnector {
* Get all sections (optionally filtered by project)
*/
async getSections(projectId?: string): Promise<TodoistSection[]> {
await this.ensureInitialized();
const params = projectId ? { project_id: projectId } : {};
return this.get<TodoistSection[]>('/sections', { params });
}
@@ -252,6 +310,7 @@ export class TodoistConnector extends BaseConnector {
* Get a single section by ID
*/
async getSection(sectionId: string): Promise<TodoistSection> {
await this.ensureInitialized();
return this.get<TodoistSection>(`/sections/${sectionId}`);
}
@@ -261,6 +320,7 @@ export class TodoistConnector extends BaseConnector {
async createSection(
request: TodoistCreateSectionRequest,
): Promise<TodoistSection> {
await this.ensureInitialized();
const requestId = this.generateRequestId();
return this.post<TodoistSection>('/sections', request, {
@@ -274,6 +334,7 @@ export class TodoistConnector extends BaseConnector {
* Delete a section
*/
async deleteSection(sectionId: string): Promise<void> {
await this.ensureInitialized();
await this.delete<void>(`/sections/${sectionId}`);
}
@@ -285,6 +346,7 @@ export class TodoistConnector extends BaseConnector {
* Get all personal labels
*/
async getLabels(): Promise<TodoistLabel[]> {
await this.ensureInitialized();
return this.get<TodoistLabel[]>('/labels');
}
@@ -292,6 +354,7 @@ export class TodoistConnector extends BaseConnector {
* Get a single label by ID
*/
async getLabel(labelId: string): Promise<TodoistLabel> {
await this.ensureInitialized();
return this.get<TodoistLabel>(`/labels/${labelId}`);
}
@@ -299,6 +362,7 @@ export class TodoistConnector extends BaseConnector {
* Create a new label
*/
async createLabel(request: TodoistCreateLabelRequest): Promise<TodoistLabel> {
await this.ensureInitialized();
const requestId = this.generateRequestId();
return this.post<TodoistLabel>('/labels', request, {
@@ -312,6 +376,7 @@ export class TodoistConnector extends BaseConnector {
* Delete a label
*/
async deleteLabel(labelId: string): Promise<void> {
await this.ensureInitialized();
await this.delete<void>(`/labels/${labelId}`);
}
@@ -326,6 +391,7 @@ export class TodoistConnector extends BaseConnector {
taskId?: string,
projectId?: string,
): Promise<TodoistComment[]> {
await this.ensureInitialized();
const params: Record<string, string> = {};
if (taskId) params.task_id = taskId;
if (projectId) params.project_id = projectId;
@@ -339,6 +405,7 @@ export class TodoistConnector extends BaseConnector {
async createComment(
request: TodoistCreateCommentRequest,
): Promise<TodoistComment> {
await this.ensureInitialized();
const requestId = this.generateRequestId();
return this.post<TodoistComment>('/comments', request, {
@@ -352,6 +419,7 @@ export class TodoistConnector extends BaseConnector {
* Delete a comment
*/
async deleteComment(commentId: string): Promise<void> {
await this.ensureInitialized();
await this.delete<void>(`/comments/${commentId}`);
}

View File

@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
import { TodoistConnector } from './todoist.connector';
import { TodoistService } from './todoist.service';
import { TodoistController } from './todoist.controller';
import { CredentialsModule } from '../../credentials/credentials.module';
@Module({
imports: [CredentialsModule],
controllers: [TodoistController],
providers: [TodoistConnector, TodoistService],
exports: [TodoistService, TodoistConnector],

View File

@@ -1,11 +1,11 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import {
BaseConnector,
BaseConnectorConfig,
ConnectionTestResult,
} from '../base-connector';
import { IntegrationConfigError } from '../../errors';
import { CredentialsService } from '../../credentials/credentials.service';
import {
ZulipAuthConfig,
ZulipMessage,
@@ -29,39 +29,84 @@ import {
*
* Provides integration with ZULIP team chat platform.
* Uses Basic Authentication with email and API key.
* Credentials are loaded lazily from the DB via CredentialsService.
*
* @see https://zulip.com/api/
*/
@Injectable()
export class ZulipConnector extends BaseConnector {
protected readonly name = 'ZULIP';
private readonly authConfig: ZulipAuthConfig;
private authConfig: ZulipAuthConfig = { baseUrl: '', email: '', apiKey: '' };
private initialized = false;
private configuredState = false;
constructor(private readonly configService: ConfigService) {
const baseUrl = configService.get<string>('ZULIP_BASE_URL') || '';
super({
baseUrl: baseUrl ? `${baseUrl}/api/v1` : '',
timeout: 30000,
maxRetries: 3,
});
this.authConfig = {
baseUrl,
email: configService.get<string>('ZULIP_EMAIL') || '',
apiKey: configService.get<string>('ZULIP_API_KEY') || '',
};
constructor(private readonly credentialsService: CredentialsService) {
super();
}
/**
* Check if the connector is properly configured
* Load credentials from DB and configure the connector.
* Idempotent - returns immediately if already initialized.
*/
isConfigured(): boolean {
return !!(
async ensureInitialized(): Promise<void> {
if (this.initialized) return;
const result = await this.credentialsService.findActiveByType('ZULIP');
if (!result) {
this.configuredState = false;
this.initialized = true;
return;
}
const { baseUrl, email, apiKey } = result.credentials;
this.authConfig = {
baseUrl: baseUrl || '',
email: email || '',
apiKey: apiKey || '',
};
if (this.authConfig.baseUrl) {
this.reconfigure({
baseUrl: `${this.authConfig.baseUrl}/api/v1`,
timeout: 30000,
maxRetries: 3,
});
}
this.configuredState = !!(
this.authConfig.baseUrl &&
this.authConfig.email &&
this.authConfig.apiKey
);
this.initialized = true;
}
/**
* Reload credentials from DB (called when credentials change)
*/
async reload(): Promise<void> {
this.initialized = false;
await this.ensureInitialized();
}
/**
* Handle credential change events
*/
@OnEvent('credentials.changed')
async onCredentialsChanged(payload: { type: string }): Promise<void> {
if (payload.type === 'ZULIP') {
this.logger.log('ZULIP credentials changed, reloading');
await this.reload();
}
}
/**
* Check if the connector is properly configured (last known state)
*/
isConfigured(): boolean {
return this.configuredState;
}
/**
@@ -69,9 +114,9 @@ export class ZulipConnector extends BaseConnector {
*/
getMissingConfig(): string[] {
const missing: string[] = [];
if (!this.authConfig.baseUrl) missing.push('ZULIP_BASE_URL');
if (!this.authConfig.email) missing.push('ZULIP_EMAIL');
if (!this.authConfig.apiKey) missing.push('ZULIP_API_KEY');
if (!this.authConfig.baseUrl) missing.push('baseUrl');
if (!this.authConfig.email) missing.push('email');
if (!this.authConfig.apiKey) missing.push('apiKey');
return missing;
}
@@ -79,6 +124,8 @@ export class ZulipConnector extends BaseConnector {
* Test the connection to ZULIP
*/
async testConnection(): Promise<ConnectionTestResult> {
await this.ensureInitialized();
if (!this.isConfigured()) {
return {
success: false,
@@ -89,7 +136,6 @@ export class ZulipConnector extends BaseConnector {
const startTime = Date.now();
try {
// Try to get server settings (doesn't require auth but verifies the server)
const response = await this.get<{
result: string;
zulip_version: string;
@@ -100,7 +146,6 @@ export class ZulipConnector extends BaseConnector {
throw new Error('Server returned non-success result');
}
// Verify credentials by fetching the authenticated user
await this.get<{ result: string }>('/users/me');
return {
@@ -127,6 +172,8 @@ export class ZulipConnector extends BaseConnector {
* Get authentication headers for requests (Basic Auth)
*/
async getAuthHeaders(): Promise<Record<string, string>> {
await this.ensureInitialized();
if (!this.isConfigured()) {
throw new IntegrationConfigError(this.name, this.getMissingConfig());
}
@@ -150,6 +197,7 @@ export class ZulipConnector extends BaseConnector {
async getMessages(
request: ZulipGetMessagesRequest = {},
): Promise<ZulipGetMessagesResponse> {
await this.ensureInitialized();
const params = this.buildMessagesParams(request);
return this.get<ZulipGetMessagesResponse>('/messages', { params });
}
@@ -233,6 +281,7 @@ export class ZulipConnector extends BaseConnector {
async sendMessage(
request: ZulipSendMessageRequest,
): Promise<ZulipSendMessageResponse> {
await this.ensureInitialized();
const formData = new URLSearchParams();
formData.append('type', request.type);
@@ -309,6 +358,7 @@ export class ZulipConnector extends BaseConnector {
includeOwnerSubscribed?: boolean;
} = {},
): Promise<ZulipStream[]> {
await this.ensureInitialized();
const params: Record<string, string> = {};
if (options.includePublic !== undefined) {
@@ -338,6 +388,7 @@ export class ZulipConnector extends BaseConnector {
* Get subscribed streams
*/
async getSubscriptions(): Promise<ZulipStreamSubscription[]> {
await this.ensureInitialized();
const response = await this.get<ZulipGetSubscriptionsResponse>(
'/users/me/subscriptions',
);
@@ -350,6 +401,7 @@ export class ZulipConnector extends BaseConnector {
async subscribe(
request: ZulipSubscribeRequest,
): Promise<ZulipSubscribeResponse> {
await this.ensureInitialized();
const formData = new URLSearchParams();
formData.append('subscriptions', JSON.stringify(request.subscriptions));
@@ -387,6 +439,7 @@ export class ZulipConnector extends BaseConnector {
* Unsubscribe from streams
*/
async unsubscribe(streamNames: string[]): Promise<{ result: string }> {
await this.ensureInitialized();
const formData = new URLSearchParams();
formData.append('subscriptions', JSON.stringify(streamNames));
@@ -408,6 +461,7 @@ export class ZulipConnector extends BaseConnector {
includeCustomProfileFields?: boolean;
} = {},
): Promise<ZulipUser[]> {
await this.ensureInitialized();
const params: Record<string, string> = {};
if (options.clientGravatar !== undefined) {
@@ -428,14 +482,15 @@ export class ZulipConnector extends BaseConnector {
* Get current user profile
*/
async getCurrentUser(): Promise<ZulipUser> {
const response = await this.get<ZulipUser>('/users/me');
return response;
await this.ensureInitialized();
return this.get<ZulipUser>('/users/me');
}
/**
* Get a specific user by ID
*/
async getUser(userId: number): Promise<ZulipUser> {
await this.ensureInitialized();
const response = await this.get<{ user: ZulipUser }>(`/users/${userId}`);
return response.user;
}
@@ -444,6 +499,7 @@ export class ZulipConnector extends BaseConnector {
* Get a specific user by email
*/
async getUserByEmail(email: string): Promise<ZulipUser> {
await this.ensureInitialized();
const response = await this.get<{ user: ZulipUser }>(
`/users/${encodeURIComponent(email)}`,
);

View File

@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
import { ZulipConnector } from './zulip.connector';
import { ZulipService } from './zulip.service';
import { ZulipController } from './zulip.controller';
import { CredentialsModule } from '../../credentials/credentials.module';
@Module({
imports: [CredentialsModule],
controllers: [ZulipController],
providers: [ZulipConnector, ZulipService],
exports: [ZulipService, ZulipConnector],

View File

@@ -5,6 +5,7 @@ import {
BadRequestException,
Logger,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { PrismaService } from '../../../prisma/prisma.service';
import { EncryptionService } from '../../../common/services/encryption.service';
import { CreateCredentialDto } from './dto/create-credential.dto';
@@ -61,6 +62,7 @@ export class CredentialsService {
constructor(
private readonly prisma: PrismaService,
private readonly encryptionService: EncryptionService,
private readonly eventEmitter: EventEmitter2,
) {}
/**
@@ -114,6 +116,8 @@ export class CredentialsService {
`Created credential ${credential.id} (${createDto.type}:${createDto.name}) by user ${userId}`,
);
this.eventEmitter.emit('credentials.changed', { type: credential.type });
return this.mapToListItem(credential);
}
@@ -290,6 +294,8 @@ export class CredentialsService {
this.logger.log(`Updated credential ${id} by user ${userId}`);
this.eventEmitter.emit('credentials.changed', { type: updated.type });
return this.mapToListItem(updated);
}
@@ -322,6 +328,8 @@ export class CredentialsService {
this.logger.log(`Deactivated credential ${id} by user ${userId}`);
this.eventEmitter.emit('credentials.changed', { type: updated.type });
return this.mapToListItem(updated);
}
@@ -403,6 +411,29 @@ export class CredentialsService {
};
}
/**
* Find the first active credential for a given integration type.
* Returns decrypted credentials. Used internally by connectors.
*/
async findActiveByType(
type: string,
): Promise<{ id: string; credentials: Record<string, string> } | null> {
const credential = await this.prisma.integrationCredential.findFirst({
where: { type: type as IntegrationType, isActive: true },
orderBy: { updatedAt: 'desc' },
});
if (!credential) return null;
try {
const decrypted = this.encryptionService.decryptObject(credential.credentials);
return { id: credential.id, credentials: decrypted as Record<string, string> };
} catch (error) {
this.logger.error(`Failed to decrypt credentials for type ${type}: ${error}`);
return null;
}
}
/**
* Updates the sync status of a credential
*/

View File

@@ -160,6 +160,9 @@ export class IntegrationsService {
try {
const { service, connector } = this.getServiceAndConnector(type);
// Ensure credentials are loaded from DB before checking configured state
await connector.ensureInitialized();
status.configured = service.isConfigured();
if (!status.configured) {
@@ -199,7 +202,10 @@ export class IntegrationsService {
this.logger.log(`Checking health for ${meta.name}`);
try {
const { service } = this.getServiceAndConnector(type);
const { service, connector } = this.getServiceAndConnector(type);
// Ensure credentials are loaded from DB before checking configured state
await connector.ensureInitialized();
if (!service.isConfigured()) {
return {
@@ -242,7 +248,7 @@ export class IntegrationsService {
*/
private getServiceAndConnector(type: IntegrationType): {
service: { isConfigured: () => boolean; testConnection: () => Promise<{ success: boolean; message: string; latencyMs?: number; details?: Record<string, unknown> }> };
connector: { getMissingConfig?: () => string[] };
connector: { ensureInitialized: () => Promise<void>; getMissingConfig?: () => string[] };
} {
switch (type) {
case 'plentyone':
@@ -263,17 +269,17 @@ export class IntegrationsService {
case 'freescout':
return {
service: this.freescoutService,
connector: this.freescoutConnector as { getMissingConfig?: () => string[] },
connector: this.freescoutConnector as { ensureInitialized: () => Promise<void>; getMissingConfig?: () => string[] },
};
case 'nextcloud':
return {
service: this.nextcloudService,
connector: this.nextcloudConnector as { getMissingConfig?: () => string[] },
connector: this.nextcloudConnector as { ensureInitialized: () => Promise<void>; getMissingConfig?: () => string[] },
};
case 'ecodms':
return {
service: this.ecodmsService,
connector: this.ecodmsConnector as { getMissingConfig?: () => string[] },
connector: this.ecodmsConnector as { ensureInitialized: () => Promise<void>; getMissingConfig?: () => string[] },
};
case 'gembadocs':
return {

View File

@@ -0,0 +1 @@
export { UpdateSettingDto, BulkUpdateSettingsDto } from './update-setting.dto';

View File

@@ -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;
}>;
}

View File

@@ -0,0 +1,3 @@
export { SystemSettingsModule } from './system-settings.module';
export { SystemSettingsService } from './system-settings.service';
export { UpdateSettingDto, BulkUpdateSettingsDto } from './dto';

View File

@@ -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,
});
}
}

View File

@@ -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 {}

View 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,
};
}
}

View File

@@ -77,7 +77,8 @@
"departments": "Abteilungen",
"overview": "Uebersicht",
"plentyOne": "PlentyONE",
"zulip": "ZULIP"
"zulip": "ZULIP",
"systemSettings": "Einstellungen"
},
"dashboard": {
"title": "Dashboard",
@@ -1048,5 +1049,29 @@
"minutes": "Minuten",
"seconds": "Sekunden",
"days": "Tage"
},
"systemSettings": {
"title": "Systemeinstellungen",
"description": "Globale Anwendungskonfiguration verwalten",
"general": "Allgemein",
"cors": "CORS",
"sync": "Synchronisation",
"features": "Features",
"branding": "Branding",
"appName": "Anwendungsname",
"companyName": "Firmenname",
"logoUrl": "Logo-URL",
"corsOrigins": "Erlaubte Origins",
"corsOriginsDesc": "Kommagetrennte Liste von erlaubten Origins fuer Cross-Origin-Anfragen",
"syncInterval": "Sync-Intervall",
"minutes": "Minuten",
"enableSyncJobs": "Hintergrund-Sync-Jobs",
"enableSyncJobsDesc": "Automatische Synchronisation der Integrationen im Hintergrund",
"enableSwagger": "Swagger API-Dokumentation",
"enableSwaggerDesc": "Interaktive API-Dokumentation unter /api/docs verfuegbar (Neustart erforderlich)",
"saved": "Einstellungen gespeichert",
"saveError": "Fehler beim Speichern der Einstellungen",
"requiresRestart": "Aenderung erfordert einen Neustart des Backends",
"save": "Speichern"
}
}

View File

@@ -77,7 +77,8 @@
"departments": "Departments",
"overview": "Overview",
"plentyOne": "PlentyONE",
"zulip": "ZULIP"
"zulip": "ZULIP",
"systemSettings": "Settings"
},
"dashboard": {
"title": "Dashboard",
@@ -1048,5 +1049,29 @@
"minutes": "minutes",
"seconds": "seconds",
"days": "days"
},
"systemSettings": {
"title": "System Settings",
"description": "Manage global application configuration",
"general": "General",
"cors": "CORS",
"sync": "Synchronization",
"features": "Features",
"branding": "Branding",
"appName": "Application Name",
"companyName": "Company Name",
"logoUrl": "Logo URL",
"corsOrigins": "Allowed Origins",
"corsOriginsDesc": "Comma-separated list of allowed origins for cross-origin requests",
"syncInterval": "Sync Interval",
"minutes": "minutes",
"enableSyncJobs": "Background Sync Jobs",
"enableSyncJobsDesc": "Automatic synchronization of integrations in the background",
"enableSwagger": "Swagger API Documentation",
"enableSwaggerDesc": "Interactive API documentation available at /api/docs (restart required)",
"saved": "Settings saved",
"saveError": "Failed to save settings",
"requiresRestart": "Change requires a backend restart",
"save": "Save"
}
}

View File

@@ -1,10 +1,9 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { motion } from 'framer-motion';
import {
Plug,
Shield,
Building2,
MessageSquare,
@@ -16,6 +15,7 @@ import {
Settings,
Eye,
EyeOff,
Loader2,
type LucideIcon,
} from 'lucide-react';
@@ -41,8 +41,16 @@ import {
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { IntegrationStatusBadge, ConnectionTestButton } from '@/components/integrations';
import { IntegrationStatusBadge } from '@/components/integrations';
import { useAllIntegrationStatuses } from '@/hooks/integrations';
import {
useCredentials,
useCredentialDetail,
useCreateCredential,
useUpdateCredential,
useTestCredentialConnection,
} from '@/hooks/integrations/use-credentials';
import { useUpdateSetting } from '@/hooks/use-system-settings';
import { useToast } from '@/hooks/use-toast';
import type { IntegrationType } from '@/types/integrations';
@@ -50,7 +58,11 @@ interface AdminIntegrationsContentProps {
locale: string;
}
/** Integration metadata */
// ============================================================
// Static configuration
// ============================================================
/** Display metadata per integration */
const integrationMeta: Record<
IntegrationType,
{ icon: LucideIcon; nameKey: string; descKey: string }
@@ -64,77 +76,394 @@ const integrationMeta: Record<
gembadocs: { icon: ClipboardCheck, nameKey: 'gembaDocs', descKey: 'gembaDocsDesc' },
};
/** Credential field configuration per integration */
const credentialFields: Record<IntegrationType, { key: string; labelKey: string; type: 'text' | 'password' | 'url' }[]> = {
/** Credential field definitions per integration.
* Keys must match what the backend validates (credentials.service.ts). */
const credentialFields: Record<
IntegrationType,
Array<{ key: string; label: string; type: 'text' | 'password' | 'url'; placeholder?: string }>
> = {
plentyone: [
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
{ key: 'username', labelKey: 'username', type: 'text' },
{ key: 'password', labelKey: 'password', type: 'password' },
{ key: 'apiUrl', label: 'API URL', type: 'url', placeholder: 'https://your-shop.plentymarkets-cloud01.com' },
{ key: 'apiKey', label: 'API Schluessel', type: 'password' },
],
zulip: [
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
{ key: 'email', labelKey: 'username', type: 'text' },
{ key: 'apiKey', labelKey: 'apiKey', type: 'password' },
{ key: 'zulipUrl', label: 'Server URL', type: 'url', placeholder: 'https://your-org.zulipchat.com' },
{ key: 'botEmail', label: 'Bot E-Mail', type: 'text' },
{ key: 'apiKey', label: 'API Schluessel', type: 'password' },
],
todoist: [
{ key: 'apiKey', labelKey: 'apiKey', type: 'password' },
{ key: 'apiToken', label: 'API Token', type: 'password' },
],
freescout: [
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
{ key: 'apiKey', labelKey: 'apiKey', type: 'password' },
{ key: 'apiUrl', label: 'API URL', type: 'url', placeholder: 'https://helpdesk.example.com' },
{ key: 'apiKey', label: 'API Schluessel', type: 'password' },
],
nextcloud: [
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
{ key: 'username', labelKey: 'username', type: 'text' },
{ key: 'password', labelKey: 'password', type: 'password' },
{ key: 'serverUrl', label: 'Server URL', type: 'url', placeholder: 'https://cloud.example.com' },
{ key: 'username', label: 'Benutzername', type: 'text' },
{ key: 'password', label: 'Passwort', type: 'password' },
],
ecodms: [
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
{ key: 'apiKey', labelKey: 'apiKey', type: 'password' },
{ key: 'apiUrl', label: 'API URL', type: 'url', placeholder: 'https://ecodms.example.com' },
{ key: 'username', label: 'Benutzername', type: 'text' },
{ key: 'password', label: 'Passwort', type: 'password' },
],
gembadocs: [
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
{ key: 'apiKey', labelKey: 'apiKey', type: 'password' },
{ key: 'apiUrl', label: 'API URL', type: 'url', placeholder: 'https://gembadocs.example.com' },
{ key: 'apiKey', label: 'API Schluessel', type: 'password' },
],
};
// ============================================================
// Sub-component: single integration tab panel
// ============================================================
interface IntegrationPanelProps {
integrationType: IntegrationType;
}
function IntegrationPanel({ integrationType }: IntegrationPanelProps) {
const t = useTranslations('integrations');
const { toast } = useToast();
const meta = integrationMeta[integrationType];
const Icon = meta.icon;
const fields = credentialFields[integrationType];
// ---- API queries / mutations ----
const { data: allCredentials } = useCredentials();
const createCredential = useCreateCredential();
const updateCredential = useUpdateCredential();
const testConnection = useTestCredentialConnection();
const updateSetting = useUpdateSetting();
// Find the saved credential for this integration type (UPPERCASE match)
const savedCredential = allCredentials?.data.find(
(c) => c.type === integrationType.toUpperCase(),
) ?? null;
// Fetch decrypted values only when a credential exists
const { data: credentialDetail, isLoading: isLoadingDetail } =
useCredentialDetail(savedCredential?.id ?? null);
// ---- Local UI state ----
const [formData, setFormData] = useState<Record<string, string>>({});
const [isEnabled, setIsEnabled] = useState<boolean>(
savedCredential?.isActive ?? false,
);
const [visiblePasswords, setVisiblePasswords] = useState<Record<string, boolean>>({});
const [syncInterval, setSyncInterval] = useState<string>('15');
// Pre-fill form fields whenever decrypted credential values arrive
useEffect(() => {
if (credentialDetail?.credentials) {
setFormData(
Object.fromEntries(
Object.entries(credentialDetail.credentials).map(([k, v]) => [k, String(v)]),
),
);
}
}, [credentialDetail]);
// Keep enabled toggle in sync with stored value
useEffect(() => {
if (savedCredential !== null) {
setIsEnabled(savedCredential.isActive);
}
}, [savedCredential]);
// ---- Handlers ----
const handleFieldChange = (key: string, value: string) => {
setFormData((prev) => ({ ...prev, [key]: value }));
};
const togglePassword = (fieldId: string) => {
setVisiblePasswords((prev) => ({ ...prev, [fieldId]: !prev[fieldId] }));
};
const handleSave = async () => {
try {
const integrationName = t(meta.nameKey as never);
if (!savedCredential) {
// Create new credential
await createCredential.mutateAsync({
type: integrationType.toUpperCase(),
name: 'Default',
credentials: formData,
});
} else {
// Update existing credential
await updateCredential.mutateAsync({
id: savedCredential.id,
credentials: formData,
});
}
toast({
title: t('saveSettings'),
description: t('settingsSaved' as never, { name: integrationName }),
});
} catch (error) {
toast({
title: 'Fehler beim Speichern',
description: error instanceof Error ? error.message : 'Unbekannter Fehler',
variant: 'destructive',
});
}
};
const handleToggleEnabled = async (checked: boolean) => {
setIsEnabled(checked);
if (!savedCredential) return; // Nothing to update yet
try {
await updateCredential.mutateAsync({
id: savedCredential.id,
isActive: checked,
});
toast({
title: checked ? 'Integration aktiviert' : 'Integration deaktiviert',
description: `${t(meta.nameKey as never)} wurde ${checked ? 'aktiviert' : 'deaktiviert'}.`,
});
} catch (error) {
// Revert on failure
setIsEnabled(!checked);
toast({
title: 'Fehler',
description: error instanceof Error ? error.message : 'Unbekannter Fehler',
variant: 'destructive',
});
}
};
const handleTestConnection = async () => {
if (!savedCredential) return;
try {
const result = await testConnection.mutateAsync(savedCredential.id);
toast({
title: result.success ? t('testSuccess') : t('testFailed'),
description: result.message,
variant: result.success ? 'default' : 'destructive',
});
} catch (error) {
toast({
title: t('testFailed'),
description: error instanceof Error ? error.message : t('testError'),
variant: 'destructive',
});
}
};
const handleSyncIntervalChange = async (value: string) => {
setSyncInterval(value);
try {
await updateSetting.mutateAsync({
key: `sync.interval.${integrationType}`,
value,
});
} catch {
// Non-critical; sync interval is a best-effort setting
}
};
// ---- Loading skeleton while fetching decrypted values ----
if (savedCredential && isLoadingDetail) {
return (
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent>
<div className="space-y-4">
{fields.map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</CardContent>
</Card>
);
}
const isSaving =
createCredential.isPending || updateCredential.isPending;
const isTesting = testConnection.isPending;
const hasCredential = !!savedCredential;
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between">
{/* Left: icon + title */}
<div className="flex items-center gap-3">
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-lg',
isEnabled
? 'bg-primary/10 text-primary'
: 'bg-muted text-muted-foreground',
)}
>
<Icon className="h-5 w-5" />
</div>
<div>
<CardTitle className="flex items-center gap-2">
{t(meta.nameKey as never)}
{/* Show status badge only when a credential is saved */}
{hasCredential && (
<IntegrationStatusBadge
status={
savedCredential.syncStatus === 'SUCCESS'
? 'connected'
: savedCredential.syncStatus === 'ERROR'
? 'error'
: 'disconnected'
}
/>
)}
</CardTitle>
<CardDescription>{t(meta.descKey as never)}</CardDescription>
</div>
</div>
{/* Right: enable/disable toggle */}
<div className="flex items-center gap-2">
<Label htmlFor={`enable-${integrationType}`}>
{isEnabled ? t('disable') : t('enable')}
</Label>
<Switch
id={`enable-${integrationType}`}
checked={isEnabled}
onCheckedChange={handleToggleEnabled}
/>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Credentials section */}
<div>
<h3 className="mb-4 text-lg font-medium">{t('credentials')}</h3>
<div className="grid gap-4 md:grid-cols-2">
{fields.map((field) => {
const fieldId = `${integrationType}-${field.key}`;
const isPassword = field.type === 'password';
const isVisible = visiblePasswords[fieldId];
return (
<div key={field.key} className="space-y-2">
<Label htmlFor={fieldId}>{field.label}</Label>
<div className="relative">
<Input
id={fieldId}
type={isPassword && !isVisible ? 'password' : 'text'}
placeholder={field.placeholder ?? (isPassword ? '••••••••' : '')}
value={formData[field.key] ?? ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
className={cn(isPassword && 'pr-10')}
/>
{isPassword && (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3"
onClick={() => togglePassword(fieldId)}
>
{isVisible ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Sync settings section */}
<div>
<h3 className="mb-4 text-lg font-medium">{t('synchronization' as never)}</h3>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`sync-interval-${integrationType}`}>
{t('syncInterval')}
</Label>
<Select value={syncInterval} onValueChange={handleSyncIntervalChange}>
<SelectTrigger id={`sync-interval-${integrationType}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 {t('minutes')}</SelectItem>
<SelectItem value="5">5 {t('minutes')}</SelectItem>
<SelectItem value="15">15 {t('minutes')}</SelectItem>
<SelectItem value="30">30 {t('minutes')}</SelectItem>
<SelectItem value="60">60 {t('minutes')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between border-t pt-6">
{/* Connection test button */}
<Button
variant="outline"
size="sm"
onClick={handleTestConnection}
disabled={!hasCredential || isTesting}
>
{isTesting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('testing')}
</>
) : (
t('test')
)}
</Button>
{/* Save button */}
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Speichern...
</>
) : (
<>
<Settings className="mr-2 h-4 w-4" />
{t('saveSettings')}
</>
)}
</Button>
</CardFooter>
</Card>
);
}
// ============================================================
// Main page component
// ============================================================
/**
* Admin integrations management content
* Admin integrations management page.
* Lists each integration as a tab; each tab loads its own credential
* state so queries are isolated and only triggered when the tab is visited.
*/
export function AdminIntegrationsContent({ locale }: AdminIntegrationsContentProps) {
export function AdminIntegrationsContent({ locale: _locale }: AdminIntegrationsContentProps) {
const t = useTranslations('integrations');
const tAdmin = useTranslations('admin');
const { toast } = useToast();
const { data: integrations, isLoading } = useAllIntegrationStatuses();
const [visiblePasswords, setVisiblePasswords] = useState<Record<string, boolean>>({});
const [enabledState, setEnabledState] = useState<Record<string, boolean>>({});
const [formData, setFormData] = useState<Record<string, Record<string, string>>>({});
const togglePasswordVisibility = (fieldKey: string) => {
setVisiblePasswords((prev) => ({
...prev,
[fieldKey]: !prev[fieldKey],
}));
};
const handleFieldChange = (integrationType: IntegrationType, fieldKey: string, value: string) => {
setFormData((prev) => ({
...prev,
[integrationType]: {
...prev[integrationType],
[fieldKey]: value,
},
}));
};
const handleSave = (integrationType: IntegrationType) => {
toast({
title: t('saveSettings'),
description: t('settingsSaved' as never, { name: t(integrationMeta[integrationType].nameKey as never) }),
});
};
return (
<div className="container mx-auto space-y-8 py-6">
{/* Header */}
@@ -143,14 +472,16 @@ export function AdminIntegrationsContent({ locale }: AdminIntegrationsContentPro
<Shield className="h-5 w-5 text-primary" />
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">{tAdmin('integrationManagement')}</h1>
<h1 className="text-3xl font-bold tracking-tight">
{tAdmin('integrationManagement')}
</h1>
<p className="text-muted-foreground">
{tAdmin('integrationManagementDesc')}
</p>
</div>
</div>
{/* Integration Tabs */}
{/* Loading state */}
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
@@ -184,139 +515,17 @@ export function AdminIntegrationsContent({ locale }: AdminIntegrationsContentPro
})}
</TabsList>
{integrations?.map((config) => {
const meta = integrationMeta[config.type];
const Icon = meta.icon;
const fields = credentialFields[config.type];
return (
<TabsContent key={config.type} value={config.type}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-lg',
(enabledState[config.type] ?? config.enabled) ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
)}
>
<Icon className="h-5 w-5" />
</div>
<div>
<CardTitle className="flex items-center gap-2">
{t(meta.nameKey as never)}
<IntegrationStatusBadge status={config.status} />
</CardTitle>
<CardDescription>{t(meta.descKey as never)}</CardDescription>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Label htmlFor={`enable-${config.type}`}>
{(enabledState[config.type] ?? config.enabled) ? t('disable') : t('enable')}
</Label>
<Switch
id={`enable-${config.type}`}
checked={enabledState[config.type] ?? config.enabled}
onCheckedChange={(checked) =>
setEnabledState((prev) => ({ ...prev, [config.type]: checked }))
}
/>
</div>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Credentials */}
<div>
<h3 className="mb-4 text-lg font-medium">{t('credentials')}</h3>
<div className="grid gap-4 md:grid-cols-2">
{fields.map((field) => {
const fieldId = `${config.type}-${field.key}`;
const isPassword = field.type === 'password';
const isVisible = visiblePasswords[fieldId];
return (
<div key={field.key} className="space-y-2">
<Label htmlFor={fieldId}>{t(field.labelKey as never)}</Label>
<div className="relative">
<Input
id={fieldId}
type={isPassword && !isVisible ? 'password' : 'text'}
placeholder={isPassword ? '********' : ''}
value={formData[config.type]?.[field.key] ?? ''}
onChange={(e) =>
handleFieldChange(config.type, field.key, e.target.value)
}
className={cn(isPassword && 'pr-10')}
/>
{isPassword && (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3"
onClick={() => togglePasswordVisibility(fieldId)}
>
{isVisible ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Sync Settings */}
<div>
<h3 className="mb-4 text-lg font-medium">{t('synchronization' as never)}</h3>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`sync-interval-${config.type}`}>
{t('syncInterval')}
</Label>
<Select defaultValue={config.syncInterval.toString()}>
<SelectTrigger id={`sync-interval-${config.type}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 {t('minutes')}</SelectItem>
<SelectItem value="5">5 {t('minutes')}</SelectItem>
<SelectItem value="15">15 {t('minutes')}</SelectItem>
<SelectItem value="30">30 {t('minutes')}</SelectItem>
<SelectItem value="60">60 {t('minutes')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between border-t pt-6">
<ConnectionTestButton integrationType={config.type} />
<Button onClick={() => handleSave(config.type)}>
<Settings className="mr-2 h-4 w-4" />
{t('saveSettings')}
</Button>
</CardFooter>
</Card>
</motion.div>
</TabsContent>
);
})}
{integrations?.map((config) => (
<TabsContent key={config.type} value={config.type}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<IntegrationPanel integrationType={config.type} />
</motion.div>
</TabsContent>
))}
</Tabs>
)}
</div>

View 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} />;
}

View File

@@ -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&#10;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>
);
}

View File

@@ -80,13 +80,29 @@ export function IntegrationDetailContent({
const dateLocale = locale === 'de' ? de : enUS;
const meta = integrationMeta[integrationType];
const Icon = meta?.icon ?? Building2;
const ContentComponent = integrationContentMap[integrationType];
const handleSync = () => {
triggerSync.mutate(integrationType);
};
// Integration type not supported
if (!meta) {
return (
<div className="container mx-auto space-y-6 py-6">
<Button variant="ghost" size="sm" asChild>
<Link href={`/${locale}/integrations`}>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('overview')}
</Link>
</Button>
<p className="text-muted-foreground">{t('notFound')}</p>
</div>
);
}
const Icon = meta.icon;
if (isLoading) {
return (
<div className="container mx-auto space-y-6 py-6">
@@ -105,13 +121,19 @@ export function IntegrationDetailContent({
);
}
if (!config) {
return (
<div className="container mx-auto py-6">
<p className="text-muted-foreground">Integration nicht gefunden.</p>
</div>
);
}
// Use API data or fallback to disconnected state
const displayConfig = config ?? {
id: integrationType,
type: integrationType,
name: t(meta.nameKey as never),
enabled: false,
status: 'disconnected' as const,
lastSync: undefined,
lastError: undefined,
syncInterval: 15,
createdAt: new Date(),
updatedAt: new Date(),
};
return (
<div className="container mx-auto space-y-6 py-6">
@@ -136,7 +158,7 @@ export function IntegrationDetailContent({
<div
className={cn(
'flex h-12 w-12 items-center justify-center rounded-lg',
config.enabled ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
displayConfig.enabled ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
)}
>
<Icon className="h-6 w-6" />
@@ -144,7 +166,7 @@ export function IntegrationDetailContent({
<div>
<div className="flex items-center gap-2">
<CardTitle className="text-xl">{t(meta.nameKey as never)}</CardTitle>
<IntegrationStatusBadge status={config.status} />
<IntegrationStatusBadge status={displayConfig.status} />
</div>
<CardDescription>{t(meta.descKey as never)}</CardDescription>
</div>
@@ -156,7 +178,7 @@ export function IntegrationDetailContent({
variant="outline"
size="sm"
onClick={handleSync}
disabled={triggerSync.isPending || config.status !== 'connected'}
disabled={triggerSync.isPending || displayConfig.status !== 'connected'}
>
<RefreshCw
className={cn('mr-2 h-4 w-4', triggerSync.isPending && 'animate-spin')}
@@ -169,9 +191,9 @@ export function IntegrationDetailContent({
<CardContent>
<SyncStatus
lastSync={config.lastSync}
lastSync={displayConfig.lastSync}
locale={locale}
error={config.lastError}
error={displayConfig.lastError}
/>
</CardContent>
</Card>
@@ -227,15 +249,15 @@ export function IntegrationDetailContent({
<div className="flex items-center justify-between border-b py-2">
<span className="text-muted-foreground">{t('syncSuccessful')}</span>
<span>
{config.lastSync
? formatDistanceToNow(config.lastSync, { addSuffix: true, locale: dateLocale })
{displayConfig.lastSync
? formatDistanceToNow(displayConfig.lastSync, { addSuffix: true, locale: dateLocale })
: '-'}
</span>
</div>
{config.lastError && (
{displayConfig.lastError && (
<div className="rounded-lg bg-destructive/10 p-3 text-destructive">
<p className="font-medium">{t('lastError')}</p>
<p>{config.lastError}</p>
<p>{displayConfig.lastError}</p>
</div>
)}
</div>

View File

@@ -1,6 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { useState, useEffect } from 'react';
import { formatDistanceToNow } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
import { motion } from 'framer-motion';
@@ -125,6 +126,11 @@ export function ActivityWidget({
}: ActivityWidgetProps) {
const t = useTranslations('widgets.activity');
const dateLocale = locale === 'de' ? de : enUS;
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const displayedActivities = activities.slice(0, maxItems);
@@ -196,10 +202,12 @@ export function ActivityWidget({
<p className="text-sm text-muted-foreground">{activity.description}</p>
)}
<p className="text-xs text-muted-foreground">
{formatDistanceToNow(activity.timestamp, {
addSuffix: true,
locale: dateLocale,
})}
{mounted
? formatDistanceToNow(activity.timestamp, {
addSuffix: true,
locale: dateLocale,
})
: '\u00A0'}
</p>
</div>
</motion.div>

View File

@@ -44,6 +44,7 @@ interface NavItem {
icon: LucideIcon;
requiredRoles?: UserRole[];
children?: NavItem[];
exactMatch?: boolean;
}
/** Main navigation structure with role requirements */
@@ -115,6 +116,7 @@ const mainNavItems: NavItem[] = [
key: 'overview',
href: '/integrations',
icon: Plug,
exactMatch: true,
},
{
key: 'plentyOne',
@@ -152,6 +154,11 @@ const bottomNavItems: NavItem[] = [
href: '/admin/integrations',
icon: Plug,
},
{
key: 'systemSettings',
href: '/admin/settings',
icon: Settings,
},
],
},
{
@@ -209,14 +216,15 @@ export function Sidebar({ locale }: SidebarProps) {
const filteredMainNav = filterNavItems(mainNavItems, userRoles);
const filteredBottomNav = filterNavItems(bottomNavItems, userRoles);
const isActive = (href: string) => {
const isActive = (href: string, exactMatch?: boolean) => {
const localePath = `/${locale}${href}`;
if (exactMatch) return pathname === localePath;
return pathname === localePath || pathname.startsWith(`${localePath}/`);
};
const isParentActive = (item: NavItem) => {
if (isActive(item.href)) return true;
return item.children?.some((child) => isActive(child.href)) || false;
return item.children?.some((child) => isActive(child.href, child.exactMatch)) || false;
};
return (
@@ -307,7 +315,7 @@ interface NavItemComponentProps {
item: NavItem;
locale: string;
isExpanded: boolean;
isActive: (href: string) => boolean;
isActive: (href: string, exactMatch?: boolean) => boolean;
isParentActive: (item: NavItem) => boolean;
t: (key: string) => string;
}
@@ -321,7 +329,7 @@ function NavItemComponent({
t,
}: NavItemComponentProps) {
const Icon = item.icon;
const active = isActive(item.href);
const active = isActive(item.href, item.exactMatch);
const parentActive = isParentActive(item);
const hasChildren = item.children && item.children.length > 0;
@@ -383,7 +391,7 @@ function NavItemComponent({
href={`/${locale}${child.href}`}
className={cn(
'rounded px-2 py-1 text-sm transition-colors hover:bg-accent',
isActive(child.href) && 'bg-accent font-medium'
isActive(child.href, child.exactMatch) && 'bg-accent font-medium'
)}
>
{t(child.key)}
@@ -426,7 +434,7 @@ function NavItemComponent({
>
{item.children?.map((child) => {
const ChildIcon = child.icon;
const childActive = isActive(child.href);
const childActive = isActive(child.href, child.exactMatch);
return (
<Link

View File

@@ -1,4 +1,22 @@
// Integration hooks barrel export
export {
useCredentials,
useCredentialDetail,
useCreateCredential,
useUpdateCredential,
useDeleteCredential,
useTestCredentialConnection,
credentialKeys,
} from './use-credentials';
export type {
CredentialListItem,
CredentialDetail,
PaginatedCredentials,
CreateCredentialPayload,
UpdateCredentialPayload,
TestConnectionResult,
} from './use-credentials';
export {
useIntegrationStatus,
useAllIntegrationStatuses,

View 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`,
),
});
}

View 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 });
},
});
}

View File

@@ -59,7 +59,12 @@ async function request<T>(endpoint: string, config: RequestConfig = {}): Promise
// Handle empty responses
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
return response.json();
const json = await response.json();
// Unwrap backend's {success, data, timestamp} envelope
if (json && typeof json === 'object' && 'success' in json && 'data' in json) {
return json.data as T;
}
return json as T;
}
return {} as T;