@platform-modules/civil-aviation-authority 2.3.303 → 2.3.305

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.
@@ -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: parsed.raw,
31
+ claims: claimsWithoutSession,
24
32
  userId: parsed.userId,
25
- sessionId: parsed.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<unknown>;
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(`${alias}.is_deleted = :is_deleted`, { is_deleted: false });
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
- /** Create/submit step for assign-tasks-emp workflow logs. */
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
- /** Create/submit step for assign-tasks-emp workflow logs. */
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 || !(normalized in ASSIGN_TASKS_EMP_WF_FALLBACKS))
195
+ if (!normalized)
191
196
  return null;
192
- if (normalized === 'request_pending_from') {
193
- const nameEn = actors?.userName?.trim() || 'Unknown User';
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
- return resolveAssignTasksEmpWorkflowTemplate(normalized);
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 } : {}),
@@ -4,5 +4,6 @@ export declare class Departments extends BaseModel {
4
4
  department_arabic_name: string | null;
5
5
  department_code: string;
6
6
  department_description: string;
7
+ DG_department_id: number | null;
7
8
  constructor(department_name: string, department_code: string, department_description: string);
8
9
  }
@@ -37,6 +37,10 @@ __decorate([
37
37
  (0, typeorm_1.Column)({ nullable: false }),
38
38
  __metadata("design:type", String)
39
39
  ], Departments.prototype, "department_description", void 0);
