@7365admin1/layer-common 4.97.1-staging.576 → 4.97.1-staging.578

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.
@@ -28,6 +28,14 @@
28
28
  class="ml-2"
29
29
  style="max-width: 250px"
30
30
  />
31
+ <v-autocomplete
32
+ v-model="filterByCategory"
33
+ :items="categoryFilterItems"
34
+ label="Filter by category"
35
+ hide-details
36
+ class="ml-2"
37
+ style="max-width: 250px"
38
+ />
31
39
  <v-text-field
32
40
  v-model="startDate"
33
41
  :value="standardFormatDate(startDate)"
@@ -684,6 +692,7 @@ const pageRange = ref("-- - -- of --");
684
692
  const items = ref<Array<Record<string, any>>>([]);
685
693
 
686
694
  const filterByStatus = ref("All");
695
+ const filterByCategory = ref("All");
687
696
  const getPast30DaysDate = (): Date => {
688
697
  const today = new Date();
689
698
  const past30Days = new Date(today);
@@ -715,9 +724,10 @@ const {
715
724
  dateTo: moment(endDate.value, "DD/MM/YYYY").endOf("day"),
716
725
  search: searchText.value,
717
726
  status: filterByStatus.value == "All" ? "" : filterByStatus.value,
727
+ category: filterByCategory.value == "All" ? "" : filterByCategory.value,
718
728
  }),
719
729
  {
720
- watch: [filterByStatus, searchText, page, startDate, endDate],
730
+ watch: [filterByStatus, filterByCategory, searchText, page, startDate, endDate],
721
731
  }
722
732
  );
723
733
 
@@ -1068,6 +1078,12 @@ const subjectOptions = computed(() => {
1068
1078
  });
1069
1079
  });
1070
1080
 
1081
+ // Same categories as the create form, plus "All" (default: nothing hidden).
1082
+ const categoryFilterItems = computed(() => [
1083
+ "All",
1084
+ ...subjectOptions.value.map((item) => item.title),
1085
+ ]);
1086
+
1071
1087
  const isFileUploading = ref<boolean>(false);
