@memberjunction/server 2.35.1 → 2.36.1

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.
Files changed (52) hide show
  1. package/README.md +15 -1
  2. package/dist/config.d.ts +69 -1
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +11 -1
  5. package/dist/config.js.map +1 -1
  6. package/dist/generated/generated.d.ts +15 -12
  7. package/dist/generated/generated.d.ts.map +1 -1
  8. package/dist/generated/generated.js +73 -58
  9. package/dist/generated/generated.js.map +1 -1
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +41 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/resolvers/AskSkipResolver.d.ts +60 -5
  15. package/dist/resolvers/AskSkipResolver.d.ts.map +1 -1
  16. package/dist/resolvers/AskSkipResolver.js +587 -31
  17. package/dist/resolvers/AskSkipResolver.js.map +1 -1
  18. package/dist/rest/EntityCRUDHandler.d.ts +29 -0
  19. package/dist/rest/EntityCRUDHandler.d.ts.map +1 -0
  20. package/dist/rest/EntityCRUDHandler.js +197 -0
  21. package/dist/rest/EntityCRUDHandler.js.map +1 -0
  22. package/dist/rest/RESTEndpointHandler.d.ts +41 -0
  23. package/dist/rest/RESTEndpointHandler.d.ts.map +1 -0
  24. package/dist/rest/RESTEndpointHandler.js +537 -0
  25. package/dist/rest/RESTEndpointHandler.js.map +1 -0
  26. package/dist/rest/ViewOperationsHandler.d.ts +21 -0
  27. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -0
  28. package/dist/rest/ViewOperationsHandler.js +144 -0
  29. package/dist/rest/ViewOperationsHandler.js.map +1 -0
  30. package/dist/rest/index.d.ts +5 -0
  31. package/dist/rest/index.d.ts.map +1 -0
  32. package/dist/rest/index.js +5 -0
  33. package/dist/rest/index.js.map +1 -0
  34. package/dist/rest/setupRESTEndpoints.d.ts +12 -0
  35. package/dist/rest/setupRESTEndpoints.d.ts.map +1 -0
  36. package/dist/rest/setupRESTEndpoints.js +27 -0
  37. package/dist/rest/setupRESTEndpoints.js.map +1 -0
  38. package/dist/scheduler/LearningCycleScheduler.d.ts +44 -0
  39. package/dist/scheduler/LearningCycleScheduler.d.ts.map +1 -0
  40. package/dist/scheduler/LearningCycleScheduler.js +188 -0
  41. package/dist/scheduler/LearningCycleScheduler.js.map +1 -0
  42. package/package.json +24 -26
  43. package/src/config.ts +15 -1
  44. package/src/generated/generated.ts +53 -44
  45. package/src/index.ts +56 -1
  46. package/src/resolvers/AskSkipResolver.ts +787 -51
  47. package/src/rest/EntityCRUDHandler.ts +279 -0
  48. package/src/rest/RESTEndpointHandler.ts +834 -0
  49. package/src/rest/ViewOperationsHandler.ts +207 -0
  50. package/src/rest/index.ts +4 -0
  51. package/src/rest/setupRESTEndpoints.ts +89 -0
  52. package/src/scheduler/LearningCycleScheduler.ts +312 -0
