@fonderie/customers 6.1.6 → 6.2.1

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/brain/outcomes.md CHANGED
@@ -54,7 +54,9 @@ id UUID PRIMARY KEY DEFAULT gen_random_uuid()
54
54
  type fonderie_customer_label_type NOT NULL
55
55
  value TEXT NOT NULL
56
56
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
57
+ workspace_id UUID
57
58
  -- CONSTRAINT uq_fcl_type_value UNIQUE (type, value)
59
+ -- INDEX idx_fcl_workspace (workspace_id)
58
60
  ```
59
61
 
60
62
  ### `fonderie_customer_notes`
package/dist/index.cjs CHANGED
@@ -1064,47 +1064,69 @@ var CustomerEmailModel = class {
1064
1064
  };
1065
1065
 
1066
1066
  // src/models/customer-label.model.ts
1067
+ var SELECT = `id, type, value, created_at AS "createdAt"`;
1067
1068
  var CustomerLabelModel = class {
1068
1069
  constructor(store) {
1069
1070
  this.store = store;
1070
1071
  }
1071
1072
  store;
1072
- list(type) {
1073
+ /**
1074
+ * List the labels a workspace may use: the shared/system defaults
1075
+ * (workspace_id IS NULL) plus its OWN private labels. Never another
1076
+ * workspace's — so listing can't enumerate other tenants' custom
1077
+ * vocabulary.
1078
+ */
1079
+ list(type, workspaceId) {
1073
1080
  return this.store.query(
1074
- `SELECT id, type, value, created_at AS "createdAt"
1081
+ `SELECT ${SELECT}
1075
1082
  FROM fonderie_customer_labels
1076
- WHERE type = $1
1083
+ WHERE type = $1 AND (workspace_id IS NULL OR workspace_id = $2)
1077
1084
  ORDER BY value ASC`,
1078
- [type]
1085
+ [type, workspaceId]
1079
1086
  );
1080
1087
  }
1081
1088
  /**
1082
- * Returns false when the label is still referenced. Labels are a SHARED
1083
- * table (no workspace column), so an unconditional delete would let one
1084
- * tenant destroy a label other tenants' emails/phones/addresses point at.
1085
- * Until labels are workspace-scoped, only an unreferenced label may go.
1089
+ * Delete a label. Only a label OWNED BY this workspace and UNREFERENCED can
1090
+ * go: shared defaults (workspace_id IS NULL) and other workspaces' labels
1091
+ * are untouchable, and an in-use label (still pointed at by an email/phone/
1092
+ * address) is refused so it can't be destroyed out from under a record.
1093
+ * Returns false when nothing was deleted.
1086
1094
  */
