@object-ui/data-objectstack 0.5.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +595 -12
- package/dist/index.d.cts +566 -2
- package/dist/index.d.ts +566 -2
- package/dist/index.js +586 -11
- package/package.json +7 -7
- package/src/cache/MetadataCache.ts +22 -0
- package/src/cloud.ts +109 -0
- package/src/connection.test.ts +41 -0
- package/src/contracts.ts +115 -0
- package/src/errors.test.ts +1 -1
- package/src/index.ts +290 -12
- package/src/integration.ts +192 -0
- package/src/security.ts +230 -0
- package/src/studio.ts +152 -0
- package/src/upload.test.ts +112 -0
- package/src/v3-compat.test.ts +240 -0
package/src/index.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { ObjectStackClient, type QueryOptions as ObjectStackQueryOptions } from '@objectstack/client';
|
|
10
|
-
import type { DataSource, QueryParams, QueryResult } from '@object-ui/types';
|
|
10
|
+
import type { DataSource, QueryParams, QueryResult, FileUploadResult } from '@object-ui/types';
|
|
11
11
|
import { convertFiltersToAST } from '@object-ui/core';
|
|
12
12
|
import { MetadataCache } from './cache/MetadataCache';
|
|
13
13
|
import {
|
|
@@ -53,6 +53,9 @@ export type ConnectionStateListener = (event: ConnectionStateEvent) => void;
|
|
|
53
53
|
*/
|
|
54
54
|
export type BatchProgressListener = (event: BatchProgressEvent) => void;
|
|
55
55
|
|
|
56
|
+
// Re-export FileUploadResult from types for consumers
|
|
57
|
+
export type { FileUploadResult } from '@object-ui/types';
|
|
58
|
+
|
|
56
59
|
/**
|
|
57
60
|
* ObjectStack Data Source Adapter
|
|
58
61
|
*
|
|
@@ -93,6 +96,8 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
93
96
|
private maxReconnectAttempts: number;
|
|
94
97
|
private reconnectDelay: number;
|
|
95
98
|
private reconnectAttempts: number = 0;
|
|
99
|
+
private baseUrl: string;
|
|
100
|
+
private token?: string;
|
|
96
101
|
|
|
97
102
|
constructor(config: {
|
|
98
103
|
baseUrl: string;
|
|
@@ -111,6 +116,8 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
111
116
|
this.autoReconnect = config.autoReconnect ?? true;
|
|
112
117
|
this.maxReconnectAttempts = config.maxReconnectAttempts ?? 3;
|
|
113
118
|
this.reconnectDelay = config.reconnectDelay ?? 1000;
|
|
119
|
+
this.baseUrl = config.baseUrl;
|
|
120
|
+
this.token = config.token;
|
|
114
121
|
}
|
|
115
122
|
|
|
116
123
|
/**
|
|
@@ -261,14 +268,16 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
261
268
|
};
|
|
262
269
|
}
|
|
263
270
|
|
|
264
|
-
const resultObj = result as { value?: T[]; count?: number };
|
|
271
|
+
const resultObj = result as { records?: T[]; total?: number; value?: T[]; count?: number };
|
|
272
|
+
const records = resultObj.records || resultObj.value || [];
|
|
273
|
+
const total = resultObj.total ?? resultObj.count ?? records.length;
|
|
265
274
|
return {
|
|
266
|
-
data:
|
|
267
|
-
total
|
|
275
|
+
data: records,
|
|
276
|
+
total,
|
|
268
277
|
// Calculate page number safely
|
|
269
278
|
page: params?.$skip && params.$top ? Math.floor(params.$skip / params.$top) + 1 : 1,
|
|
270
279
|
pageSize: params?.$top,
|
|
271
|
-
hasMore: params?.$top ?
|
|
280
|
+
hasMore: params?.$top ? records.length === params.$top : false,
|
|
272
281
|
};
|
|
273
282
|
}
|
|
274
283
|
|
|
@@ -279,8 +288,8 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
279
288
|
await this.connect();
|
|
280
289
|
|
|
281
290
|
try {
|
|
282
|
-
const
|
|
283
|
-
return record;
|
|
291
|
+
const result = await this.client.data.get<T>(resource, String(id));
|
|
292
|
+
return result.record;
|
|
284
293
|
} catch (error: unknown) {
|
|
285
294
|
// If record not found, return null instead of throwing
|
|
286
295
|
if ((error as Record<string, unknown>)?.status === 404) {
|
|
@@ -295,7 +304,8 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
295
304
|
*/
|
|
296
305
|
async create(resource: string, data: Partial<T>): Promise<T> {
|
|
297
306
|
await this.connect();
|
|
298
|
-
|
|
307
|
+
const result = await this.client.data.create<T>(resource, data);
|
|
308
|
+
return result.record;
|
|
299
309
|
}
|
|
300
310
|
|
|
301
311
|
/**
|
|
@@ -303,7 +313,8 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
303
313
|
*/
|
|
304
314
|
async update(resource: string, id: string | number, data: Partial<T>): Promise<T> {
|
|
305
315
|
await this.connect();
|
|
306
|
-
|
|
316
|
+
const result = await this.client.data.update<T>(resource, String(id), data);
|
|
317
|
+
return result.record;
|
|
307
318
|
}
|
|
308
319
|
|
|
309
320
|
/**
|
|
@@ -312,7 +323,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
312
323
|
async delete(resource: string, id: string | number): Promise<boolean> {
|
|
313
324
|
await this.connect();
|
|
314
325
|
const result = await this.client.data.delete(resource, String(id));
|
|
315
|
-
return result.
|
|
326
|
+
return result.deleted;
|
|
316
327
|
}
|
|
317
328
|
|
|
318
329
|
/**
|
|
@@ -416,7 +427,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
416
427
|
|
|
417
428
|
try {
|
|
418
429
|
const result = await this.client.data.update<T>(resource, String(id), item);
|
|
419
|
-
results.push(result);
|
|
430
|
+
results.push(result.record);
|
|
420
431
|
completed++;
|
|
421
432
|
emitProgress();
|
|
422
433
|
} catch (error: unknown) {
|
|
@@ -548,7 +559,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
548
559
|
try {
|
|
549
560
|
// Use cache with automatic fetching
|
|
550
561
|
const schema = await this.metadataCache.get(objectName, async () => {
|
|
551
|
-
const result: any = await this.client.meta.
|
|
562
|
+
const result: any = await this.client.meta.getItem('object', objectName);
|
|
552
563
|
|
|
553
564
|
// Unwrap 'item' property if present (common API response wrapper)
|
|
554
565
|
if (result && result.item) {
|
|
@@ -582,6 +593,132 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
582
593
|
return this.client;
|
|
583
594
|
}
|
|
584
595
|
|
|
596
|
+
/**
|
|
597
|
+
* Get the discovery information from the connected server.
|
|
598
|
+
* Returns the capabilities and service status of the ObjectStack server.
|
|
599
|
+
*
|
|
600
|
+
* Note: This accesses an internal property of the ObjectStackClient.
|
|
601
|
+
* The discovery data is populated during client.connect() and cached.
|
|
602
|
+
*
|
|
603
|
+
* @returns Promise resolving to discovery data, or null if not connected
|
|
604
|
+
*/
|
|
605
|
+
async getDiscovery(): Promise<unknown | null> {
|
|
606
|
+
try {
|
|
607
|
+
// Ensure we're connected first
|
|
608
|
+
await this.connect();
|
|
609
|
+
|
|
610
|
+
// Access discovery data from the client
|
|
611
|
+
// The ObjectStackClient caches discovery during connect()
|
|
612
|
+
// This is an internal property, but documented for this use case
|
|
613
|
+
// @ts-expect-error - Accessing internal discoveryInfo property
|
|
614
|
+
return this.client.discoveryInfo || null;
|
|
615
|
+
} catch {
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Get a view definition for an object.
|
|
622
|
+
* Attempts to fetch from the server metadata API.
|
|
623
|
+
* Falls back to null if the server doesn't provide view definitions,
|
|
624
|
+
* allowing the consumer to use static config.
|
|
625
|
+
*
|
|
626
|
+
* @param objectName - Object name
|
|
627
|
+
* @param viewId - View identifier
|
|
628
|
+
* @returns Promise resolving to the view definition or null
|
|
629
|
+
*/
|
|
630
|
+
async getView(objectName: string, viewId: string): Promise<unknown | null> {
|
|
631
|
+
await this.connect();
|
|
632
|
+
|
|
633
|
+
try {
|
|
634
|
+
const cacheKey = `view:${objectName}:${viewId}`;
|
|
635
|
+
return await this.metadataCache.get(cacheKey, async () => {
|
|
636
|
+
// Try meta.getItem for view metadata
|
|
637
|
+
const result: any = await this.client.meta.getItem(objectName, `views/${viewId}`);
|
|
638
|
+
if (result && result.item) return result.item;
|
|
639
|
+
return result ?? null;
|
|
640
|
+
});
|
|
641
|
+
} catch {
|
|
642
|
+
// Server doesn't support view metadata — return null to fall back to static config
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Get an application definition by name or ID.
|
|
649
|
+
* Attempts to fetch from the server metadata API.
|
|
650
|
+
* Falls back to null if the server doesn't provide app definitions,
|
|
651
|
+
* allowing the consumer to use static config.
|
|
652
|
+
*
|
|
653
|
+
* @param appId - Application identifier
|
|
654
|
+
* @returns Promise resolving to the app definition or null
|
|
655
|
+
*/
|
|
656
|
+
async getApp(appId: string): Promise<unknown | null> {
|
|
657
|
+
await this.connect();
|
|
658
|
+
|
|
659
|
+
try {
|
|
660
|
+
const cacheKey = `app:${appId}`;
|
|
661
|
+
return await this.metadataCache.get(cacheKey, async () => {
|
|
662
|
+
const result: any = await this.client.meta.getItem('apps', appId);
|
|
663
|
+
if (result && result.item) return result.item;
|
|
664
|
+
return result ?? null;
|
|
665
|
+
});
|
|
666
|
+
} catch {
|
|
667
|
+
// Server doesn't support app metadata — return null to fall back to static config
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Get a page definition from ObjectStack.
|
|
674
|
+
* Uses the metadata API to fetch page layouts.
|
|
675
|
+
* Returns null if the server doesn't support page metadata.
|
|
676
|
+
*/
|
|
677
|
+
async getPage(pageId: string): Promise<unknown | null> {
|
|
678
|
+
await this.connect();
|
|
679
|
+
|
|
680
|
+
try {
|
|
681
|
+
const cacheKey = `page:${pageId}`;
|
|
682
|
+
return await this.metadataCache.get(cacheKey, async () => {
|
|
683
|
+
const result: any = await this.client.meta.getItem('pages', pageId);
|
|
684
|
+
if (result && result.item) return result.item;
|
|
685
|
+
return result ?? null;
|
|
686
|
+
});
|
|
687
|
+
} catch {
|
|
688
|
+
// Server doesn't support page metadata — return null to fall back to static config
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Get multiple metadata items from ObjectStack.
|
|
695
|
+
* Uses v3.0.0 metadata API pattern: getItems for batch retrieval.
|
|
696
|
+
*/
|
|
697
|
+
async getItems(category: string, names: string[]): Promise<unknown[]> {
|
|
698
|
+
await this.connect();
|
|
699
|
+
|
|
700
|
+
const results = await Promise.all(
|
|
701
|
+
names.map(async (name) => {
|
|
702
|
+
const cacheKey = `${category}:${name}`;
|
|
703
|
+
return this.metadataCache.get(cacheKey, async () => {
|
|
704
|
+
const result: any = await this.client.meta.getItem(category, name);
|
|
705
|
+
if (result && result.item) return result.item;
|
|
706
|
+
return result;
|
|
707
|
+
});
|
|
708
|
+
})
|
|
709
|
+
);
|
|
710
|
+
|
|
711
|
+
return results;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Get cached metadata if available, without triggering a fetch.
|
|
716
|
+
* Uses v3.0.0 metadata API pattern: getCached for synchronous cache access.
|
|
717
|
+
*/
|
|
718
|
+
getCached(key: string): unknown | undefined {
|
|
719
|
+
return this.metadataCache.getCachedSync(key);
|
|
720
|
+
}
|
|
721
|
+
|
|
585
722
|
/**
|
|
586
723
|
* Get cache statistics for monitoring performance.
|
|
587
724
|
*/
|
|
@@ -604,6 +741,131 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
604
741
|
clearCache(): void {
|
|
605
742
|
this.metadataCache.clear();
|
|
606
743
|
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Upload a single file to a resource.
|
|
747
|
+
* Posts the file as multipart/form-data to the ObjectStack server.
|
|
748
|
+
*
|
|
749
|
+
* @param resource - The resource/object name to attach the file to
|
|
750
|
+
* @param file - File object or Blob to upload
|
|
751
|
+
* @param options - Additional upload options (recordId, fieldName, metadata)
|
|
752
|
+
* @returns Promise resolving to the upload result (file URL, metadata)
|
|
753
|
+
*/
|
|
754
|
+
async uploadFile(
|
|
755
|
+
resource: string,
|
|
756
|
+
file: File | Blob,
|
|
757
|
+
options?: {
|
|
758
|
+
recordId?: string;
|
|
759
|
+
fieldName?: string;
|
|
760
|
+
metadata?: Record<string, unknown>;
|
|
761
|
+
onProgress?: (percent: number) => void;
|
|
762
|
+
},
|
|
763
|
+
): Promise<FileUploadResult> {
|
|
764
|
+
await this.connect();
|
|
765
|
+
|
|
766
|
+
const formData = new FormData();
|
|
767
|
+
formData.append('file', file);
|
|
768
|
+
|
|
769
|
+
if (options?.recordId) {
|
|
770
|
+
formData.append('recordId', options.recordId);
|
|
771
|
+
}
|
|
772
|
+
if (options?.fieldName) {
|
|
773
|
+
formData.append('fieldName', options.fieldName);
|
|
774
|
+
}
|
|
775
|
+
if (options?.metadata) {
|
|
776
|
+
formData.append('metadata', JSON.stringify(options.metadata));
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const url = `${this.baseUrl}/api/data/${encodeURIComponent(resource)}/upload`;
|
|
780
|
+
|
|
781
|
+
const response = await fetch(url, {
|
|
782
|
+
method: 'POST',
|
|
783
|
+
body: formData,
|
|
784
|
+
headers: {
|
|
785
|
+
...(this.getAuthHeaders()),
|
|
786
|
+
},
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
if (!response.ok) {
|
|
790
|
+
const error = await response.json().catch(() => ({ message: response.statusText }));
|
|
791
|
+
throw new ObjectStackError(
|
|
792
|
+
error.message || `Upload failed with status ${response.status}`,
|
|
793
|
+
'UPLOAD_ERROR',
|
|
794
|
+
response.status,
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
return response.json();
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Upload multiple files to a resource.
|
|
803
|
+
* Posts all files as a single multipart/form-data request.
|
|
804
|
+
*
|
|
805
|
+
* @param resource - The resource/object name to attach the files to
|
|
806
|
+
* @param files - Array of File objects or Blobs to upload
|
|
807
|
+
* @param options - Additional upload options
|
|
808
|
+
* @returns Promise resolving to array of upload results
|
|
809
|
+
*/
|
|
810
|
+
async uploadFiles(
|
|
811
|
+
resource: string,
|
|
812
|
+
files: (File | Blob)[],
|
|
813
|
+
options?: {
|
|
814
|
+
recordId?: string;
|
|
815
|
+
fieldName?: string;
|
|
816
|
+
metadata?: Record<string, unknown>;
|
|
817
|
+
onProgress?: (percent: number) => void;
|
|
818
|
+
},
|
|
819
|
+
): Promise<FileUploadResult[]> {
|
|
820
|
+
await this.connect();
|
|
821
|
+
|
|
822
|
+
const formData = new FormData();
|
|
823
|
+
files.forEach((file, idx) => {
|
|
824
|
+
formData.append(`files`, file, (file as File).name || `file-${idx}`);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
if (options?.recordId) {
|
|
828
|
+
formData.append('recordId', options.recordId);
|
|
829
|
+
}
|
|
830
|
+
if (options?.fieldName) {
|
|
831
|
+
formData.append('fieldName', options.fieldName);
|
|
832
|
+
}
|
|
833
|
+
if (options?.metadata) {
|
|
834
|
+
formData.append('metadata', JSON.stringify(options.metadata));
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const url = `${this.baseUrl}/api/data/${encodeURIComponent(resource)}/upload`;
|
|
838
|
+
|
|
839
|
+
const response = await fetch(url, {
|
|
840
|
+
method: 'POST',
|
|
841
|
+
body: formData,
|
|
842
|
+
headers: {
|
|
843
|
+
...(this.getAuthHeaders()),
|
|
844
|
+
},
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
if (!response.ok) {
|
|
848
|
+
const error = await response.json().catch(() => ({ message: response.statusText }));
|
|
849
|
+
throw new ObjectStackError(
|
|
850
|
+
error.message || `Upload failed with status ${response.status}`,
|
|
851
|
+
'UPLOAD_ERROR',
|
|
852
|
+
response.status,
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
return response.json();
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Get authorization headers from the adapter config.
|
|
861
|
+
*/
|
|
862
|
+
private getAuthHeaders(): Record<string, string> {
|
|
863
|
+
const headers: Record<string, string> = {};
|
|
864
|
+
if (this.token) {
|
|
865
|
+
headers['Authorization'] = `Bearer ${this.token}`;
|
|
866
|
+
}
|
|
867
|
+
return headers;
|
|
868
|
+
}
|
|
607
869
|
}
|
|
608
870
|
|
|
609
871
|
/**
|
|
@@ -650,3 +912,19 @@ export {
|
|
|
650
912
|
|
|
651
913
|
// Export cache types
|
|
652
914
|
export type { CacheStats } from './cache/MetadataCache';
|
|
915
|
+
|
|
916
|
+
// v3.0.0 Deep Integration modules
|
|
917
|
+
export { CloudOperations } from './cloud';
|
|
918
|
+
export type { CloudDeploymentConfig, CloudHostingConfig, CloudMarketplaceEntry } from './cloud';
|
|
919
|
+
|
|
920
|
+
export { validatePluginContract, generateContractManifest } from './contracts';
|
|
921
|
+
export type { PluginContract, PluginExport, PluginAPIContract, ContractValidationResult, ContractValidationError } from './contracts';
|
|
922
|
+
|
|
923
|
+
export { IntegrationManager } from './integration';
|
|
924
|
+
export type { IntegrationConfig, IntegrationTrigger, IntegrationProvider, SlackIntegrationConfig, EmailIntegrationConfig, WebhookIntegrationConfig } from './integration';
|
|
925
|
+
|
|
926
|
+
export { SecurityManager } from './security';
|
|
927
|
+
export type { SecurityPolicy, CSPConfig, AuditLogConfig, AuditEventType, DataMaskingConfig, DataMaskingRule, AuditLogEntry } from './security';
|
|
928
|
+
|
|
929
|
+
export { createDefaultCanvasConfig, snapToGrid, calculateAutoLayout } from './studio';
|
|
930
|
+
export type { StudioCanvasConfig, StudioPropertyEditor, StudioThemeBuilderConfig, StudioColorPalette, StudioTypographyPreset, StudioShadowPreset } from './studio';
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Integration module for @objectstack/spec v3.0.0
|
|
11
|
+
* Provides third-party service connectors for Slack, email, webhooks.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type IntegrationProvider = 'slack' | 'email' | 'webhook' | 'teams' | 'discord';
|
|
15
|
+
|
|
16
|
+
export interface IntegrationConfig {
|
|
17
|
+
/** Integration provider type */
|
|
18
|
+
provider: IntegrationProvider;
|
|
19
|
+
/** Whether this integration is enabled */
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
/** Provider-specific configuration */
|
|
22
|
+
config: Record<string, unknown>;
|
|
23
|
+
/** Event triggers */
|
|
24
|
+
triggers?: IntegrationTrigger[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface IntegrationTrigger {
|
|
28
|
+
/** Event name (e.g. 'record.created', 'record.updated') */
|
|
29
|
+
event: string;
|
|
30
|
+
/** Filter condition */
|
|
31
|
+
filter?: string;
|
|
32
|
+
/** Template for the message/payload */
|
|
33
|
+
template?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SlackIntegrationConfig extends IntegrationConfig {
|
|
37
|
+
provider: 'slack';
|
|
38
|
+
config: {
|
|
39
|
+
webhookUrl: string;
|
|
40
|
+
channel?: string;
|
|
41
|
+
username?: string;
|
|
42
|
+
iconEmoji?: string;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface EmailIntegrationConfig extends IntegrationConfig {
|
|
47
|
+
provider: 'email';
|
|
48
|
+
config: {
|
|
49
|
+
smtpHost: string;
|
|
50
|
+
smtpPort: number;
|
|
51
|
+
secure: boolean;
|
|
52
|
+
from: string;
|
|
53
|
+
to: string[];
|
|
54
|
+
subject?: string;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface WebhookIntegrationConfig extends IntegrationConfig {
|
|
59
|
+
provider: 'webhook';
|
|
60
|
+
config: {
|
|
61
|
+
url: string;
|
|
62
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH';
|
|
63
|
+
headers?: Record<string, string>;
|
|
64
|
+
retryCount?: number;
|
|
65
|
+
retryDelay?: number;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Integration service manager.
|
|
71
|
+
* Manages third-party service connections and event dispatching.
|
|
72
|
+
*/
|
|
73
|
+
export class IntegrationManager {
|
|
74
|
+
private integrations: Map<string, IntegrationConfig> = new Map();
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Register a new integration.
|
|
78
|
+
*/
|
|
79
|
+
register(id: string, config: IntegrationConfig): void {
|
|
80
|
+
this.integrations.set(id, config);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Remove an integration.
|
|
85
|
+
*/
|
|
86
|
+
unregister(id: string): void {
|
|
87
|
+
this.integrations.delete(id);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get all registered integrations.
|
|
92
|
+
*/
|
|
93
|
+
getAll(): Map<string, IntegrationConfig> {
|
|
94
|
+
return new Map(this.integrations);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get integrations that match a specific event.
|
|
99
|
+
*/
|
|
100
|
+
getForEvent(event: string): IntegrationConfig[] {
|
|
101
|
+
return Array.from(this.integrations.values()).filter(
|
|
102
|
+
(integration) =>
|
|
103
|
+
integration.enabled &&
|
|
104
|
+
integration.triggers?.some((t) => t.event === event)
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Dispatch an event to all matching integrations.
|
|
110
|
+
* Returns results for each integration.
|
|
111
|
+
*/
|
|
112
|
+
async dispatch(event: string, payload: Record<string, unknown>): Promise<Array<{ id: string; success: boolean; error?: string }>> {
|
|
113
|
+
const matching = this.getForEvent(event);
|
|
114
|
+
const results: Array<{ id: string; success: boolean; error?: string }> = [];
|
|
115
|
+
|
|
116
|
+
for (const [id, integration] of this.integrations) {
|
|
117
|
+
if (!matching.includes(integration)) continue;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
await this.send(integration, payload);
|
|
121
|
+
results.push({ id, success: true });
|
|
122
|
+
} catch (err) {
|
|
123
|
+
results.push({ id, success: false, error: (err as Error).message });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return results;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Send payload to a specific integration.
|
|
132
|
+
*/
|
|
133
|
+
private async send(integration: IntegrationConfig, payload: Record<string, unknown>): Promise<void> {
|
|
134
|
+
switch (integration.provider) {
|
|
135
|
+
case 'webhook': {
|
|
136
|
+
const cfg = integration.config as WebhookIntegrationConfig['config'];
|
|
137
|
+
const url = cfg.url;
|
|
138
|
+
// Validate URL - only allow http and https protocols
|
|
139
|
+
try {
|
|
140
|
+
const parsed = new URL(url);
|
|
141
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
142
|
+
throw new Error(`Invalid URL protocol: ${parsed.protocol}`);
|
|
143
|
+
}
|
|
144
|
+
} catch (e) {
|
|
145
|
+
if (e instanceof TypeError) {
|
|
146
|
+
throw new Error(`Invalid webhook URL: ${url}`);
|
|
147
|
+
}
|
|
148
|
+
throw e;
|
|
149
|
+
}
|
|
150
|
+
await fetch(url, {
|
|
151
|
+
method: cfg.method,
|
|
152
|
+
headers: {
|
|
153
|
+
'Content-Type': 'application/json',
|
|
154
|
+
...cfg.headers,
|
|
155
|
+
},
|
|
156
|
+
body: JSON.stringify(payload),
|
|
157
|
+
});
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
case 'slack': {
|
|
161
|
+
const cfg = integration.config as SlackIntegrationConfig['config'];
|
|
162
|
+
const url = cfg.webhookUrl;
|
|
163
|
+
// Validate URL - only allow https protocol for Slack webhooks
|
|
164
|
+
try {
|
|
165
|
+
const parsed = new URL(url);
|
|
166
|
+
if (parsed.protocol !== 'https:') {
|
|
167
|
+
throw new Error(`Invalid Slack webhook URL protocol: ${parsed.protocol}`);
|
|
168
|
+
}
|
|
169
|
+
} catch (e) {
|
|
170
|
+
if (e instanceof TypeError) {
|
|
171
|
+
throw new Error(`Invalid Slack webhook URL: ${url}`);
|
|
172
|
+
}
|
|
173
|
+
throw e;
|
|
174
|
+
}
|
|
175
|
+
await fetch(url, {
|
|
176
|
+
method: 'POST',
|
|
177
|
+
headers: { 'Content-Type': 'application/json' },
|
|
178
|
+
body: JSON.stringify({
|
|
179
|
+
channel: cfg.channel,
|
|
180
|
+
username: cfg.username,
|
|
181
|
+
icon_emoji: cfg.iconEmoji,
|
|
182
|
+
text: JSON.stringify(payload),
|
|
183
|
+
}),
|
|
184
|
+
});
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
// Email and other providers would require server-side implementation
|
|
188
|
+
default:
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|