@7365admin1/layer-common 4.43.0 → 4.44.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,18 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.44.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8d20975: Notification Settings: Email per module, with an "Email address for notifications" card (send code, verify, resend, change). New `/notifications/open` page turns a notification email link into the right page, using the bell's own target table.
8
+
9
+ ## 4.43.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 64e84bb: Service Providers screen: extracted the service-type code-to-label lookup (the one behind "security_agency" showing as "Security") into a tested pure function, with unit tests covering every known service type code.
14
+ - 238f9be: Service Providers screen: the TYPE column still showed the raw code (e.g. "security_agency" instead of "Security") for every row, because the API's `nature` field is not free text - it mirrors the same raw code as `type` - and the label lookup treated any truthy `nature` as an override. Only treat `nature` as free text when it is not itself a known service type code.
15
+
3
16
  ## 4.43.0
4
17
 
5
18
  ### Minor Changes
@@ -1489,11 +1502,11 @@
1489
1502
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1490
1503
  own rule from two endpoints the console already calls, unprojected:
1491
1504
 
1492
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1493
- GET /api/roles/id/:role that membership's role document
1505
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1506
+ GET /api/roles/id/:role that membership's role document
1494
1507
 
1495
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1496
- staff = member.type === "admin" && role.type === "admin"
1508
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1509
+ staff = member.type === "admin" && role.type === "admin"
1497
1510
 
1498
1511
  `role.default` is the marker because it is the only property of a platform
1499
1512
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -137,7 +137,7 @@ import {
137
137
  timeAgo,
138
138
  type TInboxItem,
139
139
  } from "../utils/notification-inbox";
140
- import { chooseNavigation, notificationTarget } from "../utils/notification-target";
140
+ import { appUrlsFrom, chooseNavigation, notificationTarget } from "../utils/notification-target";
141
141
 
142
142
  /**
143
143
  * The top-bar notification feed for the web, in every signed-in app.
@@ -331,17 +331,7 @@ function hasLocalPage(path: string) {
331
331
 
332
332
  /** Every web app's URL, keyed like `TAppKey`, from the layer's runtime config. */
333
333
  function appUrls() {
334
- const p = appConfig?.public ?? {};
335
- return {
336
- org: String(p.APP_ORG ?? ""),
337
- security: String(p.APP_SECURITY ?? ""),
338
- "property-management": String(p.APP_PROPERTY_MANAGEMENT ?? ""),
339
- hygiene: String(p.APP_HYGIENE ?? ""),
340
- "mechanical-electrical": String(p.APP_MECHANICAL_ELECTRICAL ?? ""),
341
- "pest-control": String(p.APP_PEST_CONTROL ?? ""),
342
- landscaping: String(p.APP_LANDSCAPING ?? ""),
343
- "pool-maintenance": String(p.APP_POOL_MAINTENANCE ?? ""),
344
- };
334
+ return appUrlsFrom(appConfig?.public as Record<string, unknown>);
345
335
  }
346
336
 
347
337
  /** The decision is `chooseNavigation` (unit-tested); this only carries it out. */
@@ -56,6 +56,90 @@
56
56
  </AppCard>
57
57
  </v-col>
58
58
 
