@platform-modules/civil-aviation-authority 2.3.303 → 2.3.304
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/auth/authenticate-jwt-session.js +10 -2
- package/dist/auth/user-session-validation.d.ts +17 -1
- package/dist/auth/user-session-validation.js +37 -2
- package/dist/i18n/workflow-chat-message.builder.d.ts +3 -1
- package/dist/i18n/workflow-chat-message.builder.js +100 -5
- package/package.json +1 -1
- package/src/auth/authenticate-jwt-session.ts +25 -3
- package/src/auth/user-session-validation.ts +57 -3
- package/src/i18n/workflow-chat-message.builder.ts +123 -5
|
@@ -17,11 +17,19 @@ async function authorizeVerifiedJwtSession(verified, sessionReader, options) {
|
|
|
17
17
|
if (!sessionValid) {
|
|
18
18
|
return { ok: false, statusCode: 401, message: 'Session Expired please login again' };
|
|
19
19
|
}
|
|
20
|
+
if (parsed.sessionId != null) {
|
|
21
|
+
const isCurrent = await (0, user_session_validation_1.validateJwtSessionIsCurrent)(parsed.userId, parsed.sessionId, sessionReader);
|
|
22
|
+
if (!isCurrent) {
|
|
23
|
+
return { ok: false, statusCode: 401, message: 'Session Expired please login again' };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
20
26
|
}
|
|
27
|
+
const sessionId = await (0, user_session_validation_1.resolveAuthorizedSessionId)(parsed.userId, parsed.sessionId, sessionReader);
|
|
28
|
+
const { sessionId: _jwtSessionId, ...claimsWithoutSession } = parsed.raw;
|
|
21
29
|
return {
|
|
22
30
|
ok: true,
|
|
23
|
-
claims:
|
|
31
|
+
claims: claimsWithoutSession,
|
|
24
32
|
userId: parsed.userId,
|
|
25
|
-
sessionId
|
|
33
|
+
sessionId,
|
|
26
34
|
};
|
|
27
35
|
}
|
|
@@ -3,11 +3,18 @@
|
|
|
3
3
|
* JWT remains valid cryptographically until exp; access is allowed only while
|
|
4
4
|
* the bound user_sessions row is active and not expired.
|
|
5
5
|
*/
|
|
6
|
+
export type UserSessionRow = {
|
|
7
|
+
id?: number;
|
|
8
|
+
};
|
|
6
9
|
export type UserSessionReader = {
|
|
7
10
|
isSessionActiveById(sessionId: number, userId: number): Promise<boolean>;
|
|
8
11
|
/** Legacy fallback when REQUIRE_JWT_SESSION_ID=false and token has no sessionId. */
|
|
9
|
-
isSessionActive?(userId: number): Promise<
|
|
12
|
+
isSessionActive?(userId: number): Promise<UserSessionRow | null | undefined>;
|
|
13
|
+
/** Latest active session row id for user (highest id). */
|
|
14
|
+
getLatestActiveSessionId?(userId: number): Promise<number | null>;
|
|
10
15
|
};
|
|
16
|
+
/** Default true — only the newest active session may use the API when JWT carries sessionId. */
|
|
17
|
+
export declare function isSingleSessionOnLogin(): boolean;
|
|
11
18
|
/** When true (default), JWT must include sessionId and that session must be active. */
|
|
12
19
|
export declare function isRequireJwtSessionId(): boolean;
|
|
13
20
|
export declare function resolveJwtSessionId(sessionId: unknown): number | null;
|
|
@@ -15,9 +22,18 @@ export declare function resolveJwtSessionId(sessionId: unknown): number | null;
|
|
|
15
22
|
* Validate that a verified JWT is still authorized (not logged out / expired server-side).
|
|
16
23
|
*/
|
|
17
24
|
export declare function validateUserSessionBinding(userId: number, sessionId: number | null | undefined, reader: UserSessionReader): Promise<boolean>;
|
|
25
|
+
/** After row-level active check, enforce single-session policy (reject superseded JWTs). */
|
|
26
|
+
export declare function validateJwtSessionIsCurrent(userId: number, sessionId: number, reader: UserSessionReader): Promise<boolean>;
|
|
27
|
+
/** Resolve session id for request meta: JWT claim first, else latest active DB row. */
|
|
28
|
+
export declare function resolveAuthorizedSessionId(userId: number, jwtSessionId: number | null, reader: UserSessionReader): Promise<number | null>;
|
|
18
29
|
/** Shared TypeORM filters: active, not deleted, not past expires_at. */
|
|
19
30
|
export declare function applyActiveUserSessionFilters(qb: {
|
|
20
31
|
andWhere: (clause: string, params?: Record<string, unknown>) => unknown;
|
|
32
|
+
orderBy?: (sort: string, order?: 'ASC' | 'DESC') => unknown;
|
|
33
|
+
}, alias?: string): void;
|
|
34
|
+
/** Prefer newest active session when multiple rows match (legacy path). */
|
|
35
|
+
export declare function applyLatestActiveUserSessionOrdering(qb: {
|
|
36
|
+
orderBy: (sort: string, order?: 'ASC' | 'DESC') => unknown;
|
|
21
37
|
}, alias?: string): void;
|
|
22
38
|
/** @deprecated Use applyActiveUserSessionFilters — kept for backward compatibility. */
|
|
23
39
|
export declare function activeUserSessionSql(alias?: string): {
|
|
@@ -5,11 +5,20 @@
|
|
|
5
5
|
* the bound user_sessions row is active and not expired.
|
|
6
6
|
*/
|
|
7
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.isSingleSessionOnLogin = isSingleSessionOnLogin;
|
|
8
9
|
exports.isRequireJwtSessionId = isRequireJwtSessionId;
|
|
9
10
|
exports.resolveJwtSessionId = resolveJwtSessionId;
|
|
10
11
|
exports.validateUserSessionBinding = validateUserSessionBinding;
|
|
12
|
+
exports.validateJwtSessionIsCurrent = validateJwtSessionIsCurrent;
|
|
13
|
+
exports.resolveAuthorizedSessionId = resolveAuthorizedSessionId;
|
|
11
14
|
exports.applyActiveUserSessionFilters = applyActiveUserSessionFilters;
|
|
15
|
+
exports.applyLatestActiveUserSessionOrdering = applyLatestActiveUserSessionOrdering;
|
|
12
16
|
exports.activeUserSessionSql = activeUserSessionSql;
|
|
17
|
+
/** Default true — only the newest active session may use the API when JWT carries sessionId. */
|
|
18
|
+
function isSingleSessionOnLogin() {
|
|
19
|
+
const raw = (process.env.SINGLE_SESSION_ON_LOGIN ?? 'true').trim().toLowerCase();
|
|
20
|
+
return raw !== 'false' && raw !== '0' && raw !== 'no';
|
|
21
|
+
}
|
|
13
22
|
/** When true (default), JWT must include sessionId and that session must be active. */
|
|
14
23
|
function isRequireJwtSessionId() {
|
|
15
24
|
const raw = (process.env.REQUIRE_JWT_SESSION_ID ?? 'true').trim().toLowerCase();
|
|
@@ -41,16 +50,42 @@ async function validateUserSessionBinding(userId, sessionId, reader) {
|
|
|
41
50
|
const legacy = await reader.isSessionActive(userId);
|
|
42
51
|
return !!legacy;
|
|
43
52
|
}
|
|
53
|
+
/** After row-level active check, enforce single-session policy (reject superseded JWTs). */
|
|
54
|
+
async function validateJwtSessionIsCurrent(userId, sessionId, reader) {
|
|
55
|
+
if (!isSingleSessionOnLogin() || !reader.getLatestActiveSessionId)
|
|
56
|
+
return true;
|
|
57
|
+
const latest = await reader.getLatestActiveSessionId(userId);
|
|
58
|
+
if (latest == null)
|
|
59
|
+
return true;
|
|
60
|
+
return latest === sessionId;
|
|
61
|
+
}
|
|
62
|
+
/** Resolve session id for request meta: JWT claim first, else latest active DB row. */
|
|
63
|
+
async function resolveAuthorizedSessionId(userId, jwtSessionId, reader) {
|
|
64
|
+
if (jwtSessionId != null)
|
|
65
|
+
return jwtSessionId;
|
|
66
|
+
if (reader.getLatestActiveSessionId) {
|
|
67
|
+
return reader.getLatestActiveSessionId(userId);
|
|
68
|
+
}
|
|
69
|
+
if (!reader.isSessionActive)
|
|
70
|
+
return null;
|
|
71
|
+
const legacy = await reader.isSessionActive(userId);
|
|
72
|
+
const legacyId = Number(legacy?.id);
|
|
73
|
+
return Number.isFinite(legacyId) && legacyId > 0 ? legacyId : null;
|
|
74
|
+
}
|
|
44
75
|
/** Shared TypeORM filters: active, not deleted, not past expires_at. */
|
|
45
76
|
function applyActiveUserSessionFilters(qb, alias = 'user_sessions') {
|
|
46
77
|
qb.andWhere(`${alias}.is_active = :is_active`, { is_active: true });
|
|
47
|
-
qb.andWhere(
|
|
78
|
+
qb.andWhere(`(${alias}.is_deleted IS NULL OR ${alias}.is_deleted = :is_deleted)`, { is_deleted: false });
|
|
48
79
|
qb.andWhere(`(${alias}.expires_at IS NULL OR ${alias}.expires_at > :session_now)`, { session_now: new Date() });
|
|
49
80
|
}
|
|
81
|
+
/** Prefer newest active session when multiple rows match (legacy path). */
|
|
82
|
+
function applyLatestActiveUserSessionOrdering(qb, alias = 'user_sessions') {
|
|
83
|
+
qb.orderBy(`${alias}.id`, 'DESC');
|
|
84
|
+
}
|
|
50
85
|
/** @deprecated Use applyActiveUserSessionFilters — kept for backward compatibility. */
|
|
51
86
|
function activeUserSessionSql(alias = 'user_sessions') {
|
|
52
87
|
return {
|
|
53
|
-
clause: `${alias}.is_active = :is_active AND ${alias}.is_deleted = :is_deleted AND (${alias}.expires_at IS NULL OR ${alias}.expires_at > :session_now)`,
|
|
88
|
+
clause: `${alias}.is_active = :is_active AND (${alias}.is_deleted IS NULL OR ${alias}.is_deleted = :is_deleted) AND (${alias}.expires_at IS NULL OR ${alias}.expires_at > :session_now)`,
|
|
54
89
|
params: {
|
|
55
90
|
is_active: true,
|
|
56
91
|
is_deleted: false,
|
|
@@ -58,7 +58,9 @@ export declare function renderSynchronizedWorkflowMessage(templateKey: string, e
|
|
|
58
58
|
export declare function renderSynchronizedChatMessage(templateKey: string, enParams: Record<string, string>, arParams: Record<string, string>): BilingualText;
|
|
59
59
|
export declare function buildBilingualFromTemplate(templateCatalog: Record<string, BilingualText>, key: string, params?: Record<string, string>, arParams?: Record<string, string>): BilingualText;
|
|
60
60
|
export declare function workflowTemplate(key: string, params?: Record<string, string>, arParams?: Record<string, string>): BilingualText;
|
|
61
|
-
/**
|
|
61
|
+
/** Submit/create step for assign-tasks-emp — same as HR / logistics (`Request Submitted`). */
|
|
62
|
+
export declare function buildAssignTasksEmpSubmittedWorkflowFields(): BilingualText;
|
|
63
|
+
/** Task-assigned step (approval pending from assignee) — not the submit row. */
|
|
62
64
|
export declare function buildAssignTasksEmpAssignedWorkflowFields(): BilingualText;
|
|
63
65
|
/** Approval step — pending from assigned employee. */
|
|
64
66
|
export declare function buildAssignTasksEmpPendingFromWorkflowFields(params: {
|
|
@@ -17,6 +17,7 @@ exports.renderSynchronizedWorkflowMessage = renderSynchronizedWorkflowMessage;
|
|
|
17
17
|
exports.renderSynchronizedChatMessage = renderSynchronizedChatMessage;
|
|
18
18
|
exports.buildBilingualFromTemplate = buildBilingualFromTemplate;
|
|
19
19
|
exports.workflowTemplate = workflowTemplate;
|
|
20
|
+
exports.buildAssignTasksEmpSubmittedWorkflowFields = buildAssignTasksEmpSubmittedWorkflowFields;
|
|
20
21
|
exports.buildAssignTasksEmpAssignedWorkflowFields = buildAssignTasksEmpAssignedWorkflowFields;
|
|
21
22
|
exports.buildAssignTasksEmpPendingFromWorkflowFields = buildAssignTasksEmpPendingFromWorkflowFields;
|
|
22
23
|
exports.buildAssignTasksEmpPendingWorkflowFields = buildAssignTasksEmpPendingWorkflowFields;
|
|
@@ -171,7 +172,11 @@ function resolveAssignTasksEmpWorkflowTemplate(key, enParams = {}, arParams) {
|
|
|
171
172
|
ar: interpolate(tpl.ar, arabicParams),
|
|
172
173
|
};
|
|
173
174
|
}
|
|
174
|
-
/**
|
|
175
|
+
/** Submit/create step for assign-tasks-emp — same as HR / logistics (`Request Submitted`). */
|
|
176
|
+
function buildAssignTasksEmpSubmittedWorkflowFields() {
|
|
177
|
+
return buildSynchronizedWorkflowMessage({ action: 'request_submitted' });
|
|
178
|
+
}
|
|
179
|
+
/** Task-assigned step (approval pending from assignee) — not the submit row. */
|
|
175
180
|
function buildAssignTasksEmpAssignedWorkflowFields() {
|
|
176
181
|
return resolveAssignTasksEmpWorkflowTemplate('request_assigned');
|
|
177
182
|
}
|
|
@@ -187,14 +192,37 @@ function buildAssignTasksEmpPendingWorkflowFields() {
|
|
|
187
192
|
}
|
|
188
193
|
function resolveStoredAssignTasksEmpTemplateKey(content, actors) {
|
|
189
194
|
const normalized = content?.trim();
|
|
190
|
-
if (!normalized
|
|
195
|
+
if (!normalized)
|
|
191
196
|
return null;
|
|
192
|
-
if (normalized
|
|
193
|
-
|
|
197
|
+
if (normalized in ASSIGN_TASKS_EMP_WF_FALLBACKS) {
|
|
198
|
+
if (normalized === 'request_pending_from') {
|
|
199
|
+
const nameEn = actors?.userName?.trim() || 'Unknown User';
|
|
200
|
+
const nameAr = resolveArabicWorkflowActorName(actors, nameEn);
|
|
201
|
+
return resolveAssignTasksEmpWorkflowTemplate('request_pending_from', { name: nameEn }, { name: nameAr });
|
|
202
|
+
}
|
|
203
|
+
return resolveAssignTasksEmpWorkflowTemplate(normalized);
|
|
204
|
+
}
|
|
205
|
+
const requestSubmitted = WORKFLOW_TEMPLATES.request_submitted;
|
|
206
|
+
if (requestSubmitted &&
|
|
207
|
+
(normalized === requestSubmitted.en ||
|
|
208
|
+
normalized.toLowerCase() === requestSubmitted.en.toLowerCase())) {
|
|
209
|
+
return { en: requestSubmitted.en, ar: requestSubmitted.ar };
|
|
210
|
+
}
|
|
211
|
+
const requestAssigned = WORKFLOW_TEMPLATES.request_assigned;
|
|
212
|
+
if (requestAssigned && normalized === requestAssigned.en) {
|
|
213
|
+
return resolveAssignTasksEmpWorkflowTemplate('request_assigned');
|
|
214
|
+
}
|
|
215
|
+
const pendingFromPrefix = WORKFLOW_TEMPLATES.request_pending_from?.en.split('{{name}}')[0].trim();
|
|
216
|
+
if (pendingFromPrefix && normalized.startsWith(pendingFromPrefix)) {
|
|
217
|
+
const nameEn = normalized.slice(pendingFromPrefix.length).trim();
|
|
194
218
|
const nameAr = resolveArabicWorkflowActorName(actors, nameEn);
|
|
195
219
|
return resolveAssignTasksEmpWorkflowTemplate('request_pending_from', { name: nameEn }, { name: nameAr });
|
|
196
220
|
}
|
|
197
|
-
|
|
221
|
+
const requestPending = WORKFLOW_TEMPLATES.request_pending;
|
|
222
|
+
if (requestPending && normalized === requestPending.en) {
|
|
223
|
+
return resolveAssignTasksEmpWorkflowTemplate('request_pending');
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
198
226
|
}
|
|
199
227
|
function chatTemplate(key, params = {}, arParams) {
|
|
200
228
|
return buildBilingualFromTemplate(CHAT_TEMPLATES, key, params, arParams);
|
|
@@ -1180,6 +1208,65 @@ function tryResolveItHelpdeskMuscatLegacyWorkflowAr(content, actors) {
|
|
|
1180
1208
|
comment,
|
|
1181
1209
|
}).ar;
|
|
1182
1210
|
}
|
|
1211
|
+
if (/^IT Technician rejected the request$/i.test(base)) {
|
|
1212
|
+
return buildItHelpdeskMuscatWorkflowLog({
|
|
1213
|
+
variant: 'role_outcome_by_user_rejected',
|
|
1214
|
+
roleName: 'IT Technician',
|
|
1215
|
+
userName: actors?.userName?.trim() || 'IT Technician',
|
|
1216
|
+
userArabicName: actors?.userArabicName,
|
|
1217
|
+
comment,
|
|
1218
|
+
}).ar;
|
|
1219
|
+
}
|
|
1220
|
+
if (/^IT Technician closed the request$/i.test(base)) {
|
|
1221
|
+
return buildItHelpdeskMuscatWorkflowLog({
|
|
1222
|
+
variant: 'role_outcome_by_user_closed',
|
|
1223
|
+
roleName: 'IT Technician',
|
|
1224
|
+
userName: actors?.userName?.trim() || 'IT Technician',
|
|
1225
|
+
userArabicName: actors?.userArabicName,
|
|
1226
|
+
comment,
|
|
1227
|
+
}).ar;
|
|
1228
|
+
}
|
|
1229
|
+
m = base.match(/^Workflow routed to direct resolution path - request closed by (.+)$/i);
|
|
1230
|
+
if (m) {
|
|
1231
|
+
return buildItHelpdeskMuscatWorkflowLog({
|
|
1232
|
+
variant: 'routing_direct_approval',
|
|
1233
|
+
roleName: m[1].trim(),
|
|
1234
|
+
roleArabicName: actors?.roleArabicName,
|
|
1235
|
+
comment,
|
|
1236
|
+
}).ar;
|
|
1237
|
+
}
|
|
1238
|
+
if (/^HOS notification not required, request rejected$/i.test(base)) {
|
|
1239
|
+
return buildItHelpdeskMuscatWorkflowLog({
|
|
1240
|
+
variant: 'hos_not_required_rejected',
|
|
1241
|
+
userName: actors?.userName?.trim() || 'IT Technician',
|
|
1242
|
+
userArabicName: actors?.userArabicName,
|
|
1243
|
+
comment,
|
|
1244
|
+
}).ar;
|
|
1245
|
+
}
|
|
1246
|
+
if (/^HOS notification not required, request closed$/i.test(base)) {
|
|
1247
|
+
return buildItHelpdeskMuscatWorkflowLog({
|
|
1248
|
+
variant: 'hos_not_required_closed',
|
|
1249
|
+
userName: actors?.userName?.trim() || 'IT Technician',
|
|
1250
|
+
userArabicName: actors?.userArabicName,
|
|
1251
|
+
comment,
|
|
1252
|
+
}).ar;
|
|
1253
|
+
}
|
|
1254
|
+
if (/^L3 assignment not required, request rejected$/i.test(base)) {
|
|
1255
|
+
return buildItHelpdeskMuscatWorkflowLog({
|
|
1256
|
+
variant: 'l3_not_required_rejected',
|
|
1257
|
+
userName: actors?.userName?.trim() || 'IT Technician',
|
|
1258
|
+
userArabicName: actors?.userArabicName,
|
|
1259
|
+
comment,
|
|
1260
|
+
}).ar;
|
|
1261
|
+
}
|
|
1262
|
+
if (/^L3 assignment not required, request closed$/i.test(base)) {
|
|
1263
|
+
return buildItHelpdeskMuscatWorkflowLog({
|
|
1264
|
+
variant: 'l3_not_required_closed',
|
|
1265
|
+
userName: actors?.userName?.trim() || 'IT Technician',
|
|
1266
|
+
userArabicName: actors?.userArabicName,
|
|
1267
|
+
comment,
|
|
1268
|
+
}).ar;
|
|
1269
|
+
}
|
|
1183
1270
|
return null;
|
|
1184
1271
|
}
|
|
1185
1272
|
function resolveWorkflowContentAr(content, actors) {
|
|
@@ -1391,6 +1478,14 @@ function enrichWorkflowLog(log) {
|
|
|
1391
1478
|
if (assignTasksEmpResolved) {
|
|
1392
1479
|
content_ar = assignTasksEmpResolved.ar;
|
|
1393
1480
|
}
|
|
1481
|
+
const submittedTpl = WORKFLOW_TEMPLATES.request_submitted;
|
|
1482
|
+
const assignedTpl = WORKFLOW_TEMPLATES.request_assigned;
|
|
1483
|
+
if (submittedTpl &&
|
|
1484
|
+
assignedTpl &&
|
|
1485
|
+
content === submittedTpl.en &&
|
|
1486
|
+
content_ar === assignedTpl.ar) {
|
|
1487
|
+
content_ar = submittedTpl.ar;
|
|
1488
|
+
}
|
|
1394
1489
|
return {
|
|
1395
1490
|
...log,
|
|
1396
1491
|
...(content !== rawContent ? { content } : {}),
|
package/package.json
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { parseVerifiedJwtClaims } from './jwt-token.helper';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
resolveAuthorizedSessionId,
|
|
4
|
+
validateJwtSessionIsCurrent,
|
|
5
|
+
validateUserSessionBinding,
|
|
6
|
+
} from './user-session-validation';
|
|
3
7
|
import type { UserSessionReader } from './user-session-validation';
|
|
4
8
|
|
|
5
9
|
export type JwtAuthFailure = {
|
|
@@ -40,12 +44,30 @@ export async function authorizeVerifiedJwtSession(
|
|
|
40
44
|
if (!sessionValid) {
|
|
41
45
|
return { ok: false, statusCode: 401, message: 'Session Expired please login again' };
|
|
42
46
|
}
|
|
47
|
+
|
|
48
|
+
if (parsed.sessionId != null) {
|
|
49
|
+
const isCurrent = await validateJwtSessionIsCurrent(
|
|
50
|
+
parsed.userId,
|
|
51
|
+
parsed.sessionId,
|
|
52
|
+
sessionReader,
|
|
53
|
+
);
|
|
54
|
+
if (!isCurrent) {
|
|
55
|
+
return { ok: false, statusCode: 401, message: 'Session Expired please login again' };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
43
58
|
}
|
|
44
59
|
|
|
60
|
+
const sessionId = await resolveAuthorizedSessionId(
|
|
61
|
+
parsed.userId,
|
|
62
|
+
parsed.sessionId,
|
|
63
|
+
sessionReader,
|
|
64
|
+
);
|
|
65
|
+
const { sessionId: _jwtSessionId, ...claimsWithoutSession } = parsed.raw;
|
|
66
|
+
|
|
45
67
|
return {
|
|
46
68
|
ok: true,
|
|
47
|
-
claims:
|
|
69
|
+
claims: claimsWithoutSession,
|
|
48
70
|
userId: parsed.userId,
|
|
49
|
-
sessionId
|
|
71
|
+
sessionId,
|
|
50
72
|
};
|
|
51
73
|
}
|
|
@@ -4,12 +4,24 @@
|
|
|
4
4
|
* the bound user_sessions row is active and not expired.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
export type UserSessionRow = {
|
|
8
|
+
id?: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
7
11
|
export type UserSessionReader = {
|
|
8
12
|
isSessionActiveById(sessionId: number, userId: number): Promise<boolean>;
|
|
9
13
|
/** Legacy fallback when REQUIRE_JWT_SESSION_ID=false and token has no sessionId. */
|
|
10
|
-
isSessionActive?(userId: number): Promise<
|
|
14
|
+
isSessionActive?(userId: number): Promise<UserSessionRow | null | undefined>;
|
|
15
|
+
/** Latest active session row id for user (highest id). */
|
|
16
|
+
getLatestActiveSessionId?(userId: number): Promise<number | null>;
|
|
11
17
|
};
|
|
12
18
|
|
|
19
|
+
/** Default true — only the newest active session may use the API when JWT carries sessionId. */
|
|
20
|
+
export function isSingleSessionOnLogin(): boolean {
|
|
21
|
+
const raw = (process.env.SINGLE_SESSION_ON_LOGIN ?? 'true').trim().toLowerCase();
|
|
22
|
+
return raw !== 'false' && raw !== '0' && raw !== 'no';
|
|
23
|
+
}
|
|
24
|
+
|
|
13
25
|
/** When true (default), JWT must include sessionId and that session must be active. */
|
|
14
26
|
export function isRequireJwtSessionId(): boolean {
|
|
15
27
|
const raw = (process.env.REQUIRE_JWT_SESSION_ID ?? 'true').trim().toLowerCase();
|
|
@@ -47,28 +59,70 @@ export async function validateUserSessionBinding(
|
|
|
47
59
|
return !!legacy;
|
|
48
60
|
}
|
|
49
61
|
|
|
62
|
+
/** After row-level active check, enforce single-session policy (reject superseded JWTs). */
|
|
63
|
+
export async function validateJwtSessionIsCurrent(
|
|
64
|
+
userId: number,
|
|
65
|
+
sessionId: number,
|
|
66
|
+
reader: UserSessionReader,
|
|
67
|
+
): Promise<boolean> {
|
|
68
|
+
if (!isSingleSessionOnLogin() || !reader.getLatestActiveSessionId) return true;
|
|
69
|
+
const latest = await reader.getLatestActiveSessionId(userId);
|
|
70
|
+
if (latest == null) return true;
|
|
71
|
+
return latest === sessionId;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Resolve session id for request meta: JWT claim first, else latest active DB row. */
|
|
75
|
+
export async function resolveAuthorizedSessionId(
|
|
76
|
+
userId: number,
|
|
77
|
+
jwtSessionId: number | null,
|
|
78
|
+
reader: UserSessionReader,
|
|
79
|
+
): Promise<number | null> {
|
|
80
|
+
if (jwtSessionId != null) return jwtSessionId;
|
|
81
|
+
|
|
82
|
+
if (reader.getLatestActiveSessionId) {
|
|
83
|
+
return reader.getLatestActiveSessionId(userId);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!reader.isSessionActive) return null;
|
|
87
|
+
const legacy = await reader.isSessionActive(userId);
|
|
88
|
+
const legacyId = Number(legacy?.id);
|
|
89
|
+
return Number.isFinite(legacyId) && legacyId > 0 ? legacyId : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
50
92
|
/** Shared TypeORM filters: active, not deleted, not past expires_at. */
|
|
51
93
|
export function applyActiveUserSessionFilters(
|
|
52
94
|
qb: {
|
|
53
95
|
andWhere: (clause: string, params?: Record<string, unknown>) => unknown;
|
|
96
|
+
orderBy?: (sort: string, order?: 'ASC' | 'DESC') => unknown;
|
|
54
97
|
},
|
|
55
98
|
alias = 'user_sessions',
|
|
56
99
|
): void {
|
|
57
100
|
qb.andWhere(`${alias}.is_active = :is_active`, { is_active: true });
|
|
58
|
-
qb.andWhere(
|
|
101
|
+
qb.andWhere(
|
|
102
|
+
`(${alias}.is_deleted IS NULL OR ${alias}.is_deleted = :is_deleted)`,
|
|
103
|
+
{ is_deleted: false },
|
|
104
|
+
);
|
|
59
105
|
qb.andWhere(
|
|
60
106
|
`(${alias}.expires_at IS NULL OR ${alias}.expires_at > :session_now)`,
|
|
61
107
|
{ session_now: new Date() },
|
|
62
108
|
);
|
|
63
109
|
}
|
|
64
110
|
|
|
111
|
+
/** Prefer newest active session when multiple rows match (legacy path). */
|
|
112
|
+
export function applyLatestActiveUserSessionOrdering(
|
|
113
|
+
qb: { orderBy: (sort: string, order?: 'ASC' | 'DESC') => unknown },
|
|
114
|
+
alias = 'user_sessions',
|
|
115
|
+
): void {
|
|
116
|
+
qb.orderBy(`${alias}.id`, 'DESC');
|
|
117
|
+
}
|
|
118
|
+
|
|
65
119
|
/** @deprecated Use applyActiveUserSessionFilters — kept for backward compatibility. */
|
|
66
120
|
export function activeUserSessionSql(alias = 'user_sessions'): {
|
|
67
121
|
clause: string;
|
|
68
122
|
params: Record<string, unknown>;
|
|
69
123
|
} {
|
|
70
124
|
return {
|
|
71
|
-
clause: `${alias}.is_active = :is_active AND ${alias}.is_deleted = :is_deleted AND (${alias}.expires_at IS NULL OR ${alias}.expires_at > :session_now)`,
|
|
125
|
+
clause: `${alias}.is_active = :is_active AND (${alias}.is_deleted IS NULL OR ${alias}.is_deleted = :is_deleted) AND (${alias}.expires_at IS NULL OR ${alias}.expires_at > :session_now)`,
|
|
72
126
|
params: {
|
|
73
127
|
is_active: true,
|
|
74
128
|
is_deleted: false,
|
|
@@ -221,7 +221,12 @@ function resolveAssignTasksEmpWorkflowTemplate(
|
|
|
221
221
|
};
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
-
/**
|
|
224
|
+
/** Submit/create step for assign-tasks-emp — same as HR / logistics (`Request Submitted`). */
|
|
225
|
+
export function buildAssignTasksEmpSubmittedWorkflowFields(): BilingualText {
|
|
226
|
+
return buildSynchronizedWorkflowMessage({ action: 'request_submitted' });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Task-assigned step (approval pending from assignee) — not the submit row. */
|
|
225
230
|
export function buildAssignTasksEmpAssignedWorkflowFields(): BilingualText {
|
|
226
231
|
return resolveAssignTasksEmpWorkflowTemplate('request_assigned');
|
|
227
232
|
}
|
|
@@ -250,9 +255,38 @@ function resolveStoredAssignTasksEmpTemplateKey(
|
|
|
250
255
|
actors?: BilingualActorNames,
|
|
251
256
|
): BilingualText | null {
|
|
252
257
|
const normalized = content?.trim();
|
|
253
|
-
if (!normalized
|
|
254
|
-
|
|
255
|
-
|
|
258
|
+
if (!normalized) return null;
|
|
259
|
+
|
|
260
|
+
if (normalized in ASSIGN_TASKS_EMP_WF_FALLBACKS) {
|
|
261
|
+
if (normalized === 'request_pending_from') {
|
|
262
|
+
const nameEn = actors?.userName?.trim() || 'Unknown User';
|
|
263
|
+
const nameAr = resolveArabicWorkflowActorName(actors, nameEn);
|
|
264
|
+
return resolveAssignTasksEmpWorkflowTemplate(
|
|
265
|
+
'request_pending_from',
|
|
266
|
+
{ name: nameEn },
|
|
267
|
+
{ name: nameAr },
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
return resolveAssignTasksEmpWorkflowTemplate(normalized as AssignTasksEmpWorkflowTemplateKey);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const requestSubmitted = WORKFLOW_TEMPLATES.request_submitted;
|
|
274
|
+
if (
|
|
275
|
+
requestSubmitted &&
|
|
276
|
+
(normalized === requestSubmitted.en ||
|
|
277
|
+
normalized.toLowerCase() === requestSubmitted.en.toLowerCase())
|
|
278
|
+
) {
|
|
279
|
+
return { en: requestSubmitted.en, ar: requestSubmitted.ar };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const requestAssigned = WORKFLOW_TEMPLATES.request_assigned;
|
|
283
|
+
if (requestAssigned && normalized === requestAssigned.en) {
|
|
284
|
+
return resolveAssignTasksEmpWorkflowTemplate('request_assigned');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const pendingFromPrefix = WORKFLOW_TEMPLATES.request_pending_from?.en.split('{{name}}')[0].trim();
|
|
288
|
+
if (pendingFromPrefix && normalized.startsWith(pendingFromPrefix)) {
|
|
289
|
+
const nameEn = normalized.slice(pendingFromPrefix.length).trim();
|
|
256
290
|
const nameAr = resolveArabicWorkflowActorName(actors, nameEn);
|
|
257
291
|
return resolveAssignTasksEmpWorkflowTemplate(
|
|
258
292
|
'request_pending_from',
|
|
@@ -260,7 +294,13 @@ function resolveStoredAssignTasksEmpTemplateKey(
|
|
|
260
294
|
{ name: nameAr },
|
|
261
295
|
);
|
|
262
296
|
}
|
|
263
|
-
|
|
297
|
+
|
|
298
|
+
const requestPending = WORKFLOW_TEMPLATES.request_pending;
|
|
299
|
+
if (requestPending && normalized === requestPending.en) {
|
|
300
|
+
return resolveAssignTasksEmpWorkflowTemplate('request_pending');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return null;
|
|
264
304
|
}
|
|
265
305
|
|
|
266
306
|
export function chatTemplate(
|
|
@@ -1800,6 +1840,72 @@ function tryResolveItHelpdeskMuscatLegacyWorkflowAr(
|
|
|
1800
1840
|
}).ar;
|
|
1801
1841
|
}
|
|
1802
1842
|
|
|
1843
|
+
if (/^IT Technician rejected the request$/i.test(base)) {
|
|
1844
|
+
return buildItHelpdeskMuscatWorkflowLog({
|
|
1845
|
+
variant: 'role_outcome_by_user_rejected',
|
|
1846
|
+
roleName: 'IT Technician',
|
|
1847
|
+
userName: actors?.userName?.trim() || 'IT Technician',
|
|
1848
|
+
userArabicName: actors?.userArabicName,
|
|
1849
|
+
comment,
|
|
1850
|
+
}).ar;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
if (/^IT Technician closed the request$/i.test(base)) {
|
|
1854
|
+
return buildItHelpdeskMuscatWorkflowLog({
|
|
1855
|
+
variant: 'role_outcome_by_user_closed',
|
|
1856
|
+
roleName: 'IT Technician',
|
|
1857
|
+
userName: actors?.userName?.trim() || 'IT Technician',
|
|
1858
|
+
userArabicName: actors?.userArabicName,
|
|
1859
|
+
comment,
|
|
1860
|
+
}).ar;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
m = base.match(/^Workflow routed to direct resolution path - request closed by (.+)$/i);
|
|
1864
|
+
if (m) {
|
|
1865
|
+
return buildItHelpdeskMuscatWorkflowLog({
|
|
1866
|
+
variant: 'routing_direct_approval',
|
|
1867
|
+
roleName: m[1].trim(),
|
|
1868
|
+
roleArabicName: actors?.roleArabicName,
|
|
1869
|
+
comment,
|
|
1870
|
+
}).ar;
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
if (/^HOS notification not required, request rejected$/i.test(base)) {
|
|
1874
|
+
return buildItHelpdeskMuscatWorkflowLog({
|
|
1875
|
+
variant: 'hos_not_required_rejected',
|
|
1876
|
+
userName: actors?.userName?.trim() || 'IT Technician',
|
|
1877
|
+
userArabicName: actors?.userArabicName,
|
|
1878
|
+
comment,
|
|
1879
|
+
}).ar;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
if (/^HOS notification not required, request closed$/i.test(base)) {
|
|
1883
|
+
return buildItHelpdeskMuscatWorkflowLog({
|
|
1884
|
+
variant: 'hos_not_required_closed',
|
|
1885
|
+
userName: actors?.userName?.trim() || 'IT Technician',
|
|
1886
|
+
userArabicName: actors?.userArabicName,
|
|
1887
|
+
comment,
|
|
1888
|
+
}).ar;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
if (/^L3 assignment not required, request rejected$/i.test(base)) {
|
|
1892
|
+
return buildItHelpdeskMuscatWorkflowLog({
|
|
1893
|
+
variant: 'l3_not_required_rejected',
|
|
1894
|
+
userName: actors?.userName?.trim() || 'IT Technician',
|
|
1895
|
+
userArabicName: actors?.userArabicName,
|
|
1896
|
+
comment,
|
|
1897
|
+
}).ar;
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
if (/^L3 assignment not required, request closed$/i.test(base)) {
|
|
1901
|
+
return buildItHelpdeskMuscatWorkflowLog({
|
|
1902
|
+
variant: 'l3_not_required_closed',
|
|
1903
|
+
userName: actors?.userName?.trim() || 'IT Technician',
|
|
1904
|
+
userArabicName: actors?.userArabicName,
|
|
1905
|
+
comment,
|
|
1906
|
+
}).ar;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1803
1909
|
return null;
|
|
1804
1910
|
}
|
|
1805
1911
|
|
|
@@ -2042,6 +2148,18 @@ export function enrichWorkflowLog(log: Record<string, unknown>): Record<string,
|
|
|
2042
2148
|
if (assignTasksEmpResolved) {
|
|
2043
2149
|
content_ar = assignTasksEmpResolved.ar;
|
|
2044
2150
|
}
|
|
2151
|
+
|
|
2152
|
+
const submittedTpl = WORKFLOW_TEMPLATES.request_submitted;
|
|
2153
|
+
const assignedTpl = WORKFLOW_TEMPLATES.request_assigned;
|
|
2154
|
+
if (
|
|
2155
|
+
submittedTpl &&
|
|
2156
|
+
assignedTpl &&
|
|
2157
|
+
content === submittedTpl.en &&
|
|
2158
|
+
content_ar === assignedTpl.ar
|
|
2159
|
+
) {
|
|
2160
|
+
content_ar = submittedTpl.ar;
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2045
2163
|
return {
|
|
2046
2164
|
...log,
|
|
2047
2165
|
...(content !== rawContent ? { content } : {}),
|