@porulle/core 0.53.0 → 0.54.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/core",
3
- "version": "0.53.0",
3
+ "version": "0.54.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -24,7 +24,7 @@ import { paginate } from "../../utils/pagination.js";
24
24
  import type { JobsAdapter } from "../../kernel/jobs/adapter.js";
25
25
  import type { PluginDb } from "../../kernel/database/plugin-types.js";
26
26
  import type { CatalogWriteContext, TxContext } from "../../kernel/database/tx-context.js";
27
- import { isWriteContextTransactional } from "../../kernel/database/tx-context.js";
27
+ import { createTxContext, isWriteContextTransactional } from "../../kernel/database/tx-context.js";
28
28
  import { canReadUnpublishedCatalog, isCatalogEntityVisible } from "./read-policy.js";
29
29
  import { isValidFieldPath } from "./ownership.js";
30
30
  import type {
@@ -519,21 +519,36 @@ export class EntityService {
519
519
  try { this.assertSameOrg(entity, actor); } catch (error) { return Err(toCommerceError(error)); }
520
520
  const beforeAttr = await this.repo.findAttributeByLocale(entityId, locale, txCtx);
521
521
  const changedFieldPaths: string[] = [];
522
+ let changed = false;
522
523
  for (const field of attributeFields) {
523
524
  if (attrs[field] === undefined) continue;
524
525
  const before = beforeAttr?.[field] ?? null;
525
526
  const after = attrs[field] ?? null;
526
527
  if (before !== after) {
528
+ changed = true;
527
529
  const path = `attributes.${locale}.${field}`;
528
530
  if (isValidFieldPath(path)) changedFieldPaths.push(path);
529
531
  }
530
532
  }
531
- await this.repo.upsertAttribute(entityId, locale, { title: attrs.title, subtitle: attrs.subtitle, description: attrs.description, richDescription: attrs.richDescription, seoTitle: attrs.seoTitle, seoDescription: attrs.seoDescription }, txCtx);
533
+ // An identical re-set writes nothing and moves nothing: an idempotent save must not look like an
534
+ // edit to anything that versions the product.
535
+ if (!changed && beforeAttr) return Ok(undefined);
536
+
537
+ // The attribute write and the entity's `updated_at` bump commit together. Consumers version a
538
+ // product from `sellable_entities.updated_at`; an attribute change that left it unmoved was
539
+ // invisible to them — a changed title never re-indexed (sim, 2026-09-24).
540
+ const write = async (writeCtx: TxContext): Promise<SellableEntity | undefined> => {
541
+ await this.repo.upsertAttribute(entityId, locale, { title: attrs.title, subtitle: attrs.subtitle, description: attrs.description, richDescription: attrs.richDescription, seoTitle: attrs.seoTitle, seoDescription: attrs.seoDescription }, writeCtx);
542
+ return changed ? this.repo.updateEntity(entityId, {}, writeCtx) : undefined;
543
+ };
544
+ const updated = txCtx
545
+ ? await write(txCtx)
546
+ : await this.deps.database.transaction((tx) => write(createTxContext(tx, { actor })));
532
547
  if (changedFieldPaths.length > 0) {
533
548
  const afterHooks = this.deps.hooks.resolve("catalog.afterUpdate") as CatalogUpdateAfterHook[];
534
549
  const context = catalogHookContext(this.deps, actor, ctx, "update");
535
550
  context.context.changedFieldPaths = changedFieldPaths;
536
- await runAfterHooks(afterHooks, entity, entity, "update", context, (hook) => this.deps.hooks.runsInTransaction(hook));
551
+ await runAfterHooks(afterHooks, entity, updated ?? entity, "update", context, (hook) => this.deps.hooks.runsInTransaction(hook));
537
552
  }
538
553
  return Ok(undefined);
539
554
  }
@@ -563,10 +578,13 @@ export class EntityService {
563
578
  const txCtx = isWriteContextTransactional(ctx) ? ctx : undefined;
564
579
  const entity = await this.repo.findEntityById(entityId, txCtx);
565
580
  if (!entity) return;
581
+ // The related rows changed, so the entity's `updated_at` moves with them (in the caller's
582
+ // transaction when it has one) — the same versioning contract as `setAttributes`.
583
+ const updated = await this.repo.updateEntity(entityId, {}, txCtx);
566
584
  const afterHooks = this.deps.hooks.resolve("catalog.afterUpdate") as CatalogUpdateAfterHook[];
567
585
  const context = catalogHookContext(this.deps, actor, ctx, "update");
568
586
  context.context.changedFieldPaths = [...changedFieldPaths];
569
- await runAfterHooks(afterHooks, entity, entity, "update", context, (hook) => this.deps.hooks.runsInTransaction(hook));
587
+ await runAfterHooks(afterHooks, entity, updated ?? entity, "update", context, (hook) => this.deps.hooks.runsInTransaction(hook));
570
588
  }
571
589
 
572
590
  async getAttributes(entityId: string, locale: string, actor: Actor | null, ctx?: TxContext): Promise<Result<SellableAttribute>> {
@@ -1,5 +1,6 @@
1
1
  import { eq, and, desc } from "drizzle-orm";
2
2
  import type { TxContext } from "../../../kernel/database/tx-context.js";
3
+ import { user } from "../../../auth/auth-schema.js";
3
4
  import type {
4
5
  DrizzleDatabase,
5
6
  DbOrTx,
@@ -51,6 +52,27 @@ export class CustomersRepository {
51
52
  return rows[0];
52
53
  }
53
54
 
55
+ /**
56
+ * The auth user's email, when no OTHER customer profile in this organization already holds it
57
+ * (`customers` is unique on organization + email). Undefined otherwise — the caller then leaves the
58
+ * profile's email as it is rather than fail the whole profile read on the unique index.
59
+ */
60
+ async claimableUserEmail(
61
+ orgId: string,
62
+ userId: string,
63
+ ctx?: TxContext,
64
+ ): Promise<string | undefined> {
65
+ const db = this.getDb(ctx);
66
+ const [account] = await db.select({ email: user.email }).from(user).where(eq(user.id, userId));
67
+ if (!account?.email) return undefined;
68
+ const [holder] = await db
69
+ .select({ userId: customers.userId })
70
+ .from(customers)
71
+ .where(and(eq(customers.organizationId, orgId), eq(customers.email, account.email)))
72
+ .limit(1);
73
+ return holder === undefined || holder.userId === userId ? account.email : undefined;
74
+ }
75
+
54
76
  async findByUserId(
55
77
  orgId: string,
56
78
  userId: string,
@@ -177,16 +177,24 @@ export class CustomerService {
177
177
  actor: Actor | null,
178
178
  ctx?: TxContext,
179
179
  ): Promise<Customer> {
180
+ // The profile carries the shopper's email: it is the key channel redaction uses, so a profile
181
+ // without one cannot be erased once its orders have been exported. Filled on creation, and on
182
+ // the next access of a profile created before this (never overwriting an email it has).
180
183
  const existing = await this.repo.findByUserId(orgId, userId, ctx);
181
184
  if (existing) {
182
- return existing;
185
+ if (existing.email) return existing;
186
+ const email = await this.repo.claimableUserEmail(orgId, userId, ctx);
187
+ if (!email) return existing;
188
+ return (await this.repo.update(existing.id, { email }, ctx)) ?? existing;
183
189
  }
184
190
 
191
+ const email = await this.repo.claimableUserEmail(orgId, userId, ctx);
185
192
  const customer = await this.repo.create(
186
193
  {
187
194
  organizationId: orgId,
188
195
  userId,
189
196
  metadata: {},
197
+ ...(email !== undefined ? { email } : {}),
190
198
  },
191
199
  ctx,
192
200
  );