@antelopejs/dms-saas 0.3.1 → 0.3.2

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.
Files changed (98) hide show
  1. package/README.md +0 -86
  2. package/dist/config/runtime.d.ts +0 -1
  3. package/dist/config/runtime.js +0 -7
  4. package/dist/config/runtime.js.map +1 -1
  5. package/dist/data-api/platformOwner/index.d.ts +0 -1
  6. package/dist/data-api/platformOwner/index.js +0 -1
  7. package/dist/data-api/platformOwner/index.js.map +1 -1
  8. package/dist/data-api/tenant/index.d.ts +0 -1
  9. package/dist/data-api/tenant/index.js +0 -1
  10. package/dist/data-api/tenant/index.js.map +1 -1
  11. package/dist/notifications/subjects.d.ts +0 -2
  12. package/dist/notifications/subjects.js +1 -13
  13. package/dist/notifications/subjects.js.map +1 -1
  14. package/dist/pages/platform/categories.d.ts +0 -1
  15. package/dist/pages/platform/categories.js +1 -8
  16. package/dist/pages/platform/categories.js.map +1 -1
  17. package/dist/pages/platform/index.d.ts +0 -1
  18. package/dist/pages/platform/index.js +0 -1
  19. package/dist/pages/platform/index.js.map +1 -1
  20. package/dist/pages/tenant/index.d.ts +0 -1
  21. package/dist/pages/tenant/index.js +0 -1
  22. package/dist/pages/tenant/index.js.map +1 -1
  23. package/dist/routes/platformOwner/index.d.ts +0 -1
  24. package/dist/routes/platformOwner/index.js +0 -1
  25. package/dist/routes/platformOwner/index.js.map +1 -1
  26. package/dist/routes/tenant/index.d.ts +0 -1
  27. package/dist/routes/tenant/index.js +0 -1
  28. package/dist/routes/tenant/index.js.map +1 -1
  29. package/dist/types/config.d.ts +0 -1
  30. package/frontend-vue/i18n/locales/saas-en-GB.json +1 -142
  31. package/frontend-vue/i18n/locales/saas-fr-FR.json +1 -142
  32. package/package.json +2 -4
  33. package/dist/data-api/platformOwner/support.d.ts +0 -58
  34. package/dist/data-api/platformOwner/support.js +0 -111
  35. package/dist/data-api/platformOwner/support.js.map +0 -1
  36. package/dist/data-api/support-fields.d.ts +0 -49
  37. package/dist/data-api/support-fields.js +0 -166
  38. package/dist/data-api/support-fields.js.map +0 -1
  39. package/dist/data-api/support-routes.d.ts +0 -10
  40. package/dist/data-api/support-routes.js +0 -71
  41. package/dist/data-api/support-routes.js.map +0 -1
  42. package/dist/data-api/tenant/support.d.ts +0 -39
  43. package/dist/data-api/tenant/support.js +0 -53
  44. package/dist/data-api/tenant/support.js.map +0 -1
  45. package/dist/pages/platform/support.d.ts +0 -9
  46. package/dist/pages/platform/support.js +0 -29
  47. package/dist/pages/platform/support.js.map +0 -1
  48. package/dist/pages/tenant/support.d.ts +0 -9
  49. package/dist/pages/tenant/support.js +0 -27
  50. package/dist/pages/tenant/support.js.map +0 -1
  51. package/dist/routes/platformOwner/support.d.ts +0 -21
  52. package/dist/routes/platformOwner/support.js +0 -180
  53. package/dist/routes/platformOwner/support.js.map +0 -1
  54. package/dist/routes/tenant/support.d.ts +0 -27
  55. package/dist/routes/tenant/support.js +0 -185
  56. package/dist/routes/tenant/support.js.map +0 -1
  57. package/dist/support/admissions.d.ts +0 -36
  58. package/dist/support/admissions.js +0 -136
  59. package/dist/support/admissions.js.map +0 -1
  60. package/dist/support/attachments.d.ts +0 -12
  61. package/dist/support/attachments.js +0 -56
  62. package/dist/support/attachments.js.map +0 -1
  63. package/dist/support/config.d.ts +0 -19
  64. package/dist/support/config.js +0 -75
  65. package/dist/support/config.js.map +0 -1
  66. package/dist/support/index.d.ts +0 -6
  67. package/dist/support/index.js +0 -23
  68. package/dist/support/index.js.map +0 -1
  69. package/dist/support/operations.d.ts +0 -51
  70. package/dist/support/operations.js +0 -109
  71. package/dist/support/operations.js.map +0 -1
  72. package/dist/support/plans.d.ts +0 -6
  73. package/dist/support/plans.js +0 -130
  74. package/dist/support/plans.js.map +0 -1
  75. package/dist/support/publications.d.ts +0 -8
  76. package/dist/support/publications.js +0 -106
  77. package/dist/support/publications.js.map +0 -1
  78. package/dist/support/recovery.d.ts +0 -7
  79. package/dist/support/recovery.js +0 -43
  80. package/dist/support/recovery.js.map +0 -1
  81. package/dist/support/service.d.ts +0 -51
  82. package/dist/support/service.js +0 -143
  83. package/dist/support/service.js.map +0 -1
  84. package/dist/support/transitions.d.ts +0 -4
  85. package/dist/support/transitions.js +0 -29
  86. package/dist/support/transitions.js.map +0 -1
  87. package/dist/support/uploads.d.ts +0 -39
  88. package/dist/support/uploads.js +0 -115
  89. package/dist/support/uploads.js.map +0 -1
  90. package/dist/support/validation.d.ts +0 -15
  91. package/dist/support/validation.js +0 -54
  92. package/dist/support/validation.js.map +0 -1
  93. package/frontend-vue/app/components/PlatformSupport.vue +0 -461
  94. package/frontend-vue/app/components/SupportAttachmentUpload.vue +0 -140
  95. package/frontend-vue/app/components/SupportThreadHistory.vue +0 -139
  96. package/frontend-vue/app/components/TenantSupport.vue +0 -415
  97. package/frontend-vue/app/composables/useSupportRequest.ts +0 -74
  98. package/frontend-vue/app/types/support.ts +0 -123
