@7365admin1/layer-common 3.2.8-staging.208 → 3.2.8-staging.210
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.
|
@@ -616,7 +616,7 @@ async function openPermissionDialog(category: THidPermissionCategory | "intercom
|
|
|
616
616
|
page: 1,
|
|
617
617
|
limit: 500,
|
|
618
618
|
});
|
|
619
|
-
permissionCandidates.value = response.items
|
|
619
|
+
permissionCandidates.value = response.items ?? response.data?.items ?? [];
|
|
620
620
|
selectedPermissionIds.value = new Set(
|
|
621
621
|
permissionCandidates.value
|
|
622
622
|
.filter((candidate) => category === "intercom" ? candidate.intercom : candidate.selected)
|
|
@@ -936,7 +936,7 @@ async function loadSubjectCandidates() {
|
|
|
936
936
|
page: 1,
|
|
937
937
|
limit: 500,
|
|
938
938
|
});
|
|
939
|
-
permissionCandidates.value = response.items
|
|
939
|
+
permissionCandidates.value = response.items ?? response.data?.items ?? [];
|
|
940
940
|
} finally {
|
|
941
941
|
loadingSubjects.value = false;
|
|
942
942
|
}
|
|
@@ -80,11 +80,18 @@ type HidVisitorQrData = {
|
|
|
80
80
|
type HidVisitorQrResponse = HidVisitorQrData | { data: HidVisitorQrData };
|
|
81
81
|
|
|
82
82
|
type HidPermissionListResponse = {
|
|
83
|
-
items
|
|
84
|
-
page
|
|
85
|
-
pages
|
|
86
|
-
pageRange
|
|
87
|
-
limit
|
|
83
|
+
items?: THidPermissionCandidate[];
|
|
84
|
+
page?: number;
|
|
85
|
+
pages?: number;
|
|
86
|
+
pageRange?: string;
|
|
87
|
+
limit?: number;
|
|
88
|
+
data?: {
|
|
89
|
+
items?: THidPermissionCandidate[];
|
|
90
|
+
page?: number;
|
|
91
|
+
pages?: number;
|
|
92
|
+
pageRange?: string;
|
|
93
|
+
limit?: number;
|
|
94
|
+
};
|
|
88
95
|
};
|
|
89
96
|
|
|
90
97
|
type HidFacialEnrollmentResult = {
|
|
@@ -28,9 +28,66 @@ export default function usePromoCode() {
|
|
|
28
28
|
});
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/** One record, by its id. The console's detail screen opens on this. */
|
|
32
|
+
function getById(id: string) {
|
|
33
|
+
return useNuxtApp().$api<Record<string, any>>(
|
|
34
|
+
`/api/promo-codes/id/${id}`,
|
|
35
|
+
{
|
|
36
|
+
method: "GET",
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Edit a code.
|
|
43
|
+
*
|
|
44
|
+
* `code` is NOT sent. It is immutable on the server - a subscription records
|
|
45
|
+
* the promo code it was bought with as text, so renaming one rewrites every
|
|
46
|
+
* invoice that already quotes it. The API accepts a `code` key and ignores
|
|
47
|
+
* it; leaving it out of the body means the screen can never look as though
|
|
48
|
+
* it changed something it did not.
|
|
49
|
+
*/
|
|
50
|
+
function update(id: string, value: Partial<TPromoCode>) {
|
|
51
|
+
const { code, ...body } = value as Record<string, any>;
|
|
52
|
+
|
|
53
|
+
return useNuxtApp().$api<Record<string, any>>(`/api/promo-codes/${id}`, {
|
|
54
|
+
method: "PUT",
|
|
55
|
+
body,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Turn a code off, or back on. The only two states staff may set. */
|
|
60
|
+
function updateStatus(id: string, status: "active" | "disabled") {
|
|
61
|
+
return useNuxtApp().$api<Record<string, any>>(
|
|
62
|
+
`/api/promo-codes/${id}/status`,
|
|
63
|
+
{
|
|
64
|
+
method: "PATCH",
|
|
65
|
+
body: { status },
|
|
66
|
+
}
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Remove a code from the console.
|
|
72
|
+
*
|
|
73
|
+
* A SOFT delete on the server: the record stays and is marked disabled and
|
|
74
|
+
* deleted, because subscriptions and invoices quote the code they were
|
|
75
|
+
* bought with. It leaves the list and stops being redeemable; it is not
|
|
76
|
+
* erased.
|
|
77
|
+
*/
|
|
78
|
+
function remove(id: string) {
|
|
79
|
+
return useNuxtApp().$api<Record<string, any>>(`/api/promo-codes/${id}`, {
|
|
80
|
+
method: "DELETE",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
31
84
|
return {
|
|
32
85
|
add,
|
|
33
86
|
getPromoCodes,
|
|
34
87
|
getByCode,
|
|
88
|
+
getById,
|
|
89
|
+
update,
|
|
90
|
+
updateStatus,
|
|
91
|
+
remove,
|
|
35
92
|
};
|
|
36
93
|
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "3.2.8-staging.
|
|
5
|
+
"version": "3.2.8-staging.210",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
promoExpiryDate,
|
|
6
|
+
promoCodeFormValues,
|
|
7
|
+
promoCodeUpdatePayload,
|
|
8
|
+
validatePromoCodeEdit,
|
|
9
|
+
promoCodeApiError,
|
|
10
|
+
} from "./promo-code-form.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* THE PROMO CODE EDITOR AND THE THREE WRITE CALLS BEHIND IT.
|
|
14
|
+
*
|
|
15
|
+
* Two halves, both pinned here:
|
|
16
|
+
*
|
|
17
|
+
* 1. the rules the edit form applies before it sends anything - taken from
|
|
18
|
+
* the MERGED backend (`promoCodeUpdateSchema` in `@7365admin1/core`), not
|
|
19
|
+
* from a guess about it;
|
|
20
|
+
* 2. what `usePromoCode()` actually puts on the wire. The composable is three
|
|
21
|
+
* `$api` calls, and the thing that can silently be wrong about it is the
|
|
22
|
+
* verb, the path or the body - a `PUT` to the wrong path, or a body that
|
|
23
|
+
* carries `code` and so looks as though it renamed a code it cannot.
|
|
24
|
+
*
|
|
25
|
+
* The composable reaches for `useNuxtApp()`, which only exists inside a Nuxt
|
|
26
|
+
* app, so one stub stands in for it and records the call.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const NOW = new Date(2026, 7, 20, 12, 0, 0); // 20 Aug 2026, midday
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// The expiry rule
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
test("an expiry date is read as the END of its day, in both stored formats", () => {
|
|
36
|
+
// A code dated today is good for all of today, so a check at midday passes.
|
|
37
|
+
const usFormat = promoExpiryDate("08/20/2026");
|
|
38
|
+
assert.ok(usFormat);
|
|
39
|
+
assert.equal(usFormat!.getHours(), 23);
|
|
40
|
+
assert.ok(usFormat!.getTime() > NOW.getTime());
|
|
41
|
+
|
|
42
|
+
const iso = promoExpiryDate("2026-08-20");
|
|
43
|
+
assert.ok(iso);
|
|
44
|
+
assert.equal(iso!.getTime(), usFormat!.getTime());
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a date that is not a date is no expiry at all, never an Invalid Date", () => {
|
|
48
|
+
assert.equal(promoExpiryDate(""), null);
|
|
49
|
+
assert.equal(promoExpiryDate(null), null);
|
|
50
|
+
assert.equal(promoExpiryDate("whenever"), null);
|
|
51
|
+
// Month 13 rolls into the next year in JavaScript rather than failing, so
|
|
52
|
+
// it is caught explicitly - otherwise 13/01/2026 would read as Jan 2027.
|
|
53
|
+
assert.equal(promoExpiryDate("13/01/2026"), null);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Loading a record into the form, and building the body back out
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
test("the form loads from the record and does NOT carry the code", () => {
|
|
61
|
+
const values = promoCodeFormValues({
|
|
62
|
+
_id: "652f1a2b3c4d5e6f70819293",
|
|
63
|
+
code: "WELCOME2026",
|
|
64
|
+
description: "Launch offer",
|
|
65
|
+
type: "fixed",
|
|
66
|
+
fixed_rate: 12,
|
|
67
|
+
expiresAt: "12/31/2026",
|
|
68
|
+
status: "active",
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
assert.equal((values as any).code, undefined);
|
|
72
|
+
assert.deepEqual(values, {
|
|
73
|
+
description: "Launch offer",
|
|
74
|
+
type: "fixed",
|
|
75
|
+
fixed_rate: 12,
|
|
76
|
+
expiresAt: "12/31/2026",
|
|
77
|
+
tiers: [],
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("a tiered record keeps its bands; a fixed one is given none", () => {
|
|
82
|
+
const tiered = promoCodeFormValues({
|
|
83
|
+
type: "tiered",
|
|
84
|
+
tiers: [{ min: 1, max: 10, price: 9 }, { min: 11, max: 0, price: 7 }],
|
|
85
|
+
});
|
|
86
|
+
assert.equal(tiered.tiers.length, 2);
|
|
87
|
+
assert.deepEqual(tiered.tiers[1], { min: 11, max: 0, price: 7 });
|
|
88
|
+
|
|
89
|
+
// A code stored as fixed but carrying leftover bands must not send them:
|
|
90
|
+
// `promoCodeUpdateSchema` FORBIDS `tiers` on a fixed code and would 400.
|
|
91
|
+
const fixed = promoCodeFormValues({
|
|
92
|
+
type: "fixed",
|
|
93
|
+
fixed_rate: 5,
|
|
94
|
+
tiers: [{ min: 1, max: 10, price: 9 }],
|
|
95
|
+
});
|
|
96
|
+
assert.deepEqual(fixed.tiers, []);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("an empty record gives a usable, valid, fixed-price form", () => {
|
|
100
|
+
const values = promoCodeFormValues(null);
|
|
101
|
+
assert.deepEqual(values, {
|
|
102
|
+
description: "",
|
|
103
|
+
type: "fixed",
|
|
104
|
+
fixed_rate: 0,
|
|
105
|
+
expiresAt: "",
|
|
106
|
+
tiers: [],
|
|
107
|
+
});
|
|
108
|
+
assert.equal(validatePromoCodeEdit(promoCodeUpdatePayload(values), NOW), null);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("the payload never contains code, _id or status", () => {
|
|
112
|
+
const payload = promoCodeUpdatePayload({
|
|
113
|
+
description: "Launch offer",
|
|
114
|
+
type: "fixed",
|
|
115
|
+
fixed_rate: 12,
|
|
116
|
+
expiresAt: "12/31/2026",
|
|
117
|
+
tiers: [],
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
assert.deepEqual(Object.keys(payload).sort(), [
|
|
121
|
+
"description",
|
|
122
|
+
"expiresAt",
|
|
123
|
+
"fixed_rate",
|
|
124
|
+
"type",
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("a tiered payload sends bands and no fixed_rate, and the reverse", () => {
|
|
129
|
+
const tiered = promoCodeUpdatePayload({
|
|
130
|
+
description: "",
|
|
131
|
+
type: "tiered",
|
|
132
|
+
fixed_rate: 99,
|
|
133
|
+
expiresAt: "",
|
|
134
|
+
tiers: [{ min: 1, max: 10, price: 9 }],
|
|
135
|
+
});
|
|
136
|
+
assert.deepEqual(tiered.tiers, [{ min: 1, max: 10, price: 9 }]);
|
|
137
|
+
assert.equal("fixed_rate" in tiered, false);
|
|
138
|
+
|
|
139
|
+
const fixed = promoCodeUpdatePayload({
|
|
140
|
+
description: "",
|
|
141
|
+
type: "fixed",
|
|
142
|
+
fixed_rate: 9,
|
|
143
|
+
expiresAt: "",
|
|
144
|
+
tiers: [{ min: 1, max: 10, price: 9 }],
|
|
145
|
+
});
|
|
146
|
+
assert.equal("tiers" in fixed, false);
|
|
147
|
+
assert.equal(fixed.fixed_rate, 9);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("a price nobody has typed is NOT quietly saved as free", () => {
|
|
151
|
+
// `Number("")` is 0. Coercing a blank field would turn "not filled in" into
|
|
152
|
+
// "every seat is free" - a real price, saved without anyone choosing it.
|
|
153
|
+
const payload = promoCodeUpdatePayload({
|
|
154
|
+
description: "",
|
|
155
|
+
type: "fixed",
|
|
156
|
+
fixed_rate: "" as any,
|
|
157
|
+
expiresAt: "",
|
|
158
|
+
tiers: [],
|
|
159
|
+
});
|
|
160
|
+
assert.equal(payload.fixed_rate, "");
|
|
161
|
+
assert.match(
|
|
162
|
+
String(validatePromoCodeEdit(payload, NOW)),
|
|
163
|
+
/Enter a price per seat/
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// A deliberate 0, however, is a real answer and goes through.
|
|
167
|
+
const free = promoCodeUpdatePayload({
|
|
168
|
+
description: "",
|
|
169
|
+
type: "fixed",
|
|
170
|
+
fixed_rate: 0,
|
|
171
|
+
expiresAt: "",
|
|
172
|
+
tiers: [],
|
|
173
|
+
});
|
|
174
|
+
assert.equal(free.fixed_rate, 0);
|
|
175
|
+
assert.equal(validatePromoCodeEdit(free, NOW), null);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("band numbers typed as text reach the API as numbers", () => {
|
|
179
|
+
const payload = promoCodeUpdatePayload({
|
|
180
|
+
description: "",
|
|
181
|
+
type: "tiered",
|
|
182
|
+
fixed_rate: 0,
|
|
183
|
+
expiresAt: "",
|
|
184
|
+
tiers: [{ min: "1", max: "10", price: "9.5" } as any],
|
|
185
|
+
});
|
|
186
|
+
assert.deepEqual(payload.tiers, [{ min: 1, max: 10, price: 9.5 }]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// What the form refuses, in the API's terms
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
test("a valid edit is refused nothing", () => {
|
|
194
|
+
assert.equal(
|
|
195
|
+
validatePromoCodeEdit(
|
|
196
|
+
{ type: "fixed", fixed_rate: 0, expiresAt: "", description: "" },
|
|
197
|
+
NOW
|
|
198
|
+
),
|
|
199
|
+
null
|
|
200
|
+
);
|
|
201
|
+
assert.equal(
|
|
202
|
+
validatePromoCodeEdit(
|
|
203
|
+
{
|
|
204
|
+
type: "tiered",
|
|
205
|
+
tiers: [{ min: 1, max: 10, price: 9 }, { min: 11, max: 0, price: 7 }],
|
|
206
|
+
expiresAt: "12/31/2026",
|
|
207
|
+
},
|
|
208
|
+
NOW
|
|
209
|
+
),
|
|
210
|
+
null
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("an expiry already in the past is refused, and says what to do", () => {
|
|
215
|
+
const message = validatePromoCodeEdit(
|
|
216
|
+
{ type: "fixed", fixed_rate: 5, expiresAt: "01/01/2020" },
|
|
217
|
+
NOW
|
|
218
|
+
);
|
|
219
|
+
assert.match(String(message), /already passed/);
|
|
220
|
+
assert.match(String(message), /clear the date/);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("TODAY is not in the past - a code expiring today is still savable", () => {
|
|
224
|
+
assert.equal(
|
|
225
|
+
validatePromoCodeEdit(
|
|
226
|
+
{ type: "fixed", fixed_rate: 5, expiresAt: "08/20/2026" },
|
|
227
|
+
NOW
|
|
228
|
+
),
|
|
229
|
+
null
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("no expiry at all is allowed - that is a code with no end date", () => {
|
|
234
|
+
assert.equal(
|
|
235
|
+
validatePromoCodeEdit({ type: "fixed", fixed_rate: 5, expiresAt: "" }, NOW),
|
|
236
|
+
null
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("an unreadable expiry names the format instead of guessing", () => {
|
|
241
|
+
assert.match(
|
|
242
|
+
String(
|
|
243
|
+
validatePromoCodeEdit(
|
|
244
|
+
{ type: "fixed", fixed_rate: 5, expiresAt: "next Tuesday" },
|
|
245
|
+
NOW
|
|
246
|
+
)
|
|
247
|
+
),
|
|
248
|
+
/MM\/DD\/YYYY/
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("a free BAND is refused, because Joi's positive() refuses it", () => {
|
|
253
|
+
const message = validatePromoCodeEdit(
|
|
254
|
+
{ type: "tiered", tiers: [{ min: 1, max: 10, price: 0 }] },
|
|
255
|
+
NOW
|
|
256
|
+
);
|
|
257
|
+
assert.match(String(message), /^Band 1: /);
|
|
258
|
+
assert.match(String(message), /above 0/);
|
|
259
|
+
// ...and it says the way to actually make seats free, which is the other
|
|
260
|
+
// pricing shape. `fixed_rate` is `min(0)`, not `positive()`.
|
|
261
|
+
assert.equal(
|
|
262
|
+
validatePromoCodeEdit({ type: "fixed", fixed_rate: 0 }, NOW),
|
|
263
|
+
null
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("the refused band is named, not just 'a band'", () => {
|
|
268
|
+
assert.match(
|
|
269
|
+
String(
|
|
270
|
+
validatePromoCodeEdit(
|
|
271
|
+
{
|
|
272
|
+
type: "tiered",
|
|
273
|
+
tiers: [
|
|
274
|
+
{ min: 1, max: 10, price: 9 },
|
|
275
|
+
{ min: 11, max: 5, price: 7 },
|
|
276
|
+
],
|
|
277
|
+
},
|
|
278
|
+
NOW
|
|
279
|
+
)
|
|
280
|
+
),
|
|
281
|
+
/^Band 2: /
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("a band with no upper limit is allowed - 0 means 'and above'", () => {
|
|
286
|
+
assert.equal(
|
|
287
|
+
validatePromoCodeEdit(
|
|
288
|
+
{ type: "tiered", tiers: [{ min: 5, max: 0, price: 7 }] },
|
|
289
|
+
NOW
|
|
290
|
+
),
|
|
291
|
+
null
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("a tiered code with no bands is refused, and offers the other shape", () => {
|
|
296
|
+
assert.match(
|
|
297
|
+
String(validatePromoCodeEdit({ type: "tiered", tiers: [] }, NOW)),
|
|
298
|
+
/at least one quantity band/
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("a negative fixed price is refused; a missing one is refused too", () => {
|
|
303
|
+
assert.match(
|
|
304
|
+
String(validatePromoCodeEdit({ type: "fixed", fixed_rate: -1 }, NOW)),
|
|
305
|
+
/cannot be less than 0/
|
|
306
|
+
);
|
|
307
|
+
assert.match(
|
|
308
|
+
String(validatePromoCodeEdit({ type: "fixed", fixed_rate: "" }, NOW)),
|
|
309
|
+
/Enter a price per seat/
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("a code with no pricing shape is refused first, before anything else", () => {
|
|
314
|
+
assert.equal(
|
|
315
|
+
validatePromoCodeEdit({ expiresAt: "01/01/2020" }, NOW),
|
|
316
|
+
"Choose how this code prices seats."
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// What a refusal from the server reads like
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
test("the API's own wording is what the screen shows", () => {
|
|
325
|
+
assert.equal(
|
|
326
|
+
promoCodeApiError(
|
|
327
|
+
{ response: { _data: { message: "Promo code not found." } } },
|
|
328
|
+
"Could not save."
|
|
329
|
+
),
|
|
330
|
+
"Promo code not found."
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("'Not authorized.' is replaced with something a person can act on", () => {
|
|
335
|
+
const message = promoCodeApiError(
|
|
336
|
+
{ data: { message: "Not authorized." } },
|
|
337
|
+
"Could not save."
|
|
338
|
+
);
|
|
339
|
+
assert.match(message, /Seven365 staff account/);
|
|
340
|
+
assert.match(message, /Ask a Seven365 administrator/);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("an unreadable failure falls back rather than dumping the error", () => {
|
|
344
|
+
assert.equal(
|
|
345
|
+
promoCodeApiError(new Error("fetch failed"), "Could not save the changes."),
|
|
346
|
+
"Could not save the changes."
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// The composable: verb, path and body of every write
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
const calls: Array<{ path: string; options: any }> = [];
|
|
355
|
+
|
|
356
|
+
(globalThis as any).useNuxtApp = () => ({
|
|
357
|
+
$api: (path: string, options: any = {}) => {
|
|
358
|
+
calls.push({ path, options });
|
|
359
|
+
return Promise.resolve({ message: "ok" });
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const promoCodeApi = (await import("../composables/usePromoCode.ts")).default();
|
|
364
|
+
|
|
365
|
+
function lastCall() {
|
|
366
|
+
return calls[calls.length - 1];
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const ID = "652f1a2b3c4d5e6f70819293";
|
|
370
|
+
|
|
371
|
+
test("getById reads the one record, from the path the API mounts", async () => {
|
|
372
|
+
await promoCodeApi.getById(ID);
|
|
373
|
+
assert.equal(lastCall().path, `/api/promo-codes/id/${ID}`);
|
|
374
|
+
assert.equal(lastCall().options.method, "GET");
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("update is a PUT to /api/promo-codes/:id", async () => {
|
|
378
|
+
await promoCodeApi.update(ID, {
|
|
379
|
+
description: "Launch offer",
|
|
380
|
+
type: "fixed",
|
|
381
|
+
fixed_rate: 12,
|
|
382
|
+
expiresAt: "12/31/2026",
|
|
383
|
+
} as any);
|
|
384
|
+
|
|
385
|
+
assert.equal(lastCall().path, `/api/promo-codes/${ID}`);
|
|
386
|
+
assert.equal(lastCall().options.method, "PUT");
|
|
387
|
+
assert.equal(lastCall().options.body.description, "Launch offer");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("update NEVER sends code, however it is called", async () => {
|
|
391
|
+
// The screen loads a record and could easily send it back whole. `code` is
|
|
392
|
+
// immutable on the server; sending it would be accepted and dropped, and
|
|
393
|
+
// the person would watch a rename succeed and change nothing.
|
|
394
|
+
await promoCodeApi.update(ID, {
|
|
395
|
+
code: "RENAMED",
|
|
396
|
+
type: "fixed",
|
|
397
|
+
fixed_rate: 1,
|
|
398
|
+
} as any);
|
|
399
|
+
|
|
400
|
+
assert.equal("code" in lastCall().options.body, false);
|
|
401
|
+
assert.equal(lastCall().options.body.type, "fixed");
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("updateStatus is a PATCH carrying only the new status", async () => {
|
|
405
|
+
await promoCodeApi.updateStatus(ID, "disabled");
|
|
406
|
+
assert.equal(lastCall().path, `/api/promo-codes/${ID}/status`);
|
|
407
|
+
assert.equal(lastCall().options.method, "PATCH");
|
|
408
|
+
assert.deepEqual(lastCall().options.body, { status: "disabled" });
|
|
409
|
+
|
|
410
|
+
await promoCodeApi.updateStatus(ID, "active");
|
|
411
|
+
assert.deepEqual(lastCall().options.body, { status: "active" });
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("remove is a DELETE, with no body to get wrong", async () => {
|
|
415
|
+
await promoCodeApi.remove(ID);
|
|
416
|
+
assert.equal(lastCall().path, `/api/promo-codes/${ID}`);
|
|
417
|
+
assert.equal(lastCall().options.method, "DELETE");
|
|
418
|
+
assert.equal(lastCall().options.body, undefined);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("the four reads and writes are all on the composable", () => {
|
|
422
|
+
for (const name of [
|
|
423
|
+
"add",
|
|
424
|
+
"getPromoCodes",
|
|
425
|
+
"getByCode",
|
|
426
|
+
"getById",
|
|
427
|
+
"update",
|
|
428
|
+
"updateStatus",
|
|
429
|
+
"remove",
|
|
430
|
+
]) {
|
|
431
|
+
assert.equal(
|
|
432
|
+
typeof (promoCodeApi as any)[name],
|
|
433
|
+
"function",
|
|
434
|
+
`usePromoCode() is missing ${name}`
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
});
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE STAFF CONSOLE'S PROMO CODE EDITOR, DECIDED IN ONE PLACE.
|
|
3
|
+
*
|
|
4
|
+
* A Seven365 staff member opens a code, changes what it prices or when it
|
|
5
|
+
* stops, turns it off, turns it back on, or removes it. Everything the screen
|
|
6
|
+
* has to be sure of before it sends the request is worked out here, so the
|
|
7
|
+
* form and the API can never disagree about what is wrong.
|
|
8
|
+
*
|
|
9
|
+
* WHAT THE SERVER ACTUALLY ENFORCES, read from the merged backend rather than
|
|
10
|
+
* assumed (`@7365admin1/core` `models/promo-code.model.ts` `promoCodeUpdate` /
|
|
11
|
+
* `promoCodeUpdateSchema`, mounted by `API-core` as `PUT /api/promo-codes/:id`,
|
|
12
|
+
* `PATCH /api/promo-codes/:id/status`, `DELETE /api/promo-codes/:id`):
|
|
13
|
+
*
|
|
14
|
+
* - `code` is IMMUTABLE. The update schema accepts the key and drops it,
|
|
15
|
+
* because a subscription records the promo code it was bought with as
|
|
16
|
+
* text. The form must not offer to change it, or a person would watch a
|
|
17
|
+
* save succeed and the code stay as it was.
|
|
18
|
+
* - `type` is required and is "fixed" or "tiered".
|
|
19
|
+
* - a tiered code needs at least one band; every band starts at 1 or more,
|
|
20
|
+
* ends at or after where it starts (0 means "and above"), and is priced at
|
|
21
|
+
* MORE than 0 - Joi's `positive()`, so a free band is refused.
|
|
22
|
+
* - a fixed code needs a price of 0 or more; 0 is legal and means free.
|
|
23
|
+
* - `expiresAt` is free text to the server. It refuses nothing, which is
|
|
24
|
+
* exactly why a date already in the past is caught HERE: saving one is not
|
|
25
|
+
* an error, it is a code that silently stops working. Since core #1885 the
|
|
26
|
+
* shared lookup DOES refuse an expired or disabled code at checkout, so a
|
|
27
|
+
* past date is now a real off-switch rather than a decoration.
|
|
28
|
+
*
|
|
29
|
+
* Refusals are returned one at a time and in the API's order. A list of five
|
|
30
|
+
* complaints about one form is harder to act on than the first thing to fix.
|
|
31
|
+
*
|
|
32
|
+
* Nothing here takes a card, quotes a total, or moves money. Red Dot is the
|
|
33
|
+
* payment provider (owner decision 1); a promo code sets a price per seat.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
// Imported rather than left to Nuxt's auto-import so this file also loads
|
|
37
|
+
// under plain `node --test`, the same way `theme-aa-ledger.ts` imports
|
|
38
|
+
// `theme.ts`. One reader of a failed request for the whole console.
|
|
39
|
+
import { readApiError } from "./subscription-form.ts";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `expiresAt` is stored as free text and this console writes MM/DD/YYYY, but
|
|
43
|
+
* nothing has ever stopped another caller storing ISO - so both are read.
|
|
44
|
+
* Returns the END of the expiry day: a code dated today is good all of today.
|
|
45
|
+
*
|
|
46
|
+
* Deliberately the same rule as the org app's `utils/promo-code.js` and core's
|
|
47
|
+
* `promo-code-currency.util.ts`. The three are separate copies on purpose -
|
|
48
|
+
* the org app's is plain JS so `node --test` loads it directly, and core's
|
|
49
|
+
* runs on the server - but they must never disagree in front of a customer,
|
|
50
|
+
* so any change to one belongs in all three.
|
|
51
|
+
*/
|
|
52
|
+
export function promoExpiryDate(value: any): Date | null {
|
|
53
|
+
if (!value) return null;
|
|
54
|
+
const raw = String(value).trim();
|
|
55
|
+
if (!raw) return null;
|
|
56
|
+
|
|
57
|
+
const parts = raw.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
|
58
|
+
if (parts) {
|
|
59
|
+
const [, m, d, y] = parts;
|
|
60
|
+
const date = new Date(Number(y), Number(m) - 1, Number(d), 23, 59, 59, 999);
|
|
61
|
+
// `new Date(2026, 12, ...)` rolls into the next year rather than failing.
|
|
62
|
+
return date.getMonth() === Number(m) - 1 ? date : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const iso = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
66
|
+
if (iso) {
|
|
67
|
+
const [, y, m, d] = iso;
|
|
68
|
+
const date = new Date(Number(y), Number(m) - 1, Number(d), 23, 59, 59, 999);
|
|
69
|
+
return date.getMonth() === Number(m) - 1 ? date : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const parsed = new Date(raw);
|
|
73
|
+
return isNaN(parsed.getTime()) ? null : parsed;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Nothing typed yet. Not the same thing as a zero, which is a real price. */
|
|
77
|
+
function isBlank(value: any): boolean {
|
|
78
|
+
return value === "" || value === null || value === undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** What the edit form holds while a person is filling it in. */
|
|
82
|
+
export interface TPromoCodeFormValues {
|
|
83
|
+
description: string;
|
|
84
|
+
type: "fixed" | "tiered";
|
|
85
|
+
fixed_rate: number;
|
|
86
|
+
expiresAt: string;
|
|
87
|
+
tiers: Array<{ min: number; max: number; price: number }>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Pre-fill the form from the record the API sent back.
|
|
92
|
+
*
|
|
93
|
+
* `code` is not a form value - it is not editable, so it is displayed from the
|
|
94
|
+
* record and never carried through the form where a change could reach it.
|
|
95
|
+
*/
|
|
96
|
+
export function promoCodeFormValues(
|
|
97
|
+
record: Record<string, any> | null | undefined
|
|
98
|
+
): TPromoCodeFormValues {
|
|
99
|
+
const promo = record ?? {};
|
|
100
|
+
const type = promo.type === "tiered" ? "tiered" : "fixed";
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
description: String(promo.description ?? ""),
|
|
104
|
+
type,
|
|
105
|
+
fixed_rate: Number(promo.fixed_rate ?? 0),
|
|
106
|
+
expiresAt: String(promo.expiresAt ?? ""),
|
|
107
|
+
tiers:
|
|
108
|
+
type === "tiered" && Array.isArray(promo.tiers)
|
|
109
|
+
? promo.tiers.map((tier: any) => ({
|
|
110
|
+
min: Number(tier?.min ?? 1),
|
|
111
|
+
max: Number(tier?.max ?? 0),
|
|
112
|
+
price: Number(tier?.price ?? 0),
|
|
113
|
+
}))
|
|
114
|
+
: [],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The body `PUT /api/promo-codes/:id` takes, and nothing more.
|
|
120
|
+
*
|
|
121
|
+
* No `code`, no `_id`, no `status`. The API would accept and ignore all three;
|
|
122
|
+
* not sending them means this screen cannot appear to change something it
|
|
123
|
+
* cannot. `status` in particular has its own endpoint - folding it in here
|
|
124
|
+
* would give two answers to "is this code on".
|
|
125
|
+
*/
|
|
126
|
+
export function promoCodeUpdatePayload(
|
|
127
|
+
values: TPromoCodeFormValues
|
|
128
|
+
): Record<string, any> {
|
|
129
|
+
const payload: Record<string, any> = {
|
|
130
|
+
description: values.description ?? "",
|
|
131
|
+
type: values.type,
|
|
132
|
+
expiresAt: values.expiresAt ?? "",
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// The schema FORBIDS `tiers` on a fixed code, so each shape sends only the
|
|
136
|
+
// half that applies to it. `fixed_rate` is allowed either way but means
|
|
137
|
+
// nothing on a tiered code, and the server zeroes it there regardless.
|
|
138
|
+
if (values.type === "tiered") {
|
|
139
|
+
payload.tiers = (values.tiers ?? []).map((tier) => ({
|
|
140
|
+
min: Number(tier.min),
|
|
141
|
+
max: Number(tier.max),
|
|
142
|
+
price: Number(tier.price),
|
|
143
|
+
}));
|
|
144
|
+
} else {
|
|
145
|
+
// A blank price is passed through blank rather than quietly becoming 0.
|
|
146
|
+
// `Number("")` is 0, so coercing here would turn "they have not typed a
|
|
147
|
+
// price yet" into "every seat is free" - a real price, silently saved.
|
|
148
|
+
// The API refuses a blank `fixed_rate` too, so the two agree.
|
|
149
|
+
payload.fixed_rate = isBlank(values.fixed_rate)
|
|
150
|
+
? ""
|
|
151
|
+
: Number(values.fixed_rate);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return payload;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Everything that must be true before the request goes out. Returns the ONE
|
|
159
|
+
* thing to tell the person, or `null` when there is nothing to tell them.
|
|
160
|
+
*/
|
|
161
|
+
export function validatePromoCodeEdit(
|
|
162
|
+
payload: Record<string, any>,
|
|
163
|
+
now: Date = new Date()
|
|
164
|
+
): string | null {
|
|
165
|
+
if (payload?.type !== "fixed" && payload?.type !== "tiered") {
|
|
166
|
+
return "Choose how this code prices seats.";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (payload.type === "fixed") {
|
|
170
|
+
const rate = Number(payload.fixed_rate);
|
|
171
|
+
if (isBlank(payload.fixed_rate) || !Number.isFinite(rate)) {
|
|
172
|
+
return "Enter a price per seat. Use 0 to make every seat free.";
|
|
173
|
+
}
|
|
174
|
+
if (rate < 0) {
|
|
175
|
+
return "The price per seat cannot be less than 0.";
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (payload.type === "tiered") {
|
|
180
|
+
const tiers = Array.isArray(payload.tiers) ? payload.tiers : [];
|
|
181
|
+
if (!tiers.length) {
|
|
182
|
+
return "Add at least one quantity band, or switch this code to one price for every seat.";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (let i = 0; i < tiers.length; i++) {
|
|
186
|
+
const band = tiers[i] ?? {};
|
|
187
|
+
const where = "Band " + (i + 1) + ": ";
|
|
188
|
+
const min = Number(band.min);
|
|
189
|
+
const max = Number(band.max);
|
|
190
|
+
const price = Number(band.price);
|
|
191
|
+
|
|
192
|
+
if (!Number.isInteger(min) || min < 1) {
|
|
193
|
+
return where + "a band has to start at 1 seat or more.";
|
|
194
|
+
}
|
|
195
|
+
if (!Number.isInteger(max) || max < 0) {
|
|
196
|
+
return (
|
|
197
|
+
where +
|
|
198
|
+
"the upper limit has to be a whole number of seats, or no limit."
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
// `max: 0` is how the band editor stores "and above" - not a band that
|
|
202
|
+
// ends at zero seats, which is why the API allows it.
|
|
203
|
+
if (max !== 0 && max < min) {
|
|
204
|
+
return (
|
|
205
|
+
where + "the upper limit cannot be lower than where the band starts."
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (!Number.isFinite(price) || price <= 0) {
|
|
209
|
+
return (
|
|
210
|
+
where +
|
|
211
|
+
"every band needs a price above 0. To make seats free, use one price for every seat and set it to 0."
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (payload.expiresAt) {
|
|
218
|
+
const expiry = promoExpiryDate(payload.expiresAt);
|
|
219
|
+
if (!expiry) {
|
|
220
|
+
return "The expiry date is not a date we can read. Use MM/DD/YYYY.";
|
|
221
|
+
}
|
|
222
|
+
if (expiry.getTime() < now.getTime()) {
|
|
223
|
+
return "That expiry date has already passed, so the code would stop working the moment it is saved. Pick a date in the future, or clear the date to remove the expiry.";
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Turn a refused request into something a person can act on.
|
|
232
|
+
*
|
|
233
|
+
* `readApiError` is what every other console screen uses, and the API's promo
|
|
234
|
+
* messages are already written to be read - "Promo code not found.", "This
|
|
235
|
+
* promo code has expired." Only one answer needs rewording: the staff guard
|
|
236
|
+
* says "Not authorized.", which is true and tells nobody what to do next.
|
|
237
|
+
*/
|
|
238
|
+
export function promoCodeApiError(error: any, fallback: string): string {
|
|
239
|
+
const message = readApiError(error, fallback);
|
|
240
|
+
|
|
241
|
+
if (/^not authorized\.?$/i.test(String(message).trim())) {
|
|
242
|
+
return "Your account is not a Seven365 staff account, so it cannot change promo codes. Ask a Seven365 administrator to make the change.";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return message;
|
|
246
|
+
}
|