diff --git a/app/components/FormEditPanel.vue b/app/components/FormEditPanel.vue new file mode 100644 index 0000000..0a1c55a --- /dev/null +++ b/app/components/FormEditPanel.vue @@ -0,0 +1,180 @@ +// The edit panel with 5-step stepper for application and radio inputs for clinical forms + + + \ No newline at end of file diff --git a/app/components/Notes.vue b/app/components/Notes.vue index 011427e..c01cf30 100644 --- a/app/components/Notes.vue +++ b/app/components/Notes.vue @@ -140,6 +140,9 @@ const selectedSessionNoteId = ref(null) const localPreviousNotes = ref([...props.previousNotes]) const localSessionNotes = ref([...props.sessionNotes]) + const showEditHistory = ref(false) + + const formEditPanelRef = ref<{ handleSave: () => void } | null>(null) watch( () => props.previousNotes, @@ -250,6 +253,17 @@ } async function selectSessionNote(sn: SessionNoteRow) { + if (sn.status === 'DRAFT' && sn.appointmentId) { + selectedSessionNoteId.value = null + selectedPreviousNote.value = null + editingNoteId.value = null + editingSessionNoteId.value = null + isEditingPreviousPanel.value = false + selectedAppointmentId.value = sn.appointmentId + noteContent.value = sn.content + currentNoteKind.value = sn.kind ?? 'PROGRESS' + return + } if (sn.appointmentId && sn.appointmentId === selectedAppointmentId.value) { alert('This session is already open in the current note editor.') return @@ -274,6 +288,7 @@ const noteContent = ref(props.currentNote.content || '') /** Kind for the current in-progress note; progress notes are the default. */ + watch(noteContent, (val) => console.log('noteContent changed:', val)) const currentNoteKind = ref('PROGRESS') /** Filter for the sidebar Notes tab: 'all' | 'PROGRESS' | 'PSYCHOTHERAPY'. */ const notesKindFilter = ref<'all' | NoteKind>('all') @@ -294,7 +309,7 @@ const newFormModalSelection = ref('') function openNewFormVersion() { - newFormModalSelection.value = props.forms[0]?.label ?? '' + newFormModalSelection.value = props.forms.filter(f => f.label !== 'Application')[0]?.label ?? '' showNewFormModal.value = true } @@ -338,10 +353,6 @@ 'PCL-5': 'pcl', } - watch(sidebarTab, (t) => { - if (t !== 'forms') selectedForm.value = null - }) - const formPanelSubTab = ref<'answers' | 'history'>('answers') const CLINICAL_FORM_KEYS = new Set(['ace', 'gad', 'phq', 'pcl']) @@ -449,6 +460,25 @@ const formPreviewError = ref(null) const isEditingForm = ref(false) const editableAnswers = ref<{ label: string; answer: string }[]>([]) + + const viewerStep = ref(1) + + const APP_STEP_RANGES = [ + [0, 6], + [7, 17], + [18, 35], + [36, 45], + [46, 49], + ] + + const viewerStepLabels = ['Profile', 'Child', 'Guardian', 'Treatment', 'Therapy'] + + const viewerStepAnswers = computed(() => { + const range = APP_STEP_RANGES[viewerStep.value - 1] + if (!range) return editableAnswers.value + return editableAnswers.value.slice(range[0] ?? 0, (range[1] ?? 0) + 1) + }) + let formPreviewSeq = 0 function severityColor(label: string): string { @@ -513,18 +543,24 @@ } }) - async function saveFormEdits() { + async function onFormEditSave(answers: { label: string; answer: string }[]) { const key = FORM_LABEL_TO_KEY[selectedForm.value!] await $fetch(`/api/clients/${props.client.id}/forms/${key}`, { method: 'PATCH', - body: { answers: editableAnswers.value } + body: { answers }, }) - if (formPreviewData.value) { - formPreviewData.value.questions = [...editableAnswers.value] - } + + // Re-fetch to get updated score/severity + const data = await $fetch( + `/api/clients/${props.client.id}/forms/${key}` + ) + formPreviewData.value = data + editableAnswers.value = data.questions.map((q) => ({ ...q })) + if (data.score != null) formScores.value[selectedForm.value!] = data.score + if (data.severity != null) formSeverities.value[selectedForm.value!] = data.severity + isEditingForm.value = false } - const isEditingPreviousPanel = ref(false) const editingNoteId = ref(null) const editingSessionNoteId = ref(null) @@ -583,26 +619,64 @@ ) async function saveDraftNote() { - if (!noteContent.value.trim() || !selectedAppointmentId.value) return - saveStatus.value = 'saving' - try { - await $fetch(`/api/clients/${props.client.id}/notes`, { - method: 'POST', - body: { + if (!noteContent.value.trim() || !selectedAppointmentId.value) return + saveStatus.value = 'saving' + try { + const response = await $fetch(`/api/clients/${props.client.id}/notes`, { + method: 'POST', + body: { + content: noteContent.value, + appointmentId: selectedAppointmentId.value, + kind: currentNoteKind.value, + action: 'save-draft', + }, + }) as SessionNoteRow + + // Update or insert the saved note in the local list + const existingIdx = localSessionNotes.value.findIndex((n) => n.id === response.id) + const row: SessionNoteRow = { + id: response.id, + createdAt: response.createdAt, content: noteContent.value, - appointmentId: selectedAppointmentId.value, - kind: currentNoteKind.value, - action: 'draft', - }, - }) - localStorage.setItem(`note_draft_${props.client.id}`, noteContent.value) - lastSaved.value = new Date() - saveStatus.value = 'saved' - } catch (err) { - console.error('Draft save failed:', err) - saveStatus.value = 'error' + attendanceStatus: response.attendanceStatus ?? 'show', + sessionName: response.sessionName, + sessionNumber: response.sessionNumber, + appointmentId: response.appointmentId, + appointmentStartTime: selectedAppointment.value?.startTime ?? null, + kind: response.kind, + status: response.status, + } + if (existingIdx === -1) localSessionNotes.value.unshift(row) + else localSessionNotes.value[existingIdx] = row + + localStorage.setItem(`note_draft_${props.client.id}`, noteContent.value) + lastSaved.value = new Date() + saveStatus.value = 'saved' + } catch (err) { + console.error('Draft save failed:', err) + saveStatus.value = 'error' + } } - } +// if (!noteContent.value.trim() || !selectedAppointmentId.value) return +// saveStatus.value = 'saving' +// try { +// await $fetch(`/api/clients/${props.client.id}/notes`, { +// method: 'POST', +// body: { +// content: noteContent.value, +// appointmentId: selectedAppointmentId.value, +// kind: currentNoteKind.value, +// action: 'save-draft', +// }, +// }) +// localStorage.setItem(`note_draft_${props.client.id}`, noteContent.value) +// lastSaved.value = new Date() +// saveStatus.value = 'saved' +// } catch (err) { +// console.error('Draft save failed:', err) +// saveStatus.value = 'error' +// } +// } function startEditPrevious() { const sd = selectedNoteData.value @@ -665,6 +739,8 @@ showEditJustificationModal.value = false } + const showAttendanceWarningModal = ref(false) + const isSavingPrevious = ref(false) const didApplyInitialFocus = ref(false) @@ -824,6 +900,11 @@ async function saveNote() { if (!noteContent.value.trim()) return + if (!attendanceStatus.value) { + showAttendanceWarningModal.value = true + return + } + showSaveModal.value = true } @@ -1027,6 +1108,35 @@ const diff = Math.floor((now.getTime() - date.getTime()) / 60000) return diff < 1 ? 'just now' : `${diff} min ago` } + + function formatAppAnswer(val: string): string { + if (!val) return '—' + if (val.includes('|||')) { + return val.split('|||').filter(Boolean).join(', ') + } + try { + const parsed = JSON.parse(val) + if (Array.isArray(parsed)) { + return parsed.map((item: any) => + typeof item === 'object' + ? Object.values(item).filter(Boolean).join(' ') + : String(item) + ).join(', ') + } + if (parsed && typeof parsed === 'object') { + if (Array.isArray(parsed.values)) { + return [...parsed.values, parsed.other].filter(Boolean).join(', ') + } + if (typeof parsed.value === 'string') return parsed.value + if (typeof parsed.firstName === 'string') { + return [parsed.firstName, parsed.middleInitial, parsed.lastName, parsed.age ? `(age ${parsed.age})` : '', parsed.relationship].filter(Boolean).join(' ') + } + } + } catch { + // plain text + } + return val.replace(/_/g, ' ') + } + \ No newline at end of file diff --git a/app/components/NotesToolbar.vue b/app/components/NotesToolbar.vue index 8b1d77a..1ff56cf 100644 --- a/app/components/NotesToolbar.vue +++ b/app/components/NotesToolbar.vue @@ -14,6 +14,11 @@ const emit = defineEmits<{ const localContent = ref(props.modelValue) +watch(localContent, (val) => emit('update:modelValue', val)) +watch(() => props.modelValue, (val) => { + if (val !== localContent.value) localContent.value = val +}) + const items: EditorToolbarItem[][] = [ [ { diff --git a/app/composables/useApplicationOptions.ts b/app/composables/useApplicationOptions.ts new file mode 100644 index 0000000..c85f18d --- /dev/null +++ b/app/composables/useApplicationOptions.ts @@ -0,0 +1,57 @@ +// Shared options (gender, yes/no, custody, etc.) +export function useApplicationOptions() { + const genderOptions = [ + { label: 'Male', value: 'Male' }, + { label: 'Female', value: 'Female' }, + { label: 'Non-binary', value: 'Non-binary' }, + { label: 'Prefer not to say', value: 'Prefer not to say' }, + { label: 'Other', value: 'Other' }, + ] + const yesNoOptions = [ + { label: 'Yes', value: 'yes' }, + { label: 'No', value: 'no' }, + ] + const custodyOptions = [ + { label: 'Mother', value: 'mother' }, + { label: 'Father', value: 'father' }, + { label: 'Joint', value: 'joint' }, + { label: 'Other', value: 'other' }, + ] + const caregiverOptions = [ + { label: 'Mom', value: 'Mom' }, + { label: 'Dad', value: 'Dad' }, + { label: 'Both', value: 'Both' }, + { label: 'Other', value: 'Other' }, + ] + const siblingOptions = [ + { label: 'Yes', value: 'yes' }, + { label: 'No', value: 'no' }, + { label: 'N/A', value: 'na' }, + ] + const supportGroupOptions = [ + { label: 'Adolescent child diagnosed with cancer', value: 'adolescent_child_diagnosed_with_cancer' }, + { label: 'Adolescent sibling', value: 'adolescent_sibling' }, + { label: 'Parent', value: 'parent' }, + { label: 'No', value: 'no' }, + ] + const referralOptions = [ + { label: 'I have a therapist', value: 'have_therapist' }, + { label: 'I need a referral', value: 'need_referral' }, + ] + const insuranceOptions = [ + { label: 'Yes, with mental health benefits', value: 'yes_with_mental_health_benefits' }, + { label: 'Yes, without mental health benefits', value: 'yes_without_mental_health_benefits' }, + { label: 'No insurance', value: 'no_insurance' }, + ] + + return { + genderOptions, + yesNoOptions, + custodyOptions, + caregiverOptions, + siblingOptions, + supportGroupOptions, + referralOptions, + insuranceOptions, + } +} \ No newline at end of file diff --git a/app/pages/calendar.vue b/app/pages/calendar.vue index f497898..191c7e0 100644 --- a/app/pages/calendar.vue +++ b/app/pages/calendar.vue @@ -17,6 +17,9 @@ | ({ id: string; role?: string } & Record) | null) ?? null ) + const deleteType = ref<'ONE' | 'FUTURE' | 'ALL' | null>(null) + const isDeleteTypeModalOpen = ref(false) + const isDeleteConfirmOpen = ref(false) const { data: adminData, refresh: refreshAdminData } = await useFetch<{ isAdmin: boolean isClinician: boolean @@ -78,6 +81,7 @@ videoProvider: string | null videoJoinUrl: string | null assignedClinicianName: string | null + seriesId?: string | null } const { data: clinicians } = await useFetch('/api/clinicians', { @@ -306,6 +310,32 @@ const isEditMode = ref(false) const isDeleteConfirming = ref(false) const mobileView = ref('week') + const currentRangeLabel = ref('') + + function formatShortDate(date: Date) { + return date.toLocaleDateString('en-US') + } + + function updateRangeLabel(viewType: string, start: Date) { + if (viewType.includes('Week')) { + currentRangeLabel.value = `Week of ${formatShortDate(start)}` + return + } + + if (viewType.includes('Month')) { + currentRangeLabel.value = start.toLocaleDateString('en-US', { + month: 'long', + year: 'numeric', + }) + return + } + + currentRangeLabel.value = start.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + }) + } watch(mobileView, (view) => { changeView(view) @@ -372,6 +402,7 @@ datesSet(info: any) { const view = info.view.type + updateRangeLabel(view, info.view.currentStart) if (view.includes('Day')) mobileView.value = 'day' if (view.includes('Week')) mobileView.value = 'week' @@ -432,14 +463,14 @@ isEditMode.value = false - // Get clientName from either extendedProps or _def.extendedProps const clientName = info.event.extendedProps?.clientName || info.event._def?.extendedProps?.clientName const ext = info.event.extendedProps || info.event._def?.extendedProps || {} + selectedEvent.value = { ...ext, - clientName: clientName, // Make sure clientName is included + clientName, id: info.event.id, clientId: ext.clientId, title: info.event.title, @@ -447,8 +478,12 @@ sessionNumber: ext.sessionNumber ?? null, start: info.event.start, end: info.event.end, + + // merged fields description: ext.description, status: ext.status, + seriesId: ext.seriesId ?? null, + videoProvider: ext.videoProvider ?? null, videoJoinUrl: ext.videoJoinUrl ?? null, assignedClinicianName: ext.assignedClinicianName ?? null, @@ -545,13 +580,40 @@ } } - async function deleteEvent() { + function onDeleteClick() { + // CLOSE the current modal first + isViewModalOpen.value = false + + setTimeout(() => { + if (selectedEvent.value?.seriesId) { + isDeleteTypeModalOpen.value = true + } else { + deleteType.value = 'ONE' + isDeleteConfirmOpen.value = true + } + }, 100) // small delay so UI updates cleanly + } + + function selectDeleteType(type: 'ONE' | 'FUTURE' | 'ALL') { + deleteType.value = type + isDeleteTypeModalOpen.value = false + + setTimeout(() => { + isDeleteConfirmOpen.value = true + }, 100) + } + + async function confirmDelete() { const event = selectedEvent.value if (!event) return try { - await $fetch(`/api/appointments/${event.id}`, { + await $fetch(`/api/appointments/${selectedEvent.value?.id}`, { method: 'DELETE', - credentials: 'include', + body: { + type: deleteType.value || 'ONE', // fallback safety + startTime: event.start.toISOString(), + seriesId: event.seriesId || null, + }, }) toast.add({ @@ -559,12 +621,14 @@ color: 'success', }) - isDeleteConfirming.value = false + // reset state + deleteType.value = null + isDeleteConfirmOpen.value = false isViewModalOpen.value = false await loadEvents() } catch (error) { - console.error('Delete error:', error) + console.error(error) toast.add({ title: 'Failed to delete session', @@ -581,6 +645,7 @@ date: '', startTime: '', endTime: '', + recurrence: '', includeVideo: false, videoProvider: '' as '' | 'GOOGLE_MEET' | 'ZOOM' | 'OTHER', videoJoinUrl: '', @@ -670,6 +735,8 @@ date: form.date, startTime: form.startTime, endTime: form.endTime, + isRecurring: !!form.recurrence, + recurrence: form.recurrence || null, videoProvider: form.includeVideo ? form.videoProvider || 'OTHER' : undefined, videoJoinUrl: form.includeVideo ? form.videoJoinUrl.trim() : undefined, }, @@ -726,6 +793,9 @@ +

