@porulle/core 0.41.0 → 0.43.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.
@@ -1,4 +1,4 @@
1
- import { eq, desc, and, sql } from "drizzle-orm";
1
+ import { eq, desc, and, sql, exists } from "drizzle-orm";
2
2
  import type { TxContext } from "../../../kernel/database/tx-context.js";
3
3
  import type {
4
4
  DrizzleDatabase,
@@ -9,6 +9,7 @@ import {
9
9
  fulfillmentLineItems,
10
10
  fulfillmentEvents,
11
11
  } from "../schema.js";
12
+ import { orders } from "../../orders/schema.js";
12
13
 
13
14
  // Infer types from Drizzle schema
14
15
  export type FulfillmentRecord = typeof fulfillmentRecords.$inferSelect;
@@ -38,29 +39,45 @@ export class FulfillmentRepository {
38
39
  // ─────────────────────────────────────────────────────────────────────────────
39
40
 
40
41
  async findById(
42
+ orgId: string,
41
43
  id: string,
42
44
  ctx?: TxContext,
43
45
  ): Promise<FulfillmentRecord | undefined> {
44
46
  const db = this.getDb(ctx);
45
47
  const rows = await db
46
- .select()
48
+ .select({ fulfillment: fulfillmentRecords })
47
49
  .from(fulfillmentRecords)
48
- .where(eq(fulfillmentRecords.id, id));
49
- return rows[0];
50
+ .innerJoin(orders, eq(fulfillmentRecords.orderId, orders.id))
51
+ .where(
52
+ and(
53
+ eq(fulfillmentRecords.id, id),
54
+ eq(orders.organizationId, orgId),
55
+ ),
56
+ );
57
+ return rows[0]?.fulfillment;
50
58
  }
51
59
 
52
60
  async findByOrderId(
61
+ orgId: string,
53
62
  orderId: string,
54
63
  ctx?: TxContext,
55
64
  ): Promise<FulfillmentRecord[]> {
56
65
  const db = this.getDb(ctx);
57
- return db
58
- .select()
66
+ const rows = await db
67
+ .select({ fulfillment: fulfillmentRecords })
59
68
  .from(fulfillmentRecords)
60
- .where(eq(fulfillmentRecords.orderId, orderId))
69
+ .innerJoin(orders, eq(fulfillmentRecords.orderId, orders.id))
70
+ .where(
71
+ and(
72
+ eq(fulfillmentRecords.orderId, orderId),
73
+ eq(orders.organizationId, orgId),
74
+ ),
75
+ )
61
76
  .orderBy(desc(fulfillmentRecords.createdAt));
77
+ return rows.map((row) => row.fulfillment);
62
78
  }
63
79
 
80
+ // not tenant-scoped; do not use for tenant-facing access
64
81
  async findByCustomerId(
65
82
  customerId: string,
66
83
  ctx?: TxContext,
@@ -73,6 +90,7 @@ export class FulfillmentRepository {
73
90
  .orderBy(desc(fulfillmentRecords.createdAt));
74
91
  }
75
92
 
93
+ // not tenant-scoped; do not use for tenant-facing access
76
94
  async findByStatus(
77
95
  status: string,
78
96
  ctx?: TxContext,
@@ -85,6 +103,7 @@ export class FulfillmentRepository {
85
103
  .orderBy(desc(fulfillmentRecords.createdAt));
86
104
  }
87
105
 
106
+ // not tenant-scoped; do not use for tenant-facing access
88
107
  async findByType(
89
108
  type: string,
90
109
  ctx?: TxContext,
@@ -97,6 +116,7 @@ export class FulfillmentRepository {
97
116
  .orderBy(desc(fulfillmentRecords.createdAt));
98
117
  }
99
118
 
119
+ // not tenant-scoped; do not use for tenant-facing access
100
120
  async findByOrderIdAndStatus(
101
121
  orderId: string,
102
122
  status: string,
@@ -115,6 +135,7 @@ export class FulfillmentRepository {
115
135
  .orderBy(desc(fulfillmentRecords.createdAt));
116
136
  }
117
137
 
138
+ // not tenant-scoped; do not use for tenant-facing access
118
139
  async findActiveAccessGrants(
119
140
  customerId: string,
120
141
  ctx?: TxContext,
@@ -133,6 +154,7 @@ export class FulfillmentRepository {
133
154
  .orderBy(desc(fulfillmentRecords.grantedAt));
134
155
  }
135
156
 
157
+ // not tenant-scoped; do not use for tenant-facing access
136
158
  async findAll(
137
159
  options?: { limit?: number; offset?: number },
138
160
  ctx?: TxContext,
@@ -163,6 +185,7 @@ export class FulfillmentRepository {
163
185
  }
164
186
 
165
187
  async update(
188
+ orgId: string,
166
189
  id: string,
167
190
  data: Partial<Omit<FulfillmentRecordInsert, "id">>,
168
191
  ctx?: TxContext,
@@ -171,12 +194,28 @@ export class FulfillmentRepository {
171
194
  const rows = await db
172
195
  .update(fulfillmentRecords)
173
196
  .set({ ...data, updatedAt: new Date() })
174
- .where(eq(fulfillmentRecords.id, id))
197
+ .where(
198
+ and(
199
+ eq(fulfillmentRecords.id, id),
200
+ exists(
201
+ db
202
+ .select({ id: orders.id })
203
+ .from(orders)
204
+ .where(
205
+ and(
206
+ eq(orders.id, fulfillmentRecords.orderId),
207
+ eq(orders.organizationId, orgId),
208
+ ),
209
+ ),
210
+ ),
211
+ ),
212
+ )
175
213
  .returning();
176
214
  return rows[0];
177
215
  }
178
216
 
179
217
  async updateStatus(
218
+ orgId: string,
180
219
  id: string,
181
220
  status: string,
182
221
  ctx?: TxContext,
@@ -190,9 +229,11 @@ export class FulfillmentRepository {
190
229
  data.deliveredAt = new Date();
191
230
  }
192
231
 
193
- return this.update(id, data, ctx);
232
+ return this.update(orgId, id, data, ctx);
194
233
  }
195
234
 
235
+ // not tenant-scoped; do not use for tenant-facing access — this is an unscoped WRITE and
236
+ // has no caller today; it needs the same orgId predicate as `update` before it gains one
196
237
  async delete(id: string, ctx?: TxContext): Promise<boolean> {
197
238
  const db = this.getDb(ctx);
198
239
  const result = await db
@@ -207,6 +248,7 @@ export class FulfillmentRepository {
207
248
  // ─────────────────────────────────────────────────────────────────────────────
208
249
 
209
250
  async incrementDownloadCount(
251
+ orgId: string,
210
252
  id: string,
211
253
  ctx?: TxContext,
212
254
  ): Promise<FulfillmentRecord | undefined> {
@@ -217,13 +259,32 @@ export class FulfillmentRepository {
217
259
  downloadCount: sql`${fulfillmentRecords.downloadCount} + 1`,
218
260
  updatedAt: new Date(),
219
261
  })
220
- .where(eq(fulfillmentRecords.id, id))
262
+ .where(
263
+ and(
264
+ eq(fulfillmentRecords.id, id),
265
+ exists(
266
+ db
267
+ .select({ id: orders.id })
268
+ .from(orders)
269
+ .where(
270
+ and(
271
+ eq(orders.id, fulfillmentRecords.orderId),
272
+ eq(orders.organizationId, orgId),
273
+ ),
274
+ ),
275
+ ),
276
+ ),
277
+ )
221
278
  .returning();
222
279
  return rows[0];
223
280
  }
224
281
 
225
- async isDownloadAllowed(id: string, ctx?: TxContext): Promise<boolean> {
226
- const fulfillment = await this.findById(id, ctx);
282
+ async isDownloadAllowed(
283
+ orgId: string,
284
+ id: string,
285
+ ctx?: TxContext,
286
+ ): Promise<boolean> {
287
+ const fulfillment = await this.findById(orgId, id, ctx);
227
288
  if (!fulfillment || fulfillment.type !== "digital") return false;
228
289
 
229
290
  // Check expiration
@@ -250,21 +311,27 @@ export class FulfillmentRepository {
250
311
  // ─────────────────────────────────────────────────────────────────────────────
251
312
 
252
313
  async deactivateAccessGrant(
314
+ orgId: string,
253
315
  id: string,
254
316
  ctx?: TxContext,
255
317
  ): Promise<FulfillmentRecord | undefined> {
256
- return this.update(id, { isActive: false }, ctx);
318
+ return this.update(orgId, id, { isActive: false }, ctx);
257
319
  }
258
320
 
259
321
  async activateAccessGrant(
322
+ orgId: string,
260
323
  id: string,
261
324
  ctx?: TxContext,
262
325
  ): Promise<FulfillmentRecord | undefined> {
263
- return this.update(id, { isActive: true, grantedAt: new Date() }, ctx);
326
+ return this.update(orgId, id, { isActive: true, grantedAt: new Date() }, ctx);
264
327
  }
265
328
 
266
- async isAccessGrantActive(id: string, ctx?: TxContext): Promise<boolean> {
267
- const fulfillment = await this.findById(id, ctx);
329
+ async isAccessGrantActive(
330
+ orgId: string,
331
+ id: string,
332
+ ctx?: TxContext,
333
+ ): Promise<boolean> {
334
+ const fulfillment = await this.findById(orgId, id, ctx);
268
335
  if (!fulfillment || fulfillment.type !== "access_grant") return false;
269
336
 
270
337
  if (!fulfillment.isActive) return false;
@@ -281,6 +348,7 @@ export class FulfillmentRepository {
281
348
  // Fulfillment Line Items
282
349
  // ─────────────────────────────────────────────────────────────────────────────
283
350
 
351
+ // not tenant-scoped; do not use for tenant-facing access
284
352
  async findLineItemsByFulfillmentId(
285
353
  fulfillmentId: string,
286
354
  ctx?: TxContext,
@@ -292,6 +360,7 @@ export class FulfillmentRepository {
292
360
  .where(eq(fulfillmentLineItems.fulfillmentId, fulfillmentId));
293
361
  }
294
362
 
363
+ // not tenant-scoped; do not use for tenant-facing access
295
364
  async findLineItemsByOrderLineItemId(
296
365
  orderLineItemId: string,
297
366
  ctx?: TxContext,
@@ -303,6 +372,7 @@ export class FulfillmentRepository {
303
372
  .where(eq(fulfillmentLineItems.orderLineItemId, orderLineItemId));
304
373
  }
305
374
 
375
+ // not tenant-scoped; do not use for tenant-facing access
306
376
  async createLineItem(
307
377
  data: FulfillmentLineItemInsert,
308
378
  ctx?: TxContext,
@@ -312,6 +382,7 @@ export class FulfillmentRepository {
312
382
  return rows[0]!;
313
383
  }
314
384
 
385
+ // not tenant-scoped; do not use for tenant-facing access
315
386
  async createLineItems(
316
387
  data: FulfillmentLineItemInsert[],
317
388
  ctx?: TxContext,
@@ -321,6 +392,7 @@ export class FulfillmentRepository {
321
392
  return db.insert(fulfillmentLineItems).values(data).returning();
322
393
  }
323
394
 
395
+ // not tenant-scoped; do not use for tenant-facing access
324
396
  async deleteLineItemsByFulfillmentId(
325
397
  fulfillmentId: string,
326
398
  ctx?: TxContext,
@@ -335,6 +407,7 @@ export class FulfillmentRepository {
335
407
  // Fulfillment Events
336
408
  // ─────────────────────────────────────────────────────────────────────────────
337
409
 
410
+ // not tenant-scoped; do not use for tenant-facing access
338
411
  async findEventsByFulfillmentId(
339
412
  fulfillmentId: string,
340
413
  ctx?: TxContext,
@@ -347,6 +420,7 @@ export class FulfillmentRepository {
347
420
  .orderBy(desc(fulfillmentEvents.occurredAt));
348
421
  }
349
422
 
423
+ // not tenant-scoped; do not use for tenant-facing access
350
424
  async createEvent(
351
425
  data: FulfillmentEventInsert,
352
426
  ctx?: TxContext,
@@ -356,6 +430,7 @@ export class FulfillmentRepository {
356
430
  return rows[0]!;
357
431
  }
358
432
 
433
+ // not tenant-scoped; do not use for tenant-facing access
359
434
  async recordStatusChange(
360
435
  fulfillmentId: string,
361
436
  fromStatus: string,
@@ -382,25 +457,27 @@ export class FulfillmentRepository {
382
457
  // ─────────────────────────────────────────────────────────────────────────────
383
458
 
384
459
  async findWithLineItems(
460
+ orgId: string,
385
461
  id: string,
386
462
  ctx?: TxContext,
387
463
  ): Promise<
388
464
  | { fulfillment: FulfillmentRecord; lineItems: FulfillmentLineItem[] }
389
465
  | undefined
390
466
  > {
391
- const fulfillment = await this.findById(id, ctx);
467
+ const fulfillment = await this.findById(orgId, id, ctx);
392
468
  if (!fulfillment) return undefined;
393
469
  const lineItems = await this.findLineItemsByFulfillmentId(id, ctx);
394
470
  return { fulfillment, lineItems };
395
471
  }
396
472
 
397
473
  async findWithEvents(
474
+ orgId: string,
398
475
  id: string,
399
476
  ctx?: TxContext,
400
477
  ): Promise<
401
478
  { fulfillment: FulfillmentRecord; events: FulfillmentEvent[] } | undefined
402
479
  > {
403
- const fulfillment = await this.findById(id, ctx);
480
+ const fulfillment = await this.findById(orgId, id, ctx);
404
481
  if (!fulfillment) return undefined;
405
482
  const events = await this.findEventsByFulfillmentId(id, ctx);
406
483
  return { fulfillment, events };
@@ -410,6 +487,7 @@ export class FulfillmentRepository {
410
487
  * Get fulfilled quantity for an order line item.
411
488
  * Sums all fulfillment line items linked to the order line item.
412
489
  */
490
+ // not tenant-scoped; do not use for tenant-facing access
413
491
  async getFulfilledQuantity(
414
492
  orderLineItemId: string,
415
493
  ctx?: TxContext,
@@ -15,6 +15,7 @@ import { Err, Ok, type Result } from "../../kernel/result.js";
15
15
  import { createLogger } from "../../utils/logger.js";
16
16
  import type {
17
17
  FulfillmentRecord as FulfillmentDbRow,
18
+ FulfillmentRecordInsert,
18
19
  FulfillmentRepository,
19
20
  } from "./repository/index.js";
20
21
  import type { OrdersRepository } from "../orders/repository/index.js";
@@ -55,6 +56,11 @@ interface FulfillmentServiceDeps {
55
56
  database: DatabaseAdapter;
56
57
  }
57
58
 
59
+ type FulfillmentTrackingPatch = Pick<
60
+ FulfillmentRecordInsert,
61
+ "carrier" | "trackingNumber" | "trackingUrl" | "status" | "shippedAt" | "deliveredAt"
62
+ >;
63
+
58
64
  function toFulfillmentLineItem(lineItem: FulfillmentLineItem): {
59
65
  id: string;
60
66
  title: string;
@@ -497,9 +503,21 @@ export class FulfillmentService {
497
503
 
498
504
  async getByOrderId(
499
505
  orderId: string,
506
+ actor?: Actor | null,
500
507
  ctx?: TxContext,
501
508
  ): Promise<Result<FulfillmentRecord[]>> {
502
- const dbRecords = await this.deps.repository.findByOrderId(orderId, ctx);
509
+ // Fail closed through the Result boundary, as updateTracking does. Strict org resolution
510
+ // THROWS, and this method did not resolve an org before this change — so leaving it
511
+ // unwrapped would add a throw to a Result-returning method whose REST callers branch on
512
+ // `!result.ok` and would never see it.
513
+ let orgId: string;
514
+ try {
515
+ orgId = resolveOrgIdForCommerce(actor ?? ctx?.actor ?? null, this.deps.config);
516
+ } catch (error) {
517
+ return Err(toCommerceError(error));
518
+ }
519
+
520
+ const dbRecords = await this.deps.repository.findByOrderId(orgId, orderId, ctx);
503
521
 
504
522
  // Hydrate each record with its associated line items
505
523
  const records: FulfillmentRecord[] = [];
@@ -523,9 +541,18 @@ export class FulfillmentService {
523
541
  trackingUrl?: string;
524
542
  status?: string;
525
543
  },
544
+ actor?: Actor | null,
526
545
  ctx?: TxContext,
527
546
  ): Promise<Result<void>> {
547
+ let orgId: string;
548
+ try {
549
+ orgId = resolveOrgIdForCommerce(actor ?? ctx?.actor ?? null, this.deps.config);
550
+ } catch (error) {
551
+ return Err(toCommerceError(error));
552
+ }
553
+
528
554
  const existing = await this.deps.repository.findById(
555
+ orgId,
529
556
  input.fulfillmentId,
530
557
  ctx,
531
558
  );
@@ -533,7 +560,7 @@ export class FulfillmentService {
533
560
  return Err(new CommerceNotFoundError("Fulfillment record not found."));
534
561
  }
535
562
 
536
- const updateData: Record<string, unknown> = {};
563
+ const updateData: Partial<FulfillmentTrackingPatch> = {};
537
564
  if (input.carrier !== undefined) updateData.carrier = input.carrier;
538
565
  if (input.trackingNumber !== undefined)
539
566
  updateData.trackingNumber = input.trackingNumber;
@@ -543,7 +570,15 @@ export class FulfillmentService {
543
570
  if (input.status === "shipped") updateData.shippedAt = new Date();
544
571
  if (input.status === "delivered") updateData.deliveredAt = new Date();
545
572
 
546
- await this.deps.repository.update(input.fulfillmentId, updateData, ctx);
573
+ const updated = await this.deps.repository.update(
574
+ orgId,
575
+ input.fulfillmentId,
576
+ updateData,
577
+ ctx,
578
+ );
579
+ if (!updated) {
580
+ return Err(new CommerceNotFoundError("Fulfillment record not found."));
581
+ }
547
582
  return Ok(undefined);
548
583
  }
549
584
 
@@ -554,7 +589,8 @@ export class FulfillmentService {
554
589
  actor?: Actor | null,
555
590
  ctx?: TxContext,
556
591
  ): Promise<Result<{ url: string; remaining: number; expiresAt: string }>> {
557
- const dbRecords = await this.deps.repository.findByOrderId(orderId, ctx);
592
+ const orgId = resolveOrgIdForCommerce(actor ?? ctx?.actor ?? null, this.deps.config);
593
+ const dbRecords = await this.deps.repository.findByOrderId(orgId, orderId, ctx);
558
594
 
559
595
  let matchedRecord: (typeof dbRecords)[number] | undefined;
560
596
  for (const dbRecord of dbRecords) {
@@ -574,7 +610,6 @@ export class FulfillmentService {
574
610
  return Err(new CommerceNotFoundError("Digital download not found."));
575
611
  }
576
612
 
577
- const orgId = resolveOrgIdForCommerce(actor ?? ctx?.actor ?? null, this.deps.config);
578
613
  const order = await this.deps.ordersRepository.findById(orgId, orderId, ctx);
579
614
  if (!order || order.customerId !== userId) {
580
615
  return Err(new CommerceNotFoundError("Order not found."));
@@ -589,6 +624,7 @@ export class FulfillmentService {
589
624
  }
590
625
 
591
626
  const updated = await this.deps.repository.incrementDownloadCount(
627
+ orgId,
592
628
  matchedRecord.id,
593
629
  ctx,
594
630
  );