59
+ <!-- where notification emails go. Only when the server offers email to
60
+ this person; a module's Email switch does nothing until the
61
+ address below is verified. -->
62
+ <v-col v-if="email?.available" cols="12" class="mt-4">
63
+ <AppCard>
64
+ <div class="pa-6">
65
+ <div class="screen-card-title">Email address for notifications</div>
66
+
67
+ <!-- verified, nothing waiting -->
68
+ <template v-if="emailMode === 'verified'">
69
+ <div class="screen-hint summary-line mt-1">
70
+ Emails go to <strong>{{ email.address }}</strong>.
71
+ </div>
72
+ <AppButton variant="ghost" class="mt-4" :disabled="emailBusy" @click="startChange">
73
+ Change
74
+ </AppButton>
75
+ </template>
76
+
77
+ <!-- a code is on its way -->
78
+ <template v-else-if="emailMode === 'pending'">
79
+ <div class="screen-hint summary-line mt-1">
80
+ We sent a 6-digit code to <strong>{{ email.pendingAddress }}</strong>.
81
+ Enter it here.
82
+ </div>
83
+ <div v-if="email.verified" class="screen-hint summary-line mt-1">
84
+ Emails still go to {{ email.address }} until then.
85
+ </div>
86
+ <div class="d-flex flex-wrap align-end ga-3 mt-4">
87
+ <AppField
88
+ v-model="codeInput"
89
+ label="Code"
90
+ inputmode="numeric"
91
+ autocomplete="one-time-code"
92
+ maxlength="6"
93
+ aria-label="6-digit code"
94
+ :disabled="emailBusy"
95
+ class="email-code"
96
+ />
97
+ <AppButton :disabled="emailBusy || codeInput.trim().length !== 6" @click="verifyCode">
98
+ Verify
99
+ </AppButton>
100
+ <AppButton variant="ghost" :disabled="emailBusy" @click="sendCode(email.pendingAddress || '')">
101
+ Resend code
102
+ </AppButton>
103
+ <AppButton variant="ghost" :disabled="emailBusy" @click="startChange">
104
+ Change
105
+ </AppButton>
106
+ </div>
107
+ </template>
108
+
109
+ <!-- no address yet, or changing it -->
110
+ <template v-else>
111
+ <div class="d-flex flex-wrap align-end ga-3 mt-4">
112
+ <AppField
113
+ v-model="addressInput"
114
+ label="Email address"
115
+ type="email"
116
+ autocomplete="email"
117
+ aria-label="Email address for notifications"
118
+ :disabled="emailBusy"
119
+ class="email-address"
120
+ />
121
+ <AppButton :disabled="emailBusy || !addressInput.trim()" @click="sendCode(addressInput)">
122
+ Send code
123
+ </AppButton>
124
+ <AppButton
125
+ v-if="changing"
126
+ variant="ghost"
127
+ :disabled="emailBusy"
128
+ @click="changing = false"
129
+ >
130
+ Cancel
131
+ </AppButton>
132
+ </div>
133
+ </template>
134
+
135
+ <div v-if="emailError" class="email-error mt-3" role="alert">{{ emailError }}</div>
136
+ <div v-if="!email.verified" class="screen-hint summary-line mt-3">
137
+ Emails start once your address is verified.
138
+ </div>
139
+ </div>
140
+ </AppCard>
141
+ </v-col>
142
+
59
143
  <!-- one panel per module, collapsed by default so a person with many
60
144
  modules gets a short list rather than a wall of switches -->
61
145
  <v-col cols="12" class="mt-4">
@@ -174,9 +258,18 @@
174
258
  import type {
175
259
  TNotificationCategorySetting,
176
260
  TNotificationChannelSetting,
261
+ TNotificationEmailSetting,
177
262
  } from "../composables/useNotificationPreference";
178
263
 
179
- const { getPreferences, savePreferences } = useNotificationPreference();
264
+ const { getPreferences, savePreferences, requestEmailCode, verifyEmailCode } =
265
+ useNotificationPreference();
266
+
267
+ const email = ref<TNotificationEmailSetting | null>(null);
268
+ const addressInput = ref("");
269
+ const codeInput = ref("");
270
+ const changing = ref(false);
271
+ const emailBusy = ref(false);
272
+ const emailError = ref("");
180
273
 
181
274
  const categories = ref<TNotificationCategorySetting[]>([]);
182
275
  const loading = ref(true);
@@ -193,8 +286,20 @@ let confirmAccept: (() => void) | null = null;
193
286
  const switchableIn = (category: TNotificationCategorySetting) =>
194
287
  category.channels.filter((c) => c.supported);
195
288
 
