@friggframework/core 2.0.0-next.44 → 2.0.0-next.45

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.
@@ -0,0 +1,151 @@
1
+ # Webhook Quick Start Guide
2
+
3
+ Get webhooks working in your Frigg integration in 3 simple steps.
4
+
5
+ ## Step 1: Enable Webhooks
6
+
7
+ Add `webhooks: true` to your Integration Definition:
8
+
9
+ ```javascript
10
+ class MyIntegration extends IntegrationBase {
11
+ static Definition = {
12
+ name: 'my-integration',
13
+ version: '1.0.0',
14
+ modules: {
15
+ myapi: { definition: MyApiDefinition },
16
+ },
17
+ webhooks: true, // ← Add this line
18
+ };
19
+ }
20
+ ```
21
+
22
+ ## Step 2: Handle Webhook Processing
23
+
24
+ Override the `onWebhook` handler to process webhooks:
25
+
26
+ ```javascript
27
+ class MyIntegration extends IntegrationBase {
28
+ // ... Definition ...
29
+
30
+ async onWebhook({ data }) {
31
+ const { body } = data;
32
+
33
+ // You have full access to:
34
+ // - this.myapi (your API modules)
35
+ // - this.config (integration config)
36
+ // - Database operations
37
+
38
+ if (body.event === 'item.created') {
39
+ await this.myapi.api.createItem(body.data);
40
+ }
41
+
42
+ return { processed: true };
43
+ }
44
+ }
45
+ ```
46
+
47
+ ## Step 3: Deploy
48
+
49
+ Deploy your Frigg app - webhook routes are automatically created:
50
+
51
+ ```bash
52
+ POST /api/my-integration-integration/webhooks/:integrationId
53
+ ```
54
+
55
+ ## That's It!
56
+
57
+ The default behavior handles:
58
+ - ✅ Receiving webhooks (instant 200 OK response)
59
+ - ✅ Queuing to SQS
60
+ - ✅ Loading your integration with DB and API modules
61
+ - ✅ Calling your `onWebhook` handler
62
+
63
+ ## Optional: Custom Signature Verification
64
+
65
+ Override `onWebhookReceived` for custom signature checks:
66
+
67
+ ```javascript
68
+ async onWebhookReceived({ req, res }) {
69
+ // Verify signature
70
+ const signature = req.headers['x-webhook-signature'];
71
+ if (!this.verifySignature(req.body, signature)) {
72
+ return res.status(401).json({ error: 'Invalid signature' });
73
+ }
74
+
75
+ // Queue for processing (default behavior)
76
+ await this.queueWebhook({
77
+ integrationId: req.params.integrationId,
78
+ body: req.body,
79
+ });
80
+
81
+ res.status(200).json({ received: true });
82
+ }
83
+ ```
84
+
85
+ ## Two Webhook Routes
86
+
87
+ ### With Integration ID (Recommended)
88
+ ```
89
+ POST /api/{name}-integration/webhooks/:integrationId
90
+ ```
91
+ - Full integration loaded in worker
92
+ - Access to DB, config, and API modules
93
+ - Use `this.myapi`, `this.config`, etc.
94
+
95
+ ### Without Integration ID
96
+ ```
97
+ POST /api/{name}-integration/webhooks
98
+ ```
99
+ - Unhydrated integration
100
+ - Useful for system-wide events
101
+ - Limited context
102
+
103
+ ## Need Help?
104
+
105
+ See full documentation: `packages/core/handlers/WEBHOOKS.md`
106
+
107
+ ## Common Patterns
108
+
109
+ ### Slack
110
+ ```javascript
111
+ async onWebhookReceived({ req, res }) {
112
+ if (req.body.type === 'url_verification') {
113
+ return res.json({ challenge: req.body.challenge });
114
+ }
115
+ // ... verify signature, queue ...
116
+ }
117
+ ```
118
+
119
+ ### Stripe
120
+ ```javascript
121
+ async onWebhookReceived({ req, res }) {
122
+ const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
123
+ const event = stripe.webhooks.constructEvent(
124
+ JSON.stringify(req.body),
125
+ req.headers['stripe-signature'],
126
+ process.env.STRIPE_WEBHOOK_SECRET
127
+ );
128
+ await this.queueWebhook({ body: event });
129
+ res.status(200).json({ received: true });
130
+ }
131
+ ```
132
+
133
+ ### GitHub
134
+ ```javascript
135
+ async onWebhookReceived({ req, res }) {
136
+ const crypto = require('crypto');
137
+ const signature = req.headers['x-hub-signature-256'];
138
+ const hash = crypto
139
+ .createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET)
140
+ .update(JSON.stringify(req.body))
141
+ .digest('hex');
142
+
143
+ if (`sha256=${hash}` !== signature) {
144
+ return res.status(401).json({ error: 'Invalid signature' });
145
+ }
146
+
147
+ await this.queueWebhook({ integrationId: req.params.integrationId, body: req.body });
148
+ res.status(200).json({ received: true });
149
+ }
150
+ ```
151
+
@@ -22,6 +22,8 @@ const constantsToBeMigrated = {
22
22
  GET_USER_ACTIONS: 'GET_USER_ACTIONS',
23
23
  GET_USER_ACTION_OPTIONS: 'GET_USER_ACTION_OPTIONS',
24
24
  REFRESH_USER_ACTION_OPTIONS: 'REFRESH_USER_ACTION_OPTIONS',
25
+ WEBHOOK_RECEIVED: 'WEBHOOK_RECEIVED', // HTTP handler, no DB
26
+ ON_WEBHOOK: 'ON_WEBHOOK', // Queue worker, DB-connected
25
27
  // etc...
26
28
  },