@@ -0,0 +1,207 @@
1
+ import {
2
+ LogError, Metadata, RunView, RunViewParams,
3
+ RunViewResult, UserInfo
4
+ } from '@memberjunction/core';
5
+
6
+ /**
7
+ * View Operations Implementation for REST endpoints
8
+ * These functions handle running views through the REST API
9
+ */
10
+ export class ViewOperationsHandler {
11
+ /**
12
+ * Run a view and return results
13
+ */
14
+ static async runView(params: RunViewParams, user: UserInfo): Promise<{ success: boolean, result?: RunViewResult, error?: string }> {
15
+ try {
16
+ // Validate entity exists
17
+ const md = new Metadata();
18
+ const entity = md.Entities.find(e => e.Name === params.EntityName);
19
+ if (!entity) {
20
+ return {
21
+ success: false,
22
+ error: `Entity '${params.EntityName}' not found`
23
+ };
24
+ }
25
+
26
+ // Check read permission
27
+ const permissions = entity.GetUserPermisions(user);
28
+ if (!permissions.CanRead) {
29
+ return {
30
+ success: false,
31
+ error: `User ${user.Name} does not have permission to read ${params.EntityName} records`
32
+ };
33
+ }
34
+
35
+ // Sanitize and validate parameters
36
+ this.sanitizeRunViewParams(params);
37
+
38
+ // Execute the view
39
+ const runView = new RunView();
40
+ const result = await runView.RunView(params, user);
41
+
42
+ return { success: true, result };
43
+ } catch (error) {
44
+ LogError(error);
45
+ return { success: false, error: (error as Error)?.message || 'Unknown error' };
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Run multiple views in batch
51
+ */
52
+ static async runViews(paramsArray: RunViewParams[], user: UserInfo): Promise<{ success: boolean, results?: RunViewResult[], error?: string }> {
53
+ try {
54
+ // Validate and sanitize each set of parameters
55
+ const md = new Metadata();
56
+ for (const params of paramsArray) {
57
+ // Validate entity exists
58
+ const entity = md.Entities.find(e => e.Name === params.EntityName);
59
+ if (!entity) {
60
+ return {
61
+ success: false,
62
+ error: `Entity '${params.EntityName}' not found`
63
+ };
64
+ }
65
+
66
+ // Check read permission
67
+ const permissions = entity.GetUserPermisions(user);
68
+ if (!permissions.CanRead) {
69
+ return {
70
+ success: false,
71
+ error: `User ${user.Name} does not have permission to read ${params.EntityName} records`
72
+ };
73
+ }
74
+
75
+ // Sanitize parameters
76
+ this.sanitizeRunViewParams(params);
77
+ }
78
+
79
+ // Execute the views
80
+ const runView = new RunView();
81
+ const results = await runView.RunViews(paramsArray, user);
82
+
83
+ return { success: true, results };
84
+ } catch (error) {
85
+ LogError(error);
86
+ return { success: false, error: (error as Error)?.message || 'Unknown error' };
87
+ }
88
+ }
89
+
90
+ /**
91
+ * List entities with optional filtering
92
+ */
93
+ static async listEntities(params: RunViewParams, user: UserInfo): Promise<RunViewResult> {
94
+ try {
95
+ // Check entity exists and user has permission
96
+ const md = new Metadata();
97
+ const entity = md.Entities.find(e => e.Name === params.EntityName);
98
+ if (!entity) {
99
+ throw new Error(`Entity '${params.EntityName}' not found`);
100
+ }
101
+
102
+ const permissions = entity.GetUserPermisions(user);
103
+ if (!permissions.CanRead) {
104
+ throw new Error(`User ${user.Name} does not have permission to read ${params.EntityName} records`);
105
+ }
106
+
107
+ // Sanitize and validate parameters
108
+ this.sanitizeRunViewParams(params);
109
+
110
+ // Execute the view
111
+ const runView = new RunView();
112
+ return await runView.RunView(params, user);
113
+ } catch (error) {
114
+ LogError(error);
115
+ throw error;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Get available views for an entity
121
+ */
122
+ static async getEntityViews(entityName: string, user: UserInfo): Promise<{ success: boolean, views?: any[], error?: string }> {
123
+ try {
124
+ // Validate entity exists
125
+ const md = new Metadata();
126
+ const entity = md.Entities.find(e => e.Name === entityName);
127
+ if (!entity) {
128
+ return {
129
+ success: false,
130
+ error: `Entity '${entityName}' not found`
131
+ };
132
+ }
133
+
134
+ // Check read permission
135
+ const permissions = entity.GetUserPermisions(user);
136
+ if (!permissions.CanRead) {
137
+ return {
138
+ success: false,
139
+ error: `User ${user.Name} does not have permission to read ${entityName} records`
140
+ };
141
+ }
142
+
143
+ // Run a view to get the available views
144
+ const params: RunViewParams = {
145
+ EntityName: 'User Views',
146
+ ExtraFilter: `Entity = '${entityName}'`
147
+ };
148
+
149
+ const runView = new RunView();
150
+ const result = await runView.RunView(params, user);
151
+
152
+ if (!result.Success) {
153
+ return {
154
+ success: false,
155
+ error: result.ErrorMessage || 'Failed to retrieve views'
156
+ };
157
+ }
158
+
159
+ // Format the view data
160
+ const views = result.Results.map(view => ({
161
+ ID: view.ID,
162
+ Name: view.Name,
163
+ Description: view.Description,
164
+ IsShared: view.IsShared,
165
+ CreatedAt: view.CreatedAt
166
+ }));
167
+
168
+ return { success: true, views };
169
+ } catch (error) {
170
+ LogError(error);
171
+ return { success: false, error: (error as Error)?.message || 'Unknown error' };
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Sanitize and validate RunViewParams
177
+ */
178
+ private static sanitizeRunViewParams(params: RunViewParams): void {
179
+ // Ensure EntityName is provided
180
+ if (!params.EntityName) {
181
+ throw new Error('EntityName is required');
182
+ }
183
+
184
+ // Convert string arrays if they came in as comma-separated strings
185
+ if (params.Fields && typeof params.Fields === 'string') {
186
+ params.Fields = (params.Fields as string).split(',');
187
+ }
188
+
189
+ // Sanitize numeric values
190
+ if (params.MaxRows !== undefined) {
191
+ params.MaxRows = typeof params.MaxRows === 'string'
192
+ ? parseInt(params.MaxRows as string)
193
+ : params.MaxRows;
194
+ }
195
+
196
+ if (params.StartRow !== undefined) {
197
+ params.StartRow = typeof params.StartRow === 'string'
198
+ ? parseInt(params.StartRow as string)
199
+ : params.StartRow;
200
+ }
201
+
202
+ // Default ResultType if not provided
203
+ if (!params.ResultType) {
204
+ params.ResultType = 'simple';
205
+ }
206
+ }
207
+ }
@@ -0,0 +1,4 @@
1
+ export * from './RESTEndpointHandler.js';
2
+ export * from './EntityCRUDHandler.js';
3
+ export * from './ViewOperationsHandler.js';
4
+ export * from './setupRESTEndpoints.js';
@@ -0,0 +1,89 @@
1
+ import express from 'express';
2
+ import { RESTEndpointHandler } from './RESTEndpointHandler.js';
3
+
4
+
5
+ export const ___REST_API_BASE_PATH = '/api/v1';
6
+
7
+ /**
8
+ * Configuration options for REST API endpoints
9
+ */
10
+ export interface RESTApiOptions {
11
+ /**
12
+ * Whether to enable REST API endpoints (default: true)
13
+ */
14
+ enabled: boolean;
15
+
16
+ /**
17
+ * Array of entity names to include in the API (case-insensitive)
18
+ * If provided, only these entities will be accessible through the REST API
19
+ * Supports wildcards using '*' (e.g., 'User*' matches 'User', 'UserRole', etc.)
20
+ */
21
+ includeEntities?: string[];
22
+
23
+ /**
24
+ * Array of entity names to exclude from the API (case-insensitive)
25
+ * These entities will not be accessible through the REST API
26
+ * Supports wildcards using '*' (e.g., 'Secret*' matches 'Secret', 'SecretKey', etc.)
27
+ * Note: Exclude patterns always override include patterns
28
+ */
29
+ excludeEntities?: string[];
30
+
31
+ /**
32
+ * Array of schema names to include in the API (case-insensitive)
33
+ * If provided, only entities in these schemas will be accessible through the REST API
34
+ */
35
+ includeSchemas?: string[];
36
+
37
+ /**
38
+ * Array of schema names to exclude from the API (case-insensitive)
39
+ * Entities in these schemas will not be accessible through the REST API
40
+ * Note: Exclude patterns always override include patterns
41
+ */
42
+ excludeSchemas?: string[];
43
+ }
44
+
45
+ /**
46
+ * Default REST API configuration
47
+ */
48
+ export const DEFAULT_REST_API_OPTIONS: RESTApiOptions = {
49
+ enabled: true,
50
+ };
51
+
52
+ /**
53
+ * Adds the REST API endpoints to an existing Express application
54
+ * @param app The Express application to add the endpoints to
55
+ * @param options Configuration options for REST API
56
+ * @param authMiddleware Optional authentication middleware to use
57
+ */
58
+ export function setupRESTEndpoints(
59
+ app: express.Application,
60
+ options?: Partial<RESTApiOptions>,
61
+ authMiddleware?: express.RequestHandler
62
+ ): void {
63
+ // Merge with default options
64
+ const config = { ...DEFAULT_REST_API_OPTIONS, ...options };
65
+
66
+ // Skip setup if REST API is disabled
67
+ if (!config.enabled) {
68
+ console.log('REST API endpoints are disabled');
69
+ return;
70
+ }
71
+
72
+ // Create REST endpoint handler with entity and schema filters
73
+ const restHandler = new RESTEndpointHandler({
74
+ includeEntities: config.includeEntities ? config.includeEntities.map(e => e.toLowerCase()) : undefined,
75
+ excludeEntities: config.excludeEntities ? config.excludeEntities.map(e => e.toLowerCase()) : undefined,
76
+ includeSchemas: config.includeSchemas ? config.includeSchemas.map(s => s.toLowerCase()) : undefined,
77
+ excludeSchemas: config.excludeSchemas ? config.excludeSchemas.map(s => s.toLowerCase()) : undefined
78
+ });
79
+
80
+ // Mount REST API at the specified base path with authentication
81
+ const basePath = ___REST_API_BASE_PATH;
82
+ if (authMiddleware) {
83
+ app.use(basePath, authMiddleware, restHandler.getRouter());
84
+ } else {
85
+ app.use(basePath, restHandler.getRouter());
86
+ }
87
+
88
+ console.log(`REST API endpoints have been set up at ${basePath}`);
89
+ }
@@ -0,0 +1,312 @@
1
+ import { LogStatus, LogError } from '@memberjunction/core';
2
+ import { UserCache } from '@memberjunction/sqlserver-dataprovider';
3
+ import { GetReadWriteDataSource } from '../util.js';
4
+ import { AskSkipResolver } from '../resolvers/AskSkipResolver.js';
5
+ import { DataSourceInfo } from '../types.js';
6
+ import { getSystemUser } from '../auth/index.js';
7
+ import { BaseSingleton } from '@memberjunction/global';
8
+
9
+ /**
10
+ * A simple scheduler for the Skip AI learning cycle
11
+ * Implements BaseSingleton pattern for cross-instance synchronization
12
+ */
13
+ export class LearningCycleScheduler extends BaseSingleton<LearningCycleScheduler> {
14
+ private intervalId: NodeJS.Timeout | null = null;
15
+
16
+ // Track executions by organization ID instead of a global flag
17
+ private runningOrganizations: Map<string, { startTime: Date, learningCycleId: string }> = new Map();
18
+
19
+ private lastRunTime: Date | null = null;
20
+ private dataSources: DataSourceInfo[] = [];
21
+
22
+ // Protected constructor to enforce singleton pattern via BaseSingleton
23
+ protected constructor() {
24
+ super();
25
+ }
26
+
27
+ public static get Instance(): LearningCycleScheduler {
28
+ return super.getInstance<LearningCycleScheduler>();
29
+ }
30
+
31
+ /**
32
+ * Set the data sources for the scheduler
33
+ * @param dataSources Array of data sources
34
+ */
35
+ public setDataSources(dataSources: DataSourceInfo[]): void {
36
+ this.dataSources = dataSources;
37
+ }
38
+
39
+ /**
40
+ * Start the scheduler with the specified interval in minutes
41
+ * @param intervalMinutes The interval in minutes between runs
42
+ */
43
+ public start(intervalMinutes: number = 60): void {
44
+
45
+ // start learning cycle immediately upon the server start
46
+ this.runLearningCycle()
47
+ .catch(error => LogError(`Error in initial learning cycle: ${error}`));
48
+
49
+ const intervalMs = intervalMinutes * 60 * 1000;
50
+
51
+ LogStatus(`Starting learning cycle scheduler with interval of ${intervalMinutes} minutes`);
52
+
53
+ // Schedule the recurring task
54
+ this.intervalId = setInterval(() => {
55
+ this.runLearningCycle()
56
+ .catch(error => LogError(`Error in scheduled learning cycle: ${error}`));
57
+ }, intervalMs);
58
+ }
59
+
60
+ /**
61
+ * Stop the scheduler
62
+ */
63
+ public stop(): void {
64
+ if (this.intervalId) {
65
+ clearInterval(this.intervalId);
66
+ this.intervalId = null;
67
+ LogStatus('Learning cycle scheduler stopped');
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Run the learning cycle if it's not already running
73
+ * @returns A promise that resolves when the learning cycle completes
74
+ */
75
+ public async runLearningCycle(): Promise<boolean> {
76
+ const startTime = new Date();
77
+
78
+ try {
79
+ LogStatus('Starting scheduled learning cycle execution');
80
+
81
+ // Make sure we have data sources
82
+ if (!this.dataSources || this.dataSources.length === 0) {
83
+ throw new Error('No data sources available for the learning cycle');
84
+ }
85
+
86
+ const dataSource = GetReadWriteDataSource(this.dataSources);
87
+
88
+ // Get system user for operation
89
+ const systemUser = await getSystemUser(dataSource);
90
+ if (!systemUser) {
91
+ throw new Error('System user not found');
92
+ }
93
+
94
+ // Create context for the resolver
95
+ const context = {
96
+ dataSource: dataSource,
97
+ dataSources: this.dataSources,
98
+ userPayload: {
99
+ email: systemUser.Email,
100
+ sessionId: `scheduler_${Date.now()}`,
101
+ userRecord: systemUser,
102
+ isSystemUser: true
103
+ }
104
+ };
105
+
106
+ // Execute the learning cycle
107
+ const skipResolver = new AskSkipResolver();
108
+ const result = await skipResolver.ExecuteAskSkipLearningCycle(
109
+ context,
110
+ false // forceEntityRefresh
111
+ );
112
+
113
+ const endTime = new Date();
114
+ const elapsedMs = endTime.getTime() - startTime.getTime();
115
+
116
+ this.lastRunTime = startTime;
117
+
118
+ if (result.success) {
119
+ LogStatus(`Learning cycle completed successfully in ${elapsedMs}ms`);
120
+ return true;
121
+ } else {
122
+ LogError(`Learning cycle failed after ${elapsedMs}ms: ${result.error}`);
123
+ return false;
124
+ }
125
+ } catch (error) {
126
+ LogError(`Error executing learning cycle: ${error}`);
127
+ return false;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Get the current status of the scheduler
133
+ */
134
+ public getStatus() {
135
+ return {
136
+ isSchedulerRunning: this.intervalId !== null,
137
+ lastRunTime: this.lastRunTime,
138
+ runningOrganizations: Array.from(this.runningOrganizations.entries()).map(([orgId, info]) => ({
139
+ organizationId: orgId,
140
+ learningCycleId: info.learningCycleId,
141
+ startTime: info.startTime,
142
+ runningForMinutes: (new Date().getTime() - info.startTime.getTime()) / (1000 * 60)
143
+ }))
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Checks if an organization is currently running a learning cycle
149
+ * @param organizationId The organization ID to check
150
+ * @returns Whether the organization is running a cycle and details if running
151
+ */
152
+ public isOrganizationRunningCycle(
153
+ organizationId: string
154
+ ): { isRunning: boolean, startTime?: Date, learningCycleId?: string, runningForMinutes?: number } {
155
+ const runningInfo = this.runningOrganizations.get(organizationId);
156
+
157
+ if (runningInfo) {
158
+ // Check if it's been running too long and should be considered stalled
159
+ const now = new Date();
160
+ const elapsedMinutes = (now.getTime() - runningInfo.startTime.getTime()) / (1000 * 60);
161
+
162
+ return {
163
+ isRunning: true,
164
+ startTime: runningInfo.startTime,
165
+ learningCycleId: runningInfo.learningCycleId,
166
+ runningForMinutes: elapsedMinutes
167
+ };
168
+ }
169
+
170
+ return { isRunning: false };
171
+ }
172
+
173
+ /**
174
+ * Registers an organization as running a learning cycle
175
+ * @param organizationId The organization ID to register
176
+ * @param learningCycleId The ID of the learning cycle
177
+ * @returns true if successfully registered, false if already running
178
+ */
179
+ public registerRunningCycle(organizationId: string, learningCycleId: string): boolean {
180
+ // First check if already running
181
+ const { isRunning } = this.isOrganizationRunningCycle(organizationId);
182
+
183
+ if (isRunning) {
184
+ return false;
185
+ }
186
+
187
+ // Register the organization as running a cycle
188
+ this.runningOrganizations.set(organizationId, {
189
+ startTime: new Date(),
190
+ learningCycleId
191
+ });
192
+
193
+ return true;
194
+ }
195
+
196
+ /**
197
+ * Unregisters an organization after its learning cycle completes
198
+ * @param organizationId The organization ID to unregister
199
+ * @returns true if successfully unregistered, false if wasn't registered
200
+ */
201
+ public unregisterRunningCycle(organizationId: string): boolean {
202
+ if (this.runningOrganizations.has(organizationId)) {
203
+ this.runningOrganizations.delete(organizationId);
204
+ return true;
205
+ }
206
+
207
+ return false;
208
+ }
209
+
210
+ /**
211
+ * Manually execute a learning cycle run for testing purposes
212
+ * This is intended for debugging/testing only and will force a run
213
+ * even if the scheduler is not started
214
+ * @param organizationId Optional organization ID to register for the manual run
215
+ * @returns A promise that resolves when the learning cycle completes
216
+ */
217
+ public async manuallyExecuteLearningCycle(organizationId?: string): Promise<boolean> {
218
+ try {
219
+ LogStatus('🧪 Manually executing learning cycle for testing...');
220
+
221
+ // If an organization ID is provided, register it as running
222
+ const learningCycleId = `manual_${Date.now()}`;
223
+ let orgRegistered = false;
224
+
225
+ if (organizationId) {
226
+ // Check if already running
227
+ const runningStatus = this.isOrganizationRunningCycle(organizationId);
228
+
229
+ if (runningStatus.isRunning) {
230
+ LogError(`Organization ${organizationId} is already running a learning cycle. Cannot start a new one.`);
231
+ return false;
232
+ }
233
+
234
+ // Register this organization
235
+ orgRegistered = this.registerRunningCycle(organizationId, learningCycleId);
236
+ if (!orgRegistered) {
237
+ LogError(`Failed to register organization ${organizationId} for manual learning cycle execution`);
238
+ return false;
239
+ }
240
+ }
241
+
242
+ // Run the learning cycle
243
+ const result = await this.runLearningCycle();
244
+ LogStatus(`🧪 Manual learning cycle execution completed with result: ${result ? 'Success' : 'Failed'}`);
245
+
246
+ // Unregister the organization if it was registered
247
+ if (organizationId && orgRegistered) {
248
+ this.unregisterRunningCycle(organizationId);
249
+ }
250
+
251
+ return result;
252
+ } catch (error) {
253
+ // Make sure to unregister on error
254
+ if (organizationId && this.runningOrganizations.has(organizationId)) {
255
+ this.unregisterRunningCycle(organizationId);
256
+ }
257
+
258
+ LogError(`Error in manual learning cycle execution: ${error}`);
259
+ return false;
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Force stop a running learning cycle for an organization
265
+ * @param organizationId The organization ID to stop the cycle for
266
+ * @returns Information about the stopped cycle
267
+ */
268
+ public stopLearningCycleForOrganization(organizationId: string): {
269
+ success: boolean,
270
+ message: string,
271
+ wasRunning: boolean,
272
+ cycleDetails?: { learningCycleId: string, startTime: Date, runningForMinutes: number }
273
+ } {
274
+ // Check if this organization has a running cycle
275
+ const runningStatus = this.isOrganizationRunningCycle(organizationId);
276
+
277
+ if (!runningStatus.isRunning) {
278
+ return {
279
+ success: false,
280
+ message: `No running learning cycle found for organization ${organizationId}`,
281
+ wasRunning: false
282
+ };
283
+ }
284
+
285
+ // Capture details before unregistering
286
+ const startTime = runningStatus.startTime!;
287
+ const learningCycleId = runningStatus.learningCycleId!;
288
+ const runningForMinutes = runningStatus.runningForMinutes!;
289
+
290
+ // Unregister the organization
291
+ const unregistered = this.unregisterRunningCycle(organizationId);
292
+
293
+ if (unregistered) {
294
+ return {
295
+ success: true,
296
+ message: `Successfully stopped learning cycle for organization ${organizationId}`,
297
+ wasRunning: true,
298
+ cycleDetails: {
299
+ learningCycleId,
300
+ startTime,
301
+ runningForMinutes
302
+ }
303
+ };
304
+ } else {
305
+ return {
306
+ success: false,
307
+ message: `Failed to stop learning cycle for organization ${organizationId}`,
308
+ wasRunning: true
309
+ };
310
+ }
311
+ }
312
+ }