@7365admin1/layer-common 3.2.8-staging.210 → 3.3.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/CHANGELOG.md +537 -0
- package/components/HidQrCodeConfiguration.vue +1 -1
- package/components/HidReaderManagement.vue +1 -0
- package/components/HidUserEnrollment.vue +1 -1
- package/composables/useHidAmico.ts +5 -12
- package/composables/usePromoCode.ts +0 -57
- package/package.json +1 -1
- package/utils/hid-discover.test.ts +2 -15
- package/utils/promo-code-form.test.ts +0 -437
- package/utils/promo-code-form.ts +0 -246
package/utils/promo-code-form.ts
DELETED
|
@@ -1,246 +0,0 @@
|
|
|
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
|
-
}
|