27
29
  types: {
@@ -130,6 +132,14 @@ class IntegrationBase {
130
132
  type: constantsToBeMigrated.types.LIFE_CYCLE_EVENT,
131
133
  handler: this.refreshActionOptions,
132
134
  },
135
+ [constantsToBeMigrated.defaultEvents.WEBHOOK_RECEIVED]: {
136
+ type: constantsToBeMigrated.types.LIFE_CYCLE_EVENT,
137
+ handler: this.onWebhookReceived,
138
+ },
139
+ [constantsToBeMigrated.defaultEvents.ON_WEBHOOK]: {
140
+ type: constantsToBeMigrated.types.LIFE_CYCLE_EVENT,
141
+ handler: this.onWebhook,
142
+ },
133
143
  };
134
144
  }
135
145
 
@@ -294,7 +304,9 @@ class IntegrationBase {
294
304
  await this.updateIntegrationStatus.execute(integrationId, 'ENABLED');
295
305
  }
296
306
 
297
- async onUpdate(params) {}
307
+ async onUpdate(params) {
308
+ return this.validateConfig();
309
+ }
298
310
 
299
311
  async onDelete(params) {}
300
312
 
@@ -357,6 +369,50 @@ class IntegrationBase {
357
369
  return options;
358
370
  }
359
371
 
372
+ /**
373
+ * WEBHOOK EVENT HANDLERS
374
+ */
375
+ async onWebhookReceived({ req, res }) {
376
+ // Default: queue webhook for processing
377
+ const body = req.body;
378
+ const integrationId = req.params.integrationId || null;
379
+
380
+ await this.queueWebhook({
381
+ integrationId,
382
+ body,
383
+ headers: req.headers,
384
+ query: req.query,
385
+ });
386
+
387
+ res.status(200).json({ received: true });
388
+ }
389
+
390
+ async onWebhook({ data }) {
391
+ // Default: no-op, integrations override this
392
+ console.log('Webhook received:', data);
393
+ }
394
+
395
+ async queueWebhook(data) {
396
+ const { QueuerUtil } = require('../queues');
397
+
398
+ const queueName = `${this.constructor.Definition.name
399
+ .toUpperCase()
400
+ .replace(/-/g, '_')}_QUEUE_URL`;
401
+ const queueUrl = process.env[queueName];
402
+
403
+ if (!queueUrl) {
404
+ throw new Error(`Queue URL not found for ${queueName}`);
405
+ }
406
+
407
+ return QueuerUtil.send(
408
+ {
409
+ event: 'ON_WEBHOOK',
410
+ data,
411
+ },
412
+ queueUrl
413
+ );
414
+ }
415
+
360
416
  // === Domain Methods (moved from Integration.js) ===
361
417
 
