From dc7d3fe0c5d4050bcd96e55eb60878578e078fb1 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Thu, 12 Feb 2026 21:08:18 -0500 Subject: [PATCH] feat: add multi-language (i18n) support with EN, ES, FR, DE translations Replace hardcoded English strings across controllers, middleware, provider, and types with custom t() translation helper. - Add JSON translation files for 4 locales (en, es, fr, de) - Create i18n support module with t() helper function - Update controllers to use t() for flash messages and errors - Update middleware to use t() for authorization errors - Register i18n in provider and export from package index --- index.ts | 3 + providers/escalated_provider.ts | 6 ++ resources/lang/de/messages.json | 84 +++++++++++++++++++ resources/lang/en/messages.json | 84 +++++++++++++++++++ resources/lang/es/messages.json | 84 +++++++++++++++++++ resources/lang/fr/messages.json | 84 +++++++++++++++++++ .../admin_canned_responses_controller.ts | 7 +- .../admin_departments_controller.ts | 7 +- .../admin_escalation_rules_controller.ts | 7 +- src/controllers/admin_macros_controller.ts | 7 +- src/controllers/admin_settings_controller.ts | 3 +- .../admin_sla_policies_controller.ts | 7 +- src/controllers/admin_tags_controller.ts | 7 +- src/controllers/admin_tickets_controller.ts | 25 +++--- src/controllers/agent_tickets_controller.ts | 27 +++--- src/controllers/bulk_actions_controller.ts | 3 +- .../customer_tickets_controller.ts | 11 +-- src/controllers/guest_tickets_controller.ts | 7 +- src/controllers/inbound_email_controller.ts | 7 +- .../satisfaction_rating_controller.ts | 13 +-- src/middleware/ensure_is_admin.ts | 5 +- src/middleware/ensure_is_agent.ts | 5 +- src/support/i18n.ts | 64 ++++++++++++++ src/types.ts | 37 +++++++- 24 files changed, 526 insertions(+), 68 deletions(-) create mode 100644 resources/lang/de/messages.json create mode 100644 resources/lang/en/messages.json create mode 100644 resources/lang/es/messages.json create mode 100644 resources/lang/fr/messages.json create mode 100644 src/support/i18n.ts diff --git a/index.ts b/index.ts index 04060c6..b16e8f6 100644 --- a/index.ts +++ b/index.ts @@ -15,3 +15,6 @@ export * from './src/types.js' // Re-export events export * from './src/events/index.js' + +// Re-export i18n support +export { t, setLocale, getLocale } from './src/support/i18n.js' diff --git a/providers/escalated_provider.ts b/providers/escalated_provider.ts index 7c2280d..63c04d1 100644 --- a/providers/escalated_provider.ts +++ b/providers/escalated_provider.ts @@ -1,5 +1,6 @@ import type { ApplicationService } from '@adonisjs/core/types' import type { EscalatedConfig } from '../src/types.js' +import { setLocale } from '../src/support/i18n.js' export default class EscalatedProvider { constructor(protected app: ApplicationService) {} @@ -74,6 +75,11 @@ export default class EscalatedProvider { // Store config globally for services that cannot inject the container ;(globalThis as any).__escalated_config = config + + // Set locale from config if available + if (config.locale) { + setLocale(config.locale) + } } catch { // Config may not be available yet during testing ;(globalThis as any).__escalated_config = {} diff --git a/resources/lang/de/messages.json b/resources/lang/de/messages.json new file mode 100644 index 0000000..f5b5aee --- /dev/null +++ b/resources/lang/de/messages.json @@ -0,0 +1,84 @@ +{ + "ticket": { + "reply_sent": "Antwort gesendet.", + "note_added": "Notiz hinzugef\u00fcgt.", + "assigned": "Ticket zugewiesen.", + "status_updated": "Status aktualisiert.", + "priority_updated": "Priorit\u00e4t aktualisiert.", + "tags_updated": "Tags aktualisiert.", + "department_updated": "Abteilung aktualisiert.", + "macro_applied": "Makro \":name\" angewendet.", + "unfollowed": "Ticket nicht mehr verfolgt.", + "following": "Ticket wird verfolgt.", + "note_pinned": "Notiz angepinnt.", + "note_unpinned": "Notiz losgel\u00f6st.", + "pin_notes_only": "Nur interne Notizen k\u00f6nnen angepinnt werden.", + "updated": "Ticket aktualisiert.", + "created": "Ticket erfolgreich erstellt.", + "closed": "Ticket geschlossen.", + "reopened": "Ticket wieder ge\u00f6ffnet.", + "customer_close_forbidden": "Kunden k\u00f6nnen Tickets nicht schlie\u00dfen." + }, + "guest": { + "created": "Ticket erstellt. Speichern Sie diesen Link, um den Status Ihres Tickets zu \u00fcberpr\u00fcfen.", + "reply_sent": "Antwort gesendet.", + "ticket_closed": "Dieses Ticket ist geschlossen." + }, + "bulk": { + "updated": ":count Ticket(s) aktualisiert." + }, + "rating": { + "thank_you": "Vielen Dank f\u00fcr Ihr Feedback!", + "only_resolved_closed": "Sie k\u00f6nnen nur gel\u00f6ste oder geschlossene Tickets bewerten.", + "already_rated": "Dieses Ticket wurde bereits bewertet." + }, + "admin": { + "department_created": "Abteilung erstellt.", + "department_updated": "Abteilung aktualisiert.", + "department_deleted": "Abteilung gel\u00f6scht.", + "sla_policy_created": "SLA-Richtlinie erstellt.", + "sla_policy_updated": "SLA-Richtlinie aktualisiert.", + "sla_policy_deleted": "SLA-Richtlinie gel\u00f6scht.", + "rule_created": "Regel erstellt.", + "rule_updated": "Regel aktualisiert.", + "rule_deleted": "Regel gel\u00f6scht.", + "canned_response_created": "Vorgefertigte Antwort erstellt.", + "canned_response_updated": "Vorgefertigte Antwort aktualisiert.", + "canned_response_deleted": "Vorgefertigte Antwort gel\u00f6scht.", + "macro_created": "Makro erstellt.", + "macro_updated": "Makro aktualisiert.", + "macro_deleted": "Makro gel\u00f6scht.", + "tag_created": "Tag erstellt.", + "tag_updated": "Tag aktualisiert.", + "tag_deleted": "Tag gel\u00f6scht.", + "settings_updated": "Einstellungen aktualisiert." + }, + "middleware": { + "not_admin": "Sie sind nicht als Support-Administrator autorisiert.", + "not_agent": "Sie sind nicht als Support-Agent autorisiert." + }, + "inbound": { + "disabled": "Eingehende E-Mail ist deaktiviert.", + "invalid_signature": "Ung\u00fcltige Signatur.", + "processing_failed": "Verarbeitung fehlgeschlagen." + }, + "labels": { + "status": { + "open": "Offen", + "in_progress": "In Bearbeitung", + "waiting_on_customer": "Warten auf Kunde", + "waiting_on_agent": "Warten auf Agent", + "escalated": "Eskaliert", + "resolved": "Gel\u00f6st", + "closed": "Geschlossen", + "reopened": "Wieder ge\u00f6ffnet" + }, + "priority": { + "low": "Niedrig", + "medium": "Mittel", + "high": "Hoch", + "urgent": "Dringend", + "critical": "Kritisch" + } + } +} diff --git a/resources/lang/en/messages.json b/resources/lang/en/messages.json new file mode 100644 index 0000000..eb476af --- /dev/null +++ b/resources/lang/en/messages.json @@ -0,0 +1,84 @@ +{ + "ticket": { + "reply_sent": "Reply sent.", + "note_added": "Note added.", + "assigned": "Ticket assigned.", + "status_updated": "Status updated.", + "priority_updated": "Priority updated.", + "tags_updated": "Tags updated.", + "department_updated": "Department updated.", + "macro_applied": "Macro \":name\" applied.", + "unfollowed": "Unfollowed ticket.", + "following": "Following ticket.", + "note_pinned": "Note pinned.", + "note_unpinned": "Note unpinned.", + "pin_notes_only": "Only internal notes can be pinned.", + "updated": "Ticket updated.", + "created": "Ticket created successfully.", + "closed": "Ticket closed.", + "reopened": "Ticket reopened.", + "customer_close_forbidden": "Customers cannot close tickets." + }, + "guest": { + "created": "Ticket created. Save this link to check your ticket status.", + "reply_sent": "Reply sent.", + "ticket_closed": "This ticket is closed." + }, + "bulk": { + "updated": ":count ticket(s) updated." + }, + "rating": { + "thank_you": "Thank you for your feedback!", + "only_resolved_closed": "You can only rate resolved or closed tickets.", + "already_rated": "This ticket has already been rated." + }, + "admin": { + "department_created": "Department created.", + "department_updated": "Department updated.", + "department_deleted": "Department deleted.", + "sla_policy_created": "SLA Policy created.", + "sla_policy_updated": "SLA Policy updated.", + "sla_policy_deleted": "SLA Policy deleted.", + "rule_created": "Rule created.", + "rule_updated": "Rule updated.", + "rule_deleted": "Rule deleted.", + "canned_response_created": "Canned response created.", + "canned_response_updated": "Canned response updated.", + "canned_response_deleted": "Canned response deleted.", + "macro_created": "Macro created.", + "macro_updated": "Macro updated.", + "macro_deleted": "Macro deleted.", + "tag_created": "Tag created.", + "tag_updated": "Tag updated.", + "tag_deleted": "Tag deleted.", + "settings_updated": "Settings updated." + }, + "middleware": { + "not_admin": "You are not authorized as a support administrator.", + "not_agent": "You are not authorized as a support agent." + }, + "inbound": { + "disabled": "Inbound email is disabled.", + "invalid_signature": "Invalid signature.", + "processing_failed": "Processing failed." + }, + "labels": { + "status": { + "open": "Open", + "in_progress": "In Progress", + "waiting_on_customer": "Waiting on Customer", + "waiting_on_agent": "Waiting on Agent", + "escalated": "Escalated", + "resolved": "Resolved", + "closed": "Closed", + "reopened": "Reopened" + }, + "priority": { + "low": "Low", + "medium": "Medium", + "high": "High", + "urgent": "Urgent", + "critical": "Critical" + } + } +} diff --git a/resources/lang/es/messages.json b/resources/lang/es/messages.json new file mode 100644 index 0000000..b1a1570 --- /dev/null +++ b/resources/lang/es/messages.json @@ -0,0 +1,84 @@ +{ + "ticket": { + "reply_sent": "Respuesta enviada.", + "note_added": "Nota agregada.", + "assigned": "Ticket asignado.", + "status_updated": "Estado actualizado.", + "priority_updated": "Prioridad actualizada.", + "tags_updated": "Etiquetas actualizadas.", + "department_updated": "Departamento actualizado.", + "macro_applied": "Macro \":name\" aplicada.", + "unfollowed": "Dejaste de seguir el ticket.", + "following": "Siguiendo el ticket.", + "note_pinned": "Nota fijada.", + "note_unpinned": "Nota desfijada.", + "pin_notes_only": "Solo las notas internas pueden ser fijadas.", + "updated": "Ticket actualizado.", + "created": "Ticket creado exitosamente.", + "closed": "Ticket cerrado.", + "reopened": "Ticket reabierto.", + "customer_close_forbidden": "Los clientes no pueden cerrar tickets." + }, + "guest": { + "created": "Ticket creado. Guarde este enlace para consultar el estado de su ticket.", + "reply_sent": "Respuesta enviada.", + "ticket_closed": "Este ticket est\u00e1 cerrado." + }, + "bulk": { + "updated": ":count ticket(s) actualizado(s)." + }, + "rating": { + "thank_you": "\u00a1Gracias por sus comentarios!", + "only_resolved_closed": "Solo puede calificar tickets resueltos o cerrados.", + "already_rated": "Este ticket ya ha sido calificado." + }, + "admin": { + "department_created": "Departamento creado.", + "department_updated": "Departamento actualizado.", + "department_deleted": "Departamento eliminado.", + "sla_policy_created": "Pol\u00edtica SLA creada.", + "sla_policy_updated": "Pol\u00edtica SLA actualizada.", + "sla_policy_deleted": "Pol\u00edtica SLA eliminada.", + "rule_created": "Regla creada.", + "rule_updated": "Regla actualizada.", + "rule_deleted": "Regla eliminada.", + "canned_response_created": "Respuesta predefinida creada.", + "canned_response_updated": "Respuesta predefinida actualizada.", + "canned_response_deleted": "Respuesta predefinida eliminada.", + "macro_created": "Macro creada.", + "macro_updated": "Macro actualizada.", + "macro_deleted": "Macro eliminada.", + "tag_created": "Etiqueta creada.", + "tag_updated": "Etiqueta actualizada.", + "tag_deleted": "Etiqueta eliminada.", + "settings_updated": "Configuraci\u00f3n actualizada." + }, + "middleware": { + "not_admin": "No est\u00e1 autorizado como administrador de soporte.", + "not_agent": "No est\u00e1 autorizado como agente de soporte." + }, + "inbound": { + "disabled": "El correo entrante est\u00e1 deshabilitado.", + "invalid_signature": "Firma inv\u00e1lida.", + "processing_failed": "El procesamiento fall\u00f3." + }, + "labels": { + "status": { + "open": "Abierto", + "in_progress": "En Progreso", + "waiting_on_customer": "Esperando al Cliente", + "waiting_on_agent": "Esperando al Agente", + "escalated": "Escalado", + "resolved": "Resuelto", + "closed": "Cerrado", + "reopened": "Reabierto" + }, + "priority": { + "low": "Baja", + "medium": "Media", + "high": "Alta", + "urgent": "Urgente", + "critical": "Cr\u00edtica" + } + } +} diff --git a/resources/lang/fr/messages.json b/resources/lang/fr/messages.json new file mode 100644 index 0000000..0bffd3e --- /dev/null +++ b/resources/lang/fr/messages.json @@ -0,0 +1,84 @@ +{ + "ticket": { + "reply_sent": "R\u00e9ponse envoy\u00e9e.", + "note_added": "Note ajout\u00e9e.", + "assigned": "Ticket assign\u00e9.", + "status_updated": "Statut mis \u00e0 jour.", + "priority_updated": "Priorit\u00e9 mise \u00e0 jour.", + "tags_updated": "\u00c9tiquettes mises \u00e0 jour.", + "department_updated": "D\u00e9partement mis \u00e0 jour.", + "macro_applied": "Macro \u00ab :name \u00bb appliqu\u00e9e.", + "unfollowed": "Ticket d\u00e9sabonn\u00e9.", + "following": "Ticket suivi.", + "note_pinned": "Note \u00e9pingl\u00e9e.", + "note_unpinned": "Note d\u00e9s\u00e9pingl\u00e9e.", + "pin_notes_only": "Seules les notes internes peuvent \u00eatre \u00e9pingl\u00e9es.", + "updated": "Ticket mis \u00e0 jour.", + "created": "Ticket cr\u00e9\u00e9 avec succ\u00e8s.", + "closed": "Ticket ferm\u00e9.", + "reopened": "Ticket rouvert.", + "customer_close_forbidden": "Les clients ne peuvent pas fermer les tickets." + }, + "guest": { + "created": "Ticket cr\u00e9\u00e9. Enregistrez ce lien pour suivre l'\u00e9tat de votre ticket.", + "reply_sent": "R\u00e9ponse envoy\u00e9e.", + "ticket_closed": "Ce ticket est ferm\u00e9." + }, + "bulk": { + "updated": ":count ticket(s) mis \u00e0 jour." + }, + "rating": { + "thank_you": "Merci pour votre retour !", + "only_resolved_closed": "Vous ne pouvez \u00e9valuer que les tickets r\u00e9solus ou ferm\u00e9s.", + "already_rated": "Ce ticket a d\u00e9j\u00e0 \u00e9t\u00e9 \u00e9valu\u00e9." + }, + "admin": { + "department_created": "D\u00e9partement cr\u00e9\u00e9.", + "department_updated": "D\u00e9partement mis \u00e0 jour.", + "department_deleted": "D\u00e9partement supprim\u00e9.", + "sla_policy_created": "Politique SLA cr\u00e9\u00e9e.", + "sla_policy_updated": "Politique SLA mise \u00e0 jour.", + "sla_policy_deleted": "Politique SLA supprim\u00e9e.", + "rule_created": "R\u00e8gle cr\u00e9\u00e9e.", + "rule_updated": "R\u00e8gle mise \u00e0 jour.", + "rule_deleted": "R\u00e8gle supprim\u00e9e.", + "canned_response_created": "R\u00e9ponse pr\u00e9d\u00e9finie cr\u00e9\u00e9e.", + "canned_response_updated": "R\u00e9ponse pr\u00e9d\u00e9finie mise \u00e0 jour.", + "canned_response_deleted": "R\u00e9ponse pr\u00e9d\u00e9finie supprim\u00e9e.", + "macro_created": "Macro cr\u00e9\u00e9e.", + "macro_updated": "Macro mise \u00e0 jour.", + "macro_deleted": "Macro supprim\u00e9e.", + "tag_created": "\u00c9tiquette cr\u00e9\u00e9e.", + "tag_updated": "\u00c9tiquette mise \u00e0 jour.", + "tag_deleted": "\u00c9tiquette supprim\u00e9e.", + "settings_updated": "Param\u00e8tres mis \u00e0 jour." + }, + "middleware": { + "not_admin": "Vous n'\u00eates pas autoris\u00e9 en tant qu'administrateur du support.", + "not_agent": "Vous n'\u00eates pas autoris\u00e9 en tant qu'agent de support." + }, + "inbound": { + "disabled": "L'e-mail entrant est d\u00e9sactiv\u00e9.", + "invalid_signature": "Signature invalide.", + "processing_failed": "Le traitement a \u00e9chou\u00e9." + }, + "labels": { + "status": { + "open": "Ouvert", + "in_progress": "En cours", + "waiting_on_customer": "En attente du client", + "waiting_on_agent": "En attente de l'agent", + "escalated": "Escalad\u00e9", + "resolved": "R\u00e9solu", + "closed": "Ferm\u00e9", + "reopened": "Rouvert" + }, + "priority": { + "low": "Basse", + "medium": "Moyenne", + "high": "\u00c9lev\u00e9e", + "urgent": "Urgente", + "critical": "Critique" + } + } +} diff --git a/src/controllers/admin_canned_responses_controller.ts b/src/controllers/admin_canned_responses_controller.ts index 1f9be47..be20a62 100644 --- a/src/controllers/admin_canned_responses_controller.ts +++ b/src/controllers/admin_canned_responses_controller.ts @@ -1,5 +1,6 @@ import type { HttpContext } from '@adonisjs/core/http' import CannedResponse from '../models/canned_response.js' +import { t } from '../support/i18n.js' export default class AdminCannedResponsesController { async index({ inertia }: HttpContext) { @@ -18,7 +19,7 @@ export default class AdminCannedResponsesController { createdBy: auth.user!.id, }) - session.flash('success', 'Canned response created.') + session.flash('success', t('admin.canned_response_created')) return response.redirect().back() } @@ -34,14 +35,14 @@ export default class AdminCannedResponsesController { }) await cannedResponse.save() - session.flash('success', 'Canned response updated.') + session.flash('success', t('admin.canned_response_updated')) return response.redirect().back() } async destroy({ params, response, session }: HttpContext) { const cannedResponse = await CannedResponse.findOrFail(params.cannedResponse || params.id) await cannedResponse.delete() - session.flash('success', 'Canned response deleted.') + session.flash('success', t('admin.canned_response_deleted')) return response.redirect().back() } } diff --git a/src/controllers/admin_departments_controller.ts b/src/controllers/admin_departments_controller.ts index ad19fdb..e7f27e7 100644 --- a/src/controllers/admin_departments_controller.ts +++ b/src/controllers/admin_departments_controller.ts @@ -1,5 +1,6 @@ import type { HttpContext } from '@adonisjs/core/http' import Department from '../models/department.js' +import { t } from '../support/i18n.js' export default class AdminDepartmentsController { async index({ inertia }: HttpContext) { @@ -32,7 +33,7 @@ export default class AdminDepartmentsController { description: data.description || null, isActive: data.is_active !== false, }) - session.flash('success', 'Department created.') + session.flash('success', t('admin.department_created')) return response.redirect().toRoute('escalated.admin.departments.index') } @@ -51,14 +52,14 @@ export default class AdminDepartmentsController { isActive: data.is_active !== false, }) await department.save() - session.flash('success', 'Department updated.') + session.flash('success', t('admin.department_updated')) return response.redirect().toRoute('escalated.admin.departments.index') } async destroy({ params, response, session }: HttpContext) { const department = await Department.findOrFail(params.id) await department.delete() - session.flash('success', 'Department deleted.') + session.flash('success', t('admin.department_deleted')) return response.redirect().toRoute('escalated.admin.departments.index') } } diff --git a/src/controllers/admin_escalation_rules_controller.ts b/src/controllers/admin_escalation_rules_controller.ts index 691ba9f..343efc3 100644 --- a/src/controllers/admin_escalation_rules_controller.ts +++ b/src/controllers/admin_escalation_rules_controller.ts @@ -1,5 +1,6 @@ import type { HttpContext } from '@adonisjs/core/http' import EscalationRule from '../models/escalation_rule.js' +import { t } from '../support/i18n.js' export default class AdminEscalationRulesController { async index({ inertia }: HttpContext) { @@ -27,7 +28,7 @@ export default class AdminEscalationRulesController { isActive: data.is_active !== false, }) - session.flash('success', 'Rule created.') + session.flash('success', t('admin.rule_created')) return response.redirect().toRoute('escalated.admin.escalation-rules.index') } @@ -54,14 +55,14 @@ export default class AdminEscalationRulesController { }) await rule.save() - session.flash('success', 'Rule updated.') + session.flash('success', t('admin.rule_updated')) return response.redirect().toRoute('escalated.admin.escalation-rules.index') } async destroy({ params, response, session }: HttpContext) { const rule = await EscalationRule.findOrFail(params.id) await rule.delete() - session.flash('success', 'Rule deleted.') + session.flash('success', t('admin.rule_deleted')) return response.redirect().toRoute('escalated.admin.escalation-rules.index') } } diff --git a/src/controllers/admin_macros_controller.ts b/src/controllers/admin_macros_controller.ts index 8ce274a..43ea070 100644 --- a/src/controllers/admin_macros_controller.ts +++ b/src/controllers/admin_macros_controller.ts @@ -1,5 +1,6 @@ import type { HttpContext } from '@adonisjs/core/http' import Macro from '../models/macro.js' +import { t } from '../support/i18n.js' export default class AdminMacrosController { async index({ inertia }: HttpContext) { @@ -19,7 +20,7 @@ export default class AdminMacrosController { createdBy: auth.user!.id, }) - session.flash('success', 'Macro created.') + session.flash('success', t('admin.macro_created')) return response.redirect().back() } @@ -36,14 +37,14 @@ export default class AdminMacrosController { }) await macro.save() - session.flash('success', 'Macro updated.') + session.flash('success', t('admin.macro_updated')) return response.redirect().back() } async destroy({ params, response, session }: HttpContext) { const macro = await Macro.findOrFail(params.macro || params.id) await macro.delete() - session.flash('success', 'Macro deleted.') + session.flash('success', t('admin.macro_deleted')) return response.redirect().back() } } diff --git a/src/controllers/admin_settings_controller.ts b/src/controllers/admin_settings_controller.ts index 93d7008..cc1fea7 100644 --- a/src/controllers/admin_settings_controller.ts +++ b/src/controllers/admin_settings_controller.ts @@ -1,6 +1,7 @@ import type { HttpContext } from '@adonisjs/core/http' import EscalatedSetting from '../models/escalated_setting.js' import { getConfig } from '../helpers/config.js' +import { t } from '../support/i18n.js' export default class AdminSettingsController { async index({ inertia }: HttpContext) { @@ -33,7 +34,7 @@ export default class AdminSettingsController { await EscalatedSetting.set(key, strValue) } - session.flash('success', 'Settings updated.') + session.flash('success', t('admin.settings_updated')) return response.redirect().back() } diff --git a/src/controllers/admin_sla_policies_controller.ts b/src/controllers/admin_sla_policies_controller.ts index 4da743a..48f4931 100644 --- a/src/controllers/admin_sla_policies_controller.ts +++ b/src/controllers/admin_sla_policies_controller.ts @@ -1,6 +1,7 @@ import type { HttpContext } from '@adonisjs/core/http' import SlaPolicy from '../models/sla_policy.js' import { getConfig } from '../helpers/config.js' +import { t } from '../support/i18n.js' export default class AdminSlaPoliciesController { async index({ inertia }: HttpContext) { @@ -32,7 +33,7 @@ export default class AdminSlaPoliciesController { isActive: data.is_active !== false, }) - session.flash('success', 'SLA Policy created.') + session.flash('success', t('admin.sla_policy_created')) return response.redirect().toRoute('escalated.admin.sla-policies.index') } @@ -64,14 +65,14 @@ export default class AdminSlaPoliciesController { }) await policy.save() - session.flash('success', 'SLA Policy updated.') + session.flash('success', t('admin.sla_policy_updated')) return response.redirect().toRoute('escalated.admin.sla-policies.index') } async destroy({ params, response, session }: HttpContext) { const policy = await SlaPolicy.findOrFail(params.id) await policy.delete() - session.flash('success', 'SLA Policy deleted.') + session.flash('success', t('admin.sla_policy_deleted')) return response.redirect().toRoute('escalated.admin.sla-policies.index') } } diff --git a/src/controllers/admin_tags_controller.ts b/src/controllers/admin_tags_controller.ts index d2eb3b6..cb7a084 100644 --- a/src/controllers/admin_tags_controller.ts +++ b/src/controllers/admin_tags_controller.ts @@ -1,5 +1,6 @@ import type { HttpContext } from '@adonisjs/core/http' import Tag from '../models/tag.js' +import { t } from '../support/i18n.js' export default class AdminTagsController { async index({ inertia }: HttpContext) { @@ -14,7 +15,7 @@ export default class AdminTagsController { slug: data.slug || undefined, color: data.color || '#6B7280', }) - session.flash('success', 'Tag created.') + session.flash('success', t('admin.tag_created')) return response.redirect().back() } @@ -27,14 +28,14 @@ export default class AdminTagsController { ...(data.color && { color: data.color }), }) await tag.save() - session.flash('success', 'Tag updated.') + session.flash('success', t('admin.tag_updated')) return response.redirect().back() } async destroy({ params, response, session }: HttpContext) { const tag = await Tag.findOrFail(params.tag || params.id) await tag.delete() - session.flash('success', 'Tag deleted.') + session.flash('success', t('admin.tag_deleted')) return response.redirect().back() } } diff --git a/src/controllers/admin_tickets_controller.ts b/src/controllers/admin_tickets_controller.ts index 8378da2..2f3279e 100644 --- a/src/controllers/admin_tickets_controller.ts +++ b/src/controllers/admin_tickets_controller.ts @@ -9,6 +9,7 @@ import TicketService from '../services/ticket_service.js' import AssignmentService from '../services/assignment_service.js' import MacroService from '../services/macro_service.js' import type { TicketStatus, TicketPriority } from '../types.js' +import { t } from '../support/i18n.js' export default class AdminTicketsController { protected ticketService = new TicketService() @@ -99,7 +100,7 @@ export default class AdminTicketsController { const { body } = ctx.request.only(['body']) const attachments = ctx.request.files('attachments') await this.ticketService.reply(ticket, user as any, body, attachments) - ctx.session.flash('success', 'Reply sent.') + ctx.session.flash('success', t('ticket.reply_sent')) return ctx.response.redirect().back() } @@ -109,7 +110,7 @@ export default class AdminTicketsController { const { body } = ctx.request.only(['body']) const attachments = ctx.request.files('attachments') await this.ticketService.addNote(ticket, user as any, body, attachments) - ctx.session.flash('success', 'Note added.') + ctx.session.flash('success', t('ticket.note_added')) return ctx.response.redirect().back() } @@ -118,7 +119,7 @@ export default class AdminTicketsController { const user = ctx.auth.user! const { agent_id } = ctx.request.only(['agent_id']) await this.assignmentService.assign(ticket, Number(agent_id), user as any) - ctx.session.flash('success', 'Ticket assigned.') + ctx.session.flash('success', t('ticket.assigned')) return ctx.response.redirect().back() } @@ -127,7 +128,7 @@ export default class AdminTicketsController { const user = ctx.auth.user! const { status } = ctx.request.only(['status']) await this.ticketService.changeStatus(ticket, status as TicketStatus, user as any) - ctx.session.flash('success', 'Status updated.') + ctx.session.flash('success', t('ticket.status_updated')) return ctx.response.redirect().back() } @@ -136,7 +137,7 @@ export default class AdminTicketsController { const user = ctx.auth.user! const { priority } = ctx.request.only(['priority']) await this.ticketService.changePriority(ticket, priority as TicketPriority, user as any) - ctx.session.flash('success', 'Priority updated.') + ctx.session.flash('success', t('ticket.priority_updated')) return ctx.response.redirect().back() } @@ -152,7 +153,7 @@ export default class AdminTicketsController { const toRemove = currentTagIds.filter((id: number) => !newTagIds.includes(id)) if (toAdd.length) await this.ticketService.addTags(ticket, toAdd, user as any) if (toRemove.length) await this.ticketService.removeTags(ticket, toRemove, user as any) - ctx.session.flash('success', 'Tags updated.') + ctx.session.flash('success', t('ticket.tags_updated')) return ctx.response.redirect().back() } @@ -161,7 +162,7 @@ export default class AdminTicketsController { const user = ctx.auth.user! const { department_id } = ctx.request.only(['department_id']) await this.ticketService.changeDepartment(ticket, Number(department_id), user as any) - ctx.session.flash('success', 'Department updated.') + ctx.session.flash('success', t('ticket.department_updated')) return ctx.response.redirect().back() } @@ -175,7 +176,7 @@ export default class AdminTicketsController { .firstOrFail() const macroService = new MacroService() await macroService.apply(macro, ticket, user as any) - ctx.session.flash('success', `Macro "${macro.name}" applied.`) + ctx.session.flash('success', t('ticket.macro_applied', { name: macro.name })) return ctx.response.redirect().back() } @@ -184,10 +185,10 @@ export default class AdminTicketsController { const userId = ctx.auth.user!.id if (await ticket.isFollowedBy(userId)) { await ticket.unfollow(userId) - ctx.session.flash('success', 'Unfollowed ticket.') + ctx.session.flash('success', t('ticket.unfollowed')) } else { await ticket.follow(userId) - ctx.session.flash('success', 'Following ticket.') + ctx.session.flash('success', t('ticket.following')) } return ctx.response.redirect().back() } @@ -217,12 +218,12 @@ export default class AdminTicketsController { const replyId = ctx.params.reply || ctx.params.replyId const reply = await Reply.findOrFail(replyId) if (!reply.isInternalNote) { - ctx.session.flash('error', 'Only internal notes can be pinned.') + ctx.session.flash('error', t('ticket.pin_notes_only')) return ctx.response.redirect().back() } reply.isPinned = !reply.isPinned await reply.save() - ctx.session.flash('success', reply.isPinned ? 'Note pinned.' : 'Note unpinned.') + ctx.session.flash('success', reply.isPinned ? t('ticket.note_pinned') : t('ticket.note_unpinned')) return ctx.response.redirect().back() } diff --git a/src/controllers/agent_tickets_controller.ts b/src/controllers/agent_tickets_controller.ts index 2d310d4..bd74c7f 100644 --- a/src/controllers/agent_tickets_controller.ts +++ b/src/controllers/agent_tickets_controller.ts @@ -9,6 +9,7 @@ import TicketService from '../services/ticket_service.js' import AssignmentService from '../services/assignment_service.js' import MacroService from '../services/macro_service.js' import type { TicketStatus, TicketPriority } from '../types.js' +import { t } from '../support/i18n.js' export default class AgentTicketsController { protected ticketService = new TicketService() @@ -97,7 +98,7 @@ export default class AgentTicketsController { await this.ticketService.update(ticket, data) - ctx.session.flash('success', 'Ticket updated.') + ctx.session.flash('success', t('ticket.updated')) return ctx.response.redirect().back() } @@ -112,7 +113,7 @@ export default class AgentTicketsController { await this.ticketService.reply(ticket, user as any, body, attachments) - ctx.session.flash('success', 'Reply sent.') + ctx.session.flash('success', t('ticket.reply_sent')) return ctx.response.redirect().back() } @@ -127,7 +128,7 @@ export default class AgentTicketsController { await this.ticketService.addNote(ticket, user as any, body, attachments) - ctx.session.flash('success', 'Note added.') + ctx.session.flash('success', t('ticket.note_added')) return ctx.response.redirect().back() } @@ -141,7 +142,7 @@ export default class AgentTicketsController { await this.assignmentService.assign(ticket, Number(agent_id), user as any) - ctx.session.flash('success', 'Ticket assigned.') + ctx.session.flash('success', t('ticket.assigned')) return ctx.response.redirect().back() } @@ -155,7 +156,7 @@ export default class AgentTicketsController { await this.ticketService.changeStatus(ticket, status as TicketStatus, user as any) - ctx.session.flash('success', 'Status updated.') + ctx.session.flash('success', t('ticket.status_updated')) return ctx.response.redirect().back() } @@ -169,7 +170,7 @@ export default class AgentTicketsController { await this.ticketService.changePriority(ticket, priority as TicketPriority, user as any) - ctx.session.flash('success', 'Priority updated.') + ctx.session.flash('success', t('ticket.priority_updated')) return ctx.response.redirect().back() } @@ -191,7 +192,7 @@ export default class AgentTicketsController { if (toAdd.length) await this.ticketService.addTags(ticket, toAdd, user as any) if (toRemove.length) await this.ticketService.removeTags(ticket, toRemove, user as any) - ctx.session.flash('success', 'Tags updated.') + ctx.session.flash('success', t('ticket.tags_updated')) return ctx.response.redirect().back() } @@ -205,7 +206,7 @@ export default class AgentTicketsController { await this.ticketService.changeDepartment(ticket, Number(department_id), user as any) - ctx.session.flash('success', 'Department updated.') + ctx.session.flash('success', t('ticket.department_updated')) return ctx.response.redirect().back() } @@ -225,7 +226,7 @@ export default class AgentTicketsController { const macroService = new MacroService() await macroService.apply(macro, ticket, user as any) - ctx.session.flash('success', `Macro "${macro.name}" applied.`) + ctx.session.flash('success', t('ticket.macro_applied', { name: macro.name })) return ctx.response.redirect().back() } @@ -238,10 +239,10 @@ export default class AgentTicketsController { if (await ticket.isFollowedBy(userId)) { await ticket.unfollow(userId) - ctx.session.flash('success', 'Unfollowed ticket.') + ctx.session.flash('success', t('ticket.unfollowed')) } else { await ticket.follow(userId) - ctx.session.flash('success', 'Following ticket.') + ctx.session.flash('success', t('ticket.following')) } return ctx.response.redirect().back() @@ -293,14 +294,14 @@ export default class AgentTicketsController { const reply = await Reply.findOrFail(replyId) if (!reply.isInternalNote) { - ctx.session.flash('error', 'Only internal notes can be pinned.') + ctx.session.flash('error', t('ticket.pin_notes_only')) return ctx.response.redirect().back() } reply.isPinned = !reply.isPinned await reply.save() - ctx.session.flash('success', reply.isPinned ? 'Note pinned.' : 'Note unpinned.') + ctx.session.flash('success', reply.isPinned ? t('ticket.note_pinned') : t('ticket.note_unpinned')) return ctx.response.redirect().back() } } diff --git a/src/controllers/bulk_actions_controller.ts b/src/controllers/bulk_actions_controller.ts index 9e3eebd..bcc5e4a 100644 --- a/src/controllers/bulk_actions_controller.ts +++ b/src/controllers/bulk_actions_controller.ts @@ -3,6 +3,7 @@ import Ticket from '../models/ticket.js' import TicketService from '../services/ticket_service.js' import AssignmentService from '../services/assignment_service.js' import type { TicketStatus, TicketPriority } from '../types.js' +import { t } from '../support/i18n.js' export default class BulkActionsController { protected ticketService = new TicketService() @@ -46,7 +47,7 @@ export default class BulkActionsController { } } - session.flash('success', `${successCount} ticket(s) updated.`) + session.flash('success', t('bulk.updated', { count: successCount })) return response.redirect().back() } } diff --git a/src/controllers/customer_tickets_controller.ts b/src/controllers/customer_tickets_controller.ts index 25a72cb..2e43288 100644 --- a/src/controllers/customer_tickets_controller.ts +++ b/src/controllers/customer_tickets_controller.ts @@ -2,6 +2,7 @@ import type { HttpContext } from '@adonisjs/core/http' import Department from '../models/department.js' import TicketService from '../services/ticket_service.js' import { getConfig } from '../helpers/config.js' +import { t } from '../support/i18n.js' export default class CustomerTicketsController { protected ticketService = new TicketService() @@ -53,7 +54,7 @@ export default class CustomerTicketsController { attachments, }) - session.flash('success', 'Ticket created successfully.') + session.flash('success', t('ticket.created')) return response.redirect().toRoute('escalated.customer.tickets.show', { ticket: ticket.reference }) } @@ -93,7 +94,7 @@ export default class CustomerTicketsController { await this.ticketService.reply(ticket, user as any, body, attachments) - ctx.session.flash('success', 'Reply sent.') + ctx.session.flash('success', t('ticket.reply_sent')) return ctx.response.redirect().back() } @@ -108,12 +109,12 @@ export default class CustomerTicketsController { this.authorizeCustomer(ticket, user) if (!config.tickets.allowCustomerClose) { - return ctx.response.forbidden({ error: 'Customers cannot close tickets.' }) + return ctx.response.forbidden({ error: t('ticket.customer_close_forbidden') }) } await this.ticketService.close(ticket, user as any) - ctx.session.flash('success', 'Ticket closed.') + ctx.session.flash('success', t('ticket.closed')) return ctx.response.redirect().back() } @@ -128,7 +129,7 @@ export default class CustomerTicketsController { await this.ticketService.reopen(ticket, user as any) - ctx.session.flash('success', 'Ticket reopened.') + ctx.session.flash('success', t('ticket.reopened')) return ctx.response.redirect().back() } diff --git a/src/controllers/guest_tickets_controller.ts b/src/controllers/guest_tickets_controller.ts index 05c67cd..e8f2581 100644 --- a/src/controllers/guest_tickets_controller.ts +++ b/src/controllers/guest_tickets_controller.ts @@ -9,6 +9,7 @@ import AttachmentService from '../services/attachment_service.js' import { ESCALATED_EVENTS } from '../events/index.js' import { getConfig } from '../helpers/config.js' import type { TicketPriority } from '../types.js' +import { t } from '../support/i18n.js' export default class GuestTicketsController { protected attachmentService = new AttachmentService() @@ -70,7 +71,7 @@ export default class GuestTicketsController { await emitter.emit(ESCALATED_EVENTS.TICKET_CREATED, { ticket }) - session.flash('success', 'Ticket created. Save this link to check your ticket status.') + session.flash('success', t('guest.created')) return response.redirect().toRoute('escalated.guest.tickets.show', { token: ticket.guestToken }) } @@ -104,7 +105,7 @@ export default class GuestTicketsController { .firstOrFail() if (ticket.status === 'closed') { - session.flash('error', 'This ticket is closed.') + session.flash('error', t('guest.ticket_closed')) return response.redirect().back() } @@ -127,7 +128,7 @@ export default class GuestTicketsController { await emitter.emit(ESCALATED_EVENTS.REPLY_CREATED, { reply }) - session.flash('success', 'Reply sent.') + session.flash('success', t('guest.reply_sent')) return response.redirect().back() } } diff --git a/src/controllers/inbound_email_controller.ts b/src/controllers/inbound_email_controller.ts index 634c840..e47f9a8 100644 --- a/src/controllers/inbound_email_controller.ts +++ b/src/controllers/inbound_email_controller.ts @@ -3,6 +3,7 @@ import EscalatedSetting from '../models/escalated_setting.js' import InboundEmailService from '../services/inbound_email_service.js' import { getConfig } from '../helpers/config.js' import type { InboundMessage } from '../types.js' +import { t } from '../support/i18n.js' export default class InboundEmailController { protected service = new InboundEmailService() @@ -19,14 +20,14 @@ export default class InboundEmailController { config.inboundEmail?.enabled ?? false ) if (!enabled) { - return response.notFound({ error: 'Inbound email is disabled.' }) + return response.notFound({ error: t('inbound.disabled') }) } const adapter = params.adapter as string // Verify the request based on adapter if (!this.verifyRequest(adapter, request)) { - return response.forbidden({ error: 'Invalid signature.' }) + return response.forbidden({ error: t('inbound.invalid_signature') }) } try { @@ -38,7 +39,7 @@ export default class InboundEmailController { id: inboundEmail.id, }) } catch (error: any) { - return response.internalServerError({ error: 'Processing failed.' }) + return response.internalServerError({ error: t('inbound.processing_failed') }) } } diff --git a/src/controllers/satisfaction_rating_controller.ts b/src/controllers/satisfaction_rating_controller.ts index 3da3f2e..f2e5ca9 100644 --- a/src/controllers/satisfaction_rating_controller.ts +++ b/src/controllers/satisfaction_rating_controller.ts @@ -1,6 +1,7 @@ import type { HttpContext } from '@adonisjs/core/http' import Ticket from '../models/ticket.js' import SatisfactionRating from '../models/satisfaction_rating.js' +import { t } from '../support/i18n.js' export default class SatisfactionRatingController { /** @@ -12,13 +13,13 @@ export default class SatisfactionRatingController { const { rating, comment } = ctx.request.only(['rating', 'comment']) if (!['resolved', 'closed'].includes(ticket.status)) { - ctx.session.flash('error', 'You can only rate resolved or closed tickets.') + ctx.session.flash('error', t('rating.only_resolved_closed')) return ctx.response.redirect().back() } const existing = await SatisfactionRating.query().where('ticket_id', ticket.id).first() if (existing) { - ctx.session.flash('error', 'This ticket has already been rated.') + ctx.session.flash('error', t('rating.already_rated')) return ctx.response.redirect().back() } @@ -30,7 +31,7 @@ export default class SatisfactionRatingController { ratedById: user.id, }) - ctx.session.flash('success', 'Thank you for your feedback!') + ctx.session.flash('success', t('rating.thank_you')) return ctx.response.redirect().back() } @@ -45,13 +46,13 @@ export default class SatisfactionRatingController { const { rating, comment } = request.only(['rating', 'comment']) if (!['resolved', 'closed'].includes(ticket.status)) { - session.flash('error', 'You can only rate resolved or closed tickets.') + session.flash('error', t('rating.only_resolved_closed')) return response.redirect().back() } const existing = await SatisfactionRating.query().where('ticket_id', ticket.id).first() if (existing) { - session.flash('error', 'This ticket has already been rated.') + session.flash('error', t('rating.already_rated')) return response.redirect().back() } @@ -63,7 +64,7 @@ export default class SatisfactionRatingController { ratedById: null, }) - session.flash('success', 'Thank you for your feedback!') + session.flash('success', t('rating.thank_you')) return response.redirect().back() } } diff --git a/src/middleware/ensure_is_admin.ts b/src/middleware/ensure_is_admin.ts index 312868b..171652b 100644 --- a/src/middleware/ensure_is_admin.ts +++ b/src/middleware/ensure_is_admin.ts @@ -1,5 +1,6 @@ import type { HttpContext } from '@adonisjs/core/http' import type { NextFn } from '@adonisjs/core/types/http' +import { t } from '../support/i18n.js' /** * Middleware to ensure the current user is an Escalated administrator. @@ -10,7 +11,7 @@ export default class EnsureIsAdmin { const user = ctx.auth?.user if (!user) { - return ctx.response.forbidden({ error: 'You are not authorized as a support administrator.' }) + return ctx.response.forbidden({ error: t('middleware.not_admin') }) } const isAdmin = config?.authorization?.isAdmin @@ -18,7 +19,7 @@ export default class EnsureIsAdmin { : false if (!isAdmin) { - return ctx.response.forbidden({ error: 'You are not authorized as a support administrator.' }) + return ctx.response.forbidden({ error: t('middleware.not_admin') }) } return next() diff --git a/src/middleware/ensure_is_agent.ts b/src/middleware/ensure_is_agent.ts index 0cca3c2..d81134e 100644 --- a/src/middleware/ensure_is_agent.ts +++ b/src/middleware/ensure_is_agent.ts @@ -1,5 +1,6 @@ import type { HttpContext } from '@adonisjs/core/http' import type { NextFn } from '@adonisjs/core/types/http' +import { t } from '../support/i18n.js' /** * Middleware to ensure the current user is an Escalated support agent. @@ -10,7 +11,7 @@ export default class EnsureIsAgent { const user = ctx.auth?.user if (!user) { - return ctx.response.forbidden({ error: 'You are not authorized as a support agent.' }) + return ctx.response.forbidden({ error: t('middleware.not_agent') }) } const isAgent = config?.authorization?.isAgent @@ -23,7 +24,7 @@ export default class EnsureIsAgent { : false if (!isAgent && !isAdmin) { - return ctx.response.forbidden({ error: 'You are not authorized as a support agent.' }) + return ctx.response.forbidden({ error: t('middleware.not_agent') }) } return next() diff --git a/src/support/i18n.ts b/src/support/i18n.ts new file mode 100644 index 0000000..fc9d84a --- /dev/null +++ b/src/support/i18n.ts @@ -0,0 +1,64 @@ +import { readFileSync, existsSync } from 'fs' +import { join } from 'path' +import { fileURLToPath } from 'url' +import { dirname } from 'path' + +let translations: Record = {} +let currentLocale = 'en' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) +const langDir = join(__dirname, '../../resources/lang') + +function loadLocale(locale: string): Record { + const filePath = join(langDir, locale, 'messages.json') + if (existsSync(filePath)) { + return JSON.parse(readFileSync(filePath, 'utf-8')) + } + return {} +} + +// Load all locales at startup +for (const locale of ['en', 'es', 'fr', 'de']) { + translations[locale] = loadLocale(locale) +} + +export function setLocale(locale: string) { + currentLocale = locale +} + +export function getLocale(): string { + return currentLocale +} + +export function t(key: string, replacements?: Record): string { + const keys = key.split('.') + let value: any = translations[currentLocale] + + for (const k of keys) { + if (value && typeof value === 'object' && k in value) { + value = value[k] + } else { + // Fallback to English + value = translations['en'] + for (const fk of keys) { + if (value && typeof value === 'object' && fk in value) { + value = value[fk] + } else { + return key // Return key if not found + } + } + break + } + } + + if (typeof value !== 'string') return key + + if (replacements) { + for (const [rKey, rValue] of Object.entries(replacements)) { + value = value.replace(`:${rKey}`, String(rValue)) + } + } + + return value +} diff --git a/src/types.ts b/src/types.ts index d8a5def..504e9ad 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,8 @@ |-------------------------------------------------------------------------- */ +import { t } from './support/i18n.js' + /** * Ticket statuses */ @@ -61,7 +63,23 @@ export const STATUS_TRANSITIONS: Record = { } /** - * Status labels + * Status labels (localized via i18n) + */ +export function getStatusLabels(): Record { + return { + open: t('labels.status.open'), + in_progress: t('labels.status.in_progress'), + waiting_on_customer: t('labels.status.waiting_on_customer'), + waiting_on_agent: t('labels.status.waiting_on_agent'), + escalated: t('labels.status.escalated'), + resolved: t('labels.status.resolved'), + closed: t('labels.status.closed'), + reopened: t('labels.status.reopened'), + } +} + +/** + * Status labels (backward-compatible constant, English defaults) */ export const STATUS_LABELS: Record = { open: 'Open', @@ -89,7 +107,20 @@ export const STATUS_COLORS: Record = { } /** - * Priority labels + * Priority labels (localized via i18n) + */ +export function getPriorityLabels(): Record { + return { + low: t('labels.priority.low'), + medium: t('labels.priority.medium'), + high: t('labels.priority.high'), + urgent: t('labels.priority.urgent'), + critical: t('labels.priority.critical'), + } +} + +/** + * Priority labels (backward-compatible constant, English defaults) */ export const PRIORITY_LABELS: Record = { low: 'Low', @@ -142,6 +173,8 @@ export function isOpenStatus(status: TicketStatus): boolean { export interface EscalatedConfig { mode: 'self-hosted' | 'synced' | 'cloud' + locale?: string + userModel: string hosted: {