@fonderie/customers 6.1.6 → 6.2.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/brain/outcomes.md +2 -0
- package/dist/index.cjs +49 -25
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +49 -25
- package/dist/index.js.map +1 -1
- package/dist/migrations/sql/013_label_workspace_scope.sql +22 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1028,47 +1028,69 @@ var CustomerEmailModel = class {
|
|
|
1028
1028
|
};
|
|
1029
1029
|
|
|
1030
1030
|
// src/models/customer-label.model.ts
|
|
1031
|
+
var SELECT = `id, type, value, created_at AS "createdAt"`;
|
|
1031
1032
|
var CustomerLabelModel = class {
|
|
1032
1033
|
constructor(store) {
|
|
1033
1034
|
this.store = store;
|
|
1034
1035
|
}
|
|
1035
1036
|
store;
|
|
1036
|
-
|
|
1037
|
+
/**
|
|
1038
|
+
* List the labels a workspace may use: the shared/system defaults
|
|
1039
|
+
* (workspace_id IS NULL) plus its OWN private labels. Never another
|
|
1040
|
+
* workspace's — so listing can't enumerate other tenants' custom
|
|
1041
|
+
* vocabulary.
|
|
1042
|
+
*/
|
|
1043
|
+
list(type, workspaceId) {
|
|
1037
1044
|
return this.store.query(
|
|
1038
|
-
`SELECT
|
|
1045
|
+
`SELECT ${SELECT}
|
|
1039
1046
|
FROM fonderie_customer_labels
|
|
1040
|
-
WHERE type = $1
|
|
1047
|
+
WHERE type = $1 AND (workspace_id IS NULL OR workspace_id = $2)
|
|
1041
1048
|
ORDER BY value ASC`,
|
|
1042
|
-
[type]
|
|
1049
|
+
[type, workspaceId]
|
|
1043
1050
|
);
|
|
1044
1051
|
}
|
|
1045
1052
|
/**
|
|
1046
|
-
*
|
|
1047
|
-
*
|
|
1048
|
-
*
|
|
1049
|
-
*
|
|
1053
|
+
* Delete a label. Only a label OWNED BY this workspace and UNREFERENCED can
|
|
1054
|
+
* go: shared defaults (workspace_id IS NULL) and other workspaces' labels
|
|
1055
|
+
* are untouchable, and an in-use label (still pointed at by an email/phone/
|
|
1056
|
+
* address) is refused so it can't be destroyed out from under a record.
|
|
1057
|
+
* Returns false when nothing was deleted.
|
|
1050
1058
|
*/
|
|
1051
|
-
async remove(id) {
|
|
1059
|
+
async remove(id, workspaceId) {
|
|
1052
1060
|
const rows = await this.store.query(
|
|
1053
1061
|
`DELETE FROM fonderie_customer_labels l
|
|
1054
1062
|
WHERE l.id = $1
|
|
1063
|
+
AND l.workspace_id = $2
|
|
1055
1064
|
AND NOT EXISTS (SELECT 1 FROM fonderie_customer_emails WHERE label_id = l.id)
|
|
1056
1065
|
AND NOT EXISTS (SELECT 1 FROM fonderie_customer_phones WHERE label_id = l.id)
|
|
1057
1066
|
AND NOT EXISTS (SELECT 1 FROM fonderie_customer_addresses WHERE label_id = l.id)
|
|
1058
1067
|
RETURNING l.id`,
|
|
1059
|
-
[id]
|
|
1068
|
+
[id, workspaceId]
|
|
1060
1069
|
);
|
|
1061
1070
|
return rows.length > 0;
|
|
1062
1071
|
}
|
|
1063
|
-
|
|
1072
|
+
/**
|
|
1073
|
+
* Resolve a (type, value) to a label id for this workspace: reuse a shared
|
|
1074
|
+
* default when one exists (so common labels like 'work' stay canonical),
|
|
1075
|
+
* otherwise create/reuse a label PRIVATE to this workspace.
|
|
1076
|
+
*/
|
|
1077
|
+
async findOrCreate(type, raw, workspaceId) {
|
|
1064
1078
|
const value = raw.trim().toLowerCase();
|
|
1065
|
-
const [
|
|
1066
|
-
`
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
RETURNING id, type, value, created_at AS "createdAt"`,
|
|
1079
|
+
const [shared] = await this.store.query(
|
|
1080
|
+
`SELECT ${SELECT} FROM fonderie_customer_labels
|
|
1081
|
+
WHERE type = $1 AND value = $2 AND workspace_id IS NULL
|
|
1082
|
+
LIMIT 1`,
|
|
1070
1083
|
[type, value]
|
|
1071
1084
|
);
|
|
1085
|
+
if (shared) return shared;
|
|
1086
|
+
const [row] = await this.store.query(
|
|
1087
|
+
`INSERT INTO fonderie_customer_labels (type, value, workspace_id)
|
|
1088
|
+
VALUES ($1, $2, $3)
|
|
1089
|
+
ON CONFLICT (type, value, workspace_id) WHERE workspace_id IS NOT NULL
|
|
1090
|
+
DO UPDATE SET value = EXCLUDED.value
|
|
1091
|
+
RETURNING ${SELECT}`,
|
|
1092
|
+
[type, value, workspaceId]
|
|
1093
|
+
);
|
|
1072
1094
|
if (!row) throw new Error("Failed to find or create label");
|
|
1073
1095
|
return row;
|
|
1074
1096
|
}
|
|
@@ -1675,7 +1697,7 @@ function customerAddressController(store) {
|
|
|
1675
1697
|
return setApiResponse2(HTTP2.UNPROCESSABLE, "INVALID_PARAMETER", "zipPostalCode is required");
|
|
1676
1698
|
}
|
|
1677
1699
|
const rawLabel = typeof body?.["label"] === "string" ? body["label"] : "service";
|
|
1678
|
-
const { id: labelId } = await labels.findOrCreate("address", rawLabel);
|
|
1700
|
+
const { id: labelId } = await labels.findOrCreate("address", rawLabel, r.workspaceId);
|
|
1679
1701
|
let created;
|
|
1680
1702
|
try {
|
|
1681
1703
|
created = await addresses.add({
|
|
@@ -1713,7 +1735,7 @@ function customerAddressController(store) {
|
|
|
1713
1735
|
return setApiResponse2(HTTP2.UNPROCESSABLE, "INVALID_PARAMETER", "label is required");
|
|
1714
1736
|
}
|
|
1715
1737
|
try {
|
|
1716
|
-
const { id: labelId } = await labels.findOrCreate("address", body["label"].trim());
|
|
1738
|
+
const { id: labelId } = await labels.findOrCreate("address", body["label"].trim(), r.workspaceId);
|
|
1717
1739
|
const updated = await addresses.updateLabel(addrId, r.customer.id, labelId);
|
|
1718
1740
|
return setApiResponse2(HTTP2.OK, "ADDRESS_UPDATED", "Address updated successfully.", {
|
|
1719
1741
|
address: toCustomerAddressDTO(updated)
|
|
@@ -1808,7 +1830,7 @@ function customerEmailController(store) {
|
|
|
1808
1830
|
}
|
|
1809
1831
|
const rawLabel = typeof body?.["label"] === "string" ? body["label"] : "work";
|
|
1810
1832
|
const isPrimary2 = body?.["isPrimary"] === true;
|
|
1811
|
-
const { id: labelId } = await labels.findOrCreate("email", rawLabel);
|
|
1833
|
+
const { id: labelId } = await labels.findOrCreate("email", rawLabel, r.workspaceId);
|
|
1812
1834
|
let created;
|
|
1813
1835
|
try {
|
|
1814
1836
|
created = await emails.add({
|
|
@@ -1840,7 +1862,7 @@ function customerEmailController(store) {
|
|
|
1840
1862
|
return setApiResponse3(HTTP3.UNPROCESSABLE, "INVALID_PARAMETER", "label is required");
|
|
1841
1863
|
}
|
|
1842
1864
|
try {
|
|
1843
|
-
const { id: labelId } = await labels.findOrCreate("email", body["label"].trim());
|
|
1865
|
+
const { id: labelId } = await labels.findOrCreate("email", body["label"].trim(), r.workspaceId);
|
|
1844
1866
|
const updated = await emails.updateLabel(emailId, r.customer.id, labelId);
|
|
1845
1867
|
return setApiResponse3(HTTP3.OK, "EMAIL_UPDATED", "Email updated successfully.", {
|
|
1846
1868
|
email: toCustomerEmailDTO(updated)
|
|
@@ -2017,7 +2039,7 @@ function customerPhoneController(store) {
|
|
|
2017
2039
|
}
|
|
2018
2040
|
const rawLabel = typeof body?.["label"] === "string" ? body["label"] : "mobile";
|
|
2019
2041
|
const isPrimary2 = body?.["isPrimary"] === true;
|
|
2020
|
-
const { id: labelId } = await labels.findOrCreate("phone", rawLabel);
|
|
2042
|
+
const { id: labelId } = await labels.findOrCreate("phone", rawLabel, r.workspaceId);
|
|
2021
2043
|
let created;
|
|
2022
2044
|
try {
|
|
2023
2045
|
created = await phones.add({
|
|
@@ -2049,7 +2071,7 @@ function customerPhoneController(store) {
|
|
|
2049
2071
|
return setApiResponse5(HTTP5.UNPROCESSABLE, "INVALID_PARAMETER", "label is required");
|
|
2050
2072
|
}
|
|
2051
2073
|
try {
|
|
2052
|
-
const { id: labelId } = await labels.findOrCreate("phone", body["label"].trim());
|
|
2074
|
+
const { id: labelId } = await labels.findOrCreate("phone", body["label"].trim(), r.workspaceId);
|
|
2053
2075
|
const updated = await phones.updateLabel(phoneId, r.customer.id, labelId);
|
|
2054
2076
|
return setApiResponse5(HTTP5.OK, "PHONE_UPDATED", "Phone updated successfully.", {
|
|
2055
2077
|
phone: toCustomerPhoneDTO(updated)
|
|
@@ -2160,7 +2182,8 @@ function customerLabelController(store) {
|
|
|
2160
2182
|
if (!type || !VALID_TYPES.includes(type)) {
|
|
2161
2183
|
return setApiResponse7(HTTP7.UNPROCESSABLE, "INVALID_PARAMETER", "type must be phone, email, or address");
|
|
2162
2184
|
}
|
|
2163
|
-
|
|
2185
|
+
if (!ctx.workspace) return setApiResponse7(HTTP7.NOT_FOUND, "NOT_FOUND", "Workspace not found");
|
|
2186
|
+
const rows = await labels.list(type, ctx.workspace.id);
|
|
2164
2187
|
return setApiResponse7(HTTP7.OK, "LABELS_FETCHED", "Labels retrieved successfully.", {
|
|
2165
2188
|
labels: rows.map(toCustomerLabelDTO)
|
|
2166
2189
|
});
|
|
@@ -2171,12 +2194,13 @@ function customerLabelController(store) {
|
|
|
2171
2194
|
if (!isUuid(labelId)) {
|
|
2172
2195
|
return setApiResponse7(HTTP7.UNPROCESSABLE, "INVALID_PARAMETER", "labelId must be a valid UUID");
|
|
2173
2196
|
}
|
|
2174
|
-
|
|
2197
|
+
if (!ctx.workspace) return setApiResponse7(HTTP7.NOT_FOUND, "NOT_FOUND", "Workspace not found");
|
|
2198
|
+
const removed = await labels.remove(labelId, ctx.workspace.id);
|
|
2175
2199
|
if (!removed) {
|
|
2176
2200
|
return setApiResponse7(
|
|
2177
2201
|
HTTP7.CONFLICT,
|
|
2178
2202
|
"LABEL_IN_USE",
|
|
2179
|
-
"Label is
|
|
2203
|
+
"Label cannot be deleted \u2014 it is a shared default, not owned by this workspace, or still referenced."
|
|
2180
2204
|
);
|
|
2181
2205
|
}
|
|
2182
2206
|
return setApiResponse7(HTTP7.OK, "LABEL_DELETED", "Label deleted successfully.");
|