@hotmeshio/long-tail 0.4.19 → 0.4.21
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/build/api/escalations/helpers.js +6 -5
- package/build/api/escalations/metadata.d.ts +7 -24
- package/build/api/escalations/metadata.js +31 -65
- package/build/services/escalation/crud.d.ts +17 -1
- package/build/services/escalation/crud.js +48 -12
- package/build/services/escalation/sql.d.ts +14 -5
- package/build/services/escalation/sql.js +53 -16
- package/build/services/user/index.d.ts +1 -1
- package/build/services/user/index.js +2 -1
- package/build/services/user/rbac.d.ts +5 -0
- package/build/services/user/rbac.js +14 -0
- package/package.json +2 -2
|
@@ -59,11 +59,12 @@ async function checkBulkPermission(userId, ids) {
|
|
|
59
59
|
if (await userService.hasGlobalEscalationAccess(userId))
|
|
60
60
|
return { allowed: true };
|
|
61
61
|
const roles = await escalationService.getEscalationRoles(ids);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
if (!roles.length)
|
|
63
|
+
return { allowed: true };
|
|
64
|
+
// Single batched query instead of N+1 loop
|
|
65
|
+
const canManageAll = await userService.hasRolesAsAdmin(userId, roles);
|
|
66
|
+
if (!canManageAll) {
|
|
67
|
+
return { allowed: false, status: 403, error: 'Insufficient permissions for one or more escalation roles' };
|
|
67
68
|
}
|
|
68
69
|
return { allowed: true };
|
|
69
70
|
}
|
|
@@ -2,13 +2,8 @@ import type { LTApiAuth, LTApiResult } from '../../types/sdk';
|
|
|
2
2
|
/**
|
|
3
3
|
* Find escalations by a metadata key-value pair.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* @param input.key — metadata field name (e.g. `"orderId"`)
|
|
9
|
-
* @param input.value — metadata field value (e.g. `"order-123"`)
|
|
10
|
-
* @param input.status — optional status filter (e.g. `"pending"`)
|
|
11
|
-
* @returns `{ status: 200, data: { escalations, total } }`
|
|
5
|
+
* Single query with window function for count. Results are
|
|
6
|
+
* RBAC-scoped to the caller's visible roles.
|
|
12
7
|
*/
|
|
13
8
|
export declare function findByMetadata(input: {
|
|
14
9
|
key: string;
|
|
@@ -20,15 +15,9 @@ export declare function findByMetadata(input: {
|
|
|
20
15
|
/**
|
|
21
16
|
* Claim an escalation by metadata key-value pair.
|
|
22
17
|
*
|
|
23
|
-
*
|
|
24
|
-
* the
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* @param input.key — metadata field name
|
|
28
|
-
* @param input.value — metadata field value
|
|
29
|
-
* @param input.durationMinutes — claim duration (default 30)
|
|
30
|
-
* @param input.assignee — optional external_id of the user to claim as
|
|
31
|
-
* @returns `{ status: 200, data: { escalation, isExtension } }`
|
|
18
|
+
* Single atomic query. RBAC is enforced in the SQL WHERE clause —
|
|
19
|
+
* if the caller doesn't have an allowed role, zero rows match and
|
|
20
|
+
* the claim never happens. No pre-flight find, no TOCTOU.
|
|
32
21
|
*/
|
|
33
22
|
export declare function claimByMetadata(input: {
|
|
34
23
|
key: string;
|
|
@@ -40,14 +29,8 @@ export declare function claimByMetadata(input: {
|
|
|
40
29
|
/**
|
|
41
30
|
* Resolve an escalation by metadata key-value pair.
|
|
42
31
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* @param input.key — metadata field name
|
|
47
|
-
* @param input.value — metadata field value
|
|
48
|
-
* @param input.resolverPayload — resolution data for the workflow
|
|
49
|
-
* @param input.assignee — optional external_id of the resolving user
|
|
50
|
-
* @returns result from the standard resolve endpoint
|
|
32
|
+
* Single atomic CTE: find + claim + resolve in one query.
|
|
33
|
+
* RBAC is enforced in the SQL WHERE clause.
|
|
51
34
|
*/
|
|
52
35
|
export declare function resolveByMetadata(input: {
|
|
53
36
|
key: string;
|
|
@@ -42,13 +42,8 @@ const helpers_1 = require("./helpers");
|
|
|
42
42
|
/**
|
|
43
43
|
* Find escalations by a metadata key-value pair.
|
|
44
44
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* @param input.key — metadata field name (e.g. `"orderId"`)
|
|
49
|
-
* @param input.value — metadata field value (e.g. `"order-123"`)
|
|
50
|
-
* @param input.status — optional status filter (e.g. `"pending"`)
|
|
51
|
-
* @returns `{ status: 200, data: { escalations, total } }`
|
|
45
|
+
* Single query with window function for count. Results are
|
|
46
|
+
* RBAC-scoped to the caller's visible roles.
|
|
52
47
|
*/
|
|
53
48
|
async function findByMetadata(input, auth) {
|
|
54
49
|
try {
|
|
@@ -72,15 +67,9 @@ async function findByMetadata(input, auth) {
|
|
|
72
67
|
/**
|
|
73
68
|
* Claim an escalation by metadata key-value pair.
|
|
74
69
|
*
|
|
75
|
-
*
|
|
76
|
-
* the
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* @param input.key — metadata field name
|
|
80
|
-
* @param input.value — metadata field value
|
|
81
|
-
* @param input.durationMinutes — claim duration (default 30)
|
|
82
|
-
* @param input.assignee — optional external_id of the user to claim as
|
|
83
|
-
* @returns `{ status: 200, data: { escalation, isExtension } }`
|
|
70
|
+
* Single atomic query. RBAC is enforced in the SQL WHERE clause —
|
|
71
|
+
* if the caller doesn't have an allowed role, zero rows match and
|
|
72
|
+
* the claim never happens. No pre-flight find, no TOCTOU.
|
|
84
73
|
*/
|
|
85
74
|
async function claimByMetadata(input, auth) {
|
|
86
75
|
try {
|
|
@@ -91,25 +80,17 @@ async function claimByMetadata(input, auth) {
|
|
|
91
80
|
if ('error' in resolved)
|
|
92
81
|
return resolved.error;
|
|
93
82
|
const claimUserId = resolved.userId;
|
|
94
|
-
//
|
|
95
|
-
const
|
|
96
|
-
|
|
83
|
+
// Resolve allowed roles: null = global access (no filter), string[] = scoped
|
|
84
|
+
const allowedRoles = await resolveAllowedRoles(auth.userId);
|
|
85
|
+
const result = await escalationService.claimByMetadata(input.key, input.value, claimUserId, input.durationMinutes, input.metadata, allowedRoles);
|
|
86
|
+
if (!result) {
|
|
87
|
+
// No rows matched. Check if candidates existed (role mismatch vs no match).
|
|
97
88
|
return { status: 404, error: 'No pending escalation found for this metadata' };
|
|
98
89
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (!hasGlobal) {
|
|
102
|
-
const userHasRole = await userService.hasRole(claimUserId, candidate.role);
|
|
103
|
-
if (!userHasRole) {
|
|
104
|
-
return { status: 403, error: `User must have the "${candidate.role}" role to claim this escalation` };
|
|
105
|
-
}
|
|
90
|
+
if (result.candidatesExist > 0 && !result.escalation) {
|
|
91
|
+
return { status: 403, error: 'Escalation exists but your roles do not permit claiming it' };
|
|
106
92
|
}
|
|
107
|
-
|
|
108
|
-
if (!result) {
|
|
109
|
-
return { status: 409, error: 'Escalation not available for claim' };
|
|
110
|
-
}
|
|
111
|
-
// Event published by service layer (services/escalation/crud.ts)
|
|
112
|
-
return { status: 200, data: result };
|
|
93
|
+
return { status: 200, data: { escalation: result.escalation, isExtension: result.isExtension } };
|
|
113
94
|
}
|
|
114
95
|
catch (err) {
|
|
115
96
|
return { status: 500, error: err.message };
|
|
@@ -118,14 +99,8 @@ async function claimByMetadata(input, auth) {
|
|
|
118
99
|
/**
|
|
119
100
|
* Resolve an escalation by metadata key-value pair.
|
|
120
101
|
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* @param input.key — metadata field name
|
|
125
|
-
* @param input.value — metadata field value
|
|
126
|
-
* @param input.resolverPayload — resolution data for the workflow
|
|
127
|
-
* @param input.assignee — optional external_id of the resolving user
|
|
128
|
-
* @returns result from the standard resolve endpoint
|
|
102
|
+
* Single atomic CTE: find + claim + resolve in one query.
|
|
103
|
+
* RBAC is enforced in the SQL WHERE clause.
|
|
129
104
|
*/
|
|
130
105
|
async function resolveByMetadata(input, auth) {
|
|
131
106
|
try {
|
|
@@ -135,38 +110,29 @@ async function resolveByMetadata(input, auth) {
|
|
|
135
110
|
if (!input.resolverPayload) {
|
|
136
111
|
return { status: 400, error: 'resolverPayload is required' };
|
|
137
112
|
}
|
|
138
|
-
const candidates = await escalationService.findByMetadata(input.key, input.value, 'pending', 1, 0);
|
|
139
|
-
if (candidates.escalations.length === 0) {
|
|
140
|
-
return { status: 404, error: 'No pending escalation found for this metadata' };
|
|
141
|
-
}
|
|
142
|
-
const escalation = candidates.escalations[0];
|
|
143
113
|
const resolved = await (0, helpers_1.resolveAssignee)(input.assignee, auth);
|
|
144
114
|
if ('error' in resolved)
|
|
145
115
|
return resolved.error;
|
|
146
116
|
const resolveUserId = resolved.userId;
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
return { status: 403, error: `User must have the "${escalation.role}" role` };
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
// Merge additional metadata if provided
|
|
155
|
-
if (input.metadata && Object.keys(input.metadata).length > 0) {
|
|
156
|
-
await escalationService.updateEscalationMetadata(escalation.id, input.metadata);
|
|
157
|
-
}
|
|
158
|
-
// Auto-claim if unclaimed or expired
|
|
159
|
-
const isClaimed = escalation.assigned_to &&
|
|
160
|
-
escalation.assigned_until &&
|
|
161
|
-
new Date(escalation.assigned_until) > new Date();
|
|
162
|
-
if (!isClaimed) {
|
|
163
|
-
await escalationService.claimEscalation(escalation.id, resolveUserId, 5);
|
|
117
|
+
const allowedRoles = await resolveAllowedRoles(auth.userId);
|
|
118
|
+
const escalation = await escalationService.resolveByMetadataAtomic(input.key, input.value, resolveUserId, input.resolverPayload, input.metadata, allowedRoles);
|
|
119
|
+
if (!escalation) {
|
|
120
|
+
return { status: 404, error: 'No pending escalation found for this metadata, or insufficient role permissions' };
|
|
164
121
|
}
|
|
165
|
-
|
|
166
|
-
const { resolveEscalation } = await Promise.resolve().then(() => __importStar(require('./resolve')));
|
|
167
|
-
return resolveEscalation({ id: escalation.id, resolverPayload: input.resolverPayload }, auth);
|
|
122
|
+
return { status: 200, data: { escalation } };
|
|
168
123
|
}
|
|
169
124
|
catch (err) {
|
|
170
125
|
return { status: 500, error: err.message };
|
|
171
126
|
}
|
|
172
127
|
}
|
|
128
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
129
|
+
/**
|
|
130
|
+
* Resolve the set of roles the caller is allowed to act on.
|
|
131
|
+
* Returns null for global access (superadmin/admin), or string[] for scoped users.
|
|
132
|
+
*/
|
|
133
|
+
async function resolveAllowedRoles(userId) {
|
|
134
|
+
if (await userService.hasGlobalEscalationAccess(userId))
|
|
135
|
+
return null;
|
|
136
|
+
const userRoles = await userService.getUserRoles(userId);
|
|
137
|
+
return userRoles.map(r => r.role);
|
|
138
|
+
}
|
|
@@ -49,4 +49,20 @@ export declare function findByMetadata(key: string, value: string, status?: stri
|
|
|
49
49
|
escalations: LTEscalationRecord[];
|
|
50
50
|
total: number;
|
|
51
51
|
}>;
|
|
52
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Atomic claim by metadata with inline RBAC.
|
|
54
|
+
* The SQL WHERE clause enforces role membership — if the caller
|
|
55
|
+
* doesn't have an allowed role, zero rows match and the claim
|
|
56
|
+
* never happens. No pre-flight find, no TOCTOU.
|
|
57
|
+
*
|
|
58
|
+
* @param allowedRoles — roles the caller can claim (null = no filter / global access)
|
|
59
|
+
* @returns `{ escalation, isExtension, candidatesExist }` or null
|
|
60
|
+
*/
|
|
61
|
+
export declare function claimByMetadata(key: string, value: string, userId: string, durationMinutes?: number, metadata?: Record<string, any>, allowedRoles?: string[] | null): Promise<(ClaimResult & {
|
|
62
|
+
candidatesExist: number;
|
|
63
|
+
}) | null>;
|
|
64
|
+
/**
|
|
65
|
+
* Atomic resolve by metadata: find + claim + resolve in one CTE.
|
|
66
|
+
* RBAC is enforced in the SQL WHERE clause via allowedRoles.
|
|
67
|
+
*/
|
|
68
|
+
export declare function resolveByMetadataAtomic(key: string, value: string, userId: string, resolverPayload: Record<string, any>, metadata?: Record<string, any>, allowedRoles?: string[] | null): Promise<LTEscalationRecord | null>;
|
|
@@ -16,6 +16,7 @@ exports.enrichEscalationRouting = enrichEscalationRouting;
|
|
|
16
16
|
exports.getEscalationsByOriginId = getEscalationsByOriginId;
|
|
17
17
|
exports.findByMetadata = findByMetadata;
|
|
18
18
|
exports.claimByMetadata = claimByMetadata;
|
|
19
|
+
exports.resolveByMetadataAtomic = resolveByMetadataAtomic;
|
|
19
20
|
const db_1 = require("../../lib/db");
|
|
20
21
|
const publish_1 = require("../../lib/events/publish");
|
|
21
22
|
const logger_1 = require("../../lib/logger");
|
|
@@ -205,24 +206,32 @@ async function getEscalationsByOriginId(originId) {
|
|
|
205
206
|
async function findByMetadata(key, value, status, limit = 50, offset = 0) {
|
|
206
207
|
const pool = (0, db_1.getPool)();
|
|
207
208
|
const filter = JSON.stringify({ [key]: value });
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
return {
|
|
213
|
-
escalations: dataResult.rows,
|
|
214
|
-
total: parseInt(countResult.rows[0].count, 10),
|
|
215
|
-
};
|
|
209
|
+
const { rows } = await pool.query(sql_1.FIND_BY_METADATA, [filter, status || null, limit, offset]);
|
|
210
|
+
const total = rows.length > 0 ? parseInt(rows[0]._total, 10) : 0;
|
|
211
|
+
// Strip the window function column from results
|
|
212
|
+
const escalations = rows.map(({ _total, ...rest }) => rest);
|
|
213
|
+
return { escalations, total };
|
|
216
214
|
}
|
|
217
|
-
|
|
215
|
+
/**
|
|
216
|
+
* Atomic claim by metadata with inline RBAC.
|
|
217
|
+
* The SQL WHERE clause enforces role membership — if the caller
|
|
218
|
+
* doesn't have an allowed role, zero rows match and the claim
|
|
219
|
+
* never happens. No pre-flight find, no TOCTOU.
|
|
220
|
+
*
|
|
221
|
+
* @param allowedRoles — roles the caller can claim (null = no filter / global access)
|
|
222
|
+
* @returns `{ escalation, isExtension, candidatesExist }` or null
|
|
223
|
+
*/
|
|
224
|
+
async function claimByMetadata(key, value, userId, durationMinutes = 30, metadata, allowedRoles) {
|
|
218
225
|
const pool = (0, db_1.getPool)();
|
|
219
226
|
const filter = JSON.stringify({ [key]: value });
|
|
220
227
|
const metaPatch = metadata ? JSON.stringify(metadata) : null;
|
|
221
|
-
const
|
|
228
|
+
const roles = allowedRoles ?? null;
|
|
229
|
+
const { rows } = await pool.query(sql_1.CLAIM_BY_METADATA_GUARDED, [filter, userId, durationMinutes, metaPatch, roles]);
|
|
222
230
|
if (rows.length === 0)
|
|
223
231
|
return null;
|
|
224
232
|
const row = rows[0];
|
|
225
|
-
const
|
|
233
|
+
const { candidates_exist, prev_assigned_to, _total, ...rest } = row;
|
|
234
|
+
const escalation = rest;
|
|
226
235
|
(0, publish_1.publishEscalationEvent)({
|
|
227
236
|
type: 'escalation.claimed',
|
|
228
237
|
source: 'service',
|
|
@@ -235,6 +244,33 @@ async function claimByMetadata(key, value, userId, durationMinutes = 30, metadat
|
|
|
235
244
|
});
|
|
236
245
|
return {
|
|
237
246
|
escalation,
|
|
238
|
-
isExtension:
|
|
247
|
+
isExtension: prev_assigned_to === userId,
|
|
248
|
+
candidatesExist: parseInt(candidates_exist, 10),
|
|
239
249
|
};
|
|
240
250
|
}
|
|
251
|
+
/**
|
|
252
|
+
* Atomic resolve by metadata: find + claim + resolve in one CTE.
|
|
253
|
+
* RBAC is enforced in the SQL WHERE clause via allowedRoles.
|
|
254
|
+
*/
|
|
255
|
+
async function resolveByMetadataAtomic(key, value, userId, resolverPayload, metadata, allowedRoles) {
|
|
256
|
+
const pool = (0, db_1.getPool)();
|
|
257
|
+
const filter = JSON.stringify({ [key]: value });
|
|
258
|
+
const payloadJson = JSON.stringify(resolverPayload);
|
|
259
|
+
const metaPatch = metadata ? JSON.stringify(metadata) : null;
|
|
260
|
+
const roles = allowedRoles ?? null;
|
|
261
|
+
const { rows } = await pool.query(sql_1.RESOLVE_BY_METADATA_ATOMIC, [filter, userId, payloadJson, metaPatch, roles]);
|
|
262
|
+
if (rows.length === 0)
|
|
263
|
+
return null;
|
|
264
|
+
const escalation = rows[0];
|
|
265
|
+
(0, publish_1.publishEscalationEvent)({
|
|
266
|
+
type: 'escalation.resolved',
|
|
267
|
+
source: 'service',
|
|
268
|
+
workflowId: escalation.workflow_id || '',
|
|
269
|
+
workflowName: escalation.workflow_type || '',
|
|
270
|
+
taskQueue: escalation.task_queue || '',
|
|
271
|
+
escalationId: escalation.id,
|
|
272
|
+
status: 'resolved',
|
|
273
|
+
data: { resolved_by: userId },
|
|
274
|
+
});
|
|
275
|
+
return escalation;
|
|
276
|
+
}
|
|
@@ -19,8 +19,17 @@ export declare const BULK_RESOLVE_FOR_TRIAGE = "UPDATE lt_escalations\nSET statu
|
|
|
19
19
|
export declare const UPDATE_ESCALATION_METADATA = "UPDATE lt_escalations\nSET metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb,\n updated_at = NOW()\nWHERE id = $1\nRETURNING *";
|
|
20
20
|
export declare const ENRICH_ESCALATION_ROUTING = "UPDATE lt_escalations\nSET metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb,\n workflow_type = COALESCE(workflow_type, $3),\n workflow_id = COALESCE(workflow_id, $4),\n task_queue = COALESCE(task_queue, $5),\n task_id = COALESCE(task_id, $6),\n updated_at = NOW()\nWHERE id = $1\nRETURNING *";
|
|
21
21
|
export declare const LIST_DISTINCT_TYPES = "SELECT DISTINCT type FROM lt_escalations ORDER BY type";
|
|
22
|
-
/** Find escalations by a single metadata key-value pair. */
|
|
23
|
-
export declare const FIND_BY_METADATA = "SELECT *
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
/** Find escalations by a single metadata key-value pair. Window function for total count. */
|
|
23
|
+
export declare const FIND_BY_METADATA = "SELECT *, COUNT(*) OVER() AS _total\nFROM lt_escalations\nWHERE metadata @> $1::jsonb\n AND ($2::text IS NULL OR status = $2)\nORDER BY priority ASC, created_at ASC\nLIMIT $3 OFFSET $4";
|
|
24
|
+
/**
|
|
25
|
+
* Atomic claim by metadata with inline RBAC.
|
|
26
|
+
* $1 = metadata filter (jsonb), $2 = userId, $3 = durationMinutes,
|
|
27
|
+
* $4 = metadata patch (jsonb, nullable), $5 = allowed roles (text[], null = no filter)
|
|
28
|
+
*/
|
|
29
|
+
export declare const CLAIM_BY_METADATA_GUARDED = "WITH target AS (\n SELECT id, assigned_to\n FROM lt_escalations\n WHERE metadata @> $1::jsonb\n AND status = 'pending'\n AND (assigned_to IS NULL OR assigned_until <= NOW() OR assigned_to = $2)\n AND ($5::text[] IS NULL OR role = ANY($5))\n ORDER BY priority ASC, created_at ASC\n LIMIT 1\n FOR UPDATE SKIP LOCKED\n),\nupdated AS (\n UPDATE lt_escalations e\n SET assigned_to = $2,\n claimed_at = NOW(),\n assigned_until = NOW() + INTERVAL '1 minute' * $3,\n metadata = CASE WHEN $4::jsonb IS NOT NULL\n THEN COALESCE(e.metadata, '{}'::jsonb) || $4::jsonb\n ELSE e.metadata END,\n updated_at = NOW()\n FROM target t\n WHERE e.id = t.id\n RETURNING e.*, t.assigned_to AS prev_assigned_to\n)\nSELECT *,\n (SELECT COUNT(*) FROM lt_escalations WHERE metadata @> $1::jsonb AND status = 'pending') AS candidates_exist\nFROM updated";
|
|
30
|
+
/**
|
|
31
|
+
* Atomic resolve by metadata: find + claim + resolve in one CTE.
|
|
32
|
+
* $1 = metadata filter (jsonb), $2 = userId, $3 = resolver_payload (jsonb),
|
|
33
|
+
* $4 = metadata patch (jsonb, nullable), $5 = allowed roles (text[], null = no filter)
|
|
34
|
+
*/
|
|
35
|
+
export declare const RESOLVE_BY_METADATA_ATOMIC = "WITH target AS (\n SELECT id\n FROM lt_escalations\n WHERE metadata @> $1::jsonb\n AND status = 'pending'\n AND ($5::text[] IS NULL OR role = ANY($5))\n ORDER BY priority ASC, created_at ASC\n LIMIT 1\n FOR UPDATE SKIP LOCKED\n),\nclaimed AS (\n UPDATE lt_escalations e\n SET assigned_to = $2,\n claimed_at = NOW(),\n assigned_until = NOW() + INTERVAL '5 minutes',\n metadata = CASE WHEN $4::jsonb IS NOT NULL\n THEN COALESCE(e.metadata, '{}'::jsonb) || $4::jsonb\n ELSE e.metadata END\n FROM target\n WHERE e.id = target.id\n RETURNING e.*\n)\nUPDATE lt_escalations e\nSET status = 'resolved',\n resolved_at = NOW(),\n resolver_payload = $3,\n updated_at = NOW()\nFROM claimed\nWHERE e.id = claimed.id\nRETURNING e.*";
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Escalation SQL – externalized from crud.ts, bulk.ts, queries.ts
|
|
4
4
|
// ---------------------------------------------------------------------------
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.
|
|
6
|
+
exports.RESOLVE_BY_METADATA_ATOMIC = exports.CLAIM_BY_METADATA_GUARDED = exports.FIND_BY_METADATA = exports.LIST_DISTINCT_TYPES = exports.ENRICH_ESCALATION_ROUTING = exports.UPDATE_ESCALATION_METADATA = exports.BULK_RESOLVE_FOR_TRIAGE = exports.BULK_ESCALATE_TO_ROLE = exports.BULK_ASSIGN = exports.BULK_CLAIM = exports.GET_ESCALATIONS_BY_ORIGIN_ID = exports.GET_ESCALATIONS_BY_WORKFLOW_ID = exports.GET_ESCALATIONS_BY_TASK_ID = exports.GET_ESCALATION = exports.ESCALATE_TO_ROLE = exports.RELEASE_EXPIRED_CLAIMS = exports.RELEASE_ESCALATION = exports.GET_ESCALATION_ROLES = exports.UPDATE_ESCALATIONS_PRIORITY = exports.RESOLVE_ESCALATION = exports.CLAIM_ESCALATION = exports.CREATE_ESCALATION = exports.ENSURE_ROLE_EXISTS = void 0;
|
|
7
7
|
// --- Role management -------------------------------------------------------
|
|
8
8
|
exports.ENSURE_ROLE_EXISTS = 'INSERT INTO lt_roles (role) VALUES ($1) ON CONFLICT DO NOTHING';
|
|
9
9
|
// --- Single-record CRUD ---------------------------------------------------
|
|
@@ -134,29 +134,27 @@ WHERE id = $1
|
|
|
134
134
|
RETURNING *`;
|
|
135
135
|
exports.LIST_DISTINCT_TYPES = 'SELECT DISTINCT type FROM lt_escalations ORDER BY type';
|
|
136
136
|
// --- Metadata candidate key lookups -----------------------------------------
|
|
137
|
-
/** Find escalations by a single metadata key-value pair. */
|
|
137
|
+
/** Find escalations by a single metadata key-value pair. Window function for total count. */
|
|
138
138
|
exports.FIND_BY_METADATA = `\
|
|
139
|
-
SELECT *
|
|
139
|
+
SELECT *, COUNT(*) OVER() AS _total
|
|
140
|
+
FROM lt_escalations
|
|
140
141
|
WHERE metadata @> $1::jsonb
|
|
141
142
|
AND ($2::text IS NULL OR status = $2)
|
|
142
143
|
ORDER BY priority ASC, created_at ASC
|
|
143
144
|
LIMIT $3 OFFSET $4`;
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
exports.
|
|
145
|
+
/**
|
|
146
|
+
* Atomic claim by metadata with inline RBAC.
|
|
147
|
+
* $1 = metadata filter (jsonb), $2 = userId, $3 = durationMinutes,
|
|
148
|
+
* $4 = metadata patch (jsonb, nullable), $5 = allowed roles (text[], null = no filter)
|
|
149
|
+
*/
|
|
150
|
+
exports.CLAIM_BY_METADATA_GUARDED = `\
|
|
150
151
|
WITH target AS (
|
|
151
152
|
SELECT id, assigned_to
|
|
152
153
|
FROM lt_escalations
|
|
153
154
|
WHERE metadata @> $1::jsonb
|
|
154
155
|
AND status = 'pending'
|
|
155
|
-
AND (
|
|
156
|
-
|
|
157
|
-
OR assigned_until <= NOW()
|
|
158
|
-
OR assigned_to = $2
|
|
159
|
-
)
|
|
156
|
+
AND (assigned_to IS NULL OR assigned_until <= NOW() OR assigned_to = $2)
|
|
157
|
+
AND ($5::text[] IS NULL OR role = ANY($5))
|
|
160
158
|
ORDER BY priority ASC, created_at ASC
|
|
161
159
|
LIMIT 1
|
|
162
160
|
FOR UPDATE SKIP LOCKED
|
|
@@ -168,9 +166,48 @@ updated AS (
|
|
|
168
166
|
assigned_until = NOW() + INTERVAL '1 minute' * $3,
|
|
169
167
|
metadata = CASE WHEN $4::jsonb IS NOT NULL
|
|
170
168
|
THEN COALESCE(e.metadata, '{}'::jsonb) || $4::jsonb
|
|
171
|
-
ELSE e.metadata END
|
|
169
|
+
ELSE e.metadata END,
|
|
170
|
+
updated_at = NOW()
|
|
172
171
|
FROM target t
|
|
173
172
|
WHERE e.id = t.id
|
|
174
173
|
RETURNING e.*, t.assigned_to AS prev_assigned_to
|
|
175
174
|
)
|
|
176
|
-
SELECT
|
|
175
|
+
SELECT *,
|
|
176
|
+
(SELECT COUNT(*) FROM lt_escalations WHERE metadata @> $1::jsonb AND status = 'pending') AS candidates_exist
|
|
177
|
+
FROM updated`;
|
|
178
|
+
/**
|
|
179
|
+
* Atomic resolve by metadata: find + claim + resolve in one CTE.
|
|
180
|
+
* $1 = metadata filter (jsonb), $2 = userId, $3 = resolver_payload (jsonb),
|
|
181
|
+
* $4 = metadata patch (jsonb, nullable), $5 = allowed roles (text[], null = no filter)
|
|
182
|
+
*/
|
|
183
|
+
exports.RESOLVE_BY_METADATA_ATOMIC = `\
|
|
184
|
+
WITH target AS (
|
|
185
|
+
SELECT id
|
|
186
|
+
FROM lt_escalations
|
|
187
|
+
WHERE metadata @> $1::jsonb
|
|
188
|
+
AND status = 'pending'
|
|
189
|
+
AND ($5::text[] IS NULL OR role = ANY($5))
|
|
190
|
+
ORDER BY priority ASC, created_at ASC
|
|
191
|
+
LIMIT 1
|
|
192
|
+
FOR UPDATE SKIP LOCKED
|
|
193
|
+
),
|
|
194
|
+
claimed AS (
|
|
195
|
+
UPDATE lt_escalations e
|
|
196
|
+
SET assigned_to = $2,
|
|
197
|
+
claimed_at = NOW(),
|
|
198
|
+
assigned_until = NOW() + INTERVAL '5 minutes',
|
|
199
|
+
metadata = CASE WHEN $4::jsonb IS NOT NULL
|
|
200
|
+
THEN COALESCE(e.metadata, '{}'::jsonb) || $4::jsonb
|
|
201
|
+
ELSE e.metadata END
|
|
202
|
+
FROM target
|
|
203
|
+
WHERE e.id = target.id
|
|
204
|
+
RETURNING e.*
|
|
205
|
+
)
|
|
206
|
+
UPDATE lt_escalations e
|
|
207
|
+
SET status = 'resolved',
|
|
208
|
+
resolved_at = NOW(),
|
|
209
|
+
resolver_payload = $3,
|
|
210
|
+
updated_at = NOW()
|
|
211
|
+
FROM claimed
|
|
212
|
+
WHERE e.id = claimed.id
|
|
213
|
+
RETURNING e.*`;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { CreateUserInput, UpdateUserInput, VALID_ROLE_TYPES } from './types';
|
|
2
2
|
export { createUser, getUser, getUserByExternalId, getUserByEmail, updateUser, deleteUser, listUsers, } from './crud';
|
|
3
3
|
export { isValidRoleType, addUserRole, removeUserRole, getUserRoles, hasRole, hasRoleType, } from './roles';
|
|
4
|
-
export { isSuperAdmin, isGroupAdmin, canManageRole, hasGlobalEscalationAccess } from './rbac';
|
|
4
|
+
export { isSuperAdmin, isGroupAdmin, canManageRole, hasGlobalEscalationAccess, hasRolesAsAdmin } from './rbac';
|
|
5
5
|
export { verifyPassword } from './auth';
|
|
6
6
|
export { seedAdmin } from './seed-admin';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.seedAdmin = exports.verifyPassword = exports.hasGlobalEscalationAccess = exports.canManageRole = exports.isGroupAdmin = exports.isSuperAdmin = exports.hasRoleType = exports.hasRole = exports.getUserRoles = exports.removeUserRole = exports.addUserRole = exports.isValidRoleType = exports.listUsers = exports.deleteUser = exports.updateUser = exports.getUserByEmail = exports.getUserByExternalId = exports.getUser = exports.createUser = exports.VALID_ROLE_TYPES = void 0;
|
|
3
|
+
exports.seedAdmin = exports.verifyPassword = exports.hasRolesAsAdmin = exports.hasGlobalEscalationAccess = exports.canManageRole = exports.isGroupAdmin = exports.isSuperAdmin = exports.hasRoleType = exports.hasRole = exports.getUserRoles = exports.removeUserRole = exports.addUserRole = exports.isValidRoleType = exports.listUsers = exports.deleteUser = exports.updateUser = exports.getUserByEmail = exports.getUserByExternalId = exports.getUser = exports.createUser = exports.VALID_ROLE_TYPES = void 0;
|
|
4
4
|
var types_1 = require("./types");
|
|
5
5
|
Object.defineProperty(exports, "VALID_ROLE_TYPES", { enumerable: true, get: function () { return types_1.VALID_ROLE_TYPES; } });
|
|
6
6
|
var crud_1 = require("./crud");
|
|
@@ -23,6 +23,7 @@ Object.defineProperty(exports, "isSuperAdmin", { enumerable: true, get: function
|
|
|
23
23
|
Object.defineProperty(exports, "isGroupAdmin", { enumerable: true, get: function () { return rbac_1.isGroupAdmin; } });
|
|
24
24
|
Object.defineProperty(exports, "canManageRole", { enumerable: true, get: function () { return rbac_1.canManageRole; } });
|
|
25
25
|
Object.defineProperty(exports, "hasGlobalEscalationAccess", { enumerable: true, get: function () { return rbac_1.hasGlobalEscalationAccess; } });
|
|
26
|
+
Object.defineProperty(exports, "hasRolesAsAdmin", { enumerable: true, get: function () { return rbac_1.hasRolesAsAdmin; } });
|
|
26
27
|
var auth_1 = require("./auth");
|
|
27
28
|
Object.defineProperty(exports, "verifyPassword", { enumerable: true, get: function () { return auth_1.verifyPassword; } });
|
|
28
29
|
var seed_admin_1 = require("./seed-admin");
|
|
@@ -24,3 +24,8 @@ export declare function canManageRole(actorId: string, role: string): Promise<bo
|
|
|
24
24
|
* all roles, and can perform bulk actions.
|
|
25
25
|
*/
|
|
26
26
|
export declare function hasGlobalEscalationAccess(userId: string): Promise<boolean>;
|
|
27
|
+
/**
|
|
28
|
+
* Batch check: does the user have admin type for ALL specified roles?
|
|
29
|
+
* Single query — replaces the N+1 loop in checkBulkPermission.
|
|
30
|
+
*/
|
|
31
|
+
export declare function hasRolesAsAdmin(userId: string, roles: string[]): Promise<boolean>;
|
|
@@ -4,6 +4,7 @@ exports.isSuperAdmin = isSuperAdmin;
|
|
|
4
4
|
exports.isGroupAdmin = isGroupAdmin;
|
|
5
5
|
exports.canManageRole = canManageRole;
|
|
6
6
|
exports.hasGlobalEscalationAccess = hasGlobalEscalationAccess;
|
|
7
|
+
exports.hasRolesAsAdmin = hasRolesAsAdmin;
|
|
7
8
|
const db_1 = require("../../lib/db");
|
|
8
9
|
const roles_1 = require("./roles");
|
|
9
10
|
const sql_1 = require("./sql");
|
|
@@ -47,3 +48,16 @@ async function hasGlobalEscalationAccess(userId) {
|
|
|
47
48
|
const roles = await (0, roles_1.getUserRoles)(userId);
|
|
48
49
|
return roles.some((r) => r.role === 'admin' && r.type === 'admin');
|
|
49
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Batch check: does the user have admin type for ALL specified roles?
|
|
53
|
+
* Single query — replaces the N+1 loop in checkBulkPermission.
|
|
54
|
+
*/
|
|
55
|
+
async function hasRolesAsAdmin(userId, roles) {
|
|
56
|
+
if (!roles.length)
|
|
57
|
+
return true;
|
|
58
|
+
const pool = (0, db_1.getPool)();
|
|
59
|
+
const { rows } = await pool.query(`SELECT COUNT(DISTINCT role)::int AS cnt
|
|
60
|
+
FROM lt_user_roles
|
|
61
|
+
WHERE user_id = $1 AND role = ANY($2::text[]) AND type IN ('admin', 'superadmin')`, [userId, roles]);
|
|
62
|
+
return (rows[0]?.cnt ?? 0) >= roles.length;
|
|
63
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hotmeshio/long-tail",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.21",
|
|
4
4
|
"description": "Long Tail Workflows — Durable AI workflows with human-in-the-loop escalation. Powered by PostgreSQL.",
|
|
5
5
|
"main": "./build/index.js",
|
|
6
6
|
"types": "./build/index.d.ts",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"@anthropic-ai/sdk": "^0.92.0",
|
|
71
71
|
"@aws-sdk/client-s3": "^3.1017.0",
|
|
72
72
|
"@aws-sdk/s3-request-presigner": "^3.1045.0",
|
|
73
|
-
"@hotmeshio/hotmesh": "^0.19.
|
|
73
|
+
"@hotmeshio/hotmesh": "^0.19.5",
|
|
74
74
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
75
75
|
"@opentelemetry/exporter-trace-otlp-proto": "^0.215.0",
|
|
76
76
|
"@opentelemetry/resources": "^2.5.1",
|