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

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