1087
- async remove(id) {
1095
+ async remove(id, workspaceId) {
1088
1096
  const rows = await this.store.query(
1089
1097
  `DELETE FROM fonderie_customer_labels l
1090
1098
  WHERE l.id = $1
1099
+ AND l.workspace_id = $2
1091
1100
  AND NOT EXISTS (SELECT 1 FROM fonderie_customer_emails WHERE label_id = l.id)
1092
1101
  AND NOT EXISTS (SELECT 1 FROM fonderie_customer_phones WHERE label_id = l.id)
1093
1102
  AND NOT EXISTS (SELECT 1 FROM fonderie_customer_addresses WHERE label_id = l.id)
1094
1103
  RETURNING l.id`,
1095
- [id]
1104
+ [id, workspaceId]
1096
1105
  );
1097
1106
  return rows.length > 0;
1098
1107
  }
1099
- async findOrCreate(type, raw) {
1108
+ /**
1109
+ * Resolve a (type, value) to a label id for this workspace: reuse a shared
1110
+ * default when one exists (so common labels like 'work' stay canonical),
1111
+ * otherwise create/reuse a label PRIVATE to this workspace.
1112
+ */
1113
+ async findOrCreate(type, raw, workspaceId) {
1100
1114
  const value = raw.trim().toLowerCase();
1101
- const [row] = await this.store.query(
1102
- `INSERT INTO fonderie_customer_labels (type, value)
1103
- VALUES ($1, $2)
1104
- ON CONFLICT (type, value) DO UPDATE SET value = EXCLUDED.value
1105
- RETURNING id, type, value, created_at AS "createdAt"`,
1115
+ const [shared] = await this.store.query(
1116
+ `SELECT ${SELECT} FROM fonderie_customer_labels
1117
+ WHERE type = $1 AND value = $2 AND workspace_id IS NULL
1118
+ LIMIT 1`,
1106
1119
  [type, value]
1107
1120
  );
1121
+ if (shared) return shared;
1122
+ const [row] = await this.store.query(
1123
+ `INSERT INTO fonderie_customer_labels (type, value, workspace_id)
1124
+ VALUES ($1, $2, $3)
1125
+ ON CONFLICT (type, value, workspace_id) WHERE workspace_id IS NOT NULL
1126
+ DO UPDATE SET value = EXCLUDED.value
1127
+ RETURNING ${SELECT}`,
1128
+ [type, value, workspaceId]
1129
+ );
1108
1130
  if (!row) throw new Error("Failed to find or create label");
1109
1131
  return row;
1110
1132
  }
@@ -1711,7 +1733,7 @@ function customerAddressController(store) {
1711
1733
  return (0, import_core3.setApiResponse)(import_core3.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "zipPostalCode is required");
1712
1734
  }
1713
1735
  const rawLabel = typeof body?.["label"] === "string" ? body["label"] : "service";
1714
- const { id: labelId } = await labels.findOrCreate("address", rawLabel);
1736
+ const { id: labelId } = await labels.findOrCreate("address", rawLabel, r.workspaceId);
1715
1737
  let created;
1716
1738
  try {
1717
1739
  created = await addresses.add({
@@ -1749,7 +1771,7 @@ function customerAddressController(store) {
1749
1771
  return (0, import_core3.setApiResponse)(import_core3.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "label is required");
1750
1772
  }
1751
1773
  try {
1752
- const { id: labelId } = await labels.findOrCreate("address", body["label"].trim());
1774
+ const { id: labelId } = await labels.findOrCreate("address", body["label"].trim(), r.workspaceId);
1753
1775
  const updated = await addresses.updateLabel(addrId, r.customer.id, labelId);
1754
1776
  return (0, import_core3.setApiResponse)(import_core3.HTTP.OK, "ADDRESS_UPDATED", "Address updated successfully.", {
1755
1777
  address: toCustomerAddressDTO(updated)
@@ -1844,7 +1866,7 @@ function customerEmailController(store) {
1844
1866
  }
1845
1867
  const rawLabel = typeof body?.["label"] === "string" ? body["label"] : "work";
1846
1868
  const isPrimary2 = body?.["isPrimary"] === true;
1847
- const { id: labelId } = await labels.findOrCreate("email", rawLabel);
1869
+ const { id: labelId } = await labels.findOrCreate("email", rawLabel, r.workspaceId);
1848
1870
  let created;
1849
1871
  try {
1850
1872
  created = await emails.add({
@@ -1876,7 +1898,7 @@ function customerEmailController(store) {
1876
1898
  return (0, import_core4.setApiResponse)(import_core4.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "label is required");
1877
1899
  }
1878
1900
  try {
1879
- const { id: labelId } = await labels.findOrCreate("email", body["label"].trim());
1901
+ const { id: labelId } = await labels.findOrCreate("email", body["label"].trim(), r.workspaceId);
1880
1902
  const updated = await emails.updateLabel(emailId, r.customer.id, labelId);
1881
1903
  return (0, import_core4.setApiResponse)(import_core4.HTTP.OK, "EMAIL_UPDATED", "Email updated successfully.", {
1882
1904
  email: toCustomerEmailDTO(updated)
@@ -2053,7 +2075,7 @@ function customerPhoneController(store) {
2053
2075
  }
2054
2076
  const rawLabel = typeof body?.["label"] === "string" ? body["label"] : "mobile";
2055
2077
  const isPrimary2 = body?.["isPrimary"] === true;
2056
- const { id: labelId } = await labels.findOrCreate("phone", rawLabel);
2078
+ const { id: labelId } = await labels.findOrCreate("phone", rawLabel, r.workspaceId);
2057
2079
  let created;
2058
2080
  try {
2059
2081
  created = await phones.add({
@@ -2085,7 +2107,7 @@ function customerPhoneController(store) {
2085
2107
  return (0, import_core6.setApiResponse)(import_core6.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "label is required");
2086
2108
  }
2087
2109
  try {
2088
- const { id: labelId } = await labels.findOrCreate("phone", body["label"].trim());
2110
+ const { id: labelId } = await labels.findOrCreate("phone", body["label"].trim(), r.workspaceId);
2089
2111
  const updated = await phones.updateLabel(phoneId, r.customer.id, labelId);
2090
2112
  return (0, import_core6.setApiResponse)(import_core6.HTTP.OK, "PHONE_UPDATED", "Phone updated successfully.", {
2091
2113
  phone: toCustomerPhoneDTO(updated)
@@ -2196,7 +2218,8 @@ function customerLabelController(store) {
2196
2218
  if (!type || !VALID_TYPES.includes(type)) {
2197
2219
  return (0, import_core8.setApiResponse)(import_core8.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "type must be phone, email, or address");
2198
2220
  }
2199
- const rows = await labels.list(type);
2221
+ if (!ctx.workspace) return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_FOUND, "NOT_FOUND", "Workspace not found");
2222
+ const rows = await labels.list(type, ctx.workspace.id);
2200
2223
  return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "LABELS_FETCHED", "Labels retrieved successfully.", {
2201
2224
  labels: rows.map(toCustomerLabelDTO)
2202
2225
  });
@@ -2207,12 +2230,13 @@ function customerLabelController(store) {
2207
2230
  if (!isUuid(labelId)) {
2208
2231
  return (0, import_core8.setApiResponse)(import_core8.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "labelId must be a valid UUID");
2209
2232
  }
2210
- const removed = await labels.remove(labelId);
2233
+ if (!ctx.workspace) return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_FOUND, "NOT_FOUND", "Workspace not found");
2234
+ const removed = await labels.remove(labelId, ctx.workspace.id);
2211
2235
  if (!removed) {
2212
2236
  return (0, import_core8.setApiResponse)(
2213
2237
  import_core8.HTTP.CONFLICT,
2214
2238
  "LABEL_IN_USE",
2215
- "Label is still referenced by customer records and cannot be deleted."
2239
+ "Label cannot be deleted \u2014 it is a shared default, not owned by this workspace, or still referenced."
2216
2240
  );
2217
2241
  }
2218
2242
  return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "LABEL_DELETED", "Label deleted successfully.");