@7365admin1/layer-common 3.2.8-staging.209 → 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 CHANGED
@@ -1,5 +1,542 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 3.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 078521d: Add a dedicated HID Cards module with PACS and CSN assignment, card enrollment
8
+ at an Amico reader, removal, and device-backed card status. HID Users keeps the
9
+ same card action, and the Card identification toggle now updates the reader
10
+ configuration as well as site metadata.
11
+ - f368275: Add reader capability and mode configuration, site-linked HID identities,
12
+ reader-profile physical card handling, and device-backed pagination for HID
13
+ users and access logs.
14
+ - 330e103: Draw icons with the redesign's Material Symbols Outlined font instead of Material Design Icons.
15
+
16
+ Call sites are unchanged - a component still asks for `mdi-close`. The Vuetify icon set
17
+ translates 233 names (the 204 used here plus Vuetify's own internal aliases) into the
18
+ Material Symbols ligature; anything unmapped still renders through Iconify as before, so no
19
+ icon can come out as a blank box. Every consuming application picks this up with no change
20
+ of its own.
21
+
22
+ Also in this release:
23
+
24
+ - `TableWithButton`, `ScheduleTaskAreaUpdateMoreAction` and `ScheduleTaskAreaFormDialog` are
25
+ **removed** - no repository in the organisation referenced them.
26
+ - The published tarball now carries only what a consumer loads (`files`), not the changesets,
27
+ CI workflows, tests and render harness.
28
+ - The check-out label in `VisitorsReportPreview` asked for a `--error` token that does not
29
+ exist and inherited its colour; it now uses `--err`.
30
+
31
+ - bb18a57: Make the Pending tab's invitation actions reachable, and say which state an invitation is really in
32
+
33
+ The row menu in the service-provider list was drawn only for provider rows, so a
34
+ pending invitation offered no actions at all — including the cancel that was
35
+ already wired up behind it. Invitation rows now carry Cancel, Resend and Delete,
36
+ each behind a confirmation that names the company and the site, and Resend is
37
+ disabled with the reason while Seven365 has not approved the invitation yet.
38
+
39
+ Every invitation also used to read "Pending" whatever had happened to it. Rows
40
+ now show the state they are actually in — Waiting for Seven365, Waiting for the
41
+ provider, Not approved, Declined, Cancelled, Expired — in those words, with
42
+ Seven365's reason shown underneath a rejected one.
43
+
44
+ Also here: a new ServiceProviderInvitationPrompt, which is how an existing
45
+ provider answers an invitation from inside the app they already use, with no
46
+ sign-up and no one-time code.
47
+
48
+ Three contrast fixes found while rendering these screens: the list's tab strip
49
+ was a hardcoded light grey that turned into a white slab in dark mode and took
50
+ the tab labels with it; the service type column read the raw stored slug
51
+ ("security_agency") instead of "Security"; and `on-warning`, `on-info` and
52
+ dark's `on-error` are now set explicitly, because Vuetify derives white for them
53
+ and that measures 2.37:1, 3.12:1 and 3.60:1 on those fills — every filled
54
+ warning, info and error chip, alert and button in the product.
55
+
56
+ ### Patch Changes
57
+
58
+ - 4ee5fb9: Fix the attach-file icon rendering too faint in `FileInputWithList.vue`.
59
+
60
+ The `mdi-paperclip` prepend icon was drawing at Vuetify's default
61
+ `.v-input__prepend > .v-icon { opacity: var(--v-medium-emphasis-opacity) }`
62
+ (0.64) - the same field-icon dimming roughly 200 other call sites across this
63
+ package intentionally rely on (see `utils/theme.ts`), so this is fixed only
64
+ inside `FileInputWithList.vue`, not in the shared token.
65
+
66
+ The icon now renders at full opacity in `--text2`, the same colour
67
+ `.screen-icon-btn` already uses, so it reads at the same weight as a sibling
68
+ icon drawn outside a Vuetify field (which was never subject to this opacity
69
+ rule in the first place). No other icon in this component sits inside
70
+ `.v-input__prepend`/`.v-input__append`, so nothing else here was affected.
71
+
72
+ - 84d6a1f: Top bar breadcrumb: name the page, not the folder in front of the context
73
+ parameter. Every screen of the organization application read "Org", because the
74
+ route name alone cannot tell that `org` in `/org/:organization/...` is a folder
75
+ and not a parameter. The rule moves to `utils/breadcrumb.ts` with unit tests
76
+ covering each application's route shapes; the site applications are unchanged.
77
+ - dd0849e: Draw the camera wall from the gated wall endpoint, and show the server's own
78
+ reason on a tile.
79
+
80
+ `CameraWall` fetched its cameras from `GET /api/site-cameras` - the Settings
81
+ panel's paginated CRUD list, which took its site from an optional query
82
+ parameter and checked nothing about the caller. Any signed-in user from any
83
+ organisation could read every camera in the estate through it, `host` and
84
+ `username` included.
85
+
86
+ It now calls `GET /api/site-cameras/site/:siteId/wall`, which already exists,
87
+ which the React Native monitoring app was built against, and which is
88
+ authorised server-side: the caller must be a member of the site, of its owning
89
+ organisation, or work for an organisation actively engaged to serve it. That
90
+ endpoint also returns `type: "ip"` cameras only and each camera's capability
91
+ descriptor, so a wall cannot be handed an ANPR unit and a tile can explain
92
+ itself.
93
+
94
+ Two consequences worth stating:
95
+
96
+ - **The pager is gone.** The wall endpoint returns the site's cameras in one
97
+ answer. Paging a video wall was an artefact of borrowing the CRUD list.
98
+ - **A tile now prefers the server's `unavailableReason`** over the reason it
99
+ used to work out itself. The server knows things the browser cannot - chiefly
100
+ "No recorder is configured for this camera's relay", which is the true answer
101
+ for every camera in the estate until the API host is configured, and which the
102
+ wall used to replace with its own guess. Offline still beats everything, since
103
+ it explains every tile at once.
104
+
105
+ No permission gate was invented here. Each application still gates its own menu
106
+ entry; this component draws what it is given, and the server decides.
107
+
108
+ Two smaller things in the same area:
109
+
110
+ - **A refused wall now says so.** The catch-all message told every failure to
111
+ "check your connection", which sends somebody who simply may not see that
112
+ site off to debug their wifi. A 401/403/404 now reads "You do not have access
113
+ to this site's cameras." The server answers "not yours" and "does not exist"
114
+ identically, so this wording does not distinguish them either.
115
+ - **`middleware/member.ts` is removed.** It read a cookie into an unused
116
+ variable and did nothing else - a file named like a membership gate that was
117
+ not one. No page in any of the eleven web apps referenced it. The real check
118
+ is `plugins/secure-member.client.ts`, driven by `memberOnly` page meta, which
119
+ is untouched.
120
+
121
+ - f3fb808: Rewrite the camera wall's detail panel for the people who read it, and stop
122
+ repeating the same sentence four times.
123
+
124
+ The panel under the tiles was written for a developer. On the live wall it
125
+ read:
126
+
127
+ > BBQ AREA
128
+ > The video page loaded, but this browser cannot confirm a picture is arriving.
129
+ > The player belongs to the video service and a browser cannot look inside it.
130
+ > Judge the tile by what you can see, and report a blank or frozen picture.
131
+ > ● Recorder responding Last picture this server received: none yet
132
+ > Firmware and device clock are not available: this recorder is reachable over
133
+ > video only.
134
+
135
+ Every one of those statements is true, and the screen was still wrong: it
136
+ explained cross-origin isolation to a guard, repeated the camera's name the
137
+ tile already showed, put a green light beside the words "none yet", and printed
138
+ the same reason on four capability cards in a row.
139
+
140
+ Changes, all copy and layout — **no behaviour, no capability gating and no
141
+ permission check is touched**:
142
+
143
+ - **The badge reads "Not confirmed"**, not "Not verified". "Not verified"
144
+ describes a check we failed to make; "Not confirmed" describes the picture,
145
+ which is the thing the reader cares about. Same claim, and the wall still
146
+ refuses to say "Live" until the video service reports a decoded frame.
147
+ - **The sentence behind it is now an instruction**: "This page cannot confirm
148
+ the picture is arriving, so check the tile yourself. If it is blank or frozen,
149
+ report the camera." Why the software cannot confirm it is our problem, and it
150
+ has been removed from the product.
151
+ - **The camera's name is no longer a heading of its own.** It is the subject of
152
+ the status line — "BBQ AREA · Recorder responding" — so a reader on a 3x3 wall
153
+ still knows which tile the panel describes, without a second caption under the
154
+ first one.
155
+ - **"Last picture" is drawn only when there has been one.** A green "Recorder
156
+ responding" beside "none yet" read as a contradiction. They were never in
157
+ conflict, but a screen that has to be explained is wrong. The tile's own
158
+ health line has always behaved this way; the panel now matches it.
159
+ - **The firmware-and-device-clock sentence is gone**, from the panel and from
160
+ the tile's tooltip. This screen never showed either value, so explaining their
161
+ absence gave the reader nothing to do. The server still sends
162
+ `detailUnavailableReason`; nothing draws it.
163
+ - **One reason for the group instead of four copies.** When every switched-off
164
+ control is off for the same reason, that reason and its next step are stated
165
+ once above the row and each card keeps only its title and its state. When the
166
+ reasons differ — "the recorder is unreachable" and "this camera cannot move"
167
+ are two problems with two different people to ask — the group note is refused
168
+ and each card carries its own again. New rule 7, in `camera-wall.ts`, with
169
+ four tests.
170
+ - Shorter next-step and no-signal wording, and a subject on the recorder's
171
+ status labels ("Recorder not checked yet" rather than "Health not checked
172
+ yet").
173
+
174
+ Rules 3 and 6 are intact: the server's own sentence is still shown verbatim and
175
+ never rewritten, and the recorder's state is still a separate fact from the
176
+ tile's badge.
177
+
178
+ - e177e9e: Mark the selected camera on the wall, and stop telling the reader to raise a
179
+ ticket without saying what for.
180
+
181
+ Two things the owner found on the live wall after the last copy pass. Both are
182
+ copy and presentation only — **no behaviour, no capability gating, no permission
183
+ check and no request is changed.**
184
+
185
+ **1. The camera's name was still printed twice.**
186
+
187
+ The tile said `BBQ AREA` and the panel underneath said it again. The last pass
188
+ merged the panel's heading into its status line, which changed the layout and
189
+ left the repetition exactly where it was.
190
+
191
+ The name was there to answer "which of these nine tiles is this panel about?".
192
+ That is a real question and a name is the wrong answer to it — it makes the
193
+ reader scan the wall for a matching caption. So:
194
+
195
+ - **the selected tile is now visibly selected**: a 3 px accent ring with a scrim
196
+ hairline inside it, so the ring holds against a bright picture as well as a
197
+ dark one, plus the accent underline on the name chip that the toolbar already
198
+ uses to mark a selected tool. Both read at 1x1, 2x2 and 3x3;
199
+ - **the camera's name is removed from the panel entirely**. The panel opens on
200
+ the recorder's state — "● Recorder responding";
201
+ - `aria-current` marks the same tile for anyone not looking at the ring.
202
+
203
+ The highlight had never worked in the most common case, which is why the name
204
+ was load-bearing: it was bound to the raw clicked id, and before anyone clicks —
205
+ or after paging — nothing on the wall was marked while the panel was already
206
+ describing the first tile. It is now bound to the tile the panel actually
207
+ resolves to, so the two can never disagree.
208
+
209
+ **The panel can never be about a tile that is off screen**, so it never needs to
210
+ name one: the focused camera is resolved out of the current PAGE, so paging away
211
+ from a selection re-resolves to the first tile of the page in front of you and
212
+ the ring follows. Paging back restores the original selection. Extracted as
213
+ `focusedCamera()` in `camera-wall.ts` with four tests, including the 5-cameras
214
+ -across-2-pages case.
215
+
216
+ **2. "Ask your Seven365 administrator" was a dead end.**
217
+
218
+ It was the whole of the advice under every switched-off control, and it tells
219
+ the administrator nothing and a property manager only to go and wait. The screen
220
+ never said what the request should ask for.
221
+
222
+ The advice now names the two changes that are actually missing, because they are
223
+ two different jobs for two different people: **the site's network has to let our
224
+ server reach the camera recorder**, and **that recorder then has to be added to
225
+ the server's configuration**. Both are stated in plain words, with who arranges
226
+ them, and with the fact that neither is switchable from this page.
227
+
228
+ No environment variable name appears in any of it — those belong in
229
+ `iservice365-core/docs/camera-integration-config.md`, not on a property
230
+ manager's screen — and there is a test that keeps it that way. The other reason
231
+ codes (`device-http-disabled`, `device-http-unreachable`, `device-http-locked-out`,
232
+ `control-not-enabled`, `no-recorder-configured`, and the unknown-code fallback)
233
+ get the same treatment; the Site Settings ones already said what to do and are
234
+ unchanged.
235
+
236
+ The truthful-status rules are untouched: the wall still refuses to say "Live"
237
+ until the video service reports a decoded frame, and the recorder's state is
238
+ still a separate fact from the tile's badge.
239
+
240
+ - cf29add: Show each client's nature of business on the super-admin Client List, so the
241
+ one screen says who a client is, what kind of business they are, how many sites
242
+ they have and where their subscription stands.
243
+
244
+ The Client List and the admin app's Organizations list are the same screen for
245
+ the same job, and the Client List is the one that stays. Organizations showed
246
+ one thing the Client List did not: the organisation's **nature** — Property
247
+ Management Agency, Security Agency, Cleaning Services, and so on. That is what
248
+ a client actually _is_, and the list had no way to say it.
249
+
250
+ The value was already arriving. `GET /api/organizations/v2/orgs/subscriptions`
251
+ has always projected `nature`; the screen simply never read it. So this is one
252
+ column, no backend change, printed with the same `formatNature` helper the
253
+ Organizations list uses, so both screens say a nature the same way while both
254
+ exist.
255
+
256
+ Two changes pay for the width, because the table was already 1389px in a
257
+ 1386px viewport and a new column would have pushed it to 1405px:
258
+
259
+ - **The email moved under the organisation name.** It identifies the same
260
+ client the name does and had a full column to itself.
261
+ - **The Status column is gone.** The table is fetched with the status the
262
+ chosen tab asks for, so on the Active tab every row said "active" and on
263
+ Suspended every row said "suspended" — a column that could only ever repeat
264
+ the tab the reader was standing on. The status is still on the screen: it is
265
+ the tab, and the row menu offers Suspend or Activate accordingly.
266
+
267
+ Measured after the change: **1332px at a 1386px viewport and 1386px at 1440px,
268
+ with no horizontal overflow at either** — the first time this table has fitted
269
+ its own page.
270
+
271
+ A client whose record carries no nature reads "Not recorded" rather than
272
+ leaving a blank cell. `nature` is a required enum on the organisation model, so
273
+ that is defensive cover for older records, not an expected state.
274
+
275
+ No filter was added for it. The list's `$match` has no `nature` term, so the
276
+ only filter that could be built here would filter the ten rows on screen and
277
+ silently ignore the rest — the same kind of control that was removed from this
278
+ screen for pretending to work. A real one needs the aggregation to accept
279
+ `nature` first.
280
+
281
+ - e100fc9: Make the super-admin Client List say what a client's subscription actually is,
282
+ instead of printing "-".
283
+
284
+ `ClientMain.vue` showed a dash in Plan Type, Billing Cycle, Subscription Start
285
+ and Subscription End for every client with no subscription document - which is
286
+ most of them. Four dashes read as a broken screen. It is one ordinary state:
287
+ nobody has set that client up yet.
288
+
289
+ `utils/client-subscription.ts` derives the state from the row the endpoint
290
+ actually returns and never claims more than the record proves:
291
+
292
+ | Record says | Screen says |
293
+ | ------------------------------ | --------------------------- |
294
+ | no subscription document | No subscription set up |
295
+ | running | Active |
296
+ | `billingMode: "paid"` | Active (paying) |
297
+ | `billingMode: "complimentary"` | Complimentary (not charged) |
298
+ | `status: "suspended"` | Suspended |
299
+ | `status` ended / canceled | Ended |
300
+ | end date already passed | Ended (needs attention) |
301
+
302
+ Nothing sends a charging arrangement today, so a live record reads as plain
303
+ "Active" - not "paying", because nothing in the data proves that.
304
+
305
+ Removed as dead or misleading:
306
+
307
+ - The "Filter by plan type" dropdown. It was fed organization _nature_ values
308
+ (`security_agency`, `cleaning_services`, ...) and sent as `type`, which the
309
+ aggregation applies to `subscription.type` - whose only legal values are
310
+ `organization` and `affiliate`. Every option but "All" returned zero rows.
311
+ - `planTypeLabel`'s `sub.paidType` branch (the field exists in no repo) and its
312
+ `sub.description` branch (not in the endpoint's projection).
313
+ - `getSubscriptionByOrgId`, destructured and never called.
314
+
315
+ `ClientDetailForm.vue`'s Subscription Information section was six dropdowns and
316
+ two date boxes that looked editable and saved nothing - the plan list was two
317
+ invented names, and Start/End were always blank because nothing ever loaded
318
+ them. It now shows the same derived values as the list, read-only. The write
319
+ path is unchanged: Submit still saves the organisation name and contact.
320
+
321
+ Two smaller display fixes on the list: the two date column headers are "Start
322
+ Date" and "End Date" rather than "Subscription Start"/"Subscription End", and
323
+ the Sites badge is the count rather than "3 Sites" under a "Sites" header
324
+ (which also used to read "1 Sites"). Both were needed to keep the Actions
325
+ column on screen at 1440px.
326
+
327
+ - c256755: Let a Seven365 staff member actually set up and edit a client's subscription
328
+ from the super-admin Client List.
329
+
330
+ Phase 1 made that list tell the truth: a client nobody had set up read "No
331
+ subscription set up" instead of four dashes. There was still nothing anywhere
332
+ that could set one up. The API endpoints now exist
333
+ (`POST`/`PUT`/`GET /api/subscriptions/console/:id`, staff-only, `:id` being the
334
+ client's organisation), so this is the screen in front of them.
335
+
336
+ **What a person can now do**
337
+
338
+ - From a row that has no subscription: **Set up subscription**, straight off
339
+ the row.
340
+ - From the row menu, or from the client's own drawer: **Edit subscription** -
341
+ plan, start date, end date, and whether the client is complimentary or
342
+ paying.
343
+ - The list re-reads itself after a save, so the row shows the new state
344
+ immediately, using the same Phase 1 derivation (`utils/client-subscription.ts`).
345
+
346
+ **Four questions, and nothing else asked**
347
+
348
+ Plan, charging (complimentary or paying), start date, end date. Everything
349
+ else is worked out: a plan is priced per site, per month, so the form shows the
350
+ per-site price, this client's live site count and the monthly value that comes
351
+ out of the two - already multiplied, with "not charged - complimentary" beside
352
+ it when the client is free. A client with no sites yet is 0 and is said so
353
+ plainly rather than refused.
354
+
355
+ **Complimentary is the default**, because every client today is complimentary.
356
+ It is a real arrangement with real dates Seven365 monitors, not an absence of
357
+ one, and it is exactly as quick to set as a paying subscription.
358
+
359
+ **Refused in the form, before the request goes out** - in the API's own words,
360
+ so the two can never disagree: no plan chosen; a plan that does not exist or is
361
+ no longer active (named); no end date ("Every client has one, free or not."); an
362
+ end date on or before the start date; an unreadable date; a complimentary
363
+ subscription carrying a price. When the server refuses anyway, its message is
364
+ what the screen shows.
365
+
366
+ **Deliberately not here:** no card field, no payment step, no gateway - Red Dot
367
+ takes payment, and none of it belongs on a staff screen. No suspend and no
368
+ reactivate: that is Phase 3, and the API's edit endpoint does not accept a
369
+ status, so there is no control here that could set one. The drawer's
370
+ Subscription Information stays read-only; the button beside it opens this form.
371
+
372
+ `useSubscription` gains `getConsoleSubscriptionByOrgId`,
373
+ `setUpConsoleSubscription` and `updateConsoleSubscription`. Nothing existing
374
+ changed shape.
375
+
376
+ - 6b008c4: Split the dark theme's `primary` into a foreground colour and a fill, so text
377
+ using it is readable again.
378
+
379
+ Dark `primary` was set to the brand navy `#17506F` so the navigation drawer
380
+ would read as a surface. That is the right value for a fill — a white label on
381
+ it measures 8.70:1 — but Vuetify emits `.text-primary` from the same token, so
382
+ every `class="text-primary"` sentence and every `color="primary"` icon, spinner
383
+ and text button rendered navy on a dark background:
384
+
385
+ | | before | now |
386
+ | ----------------------------------------------- | ------ | ---------- |
387
+ | `primary` as text on the dark page `#0E1319` | 2.14:1 | **6.40:1** |
388
+ | `primary` as text on a dark card `#1B242F` | 1.80:1 | **5.38:1** |
389
+ | `primary` as text on `surface-bright` `#26313E` | 1.52:1 | **4.53:1** |
390
+
391
+ Dark `primary` is now `#5B9BE0`. That is not a new colour: it is the blue the
392
+ camera wall already draws its accents in (`--vms-accent`), so the two dark
393
+ surfaces in this product agree on one blue.
394
+
395
+ The navigation drawer no longer depends on `primary`. Both themes gain a
396
+ `brand-surface` colour — `#042134` light (identical to light `primary`, so
397
+ light mode renders exactly as before) and `#17506F` dark — and
398
+ `Layout/NavigationDrawer.vue` is painted with it. Its white label still reads
399
+ 16.51:1 light and 8.70:1 dark. `floating` was dropped from the drawer so it
400
+ draws its own edge: the fill is 2.14:1 against the dark page, under the 3:1 a
401
+ boundary wants, and the border at `border-opacity` 0.35 is 3.22:1.
402
+
403
+ Where `primary` is still used as a fill, Vuetify derives `on-primary` from it
404
+ and picks black, which reads at 7.21:1. What a light `primary` cannot carry is
405
+ a hardcoded white label, and a handful of app-side sites still set one — see
406
+ the pull request for the list.
407
+
408
+ The light theme is unchanged. The colours moved to `utils/theme.ts` and
409
+ `utils/theme.test.ts` now measures them, so a token cannot be moved for one use
410
+ and quietly broken for the other again.
411
+
412
+ - 626d1d7: Dashboard and filled-primary surfaces now follow the theme in dark mode.
413
+
414
+ - `DashboardMain` painted its panels, list rows and text with fixed hex values,
415
+ so the ACTIVE PATROL / STAFF STATUS / Feedbacks cards rendered white on the
416
+ dark page and the heading and KPI figures were dark grey on near-black
417
+ (1.19:1). They now use the theme's surface and foreground tokens.
418
+ - `DARK_THEME` had no `on-primary`, so Vuetify derived white for every filled
419
+ `primary` button, chip, alert, tab bar and snackbar - 2.91:1. It is now set
420
+ explicitly and measures 6.40:1.
421
+
422
+ The print / PDF / Word export styles are unchanged and stay ink-on-white.
423
+
424
+ - a39ce7f: Fix four hard-coded route names that do not exist in the apps that render them.
425
+
426
+ This layer is extended by apps whose pages sit at different depths, so a route
427
+ name written literally here is correct in some consumers and unknown in the
428
+ rest - and `router.push({ name })` on an unknown name throws, taking the click
429
+ (and, in a nav list, its neighbours) with it. Route names are now resolved
430
+ against the running app's own router via `utils/route-name.ts`
431
+ (`pickRouteName`), which returns the first candidate the app actually has, or
432
+ `null` so the caller can hide the control rather than navigate somewhere wrong.
433
+
434
+ - `QrTemplate/PrintDialog.vue` pushed `keys-visitor-pass-add`, which no
435
+ consumer has; both apps that render this dialog nest the page under
436
+ `[org]/[site]`. It also sent `site` as a query parameter where the
437
+ destination reads `org` and `site` as path parameters, so neither was
438
+ filled. Generating a pass from the print dialog threw.
439
+ - `ServiceProviderMain.vue` always rendered a Billing button
440
+ (`showBillingButton` defaults to `true`) pointing at
441
+ `org-site-service-provider-mgmt-billing`, a page only
442
+ web-app-property-management has. The button now renders only where the page
443
+ exists; property-management is unchanged.
444
+ - `require-customer.vue` used `org-organizations-customers-add`; the org app's
445
+ page is `pages/org/[organization]/customers/add.vue`, whose name is
446
+ `org-organization-customers-add` - singular. The only way off that dead-end
447
+ page threw. Every other app already took the cross-app URL branch and was
448
+ unaffected.
449
+ - `WorkOrder/Main.vue` used `work-order-details`, which no consumer has; the
450
+ detail screen is `org-site-work-orders-id`. No app currently renders this
451
+ component, so nothing was user-visible.
452
+
453
+ `require-organization-membership.vue` was reported alongside these and is NOT
454
+ changed: `organizations-create` is a real route in web-app-org, and that branch
455
+ only runs there.
456
+
457
+ - ae59ec4: Say when the Service Providers list and the Attendance Settings dialog failed to load, instead of showing an empty screen
458
+
459
+ Both surfaces treated a failed request as "there is nothing here". The Service Providers table defaulted a bad response to `[]` and drew the standard "No data available" row; the Attendance Settings dialog read its fields off whatever came back and, when that was not an object, simply opened with geofencing off and every field blank. Neither told the operator anything had gone wrong.
460
+
461
+ The table now renders the shared `DashboardEmptyState` in its error tone when the last load failed, and the dialog shows an alert saying what is on screen is not what is saved.
462
+
463
+ - 82d0091: Send the site with HID reader discovery. `HidReaderForm` now receives the site
464
+ from `HidReaderManagement` (which already has it from the page route) and
465
+ includes it in the `readers/discover` request, so the endpoint can authorize the
466
+ caller against that site. The request falls back to the old body once, and only
467
+ on the unknown-field validation error, so discovery keeps working against an API
468
+ that has not picked the change up yet.
469
+ - c887977: Add a Notification Settings screen, shared by every web app.
470
+
471
+ A person can now choose, per module and per delivery route, what they hear
472
+ about: in the app, on their phone, or by email. The screen only lists modules
473
+ their role actually gives them, and a route a module cannot use is shown dimmed
474
+ with the reason in plain words rather than hidden.
475
+
476
+ Modules are collapsed by default with a one-line summary of what is on, so
477
+ somebody with many modules gets a short list instead of thirty switches. There
478
+ is a master control for everything and one per module.
479
+
480
+ The choices are enforced where notifications are sent, in `@7365admin1/core` and
481
+ in the API, so a switch here actually stops the message.
482
+
483
+ - 5089b71: Group the sidebar's sections and give the list screens the design's page
484
+ scaffold.
485
+
486
+ - `withSections()` walked each application's menu in the order it was given and
487
+ printed a heading whenever the section changed, so Property Management drew
488
+ SERVICE DESK, PROPERTY and SECURITY OPERATIONS **twice each**. Items are now
489
+ grouped under one heading per section, in the order the design lists them,
490
+ with the application's own order kept inside each section. No menu item is
491
+ added, hidden, renamed, re-routed or re-gated.
492
+ - `ServiceProviderMain`, `FeedbackMain`, `InvitationMain` and `WorkOrder/Main`
493
+ had no page title, put their primary action top-left, floated the search
494
+ outside the card, and stacked the refresh/pager on a bar above the tabs. They
495
+ now use `PageHeader` / `AppCard` / `CardToolbar` / `AppButton` / `AppField`:
496
+ a 22px title with the action on the right, then one in-card row with the tabs
497
+ on the left and search, count and pager on the right.
498
+ - `SwitchContext` and the rail's application name print in full instead of
499
+ truncating to "Seventh Cond…" / "Property Managem…".
500
+ - The security dashboard's `ACTIVE PATROL` / `STAFF STATUS` panel headings are
501
+ written the way the design writes them, matching their sibling panels.
502
+
503
+ Placement and wording only: the same buttons open the same dialogs under the
504
+ same permissions, the same tabs load the same lists, and the tables, columns,
505
+ filters and row menus are untouched.
506
+
507
+ - 8813b26: Work Orders and Feedbacks: say why the list is empty instead of quoting an
508
+ internal id. A service-provider account viewing a site its company is not linked
509
+ to got an empty table and the toast "Service provider with siteId
510
+ 68f0...dadada not found" - an internal sentence, naming no action, on a screen
511
+ that then looked broken. The sidebar offers every site in the organisation, not
512
+ only the ones the account covers, so this is an ordinary mis-click rather than a
513
+ fault. `findServiceProviderIdBySite` still refuses (returning null would drop the
514
+ provider filter and show another provider's work orders) but now refuses with a
515
+ sentence the reader can act on, and no longer dies with a TypeError when the
516
+ account carries no provider list at all. Unit tests added in `utils/data.test.ts`.
517
+ Affects the six service-provider applications that call it: security, hygiene,
518
+ landscape, mechanical-electrical, pest-control and pool-mgmt.
519
+ - 309d0e3: Fix the duplicate `:headers` binding on `<v-data-table>` in `TableMain.vue`
520
+ that broke every consuming app's build.
521
+
522
+ The merge of `staging` into `feature/DV-0119` kept both sides' `:headers`
523
+ line: the branch's older `:headers="headers"` and staging's newer
524
+ `:headers="tableHeaders"`. Vue's template compiler rejects a duplicate
525
+ attribute outright ("Duplicate attribute."), so any app that compiles this
526
+ component fails to build. This package's own `yarn build` only builds
527
+ `.playground`, which never imports `TableMain.vue`, so the fault published
528
+ as `3.2.8-staging.201` and surfaced only in the eleven web apps.
529
+
530
+ `:headers="tableHeaders"` is the correct one and is kept.
531
+ `tableHeaders` returns `props.headers` unchanged unless a caller has put
532
+ `weight` on a column, so it is a strict superset of the old binding - no
533
+ caller behaviour changes, and the weighted-column support added in
534
+ "Bring the shared table card up to the design's table spec" stays working.
535
+
536
+ Also adds `test/sfc-compile.test.mjs`, which compiles every `.vue` template in
537
+ the package and fails on any template syntax error. It fails on the broken
538
+ file and passes on the fixed one.
539
+
3
540
  ## 3.2.7
4
541
 
5
542
  ### Patch Changes
@@ -112,6 +112,7 @@
112
112
  :mode="selectedReader ? 'edit' : 'add'"
113
113
  :reader="selectedReader"
114
114
  :loading="saving"
115
+ :site="site"
115
116
  @submit="saveReader"
116
117
  />
117
118
 
@@ -28,66 +28,9 @@ 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
-
84
31
  return {
85
32
  add,
86
33
  getPromoCodes,
87
34
  getByCode,
88
- getById,
89
- update,
90
- updateStatus,
91
- remove,
92
35
  };
93
36
  }
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.209",
5
+ "version": "3.3.0",
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.",
@@ -96,20 +96,7 @@ test("the reader form takes site as a prop and puts it in the discover call", ()
96
96
  assert.match(source, /discoverReader\(\{[\s\S]*?site:\s*props\.site[\s\S]*?\}\)/);
97
97
  });
98
98
 
99
- test("reader management passes its site prop down to the form, ONCE", () => {
99
+ test("reader management passes its site prop down to the form", () => {
100
100
  const source = readFileSync(join(root, "components", "HidReaderManagement.vue"), "utf8");
101
- const tag = source.match(/<HidReaderForm[\s\S]*?\/>/)?.[0] ?? "";
102
-
103
- // Bound to `props.site`, which is the only name for it that exists in this
104
- // component - a bare `site` resolves to nothing.
105
- assert.match(tag, /:site="props\.site"/);
106
-
107
- /*
108
- * And exactly once. It was written twice - `props.site` and then a bare
109
- * `site` - and Vite's Vue plugin treats a duplicate attribute as a COMPILE
110
- * ERROR, so every consuming application's `nuxt build` failed on this file.
111
- * The layer's own playground build does not import this component, so
112
- * nothing here caught it; the first sign was a consumer failing to build.
113
- */
114
- assert.equal((tag.match(/:site=/g) ?? []).length, 1, tag);
101
+ assert.match(source, /<HidReaderForm[\s\S]*?:site="site"[\s\S]*?\/>/);
115
102
  });
@@ -1,437 +0,0 @@
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
- });
@@ -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
- }