diff --git a/.github/actions/deploy-to-environment/action.yaml b/.github/actions/deploy-to-environment/action.yaml index a9bcc0c49..b956c3566 100644 --- a/.github/actions/deploy-to-environment/action.yaml +++ b/.github/actions/deploy-to-environment/action.yaml @@ -138,5 +138,11 @@ runs: - name: Create Cron Jobs shell: bash + env: + NAMESPACE_PREFIX: ${{ inputs.namespace_prefix }} + NAMESPACE_ENVIRONMENT: ${{ inputs.namespace_environment }} + JOB_NAME: ${{ inputs.job_name }} + ACRONYM: ${{ inputs.acronym }} + ROUTE_PATH: ${{ inputs.route_path }} run: | - oc process --namespace ${{ inputs.namespace_prefix }}-${{ inputs.namespace_environment }} -f openshift/app.cronjob.yaml -p JOB_NAME=${{ inputs.job_name }} -p NAMESPACE=${{ inputs.namespace_prefix }}-${{ inputs.namespace_environment }} -p APP_NAME=${{ inputs.acronym }} -p ROUTE_PATH=${{ inputs.route_path }} -o yaml | oc apply --namespace ${{ inputs.namespace_prefix }}-${{ inputs.namespace_environment }} -f - + oc process --namespace ${NAMESPACE_PREFIX}-${NAMESPACE_ENVIRONMENT} -f openshift/app.cronjob.yaml -p JOB_NAME=${JOB_NAME} -p NAMESPACE=${NAMESPACE_PREFIX}-${NAMESPACE_ENVIRONMENT} -p APP_NAME=${ACRONYM} -p NAMESPACE_ENVIRONMENT=${NAMESPACE_ENVIRONMENT} -p ROUTE_PATH=${ROUTE_PATH} -o yaml | oc apply --namespace ${NAMESPACE_PREFIX}-${NAMESPACE_ENVIRONMENT} -f - diff --git a/app/config/default.json b/app/config/default.json index a34dd9583..fc9df34dd 100644 --- a/app/config/default.json +++ b/app/config/default.json @@ -119,5 +119,9 @@ "matchPrecision": "occupant, unit, site, civic_number, intersection, block, street, locality, province", "precisionPoints": 100 } + }, + "recordsManagement": { + "implementation": "local", + "enabled": true } } diff --git a/app/frontend/src/components/designer/FormSettings.vue b/app/frontend/src/components/designer/FormSettings.vue index c22167231..90fa4b034 100644 --- a/app/frontend/src/components/designer/FormSettings.vue +++ b/app/frontend/src/components/designer/FormSettings.vue @@ -7,6 +7,7 @@ import FormSubmissionSettings from '~/components/designer/settings/FormSubmissio import FormScheduleSettings from '~/components/designer/settings/FormScheduleSettings.vue'; import FormMetadataSettings from '~/components/designer/settings/FormMetadataSettings.vue'; import FormEventStreamSettings from '~/components/designer/settings/FormEventStreamSettings.vue'; +import FormClassificationSettings from '~/components/designer/settings/FormClassificationSettings.vue'; import { useFormStore } from '~/store/form'; @@ -41,6 +42,9 @@ const { form, isFormPublished, isRTL } = storeToRefs(useFormStore()); + + + diff --git a/app/frontend/src/components/designer/settings/FormClassificationSettings.vue b/app/frontend/src/components/designer/settings/FormClassificationSettings.vue new file mode 100644 index 000000000..8f7b6c09d --- /dev/null +++ b/app/frontend/src/components/designer/settings/FormClassificationSettings.vue @@ -0,0 +1,333 @@ + + + diff --git a/app/frontend/src/components/forms/SubmissionsTable.vue b/app/frontend/src/components/forms/SubmissionsTable.vue index 7337c33ad..463269439 100644 --- a/app/frontend/src/components/forms/SubmissionsTable.vue +++ b/app/frontend/src/components/forms/SubmissionsTable.vue @@ -534,7 +534,10 @@ async function restoreSub() { async function deleteSingleSubs() { showDeleteDialog.value = false; - await formStore.deleteSubmission(deleteItem.value.submissionId); + await formStore.deleteSubmission( + form.value.id, + deleteItem.value.submissionId + ); refreshSubmissions(); } diff --git a/app/frontend/src/components/forms/manage/ManageForm.vue b/app/frontend/src/components/forms/manage/ManageForm.vue index 013b388f1..a9960d09d 100644 --- a/app/frontend/src/components/forms/manage/ManageForm.vue +++ b/app/frontend/src/components/forms/manage/ManageForm.vue @@ -11,6 +11,7 @@ import ManageVersions from '~/components/forms/manage/ManageVersions.vue'; import Subscription from '~/components/forms/manage/Subscription.vue'; import { useFormStore } from '~/store/form'; import { useNotificationStore } from '~/store/notification'; +import { useRecordsManagementStore } from '~/store/recordsManagement'; import { FormPermissions, NotificationTypes } from '~/utils/constants'; import FormProfile from '~/components/designer/FormProfile.vue'; @@ -28,10 +29,13 @@ const versionsPanel = ref(0); const formStore = useFormStore(); const notificationStore = useNotificationStore(); +const recordsManagementStore = useRecordsManagementStore(); const { apiKey, drafts, form, permissions, isRTL, subscriptionData } = storeToRefs(formStore); +const { formRetentionPolicy } = storeToRefs(recordsManagementStore); + const canEditForm = computed(() => permissions.value.includes(FormPermissions.FORM_UPDATE) ); @@ -102,6 +106,9 @@ async function updateSettings() { const { valid } = await settingsForm.value.validate(); if (valid) { await formStore.updateForm(); + if (formRetentionPolicy.value.retentionClassificationId) { + await recordsManagementStore.configureRetentionPolicy(form.value.id); + } formSettingsDisabled.value = true; notificationStore.addNotification({ text: 'Your form settings have been updated successfully.', diff --git a/app/frontend/src/components/forms/manage/ManageLayout.vue b/app/frontend/src/components/forms/manage/ManageLayout.vue index 63fabee11..b2956a1f7 100644 --- a/app/frontend/src/components/forms/manage/ManageLayout.vue +++ b/app/frontend/src/components/forms/manage/ManageLayout.vue @@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n'; import ManageForm from '~/components/forms/manage/ManageForm.vue'; import ManageFormActions from '~/components/forms/manage/ManageFormActions.vue'; import { useFormStore } from '~/store/form'; +import { useRecordsManagementStore } from '~/store/recordsManagement'; import { FormPermissions } from '~/utils/constants'; const { locale } = useI18n({ useScope: 'global' }); @@ -19,6 +20,8 @@ const properties = defineProps({ const loading = ref(true); +const recordsManagementStore = useRecordsManagementStore(); + const { form, permissions, isRTL } = storeToRefs(useFormStore()); onMounted(async () => { @@ -29,6 +32,7 @@ onMounted(async () => { await Promise.all([ formStore.fetchForm(properties.f), formStore.getFormPermissionsForUser(properties.f), + recordsManagementStore.getFormRetentionPolicy(properties.f), ]); if (permissions.value.includes(FormPermissions.DESIGN_READ)) diff --git a/app/frontend/src/internationalization/trans/chefs/ar/ar.json b/app/frontend/src/internationalization/trans/chefs/ar/ar.json index 5e5353c5f..eee991ad2 100644 --- a/app/frontend/src/internationalization/trans/chefs/ar/ar.json +++ b/app/frontend/src/internationalization/trans/chefs/ar/ar.json @@ -204,7 +204,38 @@ "encryptionKeyUpdatedBy": "تم تحديث مفتاح التشفير بواسطة", "encryptionKeyCopySnackbar": "تم نسخ مفتاح التشفير إلى الحافظة", "encryptionKeyCopyTooltip": "نسخ مفتاح التشفير إلى الحافظة", - "encryptionKeyGenerate": "إنشاء مفتاح التشفير" + "encryptionKeyGenerate": "إنشاء مفتاح التشفير", + "dataRetention": "سياسة الاحتفاظ بالبيانات", + "enableHardDeletion": "تمكين الحذف التلقائي النهائي", + "hardDeletionTooltip": "عند التمكين، سيتم إزالة البيانات المعلمة للحذف بشكل دائم بعد فترة الاحتفاظ.", + "hardDeletionWarning": "الحذف النهائي دائم ولا يمكن التراجع عنه.", + "permanentDeletion": "البيانات ستكون غير قابلة للاسترداد بعد الحذف.", + "dataClassification": "تصنيف البيانات", + "dataClassificationHint": "تصنيف نوع البيانات التي يتم جمعها بواسطة هذا النموذج", + "retentionPeriod": "فترة الاحتفاظ", + "retentionPeriodHint": "المدة التي يتم الاحتفاظ فيها بالطلبات المحذوفة قبل الإزالة النهائية", + "classPublic": "عامة", + "classInternal": "داخلية", + "classSensitive": "حساسة", + "classConfidential": "سرية", + "classProtected": "محمية", + "classificationCustomHint": "يمكنك كتابة تصنيف مخصص إذا لزم الأمر", + "days30": "30 يوم", + "days90": "90 يوم", + "days180": "180 يوم", + "year1": "سنة واحدة (365 يوم)", + "years2": "سنتان (730 يوم)", + "years5": "5 سنوات (1825 يوم)", + "daysCustom": "فترة مخصصة", + "customDaysLabel": "فترة احتفاظ مخصصة (بالأيام)", + "customDaysHint": "أدخل رقمًا بين 1 و 3650 يومًا (10 سنوات)", + "classificationDescription": "ملاحظات التصنيف", + "classificationDescriptionHint": "وصف اختياري لسبب اختيار هذا التصنيف وفترة الاحتفاظ", + "retentionDaysUpdated": "تم تحديث فترة الاحتفاظ", + "deletionDisclaimerWithDays": "تحذير: بعد {days} يوم، سيتم حذف البيانات المعلمة للحذف بشكل دائم ولا يمكن استردادها.", + "setRetentionPrompt": "الرجاء تحديد فترة الاحتفاظ لإكمال سياسة الاحتفاظ بالبيانات.", + "fetchRetentionClassificationListError": "حدث خطأ أثناء جلب تصنيفات الاحتفاظ.", + "fetchRetentionClassificationListConsErrMsg": "حدث خطأ أثناء جلب تصنيفات الاحتفاظ: {error}" }, "formProfile": { "message": "تقوم فرق CHEFS بجمع وتنظيم المعلومات لتكون مدخلات حاسمة لصياغة حالات أعمال شاملة. ستلعب هذه الحالات دورًا حيويًا في توجيه العمليات الاستراتيجية وتحسين CHEFS المستمر في السنوات القادمة. هذه المبادرة لجمع البيانات ضرورية لإعلام القرارات الحاسمة وتشكيل مسار CHEFS ، مما يضمن قابليتها للتكيف وفعاليتها في التعامل مع الاحتياجات والتحديات المتطورة.", @@ -1117,6 +1148,12 @@ "saveSubscriptionSettingsNotifyMsg": "تم حفظ إعدادات الاشتراك لهذا النموذج.", "saveSubscriptionSettingsErrMsg": "حدث خطأ أثناء محاولة حفظ إعدادات الاشتراك.", "saveSubscriptionSettingsConsErrMsg": "خطأ في حفظ إعدادات الاشتراك للنموذج {formId}: {error}" + }, + "recordsManagement": { + "getFormRetentionPolicyErrMsg": "حدث خطأ أثناء جلب سياسة الاحتفاظ لهذا النموذج.", + "getFormRetentionPolicyConsErrMsg": "خطأ في الحصول على سياسة الاحتفاظ للنموذج {formId}: {error}", + "configureFormRetentionPolicyErrMsg": "حدث خطأ أثناء تكوين سياسة الاحتفاظ لهذا النموذج.", + "configureFormRetentionPolicyConsErrMsg": "خطأ في تكوين سياسة الاحتفاظ للنموذج {formId}: {error}" } }, "admin": { diff --git a/app/frontend/src/internationalization/trans/chefs/de/de.json b/app/frontend/src/internationalization/trans/chefs/de/de.json index fc7752815..bd64c625e 100644 --- a/app/frontend/src/internationalization/trans/chefs/de/de.json +++ b/app/frontend/src/internationalization/trans/chefs/de/de.json @@ -204,7 +204,38 @@ "encryptionKeyUpdatedBy": "Verschlüsselungsschlüssel aktualisiert von", "encryptionKeyCopySnackbar": "Verschlüsselungsschlüssel in die Zwischenablage kopiert", "encryptionKeyCopyTooltip": "Verschlüsselungsschlüssel in die Zwischenablage kopieren", - "encryptionKeyGenerate": "Verschlüsselungsschlüssel generieren" + "encryptionKeyGenerate": "Verschlüsselungsschlüssel generieren", + "dataRetention": "Datenspeicherungsrichtlinie", + "enableHardDeletion": "Automatische endgültige Löschung aktivieren", + "hardDeletionTooltip": "Wenn aktiviert, werden zur Löschung markierte Einreichungen nach Ablauf der Aufbewahrungsfrist dauerhaft entfernt.", + "hardDeletionWarning": "Die endgültige Löschung ist dauerhaft und kann nicht rückgängig gemacht werden.", + "permanentDeletion": "Daten können nach der Löschung nicht wiederhergestellt werden.", + "dataClassification": "Datenklassifizierung", + "dataClassificationHint": "Kategorisieren Sie die Art der von diesem Formular erfassten Daten", + "retentionPeriod": "Aufbewahrungsfrist", + "retentionPeriodHint": "Wie lange gelöschte Einreichungen aufbewahrt werden, bevor sie dauerhaft entfernt werden", + "classPublic": "Öffentlich", + "classInternal": "Intern", + "classSensitive": "Sensibel", + "classConfidential": "Vertraulich", + "classProtected": "Geschützt", + "classificationCustomHint": "Sie können bei Bedarf eine benutzerdefinierte Klassifizierung eingeben", + "days30": "30 Tage", + "days90": "90 Tage", + "days180": "180 Tage", + "year1": "1 Jahr (365 Tage)", + "years2": "2 Jahre (730 Tage)", + "years5": "5 Jahre (1825 Tage)", + "daysCustom": "Benutzerdefinierter Zeitraum", + "customDaysLabel": "Benutzerdefinierte Aufbewahrungsfrist (Tage)", + "customDaysHint": "Geben Sie eine Zahl zwischen 1 und 3650 Tagen (10 Jahre) ein", + "classificationDescription": "Klassifizierungshinweise", + "classificationDescriptionHint": "Optionale Beschreibung, warum diese Klassifizierung und Aufbewahrungsfrist gewählt wurde", + "retentionDaysUpdated": "Aufbewahrungsfrist aktualisiert", + "deletionDisclaimerWithDays": "WARNUNG: Nach {days} Tagen werden zur Löschung markierte Einreichungen dauerhaft gelöscht und können nicht wiederhergestellt werden.", + "setRetentionPrompt": "Bitte legen Sie eine Aufbewahrungsfrist fest, um Ihre Datenspeicherungsrichtlinie abzuschließen.", + "fetchRetentionClassificationListError": "Fehler beim Abrufen der Aufbewahrungsklassifikationen.", + "fetchRetentionClassificationListConsErrMsg": "Fehler beim Abrufen der Aufbewahrungsklassifikationen: {error}" }, "formProfile": { "message": "Das CHEFS-Team sammelt und organisiert Informationen, um als entscheidende Grundlage für die Erstellung umfassender Geschäftsfälle zu dienen. Diese Fälle werden eine Schlüsselrolle dabei spielen, den strategischen Betrieb und die laufende Verbesserung von CHEFS in den kommenden Jahren zu leiten. Diese Initiative zur Datensammlung ist entscheidend, um kritische Entscheidungen zu informieren und die Trajektorie von CHEFS zu formen, um seine Anpassungsfähigkeit und Wirksamkeit bei der Bewältigung sich wandelnder Bedürfnisse und Herausforderungen zu gewährleisten.", @@ -1117,6 +1148,12 @@ "saveSubscriptionSettingsNotifyMsg": "Die Abonnementeinstellungen für dieses Formular wurden gespeichert.", "saveSubscriptionSettingsErrMsg": "Beim Versuch, die Abonnementeinstellungen zu speichern, ist ein Fehler aufgetreten.", "saveSubscriptionSettingsConsErrMsg": "Fehler beim Speichern der Abonnementeinstellungen für das Formular {formId}: {error}" + }, + "recordsManagement": { + "getFormRetentionPolicyErrMsg": "Beim Abrufen der Aufbewahrungsrichtlinie für dieses Formular ist ein Fehler aufgetreten.", + "getFormRetentionPolicyConsErrMsg": "Fehler beim Abrufen der Aufbewahrungsrichtlinie für das Formular {formId}: {error}", + "configureFormRetentionPolicyErrMsg": "Beim Konfigurieren der Aufbewahrungsrichtlinie für dieses Formular ist ein Fehler aufgetreten.", + "configureFormRetentionPolicyConsErrMsg": "Fehler beim Konfigurieren der Aufbewahrungsrichtlinie für das Formular {formId}: {error}" } }, "admin": { diff --git a/app/frontend/src/internationalization/trans/chefs/en/en.json b/app/frontend/src/internationalization/trans/chefs/en/en.json index cb6a3b7dd..8e5214118 100644 --- a/app/frontend/src/internationalization/trans/chefs/en/en.json +++ b/app/frontend/src/internationalization/trans/chefs/en/en.json @@ -267,7 +267,38 @@ "encryptionKeyUpdatedBy": "Encryption Key Updated By", "encryptionKeyCopySnackbar": "Encryption Key copied to clipboard", "encryptionKeyCopyTooltip": "Copy Encryption Key to clipboard", - "encryptionKeyGenerate": "Generate Encryption Key" + "encryptionKeyGenerate": "Generate Encryption Key", + "dataRetention": "Data Retention Policy", + "enableHardDeletion": "Enable automatic hard deletion", + "hardDeletionTooltip": "When enabled, submissions marked for deletion will be permanently removed after the retention period.", + "hardDeletionWarning": "Hard deletion is permanent and cannot be undone.", + "permanentDeletion": "Data will be unrecoverable after deletion.", + "dataClassification": "Data Classification", + "dataClassificationHint": "Categorize the type of data collected by this form", + "retentionPeriod": "Retention Period", + "retentionPeriodHint": "How long to keep deleted submissions before permanent removal", + "classPublic": "Public", + "classInternal": "Internal", + "classSensitive": "Sensitive", + "classConfidential": "Confidential", + "classProtected": "Protected", + "classificationCustomHint": "You can type a custom classification if needed", + "days30": "30 days", + "days90": "90 days", + "days180": "180 days", + "year1": "1 year (365 days)", + "years2": "2 years (730 days)", + "years5": "5 years (1825 days)", + "daysCustom": "Custom period", + "customDaysLabel": "Custom Retention Period (days)", + "customDaysHint": "Enter a number between 1 and 3650 days (10 years)", + "classificationDescription": "Classification Notes", + "classificationDescriptionHint": "Optional description of why this classification and retention period was chosen", + "retentionDaysUpdated": "Retention period updated", + "deletionDisclaimerWithDays": "WARNING: After {days} days, submissions marked for deletion will be permanently deleted and cannot be recovered.", + "setRetentionPrompt": "Please set a retention period to complete your data retention policy.", + "fetchRetentionClassificationListError": "Error fetching Retention Classification list.", + "fetchRetentionClassificationListConsErrMsg": "Error fetching retention classifications: {error}" }, "formProfile": { "message": "The CHEFS team is collecting and organizing information to serve as crucial input for crafting comprehensive business cases. These cases will play a pivotal role in guiding the strategic operation and ongoing improvement of CHEFS in the coming years. This initiative to gather data is essential for informing critical decisions and molding the trajectory of CHEFS, ensuring its adaptability and effectiveness in addressing evolving needs and challenges.", @@ -1182,6 +1213,12 @@ "saveSubscriptionSettingsNotifyMsg": "Subscription settings for this form has been saved.", "saveSubscriptionSettingsErrMsg": "An error occurred while trying to save subscription settings.", "saveSubscriptionSettingsConsErrMsg": "Error saving subscription settings for form {formId}: {error}" + }, + "recordsManagement": { + "getFormRetentionPolicyErrMsg": "An error occurred while fetching the retention policy for this form.", + "getFormRetentionPolicyConsErrMsg": "Error getting retention policy for form {formId}: {error}", + "configureFormRetentionPolicyErrMsg": "An error occurred while configuring the retention policy for this form.", + "configureFormRetentionPolicyConsErrMsg": "Error configuring retention policy for form {formId}: {error}" } }, "permissionUtils": { diff --git a/app/frontend/src/internationalization/trans/chefs/es/es.json b/app/frontend/src/internationalization/trans/chefs/es/es.json index f289ed9a7..fbff45074 100644 --- a/app/frontend/src/internationalization/trans/chefs/es/es.json +++ b/app/frontend/src/internationalization/trans/chefs/es/es.json @@ -204,7 +204,38 @@ "encryptionKeyUpdatedBy": "Clave de cifrado actualizada por", "encryptionKeyCopySnackbar": "Clave de cifrado copiada al portapapeles", "encryptionKeyCopyTooltip": "Copiar la clave de cifrado al portapapeles", - "encryptionKeyGenerate": "Generar clave de cifrado" + "encryptionKeyGenerate": "Generar clave de cifrado", + "dataRetention": "Política de retención de datos", + "enableHardDeletion": "Habilitar eliminación automática permanente", + "hardDeletionTooltip": "Cuando está habilitado, las presentaciones marcadas para eliminación se eliminarán permanentemente después del período de retención.", + "hardDeletionWarning": "La eliminación permanente es definitiva y no se puede deshacer.", + "permanentDeletion": "Los datos serán irrecuperables después de la eliminación.", + "dataClassification": "Clasificación de datos", + "dataClassificationHint": "Categorizar el tipo de datos recopilados por este formulario", + "retentionPeriod": "Período de retención", + "retentionPeriodHint": "Cuánto tiempo mantener las presentaciones eliminadas antes de la eliminación permanente", + "classPublic": "Público", + "classInternal": "Interno", + "classSensitive": "Sensible", + "classConfidential": "Confidencial", + "classProtected": "Protegido", + "classificationCustomHint": "Puede escribir una clasificación personalizada si es necesario", + "days30": "30 días", + "days90": "90 días", + "days180": "180 días", + "year1": "1 año (365 días)", + "years2": "2 años (730 días)", + "years5": "5 años (1825 días)", + "daysCustom": "Período personalizado", + "customDaysLabel": "Período de retención personalizado (días)", + "customDaysHint": "Ingrese un número entre 1 y 3650 días (10 años)", + "classificationDescription": "Notas de clasificación", + "classificationDescriptionHint": "Descripción opcional de por qué se eligió esta clasificación y período de retención", + "retentionDaysUpdated": "Período de retención actualizado", + "deletionDisclaimerWithDays": "ADVERTENCIA: Después de {days} días, las presentaciones marcadas para eliminación serán eliminadas permanentemente y no se podrán recuperar.", + "setRetentionPrompt": "Por favor establezca un período de retención para completar su política de retención de datos.", + "fetchRetentionClassificationListError": "Error al obtener la lista de clasificaciones de retención.", + "fetchRetentionClassificationListConsErrMsg": "Error al obtener las clasificaciones de retención: {error}" }, "formProfile": { "message": "El equipo de CHEFS está recopilando y organizando información para servir como entrada crucial para la elaboración de casos de negocio integrales. Estos casos jugarán un papel fundamental en la guía de la operación estratégica y la mejora continua de CHEFS en los próximos años. Esta iniciativa de recopilación de datos es esencial para informar decisiones críticas y dar forma a la trayectoria de CHEFS, asegurando su adaptabilidad y efectividad para abordar necesidades y desafíos en constante evolución.", @@ -1117,6 +1148,12 @@ "saveSubscriptionSettingsNotifyMsg": "Se ha guardado la configuración de suscripción para este formulario.", "saveSubscriptionSettingsErrMsg": "Se produjo un error al intentar guardar la configuración de la suscripción.", "saveSubscriptionSettingsConsErrMsg": "Error al guardar la configuración de suscripción para el formulario {formId}: {error}" + }, + "recordsManagement": { + "getFormRetentionPolicyErrMsg": "Se produjo un error al obtener la política de retención para este formulario.", + "getFormRetentionPolicyConsErrMsg": "Error al obtener la política de retención para el formulario {formId}: {error}", + "configureFormRetentionPolicyErrMsg": "Se produjo un error al configurar la política de retención para este formulario.", + "configureFormRetentionPolicyConsErrMsg": "Error al configurar la política de retención para el formulario {formId}: {error}" } }, "admin": { diff --git a/app/frontend/src/internationalization/trans/chefs/fa/fa.json b/app/frontend/src/internationalization/trans/chefs/fa/fa.json index 37a7a2c10..37e2b5914 100644 --- a/app/frontend/src/internationalization/trans/chefs/fa/fa.json +++ b/app/frontend/src/internationalization/trans/chefs/fa/fa.json @@ -204,7 +204,38 @@ "encryptionKeyUpdatedBy": "کلید رمزگذاری به روز شده توسط", "encryptionKeyCopySnackbar": "کلید رمزگذاری در کلیپ بورد کپی شد", "encryptionKeyCopyTooltip": "کلید رمزگذاری را در کلیپ بورد کپی کنید", - "encryptionKeyGenerate": "ایجاد کلید رمزگذاری" + "encryptionKeyGenerate": "ایجاد کلید رمزگذاری", + "dataRetention": "سیاست نگهداری داده‌ها", + "enableHardDeletion": "فعال‌سازی حذف خودکار دائمی", + "hardDeletionTooltip": "در صورت فعال‌سازی، پس از پایان دوره نگهداری، داده‌های علامت‌گذاری شده برای حذف به طور دائمی حذف می‌شوند.", + "hardDeletionWarning": "حذف دائمی قابل برگشت نیست.", + "permanentDeletion": "داده‌ها پس از حذف غیرقابل بازیابی خواهند بود.", + "dataClassification": "طبقه‌بندی داده‌ها", + "dataClassificationHint": "نوع داده‌های جمع‌آوری شده توسط این فرم را دسته‌بندی کنید", + "retentionPeriod": "دوره نگهداری", + "retentionPeriodHint": "مدت زمان نگهداری داده‌های حذف شده قبل از حذف دائمی", + "classPublic": "عمومی", + "classInternal": "داخلی", + "classSensitive": "حساس", + "classConfidential": "محرمانه", + "classProtected": "حفاظت شده", + "classificationCustomHint": "در صورت نیاز می‌توانید یک طبقه‌بندی سفارشی وارد کنید", + "days30": "۳۰ روز", + "days90": "۹۰ روز", + "days180": "۱۸۰ روز", + "year1": "۱ سال (۳۶۵ روز)", + "years2": "۲ سال (۷۳۰ روز)", + "years5": "۵ سال (۱۸۲۵ روز)", + "daysCustom": "دوره سفارشی", + "customDaysLabel": "دوره نگهداری سفارشی (روز)", + "customDaysHint": "عددی بین ۱ تا ۳۶۵۰ روز (۱۰ سال) وارد کنید", + "classificationDescription": "یادداشت‌های طبقه‌بندی", + "classificationDescriptionHint": "توضیحات اختیاری درباره دلیل انتخاب این طبقه‌بندی و دوره نگهداری", + "retentionDaysUpdated": "دوره نگهداری به‌روز شد", + "deletionDisclaimerWithDays": "هشدار: پس از {days} روز، داده‌های علامت‌گذاری شده برای حذف به طور دائمی حذف می‌شوند و قابل بازیابی نخواهند بود.", + "setRetentionPrompt": "لطفاً برای تکمیل سیاست نگهداری داده‌ها، یک دوره نگهداری تعیین کنید.", + "fetchRetentionClassificationListError": "خطا در دریافت فهرست طبقه‌بندی‌های نگهداری.", + "fetchRetentionClassificationListConsErrMsg": "خطا در دریافت طبقه‌بندی‌های نگهداری: {error}" }, "formProfile": { "message": "تیم CHEFS اطلاعات را جمع آوری و سازماندهی می‌کند تا به عنوان ورودی حیاتی برای ساخت مورد کارهای تجاری جامع عمل کند. این موارد نقش کلیدی در راهنمایی عملیات استراتژیک و بهبود مستمر CHEFS در سال‌های آینده خواهند داشت. این اقدام برای جمع‌آوری داده‌ها برای اطلاع از تصمیمات حیاتی و شکل‌دهی مسیر CHEFS جهت اطمینان از قابلیت تطبیق و کارایی آن در مواجهه با نیازها و چالش‌های در حال تحول حیاتی است.", @@ -1117,6 +1148,12 @@ "saveSubscriptionSettingsNotifyMsg": "تنظیمات اشتراک برای این فرم ذخیره شده است.", "saveSubscriptionSettingsErrMsg": "هنگام تلاش برای ذخیره تنظیمات اشتراک خطایی روی داد.", "saveSubscriptionSettingsConsErrMsg": "خطا در ذخیره تنظیمات اشتراک برای فرم {formId}: {error}" + }, + "recordsManagement": { + "getFormRetentionPolicyErrMsg": "هنگام واکشی سیاست نگهداری برای این فرم خطایی روی داد.", + "getFormRetentionPolicyConsErrMsg": "خطا در دریافت سیاست نگهداری فرم {formId}: {error}", + "configureFormRetentionPolicyErrMsg": "هنگام پیکربندی سیاست نگهداری برای این فرم خطایی روی داد.", + "configureFormRetentionPolicyConsErrMsg": "خطا در پیکربندی سیاست نگهداری برای فرم {formId}: {error}" } }, "admin": { diff --git a/app/frontend/src/internationalization/trans/chefs/fr/fr.json b/app/frontend/src/internationalization/trans/chefs/fr/fr.json index 967dfd873..9824b65e2 100644 --- a/app/frontend/src/internationalization/trans/chefs/fr/fr.json +++ b/app/frontend/src/internationalization/trans/chefs/fr/fr.json @@ -204,7 +204,38 @@ "encryptionKeyUpdatedBy": "Clé de cryptage mise à jour par", "encryptionKeyCopySnackbar": "Clé de chiffrement copiée dans le presse-papiers", "encryptionKeyCopyTooltip": "Copier la clé de chiffrement dans le presse-papiers", - "encryptionKeyGenerate": "Générer une clé de cryptage" + "encryptionKeyGenerate": "Générer une clé de cryptage", + "dataRetention": "Politique de conservation des données", + "enableHardDeletion": "Activer la suppression définitive automatique", + "hardDeletionTooltip": "Lorsque cette option est activée, les soumissions marquées pour suppression seront définitivement supprimées après la période de conservation.", + "hardDeletionWarning": "La suppression définitive est permanente et ne peut pas être annulée.", + "permanentDeletion": "Les données seront irrécupérables après la suppression.", + "dataClassification": "Classification des données", + "dataClassificationHint": "Catégorisez le type de données collectées par ce formulaire", + "retentionPeriod": "Période de conservation", + "retentionPeriodHint": "Durée de conservation des soumissions supprimées avant leur suppression définitive", + "classPublic": "Public", + "classInternal": "Interne", + "classSensitive": "Sensible", + "classConfidential": "Confidentiel", + "classProtected": "Protégé", + "classificationCustomHint": "Vous pouvez saisir une classification personnalisée si nécessaire", + "days30": "30 jours", + "days90": "90 jours", + "days180": "180 jours", + "year1": "1 an (365 jours)", + "years2": "2 ans (730 jours)", + "years5": "5 ans (1825 jours)", + "daysCustom": "Période personnalisée", + "customDaysLabel": "Période de conservation personnalisée (jours)", + "customDaysHint": "Entrez un nombre entre 1 et 3650 jours (10 ans)", + "classificationDescription": "Notes de classification", + "classificationDescriptionHint": "Description facultative expliquant pourquoi cette classification et cette période de conservation ont été choisies", + "retentionDaysUpdated": "Période de conservation mise à jour", + "deletionDisclaimerWithDays": "AVERTISSEMENT: Après {days} jours, les soumissions marquées pour suppression seront définitivement supprimées et ne pourront pas être récupérées.", + "setRetentionPrompt": "Veuillez définir une période de conservation pour compléter votre politique de conservation des données.", + "fetchRetentionClassificationListError": "Erreur lors de la récupération de la liste des classifications de conservation.", + "fetchRetentionClassificationListConsErrMsg": "Erreur lors de la récupération des classifications de conservation : {error}" }, "formProfile": { "message": "L'équipe CHEFS collecte et organise des informations pour servir de contribution cruciale à l'élaboration de cas d'affaires complets. Ces cas joueront un rôle central dans la direction des opérations stratégiques et l'amélioration continue de CHEFS au cours des prochaines années. Cette initiative de collecte de données est essentielle pour éclairer les décisions critiques et façonner la trajectoire de CHEFS, assurant son adaptabilité et son efficacité face aux besoins et aux défis en constante évolution.", @@ -1117,6 +1148,12 @@ "saveSubscriptionSettingsNotifyMsg": "Les paramètres d'abonnement pour ce formulaire ont été enregistrés.", "saveSubscriptionSettingsErrMsg": "Une erreur s'est produite lors de la tentative d'enregistrement des paramètres d'abonnement.", "saveSubscriptionSettingsConsErrMsg": "Erreur lors de l'enregistrement des paramètres d'abonnement pour le formulaire {formId} : {error}" + }, + "recordsManagement": { + "getFormRetentionPolicyErrMsg": "Une erreur s'est produite lors de la récupération de la politique de conservation pour ce formulaire.", + "getFormRetentionPolicyConsErrMsg": "Erreur lors de l'obtention de la politique de conservation pour le formulaire {formId} : {error}", + "configureFormRetentionPolicyErrMsg": "Une erreur s'est produite lors de la configuration de la politique de conservation pour ce formulaire.", + "configureFormRetentionPolicyConsErrMsg": "Erreur lors de la configuration de la politique de conservation pour le formulaire {formId} : {error}" } }, "admin": { diff --git a/app/frontend/src/internationalization/trans/chefs/hi/hi.json b/app/frontend/src/internationalization/trans/chefs/hi/hi.json index c434a9464..2c162773c 100644 --- a/app/frontend/src/internationalization/trans/chefs/hi/hi.json +++ b/app/frontend/src/internationalization/trans/chefs/hi/hi.json @@ -204,7 +204,38 @@ "encryptionKeyUpdatedBy": "एन्क्रिप्शन कुंजी अपडेट किया गया", "encryptionKeyCopySnackbar": "एन्क्रिप्शन कुंजी क्लिपबोर्ड पर कॉपी की गई", "encryptionKeyCopyTooltip": "एन्क्रिप्शन कुंजी को क्लिपबोर्ड पर कॉपी करें", - "encryptionKeyGenerate": "एन्क्रिप्शन कुंजी उत्पन्न करें" + "encryptionKeyGenerate": "एन्क्रिप्शन कुंजी उत्पन्न करें", + "dataRetention": "डेटा प्रतिधारण नीति", + "enableHardDeletion": "स्वचालित स्थायी हटाने को सक्षम करें", + "hardDeletionTooltip": "सक्षम होने पर, हटाने के लिए चिह्नित सबमिशन प्रतिधारण अवधि के बाद स्थायी रूप से हटा दिए जाएंगे।", + "hardDeletionWarning": "स्थायी हटाना स्थायी है और इसे पूर्ववत नहीं किया जा सकता है।", + "permanentDeletion": "हटाने के बाद डेटा अपुनर्प्राप्य होगा।", + "dataClassification": "डेटा वर्गीकरण", + "dataClassificationHint": "इस फॉर्म द्वारा एकत्र किए गए डेटा के प्रकार को वर्गीकृत करें", + "retentionPeriod": "प्रतिधारण अवधि", + "retentionPeriodHint": "स्थायी हटाने से पहले हटाए गए सबमिशन को कितने समय तक रखना है", + "classPublic": "सार्वजनिक", + "classInternal": "आंतरिक", + "classSensitive": "संवेदनशील", + "classConfidential": "गोपनीय", + "classProtected": "संरक्षित", + "classificationCustomHint": "आवश्यकता होने पर आप कस्टम वर्गीकरण टाइप कर सकते हैं", + "days30": "30 दिन", + "days90": "90 दिन", + "days180": "180 दिन", + "year1": "1 वर्ष (365 दिन)", + "years2": "2 वर्ष (730 दिन)", + "years5": "5 वर्ष (1825 दिन)", + "daysCustom": "कस्टम अवधि", + "customDaysLabel": "कस्टम प्रतिधारण अवधि (दिन)", + "customDaysHint": "1 और 3650 दिनों (10 वर्ष) के बीच एक संख्या दर्ज करें", + "classificationDescription": "वर्गीकरण नोट्स", + "classificationDescriptionHint": "यह वर्गीकरण और प्रतिधारण अवधि क्यों चुनी गई इसका वैकल्पिक विवरण", + "retentionDaysUpdated": "प्रतिधारण अवधि अपडेट की गई", + "deletionDisclaimerWithDays": "चेतावनी: {days} दिनों के बाद, हटाने के लिए चिह्नित सबमिशन स्थायी रूप से हटा दिए जाएंगे और पुनर्प्राप्त नहीं किए जा सकेंगे।", + "setRetentionPrompt": "कृपया अपनी डेटा प्रतिधारण नीति को पूरा करने के लिए एक प्रतिधारण अवधि निर्धारित करें।", + "fetchRetentionClassificationListError": "प्रतिधारण वर्गीकरण सूची प्राप्त करने में त्रुटि।", + "fetchRetentionClassificationListConsErrMsg": "प्रतिधारण वर्गीकरण प्राप्त करने में त्रुटि: {error}" }, "formProfile": { "message": "CHEFS टीम सूचना एकत्र कर रही है और उसे समृद्धिकारी व्यापक व्यापार मामलों के लिए महत्वपूर्ण इनपुट के रूप में सेवा करने के लिए। ये मामले CHEFS के आगामी वर्षों में रणनीतिक संचालन और उन्नति में महत्वपूर्ण भूमिका निभाएंगे। डेटा इकट्ठा करने का यह पहल सूचना को सूचित करने, महत्वपूर्ण निर्णयों को सूचित करने और CHEFS की यात्रा को मोल्डिंग के लिए आवश्यक है, इसे सुनिश्चित करना है कि यह आवश्यकताओं और चुनौतियों को समाप्त करने में अपनी योग्यता और प्रभावकारिता में बदलते समय का सामना कर सकता है।", @@ -1117,6 +1148,12 @@ "saveSubscriptionSettingsNotifyMsg": "इस फॉर्म के लिए सदस्यता सेटिंग्स सहेज ली गई हैं।", "saveSubscriptionSettingsErrMsg": "सदस्यता सेटिंग सहेजने का प्रयास करते समय एक त्रुटि उत्पन्न हुई.", "saveSubscriptionSettingsConsErrMsg": "फ़ॉर्म {formId} के लिए सदस्यता सेटिंग सहेजने में त्रुटि: {त्रुटि}" + }, + "recordsManagement": { + "getFormRetentionPolicyErrMsg": "इस फ़ॉर्म के लिए प्रतिधारण नीति लाते समय एक त्रुटि हुई।", + "getFormRetentionPolicyConsErrMsg": "फ़ॉर्म {formId} के लिए प्रतिधारण नीति प्राप्त करने में त्रुटि: {error}", + "configureFormRetentionPolicyErrMsg": "इस फ़ॉर्म के लिए प्रतिधारण नीति कॉन्फ़िगर करते समय एक त्रुटि हुई।", + "configureFormRetentionPolicyConsErrMsg": "फ़ॉर्म {formId} के लिए प्रतिधारण नीति कॉन्फ़िगर करने में त्रुटि: {error}" } }, "admin": { diff --git a/app/frontend/src/internationalization/trans/chefs/it/it.json b/app/frontend/src/internationalization/trans/chefs/it/it.json index d978fbd72..4008ba316 100644 --- a/app/frontend/src/internationalization/trans/chefs/it/it.json +++ b/app/frontend/src/internationalization/trans/chefs/it/it.json @@ -204,7 +204,38 @@ "encryptionKeyUpdatedBy": "Chiave di crittografia aggiornata da", "encryptionKeyCopySnackbar": "Chiave di crittografia copiata negli appunti", "encryptionKeyCopyTooltip": "Copia la chiave di crittografia negli appunti", - "encryptionKeyGenerate": "Genera chiave di crittografia" + "encryptionKeyGenerate": "Genera chiave di crittografia", + "dataRetention": "Politica di conservazione dei dati", + "enableHardDeletion": "Abilitare l'eliminazione definitiva automatica", + "hardDeletionTooltip": "Quando abilitato, le sottomissioni contrassegnate per l'eliminazione saranno rimosse permanentemente dopo il periodo di conservazione.", + "hardDeletionWarning": "L'eliminazione definitiva è permanente e non può essere annullata.", + "permanentDeletion": "I dati saranno irrecuperabili dopo l'eliminazione.", + "dataClassification": "Classificazione dei dati", + "dataClassificationHint": "Categorizzare il tipo di dati raccolti da questo modulo", + "retentionPeriod": "Periodo di conservazione", + "retentionPeriodHint": "Per quanto tempo conservare le sottomissioni eliminate prima della rimozione permanente", + "classPublic": "Pubblico", + "classInternal": "Interno", + "classSensitive": "Sensibile", + "classConfidential": "Confidenziale", + "classProtected": "Protetto", + "classificationCustomHint": "È possibile digitare una classificazione personalizzata se necessario", + "days30": "30 giorni", + "days90": "90 giorni", + "days180": "180 giorni", + "year1": "1 anno (365 giorni)", + "years2": "2 anni (730 giorni)", + "years5": "5 anni (1825 giorni)", + "daysCustom": "Periodo personalizzato", + "customDaysLabel": "Periodo di conservazione personalizzato (giorni)", + "customDaysHint": "Inserisci un numero tra 1 e 3650 giorni (10 anni)", + "classificationDescription": "Note di classificazione", + "classificationDescriptionHint": "Descrizione opzionale del motivo per cui sono stati scelti questa classificazione e periodo di conservazione", + "retentionDaysUpdated": "Periodo di conservazione aggiornato", + "deletionDisclaimerWithDays": "AVVERTENZA: Dopo {days} giorni, le sottomissioni contrassegnate per l'eliminazione saranno eliminate permanentemente e non potranno essere recuperate.", + "setRetentionPrompt": "Si prega di impostare un periodo di conservazione per completare la politica di conservazione dei dati.", + "fetchRetentionClassificationListError": "Errore durante il recupero dell'elenco delle classificazioni di conservazione.", + "fetchRetentionClassificationListConsErrMsg": "Errore durante il recupero delle classificazioni di conservazione: {error}" }, "formProfile": { "message": "Il team CHEFS sta raccogliendo e organizzando informazioni per servire come input cruciale per la creazione di casi aziendali completi. Questi casi avranno un ruolo cruciale nella guida dell'operazione strategica e nell'ulteriore miglioramento di CHEFS nei prossimi anni. Questa iniziativa di raccolta dati è essenziale per informare decisioni critiche e plasmare la traiettoria di CHEFS, garantendone adattabilità ed efficacia nel affrontare esigenze e sfide in evoluzione.", @@ -1117,6 +1148,12 @@ "saveSubscriptionSettingsNotifyMsg": "Le impostazioni di iscrizione per questo modulo sono state salvate.", "saveSubscriptionSettingsErrMsg": "Si è verificato un errore durante il tentativo di salvare le impostazioni dell'abbonamento.", "saveSubscriptionSettingsConsErrMsg": "Errore durante il salvataggio delle impostazioni di sottoscrizione per il modulo {formId}: {error}" + }, + "recordsManagement": { + "getFormRetentionPolicyErrMsg": "Si è verificato un errore durante il recupero della politica di conservazione per questo modulo.", + "getFormRetentionPolicyConsErrMsg": "Errore durante il recupero della politica di conservazione per il modulo {formId}: {error}", + "configureFormRetentionPolicyErrMsg": "Si è verificato un errore durante la configurazione della politica di conservazione per questo modulo.", + "configureFormRetentionPolicyConsErrMsg": "Errore durante la configurazione della politica di conservazione per il modulo {formId}: {error}" } }, "admin": { diff --git a/app/frontend/src/internationalization/trans/chefs/ja/ja.json b/app/frontend/src/internationalization/trans/chefs/ja/ja.json index fcf9e6ed5..048f344b5 100644 --- a/app/frontend/src/internationalization/trans/chefs/ja/ja.json +++ b/app/frontend/src/internationalization/trans/chefs/ja/ja.json @@ -204,7 +204,38 @@ "encryptionKeyUpdatedBy": "暗号化キーの更新者", "encryptionKeyCopySnackbar": "暗号化キーがクリップボードにコピーされました", "encryptionKeyCopyTooltip": "暗号化キーをクリップボードにコピー", - "encryptionKeyGenerate": "暗号化キーの生成" + "encryptionKeyGenerate": "暗号化キーの生成", + "dataRetention": "データ保持ポリシー", + "enableHardDeletion": "自動完全削除を有効にする", + "hardDeletionTooltip": "有効にすると、削除対象としてマークされた提出物は保持期間後に完全に削除されます。", + "hardDeletionWarning": "完全削除は永久的であり、元に戻すことはできません。", + "permanentDeletion": "削除後、データは復元できなくなります。", + "dataClassification": "データ分類", + "dataClassificationHint": "このフォームで収集されるデータの種類を分類する", + "retentionPeriod": "保持期間", + "retentionPeriodHint": "完全削除する前に削除された提出物を保持する期間", + "classPublic": "公開", + "classInternal": "内部", + "classSensitive": "機密", + "classConfidential": "極秘", + "classProtected": "保護", + "classificationCustomHint": "必要に応じてカスタム分類を入力できます", + "days30": "30日間", + "days90": "90日間", + "days180": "180日間", + "year1": "1年間(365日)", + "years2": "2年間(730日)", + "years5": "5年間(1825日)", + "daysCustom": "カスタム期間", + "customDaysLabel": "カスタム保持期間(日数)", + "customDaysHint": "1〜3650日(10年)の間の数字を入力してください", + "classificationDescription": "分類に関する注記", + "classificationDescriptionHint": "この分類と保持期間が選択された理由の任意の説明", + "retentionDaysUpdated": "保持期間が更新されました", + "deletionDisclaimerWithDays": "警告:{days}日後、削除対象としてマークされた提出物は完全に削除され、復元できなくなります。", + "setRetentionPrompt": "データ保持ポリシーを完了するには、保持期間を設定してください。", + "fetchRetentionClassificationListError": "保持分類リストの取得中にエラーが発生しました。", + "fetchRetentionClassificationListConsErrMsg": "保持分類の取得中にエラーが発生しました: {error}" }, "formProfile": { "message": "CHEFSチームは包括的なビジネスケースの作成に重要な入力となる情報を収集し、整理しています。これらのケースは、CHEFSの戦略的な運用と今後の改善を指南するうえで重要な役割を果たします。データを収集するこの取り組みは、重要な意思決定の情報提供やCHEFSの軌道を形作るために不可欠です。これにより、CHEFSが変化するニーズと課題に対応するための適応性と効果を確保します.", @@ -1117,6 +1148,12 @@ "saveSubscriptionSettingsNotifyMsg": "このフォームの購読設定が保存されました。", "saveSubscriptionSettingsErrMsg": "サブスクリプション設定を保存しようとしたときにエラーが発生しました。", "saveSubscriptionSettingsConsErrMsg": "フォーム {formId} のサブスクリプション設定を保存中にエラーが発生しました: {error}" + }, + "recordsManagement": { + "getFormRetentionPolicyErrMsg": "このフォームの保持ポリシーの取得中にエラーが発生しました。", + "getFormRetentionPolicyConsErrMsg": "フォーム {formId} の保持ポリシーの取得中にエラーが発生しました: {error}", + "configureFormRetentionPolicyErrMsg": "このフォームの保持ポリシーの構成中にエラーが発生しました。", + "configureFormRetentionPolicyConsErrMsg": "フォーム {formId} の保持ポリシーの構成中にエラーが発生しました: {error}" } }, "admin": { diff --git a/app/frontend/src/internationalization/trans/chefs/ko/ko.json b/app/frontend/src/internationalization/trans/chefs/ko/ko.json index c0489938a..d7898f9c1 100644 --- a/app/frontend/src/internationalization/trans/chefs/ko/ko.json +++ b/app/frontend/src/internationalization/trans/chefs/ko/ko.json @@ -204,7 +204,38 @@ "encryptionKeyUpdatedBy": "암호화 키 업데이트됨", "encryptionKeyCopySnackbar": "암호화 키가 클립보드에 복사되었습니다.", "encryptionKeyCopyTooltip": "암호화 키를 클립보드에 복사", - "encryptionKeyGenerate": "암호화 키 생성" + "encryptionKeyGenerate": "암호화 키 생성", + "dataRetention": "데이터 보존 정책", + "enableHardDeletion": "자동 완전 삭제 활성화", + "hardDeletionTooltip": "활성화하면 삭제로 표시된 제출물이 보존 기간 후 영구적으로 제거됩니다.", + "hardDeletionWarning": "완전 삭제는 영구적이며 취소할 수 없습니다.", + "permanentDeletion": "삭제 후 데이터는 복구할 수 없게 됩니다.", + "dataClassification": "데이터 분류", + "dataClassificationHint": "이 양식에서 수집하는 데이터 유형 분류", + "retentionPeriod": "보존 기간", + "retentionPeriodHint": "영구 삭제 전까지 삭제된 제출물을 보관하는 기간", + "classPublic": "공개", + "classInternal": "내부용", + "classSensitive": "민감함", + "classConfidential": "기밀", + "classProtected": "보호됨", + "classificationCustomHint": "필요한 경우 사용자 정의 분류를 입력할 수 있습니다", + "days30": "30일", + "days90": "90일", + "days180": "180일", + "year1": "1년 (365일)", + "years2": "2년 (730일)", + "years5": "5년 (1825일)", + "daysCustom": "사용자 정의 기간", + "customDaysLabel": "사용자 정의 보존 기간 (일)", + "customDaysHint": "1일에서 3650일(10년) 사이의 숫자를 입력하세요", + "classificationDescription": "분류 참고 사항", + "classificationDescriptionHint": "이 분류와 보존 기간을 선택한 이유에 대한 선택적 설명", + "retentionDaysUpdated": "보존 기간이 업데이트되었습니다", + "deletionDisclaimerWithDays": "경고: {days}일 후, 삭제로 표시된 제출물은 영구적으로 삭제되며 복구할 수 없습니다.", + "setRetentionPrompt": "데이터 보존 정책을 완료하려면 보존 기간을 설정하세요.", + "fetchRetentionClassificationListError": "보존 분류 목록을 가져오는 중 오류가 발생했습니다.", + "fetchRetentionClassificationListConsErrMsg": "보존 분류를 가져오는 중 오류가 발생했습니다: {error}" }, "formProfile": { "message": "CHEFS 팀은 포괄적인 비즈니스 케이스를 작성하는 데 중요한 입력으로 사용될 정보를 수집하고 조직하고 있습니다. 이러한 케이스는 향후 몇 년 동안 CHEFS의 전략적 운영과 지속적인 개선을 안내하는 데 중추적인 역할을 할 것입니다. 데이터 수집을 위한 이 이니셔티브는 중요한 결정에 정보를 제공하고 CHEFS의 궤도를 형성하는 데 필수적입니다. 이를 통해 CHEFS가 변화하는 필요와 도전에 대응하여 적응성과 효과를 보장합니다.", @@ -1117,6 +1148,12 @@ "saveSubscriptionSettingsNotifyMsg": "이 양식에 대한 구독 설정이 저장되었습니다.", "saveSubscriptionSettingsErrMsg": "구독 설정을 저장하는 동안 오류가 발생했습니다.", "saveSubscriptionSettingsConsErrMsg": "양식 {formId}에 대한 구독 설정 저장 오류: {error}" + }, + "recordsManagement": { + "getFormRetentionPolicyErrMsg": "이 양식의 보존 정책을 가져오는 동안 오류가 발생했습니다.", + "getFormRetentionPolicyConsErrMsg": "양식 {formId}에 대한 보존 정책 가져오기 오류: {error}", + "configureFormRetentionPolicyErrMsg": "이 양식의 보존 정책을 구성하는 동안 오류가 발생했습니다.", + "configureFormRetentionPolicyConsErrMsg": "양식 {formId}에 대한 보존 정책 구성 오류: {error}" } }, "admin": { diff --git a/app/frontend/src/internationalization/trans/chefs/pa/pa.json b/app/frontend/src/internationalization/trans/chefs/pa/pa.json index bd827c6ed..37ed1606b 100644 --- a/app/frontend/src/internationalization/trans/chefs/pa/pa.json +++ b/app/frontend/src/internationalization/trans/chefs/pa/pa.json @@ -204,7 +204,38 @@ "encryptionKeyUpdatedBy": "ਏਨਕ੍ਰਿਪਸ਼ਨ ਕੁੰਜੀ ਦੁਆਰਾ ਅੱਪਡੇਟ ਕੀਤਾ ਗਿਆ", "encryptionKeyCopySnackbar": "ਇਨਕ੍ਰਿਪਸ਼ਨ ਕੁੰਜੀ ਕਲਿੱਪਬੋਰਡ 'ਤੇ ਕਾਪੀ ਕੀਤੀ ਗਈ", "encryptionKeyCopyTooltip": "ਐਨਕ੍ਰਿਪਸ਼ਨ ਕੁੰਜੀ ਨੂੰ ਕਲਿੱਪਬੋਰਡ ਵਿੱਚ ਕਾਪੀ ਕਰੋ", - "encryptionKeyGenerate": "ਐਨਕ੍ਰਿਪਸ਼ਨ ਕੁੰਜੀ ਬਣਾਓ" + "encryptionKeyGenerate": "ਐਨਕ੍ਰਿਪਸ਼ਨ ਕੁੰਜੀ ਬਣਾਓ", + "dataRetention": "ਡਾਟਾ ਰਿਟੇਨਸ਼ਨ ਨੀਤੀ", + "enableHardDeletion": "ਆਟੋਮੈਟਿਕ ਪੱਕੀ ਮਿਟਾਉਣਾ ਚਾਲੂ ਕਰੋ", + "hardDeletionTooltip": "ਜਦੋਂ ਚਾਲੂ ਕੀਤਾ ਜਾਂਦਾ ਹੈ, ਮਿਟਾਉਣ ਲਈ ਮਾਰਕ ਕੀਤੀਆਂ ਗਈਆਂ ਸਬਮਿਸ਼ਨਾਂ ਰਿਟੇਨਸ਼ਨ ਮਿਆਦ ਤੋਂ ਬਾਅਦ ਪੱਕੇ ਤੌਰ 'ਤੇ ਹਟਾ ਦਿੱਤੀਆਂ ਜਾਣਗੀਆਂ।", + "hardDeletionWarning": "ਪੱਕੀ ਮਿਟਾਉਣਾ ਸਥਾਈ ਹੈ ਅਤੇ ਇਸਨੂੰ ਵਾਪਸ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਦਾ।", + "permanentDeletion": "ਮਿਟਾਉਣ ਤੋਂ ਬਾਅਦ ਡਾਟਾ ਬਰਾਮਦ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕੇਗਾ।", + "dataClassification": "ਡਾਟਾ ਵਰਗੀਕਰਨ", + "dataClassificationHint": "ਇਸ ਫਾਰਮ ਦੁਆਰਾ ਇਕੱਤਰ ਕੀਤੇ ਗਏ ਡਾਟਾ ਦੀ ਕਿਸਮ ਨੂੰ ਵਰਗੀਕ੍ਰਿਤ ਕਰੋ", + "retentionPeriod": "ਰਿਟੇਨਸ਼ਨ ਮਿਆਦ", + "retentionPeriodHint": "ਪੱਕੀ ਮਿਟਾਉਣ ਤੋਂ ਪਹਿਲਾਂ ਮਿਟਾਏ ਗਏ ਸਬਮਿਸ਼ਨਾਂ ਨੂੰ ਕਿੰਨੇ ਸਮੇਂ ਲਈ ਰੱਖਣਾ ਹੈ", + "classPublic": "ਜਨਤਕ", + "classInternal": "ਅੰਦਰੂਨੀ", + "classSensitive": "ਸੰਵੇਦਨਸ਼ੀਲ", + "classConfidential": "ਗੁਪਤ", + "classProtected": "ਸੁਰੱਖਿਅਤ", + "classificationCustomHint": "ਜੇ ਜ਼ਰੂਰਤ ਹੋਵੇ ਤਾਂ ਤੁਸੀਂ ਕਸਟਮ ਵਰਗੀਕਰਨ ਟਾਈਪ ਕਰ ਸਕਦੇ ਹੋ", + "days30": "30 ਦਿਨ", + "days90": "90 ਦਿਨ", + "days180": "180 ਦਿਨ", + "year1": "1 ਸਾਲ (365 ਦਿਨ)", + "years2": "2 ਸਾਲ (730 ਦਿਨ)", + "years5": "5 ਸਾਲ (1825 ਦਿਨ)", + "daysCustom": "ਕਸਟਮ ਮਿਆਦ", + "customDaysLabel": "ਕਸਟਮ ਰਿਟੇਨਸ਼ਨ ਮਿਆਦ (ਦਿਨ)", + "customDaysHint": "1 ਅਤੇ 3650 ਦਿਨਾਂ (10 ਸਾਲ) ਦੇ ਵਿਚਕਾਰ ਇੱਕ ਨੰਬਰ ਦਰਜ ਕਰੋ", + "classificationDescription": "ਵਰਗੀਕਰਨ ਨੋਟ", + "classificationDescriptionHint": "ਇਹ ਵਰਗੀਕਰਨ ਅਤੇ ਰਿਟੇਨਸ਼ਨ ਮਿਆਦ ਕਿਉਂ ਚੁਣੀ ਗਈ ਇਸ ਦਾ ਵਿਕਲਪਿਕ ਵਰਣਨ", + "retentionDaysUpdated": "ਰਿਟੇਨਸ਼ਨ ਮਿਆਦ ਅਪਡੇਟ ਕੀਤੀ ਗਈ", + "deletionDisclaimerWithDays": "ਚੇਤਾਵਨੀ: {days} ਦਿਨਾਂ ਬਾਅਦ, ਮਿਟਾਉਣ ਲਈ ਮਾਰਕ ਕੀਤੀਆਂ ਗਈਆਂ ਸਬਮਿਸ਼ਨਾਂ ਨੂੰ ਪੱਕੇ ਤੌਰ 'ਤੇ ਮਿਟਾ ਦਿੱਤਾ ਜਾਵੇਗਾ ਅਤੇ ਬਰਾਮਦ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕੇਗਾ।", + "setRetentionPrompt": "ਕਿਰਪਾ ਕਰਕੇ ਆਪਣੀ ਡਾਟਾ ਰਿਟੇਨਸ਼ਨ ਨੀਤੀ ਨੂੰ ਪੂਰਾ ਕਰਨ ਲਈ ਰਿਟੇਨਸ਼ਨ ਮਿਆਦ ਸੈੱਟ ਕਰੋ।", + "fetchRetentionClassificationListError": "ਰਿਟੇਨਸ਼ਨ ਵਰਗੀਕਰਨ ਸੂਚੀ ਪ੍ਰਾਪਤ ਕਰਨ ਵਿੱਚ ਤਰੁੱਟੀ।", + "fetchRetentionClassificationListConsErrMsg": "ਰਿਟੇਨਸ਼ਨ ਵਰਗੀਕਰਨ ਪ੍ਰਾਪਤ ਕਰਨ ਵਿੱਚ ਤਰੁੱਟੀ: {error}" }, "formProfile": { "message": "CHEFS ਟੀਮ ਜਾਣਕਾਰੀ ਇਕੱਠੀ ਕਰ ਰਹੀ ਹੈ ਅਤੇ ਵਿਗਿਆਨ ਕੇਸਾਂ ਲਈ ਮੁਦਾਵਿਆਂ ਬਣਾਉਣ ਲਈ ਮੁਹਿਮ ਵਰਤ ਰਹੀ ਹੈ। ਇਹ ਕੇਸ ਸੀਧੇ CHEFS ਦੀ ਰਣਨੀਤਿਕ ਓਪਰੇਸ਼ਨ ਅਤੇ ਚਲਦੇ ਸਾਲਾਂ ਵਿੱਚ ਚੋਣ ਦੀ ਮੁਖਮਾਨੇ ਵਿੱਚ ਏਕ ਕੀ ਬੰਦੋਬਸਤੀ ਰੋਲ ਪਵੇਗਾ। ਡਾਟਾ ਇਕੱਠਾ ਕਰਨ ਦੀ ਇਹ ਪ੍ਰਯਾਸ਼ਾ ਜਰੂਰੀ ਹੈ ਜਿਸ ਨਾਲ ਕ੍ਰਿਟਿਕਲ ਫੈਸਲਿਆਂ ਨੂੰ ਸੂਚਿਤ ਕਰਨ ਲਈ ਅਤੇ CHEFS ਦੇ ਤਰੱਕੀਪੂਰਤ ਵਿੱਚ ਮੁਕਾਬਲਾ ਕਰਨ ਲਈ ਲੋੜੀਦਾ ਹੈ। ਇਸ ਨਾਲ ਯਹ ਸੁਨਿਸ਼ਚਿਤ ਹੋ ਜਾਂਦਾ ਹੈ ਕਿ ਇਹ ਆਪਣੇ ਅਨੁਕੂਲਨ ਅਤੇ ਚੁਣੇ ਗਏ ਚੁਣੌਤੀਆਂ ਅਤੇ ਜ਼ਰੂਰਾਤਾਂ ਦਾ ਇੱਜ਼ਤੀਕਾਰ ਅਤੇ ਯੋਜਨਾ ਵਿੱਚ ਇੱਕ ਬਦਲਾਵ ਅਤੇ ਕਾਰਗੁਜ਼ਾਰੀ ਵਿਚ ਇਹ ਗੁਣਤੀ ਹੈ।", @@ -1117,6 +1148,12 @@ "saveSubscriptionSettingsNotifyMsg": "ਇਸ ਫਾਰਮ ਲਈ ਗਾਹਕੀ ਸੈਟਿੰਗਾਂ ਨੂੰ ਸੁਰੱਖਿਅਤ ਕੀਤਾ ਗਿਆ ਹੈ।", "saveSubscriptionSettingsErrMsg": "ਗਾਹਕੀ ਸੈਟਿੰਗਾਂ ਨੂੰ ਸੁਰੱਖਿਅਤ ਕਰਨ ਦੀ ਕੋਸ਼ਿਸ਼ ਕਰਦੇ ਸਮੇਂ ਇੱਕ ਤਰੁੱਟੀ ਉਤਪੰਨ ਹੋਈ।", "saveSubscriptionSettingsConsErrMsg": "ਫਾਰਮ {formId} ਲਈ ਗਾਹਕੀ ਸੈਟਿੰਗਾਂ ਨੂੰ ਸੁਰੱਖਿਅਤ ਕਰਨ ਵਿੱਚ ਤਰੁੱਟੀ: {error}" + }, + "recordsManagement": { + "getFormRetentionPolicyErrMsg": "ਇਸ ਫਾਰਮ ਲਈ ਰਿਟੇਨਸ਼ਨ ਨੀਤੀ ਪ੍ਰਾਪਤ ਕਰਨ ਦੌਰਾਨ ਇੱਕ ਤਰੁੱਟੀ ਉਤਪੰਨ ਹੋਈ।", + "getFormRetentionPolicyConsErrMsg": "ਫਾਰਮ {formId} ਲਈ ਰਿਟੇਨਸ਼ਨ ਨੀਤੀ ਪ੍ਰਾਪਤ ਕਰਨ ਵਿੱਚ ਤਰੁੱਟੀ: {error}", + "configureFormRetentionPolicyErrMsg": "ਇਸ ਫਾਰਮ ਲਈ ਰਿਟੇਨਸ਼ਨ ਨੀਤੀ ਕੌਂਫਿਗਰ ਕਰਨ ਦੌਰਾਨ ਇੱਕ ਤਰੁੱਟੀ ਉਤਪੰਨ ਹੋਈ।", + "configureFormRetentionPolicyConsErrMsg": "ਫਾਰਮ {formId} ਲਈ ਰਿਟੇਨਸ਼ਨ ਨੀਤੀ ਕੌਂਫਿਗਰ ਕਰਨ ਵਿੱਚ ਤਰੁੱਟੀ: {error}" } }, "admin": { diff --git a/app/frontend/src/internationalization/trans/chefs/pt/pt.json b/app/frontend/src/internationalization/trans/chefs/pt/pt.json index 8e1d79084..79e76d71b 100644 --- a/app/frontend/src/internationalization/trans/chefs/pt/pt.json +++ b/app/frontend/src/internationalization/trans/chefs/pt/pt.json @@ -204,7 +204,38 @@ "encryptionKeyUpdatedBy": "Chave de criptografia atualizada por", "encryptionKeyCopySnackbar": "Chave de criptografia copiada para a área de transferência", "encryptionKeyCopyTooltip": "Copiar chave de criptografia para a área de transferência", - "encryptionKeyGenerate": "Gerar chave de criptografia" + "encryptionKeyGenerate": "Gerar chave de criptografia", + "dataRetention": "Política de retenção de dados", + "enableHardDeletion": "Ativar exclusão permanente automática", + "hardDeletionTooltip": "Quando ativado, submissões marcadas para exclusão serão removidas permanentemente após o período de retenção.", + "hardDeletionWarning": "A exclusão permanente é definitiva e não pode ser desfeita.", + "permanentDeletion": "Os dados serão irrecuperáveis após a exclusão.", + "dataClassification": "Classificação de dados", + "dataClassificationHint": "Categorize o tipo de dados coletados por este formulário", + "retentionPeriod": "Período de retenção", + "retentionPeriodHint": "Por quanto tempo manter as submissões excluídas antes da remoção permanente", + "classPublic": "Público", + "classInternal": "Interno", + "classSensitive": "Sensível", + "classConfidential": "Confidencial", + "classProtected": "Protegido", + "classificationCustomHint": "Você pode digitar uma classificação personalizada se necessário", + "days30": "30 dias", + "days90": "90 dias", + "days180": "180 dias", + "year1": "1 ano (365 dias)", + "years2": "2 anos (730 dias)", + "years5": "5 anos (1825 dias)", + "daysCustom": "Período personalizado", + "customDaysLabel": "Período de retenção personalizado (dias)", + "customDaysHint": "Digite um número entre 1 e 3650 dias (10 anos)", + "classificationDescription": "Notas de classificação", + "classificationDescriptionHint": "Descrição opcional de por que esta classificação e período de retenção foram escolhidos", + "retentionDaysUpdated": "Período de retenção atualizado", + "deletionDisclaimerWithDays": "AVISO: Após {days} dias, submissões marcadas para exclusão serão permanentemente excluídas e não poderão ser recuperadas.", + "setRetentionPrompt": "Por favor, defina um período de retenção para completar sua política de retenção de dados.", + "fetchRetentionClassificationListError": "Erro ao buscar a lista de classificações de retenção.", + "fetchRetentionClassificationListConsErrMsg": "Erro ao buscar classificações de retenção: {error}" }, "formProfile": { "message": "A equipe CHEFS está coletando e organizando informações para servir como entrada crucial para a criação de casos de negócios abrangentes. Esses casos desempenharão um papel fundamental na orientação da operação estratégica e na melhoria contínua do CHEFS nos próximos anos. Essa iniciativa de coleta de dados é essencial para informar decisões críticas e moldar a trajetória do CHEFS, garantindo sua adaptabilidade e eficácia em lidar com necessidades e desafios em evolução.", @@ -1117,6 +1148,12 @@ "saveSubscriptionSettingsNotifyMsg": "As configurações de inscrição para este formulário foram salvas.", "saveSubscriptionSettingsErrMsg": "Ocorreu um erro ao tentar salvar as configurações de assinatura.", "saveSubscriptionSettingsConsErrMsg": "Erro ao salvar as configurações de assinatura para o formulário {formId}: {error}" + }, + "recordsManagement": { + "getFormRetentionPolicyErrMsg": "Ocorreu um erro ao buscar a política de retenção para este formulário.", + "getFormRetentionPolicyConsErrMsg": "Erro ao obter a política de retenção do formulário {formId}: {error}", + "configureFormRetentionPolicyErrMsg": "Ocorreu um erro ao configurar a política de retenção para este formulário.", + "configureFormRetentionPolicyConsErrMsg": "Erro ao configurar a política de retenção do formulário {formId}: {error}" } }, "admin": { diff --git a/app/frontend/src/internationalization/trans/chefs/ru/ru.json b/app/frontend/src/internationalization/trans/chefs/ru/ru.json index 9835347ae..ff8e8ad4b 100644 --- a/app/frontend/src/internationalization/trans/chefs/ru/ru.json +++ b/app/frontend/src/internationalization/trans/chefs/ru/ru.json @@ -204,7 +204,38 @@ "encryptionKeyUpdatedBy": "Ключ шифрования обновлен", "encryptionKeyCopySnackbar": "Ключ шифрования скопирован в буфер обмена", "encryptionKeyCopyTooltip": "Копировать ключ шифрования в буфер обмена", - "encryptionKeyGenerate": "Сгенерировать ключ шифрования" + "encryptionKeyGenerate": "Сгенерировать ключ шифрования", + "dataRetention": "Политика хранения данных", + "enableHardDeletion": "Включить автоматическое безвозвратное удаление", + "hardDeletionTooltip": "При включении этой опции отмеченные для удаления данные будут безвозвратно удалены после истечения срока хранения.", + "hardDeletionWarning": "Безвозвратное удаление является постоянным и не может быть отменено.", + "permanentDeletion": "Данные будут невосстановимы после удаления.", + "dataClassification": "Классификация данных", + "dataClassificationHint": "Категоризируйте тип данных, собираемых этой формой", + "retentionPeriod": "Период хранения", + "retentionPeriodHint": "Как долго хранить удаленные данные перед безвозвратным удалением", + "classPublic": "Публичные", + "classInternal": "Внутренние", + "classSensitive": "Конфиденциальные", + "classConfidential": "Секретные", + "classProtected": "Защищенные", + "classificationCustomHint": "При необходимости вы можете ввести пользовательскую классификацию", + "days30": "30 дней", + "days90": "90 дней", + "days180": "180 дней", + "year1": "1 год (365 дней)", + "years2": "2 года (730 дней)", + "years5": "5 лет (1825 дней)", + "daysCustom": "Пользовательский период", + "customDaysLabel": "Пользовательский период хранения (дни)", + "customDaysHint": "Введите число от 1 до 3650 дней (10 лет)", + "classificationDescription": "Примечания к классификации", + "classificationDescriptionHint": "Необязательное описание того, почему выбраны эта классификация и период хранения", + "retentionDaysUpdated": "Период хранения обновлен", + "deletionDisclaimerWithDays": "ПРЕДУПРЕЖДЕНИЕ: Через {days} дней отмеченные для удаления данные будут безвозвратно удалены и не смогут быть восстановлены.", + "setRetentionPrompt": "Пожалуйста, установите период хранения для завершения настройки политики хранения данных.", + "fetchRetentionClassificationListError": "Ошибка при получении списка классификаций хранения.", + "fetchRetentionClassificationListConsErrMsg": "Ошибка при получении классификаций хранения: {error}" }, "formProfile": { "message": "Команда CHEFS собирает и систематизирует информацию для создания ключевых аргументов в пользу формирования всесторонних деловых кейсов. Эти кейсы будут играть решающую роль в направлении стратегической операции и постоянного совершенствования CHEFS в ближайшие годы. Эта инициатива по сбору данных является необходимой для принятия важных решений и формирования траектории CHEFS, обеспечивая его адаптивность и эффективность в решении изменяющихся потребностей и вызовов.", @@ -1117,6 +1148,12 @@ "saveSubscriptionSettingsNotifyMsg": "Настройки подписки для этой формы сохранены.", "saveSubscriptionSettingsErrMsg": "Произошла ошибка при попытке сохранить настройки подписки.", "saveSubscriptionSettingsConsErrMsg": "Ошибка сохранения настроек подписки для формы {formId}: {error}" + }, + "recordsManagement": { + "getFormRetentionPolicyErrMsg": "Произошла ошибка при получении политики хранения для этой формы.", + "getFormRetentionPolicyConsErrMsg": "Ошибка при получении политики хранения формы {formId}: {error}", + "configureFormRetentionPolicyErrMsg": "Произошла ошибка при настройке политики хранения для этой формы.", + "configureFormRetentionPolicyConsErrMsg": "Ошибка при настройке политики хранения формы {formId}: {error}" } }, "admin": { diff --git a/app/frontend/src/internationalization/trans/chefs/tl/tl.json b/app/frontend/src/internationalization/trans/chefs/tl/tl.json index ab8fd1720..6eabd29c5 100644 --- a/app/frontend/src/internationalization/trans/chefs/tl/tl.json +++ b/app/frontend/src/internationalization/trans/chefs/tl/tl.json @@ -204,7 +204,38 @@ "encryptionKeyUpdatedBy": "Encryption Key Na-update Ni", "encryptionKeyCopySnackbar": "Nakopya ang Encryption Key sa clipboard", "encryptionKeyCopyTooltip": "Kopyahin ang Encryption Key sa clipboard", - "encryptionKeyGenerate": "Bumuo ng Encryption Key" + "encryptionKeyGenerate": "Bumuo ng Encryption Key", + "dataRetention": "Patakaran sa Pagpapanatili ng Data", + "enableHardDeletion": "Paganahin ang awtomatikong permanenteng pagtanggal", + "hardDeletionTooltip": "Kapag pinagana, ang mga submission na minarkahan para sa pagtanggal ay permanenteng matatanggal pagkatapos ng panahon ng pagpapanatili.", + "hardDeletionWarning": "Ang permanenteng pagtanggal ay hindi na maibabalik.", + "permanentDeletion": "Ang data ay hindi na maibabalik pagkatapos matanggal.", + "dataClassification": "Klasipikasyon ng Data", + "dataClassificationHint": "I-kategorya ang uri ng datos na kinokolekta ng form na ito", + "retentionPeriod": "Panahon ng Pagpapanatili", + "retentionPeriodHint": "Gaano katagal panatilihin ang mga tinanggal na submission bago permanenteng alisin", + "classPublic": "Pampubliko", + "classInternal": "Panloob", + "classSensitive": "Sensitibo", + "classConfidential": "Kumpidensyal", + "classProtected": "Protektado", + "classificationCustomHint": "Maaari kang mag-type ng custom na klasipikasyon kung kinakailangan", + "days30": "30 araw", + "days90": "90 araw", + "days180": "180 araw", + "year1": "1 taon (365 araw)", + "years2": "2 taon (730 araw)", + "years5": "5 taon (1825 araw)", + "daysCustom": "Custom na panahon", + "customDaysLabel": "Custom na Panahon ng Pagpapanatili (araw)", + "customDaysHint": "Maglagay ng numero sa pagitan ng 1 at 3650 araw (10 taon)", + "classificationDescription": "Mga Tala sa Klasipikasyon", + "classificationDescriptionHint": "Opsyonal na paglalarawan kung bakit pinili ang klasipikasyong ito at panahon ng pagpapanatili", + "retentionDaysUpdated": "Na-update ang panahon ng pagpapanatili", + "deletionDisclaimerWithDays": "BABALA: Pagkatapos ng {days} araw, ang mga submission na minarkahan para sa pagtanggal ay permanenteng matatanggal at hindi na maibabalik.", + "setRetentionPrompt": "Mangyaring magtakda ng panahon ng pagpapanatili upang makumpleto ang iyong patakaran sa pagpapanatili ng data.", + "fetchRetentionClassificationListError": "May naganap na error habang kinukuha ang listahan ng mga klasipikasyon ng pagpapanatili.", + "fetchRetentionClassificationListConsErrMsg": "May naganap na error habang kinukuha ang mga klasipikasyon ng pagpapanatili: {error}" }, "formProfile": { "message": "Ang CHEFS team ay nagkokolekta at nag-oorganisa ng impormasyon upang magsilbing mahalagang input para sa pagbuo ng kapsulang kaso ng negosyo. Ang mga kaso na ito ay magiging pangunahing bahagi sa paggabay sa pangangasiwa at patuloy na pagpapabuti ng CHEFS sa mga darating na taon. Ang inisyatibang ito sa pagsasama ng datos ay mahalaga para sa pagbibigay impormasyon sa mga kritikal na desisyon at pagsanay sa takbo ng CHEFS, tiyak na nagiging adaptable at epektibo sa pagsasaad sa mga nagbabagong pangangailangan at hamon.", @@ -1115,6 +1146,12 @@ "saveSubscriptionSettingsNotifyMsg": "Nai-save na ang mga setting ng subscription para sa form na ito.", "saveSubscriptionSettingsErrMsg": "May naganap na error habang sinusubukang i-save ang mga setting ng subscription.", "saveSubscriptionSettingsConsErrMsg": "Error sa pag-save ng mga setting ng subscription para sa form {formId}: {error}" + }, + "recordsManagement": { + "getFormRetentionPolicyErrMsg": "May naganap na error habang kinukuha ang retention policy para sa form na ito.", + "getFormRetentionPolicyConsErrMsg": "Error sa pagkuha ng retention policy para sa form {formId}: {error}", + "configureFormRetentionPolicyErrMsg": "May naganap na error habang kino-configure ang retention policy para sa form na ito.", + "configureFormRetentionPolicyConsErrMsg": "Error sa pag-configure ng retention policy para sa form {formId}: {error}" } }, "admin": { diff --git a/app/frontend/src/internationalization/trans/chefs/uk/uk.json b/app/frontend/src/internationalization/trans/chefs/uk/uk.json index 6206e1167..b653f375d 100644 --- a/app/frontend/src/internationalization/trans/chefs/uk/uk.json +++ b/app/frontend/src/internationalization/trans/chefs/uk/uk.json @@ -204,7 +204,38 @@ "encryptionKeyUpdatedBy": "Ключ шифрування оновлено", "encryptionKeyCopySnackbar": "Ключ шифрування скопійовано в буфер обміну", "encryptionKeyCopyTooltip": "Копіювати ключ шифрування в буфер обміну", - "encryptionKeyGenerate": "Згенерувати ключ шифрування" + "encryptionKeyGenerate": "Згенерувати ключ шифрування", + "dataRetention": "Політика збереження даних", + "enableHardDeletion": "Увімкнути автоматичне безповоротне видалення", + "hardDeletionTooltip": "Якщо увімкнено, подання, позначені для видалення, будуть безповоротно видалені після закінчення періоду збереження.", + "hardDeletionWarning": "Безповоротне видалення є постійним і не може бути скасоване.", + "permanentDeletion": "Дані будуть невідновні після видалення.", + "dataClassification": "Класифікація даних", + "dataClassificationHint": "Категоризуйте тип даних, що збираються цією формою", + "retentionPeriod": "Період збереження", + "retentionPeriodHint": "Як довго зберігати видалені подання перед безповоротним видаленням", + "classPublic": "Публічні", + "classInternal": "Внутрішні", + "classSensitive": "Конфіденційні", + "classConfidential": "Секретні", + "classProtected": "Захищені", + "classificationCustomHint": "Ви можете ввести власну класифікацію, якщо потрібно", + "days30": "30 днів", + "days90": "90 днів", + "days180": "180 днів", + "year1": "1 рік (365 днів)", + "years2": "2 роки (730 днів)", + "years5": "5 років (1825 днів)", + "daysCustom": "Власний період", + "customDaysLabel": "Власний період збереження (днів)", + "customDaysHint": "Введіть число від 1 до 3650 днів (10 років)", + "classificationDescription": "Примітки до класифікації", + "classificationDescriptionHint": "Необов'язковий опис того, чому обрано цю класифікацію та період збереження", + "retentionDaysUpdated": "Період збереження оновлено", + "deletionDisclaimerWithDays": "ПОПЕРЕДЖЕННЯ: Через {days} днів подання, позначені для видалення, будуть безповоротно видалені та не зможуть бути відновлені.", + "setRetentionPrompt": "Будь ласка, встановіть період збереження, щоб завершити налаштування політики збереження даних.", + "fetchRetentionClassificationListError": "Помилка отримання списку класифікацій збереження.", + "fetchRetentionClassificationListConsErrMsg": "Помилка отримання класифікацій збереження: {error}" }, "formProfile": { "message": "Команда CHEFS збирає та організовує інформацію для надання ключового внеску у створення всебічних бізнес-кейсів. Ці кейси відіграють вирішальну роль у напрямку стратегічної операції та постійного вдосконалення CHEFS у наступні роки. Ця ініціатива зібрати дані є важливою для інформування критичних рішень та формування траєкторії CHEFS, забезпечуючи його адаптивність та ефективність у вирішенні змінюючихся потреб і викликів.", @@ -1117,6 +1148,12 @@ "saveSubscriptionSettingsNotifyMsg": "Налаштування підписки на цю форму збережено.", "saveSubscriptionSettingsErrMsg": "Під час спроби зберегти налаштування підписки сталася помилка.", "saveSubscriptionSettingsConsErrMsg": "Помилка збереження налаштувань підписки для форми {formId}: {error}" + }, + "recordsManagement": { + "getFormRetentionPolicyErrMsg": "Під час отримання політики збереження для цієї форми сталася помилка.", + "getFormRetentionPolicyConsErrMsg": "Помилка отримання політики збереження для форми {formId}: {error}", + "configureFormRetentionPolicyErrMsg": "Під час налаштування політики збереження для цієї форми сталася помилка.", + "configureFormRetentionPolicyConsErrMsg": "Помилка налаштування політики збереження для форми {formId}: {error}" } }, "admin": { diff --git a/app/frontend/src/internationalization/trans/chefs/vi/vi.json b/app/frontend/src/internationalization/trans/chefs/vi/vi.json index 0cde55744..ba88a67e7 100644 --- a/app/frontend/src/internationalization/trans/chefs/vi/vi.json +++ b/app/frontend/src/internationalization/trans/chefs/vi/vi.json @@ -204,7 +204,38 @@ "encryptionKeyUpdatedBy": "Khóa mã hóa được cập nhật bởi", "encryptionKeyCopySnackbar": "Khóa mã hóa đã được sao chép vào bảng tạm", "encryptionKeyCopyTooltip": "Sao chép Khóa mã hóa vào clipboard", - "encryptionKeyGenerate": "Tạo khóa mã hóa" + "encryptionKeyGenerate": "Tạo khóa mã hóa", + "dataRetention": "Chính sách lưu giữ dữ liệu", + "enableHardDeletion": "Bật xóa vĩnh viễn tự động", + "hardDeletionTooltip": "Khi được bật, các bài nộp được đánh dấu để xóa sẽ bị xóa vĩnh viễn sau thời gian lưu giữ.", + "hardDeletionWarning": "Việc xóa vĩnh viễn là không thể hoàn tác.", + "permanentDeletion": "Dữ liệu sẽ không thể khôi phục sau khi xóa.", + "dataClassification": "Phân loại dữ liệu", + "dataClassificationHint": "Phân loại kiểu dữ liệu được thu thập bởi biểu mẫu này", + "retentionPeriod": "Thời gian lưu giữ", + "retentionPeriodHint": "Thời gian giữ các bài nộp đã xóa trước khi xóa vĩnh viễn", + "classPublic": "Công khai", + "classInternal": "Nội bộ", + "classSensitive": "Nhạy cảm", + "classConfidential": "Bảo mật", + "classProtected": "Được bảo vệ", + "classificationCustomHint": "Bạn có thể nhập phân loại tùy chỉnh nếu cần", + "days30": "30 ngày", + "days90": "90 ngày", + "days180": "180 ngày", + "year1": "1 năm (365 ngày)", + "years2": "2 năm (730 ngày)", + "years5": "5 năm (1825 ngày)", + "daysCustom": "Thời gian tùy chỉnh", + "customDaysLabel": "Thời gian lưu giữ tùy chỉnh (ngày)", + "customDaysHint": "Nhập một số từ 1 đến 3650 ngày (10 năm)", + "classificationDescription": "Ghi chú phân loại", + "classificationDescriptionHint": "Mô tả tùy chọn về lý do tại sao phân loại và thời gian lưu giữ này được chọn", + "retentionDaysUpdated": "Đã cập nhật thời gian lưu giữ", + "deletionDisclaimerWithDays": "CẢNH BÁO: Sau {days} ngày, các bài nộp được đánh dấu để xóa sẽ bị xóa vĩnh viễn và không thể khôi phục.", + "setRetentionPrompt": "Vui lòng thiết lập thời gian lưu giữ để hoàn tất chính sách lưu giữ dữ liệu của bạn.", + "fetchRetentionClassificationListError": "Lỗi khi lấy danh sách phân loại lưu giữ.", + "fetchRetentionClassificationListConsErrMsg": "Lỗi khi lấy phân loại lưu giữ: {error}" }, "formProfile": { "message": "Đội ngũ CHEFS đang thu thập và tổ chức thông tin để phục vụ như một đầu vào quan trọng cho việc xây dựng các trường hợp kinh doanh toàn diện. Những trường hợp này sẽ đóng một vai trò quan trọng trong hướng dẫn vận hành chiến lược và cải tiến liên tục của CHEFS trong những năm sắp tới. Sáng kiến này để thu thập dữ liệu là quan trọng để thông tin quyết định quan trọng và định hình quỹ đạo của CHEFS, đảm bảo tính linh hoạt và hiệu quả trong đối mặt với những nhu cầu và thách thức đang thay đổi.", @@ -1115,6 +1146,12 @@ "saveSubscriptionSettingsNotifyMsg": "Cài đặt đăng ký cho biểu mẫu này đã được lưu.", "saveSubscriptionSettingsErrMsg": "Đã xảy ra lỗi khi cố lưu cài đặt đăng ký.", "saveSubscriptionSettingsConsErrMsg": "Lỗi khi lưu cài đặt đăng ký cho biểu mẫu {formId}: {error}" + }, + "recordsManagement": { + "getFormRetentionPolicyErrMsg": "Đã xảy ra lỗi khi tìm nạp chính sách lưu giữ cho biểu mẫu này.", + "getFormRetentionPolicyConsErrMsg": "Lỗi khi nhận chính sách lưu giữ cho biểu mẫu {formId}: {error}", + "configureFormRetentionPolicyErrMsg": "Đã xảy ra lỗi khi cấu hình chính sách lưu giữ cho biểu mẫu này.", + "configureFormRetentionPolicyConsErrMsg": "Lỗi khi cấu hình chính sách lưu giữ cho biểu mẫu {formId}: {error}" } }, "admin": { diff --git a/app/frontend/src/internationalization/trans/chefs/zh/zh.json b/app/frontend/src/internationalization/trans/chefs/zh/zh.json index 47b6f7bff..fd583eee6 100644 --- a/app/frontend/src/internationalization/trans/chefs/zh/zh.json +++ b/app/frontend/src/internationalization/trans/chefs/zh/zh.json @@ -204,7 +204,38 @@ "encryptionKeyUpdatedBy": "加密密钥更新者", "encryptionKeyCopySnackbar": "加密密钥已复制到剪贴板", "encryptionKeyCopyTooltip": "将加密密钥复制到剪贴板", - "encryptionKeyGenerate": "生成加密密钥" + "encryptionKeyGenerate": "生成加密密钥", + "dataRetention": "数据保留策略", + "enableHardDeletion": "启用自动永久删除", + "hardDeletionTooltip": "启用后,标记为删除的提交将在保留期结束后被永久删除。", + "hardDeletionWarning": "永久删除是不可逆的,无法撤销。", + "permanentDeletion": "删除后数据将无法恢复。", + "dataClassification": "数据分类", + "dataClassificationHint": "对此表单收集的数据类型进行分类", + "retentionPeriod": "保留期限", + "retentionPeriodHint": "在永久删除前保留已删除提交的时间", + "classPublic": "公开", + "classInternal": "内部", + "classSensitive": "敏感", + "classConfidential": "机密", + "classProtected": "受保护", + "classificationCustomHint": "如有需要可以输入自定义分类", + "days30": "30天", + "days90": "90天", + "days180": "180天", + "year1": "1年(365天)", + "years2": "2年(730天)", + "years5": "5年(1825天)", + "daysCustom": "自定义期限", + "customDaysLabel": "自定义保留期限(天数)", + "customDaysHint": "输入1到3650天(10年)之间的数字", + "classificationDescription": "分类说明", + "classificationDescriptionHint": "可选择说明为什么选择此分类和保留期限", + "retentionDaysUpdated": "保留期限已更新", + "deletionDisclaimerWithDays": "警告:{days}天后,标记为删除的提交将被永久删除,无法恢复。", + "setRetentionPrompt": "请设置保留期限以完成您的数据保留策略。", + "fetchRetentionClassificationListError": "获取保留分类列表时出错。", + "fetchRetentionClassificationListConsErrMsg": "获取保留分类时出错:{error}" }, "formProfile": { "message": "CHEFS团队正在收集和组织信息,作为制定全面业务案例的关键输入。这些案例将在指导CHEFS未来几年的战略运作和持续改进中发挥关键作用。这一收集数据的倡议对于提供关键决策的信息和塑造CHEFS轨迹至关重要,确保其在应对不断变化的需求和挑战中的适应性和有效性。", @@ -1117,6 +1148,12 @@ "saveSubscriptionSettingsNotifyMsg": "此表单的订阅设置已保存。", "saveSubscriptionSettingsErrMsg": "尝试保存订阅设置时出错。", "saveSubscriptionSettingsConsErrMsg": "保存表单 {formId} 的订阅设置时出错:{error}" + }, + "recordsManagement": { + "getFormRetentionPolicyErrMsg": "获取此表单的保留策略时发生错误。", + "getFormRetentionPolicyConsErrMsg": "获取表单 {formId} 的保留策略时出错:{error}", + "configureFormRetentionPolicyErrMsg": "配置此表单的保留策略时发生错误。", + "configureFormRetentionPolicyConsErrMsg": "配置表单 {formId} 的保留策略时出错:{error}" } }, "admin": { diff --git a/app/frontend/src/internationalization/trans/chefs/zhTW/zh-TW.json b/app/frontend/src/internationalization/trans/chefs/zhTW/zh-TW.json index af48a93f3..92973c1d4 100644 --- a/app/frontend/src/internationalization/trans/chefs/zhTW/zh-TW.json +++ b/app/frontend/src/internationalization/trans/chefs/zhTW/zh-TW.json @@ -204,7 +204,38 @@ "encryptionKeyUpdatedBy": "加密金鑰更新者", "encryptionKeyCopySnackbar": "加密金鑰已複製到剪貼簿", "encryptionKeyCopyTooltip": "將加密金鑰複製到剪貼簿", - "encryptionKeyGenerate": "產生加密金鑰" + "encryptionKeyGenerate": "產生加密金鑰", + "dataRetention": "資料保留政策", + "enableHardDeletion": "啟用自動永久刪除", + "hardDeletionTooltip": "啟用後,標記為刪除的提交將在保留期結束後被永久刪除。", + "hardDeletionWarning": "永久刪除是不可逆的,無法撤銷。", + "permanentDeletion": "刪除後資料將無法恢復。", + "dataClassification": "資料分類", + "dataClassificationHint": "對此表單收集的資料類型進行分類", + "retentionPeriod": "保留期限", + "retentionPeriodHint": "在永久刪除前保留已刪除提交的時間", + "classPublic": "公開", + "classInternal": "內部", + "classSensitive": "敏感", + "classConfidential": "機密", + "classProtected": "受保護", + "classificationCustomHint": "如有需要可以輸入自定義分類", + "days30": "30天", + "days90": "90天", + "days180": "180天", + "year1": "1年(365天)", + "years2": "2年(730天)", + "years5": "5年(1825天)", + "daysCustom": "自定義期限", + "customDaysLabel": "自定義保留期限(天數)", + "customDaysHint": "輸入1到3650天(10年)之間的數字", + "classificationDescription": "分類說明", + "classificationDescriptionHint": "可選擇說明為什麼選擇此分類和保留期限", + "retentionDaysUpdated": "保留期限已更新", + "deletionDisclaimerWithDays": "警告:{days}天後,標記為刪除的提交將被永久刪除,無法恢復。", + "setRetentionPrompt": "請設置保留期限以完成您的資料保留政策。", + "fetchRetentionClassificationListError": "取得保留分類清單時發生錯誤。", + "fetchRetentionClassificationListConsErrMsg": "取得保留分類時發生錯誤:{error}" }, "formProfile": { "message": "CHEFS團隊正在收集和組織信息,作為制定全面業務案例的關鍵輸入。這些案例將在指導CHEFS未來幾年的戰略運作和持續改進中發揮關鍵作用。這一收集數據的倡議對於提供關鍵決策的信息和塑造CHEFS軌跡至關重要,確保其在應對不斷變化的需求和挑戰中的適應性和有效性。", @@ -1117,6 +1148,12 @@ "saveSubscriptionSettingsNotifyMsg": "此表单的订阅设置已保存。", "saveSubscriptionSettingsErrMsg": "尝试保存订阅设置时出错。", "saveSubscriptionSettingsConsErrMsg": "保存表单 {formId} 的订阅设置时出错:{error}" + }, + "recordsManagement": { + "getFormRetentionPolicyErrMsg": "獲取此表單的保留策略時發生錯誤。", + "getFormRetentionPolicyConsErrMsg": "獲取表單 {formId} 的保留策略時出錯:{error}", + "configureFormRetentionPolicyErrMsg": "配置此表單的保留策略時發生錯誤。", + "configureFormRetentionPolicyConsErrMsg": "配置表單 {formId} 的保留策略時出錯:{error}" } }, "admin": { diff --git a/app/frontend/src/services/index.js b/app/frontend/src/services/index.js index 5dd1ff563..d32d659ba 100755 --- a/app/frontend/src/services/index.js +++ b/app/frontend/src/services/index.js @@ -8,3 +8,4 @@ export { default as fileService } from './fileService'; export { default as utilsService } from './utilsService'; export { default as encryptionKeyService } from './encryptionKeyService'; export { default as eventStreamConfigService } from './eventStreamConfigService'; +export { default as recordsManagementService } from './recordsManagementService'; diff --git a/app/frontend/src/services/recordsManagementService.js b/app/frontend/src/services/recordsManagementService.js new file mode 100644 index 000000000..60a7657a2 --- /dev/null +++ b/app/frontend/src/services/recordsManagementService.js @@ -0,0 +1,62 @@ +import { appAxios } from '~/services/interceptors'; +import { ApiRoutes } from '~/utils/constants'; + +export default { + /** + * @function listRetentionClassificationTypes + * Gets the Retention Classification Types supported + * @returns {Promise} An axios response + */ + listRetentionClassificationTypes() { + return appAxios().get(`${ApiRoutes.RECORDS_MANAGEMENT}/classifications`); + }, + /** + * @function getFormRetentionPolicy + * Gets the retention policy for a form + * @param {string} formId The form ID + * @returns {Promise} An axios response + */ + getFormRetentionPolicy(formId) { + return appAxios().get( + `${ApiRoutes.RECORDS_MANAGEMENT}/containers/${formId}/policies` + ); + }, + /** + * @function configureFormRetentionPolicy + * Sets/updates the retention policy for a form + * @param {string} formId The form ID + * @param {Object} policyData The retention policy data + * @returns {Promise} An axios response + */ + configureFormRetentionPolicy(formId, policyData) { + return appAxios().post( + `${ApiRoutes.RECORDS_MANAGEMENT}/containers/${formId}/policies`, + policyData + ); + }, + /** + * @function scheduleSubmissionDeletion + * Schedules a submission for deletion based on the form's retention policy + * @param {string} formSubmissionId The form submission ID + * @returns {Promise} An axios response + */ + scheduleSubmissionDeletion(formSubmissionId, formId) { + return appAxios().post( + `${ApiRoutes.RECORDS_MANAGEMENT}/assets/${formSubmissionId}/schedule`, + { + formId: formId, + } + ); + }, + /** + * @function cancelScheduledSubmissionDeletion + * Cancels a scheduled deletion for a submission + * @param {string} formSubmissionId The form submission ID + * @returns {Promise} An axios response + */ + cancelScheduledSubmissionDeletion(formSubmissionId) { + return appAxios().delete( + `${ApiRoutes.RECORDS_MANAGEMENT}/assets/${formSubmissionId}/schedule` + ); + }, +}; diff --git a/app/frontend/src/store/form.js b/app/frontend/src/store/form.js index 9494d4774..2a5fd9872 100644 --- a/app/frontend/src/store/form.js +++ b/app/frontend/src/store/form.js @@ -9,6 +9,7 @@ import { userService, encryptionKeyService, eventStreamConfigService, + recordsManagementService, } from '~/services'; import { useNotificationStore } from '~/store/notification'; import { IdentityMode, NotificationTypes } from '~/utils/constants'; @@ -525,10 +526,14 @@ export const useFormStore = defineStore('form', { // // Submission // - async deleteSubmission(submissionId) { + async deleteSubmission(formId, submissionId) { try { // Get this submission await formService.deleteSubmission(submissionId); + await recordsManagementService.scheduleSubmissionDeletion( + submissionId, + formId + ); const notificationStore = useNotificationStore(); notificationStore.addNotification({ text: i18n.t('trans.store.form.deleteSubmissionNotifyMsg'), @@ -552,6 +557,12 @@ export const useFormStore = defineStore('form', { await formService.deleteMultipleSubmissions(submissionIds[0], formId, { data: { submissionIds: submissionIds }, }); + for (let subId of submissionIds) { + await recordsManagementService.scheduleSubmissionDeletion( + subId, + formId + ); + } notificationStore.addNotification({ text: i18n.t('trans.store.form.deleteSubmissionsNotifyMsg'), ...NotificationTypes.SUCCESS, @@ -573,6 +584,9 @@ export const useFormStore = defineStore('form', { await formService.restoreMultipleSubmissions(submissionIds[0], formId, { submissionIds: submissionIds, }); + for (let subId of submissionIds) { + await recordsManagementService.restoreMultipleSubmissions(subId); + } notificationStore.addNotification({ text: i18n.t('trans.store.form.restoreSubmissionsNotiMsg'), ...NotificationTypes.SUCCESS, @@ -595,6 +609,9 @@ export const useFormStore = defineStore('form', { try { // Get this submission await formService.restoreSubmission(submissionId, { deleted }); + await recordsManagementService.cancelScheduledSubmissionDeletion( + submissionId + ); notificationStore.addNotification({ text: i18n.t('trans.store.form.deleteSubmissionsNotifyMsg'), ...NotificationTypes.SUCCESS, diff --git a/app/frontend/src/store/recordsManagement.js b/app/frontend/src/store/recordsManagement.js new file mode 100644 index 000000000..0827994a4 --- /dev/null +++ b/app/frontend/src/store/recordsManagement.js @@ -0,0 +1,65 @@ +import { defineStore } from 'pinia'; + +import { i18n } from '~/internationalization'; +import { recordsManagementService } from '~/services'; +import { useNotificationStore } from '~/store/notification'; + +const getInitialRetentionPolicy = () => ({ + retentionDays: null, + retentionClassificationId: null, + retentionClassificationDescription: null, +}); + +export const useRecordsManagementStore = defineStore('recordsManagement', { + state: () => ({ + formRetentionPolicy: getInitialRetentionPolicy(), + }), + actions: { + async getFormRetentionPolicy(formId) { + try { + const response = await recordsManagementService.getFormRetentionPolicy( + formId + ); + this.formRetentionPolicy = response.data; + } catch (error) { + const notificationStore = useNotificationStore(); + // We ignore 404 errors because it just means a policy doesn't exist yet + if (error.response && error.response.status === 404) { + // Reset to initial state so we don't show previous form's policy + this.formRetentionPolicy = getInitialRetentionPolicy(); + return; + } + notificationStore.addNotification({ + text: i18n.t( + 'trans.store.recordsManagement.getFormRetentionPolicyErrMsg' + ), + consoleError: i18n.t( + 'trans.store.recordsManagement.getFormRetentionPolicyConsErrMsg', + { formId: formId, error: error } + ), + }); + } + }, + async configureRetentionPolicy(formId) { + try { + const response = + await recordsManagementService.configureFormRetentionPolicy( + formId, + this.formRetentionPolicy + ); + this.formRetentionPolicy = response.data; + } catch (error) { + const notificationStore = useNotificationStore(); + notificationStore.addNotification({ + text: i18n.t( + 'trans.store.recordsManagement.configureFormRetentionPolicyErrMsg' + ), + consoleError: i18n.t( + 'trans.store.recordsManagement.configureFormRetentionPolicyConsErrMsg', + { formId: formId, error: error } + ), + }); + } + }, + }, +}); diff --git a/app/frontend/src/utils/constants.js b/app/frontend/src/utils/constants.js index d7b768a5d..09f0a3243 100755 --- a/app/frontend/src/utils/constants.js +++ b/app/frontend/src/utils/constants.js @@ -19,6 +19,7 @@ export const ApiRoutes = Object.freeze({ FORM_METADATA: '/formMetadata', EVENT_STREAM_CONFIG: '/eventStreamConfig', ENCRYPTION_KEY: '/encryptionKey', + RECORDS_MANAGEMENT: '/recordsManagement', }); /** Roles a user can have on a form. These are defined in the DB and sent from the API */ diff --git a/app/frontend/tests/unit/components/designer/settings/FormClassificationSettings.spec.js b/app/frontend/tests/unit/components/designer/settings/FormClassificationSettings.spec.js new file mode 100644 index 000000000..d03a6250e --- /dev/null +++ b/app/frontend/tests/unit/components/designer/settings/FormClassificationSettings.spec.js @@ -0,0 +1,296 @@ +import { createTestingPinia } from '@pinia/testing'; +import { mount } from '@vue/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { nextTick } from 'vue'; + +import { useFormStore } from '~/store/form'; +import { useRecordsManagementStore } from '~/store/recordsManagement'; +import FormClassificationSettings from '~/components/designer/settings/FormClassificationSettings.vue'; + +// Mock the services +vi.mock('~/services', async () => ({ + recordsManagementService: { + listRetentionClassificationTypes: vi.fn().mockResolvedValue({ + data: [ + { id: 'class-1', display: 'Public' }, + { id: 'class-2', display: 'Internal' }, + { id: 'class-3', display: 'Confidential' }, + ], + }), + }, +})); + +describe('FormClassificationSettings.vue', async () => { + let pinia; + let formStore; + let recordsManagementStore; + + beforeEach(() => { + pinia = createTestingPinia(); + formStore = useFormStore(pinia); + recordsManagementStore = useRecordsManagementStore(pinia); + + // Initialize stores with proper defaults BEFORE anything else + formStore.form = { + id: '123', + name: 'Test Form', + }; + formStore.isRTL = false; + + // Initialize classification types FIRST + recordsManagementStore.retentionClassificationTypes = [ + { id: 'class-1', display: 'Public' }, + { id: 'class-2', display: 'Internal' }, + { id: 'class-3', display: 'Confidential' }, + ]; + + // Then initialize retention policy + recordsManagementStore.formRetentionPolicy = { + formId: '123', + retentionDays: null, + retentionClassificationId: null, + retentionClassificationDescription: null, + }; + }); + + it('renders with default values when hard deletion is disabled', async () => { + const wrapper = await mount(FormClassificationSettings, { + global: { + plugins: [pinia], + stubs: { + BasePanel: { + name: 'BasePanel', + template: + '
', + props: ['title'], + }, + }, + mocks: { + $t: (key) => key, + $i18n: { locale: 'en' }, + }, + }, + }); + + expect(wrapper.find('div.base-panel-stub').exists()).toBe(true); + }); + + it('shows classification fields when hard deletion is enabled', async () => { + const wrapper = await mount(FormClassificationSettings, { + global: { + plugins: [pinia], + stubs: { + BasePanel: { + name: 'BasePanel', + template: + '
', + props: ['title'], + }, + 'v-checkbox': { + template: + '
', + props: ['modelValue'], + emits: ['update:model-value'], + }, + 'v-combobox': true, + 'v-select': true, + 'v-text-field': true, + 'v-textarea': true, + 'v-alert': true, + 'v-tooltip': true, + 'v-icon': true, + }, + mocks: { + $t: (key) => key, + $i18n: { locale: 'en' }, + }, + }, + }); + + const checkbox = wrapper.find('input[type="checkbox"]'); + await checkbox.setValue(true); + await nextTick(); + + expect( + recordsManagementStore.formRetentionPolicy.retentionClassificationId + ).toBe('class-1'); + expect(recordsManagementStore.formRetentionPolicy.retentionDays).toBe(30); + }); + + it('resets classification fields when hard deletion is disabled', async () => { + recordsManagementStore.formRetentionPolicy = { + formId: '123', + retentionDays: 90, + retentionClassificationId: 'class-3', + retentionClassificationDescription: 'Test description', + }; + + const wrapper = await mount(FormClassificationSettings, { + global: { + plugins: [pinia], + stubs: { + BasePanel: { + name: 'BasePanel', + template: + '
', + props: ['title'], + }, + 'v-checkbox': { + template: + '
', + props: ['modelValue'], + emits: ['update:model-value'], + }, + 'v-combobox': true, + 'v-select': true, + 'v-textarea': true, + 'v-alert': true, + 'v-tooltip': true, + 'v-icon': true, + }, + mocks: { + $t: (key) => key, + $i18n: { locale: 'en' }, + }, + }, + }); + + const checkbox = wrapper.find('input[type="checkbox"]'); + // First enable it + await checkbox.setValue(true); + await nextTick(); + // Then disable it + await checkbox.setValue(false); + await nextTick(); + + expect(recordsManagementStore.formRetentionPolicy.retentionDays).toBeNull(); + expect( + recordsManagementStore.formRetentionPolicy.retentionClassificationId + ).toBeNull(); + }); + + it('shows warning alert when retention days are set', async () => { + recordsManagementStore.formRetentionPolicy = { + formId: '123', + retentionDays: 180, + retentionClassificationId: 'class-1', + retentionClassificationDescription: null, + }; + + const wrapper = await mount(FormClassificationSettings, { + global: { + plugins: [pinia], + stubs: { + BasePanel: { + name: 'BasePanel', + template: + '
', + props: ['title'], + }, + 'v-checkbox': true, + 'v-combobox': true, + 'v-select': true, + 'v-textarea': true, + 'v-alert': { + template: + '
', + props: ['type', 'variant'], + }, + 'v-tooltip': true, + 'v-icon': true, + }, + mocks: { + $t: (key) => key, + $i18n: { locale: 'en' }, + }, + }, + }); + + // Set enableHardDeletion to true since retention data exists + wrapper.vm.enableHardDeletion = true; + await wrapper.vm.$nextTick(); + + const alert = wrapper.find('.v-alert-stub[data-type="warning"]'); + expect(alert.exists()).toBe(true); + }); + + it('handles custom retention days selection', async () => { + const wrapper = await mount(FormClassificationSettings, { + global: { + plugins: [pinia], + stubs: { + BasePanel: { + name: 'BasePanel', + template: + '
', + props: ['title'], + }, + 'v-checkbox': { + template: + '
', + props: ['modelValue'], + emits: ['update:model-value'], + }, + 'v-combobox': true, + 'v-select': true, + 'v-text-field': true, + 'v-textarea': true, + 'v-alert': true, + 'v-tooltip': true, + 'v-icon': true, + }, + mocks: { + $t: (key) => key, + $i18n: { locale: 'en' }, + }, + }, + }); + + const checkbox = wrapper.find('input[type="checkbox"]'); + await checkbox.setValue(true); + await nextTick(); + + expect( + recordsManagementStore.formRetentionPolicy.retentionDays + ).toBeGreaterThan(0); + }); + + it('initializes correctly with existing form data', async () => { + recordsManagementStore.formRetentionPolicy = { + formId: '123', + retentionDays: 365, + retentionClassificationId: 'class-3', + retentionClassificationDescription: 'Contains personal information', + }; + + mount(FormClassificationSettings, { + global: { + plugins: [pinia], + stubs: { + BasePanel: { + name: 'BasePanel', + template: + '
', + props: ['title'], + }, + 'v-checkbox': true, + 'v-combobox': true, + 'v-select': true, + 'v-textarea': true, + 'v-alert': true, + 'v-tooltip': true, + 'v-icon': true, + }, + mocks: { + $t: (key) => key, + $i18n: { locale: 'en' }, + }, + }, + }); + + expect(recordsManagementStore.formRetentionPolicy.retentionDays).toBe(365); + expect( + recordsManagementStore.formRetentionPolicy.retentionClassificationId + ).toBe('class-3'); + }); +}); diff --git a/app/frontend/tests/unit/store/modules/form.actions.spec.js b/app/frontend/tests/unit/store/modules/form.actions.spec.js index b7b7c60a5..10a3a55e9 100644 --- a/app/frontend/tests/unit/store/modules/form.actions.spec.js +++ b/app/frontend/tests/unit/store/modules/form.actions.spec.js @@ -239,7 +239,7 @@ describe('form actions', () => { formService.deleteSubmission.mockResolvedValue({ data: { submission: {}, form: {} }, }); - await mockStore.deleteSubmission('sId'); + await mockStore.deleteSubmission('fId', 'sId'); expect(formService.deleteSubmission).toHaveBeenCalledTimes(1); expect(formService.deleteSubmission).toHaveBeenCalledWith('sId'); @@ -247,7 +247,7 @@ describe('form actions', () => { it('deleteSubmission should dispatch to notifications/addNotification', async () => { formService.deleteSubmission.mockRejectedValue(''); - await mockStore.deleteSubmission('sId'); + await mockStore.deleteSubmission('fId', 'sId'); expect(addNotificationSpy).toHaveBeenCalledTimes(1); expect(addNotificationSpy).toHaveBeenCalledWith(expect.any(Object)); diff --git a/app/frontend/tests/unit/utils/constants.spec.js b/app/frontend/tests/unit/utils/constants.spec.js index d8446657c..2b7ec3b62 100644 --- a/app/frontend/tests/unit/utils/constants.spec.js +++ b/app/frontend/tests/unit/utils/constants.spec.js @@ -19,6 +19,7 @@ describe('Constants', () => { FORM_METADATA: '/formMetadata', ENCRYPTION_KEY: '/encryptionKey', EVENT_STREAM_CONFIG: '/eventStreamConfig', + RECORDS_MANAGEMENT: '/recordsManagement', }); }); diff --git a/app/src/db/migrations/20251211165912_form-deletion-audit.js b/app/src/db/migrations/20251211165912_form-deletion-audit.js new file mode 100644 index 000000000..f4fd44838 --- /dev/null +++ b/app/src/db/migrations/20251211165912_form-deletion-audit.js @@ -0,0 +1,182 @@ +const { v4: uuidv4 } = require('uuid'); +const stamps = require('../stamps'); + +const CREATED_BY = 'form-deletion-audit'; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return Promise.resolve() + .then(() => + knex.schema.createTable('retention_classification', (table) => { + table.uuid('id').primary(); + table.string('code').notNullable().unique().comment('Unique code (internal, public, sensitive, confidential, protected_a, protected_b, protected_c)'); + table.string('display').notNullable().comment('Display name for UI'); + table.text('description').nullable().comment('Description of classification'); + table.boolean('active').defaultTo(true); + stamps(knex, table); + }) + ) + .then(() => + knex('retention_classification').insert([ + { + id: uuidv4(), + code: 'public', + display: 'Public', + description: 'Public information that can be freely shared', + active: true, + createdBy: CREATED_BY, + }, + { + id: uuidv4(), + code: 'internal', + display: 'Internal', + description: 'Internal BC government use only', + active: true, + createdBy: CREATED_BY, + }, + { + id: uuidv4(), + code: 'sensitive', + display: 'Sensitive', + description: 'Sensitive information requiring careful handling', + active: true, + createdBy: CREATED_BY, + }, + { + id: uuidv4(), + code: 'confidential', + display: 'Confidential', + description: 'Confidential information with restricted access', + active: true, + createdBy: CREATED_BY, + }, + { + id: uuidv4(), + code: 'protected_a', + display: 'Protected A', + description: 'Protected A - Highest confidentiality requiring strong controls', + active: true, + createdBy: CREATED_BY, + }, + { + id: uuidv4(), + code: 'protected_b', + display: 'Protected B', + description: 'Protected B - Confidential requiring enhanced controls', + active: true, + createdBy: CREATED_BY, + }, + { + id: uuidv4(), + code: 'protected_c', + display: 'Protected C', + description: 'Protected C - Confidential requiring standard controls', + active: true, + createdBy: CREATED_BY, + }, + ]) + ) + .then(() => + knex.schema.createTable('retention_policy', (table) => { + table.uuid('id').primary().defaultTo(uuidv4()); + table.uuid('formId').notNullable().unique().references('id').inTable('form').comment('Form ID this policy applies to'); + table.integer('retentionDays').nullable().comment('Days before hard deletion allowed (null = indefinite, default behavior)'); + table.uuid('retentionClassificationId').nullable().references('id').inTable('retention_classification').comment('Retention classification'); + table.string('retentionClassificationDescription').nullable().comment('Custom description for the retention classification'); + stamps(knex, table); + + table.index(['formId']); + table.index(['retentionClassificationId']); + }) + ) + .then(() => + knex.schema.createTable('scheduled_submission_deletion', (table) => { + table.uuid('id').primary().defaultTo(uuidv4()); + table.uuid('submissionId').notNullable().unique().references('id').inTable('form_submission').onDelete('CASCADE').comment('Submission eligible for deletion'); + table.uuid('formId').notNullable().references('id').inTable('form').comment('Form this submission belongs to'); + table.timestamp('eligibleForDeletionAt').notNullable().comment('Date/time when submission is eligible for hard deletion'); + table.string('status').defaultTo('pending').comment('pending, processing, completed, failed'); + table.text('failureReason').nullable().comment('Reason if deletion failed'); + stamps(knex, table); + + table.index(['formId']); + table.index(['eligibleForDeletionAt']); + table.index(['status']); + table.index(['submissionId']); + }) + ) + .then(() => + knex.schema.raw(`CREATE OR REPLACE FUNCTION public.submission_audited_func() RETURNS trigger AS $body$ + DECLARE + v_old_data json; + BEGIN + if (TG_OP = 'UPDATE') then + v_old_data := row_to_json(OLD); + insert into public.form_submission_audit ("submissionId", "dbUser", "updatedByUsername", "actionTimestamp", "action", "originalData") + values ( + OLD.id, + SESSION_USER, + NEW."updatedBy", + now(), + 'U', + v_old_data); + RETURN NEW; + elsif (TG_OP = 'DELETE') then + v_old_data := row_to_json(OLD); + insert into public.form_submission_audit ("submissionId", "dbUser", "actionTimestamp", "action", "originalData") + values ( + OLD.id, + SESSION_USER, + now(), + 'D', + v_old_data); + RETURN OLD; + end if; + RETURN NULL; + END; + $body$ LANGUAGE plpgsql`) + ); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return Promise.resolve() + .then(() => + knex.schema.raw(`CREATE OR REPLACE FUNCTION public.submission_audited_func() RETURNS trigger AS $body$ + DECLARE + v_old_data json; + BEGIN + if (TG_OP = 'UPDATE') then + v_old_data := row_to_json(OLD); + insert into public.form_submission_audit ("submissionId", "dbUser", "updatedByUsername", "actionTimestamp", "action", "originalData") + values ( + OLD.id, + SESSION_USER, + NEW."updatedBy", + now(), + 'U', + v_old_data); + RETURN NEW; + elsif (TG_OP = 'DELETE') then + v_old_data := row_to_json(OLD); + insert into public.form_submission_audit ("submissionId", "dbUser", "actionTimestamp", "action", "originalData") + values ( + OLD.id, + SESSION_USER, + now(), + 'D', + v_old_data); + end if; + END; + $body$ LANGUAGE plpgsql`) + ) + .then(() => knex.schema.dropTableIfExists('scheduled_submission_deletion')) + .then(() => knex.schema.dropTableIfExists('retention_policy')) + .then(() => knex.schema.dropTableIfExists('retention_classification')); +}; diff --git a/app/src/forms/common/models/index.js b/app/src/forms/common/models/index.js index 2a30db4e3..3062d24de 100644 --- a/app/src/forms/common/models/index.js +++ b/app/src/forms/common/models/index.js @@ -30,6 +30,9 @@ module.exports = { FormMetadata: require('./tables/formMetadata'), FormEncryptionKey: require('./tables/formEncryptionKey'), FormEventStreamConfig: require('./tables/formEventStreamConfig'), + RetentionClassification: require('./tables/retentionClassification'), + RetentionPolicy: require('./tables/retentionPolicy'), + ScheduledSubmissionDeletion: require('./tables/scheduledSubmissionDeletion'), // Views FormSubmissionUserPermissions: require('./views/formSubmissionUserPermissions'), diff --git a/app/src/forms/common/models/tables/retentionClassification.js b/app/src/forms/common/models/tables/retentionClassification.js new file mode 100644 index 000000000..b92823682 --- /dev/null +++ b/app/src/forms/common/models/tables/retentionClassification.js @@ -0,0 +1,41 @@ +const { Model } = require('objection'); +const { Timestamps } = require('../mixins'); +const stamps = require('../jsonSchema').stamps; + +class RetentionClassification extends Timestamps(Model) { + static get tableName() { + return 'retention_classification'; + } + + static get relationMappings() { + const RetentionPolicy = require('./retentionPolicy'); + return { + policies: { + relation: Model.HasManyRelation, + modelClass: RetentionPolicy, + join: { + from: 'retention_classification.id', + to: 'retention_policy.retentionClassificationId', + }, + }, + }; + } + + static get jsonSchema() { + return { + type: 'object', + required: ['code', 'display'], + properties: { + id: { type: 'string' }, + code: { type: 'string', minLength: 1, maxLength: 50 }, + display: { type: 'string', minLength: 1, maxLength: 255 }, + description: { type: ['string', 'null'] }, + active: { type: 'boolean' }, + ...stamps, + }, + additionalProperties: false, + }; + } +} + +module.exports = RetentionClassification; diff --git a/app/src/forms/common/models/tables/retentionPolicy.js b/app/src/forms/common/models/tables/retentionPolicy.js new file mode 100644 index 000000000..73fde1208 --- /dev/null +++ b/app/src/forms/common/models/tables/retentionPolicy.js @@ -0,0 +1,51 @@ +const { Model } = require('objection'); +const { Timestamps } = require('../mixins'); +const { Regex } = require('../../constants'); +const stamps = require('../jsonSchema').stamps; + +class RetentionPolicy extends Timestamps(Model) { + static get tableName() { + return 'retention_policy'; + } + + static get relationMappings() { + const Form = require('./form'); + const RetentionClassification = require('./retentionClassification'); + return { + form: { + relation: Model.BelongsToOneRelation, + modelClass: Form, + join: { + from: 'retention_policy.formId', + to: 'form.id', + }, + }, + classification: { + relation: Model.BelongsToOneRelation, + modelClass: RetentionClassification, + join: { + from: 'retention_policy.retentionClassificationId', + to: 'retention_classification.id', + }, + }, + }; + } + + static get jsonSchema() { + return { + type: 'object', + required: ['formId'], + properties: { + id: { type: 'string', pattern: Regex.UUID }, + formId: { type: 'string', pattern: Regex.UUID }, + retentionDays: { type: ['integer', 'null'] }, + retentionClassificationId: { type: ['string', 'null'], pattern: Regex.UUID }, + retentionClassificationDescription: { type: ['string', 'null'] }, + ...stamps, + }, + additionalProperties: false, + }; + } +} + +module.exports = RetentionPolicy; diff --git a/app/src/forms/common/models/tables/scheduledSubmissionDeletion.js b/app/src/forms/common/models/tables/scheduledSubmissionDeletion.js new file mode 100644 index 000000000..b838a1deb --- /dev/null +++ b/app/src/forms/common/models/tables/scheduledSubmissionDeletion.js @@ -0,0 +1,52 @@ +const { Model } = require('objection'); +const { Timestamps } = require('../mixins'); +const { Regex } = require('../../constants'); +const stamps = require('../jsonSchema').stamps; + +class ScheduledSubmissionDeletion extends Timestamps(Model) { + static get tableName() { + return 'scheduled_submission_deletion'; + } + + static get relationMappings() { + const FormSubmission = require('./formSubmission'); + const Form = require('./form'); + return { + submission: { + relation: Model.BelongsToOneRelation, + modelClass: FormSubmission, + join: { + from: 'scheduled_submission_deletion.submissionId', + to: 'form_submission.id', + }, + }, + form: { + relation: Model.BelongsToOneRelation, + modelClass: Form, + join: { + from: 'scheduled_submission_deletion.formId', + to: 'form.id', + }, + }, + }; + } + + static get jsonSchema() { + return { + type: 'object', + required: ['submissionId', 'formId', 'eligibleForDeletionAt'], + properties: { + id: { type: 'string', pattern: Regex.UUID }, + submissionId: { type: 'string', pattern: Regex.UUID }, + formId: { type: 'string', pattern: Regex.UUID }, + eligibleForDeletionAt: { type: 'string' }, + status: { type: 'string', enum: ['pending', 'processing', 'completed', 'failed'] }, + failureReason: { type: ['string', 'null'] }, + ...stamps, + }, + additionalProperties: false, + }; + } +} + +module.exports = ScheduledSubmissionDeletion; diff --git a/app/src/forms/recordsManagement/controller.js b/app/src/forms/recordsManagement/controller.js new file mode 100644 index 000000000..4e83032d8 --- /dev/null +++ b/app/src/forms/recordsManagement/controller.js @@ -0,0 +1,94 @@ +const service = require('./service'); +const log = require('../../components/log')(module.filename); + +module.exports = { + listRetentionClassifications: async (req, res, next) => { + try { + const classifications = await service.listRetentionClassifications(); + res.status(200).json(classifications); + } catch (err) { + log.error('listRetentionClassifications error', err); + next(err); + } + }, + + scheduleDeletion: async (req, res, next) => { + try { + const { formSubmissionId } = req.params; + const { formId } = req.body; + const user = req.currentUser?.usernameIdp || 'system'; + const result = await service.scheduleDeletion(formSubmissionId, formId, user); + res.status(200).json(result); + } catch (err) { + if (err.name === 'NotFoundError') { + // Ignore because there is no retention policy + return res.status(204).send(); + } + log.error('scheduleDeletion error', err); + next(err); + } + }, + + cancelDeletion: async (req, res, next) => { + try { + const { formSubmissionId } = req.params; + const result = await service.cancelDeletion(formSubmissionId); + res.status(200).json(result); + } catch (err) { + if (err.name === 'NotFoundError') { + // Ignore because there is no retention policy + return res.status(204).send(); + } + log.error('cancelDeletion error', err); + next(err); + } + }, + + getPolicy: async (req, res, next) => { + try { + const { formId } = req.params; + const policy = await service.getRetentionPolicy(formId); + res.status(200).json(policy); + } catch (err) { + log.error('getPolicy error', err); + next(err); + } + }, + + setPolicy: async (req, res, next) => { + try { + const { formId } = req.params; + const user = req.currentUser?.usernameIdp || 'system'; + const result = await service.configureRetentionPolicy(formId, req.body, user); + res.status(200).json(result); + } catch (err) { + log.error('setPolicy error', err); + next(err); + } + }, + + processDeletions: async (req, res, next) => { + try { + const batchSize = req.body?.batchSize || 100; + const result = await service.processDeletions(batchSize); + res.status(200).json(result); + } catch (err) { + log.error('processDeletions error', err); + next(err); + } + }, + + processDeletionsWebhook: async (req, res, next) => { + try { + const { submissionIds = [] } = req.body; + if (!Array.isArray(submissionIds)) { + return res.status(400).json({ error: 'submissionIds must be an array' }); + } + const results = await service.hardDeleteSubmissions(submissionIds); + res.status(200).json({ results }); + } catch (err) { + log.error('processDeletionsWebhook error', err); + next(err); + } + }, +}; diff --git a/app/src/forms/recordsManagement/index.js b/app/src/forms/recordsManagement/index.js new file mode 100644 index 000000000..fc023c44e --- /dev/null +++ b/app/src/forms/recordsManagement/index.js @@ -0,0 +1,12 @@ +/** + * Records Management Module + */ + +const setupMount = require('../common/utils').setupMount; +const routes = require('./routes'); + +const _PATH = 'recordsManagement'; + +module.exports.mount = (app) => { + return setupMount(_PATH, app, routes); +}; diff --git a/app/src/forms/recordsManagement/localService.js b/app/src/forms/recordsManagement/localService.js new file mode 100644 index 000000000..f2d6feb2b --- /dev/null +++ b/app/src/forms/recordsManagement/localService.js @@ -0,0 +1,123 @@ +const uuid = require('uuid'); +const { RetentionClassification, RetentionPolicy, ScheduledSubmissionDeletion } = require('../common/models'); +const submissionService = require('../submission/service'); +const log = require('../../components/log')(module.filename); + +const service = { + listRetentionClassifications: async () => { + return await RetentionClassification.query().where('active', true); + }, + + getRetentionPolicy: async (formId) => { + const policy = await RetentionPolicy.query().findOne({ formId }).withGraphFetched('classification').throwIfNotFound(); + return { + formId: policy.formId, + retentionDays: policy.retentionDays, + retentionClassificationId: policy.retentionClassificationId, + retentionClassificationDescription: policy.retentionClassificationDescription, + }; + }, + + configureRetentionPolicy: async (formId, policyData, user) => { + const { retentionDays, retentionClassificationId, retentionClassificationDescription } = policyData; + const existing = await RetentionPolicy.query().findOne({ formId }); + + if (existing) { + return await RetentionPolicy.query().patchAndFetchById(existing.id, { + retentionDays, + retentionClassificationId, + retentionClassificationDescription, + updatedBy: user, + updatedAt: new Date().toISOString(), + }); + } else { + return await RetentionPolicy.query().insert({ + id: uuid.v4(), + formId, + retentionDays, + retentionClassificationId, + retentionClassificationDescription, + createdBy: user, + }); + } + }, + + scheduleDeletion: async (submissionId, formId, user) => { + const policy = await service.getRetentionPolicy(formId); + + let eligibleForDeletionAt; + if (policy.retentionDays === null) { + // Indefinite retention + eligibleForDeletionAt = new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000).toISOString(); + } else { + eligibleForDeletionAt = new Date(Date.now() + policy.retentionDays * 24 * 60 * 60 * 1000).toISOString(); + } + + return await ScheduledSubmissionDeletion.query().insert({ + id: uuid.v4(), + submissionId, + formId, + eligibleForDeletionAt, + status: 'pending', + createdBy: user, + }); + }, + + cancelDeletion: async (submissionId) => { + const deleted = await ScheduledSubmissionDeletion.query().where('submissionId', submissionId).delete(); + return { submissionId, cancelled: deleted > 0 }; + }, + + processDeletions: async (batchSize = 100) => { + try { + const eligible = await ScheduledSubmissionDeletion.query().where('status', 'pending').where('eligibleForDeletionAt', '<=', new Date()).limit(batchSize); + + if (eligible.length === 0) { + return { processed: 0, results: [] }; + } + + const results = []; + for (const scheduled of eligible) { + try { + await ScheduledSubmissionDeletion.query().patchAndFetchById(scheduled.id, { status: 'processing' }); + await submissionService.deleteSubmissionAndRelatedData(scheduled.submissionId); + await ScheduledSubmissionDeletion.query().patchAndFetchById(scheduled.id, { status: 'completed' }); + results.push({ submissionId: scheduled.submissionId, status: 'completed' }); + } catch (err) { + log.error(`Failed to delete submission ${scheduled.submissionId}`, err); + await ScheduledSubmissionDeletion.query().patchAndFetchById(scheduled.id, { + status: 'failed', + failureReason: err.message, + }); + results.push({ submissionId: scheduled.submissionId, status: 'failed', error: err.message }); + } + } + + return { processed: results.length, results }; + } catch (err) { + log.error('Error processing deletions', err); + throw err; + } + }, + + hardDeleteSubmissions: async (submissionIds) => { + const results = []; + for (const submissionId of submissionIds) { + try { + await submissionService.deleteSubmissionAndRelatedData(submissionId); + await ScheduledSubmissionDeletion.query().where('submissionId', submissionId).update({ status: 'completed' }); + results.push({ submissionId, status: 'completed' }); + } catch (err) { + log.error(`Failed to delete submission ${submissionId}`, err); + await ScheduledSubmissionDeletion.query().where('submissionId', submissionId).update({ + status: 'failed', + failureReason: err.message, + }); + results.push({ submissionId, status: 'failed', error: err.message }); + } + } + return results; + }, +}; + +module.exports = service; diff --git a/app/src/forms/recordsManagement/routes.js b/app/src/forms/recordsManagement/routes.js new file mode 100644 index 000000000..6c23a6845 --- /dev/null +++ b/app/src/forms/recordsManagement/routes.js @@ -0,0 +1,33 @@ +const routes = require('express').Router(); + +const apiAccess = require('../public/middleware/apiAccess'); +const { currentUser, hasSubmissionPermissions, hasFormPermissions } = require('../auth/middleware/userAccess'); +const P = require('../common/constants').Permissions; +const validateParameter = require('../common/middleware/validateParameter'); +const controller = require('./controller'); + +routes.param('formId', validateParameter.validateFormId); +routes.param('formSubmissionId', validateParameter.validateFormSubmissionId); + +// Gets the available retention classifications +routes.get('/classifications', controller.listRetentionClassifications); + +// Schedule a submission for deletion +routes.post('/assets/:formSubmissionId/schedule', currentUser, hasSubmissionPermissions([P.SUBMISSION_DELETE]), controller.scheduleDeletion); + +// Cancel a scheduled deletion +routes.delete('/assets/:formSubmissionId/schedule', currentUser, hasSubmissionPermissions([P.SUBMISSION_DELETE]), controller.cancelDeletion); + +// Get retention policy for a form +routes.get('/containers/:formId/policies', currentUser, hasFormPermissions([P.FORM_READ]), controller.getPolicy); + +// Set/update retention policy for a form +routes.post('/containers/:formId/policies', currentUser, hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), controller.setPolicy); + +// Internal: process eligible deletions (called by cron) +routes.post('/internal/deletions/process', apiAccess.checkApiKey, controller.processDeletions); + +// Webhook: external service calls to delete a batch (Stage 2) +routes.post('/webhooks/process-deletions', controller.processDeletionsWebhook); + +module.exports = routes; diff --git a/app/src/forms/recordsManagement/service.js b/app/src/forms/recordsManagement/service.js new file mode 100644 index 000000000..30f3a3a20 --- /dev/null +++ b/app/src/forms/recordsManagement/service.js @@ -0,0 +1,58 @@ +const config = require('config'); +const localService = require('./localService'); +// future: const externalService = require('./externalService'); + +const implementation = (config.has('recordsManagement.implementation') && config.get('recordsManagement.implementation')) || 'local'; + +const service = { + listRetentionClassifications: async () => { + if (implementation === 'local') { + return localService.listRetentionClassifications(); + } + throw new Error(`RecordsManagement implementation '${implementation}' not available`); + }, + + getRetentionPolicy: async (formId) => { + if (implementation === 'local') { + return localService.getRetentionPolicy(formId); + } + throw new Error(`RecordsManagement implementation '${implementation}' not available`); + }, + + configureRetentionPolicy: async (formId, policyData, user) => { + if (implementation === 'local') { + return localService.configureRetentionPolicy(formId, policyData, user); + } + throw new Error(`RecordsManagement implementation '${implementation}' not available`); + }, + + scheduleDeletion: async (submissionId, formId, user) => { + if (implementation === 'local') { + return localService.scheduleDeletion(submissionId, formId, user); + } + throw new Error(`RecordsManagement implementation '${implementation}' not available`); + }, + + cancelDeletion: async (submissionId) => { + if (implementation === 'local') { + return localService.cancelDeletion(submissionId); + } + throw new Error(`RecordsManagement implementation '${implementation}' not available`); + }, + + processDeletions: async (batchSize = 100) => { + if (implementation === 'local') { + return localService.processDeletions(batchSize); + } + throw new Error(`RecordsManagement implementation '${implementation}' not available`); + }, + + hardDeleteSubmissions: async (submissionIds) => { + if (implementation === 'local') { + return localService.hardDeleteSubmissions(submissionIds); + } + throw new Error(`RecordsManagement implementation '${implementation}' not available`); + }, +}; + +module.exports = service; diff --git a/app/src/forms/submission/service.js b/app/src/forms/submission/service.js index 21a8b311d..1dee6e3ba 100644 --- a/app/src/forms/submission/service.js +++ b/app/src/forms/submission/service.js @@ -1,7 +1,7 @@ const uuid = require('uuid'); const { Statuses } = require('../common/constants'); -const { Form, FormVersion, FormSubmission, FormSubmissionStatus, Note, SubmissionAudit, SubmissionMetadata } = require('../common/models'); +const { Form, FormVersion, FormSubmission, FormSubmissionStatus, FormSubmissionUser, FileStorage, Note, SubmissionAudit, SubmissionMetadata } = require('../common/models'); const formService = require('../form/service'); const permissionService = require('../permission/service'); const { eventStreamService, SUBMISSION_EVENT_TYPES } = require('../../components/eventStreamService'); @@ -142,9 +142,11 @@ const service = { let result; try { trx = await FormSubmission.startTransaction(); + const now = new Date().toISOString(); await FormSubmission.query(trx).patchAndFetchById(formSubmissionId, { deleted: true, updatedBy: currentUser.usernameIdp, + updatedAt: now, }); await trx.commit(); result = await service.read(formSubmissionId); @@ -158,11 +160,48 @@ const service = { } }, + /** + * Hard delete a submission and all its related data + * + * @param {string} submissionId - The ID of the submission to delete + * @returns {Object} Result indicating success or failure + */ + deleteSubmissionAndRelatedData: async (submissionId) => { + let trx; + try { + trx = await FormSubmission.startTransaction(); + + // Delete in the correct order based on foreign key dependencies + + // 1. Delete notes + await Note.query(trx).where('submissionId', submissionId).delete(); + + // 2. Delete status records + await FormSubmissionStatus.query(trx).where('submissionId', submissionId).delete(); + + // 3. Delete user associations + await FormSubmissionUser.query(trx).where('formSubmissionId', submissionId).delete(); + + // 4. Delete file records + await FileStorage.query(trx).where('formSubmissionId', submissionId).delete(); + + // 5. Delete the submission record + const result = await FormSubmission.query(trx).where('id', submissionId).delete(); + + await trx.commit(); + return { success: result > 0 }; + } catch (error) { + if (trx) await trx.rollback(); + throw error; + } + }, + deleteMultipleSubmissions: async (submissionIds, currentUser) => { let trx; try { trx = await FormSubmission.startTransaction(); - await FormSubmission.query(trx).patch({ deleted: true, updatedBy: currentUser.usernameIdp }).whereIn('id', submissionIds); + const now = new Date().toISOString(); + await FormSubmission.query(trx).patch({ deleted: true, updatedBy: currentUser.usernameIdp, updatedAt: now }).whereIn('id', submissionIds); await trx.commit(); return await service.readSubmissionData(submissionIds); } catch (err) { diff --git a/app/src/routes/v1.js b/app/src/routes/v1.js index ad0363690..6ce578d74 100755 --- a/app/src/routes/v1.js +++ b/app/src/routes/v1.js @@ -17,6 +17,7 @@ const utils = require('../forms/utils'); const index = require('../forms/public'); const proxy = require('../forms/proxy'); const commonServices = require('../forms/commonServices'); +const recordsManagement = require('../forms/recordsManagement'); const statusService = require('../components/statusService'); @@ -33,6 +34,7 @@ const utilsPath = utils.mount(router); const publicPath = index.mount(router); const proxyPath = proxy.mount(router); const commonServicesPath = commonServices.mount(router); +const recordsManagementPath = recordsManagement.mount(router); const getSpec = () => { const rawSpec = fs.readFileSync(path.join(__dirname, '../docs/v1.api-spec.yaml'), 'utf8'); @@ -60,6 +62,7 @@ router.get('/', (_req, res) => { publicPath, utilsPath, commonServicesPath, + recordsManagementPath, ], }); }); diff --git a/app/tests/common/dbHelper.js b/app/tests/common/dbHelper.js index 02049f8ee..d60877019 100644 --- a/app/tests/common/dbHelper.js +++ b/app/tests/common/dbHelper.js @@ -64,6 +64,11 @@ MockModel.where = jest.fn().mockReturnThis(); MockModel.whereIn = jest.fn().mockReturnThis(); MockModel.withGraphFetched = jest.fn().mockReturnThis(); MockModel.select = jest.fn().mockReturnThis(); +MockModel.knex = jest.fn().mockReturnValue({ + transaction: jest.fn(async (callback) => { + return await callback(MockTransaction); + }), +}); // Utility Functions MockModel.mockClear = () => { @@ -80,5 +85,6 @@ MockModel.mockReset = () => { }; MockModel.mockResolvedValue = retFn; MockModel.mockReturnValue = retFn; +MockModel.throwIfNotFound = jest.fn(() => this); module.exports = { MockModel, MockTransaction }; diff --git a/app/tests/unit/forms/form/service.spec.js b/app/tests/unit/forms/form/service.spec.js index b64cb9cc3..4b058a1f7 100644 --- a/app/tests/unit/forms/form/service.spec.js +++ b/app/tests/unit/forms/form/service.spec.js @@ -364,7 +364,7 @@ describe('Document Templates', () => { }); it('should query database', async () => { - MockModel.mockResolvedValue(documentTemplate); + MockModel.throwIfNotFound.mockResolvedValue(documentTemplate); const result = await service.documentTemplateRead(documentTemplateId); diff --git a/app/tests/unit/forms/recordsManagement/controller.spec.js b/app/tests/unit/forms/recordsManagement/controller.spec.js new file mode 100644 index 000000000..a6e0f1843 --- /dev/null +++ b/app/tests/unit/forms/recordsManagement/controller.spec.js @@ -0,0 +1,192 @@ +const service = require('../../../../src/forms/recordsManagement/service'); + +jest.mock('../../../../src/forms/recordsManagement/service'); + +describe('recordsManagement controller', () => { + let controller; + let req; + let res; + let next; + + beforeEach(() => { + jest.clearAllMocks(); + controller = require('../../../../src/forms/recordsManagement/controller'); + req = { + params: {}, + body: {}, + currentUser: { usernameIdp: 'testuser' }, + }; + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), + }; + next = jest.fn(); + }); + + describe('scheduleDeletion', () => { + it('should schedule deletion and return result', async () => { + req.params = { formSubmissionId: 'sub-123' }; + req.body = { formId: 'form-123' }; + const mockResult = { formSubmissionId: 'sub-123', status: 'pending' }; + + service.scheduleDeletion = jest.fn().mockResolvedValue(mockResult); + + await controller.scheduleDeletion(req, res, next); + + expect(service.scheduleDeletion).toHaveBeenCalledWith('sub-123', 'form-123', 'testuser'); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(mockResult); + }); + + it('should return 204 when no retention policy exists', async () => { + req.params = { formSubmissionId: 'sub-123' }; + req.body = { formId: 'form-123' }; + const error = new Error('No matching row found'); + error.name = 'NotFoundError'; + + service.scheduleDeletion = jest.fn().mockRejectedValue(error); + + await controller.scheduleDeletion(req, res, next); + + expect(res.status).toHaveBeenCalledWith(204); + expect(res.send).toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); + + it('should handle errors', async () => { + req.params = { formSubmissionId: 'sub-123' }; + const error = new Error('Service error'); + + service.scheduleDeletion = jest.fn().mockRejectedValue(error); + + await controller.scheduleDeletion(req, res, next); + + expect(next).toHaveBeenCalledWith(error); + }); + }); + + describe('cancelDeletion', () => { + it('should cancel deletion and return result', async () => { + req.params = { formSubmissionId: 'sub-123' }; + const mockResult = { formSubmissionId: 'sub-123', cancelled: true }; + + service.cancelDeletion = jest.fn().mockResolvedValue(mockResult); + + await controller.cancelDeletion(req, res, next); + + expect(service.cancelDeletion).toHaveBeenCalledWith('sub-123'); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(mockResult); + }); + + it('should return 204 when no retention policy exists', async () => { + req.params = { formSubmissionId: 'sub-123' }; + const error = new Error('No matching row found'); + error.name = 'NotFoundError'; + + service.cancelDeletion = jest.fn().mockRejectedValue(error); + + await controller.cancelDeletion(req, res, next); + + expect(res.status).toHaveBeenCalledWith(204); + expect(res.send).toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); + + it('should handle errors', async () => { + req.params = { formSubmissionId: 'sub-123' }; + const error = new Error('Service error'); + + service.cancelDeletion = jest.fn().mockRejectedValue(error); + + await controller.cancelDeletion(req, res, next); + + expect(next).toHaveBeenCalledWith(error); + }); + }); + + describe('getPolicy', () => { + it('should return retention policy', async () => { + req.params = { formId: 'form-123' }; + const mockPolicy = { formId: 'form-123', retentionDays: 365 }; + + service.getRetentionPolicy = jest.fn().mockResolvedValue(mockPolicy); + + await controller.getPolicy(req, res, next); + + expect(service.getRetentionPolicy).toHaveBeenCalledWith('form-123'); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(mockPolicy); + }); + }); + + describe('setPolicy', () => { + it('should configure retention policy', async () => { + req.params = { formId: 'form-123' }; + req.body = { retentionDays: 730, retentionClassificationId: 'class-123' }; + const mockResult = { formId: 'form-123', retentionDays: 730 }; + + service.configureRetentionPolicy = jest.fn().mockResolvedValue(mockResult); + + await controller.setPolicy(req, res, next); + + expect(service.configureRetentionPolicy).toHaveBeenCalledWith('form-123', req.body, 'testuser'); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(mockResult); + }); + }); + + describe('processDeletions', () => { + it('should process deletions with default batch size', async () => { + req.body = {}; + const mockResult = { processed: 5, results: [] }; + + service.processDeletions = jest.fn().mockResolvedValue(mockResult); + + await controller.processDeletions(req, res, next); + + expect(service.processDeletions).toHaveBeenCalledWith(100); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(mockResult); + }); + + it('should process deletions with custom batch size', async () => { + req.body = { batchSize: 50 }; + const mockResult = { processed: 5, results: [] }; + + service.processDeletions = jest.fn().mockResolvedValue(mockResult); + + await controller.processDeletions(req, res, next); + + expect(service.processDeletions).toHaveBeenCalledWith(50); + }); + }); + + describe('processDeletionsWebhook', () => { + it('should hard delete submissions from webhook', async () => { + req.body = { submissionIds: ['sub-1', 'sub-2'] }; + const mockResults = [ + { formSubmissionId: 'sub-1', status: 'completed' }, + { formSubmissionId: 'sub-2', status: 'completed' }, + ]; + + service.hardDeleteSubmissions = jest.fn().mockResolvedValue(mockResults); + + await controller.processDeletionsWebhook(req, res, next); + + expect(service.hardDeleteSubmissions).toHaveBeenCalledWith(['sub-1', 'sub-2']); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ results: mockResults }); + }); + + it('should reject non-array submissionIds', async () => { + req.body = { submissionIds: 'not-an-array' }; + + await controller.processDeletionsWebhook(req, res, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'submissionIds must be an array' }); + }); + }); +}); diff --git a/app/tests/unit/forms/recordsManagement/localService.spec.js b/app/tests/unit/forms/recordsManagement/localService.spec.js new file mode 100644 index 000000000..9b762f00d --- /dev/null +++ b/app/tests/unit/forms/recordsManagement/localService.spec.js @@ -0,0 +1,341 @@ +const localService = require('../../../../src/forms/recordsManagement/localService'); +const { RetentionPolicy, ScheduledSubmissionDeletion } = require('../../../../src/forms/common/models'); +const submissionService = require('../../../../src/forms/submission/service'); + +const testUuid = '123'; +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue(testUuid), +})); +jest.mock('../../../../src/forms/common/models'); +jest.mock('../../../../src/forms/submission/service'); + +// Helper functions to reduce nesting depth +const createRetentionPolicyQueryMock = (mockPolicy) => { + const mockThrowIfNotFound = jest.fn().mockResolvedValue(mockPolicy); + const mockWithGraphFetched = jest.fn().mockReturnValue({ + throwIfNotFound: mockThrowIfNotFound, + }); + const mockFindOne = jest.fn().mockReturnValue({ + withGraphFetched: mockWithGraphFetched, + }); + return { + findOne: mockFindOne, + }; +}; + +const createRetentionPolicyQueryMockWithError = (error) => { + const mockThrowIfNotFound = jest.fn().mockRejectedValue(error); + const mockWithGraphFetched = jest.fn().mockReturnValue({ + throwIfNotFound: mockThrowIfNotFound, + }); + const mockFindOne = jest.fn().mockReturnValue({ + withGraphFetched: mockWithGraphFetched, + }); + return { + findOne: mockFindOne, + }; +}; + +const createScheduledDeletionWhereDeleteMock = (mockDelete) => { + const mockWhere = jest.fn().mockReturnValue({ + delete: mockDelete, + }); + return { + where: mockWhere, + }; +}; + +const createScheduledDeletionWhereUpdateMock = (mockUpdate) => { + const mockWhere = jest.fn().mockReturnValue({ + update: mockUpdate, + }); + return { + where: mockWhere, + }; +}; + +describe('localService', () => { + beforeEach(() => { + jest.clearAllMocks(); + if (submissionService.deleteSubmissionAndRelatedData) { + submissionService.deleteSubmissionAndRelatedData.mockReset?.(); + } + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('getRetentionPolicy should return retention policy with classification', async () => { + const formId = 'form-123'; + const mockPolicy = { + formId, + retentionDays: 365, + retentionClassificationDescription: 'test', + retentionClassificationId: '123123', + }; + + RetentionPolicy.query = jest.fn().mockReturnValue(createRetentionPolicyQueryMock(mockPolicy)); + + const result = await localService.getRetentionPolicy(formId); + + expect(result).toEqual(mockPolicy); + expect(RetentionPolicy.query).toHaveBeenCalled(); + }); + + it('getRetentionPolicy should throw error if no policy found', async () => { + const formId = 'form-456'; + const error = new Error(`No retention policy found for form ${formId}`); + RetentionPolicy.query = jest.fn().mockReturnValue(createRetentionPolicyQueryMockWithError(error)); + + await expect(localService.getRetentionPolicy(formId)).rejects.toThrow(`No retention policy found for form ${formId}`); + }); + + it('configureRetentionPolicy should create new retention policy if not exists', async () => { + const formId = 'form-123'; + const policyData = { retentionDays: 365, retentionClassificationId: 'class-123', retentionClassificationDescription: 'lorem ipsum' }; + const user = 'testuser'; + + const mockInsert = jest.fn().mockResolvedValue({ ...policyData, formId, createdBy: user }); + + RetentionPolicy.query = jest + .fn() + .mockReturnValueOnce({ + findOne: jest.fn().mockResolvedValue(null), + }) + .mockReturnValueOnce({ + insert: mockInsert, + }); + + const result = await localService.configureRetentionPolicy(formId, policyData, user); + + expect(result).toEqual({ ...policyData, formId, createdBy: user }); + expect(mockInsert).toHaveBeenCalledWith({ + id: testUuid, + formId, + retentionDays: policyData.retentionDays, + retentionClassificationId: policyData.retentionClassificationId, + retentionClassificationDescription: policyData.retentionClassificationDescription, + createdBy: user, + }); + }); + + it('configureRetentionPolicy should update existing retention policy', async () => { + const formId = 'form-123'; + const policyData = { retentionDays: 730, retentionClassificationId: 'class-456' }; + const user = 'testuser'; + const existingPolicy = { id: 'policy-123', formId }; + + const mockPatchAndFetchById = jest.fn().mockResolvedValue({ + id: 'policy-123', + ...policyData, + updatedBy: user, + }); + + RetentionPolicy.query = jest + .fn() + .mockReturnValueOnce({ + findOne: jest.fn().mockResolvedValue(existingPolicy), + }) + .mockReturnValueOnce({ + patchAndFetchById: mockPatchAndFetchById, + }); + + const result = await localService.configureRetentionPolicy(formId, policyData, user); + + expect(result).toEqual({ + id: 'policy-123', + ...policyData, + updatedBy: user, + }); + expect(mockPatchAndFetchById).toHaveBeenCalledWith('policy-123', { + retentionDays: policyData.retentionDays, + retentionClassificationId: policyData.retentionClassificationId, + updatedBy: user, + updatedAt: expect.any(String), + }); + }); + + it('scheduleDeletion should schedule deletion with calculated eligibleForDeletionAt', async () => { + const submissionId = 'sub-123'; + const formId = 'form-123'; + const user = 'testuser'; + const mockPolicy = { + formId, + retentionDays: 365, + classification: { code: 'public' }, + }; + + jest.spyOn(localService, 'getRetentionPolicy').mockResolvedValue(mockPolicy); + + const mockScheduled = { + id: 'sched-123', + submissionId, + formId, + status: 'pending', + createdBy: user, + }; + + const mockInsert = jest.fn().mockResolvedValue(mockScheduled); + ScheduledSubmissionDeletion.query = jest.fn().mockReturnValue({ + insert: mockInsert, + }); + + const result = await localService.scheduleDeletion(submissionId, formId, user); + + expect(result).toEqual(mockScheduled); + expect(mockInsert).toHaveBeenCalledWith( + expect.objectContaining({ + submissionId, + formId, + status: 'pending', + createdBy: user, + }) + ); + }); + + it('scheduleDeletion should handle indefinite retention (null retentionDays)', async () => { + const submissionId = 'sub-456'; + const formId = 'form-456'; + const user = 'testuser'; + const mockPolicy = { + formId, + retentionDays: null, + classification: { code: 'sensitive' }, + }; + + jest.spyOn(localService, 'getRetentionPolicy').mockResolvedValue(mockPolicy); + + const mockInsert = jest.fn().mockResolvedValue({ + submissionId, + formId, + eligibleForDeletionAt: expect.any(String), + status: 'pending', + }); + + ScheduledSubmissionDeletion.query = jest.fn().mockReturnValue({ + insert: mockInsert, + }); + + await localService.scheduleDeletion(submissionId, formId, user); + + expect(mockInsert).toHaveBeenCalledWith( + expect.objectContaining({ + eligibleForDeletionAt: expect.any(String), + }) + ); + }); + + it('cancelDeletion should delete scheduled deletion record', async () => { + const submissionId = 'sub-123'; + + const mockDelete = jest.fn().mockResolvedValue(1); + ScheduledSubmissionDeletion.query = jest.fn().mockReturnValue(createScheduledDeletionWhereDeleteMock(mockDelete)); + + const result = await localService.cancelDeletion(submissionId); + + expect(result).toEqual({ submissionId, cancelled: true }); + expect(mockDelete).toHaveBeenCalled(); + }); + + it('cancelDeletion should return cancelled false if no record found', async () => { + const submissionId = 'sub-999'; + + const mockDelete = jest.fn().mockResolvedValue(0); + ScheduledSubmissionDeletion.query = jest.fn().mockReturnValue(createScheduledDeletionWhereDeleteMock(mockDelete)); + + const result = await localService.cancelDeletion(submissionId); + + expect(result).toEqual({ submissionId, cancelled: false }); + }); + + it('processDeletions should process eligible deletions', async () => { + const mockScheduled = [ + { + id: 'sched-1', + submissionId: 'sub-1', + status: 'pending', + }, + ]; + + const mockLimit = jest.fn().mockResolvedValue(mockScheduled); + const mockWhere2 = jest.fn().mockReturnValue({ + limit: mockLimit, + }); + const mockWhere1 = jest.fn().mockReturnValue({ + where: mockWhere2, + }); + + ScheduledSubmissionDeletion.query = jest + .fn() + .mockReturnValueOnce({ + where: mockWhere1, + }) + .mockReturnValue({ + patchAndFetchById: jest.fn().mockResolvedValue({ status: 'completed' }), + }); + + submissionService.deleteSubmissionAndRelatedData = jest.fn().mockResolvedValue(true); + + const result = await localService.processDeletions(100); + + expect(result.processed).toBeGreaterThanOrEqual(0); + expect(Array.isArray(result.results)).toBe(true); + }); + + it('processDeletions should return empty results if no eligible deletions', async () => { + const mockLimit = jest.fn().mockResolvedValue([]); + const mockWhere2 = jest.fn().mockReturnValue({ + limit: mockLimit, + }); + const mockWhere1 = jest.fn().mockReturnValue({ + where: mockWhere2, + }); + + ScheduledSubmissionDeletion.query = jest.fn().mockReturnValue({ + where: mockWhere1, + }); + + const result = await localService.processDeletions(100); + + expect(result).toEqual({ processed: 0, results: [] }); + }); + + describe('hardDeleteSubmissions', () => { + beforeEach(() => { + jest.clearAllMocks(); + submissionService.deleteSubmissionAndRelatedData = jest.fn(); + ScheduledSubmissionDeletion.query = jest.fn(); + }); + + it('should hard delete multiple submissions', async () => { + const submissionIds = ['sub-1', 'sub-2']; + + submissionService.deleteSubmissionAndRelatedData.mockResolvedValue(true); + + const mockUpdate = jest.fn().mockResolvedValue(1); + ScheduledSubmissionDeletion.query.mockReturnValue(createScheduledDeletionWhereUpdateMock(mockUpdate)); + + const result = await localService.hardDeleteSubmissions(submissionIds); + + expect(result).toHaveLength(2); + expect(result.every((r) => r.status === 'completed')).toBe(true); + expect(submissionService.deleteSubmissionAndRelatedData).toHaveBeenCalledTimes(2); + }); + + it('should handle deletion failures', async () => { + const submissionIds = ['sub-1', 'sub-error']; + + submissionService.deleteSubmissionAndRelatedData = jest.fn().mockResolvedValueOnce(true).mockRejectedValueOnce(new Error('Deletion failed')); + + const mockUpdate = jest.fn().mockResolvedValue(1); + ScheduledSubmissionDeletion.query = jest.fn().mockReturnValue(createScheduledDeletionWhereUpdateMock(mockUpdate)); + + const result = await localService.hardDeleteSubmissions(submissionIds); + + expect(result).toHaveLength(2); + expect(result[0].status).toBe('completed'); + expect(result[1].status).toBe('failed'); + }); + }); +}); diff --git a/app/tests/unit/forms/recordsManagement/service.spec.js b/app/tests/unit/forms/recordsManagement/service.spec.js new file mode 100644 index 000000000..b908a268b --- /dev/null +++ b/app/tests/unit/forms/recordsManagement/service.spec.js @@ -0,0 +1,120 @@ +jest.mock('../../../../src/components/log', () => () => ({ + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), +})); +jest.mock('../../../../src/components/eventStreamService', () => ({ + eventStreamService: { + onSubmit: jest.fn(), + onPublish: jest.fn(), + }, + SUBMISSION_EVENT_TYPES: {}, +})); +jest.mock('config'); +jest.mock('../../../../src/components/chesService', () => ({ + send: jest.fn(), + merge: jest.fn(), + health: jest.fn(), +})); +jest.mock('../../../../src/forms/email/emailService', () => ({ + submissionReceived: jest.fn(), +})); +jest.mock('../../../../src/forms/file/service', () => ({ + read: jest.fn(), + moveSubmissionFile: jest.fn(), +})); +jest.mock('../../../../src/forms/submission/service'); + +const service = require('../../../../src/forms/recordsManagement/service'); +const localService = require('../../../../src/forms/recordsManagement/localService'); +const config = require('config'); + +describe('recordsManagement service', () => { + beforeEach(() => { + jest.clearAllMocks(); + config.has = jest.fn().mockReturnValue(true); + config.get = jest.fn().mockReturnValue('local'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('getRetentionPolicy should delegate to localService when implementation is local', async () => { + const formId = 'form-123'; + localService.getRetentionPolicy = jest.fn().mockResolvedValue({ formId, retentionDays: 365 }); + + const result = await service.getRetentionPolicy(formId); + + expect(localService.getRetentionPolicy).toHaveBeenCalledWith(formId); + expect(result).toEqual({ formId, retentionDays: 365 }); + }); + + it('getRetentionPolicy should throw error for unknown implementation', async () => { + const formId = 'form-123'; + localService.getRetentionPolicy = jest.fn().mockRejectedValue(new Error("RecordsManagement implementation 'unknown' not available")); + + await expect(service.getRetentionPolicy(formId)).rejects.toThrow("RecordsManagement implementation 'unknown' not available"); + }); + + it('configureRetentionPolicy should delegate to localService', async () => { + const formId = 'form-123'; + const policyData = { retentionDays: 730 }; + const user = 'testuser'; + + localService.configureRetentionPolicy = jest.fn().mockResolvedValue(policyData); + + const result = await service.configureRetentionPolicy(formId, policyData, user); + + expect(localService.configureRetentionPolicy).toHaveBeenCalledWith(formId, policyData, user); + expect(result).toEqual(policyData); + }); + + it('scheduleDeletion should delegate to localService', async () => { + const submissionId = 'sub-123'; + const formId = 'form-123'; + const user = 'testuser'; + + localService.scheduleDeletion = jest.fn().mockResolvedValue({ submissionId, status: 'pending' }); + + const result = await service.scheduleDeletion(submissionId, formId, user); + + expect(localService.scheduleDeletion).toHaveBeenCalledWith(submissionId, formId, user); + expect(result).toEqual({ submissionId, status: 'pending' }); + }); + + it('cancelDeletion should delegate to localService', async () => { + const submissionId = 'sub-123'; + + localService.cancelDeletion = jest.fn().mockResolvedValue({ submissionId, cancelled: true }); + + const result = await service.cancelDeletion(submissionId); + + expect(localService.cancelDeletion).toHaveBeenCalledWith(submissionId); + expect(result).toEqual({ submissionId, cancelled: true }); + }); + + it('processDeletions should delegate to localService', async () => { + localService.processDeletions = jest.fn().mockResolvedValue({ processed: 10, results: [] }); + + const result = await service.processDeletions(100); + + expect(localService.processDeletions).toHaveBeenCalledWith(100); + expect(result).toEqual({ processed: 10, results: [] }); + }); + + it('hardDeleteSubmissions should delegate to localService', async () => { + const submissionIds = ['sub-1', 'sub-2']; + + localService.hardDeleteSubmissions = jest.fn().mockResolvedValue([ + { submissionId: 'sub-1', status: 'completed' }, + { submissionId: 'sub-2', status: 'completed' }, + ]); + + const result = await service.hardDeleteSubmissions(submissionIds); + + expect(localService.hardDeleteSubmissions).toHaveBeenCalledWith(submissionIds); + expect(result).toHaveLength(2); + }); +}); diff --git a/app/tests/unit/forms/submission/service.spec.js b/app/tests/unit/forms/submission/service.spec.js index 3badf440c..c8de30790 100644 --- a/app/tests/unit/forms/submission/service.spec.js +++ b/app/tests/unit/forms/submission/service.spec.js @@ -57,6 +57,21 @@ describe('createStatus', () => { describe('deleteMultipleSubmissions', () => { it('should delete the selected submissions', async () => { + // Mock the Date object + const mockDate = new Date('2023-05-15T10:30:00.000Z'); + const mockISOString = '2023-05-15T10:30:00.000Z'; + + // Save the original Date constructor + const OriginalDate = globalThis.Date; + + // Replace the global Date constructor with a mock + globalThis.Date = jest.fn(() => mockDate); + + // Add the toISOString method to the mock + globalThis.Date.prototype.toISOString = jest.fn(() => mockISOString); + + // Copy any other methods you need from the original Date + globalThis.Date.now = OriginalDate.now; let submissionId1 = uuid.v4(); let submissionId2 = uuid.v4(); const submissionIds = [submissionId1, submissionId2]; @@ -78,11 +93,13 @@ describe('deleteMultipleSubmissions', () => { expect(MockModel.query).toBeCalledTimes(1); expect(MockModel.query).toBeCalledWith(expect.anything()); expect(MockModel.patch).toBeCalledTimes(1); - expect(MockModel.patch).toBeCalledWith({ deleted: true, updatedBy: currentUser.usernameIdp }); + expect(MockModel.patch).toBeCalledWith({ deleted: true, updatedAt: mockISOString, updatedBy: currentUser.usernameIdp }); expect(MockModel.whereIn).toBeCalledTimes(1); expect(MockModel.whereIn).toBeCalledWith('id', submissionIds); expect(spy).toBeCalledWith(submissionIds); expect(res).toEqual(returnValue); + // Then restore the original Date object when done + globalThis.Date = OriginalDate; }); }); diff --git a/app/tests/unit/routes/v1.spec.js b/app/tests/unit/routes/v1.spec.js index de83b747e..39bcb3d24 100755 --- a/app/tests/unit/routes/v1.spec.js +++ b/app/tests/unit/routes/v1.spec.js @@ -17,7 +17,7 @@ describe(`${basePath}`, () => { expect(response.statusCode).toBe(200); expect(response.body).toBeTruthy(); expect(Array.isArray(response.body.endpoints)).toBeTruthy(); - expect(response.body.endpoints).toHaveLength(14); + expect(response.body.endpoints).toHaveLength(15); expect(response.body.endpoints).toContain('/docs'); expect(response.body.endpoints).toContain('/status'); expect(response.body.endpoints).toContain('/files'); @@ -30,6 +30,7 @@ describe(`${basePath}`, () => { expect(response.body.endpoints).toContain('/users'); expect(response.body.endpoints).toContain('/utils'); expect(response.body.endpoints).toContain('/cs'); + expect(response.body.endpoints).toContain('/recordsManagement'); }); }); }); diff --git a/openshift/app.cronjob.yaml b/openshift/app.cronjob.yaml index 442384b84..43e3a36d1 100644 --- a/openshift/app.cronjob.yaml +++ b/openshift/app.cronjob.yaml @@ -64,7 +64,42 @@ objects: command: - /bin/sh - -ec - - 'curl -v -X GET -ks -H "apikey: $APITOKEN" -H "Content-Type: application/json" "http://${APP_NAME}-app-${JOB_NAME}:8080${ROUTE_PATH}/api/v1/public/reminder"||exit 0' + - 'curl -v -X GET -ks -H "apikey: $APITOKEN" -H "Content-Type: application/json" "https://${APP_NAME}-${NAMESPACE_ENVIRONMENT}.apps.silver.devops.gov.bc.ca${ROUTE_PATH}/api/v1/public/reminder"||exit 0' + restartPolicy: OnFailure + - apiVersion: batch/v1 + kind: CronJob + metadata: + name: "${APP_NAME}-form-submission-deletion-cronjob-${JOB_NAME}" + spec: + # Run at 3am daily (less busy time) + schedule: "0 3 * * *" + concurrencyPolicy: "Replace" + startingDeadlineSeconds: 200 + suspend: false + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 1 + jobTemplate: + spec: + template: + metadata: + labels: + parent: "cronjobpi" + workload: formsubmissiondeletioncronjob + type: trigger + spec: + containers: + - name: form-submission-deletion-container + image: docker.io/curlimages/curl:8.12.1 + env: + - name: APITOKEN + valueFrom: + secretKeyRef: + name: "chefs-${JOB_NAME}-secret" + key: mailapitoken + command: + - /bin/sh + - -ec + - 'curl -v -X POST -ks -H "apikey: $APITOKEN" -H "Content-Type: application/json" "https://${APP_NAME}-${NAMESPACE_ENVIRONMENT}.apps.silver.devops.gov.bc.ca${ROUTE_PATH}/api/v1/recordsManagement/internal/deletions/process"||exit 0' restartPolicy: OnFailure parameters: - name: APP_NAME @@ -83,3 +118,7 @@ parameters: description: Target namespace reference (i.e. 'wfezkf-dev') displayName: Target Namespace required: true + - name: NAMESPACE_ENVIRONMENT + description: Target namespace environment (i.e. 'dev' or 'test' or 'prod') + displayName: Namespace Environment + required: true