289
+ /**
290
+ * In the app and Phone alerts. "All on" is measured on these alone: Email is
291
+ * opt-in, so a module with Email off is still "all on" - and no bulk "on"
292
+ * button ever turns Email on (nobody gets email they did not pick one by one).
293
+ */
294
+ const alertChannelsIn = (category: TNotificationCategorySetting) =>
295
+ switchableIn(category).filter((c) => c.key !== "email");
296
+
297
+ /** What a bulk switch moves: every channel when turning off, never Email when turning on. */
298
+ const bulkChannelsIn = (category: TNotificationCategorySetting, value: boolean) =>
299
+ value ? alertChannelsIn(category) : switchableIn(category);
300
+
196
301
  const categoryAllOn = (category: TNotificationCategorySetting) =>
197
- switchableIn(category).every((c) => c.enabled);
302
+ alertChannelsIn(category).every((c) => c.enabled);
198
303
 
199
304
  const allOn = computed(
200
305
  () =>
@@ -230,6 +335,7 @@ async function load() {
230
335
  try {
231
336
  const res = await getPreferences();
232
337
  categories.value = res.data.categories;
338
+ setEmail(res.data.email);
233
339
  } catch {
234
340
  loadError.value =
235
341
  "We could not load your notification settings. Please refresh the page and try again.";
@@ -247,6 +353,7 @@ async function persist(previous: TNotificationCategorySetting[]) {
247
353
  try {
248
354
  const res = await savePreferences({ channels: [], categories: categories.value });
249
355
  categories.value = res.data.categories;
356
+ if (res.data.email) setEmail(res.data.email);
250
357
  notice.value = "Saved.";
251
358
  } catch {
252
359
  categories.value = previous;
@@ -276,7 +383,8 @@ function onToggle(
276
383
  const commit = () => apply(() => (channel.enabled = value));
277
384
 
278
385
  // switching a safety alert OFF is the only thing worth stopping to confirm
279
- if (category.safety && !value) {
386
+ // Email is opt-in, so turning it off needs no warning.
387
+ if (category.safety && !value && channel.key !== "email") {
280
388
  confirmText.value = safetyOffConfirmation(category.label, channel.label);
281
389
  confirmAccept = commit;
282
390
  confirmOpen.value = true;
@@ -289,7 +397,7 @@ function onToggle(
289
397
  function setCategory(category: TNotificationCategorySetting, value: boolean) {
290
398
  const commit = () =>
291
399
  apply(() => {
292
- for (const channel of switchableIn(category)) channel.enabled = value;
400
+ for (const channel of bulkChannelsIn(category, value)) channel.enabled = value;
293
401
  });
294
402
 
295
403
  if (category.safety && !value) {
@@ -306,7 +414,7 @@ function setAll(value: boolean) {
306
414
  const commit = () =>
307
415
  apply(() => {
308
416
  for (const category of categories.value) {
309
- for (const channel of switchableIn(category)) channel.enabled = value;
417
+ for (const channel of bulkChannelsIn(category, value)) channel.enabled = value;
310
418
  }
311
419
  });
312
420
 
@@ -320,6 +428,67 @@ function setAll(value: boolean) {
320
428
  commit();
321
429
  }
322
430
 
431
+ /** "pending" and "verified" show the address; "enter" shows the field. */
432
+ const emailMode = computed(() => {
433
+ if (changing.value || !email.value) return "enter";
434
+ if (email.value.pendingAddress) return "pending";
435
+ if (email.value.verified) return "verified";
436
+ return "enter";
437
+ });
438
+
439
+ function setEmail(next?: TNotificationEmailSetting) {
440
+ email.value = next ?? null;
441
+ if (!addressInput.value) {
442
+ addressInput.value = next?.address || next?.pendingAddress || next?.accountEmail || "";
443
+ }
444
+ }
445
+
446
+ /** The server's own sentence when it has one ("That code is not right..."). */
447
+ function messageOf(err: any, fallback: string) {
448
+ return String(err?.data?.message || err?.response?._data?.message || fallback);
449
+ }
450
+
451
+ function startChange() {
452
+ changing.value = true;
453
+ emailError.value = "";
454
+ addressInput.value = email.value?.pendingAddress || email.value?.address || email.value?.accountEmail || "";
455
+ }
456
+
457
+ async function sendCode(address: string) {
458
+ emailBusy.value = true;
459
+ emailError.value = "";
460
+ try {
461
+ const res = await requestEmailCode(address.trim());
462
+ categories.value = res.data.categories;
463
+ setEmail(res.data.email);
464
+ changing.value = false;
465
+ codeInput.value = "";
466
+ notice.value = res.data.email?.pendingAddress ? "Code sent. Check your email." : "Saved.";
467
+ noticeOpen.value = true;
468
+ } catch (err) {
469
+ emailError.value = messageOf(err, "We could not send the code. Please try again.");
470
+ } finally {
471
+ emailBusy.value = false;
472
+ }
473
+ }
474
+
475
+ async function verifyCode() {
476
+ emailBusy.value = true;
477
+ emailError.value = "";
478
+ try {
479
+ const res = await verifyEmailCode(codeInput.value.trim());
480
+ categories.value = res.data.categories;
481
+ setEmail(res.data.email);
482
+ codeInput.value = "";
483
+ notice.value = "Email address verified.";
484
+ noticeOpen.value = true;
485
+ } catch (err) {
486
+ emailError.value = messageOf(err, "We could not check that code. Please try again.");
487
+ } finally {
488
+ emailBusy.value = false;
489
+ }
490
+ }
491
+
323
492
  function acceptConfirm() {
324
493
  confirmOpen.value = false;
325
494
  confirmAccept?.();
@@ -354,6 +523,20 @@ onMounted(load);
354
523
  color: var(--text);
355
524
  }
356
525
 
526
+ .email-address {
527
+ flex: 1 1 260px;
528
+ max-width: 360px;
529
+ }
530
+
531
+ .email-code {
532
+ width: 140px;
533
+ }
534
+
535
+ .email-error {
536
+ font-size: var(--fs-breadcrumb);
537
+ color: rgb(var(--v-theme-error));
538
+ }
539
+
357
540
  .channel-note {
358
541
  font-family: var(--font-sans);
359
542
  font-size: var(--fs-breadcrumb);
@@ -720,6 +720,7 @@ import {
720
720
  sentInvitedLine,
721
721
  sentStatusLine,
722
722
  spDay,
723
+ spTypeLabel,
723
724
  } from "../utils/sp-inviter-row";
724
725
 
725
726
  type ListTab = "active" | "pending" | "inactive";
@@ -1237,17 +1238,7 @@ function rowActionLabel(row: TableRow) {
1237
1238
  }
1238
1239
 
1239
1240
  function typeDisplay(type: string, nature?: string) {
1240
- if (nature && String(nature).trim()) return String(nature);
1241
- // useLocal returns a plain array; reading `.value` off it was always
1242
- // undefined, so every row fell through to the raw stored slug
1243
- // ("security_agency") instead of "Security".
1244
- const source = natureOfBusinessV3 as any;
1245
- const list = (Array.isArray(source) ? source : source?.value) as Array<{
1246
- title?: string;
1247
- value?: string;
1248
- }>;
1249
- const hit = list?.find((x) => x.value === type);
1250
- return hit?.title ?? type ?? "-";
1241
+ return spTypeLabel(type, nature, natureOfBusinessV3);
1251
1242
  }
1252
1243
 
1253
1244
  const providerDialog = ref(false);
@@ -32,17 +32,59 @@ export type TNotificationCategorySetting = {
32
32
  channels: TNotificationChannelSetting[];
33
33
  };
34
34
 
35
+ /**
36
+ * Where notification emails go. Sent only because this screen asks with
37
+ * `?include=email`; `available: false` means email is not offered to this
38
+ * person yet and the screen shows nothing about it.
39
+ */
40
+ export type TNotificationEmailSetting = {
41
+ available: boolean;
42
+ /** the VERIFIED address emails go to */
43
+ address: string | null;
44
+ verified: boolean;
45
+ verifiedAt: string | null;
46
+ /** an address waiting for its 6-digit code */
47
+ pendingAddress: string | null;
48
+ /** the sign-in address, suggested when nothing is set */
49
+ accountEmail: string;
50
+ };
51
+
35
52
  export type TNotificationPreferences = {
36
53
  channels: Array<{ key: string; label: string }>;
37
54
  categories: TNotificationCategorySetting[];
55
+ email?: TNotificationEmailSetting;
38
56
  };
39
57
 
40
58
  const EMPTY: TNotificationPreferences = { channels: [], categories: [] };
41
59
 
60
+ const PREF_URL = "/api/notification-preferences";
61
+
62
+ /**
63
+ * The PUT body: the switches that are OFF for in-app/phone (absent is on), and
64
+ * the modules with Email ON (email is opt-in, so it is sent as its own list).
65
+ */
66
+ export function preferencesBody(preferences: Pick<TNotificationPreferences, "categories">) {
67
+ const off = preferences.categories.flatMap((category) =>
68
+ category.channels
69
+ .filter((channel) => channel.key !== "email" && channel.supported && !channel.enabled)
70
+ .map((channel) => ({ category: category.key, channel: channel.key })),
71
+ );
72
+ // No Email row on screen (email not offered to this person): send no list,
73
+ // so the server leaves whatever is saved alone.
74
+ const hasEmail = preferences.categories.some((c) => c.channels.some((ch) => ch.key === "email"));
75
+ if (!hasEmail) return { off };
76
+ const email = preferences.categories
77
+ .filter((category) =>
78
+ category.channels.some((c) => c.key === "email" && c.supported && c.enabled),
79
+ )
80
+ .map((category) => category.key);
81
+ return { off, email };
82
+ }
83
+
42
84
  export default function useNotificationPreference() {
43
85
  function getPreferences() {
44
86
  return useNuxtApp().$api<{ data: TNotificationPreferences }>(
45
- "/api/notification-preferences",
87
+ `${PREF_URL}?include=email`,
46
88
  { method: "GET" },
47
89
  );
48
90
  }
@@ -55,19 +97,35 @@ export default function useNotificationPreference() {
55
97
  * all, and behaves exactly as they did before this screen existed.
56
98
  */
57
99
  function savePreferences(preferences: TNotificationPreferences) {
58
- const off = preferences.categories.flatMap((category) =>
59
- category.channels
60
- .filter((channel) => channel.supported && !channel.enabled)
61
- .map((channel) => ({ category: category.key, channel: channel.key })),
62
- );
63
-
64
100
  return useNuxtApp().$api<{ data: TNotificationPreferences }>(
65
- "/api/notification-preferences",
66
- { method: "PUT", body: { off } },
101
+ `${PREF_URL}?include=email`,
102
+ { method: "PUT", body: preferencesBody(preferences) },
67
103
  );
68
104
  }
69
105
 
70
- return { getPreferences, savePreferences, EMPTY_PREFERENCES: EMPTY };
106
+ /** Email a 6-digit code to the address the person wants emails at. */
107
+ function requestEmailCode(address: string) {
108
+ return useNuxtApp().$api<{ data: TNotificationPreferences }>(`${PREF_URL}/email`, {
109
+ method: "POST",
110
+ body: { address },
111
+ });
112
+ }
113
+
114
+ /** The code from that email. Verifies the address; emails start after this. */
115
+ function verifyEmailCode(code: string) {
116
+ return useNuxtApp().$api<{ data: TNotificationPreferences }>(`${PREF_URL}/email/verify`, {
117
+ method: "POST",
118
+ body: { code },
119
+ });
120
+ }
121
+
122
+ return {
123
+ getPreferences,
124
+ savePreferences,
125
+ requestEmailCode,
126
+ verifyEmailCode,
127
+ EMPTY_PREFERENCES: EMPTY,
128
+ };
71
129
  }
72
130
 
73
131
  /**
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "4.43.0",
5
+ "version": "4.44.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.",
@@ -0,0 +1,85 @@
1
+ <template>
2
+ <v-row no-gutters class="pa-8" justify="center">
3
+ <v-col cols="12" md="6">
4
+ <v-skeleton-loader v-if="!nowhere" type="list-item-two-line" />
5
+ <AppCard v-else>
6
+ <div class="pa-6">
7
+ <div class="screen-card-title">Open it in the app</div>
8
+ <div class="screen-hint mt-2">
9
+ This opens in the iService365 app on your phone.
10
+ </div>
11
+ <AppButton class="mt-6" @click="router.replace('/notification-settings')">
12
+ Notification settings
13
+ </AppButton>
14
+ </div>
15
+ </AppCard>
16
+ </v-col>
17
+ </v-row>
18
+ </template>
19
+
20
+ <script setup lang="ts">
21
+ /**
22
+ * Where the "Open in iService365" button in a notification email lands.
23
+ *
24
+ * The email carries what the notification is about (`?kind=&id=&site=&org=&app=`,
25
+ * core `openLink`); this page turns it into the page for it with the SAME table
26
+ * and the same rules as the bell (`notificationTarget` + `chooseNavigation`), so
27
+ * there is one list of destinations. A link grants nothing: the page it opens
28
+ * still checks the person may see it.
29
+ */
30
+ import {
31
+ appUrlsFrom,
32
+ chooseNavigation,
33
+ notificationTarget,
34
+ rowFromEmailLink,
35
+ } from "../../utils/notification-target";
36
+
37
+ definePageMeta({
38
+ middleware: ["01-auth"],
39
+ });
40
+
41
+ const { authenticate, currentUser } = useLocalAuth();
42
+ const route = useRoute();
43
+ const router = useRouter();
44
+ const appConfig = useRuntimeConfig();
45
+ const nowhere = ref(false);
46
+ authenticate();
47
+
48
+ /** The link's organisation, else the person's own default one (as the bell does). */
49
+ async function organizationFor(fromLink: string): Promise<string> {
50
+ if (fromLink) return fromLink;
51
+ if (currentUser.value?.defaultOrg) return String(currentUser.value.defaultOrg);
52
+ try {
53
+ const { cookieConfig } = appConfig.public as any;
54
+ const id = useCookie("user", cookieConfig).value;
55
+ const user = await useNuxtApp().$api<Record<string, any>>(`/api/users/id/${id}`);
56
+ return String(user?.defaultOrg ?? "");
57
+ } catch {
58
+ return "";
59
+ }
60
+ }
61
+
62
+ function hasLocalPage(path: string) {
63
+ try {
64
+ const resolved = router.resolve(path);
65
+ return resolved.matched.length > 0 && !resolved.matched.some((m) => m.path.includes("pathMatch"));
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ onMounted(async () => {
72
+ const { row, organization } = rowFromEmailLink(route.query as Record<string, unknown>);
73
+ const nav = chooseNavigation(
74
+ notificationTarget(row, { organization: await organizationFor(organization) }),
75
+ {
76
+ hasRoute: (name) => typeof router.hasRoute === "function" && router.hasRoute(name),
77
+ hasPage: hasLocalPage,
78
+ appUrls: appUrlsFrom(appConfig?.public as Record<string, unknown>),
79
+ },
80
+ );
81
+ if (nav?.type === "href") return window.location.replace(nav.href);
82
+ if (nav?.type === "route") return router.replace(nav.to as any).catch(() => (nowhere.value = true));
83
+ nowhere.value = true;
84
+ });
85
+ </script>
@@ -342,3 +342,43 @@ export function chooseNavigation(
342
342
  const href = externalTargetUrl(target, app.appUrls);
343
343
  return href ? { type: "href", href } : null;
344
344
  }
345
+
346
+ /** Every web app's URL, keyed like `TAppKey`, from the layer's public runtime config. */
347
+ export function appUrlsFrom(p: Record<string, unknown> | null | undefined): Record<TAppKey, string> {
348
+ const c = p ?? {};
349
+ return {
350
+ org: String(c.APP_ORG ?? ""),
351
+ security: String(c.APP_SECURITY ?? ""),
352
+ "property-management": String(c.APP_PROPERTY_MANAGEMENT ?? ""),
353
+ hygiene: String(c.APP_HYGIENE ?? ""),
354
+ "mechanical-electrical": String(c.APP_MECHANICAL_ELECTRICAL ?? ""),
355
+ "pest-control": String(c.APP_PEST_CONTROL ?? ""),
356
+ landscaping: String(c.APP_LANDSCAPING ?? ""),
357
+ "pool-maintenance": String(c.APP_POOL_MAINTENANCE ?? ""),
358
+ };
359
+ }
360
+
361
+ /**
362
+ * The row a notification-email link stands for. The email's button carries
363
+ * `?kind=&id=&site=&org=&app=` (core `openLink`); this turns that back into
364
+ * the same shape a bell row has, so the same table decides where it lands.
365
+ */
366
+ export function rowFromEmailLink(query: Record<string, unknown>): {
367
+ row: TTargetRow;
368
+ organization: string;
369
+ } {
370
+ const one = (k: string) => {
371
+ const v = query?.[k];
372
+ return String((Array.isArray(v) ? v[0] : v) ?? "").trim();
373
+ };
374
+ const kind = one("kind");
375
+ const id = one("id");
376
+ return {
377
+ row: {
378
+ params: kind || id ? { kind, id, ...(one("org") ? { org: one("org") } : {}) } : null,
379
+ siteId: one("site") || null,
380
+ appSlug: one("app"),
381
+ },
382
+ organization: one("org"),
383
+ };
384
+ }
@@ -29,6 +29,34 @@ const ANSWERED: Record<string, string> = {
29
29
  expired: "Expired",
30
30
  };
31
31
 
32
+ type TNatureOption = { title?: string; value?: string };
33
+
34
+ /**
35
+ * The service type in words: the org's free-text nature if it gave one,
36
+ * else the label from the nature-of-business list, else the raw stored code
37
+ * ("security_agency") as a last resort. `useLocal` can hand back a plain
38
+ * array or a ref-like `{ value }` wrapper - read whichever shape was passed.
39
+ *
40
+ * `nature` is NOT always free text: both `_getServiceProviders` and the
41
+ * `customer.sites`-derived `getServiceProvidersForSite` aggregations in
42
+ * @7365admin1/core set it to `organization.nature`, the SAME raw code `type`
43
+ * carries ("security_agency") - not a human-typed description. Treating it as
44
+ * free text made the raw code win over the label lookup for every row, since
45
+ * `nature` is truthy whenever it is set at all. Only trust it as an override
46
+ * when it is NOT itself one of the known codes.
47
+ */
48
+ export function spTypeLabel(type: string, nature: string | undefined, source: unknown): string {
49
+ const list = (Array.isArray(source) ? source : (source as any)?.value) as
50
+ | TNatureOption[]
51
+ | undefined;
52
+ const codeMatch = (value?: string) => list?.find((x) => x.value === value);
53
+
54
+ if (nature && String(nature).trim() && !codeMatch(nature)) return String(nature);
55
+
56
+ const hit = codeMatch(type) ?? codeMatch(nature);
57
+ return hit?.title ?? type ?? nature ?? "-";
58
+ }
59
+
32
60
  export function spDay(value?: string | null): string {
33
61
  if (!value) return "";
34
62
  const d = new Date(value);