@way_marks/server 0.9.0 → 1.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/api/server.js +598 -0
- package/dist/approval/manager.js +249 -0
- package/dist/approval/manager.test.js +315 -0
- package/dist/db/database.js +544 -1
- package/dist/escalation/manager.js +205 -0
- package/dist/escalation/manager.test.js +519 -0
- package/dist/notifications/service.js +457 -0
- package/dist/policy/engine.js +420 -0
- package/dist/remediation/recommender.js +309 -0
- package/dist/risk/analyzer.js +442 -0
- package/dist/risk/analyzer.test.js +482 -0
- package/dist/rollback/blocker.js +222 -0
- package/dist/rollback/manager.js +245 -0
- package/dist/rollback/manager.test.js +552 -0
- package/package.json +1 -1
- package/src/ui/index.html +862 -0
package/dist/db/database.js
CHANGED
|
@@ -49,6 +49,40 @@ exports.getActionsWithFiltering = getActionsWithFiltering;
|
|
|
49
49
|
exports.archiveOldActions = archiveOldActions;
|
|
50
50
|
exports.getArchivedActionsWithFiltering = getArchivedActionsWithFiltering;
|
|
51
51
|
exports.getSummaryStats = getSummaryStats;
|
|
52
|
+
exports.createSession = createSession;
|
|
53
|
+
exports.getSession = getSession;
|
|
54
|
+
exports.getAllSessions = getAllSessions;
|
|
55
|
+
exports.getSessionActions = getSessionActions;
|
|
56
|
+
exports.rollbackSession = rollbackSession;
|
|
57
|
+
exports.markSessionRolledBack = markSessionRolledBack;
|
|
58
|
+
exports.addTeamMember = addTeamMember;
|
|
59
|
+
exports.getTeamMember = getTeamMember;
|
|
60
|
+
exports.getTeamMemberByEmail = getTeamMemberByEmail;
|
|
61
|
+
exports.getAllTeamMembers = getAllTeamMembers;
|
|
62
|
+
exports.removeTeamMember = removeTeamMember;
|
|
63
|
+
exports.addApprovalRoute = addApprovalRoute;
|
|
64
|
+
exports.getApprovalRoute = getApprovalRoute;
|
|
65
|
+
exports.getAllApprovalRoutes = getAllApprovalRoutes;
|
|
66
|
+
exports.updateApprovalRoute = updateApprovalRoute;
|
|
67
|
+
exports.deleteApprovalRoute = deleteApprovalRoute;
|
|
68
|
+
exports.createApprovalRequest = createApprovalRequest;
|
|
69
|
+
exports.getApprovalRequest = getApprovalRequest;
|
|
70
|
+
exports.getSessionApprovalRequests = getSessionApprovalRequests;
|
|
71
|
+
exports.getPendingApprovals = getPendingApprovals;
|
|
72
|
+
exports.submitApprovalDecision = submitApprovalDecision;
|
|
73
|
+
exports.getApprovalDecisions = getApprovalDecisions;
|
|
74
|
+
exports.addEscalationRule = addEscalationRule;
|
|
75
|
+
exports.getEscalationRule = getEscalationRule;
|
|
76
|
+
exports.getAllEscalationRules = getAllEscalationRules;
|
|
77
|
+
exports.updateEscalationRule = updateEscalationRule;
|
|
78
|
+
exports.deleteEscalationRule = deleteEscalationRule;
|
|
79
|
+
exports.createEscalationRequest = createEscalationRequest;
|
|
80
|
+
exports.getEscalationRequest = getEscalationRequest;
|
|
81
|
+
exports.getPendingEscalations = getPendingEscalations;
|
|
82
|
+
exports.getStaleApprovals = getStaleApprovals;
|
|
83
|
+
exports.submitEscalationDecision = submitEscalationDecision;
|
|
84
|
+
exports.getEscalationDecisions = getEscalationDecisions;
|
|
85
|
+
exports.getEscalationHistory = getEscalationHistory;
|
|
52
86
|
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
|
|
53
87
|
const fs = __importStar(require("fs"));
|
|
54
88
|
const path = __importStar(require("path"));
|
|
@@ -138,6 +172,30 @@ try {
|
|
|
138
172
|
db.exec("ALTER TABLE action_log ADD COLUMN source TEXT DEFAULT 'mcp'");
|
|
139
173
|
}
|
|
140
174
|
catch { }
|
|
175
|
+
// Migrate v6: Phase 1 — Session-level rollback
|
|
176
|
+
try {
|
|
177
|
+
db.exec('ALTER TABLE action_log ADD COLUMN rollback_group TEXT');
|
|
178
|
+
}
|
|
179
|
+
catch { }
|
|
180
|
+
try {
|
|
181
|
+
db.exec('ALTER TABLE action_log ADD COLUMN is_reversible INTEGER DEFAULT 1');
|
|
182
|
+
}
|
|
183
|
+
catch { }
|
|
184
|
+
try {
|
|
185
|
+
db.exec('ALTER TABLE action_log ADD COLUMN revert_action_id TEXT');
|
|
186
|
+
}
|
|
187
|
+
catch { }
|
|
188
|
+
// Phase 1: Create sessions table
|
|
189
|
+
db.exec(`
|
|
190
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
191
|
+
session_id TEXT PRIMARY KEY,
|
|
192
|
+
user_id TEXT,
|
|
193
|
+
project_id TEXT,
|
|
194
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
195
|
+
rolled_back_at DATETIME,
|
|
196
|
+
status TEXT NOT NULL DEFAULT 'active'
|
|
197
|
+
)
|
|
198
|
+
`);
|
|
141
199
|
// Indexes for query performance
|
|
142
200
|
try {
|
|
143
201
|
db.exec('CREATE INDEX IF NOT EXISTS idx_action_id ON action_log(action_id)');
|
|
@@ -192,9 +250,25 @@ db.exec(`
|
|
|
192
250
|
event_type TEXT NOT NULL DEFAULT 'execution',
|
|
193
251
|
observation_context TEXT,
|
|
194
252
|
request_source TEXT DEFAULT 'direct',
|
|
195
|
-
source TEXT DEFAULT 'mcp'
|
|
253
|
+
source TEXT DEFAULT 'mcp',
|
|
254
|
+
rollback_group TEXT,
|
|
255
|
+
is_reversible INTEGER DEFAULT 1,
|
|
256
|
+
revert_action_id TEXT
|
|
196
257
|
)
|
|
197
258
|
`);
|
|
259
|
+
// Phase 1: Add same columns to archive (migrations)
|
|
260
|
+
try {
|
|
261
|
+
db.exec('ALTER TABLE action_archive ADD COLUMN rollback_group TEXT');
|
|
262
|
+
}
|
|
263
|
+
catch { }
|
|
264
|
+
try {
|
|
265
|
+
db.exec('ALTER TABLE action_archive ADD COLUMN is_reversible INTEGER DEFAULT 1');
|
|
266
|
+
}
|
|
267
|
+
catch { }
|
|
268
|
+
try {
|
|
269
|
+
db.exec('ALTER TABLE action_archive ADD COLUMN revert_action_id TEXT');
|
|
270
|
+
}
|
|
271
|
+
catch { }
|
|
198
272
|
// Phase 3: Add indexes on archive table
|
|
199
273
|
try {
|
|
200
274
|
db.exec('CREATE INDEX IF NOT EXISTS idx_archive_action_id ON action_archive(action_id)');
|
|
@@ -204,6 +278,174 @@ try {
|
|
|
204
278
|
db.exec('CREATE INDEX IF NOT EXISTS idx_archive_created_at ON action_archive(created_at DESC)');
|
|
205
279
|
}
|
|
206
280
|
catch { }
|
|
281
|
+
// Phase 1: Add indexes for session rollback
|
|
282
|
+
try {
|
|
283
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_action_session ON action_log(session_id, status)');
|
|
284
|
+
}
|
|
285
|
+
catch { }
|
|
286
|
+
try {
|
|
287
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_action_rollback_group ON action_log(rollback_group)');
|
|
288
|
+
}
|
|
289
|
+
catch { }
|
|
290
|
+
try {
|
|
291
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status, created_at DESC)');
|
|
292
|
+
}
|
|
293
|
+
catch { }
|
|
294
|
+
try {
|
|
295
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_archive_session ON action_archive(session_id)');
|
|
296
|
+
}
|
|
297
|
+
catch { }
|
|
298
|
+
// Phase 2: Create team_members table
|
|
299
|
+
db.exec(`
|
|
300
|
+
CREATE TABLE IF NOT EXISTS team_members (
|
|
301
|
+
member_id TEXT PRIMARY KEY,
|
|
302
|
+
name TEXT NOT NULL,
|
|
303
|
+
email TEXT NOT NULL UNIQUE,
|
|
304
|
+
slack_id TEXT,
|
|
305
|
+
role TEXT DEFAULT 'approver',
|
|
306
|
+
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
307
|
+
added_by TEXT,
|
|
308
|
+
status TEXT DEFAULT 'active'
|
|
309
|
+
)
|
|
310
|
+
`);
|
|
311
|
+
// Phase 2: Create approval_routes table (rules for who approves what)
|
|
312
|
+
db.exec(`
|
|
313
|
+
CREATE TABLE IF NOT EXISTS approval_routes (
|
|
314
|
+
route_id TEXT PRIMARY KEY,
|
|
315
|
+
name TEXT NOT NULL,
|
|
316
|
+
description TEXT,
|
|
317
|
+
condition_type TEXT DEFAULT 'all_sessions',
|
|
318
|
+
condition_json TEXT,
|
|
319
|
+
required_approvers INTEGER DEFAULT 1,
|
|
320
|
+
approver_ids TEXT NOT NULL,
|
|
321
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
322
|
+
created_by TEXT,
|
|
323
|
+
status TEXT DEFAULT 'active'
|
|
324
|
+
)
|
|
325
|
+
`);
|
|
326
|
+
// Phase 2: Create approval_requests table (pending/completed approvals)
|
|
327
|
+
db.exec(`
|
|
328
|
+
CREATE TABLE IF NOT EXISTS approval_requests (
|
|
329
|
+
request_id TEXT PRIMARY KEY,
|
|
330
|
+
session_id TEXT NOT NULL,
|
|
331
|
+
route_id TEXT NOT NULL,
|
|
332
|
+
triggered_by TEXT NOT NULL,
|
|
333
|
+
triggered_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
334
|
+
status TEXT DEFAULT 'pending',
|
|
335
|
+
completed_at DATETIME,
|
|
336
|
+
approver_ids TEXT NOT NULL,
|
|
337
|
+
approved_count INTEGER DEFAULT 0,
|
|
338
|
+
rejected_count INTEGER DEFAULT 0,
|
|
339
|
+
approval_details TEXT
|
|
340
|
+
)
|
|
341
|
+
`);
|
|
342
|
+
// Phase 2: Create approval_decisions table (audit trail)
|
|
343
|
+
db.exec(`
|
|
344
|
+
CREATE TABLE IF NOT EXISTS approval_decisions (
|
|
345
|
+
decision_id TEXT PRIMARY KEY,
|
|
346
|
+
request_id TEXT NOT NULL,
|
|
347
|
+
approver_id TEXT NOT NULL,
|
|
348
|
+
decision TEXT NOT NULL,
|
|
349
|
+
reason TEXT,
|
|
350
|
+
decided_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
351
|
+
)
|
|
352
|
+
`);
|
|
353
|
+
// Phase 2: Add indexes for team tables
|
|
354
|
+
try {
|
|
355
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_team_members_email ON team_members(email)');
|
|
356
|
+
}
|
|
357
|
+
catch { }
|
|
358
|
+
try {
|
|
359
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_team_members_status ON team_members(status)');
|
|
360
|
+
}
|
|
361
|
+
catch { }
|
|
362
|
+
try {
|
|
363
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_approval_routes_status ON approval_routes(status)');
|
|
364
|
+
}
|
|
365
|
+
catch { }
|
|
366
|
+
try {
|
|
367
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_approval_requests_session ON approval_requests(session_id)');
|
|
368
|
+
}
|
|
369
|
+
catch { }
|
|
370
|
+
try {
|
|
371
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_approval_requests_status ON approval_requests(status)');
|
|
372
|
+
}
|
|
373
|
+
catch { }
|
|
374
|
+
try {
|
|
375
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_approval_requests_triggered ON approval_requests(triggered_at DESC)');
|
|
376
|
+
}
|
|
377
|
+
catch { }
|
|
378
|
+
try {
|
|
379
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_approval_decisions_request ON approval_decisions(request_id)');
|
|
380
|
+
}
|
|
381
|
+
catch { }
|
|
382
|
+
try {
|
|
383
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_approval_decisions_approver ON approval_decisions(approver_id)');
|
|
384
|
+
}
|
|
385
|
+
catch { }
|
|
386
|
+
// Phase 3: Create escalation_rules table
|
|
387
|
+
db.exec(`
|
|
388
|
+
CREATE TABLE IF NOT EXISTS escalation_rules (
|
|
389
|
+
rule_id TEXT PRIMARY KEY,
|
|
390
|
+
name TEXT NOT NULL,
|
|
391
|
+
description TEXT,
|
|
392
|
+
timeout_hours INTEGER DEFAULT 24,
|
|
393
|
+
escalation_targets TEXT NOT NULL,
|
|
394
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
395
|
+
created_by TEXT,
|
|
396
|
+
status TEXT DEFAULT 'active'
|
|
397
|
+
)
|
|
398
|
+
`);
|
|
399
|
+
// Phase 3: Create escalation_requests table
|
|
400
|
+
db.exec(`
|
|
401
|
+
CREATE TABLE IF NOT EXISTS escalation_requests (
|
|
402
|
+
request_id TEXT PRIMARY KEY,
|
|
403
|
+
approval_request_id TEXT NOT NULL,
|
|
404
|
+
session_id TEXT NOT NULL,
|
|
405
|
+
escalation_triggered_at DATETIME,
|
|
406
|
+
escalation_deadline DATETIME,
|
|
407
|
+
escalation_targets TEXT NOT NULL,
|
|
408
|
+
status TEXT DEFAULT 'pending',
|
|
409
|
+
decided_at DATETIME,
|
|
410
|
+
decision TEXT
|
|
411
|
+
)
|
|
412
|
+
`);
|
|
413
|
+
// Phase 3: Create escalation_decisions table
|
|
414
|
+
db.exec(`
|
|
415
|
+
CREATE TABLE IF NOT EXISTS escalation_decisions (
|
|
416
|
+
decision_id TEXT PRIMARY KEY,
|
|
417
|
+
escalation_request_id TEXT NOT NULL,
|
|
418
|
+
target_id TEXT NOT NULL,
|
|
419
|
+
decision TEXT NOT NULL,
|
|
420
|
+
reason TEXT,
|
|
421
|
+
decided_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
422
|
+
)
|
|
423
|
+
`);
|
|
424
|
+
// Phase 3: Add indexes for escalation tables
|
|
425
|
+
try {
|
|
426
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_escalation_rules_status ON escalation_rules(status)');
|
|
427
|
+
}
|
|
428
|
+
catch { }
|
|
429
|
+
try {
|
|
430
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_escalation_requests_approval ON escalation_requests(approval_request_id)');
|
|
431
|
+
}
|
|
432
|
+
catch { }
|
|
433
|
+
try {
|
|
434
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_escalation_requests_deadline ON escalation_requests(escalation_deadline)');
|
|
435
|
+
}
|
|
436
|
+
catch { }
|
|
437
|
+
try {
|
|
438
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_escalation_requests_status ON escalation_requests(status)');
|
|
439
|
+
}
|
|
440
|
+
catch { }
|
|
441
|
+
try {
|
|
442
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_escalation_decisions_request ON escalation_decisions(escalation_request_id)');
|
|
443
|
+
}
|
|
444
|
+
catch { }
|
|
445
|
+
try {
|
|
446
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_escalation_decisions_target ON escalation_decisions(target_id)');
|
|
447
|
+
}
|
|
448
|
+
catch { }
|
|
207
449
|
const insertStmt = db.prepare(`
|
|
208
450
|
INSERT INTO action_log (action_id, session_id, tool_name, target_path, input_payload, before_snapshot, status, decision, policy_reason, matched_rule, event_type, observation_context, request_source, source)
|
|
209
451
|
VALUES (@action_id, @session_id, @tool_name, @target_path, @input_payload, @before_snapshot, @status, @decision, @policy_reason, @matched_rule, @event_type, @observation_context, @request_source, @source)
|
|
@@ -441,4 +683,305 @@ function getSummaryStats() {
|
|
|
441
683
|
topPaths,
|
|
442
684
|
};
|
|
443
685
|
}
|
|
686
|
+
// Phase 1: Session management functions
|
|
687
|
+
function createSession(session_id, user_id, project_id) {
|
|
688
|
+
db.prepare(`
|
|
689
|
+
INSERT OR IGNORE INTO sessions (session_id, user_id, project_id, status)
|
|
690
|
+
VALUES (?, ?, ?, 'active')
|
|
691
|
+
`).run(session_id, user_id ?? null, project_id ?? null);
|
|
692
|
+
}
|
|
693
|
+
function getSession(session_id) {
|
|
694
|
+
return db.prepare(`
|
|
695
|
+
SELECT * FROM sessions WHERE session_id = ?
|
|
696
|
+
`).get(session_id);
|
|
697
|
+
}
|
|
698
|
+
function getAllSessions() {
|
|
699
|
+
return db.prepare(`
|
|
700
|
+
SELECT * FROM sessions ORDER BY created_at DESC
|
|
701
|
+
`).all();
|
|
702
|
+
}
|
|
703
|
+
function getSessionActions(session_id) {
|
|
704
|
+
return db.prepare(`
|
|
705
|
+
SELECT * FROM action_log
|
|
706
|
+
WHERE session_id = ?
|
|
707
|
+
ORDER BY created_at ASC
|
|
708
|
+
`).all(session_id);
|
|
709
|
+
}
|
|
710
|
+
function rollbackSession(session_id) {
|
|
711
|
+
try {
|
|
712
|
+
// Start transaction
|
|
713
|
+
const transaction = db.transaction(() => {
|
|
714
|
+
// Get all actions in session
|
|
715
|
+
const actions = getSessionActions(session_id);
|
|
716
|
+
if (actions.length === 0) {
|
|
717
|
+
throw new Error(`Session ${session_id} has no actions to rollback`);
|
|
718
|
+
}
|
|
719
|
+
// Check if all actions are reversible
|
|
720
|
+
const nonReversible = actions.filter(a => a.is_reversible === 0);
|
|
721
|
+
if (nonReversible.length > 0) {
|
|
722
|
+
throw new Error(`${nonReversible.length} action(s) in session are not reversible`);
|
|
723
|
+
}
|
|
724
|
+
// For each action, perform rollback
|
|
725
|
+
let rolledBackCount = 0;
|
|
726
|
+
for (const action of actions) {
|
|
727
|
+
// Mark as rolled back
|
|
728
|
+
markRolledBack(action.action_id);
|
|
729
|
+
rolledBackCount++;
|
|
730
|
+
// If before_snapshot exists, restore it (for write_file actions)
|
|
731
|
+
if (action.before_snapshot) {
|
|
732
|
+
try {
|
|
733
|
+
const snapshot = JSON.parse(action.before_snapshot);
|
|
734
|
+
if (snapshot.file_path && snapshot.content !== undefined) {
|
|
735
|
+
// This will be handled by the rollback manager
|
|
736
|
+
// We just mark it as rolled back here
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
catch { }
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
// Mark session as rolled back
|
|
743
|
+
db.prepare(`
|
|
744
|
+
UPDATE sessions
|
|
745
|
+
SET rolled_back_at = datetime('now'), status = 'rolled_back'
|
|
746
|
+
WHERE session_id = ?
|
|
747
|
+
`).run(session_id);
|
|
748
|
+
return rolledBackCount;
|
|
749
|
+
});
|
|
750
|
+
const actionsRolledBack = transaction();
|
|
751
|
+
return {
|
|
752
|
+
success: true,
|
|
753
|
+
session_id,
|
|
754
|
+
actions_rolled_back: actionsRolledBack,
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
catch (error) {
|
|
758
|
+
return {
|
|
759
|
+
success: false,
|
|
760
|
+
session_id,
|
|
761
|
+
actions_rolled_back: 0,
|
|
762
|
+
error: error.message,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
function markSessionRolledBack(session_id) {
|
|
767
|
+
db.prepare(`
|
|
768
|
+
UPDATE sessions
|
|
769
|
+
SET rolled_back_at = datetime('now'), status = 'rolled_back'
|
|
770
|
+
WHERE session_id = ?
|
|
771
|
+
`).run(session_id);
|
|
772
|
+
}
|
|
773
|
+
// Phase 2: Team member functions
|
|
774
|
+
function addTeamMember(member_id, name, email, added_by, slack_id) {
|
|
775
|
+
db.prepare(`
|
|
776
|
+
INSERT OR REPLACE INTO team_members (member_id, name, email, slack_id, added_by, status)
|
|
777
|
+
VALUES (?, ?, ?, ?, ?, 'active')
|
|
778
|
+
`).run(member_id, name, email, slack_id ?? null, added_by);
|
|
779
|
+
}
|
|
780
|
+
function getTeamMember(member_id) {
|
|
781
|
+
return db.prepare(`
|
|
782
|
+
SELECT * FROM team_members WHERE member_id = ?
|
|
783
|
+
`).get(member_id);
|
|
784
|
+
}
|
|
785
|
+
function getTeamMemberByEmail(email) {
|
|
786
|
+
return db.prepare(`
|
|
787
|
+
SELECT * FROM team_members WHERE email = ?
|
|
788
|
+
`).get(email);
|
|
789
|
+
}
|
|
790
|
+
function getAllTeamMembers() {
|
|
791
|
+
return db.prepare(`
|
|
792
|
+
SELECT * FROM team_members WHERE status = 'active' ORDER BY name ASC
|
|
793
|
+
`).all();
|
|
794
|
+
}
|
|
795
|
+
function removeTeamMember(member_id) {
|
|
796
|
+
db.prepare(`
|
|
797
|
+
UPDATE team_members SET status = 'inactive' WHERE member_id = ?
|
|
798
|
+
`).run(member_id);
|
|
799
|
+
}
|
|
800
|
+
// Phase 2: Approval route functions
|
|
801
|
+
function addApprovalRoute(route_id, name, approver_ids, created_by, description, condition_type, condition_json) {
|
|
802
|
+
db.prepare(`
|
|
803
|
+
INSERT OR REPLACE INTO approval_routes (route_id, name, description, condition_type, condition_json, approver_ids, created_by, status)
|
|
804
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'active')
|
|
805
|
+
`).run(route_id, name, description ?? null, condition_type ?? 'all_sessions', condition_json ?? null, JSON.stringify(approver_ids), created_by);
|
|
806
|
+
}
|
|
807
|
+
function getApprovalRoute(route_id) {
|
|
808
|
+
return db.prepare(`
|
|
809
|
+
SELECT * FROM approval_routes WHERE route_id = ?
|
|
810
|
+
`).get(route_id);
|
|
811
|
+
}
|
|
812
|
+
function getAllApprovalRoutes() {
|
|
813
|
+
return db.prepare(`
|
|
814
|
+
SELECT * FROM approval_routes WHERE status = 'active' ORDER BY name ASC
|
|
815
|
+
`).all();
|
|
816
|
+
}
|
|
817
|
+
function updateApprovalRoute(route_id, updates) {
|
|
818
|
+
const current = getApprovalRoute(route_id);
|
|
819
|
+
if (!current)
|
|
820
|
+
throw new Error(`Route ${route_id} not found`);
|
|
821
|
+
db.prepare(`
|
|
822
|
+
UPDATE approval_routes
|
|
823
|
+
SET name = ?, description = ?, approver_ids = ?
|
|
824
|
+
WHERE route_id = ?
|
|
825
|
+
`).run(updates.name ?? current.name, updates.description ?? current.description, updates.approver_ids ? JSON.stringify(updates.approver_ids) : current.approver_ids, route_id);
|
|
826
|
+
}
|
|
827
|
+
function deleteApprovalRoute(route_id) {
|
|
828
|
+
db.prepare(`
|
|
829
|
+
UPDATE approval_routes SET status = 'inactive' WHERE route_id = ?
|
|
830
|
+
`).run(route_id);
|
|
831
|
+
}
|
|
832
|
+
// Phase 2: Approval request functions
|
|
833
|
+
function createApprovalRequest(request_id, session_id, route_id, triggered_by, approver_ids) {
|
|
834
|
+
db.prepare(`
|
|
835
|
+
INSERT INTO approval_requests (request_id, session_id, route_id, triggered_by, approver_ids, status)
|
|
836
|
+
VALUES (?, ?, ?, ?, ?, 'pending')
|
|
837
|
+
`).run(request_id, session_id, route_id, triggered_by, JSON.stringify(approver_ids));
|
|
838
|
+
}
|
|
839
|
+
function getApprovalRequest(request_id) {
|
|
840
|
+
return db.prepare(`
|
|
841
|
+
SELECT * FROM approval_requests WHERE request_id = ?
|
|
842
|
+
`).get(request_id);
|
|
843
|
+
}
|
|
844
|
+
function getSessionApprovalRequests(session_id) {
|
|
845
|
+
return db.prepare(`
|
|
846
|
+
SELECT * FROM approval_requests WHERE session_id = ? ORDER BY triggered_at DESC
|
|
847
|
+
`).all(session_id);
|
|
848
|
+
}
|
|
849
|
+
function getPendingApprovals(approver_id) {
|
|
850
|
+
if (approver_id) {
|
|
851
|
+
return db.prepare(`
|
|
852
|
+
SELECT * FROM approval_requests
|
|
853
|
+
WHERE status = 'pending' AND approver_ids LIKE ?
|
|
854
|
+
ORDER BY triggered_at DESC
|
|
855
|
+
`).all(`%"${approver_id}"%`);
|
|
856
|
+
}
|
|
857
|
+
return db.prepare(`
|
|
858
|
+
SELECT * FROM approval_requests WHERE status = 'pending' ORDER BY triggered_at DESC
|
|
859
|
+
`).all();
|
|
860
|
+
}
|
|
861
|
+
function submitApprovalDecision(decision_id, request_id, approver_id, decision, reason) {
|
|
862
|
+
// Insert decision
|
|
863
|
+
db.prepare(`
|
|
864
|
+
INSERT INTO approval_decisions (decision_id, request_id, approver_id, decision, reason)
|
|
865
|
+
VALUES (?, ?, ?, ?, ?)
|
|
866
|
+
`).run(decision_id, request_id, approver_id, decision, reason ?? null);
|
|
867
|
+
// Update approval request counts
|
|
868
|
+
const request = getApprovalRequest(request_id);
|
|
869
|
+
if (!request)
|
|
870
|
+
throw new Error(`Request ${request_id} not found`);
|
|
871
|
+
const decisions = db.prepare(`
|
|
872
|
+
SELECT decision FROM approval_decisions WHERE request_id = ?
|
|
873
|
+
`).all(request_id);
|
|
874
|
+
const approvedCount = decisions.filter(d => d.decision === 'approve').length;
|
|
875
|
+
const rejectedCount = decisions.filter(d => d.decision === 'reject').length;
|
|
876
|
+
const approverIds = JSON.parse(request.approver_ids);
|
|
877
|
+
// Determine new status
|
|
878
|
+
let newStatus = 'pending';
|
|
879
|
+
if (rejectedCount > 0) {
|
|
880
|
+
newStatus = 'rejected';
|
|
881
|
+
}
|
|
882
|
+
else if (approvedCount >= approverIds.length) {
|
|
883
|
+
newStatus = 'approved';
|
|
884
|
+
}
|
|
885
|
+
db.prepare(`
|
|
886
|
+
UPDATE approval_requests
|
|
887
|
+
SET approved_count = ?, rejected_count = ?, status = ?, completed_at = ?
|
|
888
|
+
WHERE request_id = ?
|
|
889
|
+
`).run(approvedCount, rejectedCount, newStatus, newStatus !== 'pending' ? new Date().toISOString() : null, request_id);
|
|
890
|
+
}
|
|
891
|
+
function getApprovalDecisions(request_id) {
|
|
892
|
+
return db.prepare(`
|
|
893
|
+
SELECT * FROM approval_decisions WHERE request_id = ? ORDER BY decided_at ASC
|
|
894
|
+
`).all(request_id);
|
|
895
|
+
}
|
|
896
|
+
// Phase 3: Escalation rule functions
|
|
897
|
+
function addEscalationRule(rule_id, name, escalation_targets, created_by, description, timeout_hours) {
|
|
898
|
+
db.prepare(`
|
|
899
|
+
INSERT OR REPLACE INTO escalation_rules (rule_id, name, description, timeout_hours, escalation_targets, created_by, status)
|
|
900
|
+
VALUES (?, ?, ?, ?, ?, ?, 'active')
|
|
901
|
+
`).run(rule_id, name, description ?? null, timeout_hours ?? 24, JSON.stringify(escalation_targets), created_by);
|
|
902
|
+
}
|
|
903
|
+
function getEscalationRule(rule_id) {
|
|
904
|
+
return db.prepare(`
|
|
905
|
+
SELECT * FROM escalation_rules WHERE rule_id = ?
|
|
906
|
+
`).get(rule_id);
|
|
907
|
+
}
|
|
908
|
+
function getAllEscalationRules() {
|
|
909
|
+
return db.prepare(`
|
|
910
|
+
SELECT * FROM escalation_rules WHERE status = 'active' ORDER BY name ASC
|
|
911
|
+
`).all();
|
|
912
|
+
}
|
|
913
|
+
function updateEscalationRule(rule_id, updates) {
|
|
914
|
+
const current = getEscalationRule(rule_id);
|
|
915
|
+
if (!current)
|
|
916
|
+
throw new Error(`Rule ${rule_id} not found`);
|
|
917
|
+
db.prepare(`
|
|
918
|
+
UPDATE escalation_rules
|
|
919
|
+
SET name = ?, description = ?, timeout_hours = ?, escalation_targets = ?
|
|
920
|
+
WHERE rule_id = ?
|
|
921
|
+
`).run(updates.name ?? current.name, updates.description ?? current.description, updates.timeout_hours ?? current.timeout_hours, updates.escalation_targets ? JSON.stringify(updates.escalation_targets) : current.escalation_targets, rule_id);
|
|
922
|
+
}
|
|
923
|
+
function deleteEscalationRule(rule_id) {
|
|
924
|
+
db.prepare(`
|
|
925
|
+
UPDATE escalation_rules SET status = 'inactive' WHERE rule_id = ?
|
|
926
|
+
`).run(rule_id);
|
|
927
|
+
}
|
|
928
|
+
// Phase 3: Escalation request functions
|
|
929
|
+
function createEscalationRequest(request_id, approval_request_id, session_id, escalation_targets, deadline) {
|
|
930
|
+
db.prepare(`
|
|
931
|
+
INSERT INTO escalation_requests (request_id, approval_request_id, session_id, escalation_triggered_at, escalation_deadline, escalation_targets, status)
|
|
932
|
+
VALUES (?, ?, ?, datetime('now'), ?, ?, 'pending')
|
|
933
|
+
`).run(request_id, approval_request_id, session_id, deadline, JSON.stringify(escalation_targets));
|
|
934
|
+
}
|
|
935
|
+
function getEscalationRequest(request_id) {
|
|
936
|
+
return db.prepare(`
|
|
937
|
+
SELECT * FROM escalation_requests WHERE request_id = ?
|
|
938
|
+
`).get(request_id);
|
|
939
|
+
}
|
|
940
|
+
function getPendingEscalations() {
|
|
941
|
+
return db.prepare(`
|
|
942
|
+
SELECT * FROM escalation_requests WHERE status = 'pending' ORDER BY escalation_deadline ASC
|
|
943
|
+
`).all();
|
|
944
|
+
}
|
|
945
|
+
function getStaleApprovals(now) {
|
|
946
|
+
return db.prepare(`
|
|
947
|
+
SELECT DISTINCT ar.request_id as approval_request_id, ar.session_id
|
|
948
|
+
FROM approval_requests ar
|
|
949
|
+
WHERE ar.status = 'pending'
|
|
950
|
+
AND ar.triggered_at < ?
|
|
951
|
+
AND NOT EXISTS (
|
|
952
|
+
SELECT 1 FROM escalation_requests er
|
|
953
|
+
WHERE er.approval_request_id = ar.request_id
|
|
954
|
+
)
|
|
955
|
+
`).all(now);
|
|
956
|
+
}
|
|
957
|
+
function submitEscalationDecision(decision_id, escalation_request_id, target_id, decision, reason) {
|
|
958
|
+
db.prepare(`
|
|
959
|
+
INSERT INTO escalation_decisions (decision_id, escalation_request_id, target_id, decision, reason)
|
|
960
|
+
VALUES (?, ?, ?, ?, ?)
|
|
961
|
+
`).run(decision_id, escalation_request_id, target_id, decision, reason ?? null);
|
|
962
|
+
// Update escalation request status if all targets have decided
|
|
963
|
+
const request = getEscalationRequest(escalation_request_id);
|
|
964
|
+
if (!request)
|
|
965
|
+
return;
|
|
966
|
+
const decisions = db.prepare(`
|
|
967
|
+
SELECT DISTINCT decision FROM escalation_decisions WHERE escalation_request_id = ?
|
|
968
|
+
`).all(escalation_request_id);
|
|
969
|
+
const hasBlockDecision = decisions.some(d => d.decision === 'block');
|
|
970
|
+
const newStatus = hasBlockDecision ? 'blocked' : 'proceeded';
|
|
971
|
+
db.prepare(`
|
|
972
|
+
UPDATE escalation_requests
|
|
973
|
+
SET status = ?, decided_at = datetime('now'), decision = ?
|
|
974
|
+
WHERE request_id = ?
|
|
975
|
+
`).run(newStatus, newStatus, escalation_request_id);
|
|
976
|
+
}
|
|
977
|
+
function getEscalationDecisions(escalation_request_id) {
|
|
978
|
+
return db.prepare(`
|
|
979
|
+
SELECT * FROM escalation_decisions WHERE escalation_request_id = ? ORDER BY decided_at ASC
|
|
980
|
+
`).all(escalation_request_id);
|
|
981
|
+
}
|
|
982
|
+
function getEscalationHistory(session_id) {
|
|
983
|
+
return db.prepare(`
|
|
984
|
+
SELECT * FROM escalation_requests WHERE session_id = ? ORDER BY escalation_triggered_at DESC
|
|
985
|
+
`).all(session_id);
|
|
986
|
+
}
|
|
444
987
|
exports.default = db;
|