@facteurjs/core 2.0.0-beta.1 → 2.0.0-beta.2

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.
@@ -41,12 +41,18 @@ const updatePreferencesRoute = defineRoute(({ facteur, authorize }) => ({
41
41
  const notifiableId = request.params.notifiableId;
42
42
  const tenantId = request.body.tenantId;
43
43
  const preferences = request.body.preferences;
44
+ const notificationName = request.body.notificationName;
45
+ const category = request.body.category;
44
46
  if (!await checkAuthorization({
45
47
  authorize,
46
48
  request,
47
49
  notifiableId,
48
50
  tenantId
49
51
  })) return UNAUTHORIZED_RESPONSE;
52
+ if (notificationName && category) return {
53
+ status: 400,
54
+ body: { error: "Cannot specify both \"notificationName\" and \"category\"" }
55
+ };
50
56
  if (!preferences) return {
51
57
  status: 400,
52
58
  body: { error: "Preferences are required" }
@@ -56,10 +62,33 @@ const updatePreferencesRoute = defineRoute(({ facteur, authorize }) => ({
56
62
  status: 400,
57
63
  body: { error: validation.error }
58
64
  };
65
+ /**
66
+ * Per-category scope: resolve notifications in category and update each one
67
+ */
68
+ if (category) {
69
+ const matching = (await facteur.discoverer.getNotificationIdentities()).filter((n) => n.category === category);
70
+ if (matching.length === 0) return {
71
+ status: 400,
72
+ body: { error: `No notifications found for category "${category}"` }
73
+ };
74
+ for (const identity of matching) await facteur.db.updatePreferences({
75
+ notifiableId,
76
+ tenantId,
77
+ notificationName: identity.identifier,
78
+ channelPreferences: preferences
79
+ });
80
+ return {
81
+ status: 204,
82
+ body: {}
83
+ };
84
+ }
85
+ /**
86
+ * Global scope (no notificationName) or per-notification scope
87
+ */
59
88
  await facteur.db.updatePreferences({
60
89
  notifiableId,
61
90
  tenantId,
62
- notificationName: request.body.notificationName,
91
+ notificationName,
63
92
  channelPreferences: preferences
64
93
  });
65
94
  return {
@@ -2,6 +2,7 @@ import { BatchConfig, BatchSendResult, Channel, ChannelSendParams, kTargetSymbol
2
2
  import "../../types.mjs";
3
3
  import { FcmMessage } from "./message.mjs";
4
4
  import { FcmConfig, FcmTargets } from "./types.mjs";
5
+ import { Messaging } from "firebase-admin/messaging";
5
6
  import { Awaitable } from "@julr/utils/types";
6
7
 
7
8
  //#region src/channels/fcm/channel.d.ts
@@ -11,7 +12,7 @@ declare class FcmChannel implements Channel<FcmConfig, FcmMessage, any, FcmTarge
11
12
  name: "fcm";
12
13
  [kTargetSymbol]: FcmTargets;
13
14
  batchConfig: BatchConfig;
14
- constructor(config: FcmConfig);
15
+ constructor(config: FcmConfig, messaging?: Messaging);
15
16
  send(options: ChannelSendParams<FcmMessage, FcmTargets>): Promise<string>;
16
17
  sendBatch(messages: ChannelSendParams<FcmMessage, FcmTargets>[]): Promise<BatchSendResult>;
17
18
  }
@@ -1,6 +1,6 @@
1
1
  import { errors } from "../../errors/index.mjs";
2
2
  import { kTargetSymbol } from "../../types/channel.mjs";
3
- import { getMessaging } from "firebase-admin/messaging";
3
+ import { Messaging, getMessaging } from "firebase-admin/messaging";
4
4
  import { cert, initializeApp } from "firebase-admin/app";
5
5
 
6
6
  //#region src/channels/fcm/channel.ts
@@ -16,9 +16,10 @@ var FcmChannel = class {
16
16
  };
17
17
  #messaging;
18
18
  #config;
19
- constructor(config) {
19
+ constructor(config, messaging) {
20
20
  this.#config = config;
21
- this.#messaging = getMessaging(initializeApp({
21
+ if (messaging) this.#messaging = messaging;
22
+ else this.#messaging = getMessaging(initializeApp({
22
23
  ...config,
23
24
  ...config.serviceAccountKeyPath ? { credential: cert(config.serviceAccountKeyPath) } : {}
24
25
  }));
@@ -35,8 +36,11 @@ var FcmChannel = class {
35
36
  #buildMessage(options) {
36
37
  const message = options.message.serialize();
37
38
  const targets = this.#resolveTargets(options);
38
- if (this.#config.debugToken) message.token = this.#config.debugToken;
39
- if (targets.token) message.token = this.#config.debugToken || targets.token;
39
+ if (this.#config.debugToken) {
40
+ message.token = this.#config.debugToken;
41
+ return message;
42
+ }
43
+ if (targets.token) message.token = targets.token;
40
44
  else if (targets.topic) message.topic = targets.topic;
41
45
  else if (targets.condition) message.condition = targets.condition;
42
46
  return message;
@@ -97,7 +97,10 @@ var KnexAdapter = class {
97
97
  }
98
98
  async updatePreferences(options) {
99
99
  await this.#getConnection().transaction(async (trx) => {
100
- const existing = await trx.table(this.#preferencesTableName).where("user_id", options.notifiableId).andWhere("notification_name", options.notificationName).andWhere((builder) => {
100
+ const existing = await trx.table(this.#preferencesTableName).where("user_id", options.notifiableId).andWhere((builder) => {
101
+ if (options.notificationName) builder.where("notification_name", options.notificationName);
102
+ else builder.whereNull("notification_name");
103
+ }).andWhere((builder) => {
101
104
  if (options.tenantId) builder.where("tenant_id", options.tenantId);
102
105
  else builder.whereNull("tenant_id");
103
106
  }).first();
@@ -108,7 +111,7 @@ var KnexAdapter = class {
108
111
  else await trx.table(this.#preferencesTableName).insert({
109
112
  user_id: options.notifiableId,
110
113
  tenant_id: options.tenantId || null,
111
- notification_name: options.notificationName,
114
+ notification_name: options.notificationName || null,
112
115
  channels: JSON.stringify(options.channelPreferences),
113
116
  created_at: /* @__PURE__ */ new Date(),
114
117
  updated_at: /* @__PURE__ */ new Date()
@@ -78,7 +78,7 @@ var KyselyAdapter = class {
78
78
  }
79
79
  async updatePreferences(options) {
80
80
  await this.#connection.transaction().execute(async (trx) => {
81
- const existing = await trx.selectFrom(this.#preferencesTableName).selectAll().where("user_id", "=", options.notifiableId).where("notification_name", "=", options.notificationName).$if(!!options.tenantId, (qb) => qb.where("tenant_id", "=", options.tenantId)).$if(!options.tenantId, (qb) => qb.where("tenant_id", "is", null)).executeTakeFirst();
81
+ const existing = await trx.selectFrom(this.#preferencesTableName).selectAll().where("user_id", "=", options.notifiableId).$if(!!options.notificationName, (qb) => qb.where("notification_name", "=", options.notificationName)).$if(!options.notificationName, (qb) => qb.where("notification_name", "is", null)).$if(!!options.tenantId, (qb) => qb.where("tenant_id", "=", options.tenantId)).$if(!options.tenantId, (qb) => qb.where("tenant_id", "is", null)).executeTakeFirst();
82
82
  if (existing) await trx.updateTable(this.#preferencesTableName).set({
83
83
  channels: JSON.stringify(options.channelPreferences),
84
84
  updated_at: /* @__PURE__ */ new Date()
@@ -86,7 +86,7 @@ var KyselyAdapter = class {
86
86
  else await trx.insertInto(this.#preferencesTableName).values({
87
87
  user_id: options.notifiableId,
88
88
  tenant_id: options.tenantId || null,
89
- notification_name: options.notificationName,
89
+ notification_name: options.notificationName || null,
90
90
  channels: JSON.stringify(options.channelPreferences),
91
91
  created_at: /* @__PURE__ */ new Date(),
92
92
  updated_at: /* @__PURE__ */ new Date()
@@ -131,7 +131,7 @@ interface SavePreferencesParams {
131
131
  interface UpdatePreferencesParams {
132
132
  notifiableId: Identifier;
133
133
  tenantId?: Identifier;
134
- notificationName: string;
134
+ notificationName?: string;
135
135
  channelPreferences: Record<ChannelName, boolean>;
136
136
  }
137
137
  /**
@@ -22,6 +22,12 @@ declare class Facteur<KnownChannels extends Record<string, Channel>, DBAdapter e
22
22
  get discoverer(): {
23
23
  discoverNotifications: () => Promise<(new (...args: any[]) => Notification)[]>;
24
24
  getNotifications: () => Promise<(new (...args: any[]) => Notification)[]>;
25
+ getNotificationIdentities: () => Promise<{
26
+ name: string;
27
+ identifier: string;
28
+ tags?: string[];
29
+ category?: string;
30
+ }[]>;
25
31
  getNotificationTags: () => Promise<string[]>;
26
32
  clearCache: () => void;
27
33
  };
package/dist/facteur.mjs CHANGED
@@ -138,6 +138,7 @@ var Facteur = class {
138
138
  return {
139
139
  discoverNotifications: () => this.#discoverer.discoverNotifications(),
140
140
  getNotifications: () => this.#discoverer.getNotifications(),
141
+ getNotificationIdentities: () => this.#discoverer.getNotificationIdentities(),
141
142
  getNotificationTags: () => this.#discoverer.getAllNotificationTags(),
142
143
  clearCache: () => this.#discoverer.clearCache()
143
144
  };
@@ -146,7 +147,7 @@ var Facteur = class {
146
147
  * Enable fake mode for testing - captures sent notifications instead of sending
147
148
  */
148
149
  fake() {
149
- this.#fake = new FacteurFake();
150
+ this.#fake = new FacteurFake(() => this.restore());
150
151
  return this.#fake;
151
152
  }
152
153
  /**
package/dist/fake.d.mts CHANGED
@@ -11,6 +11,8 @@ interface SentNotification<N extends Notification = Notification> {
11
11
  }
12
12
  declare class FacteurFake {
13
13
  #private;
14
+ constructor(restoreFn: () => void);
15
+ [Symbol.dispose](): void;
14
16
  /**
15
17
  * Record a notification as sent during fake mode
16
18
  */
package/dist/fake.mjs CHANGED
@@ -3,6 +3,13 @@ import { AssertionError } from "node:assert";
3
3
  //#region src/fake.ts
4
4
  var FacteurFake = class {
5
5
  #sentNotifications = [];
6
+ #restoreFn;
7
+ constructor(restoreFn) {
8
+ this.#restoreFn = restoreFn;
9
+ }
10
+ [Symbol.dispose]() {
11
+ this.#restoreFn();
12
+ }
6
13
  /**
7
14
  * Record a notification as sent during fake mode
8
15
  */
@@ -90,7 +90,7 @@ var NotificationDiscoverer = class {
90
90
  const options = NotificationClass.options || {};
91
91
  return {
92
92
  name: options.name,
93
- identifier: NotificationClass.name || options.identifier,
93
+ identifier: options.identifier || NotificationClass.name,
94
94
  tags: options.tags || [],
95
95
  category: options.category
96
96
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@facteurjs/core",
3
- "version": "2.0.0-beta.1",
3
+ "version": "2.0.0-beta.2",
4
4
  "description": "Framework-agnostic notification system for Node.js with support for multiple channels (email, SMS, push, webhooks, etc.)",
5
5
  "keywords": [
6
6
  "discord",