40
+ __decorate([
41
+ (0, typeorm_1.Column)({ type: 'integer', nullable: true, name: 'DG_department_id' }),
42
+ __metadata("design:type", Object)
43
+ ], Departments.prototype, "DG_department_id", void 0);
40
44
  exports.Departments = Departments = __decorate([
41
45
  (0, typeorm_1.Entity)({ name: 'departments' }),
42
46
  __metadata("design:paramtypes", [String, String, String])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platform-modules/civil-aviation-authority",
3
- "version": "2.3.303",
3
+ "version": "2.3.305",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "scripts": {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Applies SLA report views + stored procedures to PostgreSQL (idempotent CREATE OR REPLACE).
3
3
  *
4
- * Env (shared_models/.env or Reports_Service/.env):
4
+ * Env from shared_models/.env only:
5
5
  * DB_HOST / TYPEORM_HOST
6
6
  * DB_PORT / TYPEORM_PORT
7
7
  * DB_USER / TYPEORM_USERNAME
@@ -20,31 +20,25 @@ const { Client } = require('pg');
20
20
  function loadEnvFiles() {
21
21
  const dotenv = require('dotenv');
22
22
  const packageRoot = path.resolve(__dirname, '..');
23
- const cwdEnv = path.join(process.cwd(), '.env');
24
23
  const packageEnv = path.join(packageRoot, '.env');
25
- const reportsEnv = path.resolve(packageRoot, '..', 'Reports_Service', '.env');
26
24
 
27
- if (fs.existsSync(cwdEnv)) {
28
- dotenv.config({ path: cwdEnv });
29
- }
30
- if (fs.existsSync(packageEnv)) {
31
- dotenv.config({ path: packageEnv, override: true });
32
- }
33
- if (fs.existsSync(reportsEnv)) {
34
- dotenv.config({ path: reportsEnv, override: true });
25
+ if (!fs.existsSync(packageEnv)) {
26
+ throw new Error(`Missing env file: ${packageEnv}`);
35
27
  }
28
+
29
+ dotenv.config({ path: packageEnv, override: true });
36
30
  }
37
31
 
38
32
  function getDbConfig() {
39
- const host = (process.env.TYPEORM_HOST || process.env.DB_HOST || '').trim();
40
- const port = parseInt(process.env.TYPEORM_PORT || process.env.DB_PORT || '5432', 10);
41
- const user = (process.env.TYPEORM_USERNAME || process.env.DB_USER || '').trim();
42
- const password = process.env.TYPEORM_PASSWORD || process.env.DB_PASS || '';
43
- const database = (process.env.TYPEORM_DATABASE || process.env.DB_NAME || '').trim();
33
+ const host = (process.env.DB_HOST || process.env.TYPEORM_HOST || '').trim();
34
+ const port = parseInt(process.env.DB_PORT || process.env.TYPEORM_PORT || '5432', 10);
35
+ const user = (process.env.DB_USER || process.env.TYPEORM_USERNAME || '').trim();
36
+ const password = process.env.DB_PASS || process.env.TYPEORM_PASSWORD || '';
37
+ const database = (process.env.DB_NAME || process.env.TYPEORM_DATABASE || '').trim();
44
38
 
45
39
  if (!host || !user || !database) {
46
40
  throw new Error(
47
- 'Missing DB config. Set TYPEORM_* in Reports_Service/.env or DB_* in shared_models/.env'
41
+ 'Missing DB config. Set DB_HOST, DB_USER, DB_NAME (and DB_PASS) in shared_models/.env'
48
42
  );
49
43
  }
50
44
 
package/sql/README.md CHANGED
@@ -10,7 +10,7 @@ Applied in order by `npm run sync:sla-sql` (see `sla-reports-sync.manifest.json`
10
10
  | `sla_reports_approvals_workbook.sql` | Approvals Excel workbook (Requests + Approvals per sub-service) |
11
11
  | `sla_reports_admin_procedures.sql` | Admin SLA report functions (requires procedures file) |
12
12
 
13
- From **shared_models** (uses `shared_models/.env`, or `Reports_Service/.env` when present in monorepo):
13
+ From **shared_models** (reads DB config from `shared_models/.env` only `DB_HOST`, `DB_USER`, `DB_PASS`, `DB_NAME`):
14
14
 
15
15
  ```bash
16
16
  cd shared_models && npm run sync:sla-sql
@@ -1,18 +1,25 @@
1
1
  -- Migrate sla_requests.status from PostgreSQL enum to varchar(64) in-place (preserves data).
2
- -- Drops SLA views only when converting from enum (safe for sync:sla-sql manifest runs).
2
+ -- Drops SLA views when a type change is required (recreated by later sync:sla-sql files).
3
3
  -- Idempotent when status is already varchar(64).
4
4
 
5
5
  DO $$
6
6
  DECLARE
7
7
  status_udt text;
8
+ status_len integer;
8
9
  BEGIN
9
- SELECT c.udt_name
10
- INTO status_udt
10
+ SELECT c.udt_name, c.character_maximum_length
11
+ INTO status_udt, status_len
11
12
  FROM information_schema.columns c
12
13
  WHERE c.table_schema = 'public'
13
14
  AND c.table_name = 'sla_requests'
14
15
  AND c.column_name = 'status';
15
16
 
17
+ IF status_udt IS NULL THEN
18
+ RAISE NOTICE 'sla_requests.status not found — skip type migration';
19
+ RETURN;
20
+ END IF;
21
+
22
+ -- Enum -> varchar(64)
16
23
  IF status_udt = 'sla_requests_status_enum' THEN
17
24
  DROP VIEW IF EXISTS vw_sla_approvals CASCADE;
18
25
  DROP VIEW IF EXISTS vw_sla_my_requests CASCADE;
@@ -21,9 +28,16 @@ BEGIN
21
28
  ALTER COLUMN status TYPE varchar(64) USING status::text;
22
29
 
23
30
  DROP TYPE IF EXISTS sla_requests_status_enum;
24
- ELSIF status_udt = 'varchar' THEN
31
+
32
+ -- Already varchar but not length 64 (views block ALTER TYPE)
33
+ ELSIF status_udt = 'varchar' AND status_len IS DISTINCT FROM 64 THEN
34
+ DROP VIEW IF EXISTS vw_sla_approvals CASCADE;
35
+ DROP VIEW IF EXISTS vw_sla_my_requests CASCADE;
36
+
25
37
  ALTER TABLE sla_requests
26
38
  ALTER COLUMN status TYPE varchar(64);
39
+
40
+ -- Already varchar(64) — nothing to do
27
41
  END IF;
28
42
  END $$;
29
43
 
@@ -139,3 +139,7 @@ ALTER TABLE followup_report_items
139
139
  ADD COLUMN IF NOT EXISTS file_name VARCHAR(255),
140
140
  ADD COLUMN IF NOT EXISTS file_type VARCHAR(100),
141
141
  ADD COLUMN IF NOT EXISTS file_size BIGINT;
142
+
143
+ -- 10. Departments: optional parent DG department reference (self FK to departments.id)
144
+ ALTER TABLE departments
145
+ ADD COLUMN IF NOT EXISTS "DG_department_id" INTEGER NULL;
@@ -22,6 +22,8 @@ $$;
22
22
  -- ADMIN — my requests Excel (optional p_target_user_id)
23
23
  -- Per sub-service Requests + Approvals sheets (same as user my-requests download)
24
24
  -- ---------------------------------------------------------------------------
25
+ DROP FUNCTION IF EXISTS sp_sla_admin_my_requests_excel_export(INT, INT[], INT[], TEXT[], DATE, DATE, TEXT, TEXT, TEXT);
26
+
25
27
  CREATE OR REPLACE FUNCTION sp_sla_admin_my_requests_excel_export(
26
28
  p_target_user_id INT DEFAULT NULL,
27
29
  p_service_ids INT[] DEFAULT NULL,
@@ -31,7 +33,8 @@ CREATE OR REPLACE FUNCTION sp_sla_admin_my_requests_excel_export(
31
33
  p_to_date DATE DEFAULT NULL,
32
34
  p_search_text TEXT DEFAULT NULL,
33
35
  p_sort_by TEXT DEFAULT 'created_at',
34
- p_sort_order TEXT DEFAULT 'DESC'
36
+ p_sort_order TEXT DEFAULT 'DESC',
37
+ p_extra_filter_sql TEXT DEFAULT 'TRUE'
35
38
  )
36
39
  RETURNS TABLE (
37
40
  sheet_order INT,
@@ -63,7 +66,11 @@ DECLARE
63
66
  v_sheet_order INT := 0;
64
67
  v_user_filter TEXT;
65
68
  BEGIN
66
- v_user_filter := CASE WHEN p_target_user_id IS NULL THEN 'TRUE' ELSE format('v.user_id = %s', p_target_user_id) END;
69
+ v_user_filter := format(
70
+ '(%s) AND (%s)',
71
+ CASE WHEN p_target_user_id IS NULL THEN 'TRUE' ELSE format('v.user_id = %s', p_target_user_id) END,
72
+ COALESCE(NULLIF(trim(p_extra_filter_sql), ''), 'TRUE')
73
+ );
67
74
 
68
75
  v_sort_col := CASE lower(trim(p_sort_by))
69
76
  WHEN 'updated_at' THEN 'v.created_at'
@@ -199,6 +206,10 @@ DROP FUNCTION IF EXISTS sp_sla_approvals_excel_export_internal(
199
206
  TEXT, BOOLEAN, INT, INT[], INT[], TEXT[], TEXT[], DATE, DATE, TEXT, TEXT, TEXT
200
207
  );
201
208
 
209
+ DROP FUNCTION IF EXISTS sp_sla_approvals_excel_export(
210
+ INT, INT[], INT[], TEXT[], TEXT[], DATE, DATE, TEXT, TEXT, TEXT
211
+ );
212
+
202
213
  CREATE OR REPLACE FUNCTION sp_sla_approvals_excel_export(
203
214
  p_approver_user_id INT,
204
215
  p_service_ids INT[] DEFAULT NULL,
@@ -209,7 +220,8 @@ CREATE OR REPLACE FUNCTION sp_sla_approvals_excel_export(
209
220
  p_to_date DATE DEFAULT NULL,
210
221
  p_search_text TEXT DEFAULT NULL,
211
222
  p_sort_by TEXT DEFAULT 'created_at',
212
- p_sort_order TEXT DEFAULT 'DESC'
223
+ p_sort_order TEXT DEFAULT 'DESC',
224
+ p_extra_filter_sql TEXT DEFAULT 'TRUE'
213
225
  )
214
226
  RETURNS TABLE (
215
227
  sheet_order INT, sub_service_id INT, sub_service_name TEXT, sheet_name TEXT, headers JSONB, rows JSONB
@@ -219,7 +231,11 @@ STABLE
219
231
  AS $$
220
232
  DECLARE
221
233
  v_exists TEXT := sla_approval_queue_exists_sql(p_approver_user_id);
222
- v_own TEXT := format('sr.user_id != %s', p_approver_user_id);
234
+ v_own TEXT := format(
235
+ '(%s) AND (%s)',
236
+ format('sr.user_id != %s', p_approver_user_id),
237
+ COALESCE(NULLIF(trim(p_extra_filter_sql), ''), 'TRUE')
238
+ );
223
239
  BEGIN
224
240
  RETURN QUERY
225
241
  SELECT * FROM sp_sla_approvals_workbook_excel_export(
@@ -243,6 +259,10 @@ $$;
243
259
  -- Per sub-service Requests + Approvals; optional approver_user_id (NULL = all queues)
244
260
  -- Does not exclude own requests (unlike user approvals download)
245
261
  -- ---------------------------------------------------------------------------
262
+ DROP FUNCTION IF EXISTS sp_sla_admin_approvals_excel_export(
263
+ INT, INT[], INT[], TEXT[], TEXT[], DATE, DATE, TEXT, TEXT, TEXT
264
+ );
265
+
246
266
  CREATE OR REPLACE FUNCTION sp_sla_admin_approvals_excel_export(
247
267
  p_target_approver_user_id INT DEFAULT NULL,
248
268
  p_service_ids INT[] DEFAULT NULL,
@@ -253,7 +273,8 @@ CREATE OR REPLACE FUNCTION sp_sla_admin_approvals_excel_export(
253
273
  p_to_date DATE DEFAULT NULL,
254
274
  p_search_text TEXT DEFAULT NULL,
255
275
  p_sort_by TEXT DEFAULT 'created_at',
256
- p_sort_order TEXT DEFAULT 'DESC'
276
+ p_sort_order TEXT DEFAULT 'DESC',
277
+ p_extra_filter_sql TEXT DEFAULT 'TRUE'
257
278
  )
258
279
  RETURNS TABLE (
259
280
  sheet_order INT, sub_service_id INT, sub_service_name TEXT, sheet_name TEXT, headers JSONB, rows JSONB
@@ -263,7 +284,7 @@ STABLE
263
284
  AS $$
264
285
  DECLARE
265
286
  v_exists TEXT := sla_approval_queue_exists_sql_optional(p_target_approver_user_id);
266
- v_own TEXT := 'TRUE';
287
+ v_own TEXT := COALESCE(NULLIF(trim(p_extra_filter_sql), ''), 'TRUE');
267
288
  BEGIN
268
289
  RETURN QUERY
269
290
  SELECT * FROM sp_sla_approvals_workbook_excel_export(
@@ -700,6 +700,8 @@ $$;
700
700
  -- MY REQUESTS — Excel workbook payload (one result row per sub-service sheet)
701
701
  -- Uses vw_sla_my_requests; flattening and dynamic columns are built in the database.
702
702
  -- ---------------------------------------------------------------------------
703
+ DROP FUNCTION IF EXISTS sp_sla_my_requests_excel_export(INT, INT[], INT[], TEXT[], DATE, DATE, TEXT, TEXT, TEXT);
704
+
703
705
  CREATE OR REPLACE FUNCTION sp_sla_my_requests_excel_export(
704
706
  p_user_id INT,
705
707
  p_service_ids INT[] DEFAULT NULL,
@@ -709,7 +711,8 @@ CREATE OR REPLACE FUNCTION sp_sla_my_requests_excel_export(
709
711
  p_to_date DATE DEFAULT NULL,
710
712
  p_search_text TEXT DEFAULT NULL,
711
713
  p_sort_by TEXT DEFAULT 'created_at',
712
- p_sort_order TEXT DEFAULT 'DESC'
714
+ p_sort_order TEXT DEFAULT 'DESC',
715
+ p_extra_filter_sql TEXT DEFAULT 'TRUE'
713
716
  )
714
717
  RETURNS TABLE (
715
718
  sheet_order INT,
@@ -739,7 +742,10 @@ DECLARE
739
742
  v_all_rows JSONB;
740
743
  v_k TEXT;
741
744
  v_sheet_order INT := 0;
745
+ v_extra TEXT;
742
746
  BEGIN
747
+ v_extra := COALESCE(NULLIF(trim(p_extra_filter_sql), ''), 'TRUE');
748
+
743
749
  v_sort_col := CASE lower(trim(p_sort_by))
744
750
  WHEN 'updated_at' THEN 'v.created_at'
745
751
  WHEN 'id' THEN 'v.sla_request_id'
@@ -758,6 +764,7 @@ BEGIN
758
764
  INNER JOIN sla_requests sr ON sr.id = v.sla_request_id AND COALESCE(sr.is_deleted, false) = false
759
765
  LEFT JOIN caa_sub_services subsvc ON subsvc.id = sr.sub_service_id
760
766
  WHERE v.user_id = %1$s
767
+ AND (%2$s)
761
768
  AND v.status = ANY($1)
762
769
  AND ($2 IS NULL OR cardinality($2) = 0 OR sr.service_id = ANY($2))
763
770
  AND ($3 IS NULL OR cardinality($3) = 0 OR sr.sub_service_id = ANY($3))
@@ -765,24 +772,28 @@ BEGIN
765
772
  AND ($5 IS NULL OR v.created_at::date <= $5)
766
773
  AND ($6 IS NULL OR trim($6) = '' OR CAST(v.request_id AS TEXT) ILIKE '%%' || trim($6) || '%%')
767
774
  ORDER BY sub_service_name ASC
768
- $q$, p_user_id)
775
+ $q$, p_user_id, v_extra)
769
776
  USING p_statuses, p_service_ids, p_sub_service_ids, p_from_date, p_to_date, p_search_text
770
777
  LOOP
771
778
  v_sheet_order := v_sheet_order + 1;
772
779
 
773
- SELECT COALESCE(array_agg(DISTINCT keys.k ORDER BY keys.k), ARRAY[]::TEXT[])
780
+ EXECUTE format($q$
781
+ SELECT COALESCE(array_agg(DISTINCT keys.k ORDER BY keys.k), ARRAY[]::TEXT[])
782
+ FROM vw_sla_my_requests v
783
+ INNER JOIN sla_requests sr ON sr.id = v.sla_request_id AND COALESCE(sr.is_deleted, false) = false
784
+ CROSS JOIN LATERAL jsonb_object_keys(COALESCE(v.request_fields, '{}'::jsonb)) AS keys(k)
785
+ WHERE v.user_id = %1$s
786
+ AND (%2$s)
787
+ AND v.status = ANY($1)
788
+ AND ($2 IS NULL OR cardinality($2) = 0 OR sr.service_id = ANY($2))
789
+ AND ($3 IS NULL OR cardinality($3) = 0 OR sr.sub_service_id = ANY($3))
790
+ AND ($4 IS NULL OR v.created_at::date >= $4)
791
+ AND ($5 IS NULL OR v.created_at::date <= $5)
792
+ AND ($6 IS NULL OR trim($6) = '' OR CAST(v.request_id AS TEXT) ILIKE '%%' || trim($6) || '%%')
793
+ AND COALESCE(sr.sub_service_id, 0) = %3$s
794
+ $q$, p_user_id, v_extra, v_sub.sub_service_id_num)
774
795
  INTO v_dyn_keys
775
- FROM vw_sla_my_requests v
776
- INNER JOIN sla_requests sr ON sr.id = v.sla_request_id AND COALESCE(sr.is_deleted, false) = false
777
- CROSS JOIN LATERAL jsonb_object_keys(COALESCE(v.request_fields, '{}'::jsonb)) AS keys(k)
778
- WHERE v.user_id = p_user_id
779
- AND v.status = ANY(p_statuses)
780
- AND (p_service_ids IS NULL OR cardinality(p_service_ids) = 0 OR sr.service_id = ANY(p_service_ids))
781
- AND (p_sub_service_ids IS NULL OR cardinality(p_sub_service_ids) = 0 OR sr.sub_service_id = ANY(p_sub_service_ids))
782
- AND (p_from_date IS NULL OR v.created_at::date >= p_from_date)
783
- AND (p_to_date IS NULL OR v.created_at::date <= p_to_date)
784
- AND (p_search_text IS NULL OR trim(p_search_text) = '' OR CAST(v.request_id AS TEXT) ILIKE '%' || trim(p_search_text) || '%')
785
- AND COALESCE(sr.sub_service_id, 0) = v_sub.sub_service_id_num;
796
+ USING p_statuses, p_service_ids, p_sub_service_ids, p_from_date, p_to_date, p_search_text;
786
797
 
787
798
  v_dyn_labels := ARRAY[]::TEXT[];
788
799
  IF v_dyn_keys IS NOT NULL THEN
@@ -815,15 +826,16 @@ BEGIN
815
826
  INNER JOIN sla_requests sr ON sr.id = v.sla_request_id AND COALESCE(sr.is_deleted, false) = false
816
827
  LEFT JOIN users u ON u.id = v.user_id AND COALESCE(u.is_deleted, false) = false
817
828
  WHERE v.user_id = %1$s
829
+ AND (%2$s)
818
830
  AND v.status = ANY($1)
819
831
  AND ($2 IS NULL OR cardinality($2) = 0 OR sr.service_id = ANY($2))
820
832
  AND ($3 IS NULL OR cardinality($3) = 0 OR sr.sub_service_id = ANY($3))
821
833
  AND ($4 IS NULL OR v.created_at::date >= $4)
822
834
  AND ($5 IS NULL OR v.created_at::date <= $5)
823
835
  AND ($6 IS NULL OR trim($6) = '' OR CAST(v.request_id AS TEXT) ILIKE '%%' || trim($6) || '%%')
824
- AND COALESCE(sr.sub_service_id, 0) = %2$s
825
- ORDER BY %3$s %4$s, v.sla_request_id ASC
826
- $q$, p_user_id, v_sub.sub_service_id_num, v_sort_col, v_sort_dir)
836
+ AND COALESCE(sr.sub_service_id, 0) = %3$s
837
+ ORDER BY %4$s %5$s, v.sla_request_id ASC
838
+ $q$, p_user_id, v_extra, v_sub.sub_service_id_num, v_sort_col, v_sort_dir)
827
839
  USING p_statuses, p_service_ids, p_sub_service_ids, p_from_date, p_to_date, p_search_text
828
840
  LOOP
829
841
  v_cells := ARRAY[
@@ -859,7 +871,7 @@ BEGIN
859
871
  RETURN QUERY
860
872
  SELECT *
861
873
  FROM sla_my_requests_approvals_excel_sheet(
862
- format('v.user_id = %s', p_user_id),
874
+ format('v.user_id = %s AND (%s)', p_user_id, v_extra),
863
875
  p_statuses,
864
876
  p_service_ids,
865
877
  p_sub_service_ids,
@@ -1,5 +1,9 @@
1
1
  import { parseVerifiedJwtClaims } from './jwt-token.helper';
2
- import { validateUserSessionBinding } from './user-session-validation';
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: parsed.raw,
69
+ claims: claimsWithoutSession,
48
70
  userId: parsed.userId,
49
- sessionId: parsed.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<unknown>;
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(`${alias}.is_deleted = :is_deleted`, { is_deleted: false });
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
- /** Create/submit step for assign-tasks-emp workflow logs. */
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 || !(normalized in ASSIGN_TASKS_EMP_WF_FALLBACKS)) return null;
254
- if (normalized === 'request_pending_from') {
255
- const nameEn = actors?.userName?.trim() || 'Unknown User';
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
- return resolveAssignTasksEmpWorkflowTemplate(normalized as AssignTasksEmpWorkflowTemplateKey);
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 } : {}),
@@ -16,6 +16,9 @@ export class Departments extends BaseModel {
16
16
  @Column({ nullable: false })
17
17
  department_description: string;
18
18
 
19
+ @Column({ type: 'integer', nullable: true, name: 'DG_department_id' })
20
+ DG_department_id: number | null;
21
+
19
22
  constructor(
20
23
  department_name: string,
21
24
  department_code: string,