@tmlmobilidade/interfaces 20251007.1740.45-staging.0 → 20251008.1352.27-staging.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './src/aggregation-pipeline.js';
2
+ export * from './src/enrich-user-refs.js';
2
3
  export * from './src/interfaces/index.js';
3
4
  export * from './src/mongo-collection.js';
4
5
  export * from './src/mongo-transaction.js';
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /* * */
2
2
  export * from './src/aggregation-pipeline.js';
3
+ export * from './src/enrich-user-refs.js';
3
4
  export * from './src/interfaces/index.js';
4
5
  export * from './src/mongo-collection.js';
5
6
  export * from './src/mongo-transaction.js';
@@ -0,0 +1,4 @@
1
+ type AnyDoc = Record<string, any>;
2
+ export declare function enrichUserRefs<T extends AnyDoc>(doc: T): Promise<T>;
3
+ export declare function enrichUserRefsMany<T extends AnyDoc>(docs: T[]): Promise<T[]>;
4
+ export {};
@@ -0,0 +1,75 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { users } from './interfaces/auth/users.js';
3
+ import { UserDisplayFields } from '@tmlmobilidade/types';
4
+ const isUserRefKey = (k) => k === 'created_by' || k === 'updated_by';
5
+ const isEligibleId = (v) => typeof v === 'string' && v && v !== 'system';
6
+ function collectUserIds(input, out) {
7
+ if (Array.isArray(input)) {
8
+ for (const item of input)
9
+ collectUserIds(item, out);
10
+ return;
11
+ }
12
+ if (input && typeof input === 'object') {
13
+ for (const [k, v] of Object.entries(input)) {
14
+ if (isUserRefKey(k) && isEligibleId(v))
15
+ out.add(v);
16
+ collectUserIds(v, out);
17
+ }
18
+ }
19
+ }
20
+ function replaceRefs(input, map) {
21
+ if (Array.isArray(input)) {
22
+ return input.map(item => replaceRefs(item, map));
23
+ }
24
+ if (input && typeof input === 'object') {
25
+ const obj = input;
26
+ const out = Array.isArray(obj) ? [] : {};
27
+ for (const [k, v] of Object.entries(obj)) {
28
+ if (isUserRefKey(k) && typeof v === 'string' && v !== 'system') {
29
+ const found = map.get(v);
30
+ out[k] = found ?? v; // fallback to original id if user not found
31
+ }
32
+ else {
33
+ out[k] = replaceRefs(v, map);
34
+ }
35
+ }
36
+ return out;
37
+ }
38
+ return input;
39
+ }
40
+ async function fetchUsersMap(ids) {
41
+ if (ids.size === 0)
42
+ return new Map();
43
+ const coll = await users.getCollection();
44
+ // Only fetch UserDisplay fields
45
+ const projection = Object.keys(UserDisplayFields).reduce((acc, field) => {
46
+ acc[field] = 1;
47
+ return acc;
48
+ }, {});
49
+ const result = await coll.find({ _id: { $in: Array.from(ids) } }, { projection }).toArray();
50
+ const map = new Map();
51
+ for (const u of result) {
52
+ map.set(u._id, {
53
+ _id: u._id,
54
+ avatar: u.avatar ?? undefined,
55
+ email: u.email,
56
+ first_name: u.first_name,
57
+ last_name: u.last_name,
58
+ phone: u.phone ?? undefined,
59
+ });
60
+ }
61
+ return map;
62
+ }
63
+ export async function enrichUserRefs(doc) {
64
+ const ids = new Set();
65
+ collectUserIds(doc, ids);
66
+ const map = await fetchUsersMap(ids);
67
+ return replaceRefs(doc, map);
68
+ }
69
+ export async function enrichUserRefsMany(docs) {
70
+ const ids = new Set();
71
+ for (const d of docs)
72
+ collectUserIds(d, ids);
73
+ const map = await fetchUsersMap(ids);
74
+ return docs.map(d => replaceRefs(d, map));
75
+ }
@@ -2,7 +2,6 @@ import { MongoCollectionClass } from '../../mongo-collection.js';
2
2
  import { CreateUserDto, UpdateUserDto, User } from '@tmlmobilidade/types';