362
418
  getConfig() {
@@ -403,8 +459,14 @@ class IntegrationBase {
403
459
  return this.userId.toString() === userId.toString();
404
460
  }
405
461
 
462
+ registerEventHandlers() {
463
+ this.on = {
464
+ ...this.defaultEvents,
465
+ ...this.events,
466
+ };
467
+ }
468
+
406
469
  async initialize() {
407
- // Load dynamic user actions
408
470
  try {
409
471
  const additionalUserActions = await this.loadDynamicUserActions();
410
472
  this.events = { ...this.events, ...additionalUserActions };
@@ -412,7 +474,16 @@ class IntegrationBase {
412
474
  this.addError(e);
413
475
  }
414
476
 
415
- // Event handlers are no longer registered here - handled by IntegrationEventDispatcher
477
+ this.registerEventHandlers();
478
+ }
479
+
480
+ async send(event, object) {
481
+ if (!this.on[event]) {
482
+ throw new Error(
483
+ `Event ${event} is not defined in the Integration event object`
484
+ );
485
+ }
486
+ return this.on[event].handler.call(this, object);
416
487
  }
417
488
 
418
489
  getOptionDetails() {
@@ -1,5 +1,7 @@
1
1
  const { prisma } = require('../../database/prisma');
2
- const { ProcessRepositoryInterface } = require('./process-repository-interface');
2
+ const {
3
+ ProcessRepositoryInterface,
4
+ } = require('./process-repository-interface');
3
5
 
4
6
  /**
5
7
  * PostgreSQL Process Repository Adapter
@@ -22,6 +24,22 @@ class ProcessRepositoryPostgres extends ProcessRepositoryInterface {
22
24
  this.prisma = prisma;
23
25
  }
24
26
 
27
+ /**
28
+ * Convert string ID to integer for PostgreSQL queries
29
+ * @private
30
+ * @param {string|number|null|undefined} id - ID to convert
31
+ * @returns {number|null|undefined} Integer ID or null/undefined
32
+ * @throws {Error} If ID cannot be converted to integer
33
+ */
34
+ _convertId(id) {
35
+ if (id === null || id === undefined) return id;
36
+ const parsed = parseInt(id, 10);
37
+ if (isNaN(parsed)) {
38
+ throw new Error(`Invalid ID: ${id} cannot be converted to integer`);
39
+ }
40
+ return parsed;
41
+ }
42
+
25
43
  /**
26
44
  * Create a new process record
27
45
  * @param {Object} processData - Process data to create
@@ -30,15 +48,14 @@ class ProcessRepositoryPostgres extends ProcessRepositoryInterface {
30
48
  async create(processData) {
31
49
  const process = await this.prisma.process.create({
32
50
  data: {
33
- userId: processData.userId,
34
- integrationId: processData.integrationId,
51
+ userId: this._convertId(processData.userId),
52
+ integrationId: this._convertId(processData.integrationId),
35
53
  name: processData.name,
36
54
  type: processData.type,
37
55
  state: processData.state || 'INITIALIZING',
38
56
  context: processData.context || {},
39
57
  results: processData.results || {},
40
- childProcesses: processData.childProcesses || [],
41
- parentProcessId: processData.parentProcessId || null,
58
+ parentProcessId: this._convertId(processData.parentProcessId),
42
59
  },
43
60
  });
44
61
 
@@ -52,7 +69,7 @@ class ProcessRepositoryPostgres extends ProcessRepositoryInterface {
52
69
  */
53
70
  async findById(processId) {
54
71
  const process = await this.prisma.process.findUnique({
55
- where: { id: processId },
72
+ where: { id: this._convertId(processId) },
56
73
  });
57
74
 
58
75
  return process ? this._toPlainObject(process) : null;
@@ -77,15 +94,14 @@ class ProcessRepositoryPostgres extends ProcessRepositoryInterface {
77
94
  if (updates.results !== undefined) {
78
95
  updateData.results = updates.results;
79
96
  }
80
- if (updates.childProcesses !== undefined) {
81
- updateData.childProcesses = updates.childProcesses;
82
- }
83
97
  if (updates.parentProcessId !== undefined) {
84
- updateData.parentProcessId = updates.parentProcessId;
98
+ updateData.parentProcessId = this._convertId(
99
+ updates.parentProcessId
100
+ );
85
101
  }
86
102
 
87
103
  const process = await this.prisma.process.update({
88
- where: { id: processId },
104
+ where: { id: this._convertId(processId) },
89
105
  data: updateData,
90
106
  });
91
107
 
@@ -101,7 +117,7 @@ class ProcessRepositoryPostgres extends ProcessRepositoryInterface {
101
117
  async findByIntegrationAndType(integrationId, type) {
102
118
  const processes = await this.prisma.process.findMany({
103
119
  where: {
104
- integrationId,
120
+ integrationId: this._convertId(integrationId),
105
121
  type,
106
122
  },
107
123
  orderBy: {
@@ -118,10 +134,13 @@ class ProcessRepositoryPostgres extends ProcessRepositoryInterface {
118
134
  * @param {string[]} [excludeStates=['COMPLETED', 'ERROR']] - States to exclude
119
135
  * @returns {Promise<Array>} Array of active process records
120
136
  */
121
- async findActiveProcesses(integrationId, excludeStates = ['COMPLETED', 'ERROR']) {
137
+ async findActiveProcesses(
138
+ integrationId,
139
+ excludeStates = ['COMPLETED', 'ERROR']
140
+ ) {
122
141
  const processes = await this.prisma.process.findMany({
123
142
  where: {
124
- integrationId,
143
+ integrationId: this._convertId(integrationId),
125
144
  state: {
126
145
  notIn: excludeStates,
127
146
  },
@@ -157,7 +176,7 @@ class ProcessRepositoryPostgres extends ProcessRepositoryInterface {
157
176
  */
158
177
  async deleteById(processId) {
159
178
  await this.prisma.process.delete({
160
- where: { id: processId },
179
+ where: { id: this._convertId(processId) },
161
180
  });
162
181
  }
163
182
 
@@ -179,11 +198,16 @@ class ProcessRepositoryPostgres extends ProcessRepositoryInterface {
179
198
  context: process.context,
180
199
  results: process.results,
181
200
  childProcesses: Array.isArray(process.childProcesses)
182
- ? (process.childProcesses.length > 0 && typeof process.childProcesses[0] === 'object' && process.childProcesses[0] !== null
183
- ? process.childProcesses.map(child => String(child.id))
184
- : process.childProcesses)
201
+ ? process.childProcesses.length > 0 &&
202
+ typeof process.childProcesses[0] === 'object' &&
203
+ process.childProcesses[0] !== null
204
+ ? process.childProcesses.map((child) => String(child.id))
205
+ : process.childProcesses
185
206
  : [],
186
- parentProcessId: process.parentProcessId !== null ? String(process.parentProcessId) : null,
207
+ parentProcessId:
208
+ process.parentProcessId !== null
209
+ ? String(process.parentProcessId)
210
+ : null,
187
211
  createdAt: process.createdAt,
188
212
  updatedAt: process.updatedAt,
189
213
  };
@@ -191,4 +215,3 @@ class ProcessRepositoryPostgres extends ProcessRepositoryInterface {
191
215
  }
192
216
 
193
217
  module.exports = { ProcessRepositoryPostgres };
194
-
@@ -47,23 +47,16 @@ class DummyIntegration extends IntegrationBase {
47
47
  this.updateIntegrationMessages = {
48
48
  execute: jest.fn().mockResolvedValue({})
49
49
  };
50
-
51
- this.registerEventHandlers();
52
50
  }
53
51
 
54
52
  async loadDynamicUserActions() {
55
53
  return {};
56
54
  }
57
55
 
58
- async registerEventHandlers() {
59
- super.registerEventHandlers();
60
- return;
61
- }
62
-
63
56
  async send(event, data) {
64
57
  this.sendSpy(event, data);
65
58
  this.eventCallHistory.push({ event, data, timestamp: Date.now() });
66
- return super.send(event, data);
59
+ return { event, data };
67
60
  }
68
61
 
69
62
  async initialize() {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/core",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0-next.44",
4
+ "version": "2.0.0-next.45",
5
5
  "dependencies": {
6
6
  "@hapi/boom": "^10.0.1",
7
7
  "@prisma/client": "^6.16.3",
@@ -23,9 +23,9 @@
23
23
  "uuid": "^9.0.1"
24
24
  },
25
25
  "devDependencies": {
26
- "@friggframework/eslint-config": "2.0.0-next.44",
27
- "@friggframework/prettier-config": "2.0.0-next.44",
28
- "@friggframework/test": "2.0.0-next.44",
26
+ "@friggframework/eslint-config": "2.0.0-next.45",
27
+ "@friggframework/prettier-config": "2.0.0-next.45",
28
+ "@friggframework/test": "2.0.0-next.45",
29
29
  "@types/lodash": "4.17.15",
30
30
  "@typescript-eslint/eslint-plugin": "^8.0.0",
31
31
  "chai": "^4.3.6",
@@ -64,5 +64,5 @@
64
64
  "publishConfig": {
65
65
  "access": "public"
66
66
  },
67
- "gitHead": "87c8479d50810d28eb7dc73c347e0117d872273a"
67
+ "gitHead": "996a15bcdfaa4252b9891a33f1b1c84548d66bbc"
68
68
  }
@@ -14,6 +14,16 @@ AWS.config.update(awsConfigOptions());
14
14
  const sqs = new AWS.SQS();
15
15
 
16
16
  const QueuerUtil = {
17
+ send: async (message, queueUrl) => {
18
+ console.log(`Enqueuing message to SQS queue ${queueUrl}`);
19
+ return sqs
20
+ .sendMessage({
21
+ MessageBody: JSON.stringify(message),
22
+ QueueUrl: queueUrl,
23
+ })
24
+ .promise();
25
+ },
26
+
17
27
  batchSend: async (entries = [], queueUrl) => {
18
28
  console.log(
19
29
  `Enqueuing ${entries.length} entries on SQS to queue ${queueUrl}`
@@ -1,4 +1,4 @@
1
- //todo: this repository is tightly coupled to the token repository.
1
+ const bcrypt = require('bcryptjs');
2
2
  const { prisma } = require('../../database/prisma');
3
3
  const {
4
4
  createTokenRepository,
@@ -95,19 +95,38 @@ class UserRepositoryMongo extends UserRepositoryInterface {
95
95
  * Replaces: IndividualUser.create(params)
96
96
  *
97
97
  * @param {Object} params - User creation parameters
98
+ * @param {string} [params.hashword] - Plain text password (will be bcrypt hashed automatically)
98
99
  * @returns {Promise<Object>} Created user object with string IDs
99
100
  */
100
101
  async createIndividualUser(params) {
101
- return await this.prisma.user.create({
102
- data: {
103
- type: 'INDIVIDUAL',
104
- email: params.email,
105
- username: params.username,
106
- hashword: params.hashword,
107
- appUserId: params.appUserId,
108
- organizationId: params.organization || params.organizationId,
109
- },
110
- });
102
+ const data = {
103
+ type: 'INDIVIDUAL',
104
+ email: params.email,
105
+ username: params.username,
106
+ appUserId: params.appUserId,
107
+ organizationId: params.organization || params.organizationId,
108
+ };
109
+
110
+ if (
111
+ params.hashword !== undefined &&
112
+ params.hashword !== null &&
113
+ params.hashword !== ''
114
+ ) {
115
+ if (typeof params.hashword !== 'string') {
116
+ throw new Error('Password must be a string');
117
+ }
118
+
119
+ // Prevent double-hashing: bcrypt hashes start with $2a$ or $2b$
120
+ if (params.hashword.startsWith('$2')) {
121
+ throw new Error(
122
+ 'Password appears to be already hashed. Pass plain text password only.'
123
+ );
124
+ }
125
+
126
+ data.hashword = await bcrypt.hash(params.hashword, 10);
127
+ }
128
+
129
+ return await this.prisma.user.create({ data });
111
130
  }
112
131
 
113
132
  /**
@@ -204,12 +223,34 @@ class UserRepositoryMongo extends UserRepositoryInterface {
204
223
  * Update individual user
205
224
  * @param {string} userId - User ID
206
225
  * @param {Object} updates - Fields to update
226
+ * @param {string} [updates.hashword] - Plain text password (will be bcrypt hashed automatically)
207
227
  * @returns {Promise<Object>} Updated user object with string IDs
208
228
  */
209
229
  async updateIndividualUser(userId, updates) {
230
+ const data = { ...updates };
231
+
232
+ if (
233
+ data.hashword !== undefined &&
234
+ data.hashword !== null &&
235
+ data.hashword !== ''
236
+ ) {
237
+ if (typeof data.hashword !== 'string') {
238
+ throw new Error('Password must be a string');
239
+ }
240
+
241
+ // Prevent double-hashing: bcrypt hashes start with $2a$ or $2b$
242
+ if (data.hashword.startsWith('$2')) {
243
+ throw new Error(
244
+ 'Password appears to be already hashed. Pass plain text password only.'
245
+ );
246
+ }
247
+
248
+ data.hashword = await bcrypt.hash(data.hashword, 10);
249
+ }
250
+
210
251
  return await this.prisma.user.update({
211
252
  where: { id: userId },
212
- data: updates,
253
+ data,
213
254
  });
214
255
  }
215
256
 
@@ -1,4 +1,4 @@
1
- //todo: this repository is tightly coupled to the token repository.
1
+ const bcrypt = require('bcryptjs');
2
2
  const { prisma } = require('../../database/prisma');
3
3
  const {
4
4
  createTokenRepository,
@@ -130,21 +130,40 @@ class UserRepositoryPostgres extends UserRepositoryInterface {
130
130
  * Replaces: IndividualUser.create(params)
131
131
  *
132
132
  * @param {Object} params - User creation parameters (with string IDs from application layer)
133
+ * @param {string} [params.hashword] - Plain text password (will be bcrypt hashed automatically)
133
134
  * @returns {Promise<Object>} Created user object with string IDs
134
135
  */
135
136
  async createIndividualUser(params) {
136
- const user = await this.prisma.user.create({
137
- data: {
138
- type: 'INDIVIDUAL',
139
- email: params.email,
140
- username: params.username,
141
- hashword: params.hashword,
142
- appUserId: params.appUserId,
143
- organizationId: this._convertId(
144
- params.organization || params.organizationId
145
- ),
146
- },
147
- });
137
+ const data = {
138
+ type: 'INDIVIDUAL',
139
+ email: params.email,
140
+ username: params.username,
141
+ appUserId: params.appUserId,
142
+ organizationId: this._convertId(
143
+ params.organization || params.organizationId
144
+ ),
145
+ };
146
+
147
+ if (
148
+ params.hashword !== undefined &&
149
+ params.hashword !== null &&
150
+ params.hashword !== ''
151
+ ) {
152
+ if (typeof params.hashword !== 'string') {
153
+ throw new Error('Password must be a string');
154
+ }
155
+
156
+ // Prevent double-hashing: bcrypt hashes start with $2a$ or $2b$
157
+ if (params.hashword.startsWith('$2')) {
158
+ throw new Error(
159
+ 'Password appears to be already hashed. Pass plain text password only.'
160
+ );
161
+ }
162
+
163
+ data.hashword = await bcrypt.hash(params.hashword, 10);
164
+ }
165
+
166
+ const user = await this.prisma.user.create({ data });
148
167
  return this._convertUserIds(user);
149
168
  }
150
169
 
@@ -249,13 +268,14 @@ class UserRepositoryPostgres extends UserRepositoryInterface {
249
268
  * Update individual user
250
269
  * @param {string} userId - User ID (string from application layer)
251
270
  * @param {Object} updates - Fields to update (with string IDs from application layer)
271
+ * @param {string} [updates.hashword] - Plain text password (will be bcrypt hashed automatically)
252
272
  * @returns {Promise<Object>} Updated user object with string IDs
253
273
  */
254
274
  async updateIndividualUser(userId, updates) {
255
275
  const intId = this._convertId(userId);
256
276
 
257
- // Convert organizationId if present in updates
258
277
  const data = { ...updates };
278
+
259
279
  if (data.organizationId !== undefined) {
260
280
  data.organizationId = this._convertId(data.organizationId);
261
281
  }
@@ -264,6 +284,25 @@ class UserRepositoryPostgres extends UserRepositoryInterface {
264
284
  delete data.organization;
265
285
  }
266
286
 
287
+ if (
288
+ data.hashword !== undefined &&
289
+ data.hashword !== null &&
290
+ data.hashword !== ''
291
+ ) {
292
+ if (typeof data.hashword !== 'string') {
293
+ throw new Error('Password must be a string');
294
+ }
295
+
296
+ // Prevent double-hashing: bcrypt hashes start with $2a$ or $2b$
297
+ if (data.hashword.startsWith('$2')) {
298
+ throw new Error(
299
+ 'Password appears to be already hashed. Pass plain text password only.'
300
+ );
301
+ }
302
+
303
+ data.hashword = await bcrypt.hash(data.hashword, 10);
304
+ }
305
+
267
306
  const user = await this.prisma.user.update({
268
307
  where: { id: intId },
269
308
  data,