@@ -1,139 +0,0 @@
1
- <script setup lang="ts">
2
- import { computed } from "vue";
3
-
4
- interface Props {
5
- messages: SupportPageView<SupportMessageView>;
6
- events: SupportPageView<SupportTicketEventView>;
7
- assigneeLabels?: Record<string, string>;
8
- }
9
-
10
- interface HistoryEmits {
11
- attachment: [resourceKey: string];
12
- messagesPage: [page: number];
13
- eventsPage: [page: number];
14
- }
15
-
16
- const props = withDefaults(defineProps<Props>(), {
17
- assigneeLabels: () => ({}),
18
- });
19
- const emit = defineEmits<HistoryEmits>();
20
- const { t } = useI18n();
21
-
22
- const chronologicalMessages = computed(() =>
23
- [...props.messages.results].reverse(),
24
- );
25
- const messagesPage = computed(
26
- () => Math.floor(props.messages.offset / props.messages.limit) + 1,
27
- );
28
- const eventsPage = computed(
29
- () => Math.floor(props.events.offset / props.events.limit) + 1,
30
- );
31
-
32
- function formatDate(value: string): string {
33
- return new Intl.DateTimeFormat(undefined, {
34
- dateStyle: "medium",
35
- timeStyle: "short",
36
- }).format(new Date(value));
37
- }
38
-
39
- function eventValue(
40
- event: SupportTicketEventView,
41
- value: string | null,
42
- ): string {
43
- if (!value) return t("saas.support.platform.unassigned");
44
- if (event.type === "status_changed")
45
- return t(`saas.support.status.${value}`);
46
- return props.assigneeLabels[value] ?? value;
47
- }
48
- </script>
49
-
50
- <template>
51
- <section class="flex flex-col gap-4">
52
- <div class="flex items-center justify-between gap-3">
53
- <h3 class="font-medium">{{ $t("saas.support.conversation") }}</h3>
54
- <span v-if="messagesPage === 1" class="text-muted text-xs">
55
- {{ $t("saas.support.pagination.latest_page") }}
56
- </span>
57
- </div>
58
- <article
59
- v-for="message in chronologicalMessages"
60
- :key="message._id"
61
- class="rounded-lg p-4"
62
- :class="
63
- message.authorType === 'platform' ? 'bg-primary/10' : 'bg-elevated'
64
- "
65
- >
66
- <div class="text-muted mb-2 flex justify-between text-xs">
67
- <span>{{ $t(`saas.support.author.${message.authorType}`) }}</span>
68
- <time>{{ formatDate(message.createdAt) }}</time>
69
- </div>
70
- <p class="whitespace-pre-wrap text-sm">{{ message.body }}</p>
71
- <div
72
- v-if="message.attachments.length"
73
- class="mt-3 flex flex-wrap gap-2"
74
- >
75
- <UButton
76
- v-for="(attachment, index) in message.attachments"
77
- :key="attachment"
78
- size="xs"
79
- color="neutral"
80
- variant="outline"
81
- icon="i-ph-paperclip"
82
- @click="emit('attachment', attachment)"
83
- >
84
- {{ $t("saas.support.attachment", { number: index + 1 }) }}
85
- </UButton>
86
- </div>
87
- </article>
88
- <div
89
- v-if="messages.total > messages.limit"
90
- class="flex flex-col items-center gap-1"
91
- >
92
- <span class="text-muted text-xs">
93
- {{ $t("saas.support.pagination.older_messages") }}
94
- </span>
95
- <UPagination
96
- :page="messagesPage"
97
- :total="messages.total"
98
- :items-per-page="messages.limit"
99
- size="sm"
100
- @update:page="emit('messagesPage', $event)"
101
- />
102
- </div>
103
- </section>
104
-
105
- <USeparator />
106
- <section class="flex flex-col gap-3">
107
- <h3 class="font-medium">{{ $t("saas.support.history.title") }}</h3>
108
- <p v-if="!events.results.length" class="text-muted text-sm">
109
- {{ $t("saas.support.history.empty") }}
110
- </p>
111
- <div
112
- v-for="event in events.results"
113
- :key="event._id"
114
- class="bg-elevated rounded-lg p-3 text-sm"
115
- >
116
- <p>
117
- {{
118
- $t(`saas.support.history.${event.type}`, {
119
- actor: event.actorName,
120
- previous: eventValue(event, event.previousValue),
121
- next: eventValue(event, event.newValue),
122
- })
123
- }}
124
- </p>
125
- <time class="text-muted text-xs">
126
- {{ formatDate(event.createdAt) }}
127
- </time>
128
- </div>
129
- <UPagination
130
- v-if="events.total > events.limit"
131
- :page="eventsPage"
132
- :total="events.total"
133
- :items-per-page="events.limit"
134
- size="sm"
135
- class="self-center"
136
- @update:page="emit('eventsPage', $event)"
137
- />
138
- </section>
139
- </template>
@@ -1,415 +0,0 @@
1
- <script setup lang="ts">
2
- import { computed, onMounted, reactive, ref } from "vue";
3
- import { sendSupportRequest } from "../composables/useSupportRequest";
4
- import SupportAttachmentUpload from "./SupportAttachmentUpload.vue";
5
- import SupportThreadHistory from "./SupportThreadHistory.vue";
6
-
7
- const { $authFetch } = useAuthFetch();
8
- const { t } = useI18n();
9
- const toast = useToast();
10
- const { resolveApiError } = useApiErrorMessage();
11
-
12
- const tickets = ref<SupportTicketView[]>([]);
13
- const selected = ref<SupportThreadView | null>(null);
14
- const config = ref<SupportConfigView | null>(null);
15
- const isLoading = ref(true);
16
- const isSubmitting = ref(false);
17
- const isReplyUploading = ref(false);
18
- const isDraftUploading = ref(false);
19
- const isCreateOpen = ref(false);
20
- const replyBody = ref("");
21
- const replyAttachments = ref<string[]>([]);
22
- const PAGE_SIZE = 20;
23
- const listPage = ref(1);
24
- const messagesPage = ref(1);
25
- const eventsPage = ref(1);
26
- const ticketTotal = ref(0);
27
- let selectionRequest = 0;
28
- let messagesRequest = 0;
29
- let eventsRequest = 0;
30
- let listRequest = 0;
31
-
32
- const draft = reactive<SupportTicketDraft>({
33
- subject: "",
34
- category: "question",
35
- priority: "normal",
36
- body: "",
37
- attachments: [],
38
- });
39
-
40
- const categoryItems = [
41
- "question",
42
- "incident",
43
- "billing",
44
- "feature_request",
45
- ].map((value) => ({ value, label: t(`saas.support.category.${value}`) }));
46
- const priorityItems = computed(() =>
47
- (config.value?.policy.priorities ?? []).map((value) => ({
48
- value,
49
- label: t(`saas.support.priority.${value}`),
50
- })),
51
- );
52
- const attachmentConstraints = computed(() => ({
53
- maxSize: config.value?.maxAttachmentSize,
54
- allowedMimetypes: config.value?.allowedMimetypes,
55
- }));
56
-
57
- function formatDate(value: string): string {
58
- return new Intl.DateTimeFormat(undefined, {
59
- dateStyle: "medium",
60
- timeStyle: "short",
61
- }).format(new Date(value));
62
- }
63
-
64
- function resetDraft(): void {
65
- draft.subject = "";
66
- draft.category = "question";
67
- draft.priority = config.value?.policy.priorities[0] ?? "normal";
68
- draft.body = "";
69
- draft.attachments = [];
70
- }
71
-
72
- function pageQuery(page: number, sortKey: string): string {
73
- return new URLSearchParams({
74
- offset: String((page - 1) * PAGE_SIZE),
75
- limit: String(PAGE_SIZE),
76
- sortKey,
77
- sortDirection: "desc",
78
- }).toString();
79
- }
80
-
81
- async function load(): Promise<void> {
82
- const request = ++listRequest;
83
- isLoading.value = true;
84
- try {
85
- const [loadedConfig, loadedTickets] = await Promise.all([
86
- $authFetch<SupportConfigView>("/api/saas/support/config"),
87
- $authFetch<SupportPageView<SupportTicketView>>(
88
- `/api/saas/tenant/tables/support-tickets/list?${pageQuery(listPage.value, "lastMessageAt")}`,
89
- ),
90
- ]);
91
- if (request !== listRequest) return;
92
- config.value = loadedConfig;
93
- tickets.value = loadedTickets.results;
94
- ticketTotal.value = loadedTickets.total;
95
- if (!loadedConfig.policy.priorities.includes(draft.priority)) resetDraft();
96
- } catch (error) {
97
- showError(error, "saas.support.error.load");
98
- } finally {
99
- if (request === listRequest) isLoading.value = false;
100
- }
101
- }
102
-
103
- async function selectTicket(ticketId: string): Promise<void> {
104
- messagesPage.value = 1;
105
- eventsPage.value = 1;
106
- const request = ++selectionRequest;
107
- const messageRequest = ++messagesRequest;
108
- const eventRequest = ++eventsRequest;
109
- const baseUrl = `/api/saas/support/${ticketId}`;
110
- try {
111
- const [detail, messages, events] = await Promise.all([
112
- $authFetch<SupportTicketDetailView>(baseUrl),
113
- $authFetch<SupportPageView<SupportMessageView>>(
114
- `${baseUrl}/messages/list?${pageQuery(messagesPage.value, "createdAt")}`,
115
- ),
116
- $authFetch<SupportPageView<SupportTicketEventView>>(
117
- `${baseUrl}/events/list?${pageQuery(eventsPage.value, "createdAt")}`,
118
- ),
119
- ]);
120
- if (
121
- request === selectionRequest &&
122
- messageRequest === messagesRequest &&
123
- eventRequest === eventsRequest
124
- ) {
125
- selected.value = { ticket: detail.ticket, messages, events };
126
- }
127
- } catch (error) {
128
- showError(error, "saas.support.error.load");
129
- }
130
- }
131
-
132
- async function createTicket(): Promise<void> {
133
- if (!draft.subject.trim() || !draft.body.trim()) return;
134
- isSubmitting.value = true;
135
- try {
136
- const ticket = await sendSupportRequest(
137
- `${config.value?.uploadPath}:create`,
138
- draft,
139
- (body) =>
140
- $authFetch<SupportTicketView>("/api/saas/support", {
141
- method: "POST",
142
- body,
143
- }),
144
- );
145
- isCreateOpen.value = false;
146
- resetDraft();
147
- listPage.value = 1;
148
- await load();
149
- await selectTicket(ticket._id);
150
- toast.add({
151
- title: t("saas.support.tenant.created"),
152
- color: "success",
153
- });
154
- } catch (error) {
155
- showError(error, "saas.support.error.create");
156
- } finally {
157
- isSubmitting.value = false;
158
- }
159
- }
160
-
161
- async function sendReply(): Promise<void> {
162
- if (!selected.value || !replyBody.value.trim()) return;
163
- const ticketId = selected.value.ticket._id;
164
- isSubmitting.value = true;
165
- try {
166
- await sendSupportRequest(
167
- `${config.value?.uploadPath}:${ticketId}:reply`,
168
- { body: replyBody.value, attachments: replyAttachments.value },
169
- (body) =>
170
- $authFetch(`/api/saas/support/${ticketId}/messages`, {
171
- method: "POST",
172
- body,
173
- }),
174
- );
175
- replyBody.value = "";
176
- replyAttachments.value = [];
177
- if (selected.value?.ticket._id === ticketId) await selectTicket(ticketId);
178
- await load();
179
- } catch (error) {
180
- showError(error, "saas.support.error.reply");
181
- } finally {
182
- isSubmitting.value = false;
183
- }
184
- }
185
-
186
- async function openAttachment(
187
- ticketId: string,
188
- resourceKey: string,
189
- ): Promise<void> {
190
- try {
191
- const query = new URLSearchParams({ resourceKey });
192
- const file = await $authFetch<SupportAttachmentView>(
193
- `/api/saas/support/${ticketId}/attachment?${query}`,
194
- );
195
- window.open(file.url, "_blank", "noopener,noreferrer");
196
- } catch (error) {
197
- showError(error, "saas.support.error.attachment");
198
- }
199
- }
200
-
201
- function showError(error: unknown, fallback: string): void {
202
- toast.add({
203
- title: resolveApiError(error, fallback),
204
- color: "error",
205
- icon: "i-ph-warning-circle",
206
- });
207
- }
208
-
209
- onMounted(load);
210
-
211
- function changeListPage(page: number): void {
212
- listPage.value = page;
213
- void load();
214
- }
215
-
216
- function changeThreadPage(kind: "messages" | "events", page: number): void {
217
- if (!selected.value) return;
218
- const pages = { messages: messagesPage, events: eventsPage };
219
- pages[kind].value = page;
220
- void loadThreadPage(kind, selected.value.ticket._id);
221
- }
222
-
223
- async function loadThreadPage(
224
- kind: "messages" | "events",
225
- ticketId: string,
226
- ): Promise<void> {
227
- const requests = { messages: messagesRequest, events: eventsRequest };
228
- const request = ++requests[kind];
229
- if (kind === "messages") messagesRequest = request;
230
- else eventsRequest = request;
231
- try {
232
- const page = await $authFetch<
233
- SupportPageView<SupportMessageView | SupportTicketEventView>
234
- >(
235
- `/api/saas/support/${ticketId}/${kind}/list?${pageQuery(kind === "messages" ? messagesPage.value : eventsPage.value, "createdAt")}`,
236
- );
237
- const currentRequest =
238
- kind === "messages" ? messagesRequest : eventsRequest;
239
- if (request !== currentRequest || selected.value?.ticket._id !== ticketId)
240
- return;
241
- if (kind === "messages") {
242
- selected.value.messages = page as SupportPageView<SupportMessageView>;
243
- } else {
244
- selected.value.events = page as SupportPageView<SupportTicketEventView>;
245
- }
246
- } catch (error) {
247
- showError(error, "saas.support.error.load");
248
- }
249
- }
250
- </script>
251
-
252
- <template>
253
- <div class="grid gap-6 xl:grid-cols-[22rem_1fr]">
254
- <div class="flex flex-col gap-4">
255
- <UAlert
256
- v-if="config"
257
- color="primary"
258
- variant="subtle"
259
- icon="i-ph-clock-countdown"
260
- :title="$t(`saas.support.sla.${config.policy.level}.title`)"
261
- :description="$t(`saas.support.sla.${config.policy.level}.description`)"
262
- />
263
- <UButton icon="i-ph-plus" block @click="isCreateOpen = true">
264
- {{ $t("saas.support.tenant.new") }}
265
- </UButton>
266
- <USkeleton v-if="isLoading" class="h-32" />
267
- <UCard v-else-if="!tickets.length">
268
- <p class="text-muted text-center text-sm">
269
- {{ $t("saas.support.tenant.empty") }}
270
- </p>
271
- </UCard>
272
- <template v-else>
273
- <button
274
- v-for="ticket in tickets"
275
- :key="ticket._id"
276
- class="border-default hover:bg-elevated rounded-lg border p-4 text-left"
277
- :class="{
278
- 'ring-primary ring-2': selected?.ticket._id === ticket._id,
279
- }"
280
- @click="selectTicket(ticket._id)"
281
- >
282
- <div class="flex items-start justify-between gap-2">
283
- <p class="font-medium">{{ ticket.subject }}</p>
284
- <UBadge size="sm" variant="subtle">
285
- {{ $t(`saas.support.status.${ticket.status}`) }}
286
- </UBadge>
287
- </div>
288
- <p class="text-muted mt-2 text-xs">
289
- {{ formatDate(ticket.lastMessageAt) }}
290
- </p>
291
- </button>
292
- <UPagination
293
- v-if="ticketTotal > PAGE_SIZE"
294
- :page="listPage"
295
- :total="ticketTotal"
296
- :items-per-page="PAGE_SIZE"
297
- size="sm"
298
- class="self-center"
299
- @update:page="changeListPage"
300
- />
301
- </template>
302
- </div>
303
-
304
- <UCard v-if="selected">
305
- <template #header>
306
- <div class="flex flex-wrap items-start justify-between gap-3">
307
- <div>
308
- <h2 class="text-lg font-semibold">
309
- {{ selected.ticket.subject }}
310
- </h2>
311
- <p class="text-muted text-sm">
312
- {{ $t(`saas.support.category.${selected.ticket.category}`) }}
313
- ·
314
- {{ $t(`saas.support.priority.${selected.ticket.priority}`) }}
315
- </p>
316
- </div>
317
- <UBadge>
318
- {{ $t(`saas.support.status.${selected.ticket.status}`) }}
319
- </UBadge>
320
- </div>
321
- </template>
322
- <div class="flex flex-col gap-4">
323
- <SupportThreadHistory
324
- :messages="selected.messages"
325
- :events="selected.events"
326
- @attachment="openAttachment(selected.ticket._id, $event)"
327
- @messages-page="changeThreadPage('messages', $event)"
328
- @events-page="changeThreadPage('events', $event)"
329
- />
330
- <USeparator />
331
- <UTextarea
332
- v-model="replyBody"
333
- :placeholder="$t('saas.support.reply_placeholder')"
334
- :rows="4"
335
- class="w-full"
336
- />
337
- <SupportAttachmentUpload
338
- v-if="config"
339
- v-model="replyAttachments"
340
- multiple
341
- :constraints="attachmentConstraints"
342
- :disabled="isSubmitting"
343
- @busy="isReplyUploading = $event"
344
- />
345
- <div class="flex justify-end">
346
- <UButton
347
- :loading="isSubmitting"
348
- :disabled="isReplyUploading"
349
- @click="sendReply"
350
- >
351
- {{ $t("saas.support.send") }}
352
- </UButton>
353
- </div>
354
- </div>
355
- </UCard>
356
- <UCard v-else class="min-h-64">
357
- <div class="text-muted flex h-full items-center justify-center text-sm">
358
- {{ $t("saas.support.tenant.select") }}
359
- </div>
360
- </UCard>
361
- </div>
362
-
363
- <UModal v-model:open="isCreateOpen" :title="$t('saas.support.tenant.new')">
364
- <template #body>
365
- <div class="flex flex-col gap-4">
366
- <UFormField :label="$t('saas.support.subject')" required>
367
- <UInput v-model="draft.subject" class="w-full" />
368
- </UFormField>
369
- <div class="grid gap-4 sm:grid-cols-2">
370
- <UFormField :label="$t('saas.support.category.label')" required>
371
- <USelect
372
- v-model="draft.category"
373
- :items="categoryItems"
374
- class="w-full"
375
- />
376
- </UFormField>
377
- <UFormField :label="$t('saas.support.priority.label')" required>
378
- <USelect
379
- v-model="draft.priority"
380
- :items="priorityItems"
381
- class="w-full"
382
- />
383
- </UFormField>
384
- </div>
385
- <UFormField :label="$t('saas.support.message')" required>
386
- <UTextarea v-model="draft.body" :rows="5" class="w-full" />
387
- </UFormField>
388
- <UFormField :label="$t('saas.support.attachments')">
389
- <SupportAttachmentUpload
390
- v-if="config"
391
- v-model="draft.attachments"
392
- multiple
393
- :constraints="attachmentConstraints"
394
- :disabled="isSubmitting"
395
- @busy="isDraftUploading = $event"
396
- />
397
- </UFormField>
398
- </div>
399
- </template>
400
- <template #footer>
401
- <div class="flex w-full justify-end gap-2">
402
- <UButton color="neutral" variant="ghost" @click="isCreateOpen = false">
403
- {{ $t("common.cancel") }}
404
- </UButton>
405
- <UButton
406
- :loading="isSubmitting"
407
- :disabled="isDraftUploading"
408
- @click="createTicket"
409
- >
410
- {{ $t("saas.support.tenant.create") }}
411
- </UButton>
412
- </div>
413
- </template>
414
- </UModal>
415
- </template>
@@ -1,74 +0,0 @@
1
- interface PendingSupportRequest {
2
- requestId: string;
3
- fingerprint: string;
4
- }
5
-
6
- interface SupportRequestIdentity {
7
- requestId: string;
8
- }
9
-
10
- interface SupportTransportError {
11
- statusCode?: number;
12
- status?: number;
13
- data?: unknown;
14
- }
15
-
16
- type SupportRequestBody<T> = T & SupportRequestIdentity;
17
- type SupportSender<T, R> = (body: SupportRequestBody<T>) => Promise<R>;
18
- const TERMINAL_STATUSES = new Set([400, 409, 422]);
19
- const STORAGE_PREFIX = "saas:support-request:";
20
-
21
- async function fingerprint(payload: string): Promise<string> {
22
- const bytes = await crypto.subtle.digest(
23
- "SHA-256",
24
- new TextEncoder().encode(payload),
25
- );
26
- return Array.from(new Uint8Array(bytes), (byte) =>
27
- byte.toString(16).padStart(2, "0"),
28
- ).join("");
29
- }
30
-
31
- function isTerminal(error: unknown): boolean {
32
- const result = error as SupportTransportError | null;
33
- return (
34
- TERMINAL_STATUSES.has(result?.statusCode ?? result?.status ?? 0) &&
35
- typeof result?.data === "string" &&
36
- result.data.startsWith("saas.errors.support.")
37
- );
38
- }
39
-
40
- function clearPending(key: string, value: string): void {
41
- if (sessionStorage.getItem(key) === value) sessionStorage.removeItem(key);
42
- }
43
-
44
- /** Retains request identity across ambiguous transport and page reloads, without storing message content. */
45
- export async function sendSupportRequest<T extends object, R>(
46
- scope: string,
47
- body: T,
48
- send: SupportSender<T, R>,
49
- ): Promise<R> {
50
- const payload = JSON.stringify(body);
51
- const digest = await fingerprint(payload);
52
- const key = `${STORAGE_PREFIX}${scope}`;
53
- const saved = sessionStorage.getItem(key);
54
- const pending: PendingSupportRequest = saved
55
- ? JSON.parse(saved)
56
- : { requestId: crypto.randomUUID(), fingerprint: digest };
57
- if (pending.fingerprint !== digest)
58
- throw new Error(
59
- "Retry the pending support request unchanged before submitting different content.",
60
- );
61
- const value = JSON.stringify(pending);
62
- sessionStorage.setItem(key, value);
63
- try {
64
- const result = await send({
65
- ...JSON.parse(payload),
66
- requestId: pending.requestId,
67
- } as SupportRequestBody<T>);
68
- clearPending(key, value);
69
- return result;
70
- } catch (error) {
71
- if (isTerminal(error)) clearPending(key, value);
72
- throw error;
73
- }
74
- }