3
3
  import { Filter, FindOptions, IndexDescription, WithId } from 'mongodb';
4
4
  import { z } from 'zod';
5
- type NewType = string;
6
5
  declare class UsersClass extends MongoCollectionClass<User, CreateUserDto, UpdateUserDto> {
7
6
  private static _instance;
8
7
  protected createSchema: z.ZodSchema;
@@ -32,7 +31,7 @@ declare class UsersClass extends MongoCollectionClass<User, CreateUserDto, Updat
32
31
  * @param includePasswordHash - Whether to include the password hash in the result
33
32
  * @returns A promise that resolves to the matching user documents or null if not found
34
33
  */
35
- findByOrganization(id: NewType, includePasswordHash?: boolean): Promise<{
34
+ findByOrganization(id: string, includePasswordHash?: boolean): Promise<{
36
35
  created_at: number & {
37
36
  __brand: "UnixTimestamp";
38
37
  };
@@ -41,15 +40,15 @@ declare class UsersClass extends MongoCollectionClass<User, CreateUserDto, Updat
41
40
  };
42
41
  created_by?: string | undefined | undefined;
43
42
  updated_by?: string | undefined | undefined;
44
- phone?: string | null | undefined | undefined;
45
- email: string;
46
- permissions: import("@tmlmobilidade/types").Permission<unknown>[];
47
43
  avatar?: string | null | undefined | undefined;
48
44
  bio?: string | null | undefined | undefined;
45
+ email: string;
49
46
  email_verified?: import("@tmlmobilidade/types").UnixTimestamp | null | undefined;
50
47
  first_name: string;
51
48
  last_name: string;
52
49
  organization_id?: string | null | undefined | undefined;
50
+ permissions: import("@tmlmobilidade/types").Permission<unknown>[];
51
+ phone?: string | null | undefined | undefined;
53
52
  preferences?: Record<string, Record<string, string | number | boolean | string[] | number[]>> | null | undefined;
54
53
  role_ids: string[];
55
54
  session_ids: string[];
@@ -72,15 +71,15 @@ declare class UsersClass extends MongoCollectionClass<User, CreateUserDto, Updat
72
71
  };
73
72
  created_by?: string | undefined | undefined;
74
73
  updated_by?: string | undefined | undefined;
75
- phone?: string | null | undefined | undefined;
76
- email: string;
77
- permissions: import("@tmlmobilidade/types").Permission<unknown>[];
78
74
  avatar?: string | null | undefined | undefined;
79
75
  bio?: string | null | undefined | undefined;
76
+ email: string;
80
77
  email_verified?: import("@tmlmobilidade/types").UnixTimestamp | null | undefined;
81
78
  first_name: string;
82
79
  last_name: string;
83
80
  organization_id?: string | null | undefined | undefined;
81
+ permissions: import("@tmlmobilidade/types").Permission<unknown>[];
82
+ phone?: string | null | undefined | undefined;
84
83
  preferences?: Record<string, Record<string, string | number | boolean | string[] | number[]>> | null | undefined;
85
84
  role_ids: string[];
86
85
  session_ids: string[];
@@ -2,6 +2,7 @@
2
2
  import { MongoCollectionClass } from '../../mongo-collection.js';
3
3
  import { UpdateUserSchema, UserSchema } from '@tmlmobilidade/types';
4
4
  import { AsyncSingletonProxy } from '@tmlmobilidade/utils';
5
+ /* * */
5
6
  class UsersClass extends MongoCollectionClass {
6
7
  static _instance;
7
8
  createSchema = UserSchema;
@@ -13,6 +13,15 @@ declare class NotificationsClass extends MongoCollectionClass<Notification, Crea
13
13
  protected getCollectionName(): string;
14
14
  protected getCreateSchema(): z.ZodSchema;
15
15
  protected getEnvName(): string;
16
+ /**
17
+ * Collects all effective permissions of a user (direct + via roles),
18
+ * merging duplicates by (scope:action).
19
+ */
20
+ private collectUserPermissions;
21
+ /**
22
+ * Determines whether a user can receive email notifications for a topic.
23
+ */
24
+ private getNotificationPermission;
16
25
  }
17
26
  export declare const notifications: NotificationsClass;
18
27
  export {};
@@ -3,7 +3,8 @@ import { MongoCollectionClass } from '../../mongo-collection.js';
3
3
  import { sendNotificationEmail } from '@tmlmobilidade/emails';
4
4
  import { getAppConfig } from '@tmlmobilidade/lib';
5
5
  import { NotificationSchema, UpdateNotificationSchema } from '@tmlmobilidade/types';
6
- import { AsyncSingletonProxy } from '@tmlmobilidade/utils';
6
+ import { AsyncSingletonProxy, mergeObjects } from '@tmlmobilidade/utils';
7
+ import { roles } from '../auth/roles.js';
7
8
  import { users } from '../auth/users.js';
8
9
  /* * */
9
10
  class NotificationsClass extends MongoCollectionClass {
@@ -22,36 +23,49 @@ class NotificationsClass extends MongoCollectionClass {
22
23
  return NotificationsClass._instance;
23
24
  }
24
25
  async sendNotification(scope, topic, user, id, title, description) {
25
- const usersWithTopic = await users.findMany({ 'permissions.action': topic });
26
+ // Fetch roles and users that have access to this topic
27
+ const rolesWithTopic = await roles.findMany({ 'permissions.action': topic });
28
+ const roleIdsWithTopic = rolesWithTopic.map(r => r._id);
29
+ const usersWithTopic = await users.findMany({
30
+ $or: [
31
+ { 'permissions.action': topic },
32
+ { role_ids: { $in: roleIdsWithTopic } },
33
+ ],
34
+ });
26
35
  if (usersWithTopic.length === 0)
27
36
  return;
28
- const notification = {
37
+ // Base notification template
38
+ const baseNotification = {
29
39
  created_by: user?._id,
30
40
  is_read: false,
31
41
  payload: {
32
42
  body: description,
33
- href: `${getAppConfig(`${scope}`, 'frontend_url')}/${scope}/${id}`,
43
+ href: `${getAppConfig(scope, 'frontend_url')}/${scope}/${id}`,
34
44
  icon: scope,
35
- title: title,
45
+ title,
36
46
  },
37
47
  priority: 'normal',
38
- scope: scope,
39
- topic: topic,
48
+ scope,
49
+ topic,
40
50
  updated_by: user?._id,
41
51
  };
42
- for (const user of usersWithTopic.filter(u => u._id !== notification.created_by)) {
43
- const sendMail = user?.permissions.find(p => p.scope === 'notifications' && p.action === topic)?.resource ?? false;
44
- const newNotification = { ...notification, user_id: user._id };
45
- if (sendMail) {
52
+ // Iterate over eligible users (excluding creator)
53
+ for (const recipient of usersWithTopic.filter(u => u._id !== baseNotification.created_by)) {
54
+ const permissions = this.collectUserPermissions(recipient, rolesWithTopic);
55
+ const canReceiveEmail = this.getNotificationPermission(permissions, topic);
56
+ const newNotification = { ...baseNotification, user_id: recipient._id };
57
+ // Send email if permission allows
58
+ if (canReceiveEmail) {
46
59
  await sendNotificationEmail({
47
60
  props: {
48
- body: notification.payload.body,
49
- href: notification.payload.href || '',
50
- priority: notification.priority,
51
- scope: notification.scope,
52
- title: notification.payload.title,
53
- topic: notification.topic,
54
- }, to: user.email,
61
+ body: baseNotification.payload.body,
62
+ href: baseNotification.payload.href ?? '',
63
+ priority: baseNotification.priority,
64
+ scope: baseNotification.scope,
65
+ title: baseNotification.payload.title,
66
+ topic: baseNotification.topic,
67
+ },
68
+ to: recipient.email,
55
69
  });
56
70
  }
57
71
  await notifications.insertOne(newNotification);
@@ -71,5 +85,29 @@ class NotificationsClass extends MongoCollectionClass {
71
85
  getEnvName() {
72
86
  return 'DATABASE_URI';
73
87
  }
88
+ /**
89
+ * Collects all effective permissions of a user (direct + via roles),
90
+ * merging duplicates by (scope:action).
91
+ */
92
+ collectUserPermissions(user, rolesWithTopic) {
93
+ const rolePermissions = rolesWithTopic
94
+ .filter(role => user.role_ids?.includes(role._id))
95
+ .flatMap(role => role.permissions ?? []);
96
+ const allPermissions = [...(user.permissions ?? []), ...rolePermissions];
97
+ const map = new Map();
98
+ for (const permission of allPermissions) {
99
+ const key = `${permission.scope}:${permission.action}`;
100
+ const existing = map.get(key);
101
+ map.set(key, existing ? mergeObjects(existing, permission) : permission);
102
+ }
103
+ return map;
104
+ }
105
+ /**
106
+ * Determines whether a user can receive email notifications for a topic.
107
+ */
108
+ getNotificationPermission(permissions, topic) {
109
+ const permission = permissions.get(`notifications:${topic}`);
110
+ return permission?.resource?.send_mail ?? false;
111
+ }
74
112
  }
75
113
  export const notifications = AsyncSingletonProxy(NotificationsClass);
@@ -1,8 +1,8 @@
1
+ import { AggregationPipeline } from './aggregation-pipeline.js';
1
2
  import { MongoConnector } from '@tmlmobilidade/connectors';
2
3
  import { type UnixTimestamp } from '@tmlmobilidade/types';
3
4
  import { AggregateOptions, AggregationCursor, Collection, DeleteOptions, DeleteResult, Document, Filter, FindOptions, IndexDescription, InsertManyResult, InsertOneOptions, InsertOneResult, MongoClientOptions, UpdateOptions, UpdateResult, WithId } from 'mongodb';
4
5
  import { z } from 'zod';
5
- import { AggregationPipeline } from './aggregation-pipeline.js';
6
6
  export declare abstract class MongoCollectionClass<T extends Document, TCreate, TUpdate> {
7
7
  protected createSchema: null | z.ZodSchema;
8
8
  protected mongoCollection: Collection<T>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmlmobilidade/interfaces",
3
- "version": "20251007.1740.45-staging.0",
3
+ "version": "20251008.1352.27-staging.0",
4
4
  "author": "João de Vasconcelos & Jusi Monteiro",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "homepage": "https://github.com/tmlmobilidade/services#readme",
@@ -36,8 +36,8 @@
36
36
  "lint:fix": "eslint --fix"
37
37
  },
38
38
  "dependencies": {
39
- "@aws-sdk/client-s3": "3.899.0",
40
- "@aws-sdk/s3-request-presigner": "3.899.0",
39
+ "@aws-sdk/client-s3": "3.901.0",
40
+ "@aws-sdk/s3-request-presigner": "3.901.0",
41
41
  "@tmlmobilidade/connectors": "*",
42
42
  "@tmlmobilidade/emails": "*",
43
43
  "@tmlmobilidade/lib": "*",
@@ -54,7 +54,7 @@
54
54
  "devDependencies": {
55
55
  "@carrismetropolitana/eslint": "20250622.1204.50",
56
56
  "@types/luxon": "3.7.1",
57
- "@types/node": "24.6.1",
57
+ "@types/node": "24.7.0",
58
58
  "resolve-tspaths": "0.8.23",
59
59
  "rimraf": "6.0.1",
60
60
  "typescript": "5.9.3"