1072
1088
  type TFile = {
1073
1089
  name: string;
@@ -73,6 +73,7 @@ export default function useFeedback() {
73
73
  provider = "",
74
74
  service = "",
75
75
  userId = "",
76
+ category = "",
76
77
  }: {
77
78
  page?: number;
78
79
  limit?: number;
@@ -85,6 +86,8 @@ export default function useFeedback() {
85
86
  provider?: string | any;
86
87
  service?: string;
87
88
  userId?: string;
89
+ /** Optional: one category (the feedback subject) or a comma list. Empty = all. */
90
+ category?: string;
88
91
  } = {}) {
89
92
  try {
90
93
  return useNuxtApp().$api<Record<string, any>>("/api/feedbacks2", {
@@ -100,6 +103,7 @@ export default function useFeedback() {
100
103
  status,
101
104
  provider,
102
105
  service,
106
+ ...(category && { category }),
103
107
  ...(currentUser.value?.type != "site" && {
104
108
  userId,
105
109
  }),
package/nuxt.config.ts CHANGED
@@ -17,6 +17,10 @@ export default defineNuxtConfig({
17
17
  ...(process.env.API_CORE
18
18
  ? { "/api/storage/**": { proxy: `${process.env.API_CORE}/api/files/object/**` } }
19
19
  : {}),
20
+ // Browser logs (utils/client-log.ts), forwarded by API-core to the Seven365 Logger.
21
+ ...(process.env.API_CORE
22
+ ? { "/api/client-logs": { proxy: `${process.env.API_CORE}/api/client-logs` } }
23
+ : {}),
20
24
  },
21
25
  build: {
22
26
  transpile: ["vuetify"],
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.97.1-staging.576",
5
+ "version": "4.97.1-staging.578",
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.",
package/plugins/API.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import { isPermissionRefusal } from "../utils/permission-refusal";
2
2
  import { accessSuspendedMessage, SIGNIN_NOTICE_COOKIE } from "../utils/access-suspended";
3
+ import { apiPath, clientLog } from "../utils/client-log";
4
+
5
+ // Expected answers, not failures: the session lapsed, no permission, nothing there.
6
+ const UNLOGGED_REFUSALS = new Set([401, 403, 404]);
3
7
 
4
8
  export default defineNuxtPlugin((nuxtApp) => {
5
9
  const { cookieConfig } = useRuntimeConfig().public;
@@ -33,7 +37,33 @@ export default defineNuxtPlugin((nuxtApp) => {
33
37
  // Both refusal shapes count: API-core's 403 + PERMISSION_DENIED and core's
34
38
  // 401 + "You do not have permission ..." (vehicle writes and every other
35
39
  // `requireSiteReach` gate). See utils/permission-refusal.ts.
36
- onResponseError({ response }) {
40
+ // Saves that went through, so a client's "I clicked save" can be checked.
41
+ onResponse({ request, options, response }) {
42
+ const method = String(options.method ?? "GET").toUpperCase();
43
+ if (method === "GET" || !response.ok) return;
44
+ clientLog("info", `Saved: ${method} ${apiPath(String(request))}`, { status: response.status });
45
+ },
46
+ // Offline or the API unreachable: the click did nothing for the client.
47
+ onRequestError({ request, options, error }) {
48
+ const method = String(options.method ?? "GET").toUpperCase();
49
+ clientLog("error", `Request failed: ${method} ${apiPath(String(request))}: ${error?.message ?? "network error"}`, {
50
+ method,
51
+ route: apiPath(String(request)),
52
+ status: 0,
53
+ expected: !navigator.onLine || undefined,
54
+ });
55
+ },
56
+ onResponseError({ request, options, response }) {
57
+ const status = response?.status ?? 0;
58
+ if (!UNLOGGED_REFUSALS.has(status)) {
59
+ const method = String(options.method ?? "GET").toUpperCase();
60
+ const reason = (response as any)?._data?.message;
61
+ clientLog(status >= 500 ? "error" : "warn", `${method} ${apiPath(String(request))} answered ${status}${reason ? `: ${reason}` : ""}`, {
62
+ method,
63
+ route: apiPath(String(request)),
64
+ status,
65
+ });
66
+ }
37
67
  // Every membership suspended (2026-10-01): the server already ended the
38
68
  // session. Sign out here and let the sign-in page say why, once.
39
69
  const suspended = accessSuspendedMessage((response as any)?._data);
@@ -0,0 +1,47 @@
1
+ import { clientLog, configureClientLog, flushClientLogs } from "../utils/client-log";
2
+
3
+ // Crashes anywhere on the page go to the Seven365 Logger. Failed and successful
4
+ // API calls are logged from plugins/API.ts, where every request passes.
5
+ export default defineNuxtPlugin((nuxtApp) => {
6
+ configureClientLog(String(useRuntimeConfig().public.APP ?? ""));
7
+
8
+ const describe = (error: unknown) => {
9
+ const e = error as { message?: string; stack?: string; name?: string } | undefined;
10
+ return { message: e?.message || String(error), stack: e?.stack, name: e?.name };
11
+ };
12
+
13
+ // An old tab asking for a file the last deploy replaced: the page needs a
14
+ // reload, the code is not broken. Logged, never ticketed.
15
+ const isStaleBuild = (message: string) =>
16
+ /Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module/i.test(message);
17
+
18
+ nuxtApp.hook("vue:error", (error, _instance, info) => {
19
+ const { message, stack } = describe(error);
20
+ clientLog("error", message, { stack, source: `vue:${info}`, expected: isStaleBuild(message) || undefined });
21
+ });
22
+
23
+ nuxtApp.hook("app:error", (error) => {
24
+ const { message, stack } = describe(error);
25
+ clientLog("error", message, { stack, source: "app", expected: isStaleBuild(message) || undefined });
26
+ });
27
+
28
+ window.addEventListener("error", (event) => {
29
+ if (!event.message) return;
30
+ clientLog("error", event.message, {
31
+ stack: (event.error as Error | undefined)?.stack,
32
+ source: `${event.filename}:${event.lineno}:${event.colno}`,
33
+ expected: isStaleBuild(event.message) || undefined,
34
+ });
35
+ });
36
+
37
+ window.addEventListener("unhandledrejection", (event) => {
38
+ const { message, stack, name } = describe(event.reason);
39
+ // Failed $api calls are already logged with their status by plugins/API.ts.
40
+ if (name === "FetchError") return;
41
+ clientLog("error", message, { stack, source: "promise", expected: isStaleBuild(message) || undefined });
42
+ });
43
+
44
+ document.addEventListener("visibilitychange", () => {
45
+ if (document.visibilityState === "hidden") void flushClientLogs();
46
+ });
47
+ });
@@ -0,0 +1,71 @@
1
+ // Browser logs for the Seven365 Logger. Entries are batched and sent to API-core
2
+ // (POST /api/client-logs), which files them under this app's own folder and adds
3
+ // the signed-in user from the session. Nothing is sent in development.
4
+ type Level = "info" | "warn" | "error";
5
+
6
+ type Entry = { level: Level; message: string; context: Record<string, unknown>; timestamp: string };
7
+
8
+ const queue: Entry[] = [];
9
+ const recent = new Map<string, number>();
10
+ const sessionId = Math.random().toString(36).slice(2, 12);
11
+ let app = "";
12
+ let timer: ReturnType<typeof setTimeout> | null = null;
13
+
14
+ const BATCH = 20;
15
+ const FLUSH_MS = 5000;
16
+ const REPEAT_WINDOW_MS = 10000;
17
+
18
+ export function configureClientLog(appName: string) {
19
+ app = appName;
20
+ }
21
+
22
+ export function apiPath(url: string) {
23
+ return String(url)
24
+ .split("?")[0]
25
+ .replace(/\b[0-9a-f]{24}\b/gi, ":id")
26
+ .replace(/\/\d+(?=\/|$)/g, "/:n");
27
+ }
28
+
29
+ function sid() {
30
+ const match = document.cookie.match(/(?:^|;\s*)sid=([^;]+)/);
31
+ return match ? decodeURIComponent(match[1]) : "";
32
+ }
33
+
34
+ export function clientLog(level: Level, message: string, context: Record<string, unknown> = {}) {
35
+ if (!import.meta.client || import.meta.dev || !app || !message) return;
36
+
37
+ // The same line from the same tab within 10s is one event (a re-rendering
38
+ // component can throw the same error dozens of times a second).
39
+ const key = `${level}|${message}`;
40
+ const now = Date.now();
41
+ if ((recent.get(key) ?? 0) > now - REPEAT_WINDOW_MS) return;
42
+ if (recent.size > 200) recent.clear();
43
+ recent.set(key, now);
44
+
45
+ queue.push({
46
+ level,
47
+ message: String(message).slice(0, 2000),
48
+ context: { ...context, page: location.pathname, sessionId },
49
+ timestamp: new Date().toISOString(),
50
+ });
51
+
52
+ if (queue.length >= BATCH) void flushClientLogs();
53
+ else if (!timer) timer = setTimeout(() => void flushClientLogs(), FLUSH_MS);
54
+ }
55
+
56
+ export async function flushClientLogs() {
57
+ if (timer) clearTimeout(timer);
58
+ timer = null;
59
+ const logs = queue.splice(0, 50);
60
+ if (!logs.length) return;
61
+ try {
62
+ await fetch("/api/client-logs", {
63
+ method: "POST",
64
+ keepalive: true,
65
+ headers: { "Content-Type": "application/json", Authorization: sid() },
66
+ body: JSON.stringify({ app, logs }),
67
+ });
68
+ } catch {
69
+ // Logging must never break the page; a lost batch is acceptable.
70
+ }
71
+ }