@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.
@@ -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
- for (const role of roles) {
63
- const canManage = await userService.isGroupAdmin(userId, role);
64
- if (!canManage) {
65
- return { allowed: false, status: 403, error: `Insufficient permissions for role "${role}"` };
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
- * Uses JSONB containment (`@>`) backed by a GIN index.
6
- * Results are RBAC-scoped to the caller's visible roles.
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
- * Finds one available (pending + unassigned/expired) escalation matching
24
- * the metadata and claims it atomically. Optionally resolves an assignee
25
- * from an external_id.
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
- * Finds the pending escalation, auto-claims if unclaimed, then delegates
44
- * to the standard resolve logic (supports all 5 resolution paths).
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
- * Uses JSONB containment (`@>`) backed by a GIN index.
46
- * Results are RBAC-scoped to the caller's visible roles.
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
- * Finds one available (pending + unassigned/expired) escalation matching
76
- * the metadata and claims it atomically. Optionally resolves an assignee
77
- * from an external_id.
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
- // RBAC: find the candidate to check role membership before atomic claim
95
- const candidates = await escalationService.findByMetadata(input.key, input.value, 'pending', 1, 0);
96
- if (candidates.escalations.length === 0) {
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
- const candidate = candidates.escalations[0];
100
- const hasGlobal = await (0, helpers_1.hasGlobalEscalationAccess)(auth.userId);
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
- const result = await escalationService.claimByMetadata(input.key, input.value, claimUserId, input.durationMinutes, input.metadata);
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
- * Finds the pending escalation, auto-claims if unclaimed, then delegates
122
- * to the standard resolve logic (supports all 5 resolution paths).
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 hasGlobal = await (0, helpers_1.hasGlobalEscalationAccess)(auth.userId);
148
- if (!hasGlobal) {
149
- const userHasRole = await userService.hasRole(resolveUserId, escalation.role);
150
- if (!userHasRole) {
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
- // Delegate to the full resolve logic (handles all 5 resolution paths)
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
- export declare function claimByMetadata(key: string, value: string, userId: string, durationMinutes?: number, metadata?: Record<string, any>): Promise<ClaimResult | null>;
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 [countResult, dataResult] = await Promise.all([
209
- pool.query(sql_1.COUNT_BY_METADATA, [filter, status || null]),
210
- pool.query(sql_1.FIND_BY_METADATA, [filter, status || null, limit, offset]),
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
- async function claimByMetadata(key, value, userId, durationMinutes = 30, metadata) {
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 { rows } = await pool.query(sql_1.CLAIM_BY_METADATA, [filter, userId, durationMinutes, metaPatch]);
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 escalation = row;
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: row.prev_assigned_to === userId,
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 * FROM 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
- export declare const COUNT_BY_METADATA = "SELECT COUNT(*) FROM lt_escalations\nWHERE metadata @> $1::jsonb\n AND ($2::text IS NULL OR status = $2)";
25
- /** Atomic claim by metadata: find one available escalation, claim it, and optionally merge metadata. */
26
- export declare const CLAIM_BY_METADATA = "WITH target AS (\n SELECT id, assigned_to\n FROM lt_escalations\n WHERE metadata @> $1::jsonb\n AND status = 'pending'\n AND (\n assigned_to IS NULL\n OR assigned_until <= NOW()\n OR assigned_to = $2\n )\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 FROM target t\n WHERE e.id = t.id\n RETURNING e.*, t.assigned_to AS prev_assigned_to\n)\nSELECT * FROM updated";
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.CLAIM_BY_METADATA = exports.COUNT_BY_METADATA = 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;
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 * FROM lt_escalations
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
- exports.COUNT_BY_METADATA = `\
145
- SELECT COUNT(*) FROM lt_escalations
146
- WHERE metadata @> $1::jsonb
147
- AND ($2::text IS NULL OR status = $2)`;
148
- /** Atomic claim by metadata: find one available escalation, claim it, and optionally merge metadata. */
149
- exports.CLAIM_BY_METADATA = `\
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
- assigned_to IS NULL
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 * FROM updated`;
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.19",
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.4",
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",