+ {{ currentRangeLabel }} +

@@ -876,7 +946,12 @@

- +
Cancel @@ -891,6 +966,35 @@
+ + + + + + + + + + + + + Meeting Link

{{ selectedEvent.videoJoinUrl }} @@ -953,7 +1057,7 @@ :label=" selectedEvent?.videoProvider === 'GOOGLE_MEET' ? 'Join Google Meet' - : `Join ${selectedEvent?.videoProvider ? VIDEO_PROVIDER_LABEL[selectedEvent.videoProvider] ?? 'meeting' : 'meeting'}` + : `Join ${selectedEvent?.videoProvider ? (VIDEO_PROVIDER_LABEL[selectedEvent.videoProvider] ?? 'meeting') : 'meeting'}` " /> @@ -1031,27 +1135,13 @@ - -

-

- Are you sure you want to delete this session? -

-
- Cancel - Delete -
-
-
Delete diff --git a/app/pages/forms/pcl.vue b/app/pages/forms/pcl.vue index 2eb82ee..943cfed 100644 --- a/app/pages/forms/pcl.vue +++ b/app/pages/forms/pcl.vue @@ -58,11 +58,29 @@ } function buildPayload() { - const body: Record = { worstEvent: worstEvent.value } - responses.value.forEach((val, i) => { - body[`q${i + 1}`] = val === -1 ? null : val - }) - return body + return { + worstEvent: worstEvent.value, + q01: responses.value[0], + q02: responses.value[1], + q03: responses.value[2], + q04: responses.value[3], + q05: responses.value[4], + q06: responses.value[5], + q07: responses.value[6], + q08: responses.value[7], + q09: responses.value[8], + q10: responses.value[9], + q11: responses.value[10], + q12: responses.value[11], + q13: responses.value[12], + q14: responses.value[13], + q15: responses.value[14], + q16: responses.value[15], + q17: responses.value[16], + q18: responses.value[17], + q19: responses.value[18], + q20: responses.value[19], + } } async function submitForm() { diff --git a/package-lock.json b/package-lock.json index 8f4b126..c711f51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "marked": "^17.0.4", "nodemailer": "^7.0.11", "nuxt": "4.4.2", + "uuid": "^14.0.0", "vue": "^3.5.25", "vue-router": "^4.6.4", "vue-signature-pad": "^3.0.2", @@ -94,6 +95,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -467,6 +469,7 @@ "version": "1.4.18", "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.4.18.tgz", "integrity": "sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg==", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.3.5" @@ -496,12 +499,14 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.3.0.tgz", "integrity": "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@better-fetch/fetch": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz", - "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==" + "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==", + "peer": true }, "node_modules/@bomb.sh/tab": { "version": "0.0.14", @@ -646,7 +651,8 @@ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", "devOptional": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/@electric-sql/pglite-socket": { "version": "0.0.20", @@ -1132,6 +1138,7 @@ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" @@ -1893,6 +1900,7 @@ "resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.4.2.tgz", "integrity": "sha512-5+IPRNX2CjkBhuWUwz0hBuLqiaJPRoKzQ+SvcdrQDbAyE+VDeFt74VpSFr5/R0ujrK4b+XnSHUJWdS72w6hsog==", "license": "MIT", + "peer": true, "dependencies": { "c12": "^3.3.3", "consola": "^3.4.2", @@ -3707,6 +3715,7 @@ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.3.0.tgz", "integrity": "sha512-FXBIxirqQfdC6b6HnNgxGmU7ydCPEPk7maHMOduJJfnTP+MuOGa15X4omjR/zpPUUpm8ef/mEFQjJudOGkXFcQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/client-runtime-utils": "7.3.0" }, @@ -4855,6 +4864,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.4.tgz", "integrity": "sha512-vGIGm/HpqLg8EAAQXQ+koV+/S828OEpzocfWcPOwo1u2QUVf9dQG47Yy6JJ8zFFaJwfv4dBcOXli+7BrJwsxDQ==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -4951,6 +4961,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.20.5.tgz", "integrity": "sha512-IalIm6BznHds2VzR4+6gMAgi4VXwZAXdYkl28EPZ8/xscBaUn1tCxcTBbCpmN3UhkABaCoJtmmER5TDy+x72Ag==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -4980,6 +4991,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.20.5.tgz", "integrity": "sha512-D2W2fkpmXKRG4i0K0XVfbsTRYQMU9RudQIXBB7HnYZFosyJs3dvsT1cFurDUANKLPCxbI24eiubAKs6b1vzGpA==", "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/dom": "^1.6.13" }, @@ -5140,6 +5152,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.4.tgz", "integrity": "sha512-Xe8UFvvHmyp/c/TJsFwlwU9CWACYbBirNsluJ3U1+H8BTu1wqdrT/AXR5uIXeyCl5kiWKgX5q71eHWbYFOrqrg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -5195,6 +5208,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.20.5.tgz", "integrity": "sha512-6CbgZULF+dQ9KTothAORBZAXPdGneWicMWTV3Gyeh9gNySC18QsGQj3D2GxnllpMekmZXydtDbNFSoCiWjKFWQ==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -5326,6 +5340,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.20.5.tgz", "integrity": "sha512-c4am6SznqfMnbUNSh4MvufiD7cMLdqL1BArok22uBgSWkS1sB9RVBYe8+x0jrOkk0UPEVlzDHbQ+nU+WmIyS2Q==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -5357,6 +5372,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.4.tgz", "integrity": "sha512-hj8Qka6WcHRllHUdeSjDnq2XaisUo4KsoGJc1WcFpoa1Yd+OeD861zUMnV7DFVGdZRy45Obht0CUYJpXQ4yA4w==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-commands": "^1.6.2", @@ -5417,6 +5433,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.20.5.tgz", "integrity": "sha512-5fqRNgnzYdJ1oDpyLqwrbVsZwvI+5VW/U89LPMvBYM7sFS7Xd0xfyxyAOWcJN4V0zLeTcuElWN3R+IUTLKbU+Q==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -5431,6 +5448,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/vue-3/-/vue-3-3.20.5.tgz", "integrity": "sha512-5uUK3RAMNvUetZOv56Kz8nurhxHxMH60GgCCrVFgIBZoTc14u3d3v7EpcA6gNgzogutrR8GxvyFU3iIkj4kkHA==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -5881,6 +5899,7 @@ "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz", "integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.2.1", @@ -6006,6 +6025,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6340,6 +6360,7 @@ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "license": "Apache-2.0", + "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -6563,6 +6584,7 @@ "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.1.8.tgz", "integrity": "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw==", "license": "MIT", + "peer": true, "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", @@ -6708,6 +6730,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6809,6 +6832,7 @@ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -6926,6 +6950,7 @@ "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", "license": "MIT", + "peer": true, "dependencies": { "consola": "^3.2.3" } @@ -7676,7 +7701,8 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/embla-carousel-auto-height": { "version": "8.6.0", @@ -8253,6 +8279,7 @@ "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz", "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=10" } @@ -8506,6 +8533,7 @@ "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -8899,7 +8927,6 @@ "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", "license": "MIT", - "peer": true, "funding": { "type": "GitHub Sponsors ❤", "url": "https://github.com/sponsors/dmonad" @@ -8934,6 +8961,7 @@ "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/panva" } @@ -8997,6 +9025,7 @@ "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.10.tgz", "integrity": "sha512-ksNxfzIW77OcZ+QWSAPC7yDqUSaIVwkTWnTPNiIy//vifNbwsSgQ57OkkncHxxpcBHM3LRfLAZVEh7kjq5twVA==", "license": "MIT", + "peer": true, "engines": { "node": ">=20.0.0" } @@ -9058,7 +9087,6 @@ "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", "license": "MIT", - "peer": true, "dependencies": { "isomorphic.js": "^0.2.4" }, @@ -9768,6 +9796,7 @@ "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", @@ -9824,6 +9853,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -10160,6 +10190,7 @@ "resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.4.2.tgz", "integrity": "sha512-iWVFpr/YEqVU/CenqIHMnIkvb2HE/9f+q8oxZ+pj2et+60NljGRClCgnmbvGPdmNFE0F1bEhoBCYfqbDOCim3Q==", "license": "MIT", + "peer": true, "dependencies": { "@dxup/nuxt": "^0.4.0", "@nuxt/cli": "^3.34.0", @@ -10512,6 +10543,7 @@ "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.117.0.tgz", "integrity": "sha512-l3cbgK5wUvWDVNWM/JFU77qDdGZK1wudnLsFcrRyNo/bL1CyU8pC25vDhMHikVY29lbK2InTWsX42RxVSutUdQ==", "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/types": "^0.117.0" }, @@ -10711,6 +10743,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11199,7 +11232,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -11237,6 +11269,7 @@ "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11345,6 +11378,7 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "7.3.0", "@prisma/dev": "0.20.0", @@ -11476,6 +11510,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", + "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -11496,6 +11531,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -11529,6 +11565,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.7.tgz", "integrity": "sha512-jUwKNCEIGiqdvhlS91/2QAg21e4dfU5bH2iwmSDQeosXJgKF7smG0YSplOWK0cjSNgIqXe7VXqo7EIfUFJdt3w==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -11880,6 +11917,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -12166,8 +12204,7 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "devOptional": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/scule": { "version": "1.3.0", @@ -12735,6 +12772,7 @@ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -12763,7 +12801,8 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.2", @@ -13009,6 +13048,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -13477,12 +13517,26 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/valibot": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "typescript": ">=5" }, @@ -13605,6 +13659,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -13960,6 +14015,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.31.tgz", "integrity": "sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.31", "@vue/compiler-sfc": "3.5.31", @@ -14002,6 +14058,7 @@ "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", "license": "MIT", + "peer": true, "dependencies": { "@vue/devtools-api": "^6.6.4" }, @@ -14200,6 +14257,7 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -14316,6 +14374,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 2fb442e..1462c61 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "marked": "^17.0.4", "nodemailer": "^7.0.11", "nuxt": "4.4.2", + "uuid": "^14.0.0", "vue": "^3.5.25", "vue-router": "^4.6.4", "vue-signature-pad": "^3.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8cbda5..18bb813 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: nuxt: specifier: 4.4.2 version: 4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@electric-sql/pglite@0.3.15)(@parcel/watcher@2.5.6)(@types/node@25.5.0)(@vue/compiler-sfc@3.5.30)(better-sqlite3@12.8.0)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.3.15)(better-sqlite3@12.8.0)(mysql2@3.15.3))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(mysql2@3.15.3)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + uuid: + specifier: ^14.0.0 + version: 14.0.0 vue: specifier: ^3.5.25 version: 3.5.30(typescript@5.9.3) @@ -854,56 +857,48 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@oxc-minify/binding-linux-arm64-musl@0.117.0': resolution: {integrity: sha512-C3zapJconWpl2Y7LR3GkRkH6jxpuV2iVUfkFcHT5Ffn4Zu7l88mZa2dhcfdULZDybN1Phka/P34YUzuskUUrXw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@oxc-minify/binding-linux-ppc64-gnu@0.117.0': resolution: {integrity: sha512-2T/Bm+3/qTfuNS4gKSzL8qbiYk+ErHW2122CtDx+ilZAzvWcJ8IbqdZIbEWOlwwe03lESTxPwTBLFqVgQU2OeQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxc-minify/binding-linux-riscv64-gnu@0.117.0': resolution: {integrity: sha512-MKLjpldYkeoB4T+yAi4aIAb0waifxUjLcKkCUDmYAY3RqBJTvWK34KtfaKZL0IBMIXfD92CbKkcxQirDUS9Xcg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxc-minify/binding-linux-riscv64-musl@0.117.0': resolution: {integrity: sha512-UFVcbPvKUStry6JffriobBp8BHtjmLLPl4bCY+JMxIn/Q3pykCpZzRwFTcDurG/kY8tm+uSNfKKdRNa5Nh9A7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [musl] '@oxc-minify/binding-linux-s390x-gnu@0.117.0': resolution: {integrity: sha512-B9GyPQ1NKbvpETVAMyJMfRlD3c6UJ7kiuFUAlx9LTYiQL+YIyT6vpuRlq1zgsXxavZluVrfeJv6x0owV4KDx4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@oxc-minify/binding-linux-x64-gnu@0.117.0': resolution: {integrity: sha512-fXfhtr+WWBGNy4M5GjAF5vu/lpulR4Me34FjTyaK9nDrTZs7LM595UDsP1wliksqp4hD/KdoqHGmbCrC+6d4vA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@oxc-minify/binding-linux-x64-musl@0.117.0': resolution: {integrity: sha512-jFBgGbx1oLadb83ntJmy1dWlAHSQanXTS21G4PgkxyONmxZdZ/UMKr7KsADzMuoPsd2YhJHxzRpwJd9U+4BFBw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@oxc-minify/binding-openharmony-arm64@0.117.0': resolution: {integrity: sha512-nxPd9vx1vYz8IlIMdl9HFdOK/ood1H5hzbSFsyO8JU55tkcJoBL8TLCbuFf9pHpOy27l2gcPyV6z3p4eAcTH5Q==} @@ -981,56 +976,48 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@oxc-parser/binding-linux-arm64-musl@0.117.0': resolution: {integrity: sha512-QagKTDF4lrz8bCXbUi39Uq5xs7C7itAseKm51f33U+Dyar9eJY/zGKqfME9mKLOiahX7Fc1J3xMWVS0AdDXLPg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@oxc-parser/binding-linux-ppc64-gnu@0.117.0': resolution: {integrity: sha512-RPddpcE/0xxWaommWy0c5i/JdrXcXAkxBS2GOrAUh5LKmyCh03hpJedOAWszG4ADsKQwoUQQ1/tZVGRhZIWtKA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxc-parser/binding-linux-riscv64-gnu@0.117.0': resolution: {integrity: sha512-ur/WVZF9FSOiZGxyP+nfxZzuv6r5OJDYoVxJnUR7fM/hhXLh4V/be6rjbzm9KLCDBRwYCEKJtt+XXNccwd06IA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxc-parser/binding-linux-riscv64-musl@0.117.0': resolution: {integrity: sha512-ujGcAx8xAMvhy7X5sBFi3GXML1EtyORuJZ5z2T6UV3U416WgDX/4OCi3GnoteeenvxIf6JgP45B+YTHpt71vpA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [musl] '@oxc-parser/binding-linux-s390x-gnu@0.117.0': resolution: {integrity: sha512-hbsfKjUwRjcMZZvvmpZSc+qS0bHcHRu8aV/I3Ikn9BzOA0ZAgUE7ctPtce5zCU7bM8dnTLi4sJ1Pi9YHdx6Urw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@oxc-parser/binding-linux-x64-gnu@0.117.0': resolution: {integrity: sha512-1QrTrf8rige7UPJrYuDKJLQOuJlgkt+nRSJLBMHWNm9TdivzP48HaK3f4q18EjNlglKtn03lgjMu4fryDm8X4A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@oxc-parser/binding-linux-x64-musl@0.117.0': resolution: {integrity: sha512-gRvK6HPzF5ITRL68fqb2WYYs/hGviPIbkV84HWCgiJX+LkaOpp+HIHQl3zVZdyKHwopXToTbXbtx/oFjDjl8pg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@oxc-parser/binding-openharmony-arm64@0.117.0': resolution: {integrity: sha512-QPJvFbnnDZZY7xc+xpbIBWLThcGBakwaYA9vKV8b3+oS5MGfAZUoTFJcix5+Zg2Ri46sOfrUim6Y6jsKNcssAQ==} @@ -1111,56 +1098,48 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@oxc-transform/binding-linux-arm64-musl@0.117.0': resolution: {integrity: sha512-ykxpPQp0eAcSmhy0Y3qKvdanHY4d8THPonDfmCoktUXb6r0X6qnjpJB3V+taN1wevW55bOEZd97kxtjTKjqhmg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@oxc-transform/binding-linux-ppc64-gnu@0.117.0': resolution: {integrity: sha512-Rvspti4Kr7eq6zSrURK5WjscfWQPvmy/KjJZV45neRKW8RLonE3r9+NgrwSLGoHvQ3F24fbqlkplox1RtlhH5A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxc-transform/binding-linux-riscv64-gnu@0.117.0': resolution: {integrity: sha512-Dr2ZW9ZZ4l1eQ5JUEUY3smBh4JFPCPuybWaDZTLn3ADZjyd8ZtNXEjeMT8rQbbhbgSL9hEgbwaqraole3FNThQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxc-transform/binding-linux-riscv64-musl@0.117.0': resolution: {integrity: sha512-oD1Bnes1bIC3LVBSrWEoSUBj6fvatESPwAVWfJVGVQlqWuOs/ZBn1e4Nmbipo3KGPHK7DJY75r/j7CQCxhrOFQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [musl] '@oxc-transform/binding-linux-s390x-gnu@0.117.0': resolution: {integrity: sha512-qT//IAPLvse844t99Kff5j055qEbXfwzWgvCMb0FyjisnB8foy25iHZxZIocNBe6qwrCYWUP1M8rNrB/WyfS1Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@oxc-transform/binding-linux-x64-gnu@0.117.0': resolution: {integrity: sha512-2YEO5X+KgNzFqRVO5dAkhjcI5gwxus4NSWVl/+cs2sI6P0MNPjqE3VWPawl4RTC11LvetiiZdHcujUCPM8aaUw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@oxc-transform/binding-linux-x64-musl@0.117.0': resolution: {integrity: sha512-3wqWbTSaIFZvDr1aqmTul4cg8PRWYh6VC52E8bLI7ytgS/BwJLW+sDUU2YaGIds4sAf/1yKeJRmudRCDPW9INg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@oxc-transform/binding-openharmony-arm64@0.117.0': resolution: {integrity: sha512-Ebxx6NPqhzlrjvx4+PdSqbOq+li0f7X59XtJljDghkbJsbnkHvhLmPR09ifHt5X32UlZN63ekjwcg/nbmHLLlA==} @@ -1220,42 +1199,36 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@parcel/watcher-wasm@2.5.6': resolution: {integrity: sha512-byAiBZ1t3tXQvc8dMD/eoyE7lTXYorhn+6uVW5AC+JGI1KtJC/LvDche5cfUE+qiefH+Ybq0bUCJU0aB1cSHUA==} @@ -1475,79 +1448,66 @@ packages: resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.0': resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.0': resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.0': resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.0': resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.0': resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.0': resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.0': resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.0': resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.0': resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.0': resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.0': resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.0': resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.60.0': resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} @@ -1634,28 +1594,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.2': resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.2': resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.2': resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.2': resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} @@ -3292,28 +3248,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -4699,6 +4651,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@14.0.0: + resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} + hasBin: true + valibot@1.2.0: resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} peerDependencies: @@ -9856,6 +9812,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@14.0.0: {} + valibot@1.2.0(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 diff --git a/prisma/migrations/20260129194131_init/migration.sql b/prisma/migrations/20260129194131_init/migration.sql new file mode 100644 index 0000000..b547781 --- /dev/null +++ b/prisma/migrations/20260129194131_init/migration.sql @@ -0,0 +1,66 @@ +-- CreateTable +CREATE TABLE "user" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "emailVerified" BOOLEAN NOT NULL DEFAULT false, + "image" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "session" ( + "id" TEXT NOT NULL PRIMARY KEY, + "expiresAt" DATETIME NOT NULL, + "token" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "ipAddress" TEXT, + "userAgent" TEXT, + "userId" TEXT NOT NULL, + CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "account" ( + "id" TEXT NOT NULL PRIMARY KEY, + "accountId" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "accessToken" TEXT, + "refreshToken" TEXT, + "idToken" TEXT, + "accessTokenExpiresAt" DATETIME, + "refreshTokenExpiresAt" DATETIME, + "scope" TEXT, + "password" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "verification" ( + "id" TEXT NOT NULL PRIMARY KEY, + "identifier" TEXT NOT NULL, + "value" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "user_email_key" ON "user"("email"); + +-- CreateIndex +CREATE INDEX "session_userId_idx" ON "session"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "session_token_key" ON "session"("token"); + +-- CreateIndex +CREATE INDEX "account_userId_idx" ON "account"("userId"); + +-- CreateIndex +CREATE INDEX "verification_identifier_idx" ON "verification"("identifier"); diff --git a/prisma/migrations/20260217005051_add_gad/migration.sql b/prisma/migrations/20260217005051_add_gad/migration.sql new file mode 100644 index 0000000..278fd8e --- /dev/null +++ b/prisma/migrations/20260217005051_add_gad/migration.sql @@ -0,0 +1,36 @@ +-- CreateTable +CREATE TABLE "GadForm" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "userId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'IN_PROGRESS', + "totalScore" INTEGER, + "severity" TEXT, + "submittedAt" DATETIME, + CONSTRAINT "GadForm_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "GadQuestion" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "formId" INTEGER NOT NULL, + "userId" TEXT NOT NULL, + "g01" INTEGER, + "g02" INTEGER, + "g03" INTEGER, + "g04" INTEGER, + "g05" INTEGER, + "g06" INTEGER, + "g07" INTEGER, + "g08" INTEGER, + CONSTRAINT "GadQuestion_formId_fkey" FOREIGN KEY ("formId") REFERENCES "GadForm" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "GadQuestion_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "GadForm_userId_idx" ON "GadForm"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "GadQuestion_formId_key" ON "GadQuestion"("formId"); + +-- CreateIndex +CREATE INDEX "GadQuestion_userId_idx" ON "GadQuestion"("userId"); diff --git a/prisma/migrations/20260226040142/migration.sql b/prisma/migrations/20260226040142/migration.sql new file mode 100644 index 0000000..38b3e29 --- /dev/null +++ b/prisma/migrations/20260226040142/migration.sql @@ -0,0 +1,123 @@ +/* + Warnings: + + - You are about to drop the `GadForm` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `GadQuestion` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the column `image` on the `user` table. All the data in the column will be lost. + +*/ +-- DropIndex +DROP INDEX "GadForm_userId_idx"; + +-- DropIndex +DROP INDEX "GadQuestion_userId_idx"; + +-- DropIndex +DROP INDEX "GadQuestion_formId_key"; + +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "GadForm"; +PRAGMA foreign_keys=on; + +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "GadQuestion"; +PRAGMA foreign_keys=on; + +-- CreateTable +CREATE TABLE "AppForm" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "userId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'IN_PROGRESS', + "submittedAt" DATETIME, + CONSTRAINT "AppForm_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "AppQuestion" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "formId" INTEGER NOT NULL, + "userId" TEXT NOT NULL, + "q01" TEXT, + "q02" TEXT, + "q03" TEXT, + "q04" TEXT, + "q05" TEXT, + "q06" TEXT, + "q07" TEXT, + "q08" TEXT, + "q09" TEXT, + "q10" TEXT, + "q11" TEXT, + "q12" TEXT, + "q13" TEXT, + "q14" TEXT, + "q15" TEXT, + "q16" TEXT, + "q17" TEXT, + "q18" TEXT, + "q19" TEXT, + "q20" TEXT, + "q21" TEXT, + "q22" TEXT, + "q23" TEXT, + "q24" TEXT, + "q25" TEXT, + "q26" TEXT, + "q27" TEXT, + "q28" TEXT, + "q29" TEXT, + "q30" TEXT, + "q31" TEXT, + "q32" TEXT, + "q33" TEXT, + "q34" TEXT, + "q35" TEXT, + "q36" TEXT, + "q37" TEXT, + "q38" TEXT, + "q39" TEXT, + "q40" TEXT, + "q41" TEXT, + "q42" TEXT, + "q43" TEXT, + "q44" TEXT, + "q45" TEXT, + "q46" TEXT, + "q47" TEXT, + "q48" TEXT, + "q49" TEXT, + "q50" TEXT, + CONSTRAINT "AppQuestion_formId_fkey" FOREIGN KEY ("formId") REFERENCES "AppForm" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "AppQuestion_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_user" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "emailVerified" BOOLEAN NOT NULL DEFAULT false, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "role" TEXT NOT NULL DEFAULT 'CLIENT', + "phoneNumber" INTEGER +); +INSERT INTO "new_user" ("createdAt", "email", "emailVerified", "id", "name", "updatedAt") SELECT "createdAt", "email", "emailVerified", "id", "name", "updatedAt" FROM "user"; +DROP TABLE "user"; +ALTER TABLE "new_user" RENAME TO "user"; +CREATE UNIQUE INDEX "user_email_key" ON "user"("email"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- CreateIndex +CREATE UNIQUE INDEX "AppForm_userId_key" ON "AppForm"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AppQuestion_formId_key" ON "AppQuestion"("formId"); + +-- CreateIndex +CREATE INDEX "AppQuestion_userId_idx" ON "AppQuestion"("userId"); diff --git a/prisma/migrations/20260226042411_add_image/migration.sql b/prisma/migrations/20260226042411_add_image/migration.sql new file mode 100644 index 0000000..b34507c --- /dev/null +++ b/prisma/migrations/20260226042411_add_image/migration.sql @@ -0,0 +1,203 @@ +-- AlterTable +ALTER TABLE "user" ADD COLUMN "image" TEXT; + +-- CreateTable +CREATE TABLE "form" ( + "id" TEXT NOT NULL PRIMARY KEY, + "title" TEXT NOT NULL, + "description" TEXT, + "slug" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "question" ( + "id" TEXT NOT NULL PRIMARY KEY, + "text" TEXT NOT NULL, + "type" TEXT NOT NULL, + "alias" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "form_question" ( + "id" TEXT NOT NULL PRIMARY KEY, + "formId" TEXT NOT NULL, + "questionId" TEXT NOT NULL, + "order" INTEGER NOT NULL, + CONSTRAINT "form_question_formId_fkey" FOREIGN KEY ("formId") REFERENCES "form" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "form_question_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "question" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "form_assignment" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "formId" TEXT NOT NULL, + "assignedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completedAt" DATETIME, + CONSTRAINT "form_assignment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "form_assignment_formId_fkey" FOREIGN KEY ("formId") REFERENCES "form" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "ace_response" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "responses" TEXT NOT NULL, + "completedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "ace_response_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "GadForm" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "userId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'IN_PROGRESS', + "totalScore" INTEGER, + "severity" TEXT, + "submittedAt" DATETIME, + CONSTRAINT "GadForm_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "GadQuestion" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "formId" INTEGER NOT NULL, + "userId" TEXT NOT NULL, + "g01" INTEGER, + "g02" INTEGER, + "g03" INTEGER, + "g04" INTEGER, + "g05" INTEGER, + "g06" INTEGER, + "g07" INTEGER, + "g08" INTEGER, + CONSTRAINT "GadQuestion_formId_fkey" FOREIGN KEY ("formId") REFERENCES "GadForm" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "GadQuestion_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "PhqForm" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "userId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'IN_PROGRESS', + "totalScore" INTEGER, + "submittedAt" DATETIME, + CONSTRAINT "PhqForm_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "PhqQuestion" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "formId" INTEGER NOT NULL, + "userId" TEXT NOT NULL, + "q1" INTEGER, + "q2" INTEGER, + "q3" INTEGER, + "q4" INTEGER, + "q5" INTEGER, + "q6" INTEGER, + "q7" INTEGER, + "q8" INTEGER, + "q9" INTEGER, + CONSTRAINT "PhqQuestion_formId_fkey" FOREIGN KEY ("formId") REFERENCES "PhqForm" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "PhqQuestion_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "PclForm" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "userId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'IN_PROGRESS', + "submittedAt" DATETIME, + CONSTRAINT "PclForm_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "PclQuestion" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "formId" INTEGER NOT NULL, + "userId" TEXT NOT NULL, + "q01" INTEGER, + "q02" INTEGER, + "q03" INTEGER, + "q04" INTEGER, + "q05" INTEGER, + "q06" INTEGER, + "q07" INTEGER, + "q08" INTEGER, + "q09" INTEGER, + "q10" INTEGER, + "q11" INTEGER, + "q12" INTEGER, + "q13" INTEGER, + "q14" INTEGER, + "q15" INTEGER, + "q16" INTEGER, + "q17" INTEGER, + "q18" INTEGER, + "q19" INTEGER, + "q20" INTEGER, + CONSTRAINT "PclQuestion_formId_fkey" FOREIGN KEY ("formId") REFERENCES "PclForm" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "PclQuestion_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "form_slug_key" ON "form"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "question_alias_key" ON "question"("alias"); + +-- CreateIndex +CREATE INDEX "form_question_formId_idx" ON "form_question"("formId"); + +-- CreateIndex +CREATE INDEX "form_question_questionId_idx" ON "form_question"("questionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "form_question_formId_questionId_key" ON "form_question"("formId", "questionId"); + +-- CreateIndex +CREATE INDEX "form_assignment_userId_idx" ON "form_assignment"("userId"); + +-- CreateIndex +CREATE INDEX "form_assignment_formId_idx" ON "form_assignment"("formId"); + +-- CreateIndex +CREATE UNIQUE INDEX "form_assignment_userId_formId_key" ON "form_assignment"("userId", "formId"); + +-- CreateIndex +CREATE INDEX "ace_response_userId_idx" ON "ace_response"("userId"); + +-- CreateIndex +CREATE INDEX "GadForm_userId_idx" ON "GadForm"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "GadQuestion_formId_key" ON "GadQuestion"("formId"); + +-- CreateIndex +CREATE INDEX "GadQuestion_userId_idx" ON "GadQuestion"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PhqForm_userId_key" ON "PhqForm"("userId"); + +-- CreateIndex +CREATE INDEX "PhqForm_userId_idx" ON "PhqForm"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PhqQuestion_formId_key" ON "PhqQuestion"("formId"); + +-- CreateIndex +CREATE INDEX "PhqQuestion_userId_idx" ON "PhqQuestion"("userId"); + +-- CreateIndex +CREATE INDEX "PclForm_userId_idx" ON "PclForm"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PclQuestion_formId_key" ON "PclQuestion"("formId"); + +-- CreateIndex +CREATE INDEX "PclQuestion_userId_idx" ON "PclQuestion"("userId"); diff --git a/prisma/migrations/20260226060301/migration.sql b/prisma/migrations/20260226060301/migration.sql new file mode 100644 index 0000000..d40952a --- /dev/null +++ b/prisma/migrations/20260226060301/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "client" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'INCOMPLETE', + "therapyWeek" INTEGER, + CONSTRAINT "client_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "client_userId_key" ON "client"("userId"); + +-- CreateIndex +CREATE INDEX "client_status_idx" ON "client"("status"); diff --git a/prisma/migrations/20260226072412_appointments/migration.sql b/prisma/migrations/20260226072412_appointments/migration.sql new file mode 100644 index 0000000..e8d585f --- /dev/null +++ b/prisma/migrations/20260226072412_appointments/migration.sql @@ -0,0 +1,26 @@ +/* + Warnings: + + - You are about to drop the `client` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "client"; +PRAGMA foreign_keys=on; + +-- CreateTable +CREATE TABLE "Appointment" ( + "id" TEXT NOT NULL PRIMARY KEY, + "clientId" TEXT NOT NULL, + "adminId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "startTime" DATETIME NOT NULL, + "endTime" DATETIME NOT NULL, + "status" TEXT NOT NULL DEFAULT 'SCHEDULED', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Appointment_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "user" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "Appointment_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "user" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); diff --git a/prisma/migrations/20260409184455_add_recurring_fields/migration.sql b/prisma/migrations/20260409184455_add_recurring_fields/migration.sql new file mode 100644 index 0000000..c2580e5 --- /dev/null +++ b/prisma/migrations/20260409184455_add_recurring_fields/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Appointment" ADD COLUMN "recurrence" TEXT; +ALTER TABLE "Appointment" ADD COLUMN "seriesId" TEXT; diff --git a/prisma/migrations/20260409203356/migration.sql b/prisma/migrations/20260409203356/migration.sql new file mode 100644 index 0000000..ffdd9af --- /dev/null +++ b/prisma/migrations/20260409203356/migration.sql @@ -0,0 +1,310 @@ +/* + Warnings: + + - You are about to drop the `ace_response` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `form` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `form_assignment` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `form_question` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `question` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the column `recurrence` on the `Appointment` table. All the data in the column will be lost. + - You are about to drop the column `seriesId` on the `Appointment` table. All the data in the column will be lost. + +*/ +-- DropIndex +DROP INDEX "ace_response_userId_idx"; + +-- DropIndex +DROP INDEX "form_slug_key"; + +-- DropIndex +DROP INDEX "form_assignment_userId_formId_key"; + +-- DropIndex +DROP INDEX "form_assignment_formId_idx"; + +-- DropIndex +DROP INDEX "form_assignment_userId_idx"; + +-- DropIndex +DROP INDEX "form_question_formId_questionId_key"; + +-- DropIndex +DROP INDEX "form_question_questionId_idx"; + +-- DropIndex +DROP INDEX "form_question_formId_idx"; + +-- DropIndex +DROP INDEX "question_alias_key"; + +-- AlterTable +ALTER TABLE "PclForm" ADD COLUMN "severity" TEXT; +ALTER TABLE "PclForm" ADD COLUMN "totalScore" INTEGER; + +-- AlterTable +ALTER TABLE "PclQuestion" ADD COLUMN "worstEvent" TEXT; + +-- AlterTable +ALTER TABLE "PhqForm" ADD COLUMN "severity" TEXT; + +-- AlterTable +ALTER TABLE "PhqQuestion" ADD COLUMN "q10" INTEGER; + +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "ace_response"; +PRAGMA foreign_keys=on; + +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "form"; +PRAGMA foreign_keys=on; + +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "form_assignment"; +PRAGMA foreign_keys=on; + +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "form_question"; +PRAGMA foreign_keys=on; + +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "question"; +PRAGMA foreign_keys=on; + +-- CreateTable +CREATE TABLE "client" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'INCOMPLETE', + "therapyWeek" INTEGER, + "missedSessions" INTEGER NOT NULL DEFAULT 0, + CONSTRAINT "client_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "client_permission" ( + "id" TEXT NOT NULL PRIMARY KEY, + "clientId" TEXT NOT NULL, + "canViewScores" BOOLEAN NOT NULL DEFAULT false, + "canViewNotes" BOOLEAN NOT NULL DEFAULT false, + "canViewPlan" BOOLEAN NOT NULL DEFAULT false, + CONSTRAINT "client_permission_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "client" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "client_plan" ( + "id" TEXT NOT NULL PRIMARY KEY, + "clientId" TEXT NOT NULL, + "content" TEXT NOT NULL DEFAULT '', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "client_plan_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "client" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "change_audit" ( + "id" TEXT NOT NULL PRIMARY KEY, + "entityType" TEXT NOT NULL, + "entityId" TEXT NOT NULL, + "oldValue" TEXT, + "newValue" TEXT, + "reasoning" TEXT, + "documentationPath" TEXT, + "documentationName" TEXT, + "signatureData" TEXT NOT NULL, + "signedById" TEXT NOT NULL, + "signedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "change_audit_signedById_fkey" FOREIGN KEY ("signedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "ace_form" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "userId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'IN_PROGRESS', + "totalScore" INTEGER, + "severity" TEXT, + "submittedAt" DATETIME, + CONSTRAINT "ace_form_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "ace_question" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "formId" INTEGER NOT NULL, + "userId" TEXT NOT NULL, + "a01" TEXT, + "a02" TEXT, + "a03" TEXT, + "a04" TEXT, + "a05" TEXT, + "a06" TEXT, + "a07" TEXT, + "a08" TEXT, + "a09" TEXT, + "a10" TEXT, + CONSTRAINT "ace_question_formId_fkey" FOREIGN KEY ("formId") REFERENCES "ace_form" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "ace_question_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "PhysicianStatementForm" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'NOT_SUBMITTED', + "originalFileName" TEXT, + "storedFileName" TEXT, + "mimeType" TEXT, + "uploadedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "PhysicianStatementForm_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "ReleaseOfInformationAuthorizationForm" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'NOT_SUBMITTED', + "originalFileName" TEXT, + "storedFileName" TEXT, + "mimeType" TEXT, + "uploadedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "ReleaseOfInformationAuthorizationForm_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "session_note" ( + "id" TEXT NOT NULL PRIMARY KEY, + "clientId" TEXT NOT NULL, + "content" TEXT NOT NULL, + "attended" BOOLEAN NOT NULL DEFAULT true, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "session_note_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "client" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "session_note_edit" ( + "id" TEXT NOT NULL PRIMARY KEY, + "sessionNoteId" TEXT NOT NULL, + "originalContent" TEXT, + "reason" TEXT, + "signature" TEXT, + "editedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "session_note_edit_sessionNoteId_fkey" FOREIGN KEY ("sessionNoteId") REFERENCES "session_note" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "declaration_template" ( + "id" TEXT NOT NULL PRIMARY KEY, + "requestKind" TEXT NOT NULL, + "version" INTEGER NOT NULL, + "content" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "session_notes_request" ( + "id" TEXT NOT NULL PRIMARY KEY, + "clientId" TEXT NOT NULL, + "requestKind" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "signatureData" TEXT NOT NULL, + "declarationTemplateId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "decidedAt" DATETIME, + "decidedByUserId" TEXT, + "rejectionReason" TEXT, + "approvedSummaryText" TEXT, + CONSTRAINT "session_notes_request_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "client" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "session_notes_request_declarationTemplateId_fkey" FOREIGN KEY ("declarationTemplateId") REFERENCES "declaration_template" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "session_notes_request_decidedByUserId_fkey" FOREIGN KEY ("decidedByUserId") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Appointment" ( + "id" TEXT NOT NULL PRIMARY KEY, + "clientId" TEXT NOT NULL, + "adminId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "startTime" DATETIME NOT NULL, + "endTime" DATETIME NOT NULL, + "status" TEXT NOT NULL DEFAULT 'SCHEDULED', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Appointment_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "user" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "Appointment_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "user" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_Appointment" ("adminId", "clientId", "createdAt", "description", "endTime", "id", "startTime", "status", "title", "updatedAt") SELECT "adminId", "clientId", "createdAt", "description", "endTime", "id", "startTime", "status", "title", "updatedAt" FROM "Appointment"; +DROP TABLE "Appointment"; +ALTER TABLE "new_Appointment" RENAME TO "Appointment"; +CREATE INDEX "Appointment_clientId_idx" ON "Appointment"("clientId"); +CREATE INDEX "Appointment_adminId_idx" ON "Appointment"("adminId"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- CreateIndex +CREATE UNIQUE INDEX "client_userId_key" ON "client"("userId"); + +-- CreateIndex +CREATE INDEX "client_status_idx" ON "client"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "client_permission_clientId_key" ON "client_permission"("clientId"); + +-- CreateIndex +CREATE UNIQUE INDEX "client_plan_clientId_key" ON "client_plan"("clientId"); + +-- CreateIndex +CREATE INDEX "change_audit_entityType_entityId_idx" ON "change_audit"("entityType", "entityId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ace_form_userId_key" ON "ace_form"("userId"); + +-- CreateIndex +CREATE INDEX "ace_form_userId_idx" ON "ace_form"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ace_question_formId_key" ON "ace_question"("formId"); + +-- CreateIndex +CREATE INDEX "ace_question_userId_idx" ON "ace_question"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PhysicianStatementForm_userId_key" ON "PhysicianStatementForm"("userId"); + +-- CreateIndex +CREATE INDEX "PhysicianStatementForm_status_idx" ON "PhysicianStatementForm"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "ReleaseOfInformationAuthorizationForm_userId_key" ON "ReleaseOfInformationAuthorizationForm"("userId"); + +-- CreateIndex +CREATE INDEX "ReleaseOfInformationAuthorizationForm_status_idx" ON "ReleaseOfInformationAuthorizationForm"("status"); + +-- CreateIndex +CREATE INDEX "session_note_clientId_idx" ON "session_note"("clientId"); + +-- CreateIndex +CREATE INDEX "session_note_edit_sessionNoteId_idx" ON "session_note_edit"("sessionNoteId"); + +-- CreateIndex +CREATE UNIQUE INDEX "declaration_template_requestKind_version_key" ON "declaration_template"("requestKind", "version"); + +-- CreateIndex +CREATE INDEX "session_notes_request_clientId_idx" ON "session_notes_request"("clientId"); + +-- CreateIndex +CREATE INDEX "session_notes_request_status_idx" ON "session_notes_request"("status"); + +-- CreateIndex +CREATE INDEX "session_notes_request_declarationTemplateId_idx" ON "session_notes_request"("declarationTemplateId"); diff --git a/prisma/schema/appointments.prisma b/prisma/schema/appointments.prisma index e01aa7d..01f34b6 100644 --- a/prisma/schema/appointments.prisma +++ b/prisma/schema/appointments.prisma @@ -6,23 +6,33 @@ enum VideoConferenceProvider { } model Appointment { - id String @id @default(cuid()) - clientId String - adminId String - title String + id String @id @default(cuid()) + + clientId String + adminId String + + title String sessionName String sessionNumber Int - description String? - startTime DateTime - endTime DateTime - status String @default("SCHEDULED") - /** When set with videoJoinUrl, describes how the client joins the call. */ + + description String? + startTime DateTime + endTime DateTime + + status String @default("SCHEDULED") + + seriesId String? + recurrence String + videoProvider VideoConferenceProvider? videoJoinUrl String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - client User @relation("ClientAppointments", fields: [clientId], references: [id], onDelete: Restrict) - admin User @relation("AdminAppointments", fields: [adminId], references: [id], onDelete: Restrict) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + client User @relation("ClientAppointments", fields: [clientId], references: [id], onDelete: Restrict) + admin User @relation("AdminAppointments", fields: [adminId], references: [id], onDelete: Restrict) + sessionNotes SessionNote[] @relation("AppointmentSessionNotes") @@index([clientId]) diff --git a/prisma/seed.ts b/prisma/seed.ts index df323a9..8a706c4 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -14,12 +14,8 @@ async function seedForms(userId: string) { // 1. AppForm (Application) const appForm = await prisma.appForm.upsert({ where: { userId }, - update: {}, - create: { - userId, - status: 'COMPLETE', - submittedAt: new Date(), - }, + update: { status: 'COMPLETE', submittedAt: new Date() }, + create: { userId, status: 'COMPLETE', submittedAt: new Date() }, }) await prisma.appQuestion.upsert({ @@ -31,33 +27,15 @@ async function seedForms(userId: string) { q01: 'Bob', q02: 'Builder', q05: '1234567890', - }, - }) + }, +}) // 2. GAD-7 const gadForm = await prisma.gadForm.create({ - data: { - userId, - status: 'COMPLETE', - totalScore: 12, - severity: 'Moderate', - submittedAt: new Date(), - }, + data: { userId, status: 'COMPLETE', totalScore: 12, severity: 'Moderate', submittedAt: new Date() }, }) - await prisma.gadQuestion.create({ - data: { - formId: gadForm.id, - userId, - g01: 2, - g02: 1, - g03: 2, - g04: 1, - g05: 2, - g06: 2, - g07: 2, - g08: 1, - }, + data: { formId: gadForm.id, userId, g01: 2, g02: 1, g03: 2, g04: 1, g05: 2, g06: 2, g07: 2, g08: 1 }, }) // 3. PHQ-9 @@ -70,61 +48,19 @@ async function seedForms(userId: string) { submittedAt: new Date(), }, }) - - await prisma.phqQuestion.upsert({ - where: { formId: phqForm.id }, - update: {}, - create: { - formId: phqForm.id, - userId, - q1: 2, - q2: 1, - q3: 2, - q4: 1, - q5: 2, - q6: 2, - q7: 2, - q8: 1, - q9: 1, - q10: 1, - }, + await prisma.phqQuestion.create({ + data: { formId: phqForm.id, userId, q1: 2, q2: 1, q3: 2, q4: 1, q5: 2, q6: 2, q7: 2, q8: 1, q9: 1, q10: 1 }, }) // 4. PCL-5 const pclForm = await prisma.pclForm.create({ - data: { - userId, - status: 'COMPLETE', - totalScore: 45, - severity: 'Moderate', - submittedAt: new Date(), - }, + data: { userId, status: 'COMPLETE', totalScore: 45, severity: 'Moderate', submittedAt: new Date() }, }) - await prisma.pclQuestion.create({ data: { - formId: pclForm.id, - userId, - q01: 3, - q02: 2, - q03: 3, - q04: 2, - q05: 2, - q06: 3, - q07: 2, - q08: 2, - q09: 2, - q10: 2, - q11: 2, - q12: 2, - q13: 2, - q14: 2, - q15: 2, - q16: 2, - q17: 2, - q18: 2, - q19: 2, - q20: 2, + formId: pclForm.id, userId, + q01: 3, q02: 2, q03: 3, q04: 2, q05: 2, q06: 3, q07: 2, q08: 2, q09: 2, q10: 2, + q11: 2, q12: 2, q13: 2, q14: 2, q15: 2, q16: 2, q17: 2, q18: 2, q19: 2, q20: 2, }, }) @@ -138,31 +74,19 @@ async function seedForms(userId: string) { submittedAt: new Date(), }, }) - - await prisma.aceQuestion.upsert({ - where: { formId: aceForm.id }, - update: {}, - create: { - formId: aceForm.id, - userId, - a01: 'Yes', - a02: 'Yes', - a03: 'No', - a04: 'No', - a05: 'No', - a06: 'No', - a07: 'No', - a08: 'No', - a09: 'No', - a10: 'No', + await prisma.aceQuestion.create({ + data: { + formId: aceForm.id, userId, + a01: 'Yes', a02: 'No', a03: 'No', a04: 'Yes', a05: 'Yes', + a06: 'Yes', a07: 'No', a08: 'Yes', a09: 'No', a10: 'No', }, }) } -async function ensureBobBuilderSessionNotes(bobUserId: string) { +async function ensureBobBuilderSessionNotes(bobUserId: string, clinicianUserId: string) { const client = await prisma.client.upsert({ where: { userId: bobUserId }, - update: { status: 'ACTIVE' }, + update: { status: 'ACTIVE', clinician: { connect: { id: clinicianUserId } } }, create: { userId: bobUserId, status: 'ACTIVE' }, }) @@ -170,27 +94,27 @@ async function ensureBobBuilderSessionNotes(bobUserId: string) { where: { clientId: client.id }, }) - if (existingSessionNotes === 0) { - await prisma.sessionNote.createMany({ - data: [ - { - clientId: client.id, - sessionName: 'Intake / Week 1', - sessionNumber: 1, - content: - 'Intake / Week 1 — Rapport established. Bob reviewed clinic policies and confidentiality. Reported primary stressors related to work deadlines and sleep disruption. PHQ-9 and GAD-7 administered; safety screen negative. Plan: sleep hygiene handout, begin weekly CBT skills.', - }, - { - clientId: client.id, - sessionName: 'Session 2', - sessionNumber: 2, - content: - 'Session 2 — Focus on thought challenging around catastrophic predictions at work. Homework: thought record for 3 situations. Bob engaged well; identified one automatic thought pattern to monitor between sessions.', - }, - ], - }) - console.log('Created sample SessionNote rows for Bob Builder.') - } + // if (existingSessionNotes === 0) { + // await prisma.sessionNote.createMany({ + // data: [ + // { + // clientId: client.id, + // sessionName: 'Intake / Week 1', + // sessionNumber: 1, + // content: + // 'Intake / Week 1 — Rapport established. Bob reviewed clinic policies and confidentiality. Reported primary stressors related to work deadlines and sleep disruption. PHQ-9 and GAD-7 administered; safety screen negative. Plan: sleep hygiene handout, begin weekly CBT skills.', + // }, + // { + // clientId: client.id, + // sessionName: 'Session 2', + // sessionNumber: 2, + // content: + // 'Session 2 — Focus on thought challenging around catastrophic predictions at work. Homework: thought record for 3 situations. Bob engaged well; identified one automatic thought pattern to monitor between sessions.', + // }, + // ], + // }) + // console.log('Created sample SessionNote rows for Bob Builder.') + // } // Populate form dummy data if it doesn't exist const existingApp = await prisma.appForm.count({ where: { userId: bobUserId } }) @@ -198,6 +122,89 @@ async function ensureBobBuilderSessionNotes(bobUserId: string) { await seedForms(bobUserId) console.log('Seeded clinical forms for Bob Builder.') } + + return client +} + +async function seedApprovalWorkflowNotes( + clientId: string, + clinicianUserId: string, + adminUserId: string +) { + const existing = await prisma.sessionNote.count({ where: { clientId } }) + if (existing > 0) { + console.log('Session notes already exist; skipping approval-workflow seed.') + return + } + + const PLACEHOLDER_SIG = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=' + + const now = new Date() + + await prisma.sessionNote.create({ + data: { + clientId, sessionName: 'Intake – initial session', sessionNumber: 1, + kind: 'PROGRESS', status: 'DRAFT', + content: 'Client arrived on time. Presenting concerns include sleep disruption and work stress. Draft — still gathering history.', + attendanceStatus: 'show', + }, + }) + + const pendingNote = await prisma.sessionNote.create({ + data: { + clientId, sessionName: 'Session 2 – CBT intro', sessionNumber: 2, + kind: 'PROGRESS', status: 'CLINICIAN_SIGNED', + content: 'Reviewed sleep-hygiene worksheet. Client reports mild improvement. Introduced cognitive-restructuring framework.', + attendanceStatus: 'late', + clinicianSignedAt: now, clinicianSignedById: clinicianUserId, clinicianSignatureData: PLACEHOLDER_SIG, + }, + }) + + await prisma.notification.create({ + data: { + userId: adminUserId, + type: 'NOTE_READY_FOR_APPROVAL', + title: 'Note awaiting approval', + message: 'Carl Karl signed a progress note for Bob Builder. Please review and countersign.', + sessionNoteId: pendingNote.id, + }, + }) + + await prisma.sessionNote.create({ + data: { + clientId, sessionName: 'Session 3 – thought records', sessionNumber: 3, + kind: 'PROGRESS', status: 'FULLY_APPROVED', + content: 'Completed two thought-record examples in session. Client identified core belief patterns and committed to daily practice.', + attendanceStatus: 'no-show', + clinicianSignedAt: now, clinicianSignedById: clinicianUserId, clinicianSignatureData: PLACEHOLDER_SIG, + adminSignedAt: now, adminSignedById: adminUserId, adminSignatureData: PLACEHOLDER_SIG, + adminApprovalNote: 'Reviewed — documentation meets clinic standard.', + }, + }) + + await prisma.sessionNote.create({ + data: { + clientId, sessionName: 'Session 3 – clinician process notes', sessionNumber: 3, + kind: 'PSYCHOTHERAPY', status: 'DRAFT', + content: 'Clinician-only process notes: countertransference observations, working hypotheses, and next-session targets.', + attendanceStatus: 'show', + }, + }) + + await prisma.sessionNote.create({ + data: { + clientId, sessionName: 'Session 2 – clinician process notes', sessionNumber: 2, + kind: 'PSYCHOTHERAPY', status: 'FULLY_APPROVED', + content: 'Process notes: explored defense patterns around perfectionism. Plan to revisit in session 4.', + attendanceStatus: 'show', + clinicianSignedAt: now, clinicianSignedById: clinicianUserId, clinicianSignatureData: PLACEHOLDER_SIG, + adminSignedAt: now, adminSignedById: adminUserId, adminSignatureData: PLACEHOLDER_SIG, + adminApprovalNote: 'Approved — psychotherapy note retained separately.', + }, + }) + + console.log('Seeded 5 session notes across DRAFT / CLINICIAN_SIGNED / FULLY_APPROVED.') } async function main() { @@ -207,16 +214,14 @@ async function main() { await backfillSessionNotesRequestTemplates(prisma) // Create / Upsert Alice (Admin) - await prisma.user.upsert({ + const alice = await prisma.user.upsert({ where: { email: 'alice@a.com' }, update: { role: 'ADMIN', name: 'Alice Wonderland' }, - create: { - id: 'alice_id', - email: 'alice@a.com', - name: 'Alice Wonderland', - emailVerified: true, - role: 'ADMIN', - }, + create: { id: 'alice_id', + email: 'alice@a.com', + name: 'Alice Wonderland', + emailVerified: true, + role: 'ADMIN' }, }) console.log('Seeded Admin: alice@a.com') @@ -245,34 +250,22 @@ async function main() { console.log(`Seeded Admin: ${admin.email}`) } - await prisma.user.upsert({ + const carl = await prisma.user.upsert({ where: { email: 'carl@c.com' }, update: { role: 'CLINICIAN', name: 'Carl Karl' }, - create: { - id: 'carl_id', - email: 'carl@c.com', - name: 'Carl Karl', - emailVerified: true, - role: 'CLINICIAN', - }, + create: { id: 'carl_id', email: 'carl@c.com', name: 'Carl Karl', emailVerified: true, role: 'CLINICIAN' }, }) console.log('Seeded Clinician: carl@c.com') - // Create / Upsert Bob (Client) const bob = await prisma.user.upsert({ where: { email: 'bob@b.com' }, update: { role: 'CLIENT', name: 'Bob Builder' }, - create: { - id: 'bob_id', - email: 'bob@b.com', - name: 'Bob Builder', - emailVerified: true, - role: 'CLIENT', - }, + create: { id: 'bob_id', email: 'bob@b.com', name: 'Bob Builder', emailVerified: true, role: 'CLIENT' }, }) console.log('Seeded Client: bob@b.com') - await ensureBobBuilderSessionNotes(bob.id) + const bobClient = await ensureBobBuilderSessionNotes(bob.id, carl.id) + await seedApprovalWorkflowNotes(bobClient.id, carl.id, alice.id) console.log('Seeding finished.') } diff --git a/server/api/admin/backfill-absences.post.ts b/server/api/admin/backfill-absences.post.ts index 4a39ae6..23b4777 100644 --- a/server/api/admin/backfill-absences.post.ts +++ b/server/api/admin/backfill-absences.post.ts @@ -22,7 +22,7 @@ export default defineEventHandler(async (event) => { const calendarAbsences = await prisma.sessionNote.count({ where: { clientId: client.id, - attended: false, + attendanceStatus: 'no-show', }, }) diff --git a/server/api/appointments/[id].delete.ts b/server/api/appointments/[id].delete.ts index f16d2bf..03fc703 100644 --- a/server/api/appointments/[id].delete.ts +++ b/server/api/appointments/[id].delete.ts @@ -1,64 +1,67 @@ import { requireStaff } from '../../utils/guard' import { assertStaffCanAccessClient } from '../../utils/clinician-access' import { prisma } from '../../utils/prisma' -import { createError, defineEventHandler, getHeaders, getRouterParam } from 'h3' +import { createError, defineEventHandler, getRouterParam, readBody } from 'h3' export default defineEventHandler(async (event) => { - try { - const id = getRouterParam(event, 'id') - if (!id) { - throw createError({ - statusCode: 400, - statusMessage: 'Missing appointment ID', - }) - } + const id = getRouterParam(event, 'id') + const { type, startTime, seriesId } = await readBody(event) - const user = requireStaff(event) - const adminId = user.id + if (!id) { + throw createError({ + statusCode: 400, + statusMessage: 'Missing appointment id', + }) + } - const existing = await prisma.appointment.findUnique({ - where: { id }, - select: { clientId: true }, + // keep stage auth checks + const user = requireStaff(event) + const adminId = user.id // optional if needed elsewhere + + const existing = await prisma.appointment.findUnique({ + where: { id }, + select: { + clientId: true, + seriesId: true, + }, + }) + + if (!existing) { + throw createError({ + statusCode: 404, + statusMessage: 'Appointment not found', }) - if (!existing) { - throw createError({ statusCode: 404, statusMessage: 'Appointment not found' }) - } - await assertStaffCanAccessClient(event, existing.clientId) + } + + await assertStaffCanAccessClient(event, existing.clientId) + // ONE event only + if (type === 'ONE' || !seriesId) { await prisma.appointment.delete({ where: { id }, }) + } - return { - success: true, - } - } catch (error: any) { - if (error?.code === 'P2025') { - throw createError({ - statusCode: 404, - statusMessage: 'Appointment not found', - }) - } - - if (error && typeof error === 'object' && 'statusCode' in error) { - throw error - } - - console.error('Delete appointment error:', error) - - // Check if it's a Prisma error - if (error && typeof error === 'object') { - const errorObj = error as Record - console.error('Error details:', { - message: errorObj.message, - code: errorObj.code, - meta: errorObj.meta, - }) - } + // entire series + else if (type === 'ALL') { + await prisma.appointment.deleteMany({ + where: { + seriesId, + }, + }) + } - throw createError({ - statusCode: 500, - statusMessage: 'Failed to delete appointment', + // this and future + else if (type === 'FUTURE') { + await prisma.appointment.deleteMany({ + where: { + seriesId, + startTime: { + gte: new Date(startTime), + }, + }, }) } + + return { success: true } }) diff --git a/server/api/appointments/[id].put.ts b/server/api/appointments/[id].put.ts index 935e982..10e5a23 100644 --- a/server/api/appointments/[id].put.ts +++ b/server/api/appointments/[id].put.ts @@ -4,35 +4,53 @@ import { assertStaffCanAccessClient } from '../../utils/clinician-access' import { normalizeVideoJoinUrl, parseVideoProviderInput } from '../../utils/video-conference' import { defineEventHandler, getRouterParam, readBody, createError } from 'h3' import type { VideoConferenceProvider } from '../../../prisma/generated/enums' - export default defineEventHandler(async (event) => { requireStaff(event) try { const id = getRouterParam(event, 'id') - if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing ID' }) + if (!id) { + throw createError({ + statusCode: 400, + statusMessage: 'Missing ID', + }) + } + const body = await readBody(event) - const { description, date, startTime, endTime, videoProvider, videoJoinUrl } = body + + const { type, startTime, seriesId, description, date, endTime, videoProvider, videoJoinUrl } = + body const startTimeDate = new Date(`${date}T${startTime}`) const endTimeDate = new Date(`${date}T${endTime}`) const existing = await prisma.appointment.findUnique({ where: { id }, - select: { videoProvider: true, videoJoinUrl: true, clientId: true }, + select: { + clientId: true, + videoProvider: true, + videoJoinUrl: true, + }, }) + if (!existing) { - throw createError({ statusCode: 404, statusMessage: 'Appointment not found' }) + throw createError({ + statusCode: 404, + statusMessage: 'Appointment not found', + }) } + await assertStaffCanAccessClient(event, existing.clientId) const parsedProvider = videoProvider !== undefined ? (parseVideoProviderInput(videoProvider) as VideoConferenceProvider | null) : undefined - const parsedJoin = - videoJoinUrl !== undefined ? normalizeVideoJoinUrl(videoJoinUrl) : undefined + + const parsedJoin = videoJoinUrl !== undefined ? normalizeVideoJoinUrl(videoJoinUrl) : undefined + const rawJoinInput = typeof videoJoinUrl === 'string' ? videoJoinUrl.trim() : '' + if (videoJoinUrl !== undefined && rawJoinInput && !parsedJoin) { throw createError({ statusCode: 400, @@ -40,8 +58,8 @@ export default defineEventHandler(async (event) => { }) } - const effectiveProvider = - parsedProvider !== undefined ? parsedProvider : existing.videoProvider + const effectiveProvider = parsedProvider !== undefined ? parsedProvider : existing.videoProvider + const effectiveJoin = parsedJoin !== undefined ? parsedJoin : existing.videoJoinUrl if (effectiveJoin && !effectiveProvider) { @@ -51,20 +69,65 @@ export default defineEventHandler(async (event) => { }) } - await prisma.appointment.update({ - where: { id }, - data: { - description, - startTime: startTimeDate, - endTime: endTimeDate, - ...(parsedProvider !== undefined && { videoProvider: parsedProvider }), - ...(parsedJoin !== undefined && { videoJoinUrl: parsedJoin }), - }, - }) + // Update only this event + if (type === 'ONE' || !seriesId) { + await prisma.appointment.update({ + where: { id }, + data: { + description, + startTime: startTimeDate, + endTime: endTimeDate, + ...(parsedProvider !== undefined && { + videoProvider: parsedProvider, + }), + ...(parsedJoin !== undefined && { + videoJoinUrl: parsedJoin, + }), + }, + }) + } + + // Update all events in series + else if (type === 'ALL') { + await prisma.appointment.updateMany({ + where: { seriesId }, + data: { + description, + ...(parsedProvider !== undefined && { + videoProvider: parsedProvider, + }), + ...(parsedJoin !== undefined && { + videoJoinUrl: parsedJoin, + }), + }, + }) + } + + // Update this and future events + else if (type === 'FUTURE') { + await prisma.appointment.updateMany({ + where: { + seriesId, + startTime: { + gte: new Date(startTime), + }, + }, + data: { + description, + ...(parsedProvider !== undefined && { + videoProvider: parsedProvider, + }), + ...(parsedJoin !== undefined && { + videoJoinUrl: parsedJoin, + }), + }, + }) + } return { success: true } } catch (error: unknown) { const err = error as { code?: string; statusCode?: number } + if (err?.code === 'P2025') { throw createError({ statusCode: 404, @@ -77,6 +140,7 @@ export default defineEventHandler(async (event) => { } console.error('Error updating appointment:', error) + throw createError({ statusCode: 500, statusMessage: 'Failed to update appointment', diff --git a/server/api/appointments/index.get.ts b/server/api/appointments/index.get.ts index a5916ee..bf8e7fe 100644 --- a/server/api/appointments/index.get.ts +++ b/server/api/appointments/index.get.ts @@ -121,6 +121,7 @@ export default defineEventHandler(async (event) => { clientName: a.client.name, description: a.description, status: a.status, + seriesId: a.seriesId, videoProvider: a.videoProvider, videoJoinUrl: a.videoJoinUrl, assignedClinicianName: diff --git a/server/api/appointments/index.post.ts b/server/api/appointments/index.post.ts index 4a1b91d..1b398ce 100644 --- a/server/api/appointments/index.post.ts +++ b/server/api/appointments/index.post.ts @@ -1,8 +1,8 @@ import { requireStaff } from '../../utils/guard' import { assertStaffCanAccessClient } from '../../utils/clinician-access' import { prisma } from '../../utils/prisma' -import { normalizeVideoJoinUrl, parseVideoProviderInput } from '../../utils/video-conference' import { readBody, createError, defineEventHandler } from 'h3' +import { normalizeVideoJoinUrl, parseVideoProviderInput } from '../../utils/video-conference' import type { VideoConferenceProvider } from '../../../prisma/generated/enums' function sanitizeNamePart(part: string | null | undefined) { @@ -13,8 +13,10 @@ function sanitizeNamePart(part: string | null | undefined) { function deriveSessionName(fullName: string | null | undefined, sessionNumber: number) { const raw = (fullName ?? '').trim() const pieces = raw.split(/\s+/).filter(Boolean) + const first = sanitizeNamePart(pieces[0] ?? 'Client') || 'Client' const last = sanitizeNamePart(pieces.slice(1).join('_') || 'Unknown') || 'Unknown' + return `${first}_${last}_${String(sessionNumber).padStart(2, '0')}` } @@ -40,18 +42,20 @@ export default defineEventHandler(async (event) => { statusMessage: 'Missing required fields', }) } + await assertStaffCanAccessClient(event, clientId) const start = new Date(`${date}T${startTime}`) const end = new Date(`${date}T${endTime}`) const now = new Date() - if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end <= start) { + if (end <= start) { throw createError({ statusCode: 400, - statusMessage: 'Invalid date/time range', + statusMessage: 'Invalid time range', }) } + if (start < now) { throw createError({ statusCode: 400, @@ -60,14 +64,17 @@ export default defineEventHandler(async (event) => { } const parsedProvider = parseVideoProviderInput(videoProvider) as VideoConferenceProvider | null + const normalizedJoin = normalizeVideoJoinUrl(videoJoinUrl) const rawJoinInput = typeof videoJoinUrl === 'string' ? videoJoinUrl.trim() : '' + if (rawJoinInput && !normalizedJoin) { throw createError({ statusCode: 400, statusMessage: 'Enter a valid meeting link starting with http:// or https://', }) } + if (normalizedJoin && !parsedProvider) { throw createError({ statusCode: 400, @@ -99,6 +106,7 @@ export default defineEventHandler(async (event) => { return normalized !== 'CANCELED' && normalized !== 'CANCELLED' }) .map((a) => a.sessionNumber) + const sessionNumber = nextAvailableNumber(activeSessionNumbers) const sessionName = deriveSessionName(clientUser.name, sessionNumber) @@ -113,8 +121,8 @@ export default defineEventHandler(async (event) => { startTime: start, endTime: end, status: 'SCHEDULED', - ...(parsedProvider != null && { videoProvider: parsedProvider }), - ...(normalizedJoin != null && { videoJoinUrl: normalizedJoin }), + videoProvider: parsedProvider ?? null, + videoJoinUrl: normalizedJoin ?? null, }, }) @@ -122,6 +130,7 @@ export default defineEventHandler(async (event) => { where: { userId: clientId }, select: { id: true }, }) + if (clientRow) { await prisma.sessionNote.create({ data: { @@ -130,7 +139,7 @@ export default defineEventHandler(async (event) => { sessionName, sessionNumber, content: '', - attended: true, + attendanceStatus: 'show', }, }) } @@ -151,11 +160,12 @@ export default defineEventHandler(async (event) => { }) } - console.error('Create appointment error:', error) + console.error('🔥 BACKEND FULL ERROR:', error) + console.error('🔥 BACKEND STACK:', error?.stack) throw createError({ statusCode: 500, - statusMessage: 'Failed to create appointment', + statusMessage: error?.message || JSON.stringify(error), }) } }) diff --git a/server/api/clients/[id]/forms/[formKey].get.ts b/server/api/clients/[id]/forms/[formKey].get.ts index 9c2bb50..ad5af96 100644 --- a/server/api/clients/[id]/forms/[formKey].get.ts +++ b/server/api/clients/[id]/forms/[formKey].get.ts @@ -115,7 +115,18 @@ export default defineEventHandler(async (event) => { if (!trimmed) return '' try { const parsed = JSON.parse(trimmed) as unknown - if (Array.isArray(parsed)) return parsed.join(', ') + if (Array.isArray(parsed)) { + return parsed.map((item: unknown) => { + if (item && typeof item === 'object') { + const r = item as Record + if (typeof r.firstName === 'string') { + return [r.firstName, r.middleInitial, r.lastName, r.age ? `(age ${r.age})` : '', r.relationship].filter(Boolean).join(' ') + } + return Object.values(r).filter(Boolean).join(' ') + } + return String(item) + }).join(', ') + } if (parsed && typeof parsed === 'object') { const r = parsed as Record if (Array.isArray(r.values)) { @@ -176,6 +187,7 @@ export default defineEventHandler(async (event) => { formName: 'GAD-7', questions, submitted: gadForm?.status === 'COMPLETE', + submittedAt: gadForm?.submittedAt, score: gadForm?.totalScore, severity: gadForm?.severity, } @@ -193,10 +205,13 @@ export default defineEventHandler(async (event) => { formName: 'PHQ-9', questions, submitted: phqForm?.status === 'COMPLETE', + submittedAt: phqForm?.submittedAt, score: phqForm?.totalScore, + severity: phqForm?.severity, } } + // ── PCL-5 ───────────────────────────────────────────────── if (formKey === 'pcl') { const pclForm = await prisma.pclForm.findFirst({ where: { userId: clientUserId }, @@ -209,7 +224,46 @@ export default defineEventHandler(async (event) => { (await prisma.pclQuestion.findFirst({ where: { formId: pclForm.id } })) ?? (await prisma.pclQuestion.findFirst({ where: { userId: clientUserId } })) } - const questions = await loadClinicalFormQuestions(prisma, clientUserId, 'pcl') + let questions = await loadClinicalFormQuestions(prisma, clientUserId, 'pcl') + + const OPTIONS = ['Not at all', 'A little bit', 'Moderately', 'Quite a bit', 'Extremely'] + + if (questions.length < 20 && pclForm) { // ← was `< 0`, which is never true + const PCL5_QUESTIONS = [ + 'Repeated, disturbing, and unwanted memories of the stressful experience?', + 'Repeated, disturbing dreams of the stressful experience?', + 'Suddenly feeling or acting as if the stressful experience were actually happening again?', + 'Feeling very upset when something reminded you of the stressful experience?', + 'Having strong physical reactions when something reminded you of the stressful experience?', + 'Avoiding memories, thoughts, or feelings related to the stressful experience?', + 'Avoiding external reminders of the stressful experience?', + 'Trouble remembering important parts of the stressful experience?', + 'Having strong negative beliefs about yourself, other people, or the world?', + 'Blaming yourself or someone else for the stressful experience or what happened after it?', + 'Having strong negative feelings such as fear, horror, anger, guilt, or shame?', + 'Loss of interest in activities that you used to enjoy?', + 'Feeling distant or cut off from other people?', + 'Trouble experiencing positive feelings?', + 'Irritable behavior, angry outbursts, or acting aggressively?', + 'Taking too many risks or doing things that could cause you harm?', + 'Being "superalert" or watchful or on guard?', + 'Feeling jumpy or easily startled?', + 'Having difficulty concentrating?', + 'Trouble falling or staying asleep?', + ] + + questions = [ + // worstEvent as the first question so it appears at the top + { label: 'Worst event', answer: q?.worstEvent ?? '' }, + ...PCL5_QUESTIONS.map((label, i) => { + const key = `q${String(i + 1).padStart(2, '0')}` as keyof typeof q + const raw = q?.[key] + const answer = typeof raw === 'number' && raw >= 0 && raw <= 4 ? (OPTIONS[raw] ?? '') : '' + return { label, answer } + }), + ] + } + let totalScore = pclForm?.totalScore ?? null if (q && totalScore == null) { totalScore = 0 @@ -236,6 +290,5 @@ export default defineEventHandler(async (event) => { severity, } } - throw createError({ statusCode: 400, statusMessage: 'Invalid form key' }) }) diff --git a/server/api/clients/[id]/forms/[formKey].patch.ts b/server/api/clients/[id]/forms/[formKey].patch.ts index 0b77895..392944c 100644 --- a/server/api/clients/[id]/forms/[formKey].patch.ts +++ b/server/api/clients/[id]/forms/[formKey].patch.ts @@ -11,6 +11,13 @@ const PHQ_OPTIONS: Record = { 'Nearly every day': 3, } +const PHQ_DIFFICULTY_OPTIONS: Record = { + 'Not difficult at all': 0, + 'Somewhat difficult': 1, + 'Very difficult': 2, + 'Extremely difficult': 3, +} + const GAD_OPTIONS: Record = { 'Not at all': 0, 'Several days': 1, @@ -18,6 +25,14 @@ const GAD_OPTIONS: Record = { 'Nearly every day': 3, } +const PCL_OPTIONS: Record = { + 'Not at all': 0, + 'A little bit': 1, + 'Moderately': 2, + 'Quite a bit': 3, + 'Extremely': 4, +} + function toInt(val: string, map: Record): number | null { if (val in map) return map[val]! const n = parseInt(val) @@ -56,12 +71,17 @@ export default defineEventHandler(async (event) => { // ── ACE ────────────────────────────────────────────────── if (formKey === 'ace') { - const form = await prisma.aceForm.findFirst({ where: { userId: clientUserId } }) + const form = await prisma.aceForm.findFirst({ where: { userId: clientUserId }, orderBy: { id: 'desc' } }) if (!form) throw createError({ statusCode: 404, statusMessage: 'Form not found' }) const keys = ['a01','a02','a03','a04','a05','a06','a07','a08','a09','a10'] const data: Record = {} answers.forEach((a, i) => { if (keys[i]) data[keys[i]!] = a.answer }) await prisma.aceQuestion.update({ where: { formId: form.id }, data }) + + // Recalculate score + const total = Object.values(data).filter(v => v === 'Yes').length + const severity = total === 0 ? 'No reported ACEs' : total <= 3 ? 'Low' : total <= 6 ? 'Moderate' : 'High' + await prisma.aceForm.update({ where: { id: form.id }, data: { totalScore: total, severity } }) return { ok: true } } @@ -71,8 +91,17 @@ export default defineEventHandler(async (event) => { if (!form) throw createError({ statusCode: 404, statusMessage: 'Form not found' }) const keys = ['g01','g02','g03','g04','g05','g06','g07','g08'] const data: Record = {} - answers.forEach((a, i) => { if (keys[i]) data[keys[i]!] = toInt(a.answer, GAD_OPTIONS) }) + answers.forEach((a, i) => { + if (!keys[i]) return + const map = keys[i] === 'g08' ? PHQ_DIFFICULTY_OPTIONS : GAD_OPTIONS + data[keys[i]!] = toInt(a.answer, map) + }) await prisma.gadQuestion.update({ where: { formId: form.id }, data }) + + // Recalculate score (g01-g07 only, g08 is difficulty) + const total = ['g01','g02','g03','g04','g05','g06','g07'].reduce((sum, k) => sum + (data[k] ?? 0), 0) + const severity = total <= 4 ? 'Minimal' : total <= 9 ? 'Mild' : total <= 14 ? 'Moderate' : 'Severe' + await prisma.gadForm.update({ where: { id: form.id }, data: { totalScore: total, severity } }) return { ok: true } } @@ -82,23 +111,56 @@ export default defineEventHandler(async (event) => { if (!form) throw createError({ statusCode: 404, statusMessage: 'Form not found' }) const keys = ['q1','q2','q3','q4','q5','q6','q7','q8','q9','q10'] const data: Record = {} - answers.forEach((a, i) => { if (keys[i]) data[keys[i]!] = toInt(a.answer, PHQ_OPTIONS) }) - await prisma.phqQuestion.update({ where: { formId: form.id }, data }) - return { ok: true } - } - - // ── PCL-5 ───────────────────────────────────────────────── - if (formKey === 'pcl') { - const form = await prisma.pclForm.findFirst({ where: { userId: clientUserId }, orderBy: { id: 'desc' } }) - if (!form) throw createError({ statusCode: 404, statusMessage: 'Form not found' }) - const data: Record = {} answers.forEach((a, i) => { - const key = `q${String(i + 1).padStart(2, '0')}` - data[key] = toInt(a.answer, {}) + if (!keys[i]) return + // q10 uses different options than q1-q9 + const map = keys[i] === 'q10' ? PHQ_DIFFICULTY_OPTIONS : PHQ_OPTIONS + data[keys[i]!] = toInt(a.answer, map) }) - await prisma.pclQuestion.update({ where: { formId: form.id }, data }) + await prisma.phqQuestion.update({ where: { formId: form.id }, data }) + + // Recalculate score (q1-q9 only, q10 is difficulty) + const total = ['q1','q2','q3','q4','q5','q6','q7','q8','q9'].reduce((sum, k) => sum + (data[k] ?? 0), 0) + const severity = total <= 4 ? 'Minimal' : total <= 9 ? 'Mild' : total <= 14 ? 'Moderate depression' : total <= 19 ? 'Moderately severe depression' : 'Severe depression' + await prisma.phqForm.update({ where: { id: form.id }, data: { totalScore: total, severity } }) return { ok: true } } +// ── PCL-5 ───────────────────────────────────────────────── +if (formKey === 'pcl') { + const form = await prisma.pclForm.findFirst({ where: { userId: clientUserId }, orderBy: { id: 'desc' } }) + if (!form) throw createError({ statusCode: 404, statusMessage: 'Form not found' }) + + const worstEventAnswer = answers.find(a => a.label === 'Worst event') + const questionAnswers = answers.filter(a => a.label !== 'Worst event') + + const data: Record = {} + + if (worstEventAnswer !== undefined) { + data.worstEvent = worstEventAnswer.answer + } + + questionAnswers.forEach((a, i) => { + const key = `q${String(i + 1).padStart(2, '0')}` + data[key] = toInt(a.answer, PCL_OPTIONS) + }) + + await prisma.pclQuestion.update({ where: { formId: form.id }, data }) + + // Recalculate score (only scored questions, not worstEvent) + const total = questionAnswers.reduce((sum, a) => sum + (PCL_OPTIONS[a.answer] ?? 0), 0) + const severity = total <= 20 ? 'Minimal' : total <= 40 ? 'Mild' : total <= 60 ? 'Moderate' : 'Severe' + await prisma.pclForm.update({ + where: { id: form.id }, + data: { + totalScore: total, + severity, + status: 'COMPLETE', // ← add + submittedAt: new Date(), // ← add + } + }) + return { ok: true } +} + throw createError({ statusCode: 400, statusMessage: 'Invalid form key' }) }) \ No newline at end of file diff --git a/server/api/clients/[id]/forms/[formKey]/raw.get.ts b/server/api/clients/[id]/forms/[formKey]/raw.get.ts new file mode 100644 index 0000000..5abb70e --- /dev/null +++ b/server/api/clients/[id]/forms/[formKey]/raw.get.ts @@ -0,0 +1,41 @@ +// Returns raw db values for the application form +import { requireUser } from '../../../../../utils/guard' +import { assertStaffCanAccessClient } from '../../../../../utils/clinician-access' +import { createError, defineEventHandler, getRouterParam } from 'h3' +import { prisma } from '../../../../../utils/prisma' + +export default defineEventHandler(async (event) => { + requireUser(event) + const clientUserId = getRouterParam(event, 'id') + const formKey = getRouterParam(event, 'formKey') + + if (!clientUserId || !formKey) { + throw createError({ statusCode: 400, statusMessage: 'Missing params' }) + } + if (!event.context.isStaff) { + throw createError({ statusCode: 403, statusMessage: 'Staff only' }) + } + await assertStaffCanAccessClient(event, clientUserId) + + if (formKey !== 'application') { + throw createError({ statusCode: 400, statusMessage: 'Raw endpoint only supports application' }) + } + + const appForm = await prisma.appForm.findFirst({ + where: { userId: clientUserId }, + orderBy: { id: 'desc' }, + include: { questions: true }, + }) + + if (!appForm?.questions) return { answers: {} } + + const q = appForm.questions + const answers: Record = {} + for (let i = 1; i <= 50; i++) { + const key = `q${String(i).padStart(2, '0')}` as keyof typeof q + const val = q[key] + answers[key] = typeof val === 'string' ? val : '' + } + + return { answers } +}) \ No newline at end of file diff --git a/server/api/forms/pcl/save.post.ts b/server/api/forms/pcl/save.post.ts index 319e1d8..1def41a 100644 --- a/server/api/forms/pcl/save.post.ts +++ b/server/api/forms/pcl/save.post.ts @@ -42,7 +42,7 @@ export default defineEventHandler(async (event) => { let form = await prisma.pclForm.findFirst({ where: { userId }, - orderBy: { id: 'asc' }, + orderBy: { id: 'desc' }, }) if (!form) { @@ -102,8 +102,8 @@ export default defineEventHandler(async (event) => { await prisma.pclForm.update({ where: { id: form.id }, data: { - status: 'IN_PROGRESS', - submittedAt: null, + status: 'COMPLETE', + submittedAt: new Date(), totalScore, severity, }, diff --git a/server/api/forms/phq/save.post.ts b/server/api/forms/phq/save.post.ts index 9c2f9a4..94b484e 100644 --- a/server/api/forms/phq/save.post.ts +++ b/server/api/forms/phq/save.post.ts @@ -31,7 +31,7 @@ export default defineEventHandler(async (event) => { let form = await prisma.phqForm.findFirst({ where: { userId }, - orderBy: { id: 'asc' }, + orderBy: { id: 'desc' }, }) if (!form) { @@ -86,11 +86,12 @@ export default defineEventHandler(async (event) => { data, }) + // save score await prisma.phqForm.update({ where: { id: form.id }, data: { - status: 'IN_PROGRESS', - submittedAt: null, + status: 'COMPLETE', // ← was 'IN_PROGRESS' + submittedAt: new Date(), // ← was null totalScore, severity, }, diff --git a/server/api/session-notes/pending-approvals.get.ts b/server/api/session-notes/pending-approvals.get.ts index 7409519..20e2198 100644 --- a/server/api/session-notes/pending-approvals.get.ts +++ b/server/api/session-notes/pending-approvals.get.ts @@ -15,7 +15,7 @@ export default defineEventHandler(async (event) => { typeof query.kind === 'string' ? query.kind.toUpperCase() : '' const kindFilter = kindParam === 'PROGRESS' || kindParam === 'PSYCHOTHERAPY' - ? { kind: kindParam } + ? { kind: kindParam as 'PROGRESS' | 'PSYCHOTHERAPY' } : {} const rows = await prisma.sessionNote.findMany({ @@ -55,7 +55,7 @@ export default defineEventHandler(async (event) => { kind: r.kind, status: r.status, content: r.content, - attended: r.attended, + attendanceStatus: r.attendanceStatus, appointmentId: r.appointmentId, appointmentStartTime: r.appointment?.startTime?.toISOString() ?? null, clinicianSignedAt: r.clinicianSignedAt?.toISOString() ?? null, diff --git a/server/utils/clinical-form-display.ts b/server/utils/clinical-form-display.ts index 47e868a..4d68ce4 100644 --- a/server/utils/clinical-form-display.ts +++ b/server/utils/clinical-form-display.ts @@ -27,6 +27,29 @@ export const PHQ_LABELS = [ 'If you checked any problems, how difficult have they made it?', ] +export const PCL_LABELS = [ + 'Repeated, disturbing, and unwanted memories of the stressful experience?', + 'Repeated, disturbing dreams of the stressful experience?', + 'Suddenly feeling or acting as if the stressful experience were actually happening again?', + 'Feeling very upset when something reminded you of the stressful experience?', + 'Having strong physical reactions when something reminded you of the stressful experience?', + 'Avoiding memories, thoughts, or feelings related to the stressful experience?', + 'Avoiding external reminders of the stressful experience?', + 'Trouble remembering important parts of the stressful experience?', + 'Having strong negative beliefs about yourself, other people, or the world?', + 'Blaming yourself or someone else for the stressful experience or what happened after it?', + 'Having strong negative feelings such as fear, horror, anger, guilt, or shame?', + 'Loss of interest in activities that you used to enjoy?', + 'Feeling distant or cut off from other people?', + 'Trouble experiencing positive feelings?', + 'Irritable behavior, angry outbursts, or acting aggressively?', + 'Taking too many risks or doing things that could cause you harm?', + 'Being "superalert" or watchful or on guard?', + 'Feeling jumpy or easily startled?', + 'Having difficulty concentrating?', + 'Trouble falling or staying asleep?', +] + export const PHQ_OPTIONS: Record = { 0: 'Not at all', 1: 'Several days', @@ -41,6 +64,21 @@ export const GAD_OPTIONS: Record = { 3: 'Nearly every day', } +export const DIFFICULTY_OPTIONS: Record = { + 0: 'Not difficult at all', + 1: 'Somewhat difficult', + 2: 'Very difficult', + 3: 'Extremely difficult', +} + +export const PCL_OPTIONS: Record = { + 0: 'Not at all', + 1: 'A little bit', + 2: 'Moderately', + 3: 'Quite a bit', + 4: 'Extremely', +} + const ACE_QUESTIONS_TEXT = [ 'Did a parent or other adult in the household often swear at you, insult you, put you down, or humiliate you?', 'Did a parent or other adult in the household often push, grab, slap, or throw something at you?', @@ -96,7 +134,9 @@ export async function loadClinicalFormQuestions( const answers = [q.g01, q.g02, q.g03, q.g04, q.g05, q.g06, q.g07, q.g08] return GAD_LABELS.slice(0, answers.length).map((label, i) => ({ label, - answer: answers[i] != null ? (GAD_OPTIONS[answers[i] as number] ?? String(answers[i])) : '', + answer: answers[i] != null + ? ((i === 7 ? DIFFICULTY_OPTIONS : GAD_OPTIONS)[answers[i] as number] ?? String(answers[i])) + : '', })) } @@ -114,7 +154,9 @@ export async function loadClinicalFormQuestions( const answers = [q.q1, q.q2, q.q3, q.q4, q.q5, q.q6, q.q7, q.q8, q.q9, q.q10] return PHQ_LABELS.slice(0, answers.length).map((label, i) => ({ label, - answer: answers[i] != null ? (PHQ_OPTIONS[answers[i] as number] ?? String(answers[i])) : '', + answer: answers[i] != null + ? ((i === 9 ? DIFFICULTY_OPTIONS : PHQ_OPTIONS)[answers[i] as number] ?? String(answers[i])) + : '', })) } @@ -130,14 +172,14 @@ export async function loadClinicalFormQuestions( (await prisma.pclQuestion.findFirst({ where: { userId } })) } if (!q) return [] - const questions: ClinicalFormQuestionRow[] = [] + const questions: ClinicalFormQuestionRow[] = [ + { label: 'Worst event', answer: q.worstEvent ?? '' }, + ] for (let i = 1; i <= 20; i++) { const key = `q${String(i).padStart(2, '0')}` as keyof typeof q const val = q[key] const numVal = typeof val === 'number' ? val : null - if (numVal != null && numVal >= 0) { - questions.push({ label: `Item ${i}`, answer: String(numVal) }) - } + questions.push({ label: PCL_LABELS[i - 1] ?? `Item ${i}`, answer: numVal != null ? (PCL_OPTIONS[numVal] ?? String(numVal)) : '' }) } return questions }