@consilioweb/payload-support 5.0.0 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +249 -22
  2. package/dist/components/RichTextEditor/index.js +1 -1
  3. package/dist/components/TicketConversation/RewriteDropdown.js +1 -1
  4. package/dist/components/TicketConversation/components/AISummaryPanel.js +3 -0
  5. package/dist/components/TicketConversation/components/ActionPanels.js +13 -8
  6. package/dist/components/TicketConversation/components/ClientHistory.js +1 -0
  7. package/dist/components/TicketConversation/components/CodeBlock.js +1 -0
  8. package/dist/components/TicketConversation/components/CodeBlockInserter.js +1 -1
  9. package/dist/components/TicketConversation/components/QuickActions.js +10 -8
  10. package/dist/components/TicketConversation/components/TicketHeader.js +2 -0
  11. package/dist/components/TicketConversation/components/TimeTrackingPanel.js +15 -9
  12. package/dist/components/TicketConversation/index.d.ts +10 -0
  13. package/dist/components/TicketConversation/index.js +25 -7
  14. package/dist/index.cjs +306 -60
  15. package/dist/index.d.cts +107 -38
  16. package/dist/index.d.ts +107 -38
  17. package/dist/index.js +300 -59
  18. package/dist/styles/BillingView.module.scss +11 -11
  19. package/dist/styles/ChatView.module.scss +7 -7
  20. package/dist/styles/CommandPalette.module.scss +1 -1
  21. package/dist/styles/CrmView.module.scss +9 -9
  22. package/dist/styles/EmailTracking.module.scss +6 -6
  23. package/dist/styles/ImportConversation.module.scss +5 -5
  24. package/dist/styles/Logs.module.scss +5 -5
  25. package/dist/styles/NewTicket.module.scss +2 -2
  26. package/dist/styles/PendingEmails.module.scss +13 -13
  27. package/dist/styles/SupportDashboard.module.scss +24 -8
  28. package/dist/styles/TicketDetail.module.scss +20 -20
  29. package/dist/styles/TicketInbox.module.scss +6 -6
  30. package/dist/styles/TicketingSettings.module.scss +10 -10
  31. package/dist/styles/TimeDashboard.module.scss +6 -6
  32. package/dist/styles/_tokens.scss +3 -1
  33. package/dist/utils/db.js +20 -0
  34. package/dist/utils/readSettings.js +150 -0
  35. package/dist/views/BillingView/client.js +15 -8
  36. package/dist/views/ChatView/client.js +2 -0
  37. package/dist/views/CrmView/client.js +2 -0
  38. package/dist/views/EmailTrackingView/client.js +23 -11
  39. package/dist/views/ImportConversationView/client.js +1 -0
  40. package/dist/views/LogsView/client.js +1 -1
  41. package/dist/views/NewTicketView/client.js +20 -13
  42. package/dist/views/PendingEmailsView/client.js +9 -3
  43. package/dist/views/SupportDashboardView/client.js +17 -19
  44. package/dist/views/TicketDetailView/client.js +23 -11
  45. package/dist/views/TicketInboxView/client.js +52 -26
  46. package/dist/views/TicketingSettingsView/TicketingSettings.module.scss +7 -7
  47. package/dist/views/TicketingSettingsView/client.js +44 -26
  48. package/dist/views/TimeDashboardView/client.js +10 -7
  49. package/dist/views/shared/ErrorBoundary.d.ts +36 -1
  50. package/dist/views/shared/ErrorBoundary.js +59 -28
  51. package/dist/views/shared/adminTokens.d.ts +15 -5
  52. package/dist/views/shared/adminTokens.js +23 -7
  53. package/dist/views/shared/icons.d.ts +49 -0
  54. package/dist/views/shared/icons.js +92 -0
  55. package/dist/views/shared/locales/en.json +15 -1
  56. package/dist/views/shared/locales/fr.json +15 -1
  57. package/package.json +9 -4
  58. package/scripts/copy-subpath-types.mjs +103 -0
  59. package/scripts/uninstall-data.mjs +178 -0
  60. package/scripts/uninstall.mjs +184 -0
  61. package/scripts/verify-dist-imports.mjs +84 -0
  62. package/src/collections/Tickets.ts +7 -0
  63. package/src/collections/TimeEntries.ts +11 -3
  64. package/src/components/RichTextEditor/index.tsx +1 -1
  65. package/src/components/TicketConversation/RewriteDropdown.tsx +1 -1
  66. package/src/components/TicketConversation/components/AISummaryPanel.tsx +3 -0
  67. package/src/components/TicketConversation/components/ActionPanels.tsx +13 -8
  68. package/src/components/TicketConversation/components/ClientHistory.tsx +1 -0
  69. package/src/components/TicketConversation/components/CodeBlock.tsx +1 -0
  70. package/src/components/TicketConversation/components/CodeBlockInserter.tsx +1 -1
  71. package/src/components/TicketConversation/components/QuickActions.tsx +10 -8
  72. package/src/components/TicketConversation/components/TicketHeader.tsx +2 -0
  73. package/src/components/TicketConversation/components/TimeTrackingPanel.tsx +17 -9
  74. package/src/components/TicketConversation/index.tsx +41 -7
  75. package/src/endpoints/delete-account.ts +191 -47
  76. package/src/endpoints/export-data.ts +104 -4
  77. package/src/endpoints/purge-logs.ts +6 -15
  78. package/src/index.ts +2 -0
  79. package/src/plugin.ts +51 -1
  80. package/src/portal/LiveChat.tsx +1 -1
  81. package/src/portal/auth/profile/page.tsx +3 -2
  82. package/src/portal/icons.tsx +91 -0
  83. package/src/styles/BillingView.module.scss +11 -11
  84. package/src/styles/ChatView.module.scss +7 -7
  85. package/src/styles/CommandPalette.module.scss +1 -1
  86. package/src/styles/CrmView.module.scss +9 -9
  87. package/src/styles/EmailTracking.module.scss +6 -6
  88. package/src/styles/ImportConversation.module.scss +5 -5
  89. package/src/styles/Logs.module.scss +5 -5
  90. package/src/styles/NewTicket.module.scss +2 -2
  91. package/src/styles/PendingEmails.module.scss +13 -13
  92. package/src/styles/SupportDashboard.module.scss +24 -8
  93. package/src/styles/TicketDetail.module.scss +20 -20
  94. package/src/styles/TicketInbox.module.scss +6 -6
  95. package/src/styles/TicketingSettings.module.scss +10 -10
  96. package/src/styles/TimeDashboard.module.scss +6 -6
  97. package/src/styles/_tokens.scss +3 -1
  98. package/src/types.ts +20 -0
  99. package/src/utils/retention.ts +111 -0
  100. package/src/utils/slugs.ts +15 -0
  101. package/src/views/BillingView/client.tsx +17 -8
  102. package/src/views/ChatView/client.tsx +2 -0
  103. package/src/views/CrmView/client.tsx +2 -0
  104. package/src/views/EmailTrackingView/client.tsx +18 -7
  105. package/src/views/ImportConversationView/client.tsx +1 -0
  106. package/src/views/LogsView/client.tsx +1 -1
  107. package/src/views/NewTicketView/client.tsx +22 -13
  108. package/src/views/PendingEmailsView/client.tsx +9 -3
  109. package/src/views/SupportDashboardView/client.tsx +11 -5
  110. package/src/views/TicketDetailView/client.tsx +25 -11
  111. package/src/views/TicketInboxView/client.tsx +47 -7
  112. package/src/views/TicketingSettingsView/TicketingSettings.module.scss +7 -7
  113. package/src/views/TicketingSettingsView/client.tsx +201 -144
  114. package/src/views/TimeDashboardView/client.tsx +10 -7
  115. package/src/views/shared/ErrorBoundary.tsx +95 -31
  116. package/src/views/shared/adminTokens.ts +28 -11
  117. package/src/views/shared/icons.tsx +146 -0
  118. package/src/views/shared/locales/en.json +15 -1
  119. package/src/views/shared/locales/fr.json +15 -1
  120. package/src/types/lucide-react.d.ts +0 -42
@@ -34,6 +34,11 @@ export function TimeTrackingPanel({
34
34
  duration, setDuration, timeDescription, setTimeDescription,
35
35
  handleAddTime, addingTime, timeSuccess,
36
36
  }: TimeTrackingPanelProps) {
37
+ // The two `<label>`s of the manual entry row exist but were not associated
38
+ // with anything. useId, never a literal: the panel is one per ticket screen
39
+ // but the pattern has to hold if it is ever mounted twice.
40
+ const minutesId = React.useId()
41
+ const workDescriptionId = React.useId()
37
42
  const totalH = Math.floor(totalMinutes / 60)
38
43
  const totalM = totalMinutes % 60
39
44
 
@@ -63,6 +68,7 @@ export function TimeTrackingPanel({
63
68
  <div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
64
69
  {[-5, -1, 1, 5, 15, 30].map((m) => (
65
70
  <button
71
+ type="button"
66
72
  key={m}
67
73
  onClick={() => setTimerSeconds((p) => Math.max(0, p + m * 60))}
68
74
  style={{ width: Math.abs(m) >= 15 ? '32px' : '28px', height: '28px', borderRadius: '6px', border: '1px solid #e2e8f0', background: 'white', cursor: 'pointer', fontSize: Math.abs(m) >= 15 ? '12px' : '14px', fontWeight: 700, color: '#64748b' }}
@@ -75,28 +81,30 @@ export function TimeTrackingPanel({
75
81
  )}
76
82
 
77
83
  {!timerRunning && timerSeconds === 0 && (
78
- <button onClick={() => handleTimerStart(true)} style={{ ...s.btn('#dc2626', false), fontSize: '12px', padding: '6px 16px', display: 'flex', alignItems: 'center', gap: '6px' }}>
84
+ <button type="button" onClick={() => handleTimerStart(true)} style={{ ...s.btn('#dc2626', false), fontSize: '12px', padding: '6px 16px', display: 'flex', alignItems: 'center', gap: '6px' }}>
79
85
  &#9654; Démarrer
80
86
  </button>
81
87
  )}
82
88
  {timerRunning && (
83
- <button onClick={handleTimerStop} style={{ ...s.btn('#374151', false), fontSize: '12px', padding: '6px 16px', display: 'flex', alignItems: 'center', gap: '6px' }}>
89
+ <button type="button" onClick={handleTimerStop} style={{ ...s.btn('#374151', false), fontSize: '12px', padding: '6px 16px', display: 'flex', alignItems: 'center', gap: '6px' }}>
84
90
  &#9208; Pause
85
91
  </button>
86
92
  )}
87
93
  {!timerRunning && timerSeconds > 0 && (
88
94
  <>
89
- <button onClick={() => handleTimerStart(false)} style={{ ...s.btn('#dc2626', false), fontSize: '12px', padding: '6px 14px' }}>
95
+ <button type="button" onClick={() => handleTimerStart(false)} style={{ ...s.btn('#dc2626', false), fontSize: '12px', padding: '6px 14px' }}>
90
96
  &#9654; Reprendre
91
97
  </button>
92
98
  <input
93
99
  type="text"
100
+ aria-label="Description du temps en cours"
94
101
  value={timerDescription}
95
102
  onChange={(e) => setTimerDescription(e.target.value)}
96
103
  placeholder="Description..."
97
104
  style={{ ...s.input, fontSize: '12px', flex: 1, minWidth: '120px' }}
98
105
  />
99
106
  <button
107
+ type="button"
100
108
  onClick={handleTimerSave}
101
109
  disabled={addingTime || timerSeconds < 60}
102
110
  style={{ ...s.btn('#16a34a', addingTime || timerSeconds < 60), fontSize: '12px', padding: '6px 14px' }}
@@ -104,7 +112,7 @@ export function TimeTrackingPanel({
104
112
  >
105
113
  &#128190; {Math.round(timerSeconds / 60)} min
106
114
  </button>
107
- <button onClick={handleTimerDiscard} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '14px', color: '#94a3b8', padding: '4px' }} title="Annuler">
115
+ <button type="button" onClick={handleTimerDiscard} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '14px', color: '#94a3b8', padding: '4px' }} title="Annuler">
108
116
  &#10005;
109
117
  </button>
110
118
  </>
@@ -119,14 +127,14 @@ export function TimeTrackingPanel({
119
127
  {/* Manual time entry */}
120
128
  <div style={{ display: 'flex', gap: '8px', alignItems: 'flex-end', flexWrap: 'wrap', marginBottom: '14px' }}>
121
129
  <div>
122
- <label style={{ display: 'block', fontSize: '11px', color: C.textSecondary, marginBottom: '3px', fontWeight: 600 }}>Min</label>
123
- <input type="number" min="1" value={duration} onChange={(e) => setDuration(e.target.value)} placeholder="30" style={{ ...s.input, width: '80px' }} />
130
+ <label htmlFor={minutesId} style={{ display: 'block', fontSize: '11px', color: C.textSecondary, marginBottom: '3px', fontWeight: 600 }}>Min</label>
131
+ <input id={minutesId} type="number" min="1" value={duration} onChange={(e) => setDuration(e.target.value)} placeholder="30" style={{ ...s.input, width: '80px' }} />
124
132
  </div>
125
133
  <div style={{ flex: 1 }}>
126
- <label style={{ display: 'block', fontSize: '11px', color: C.textSecondary, marginBottom: '3px', fontWeight: 600 }}>Description</label>
127
- <input type="text" value={timeDescription} onChange={(e) => setTimeDescription(e.target.value)} placeholder="Travail effectué..." style={{ ...s.input, width: '100%' }} />
134
+ <label htmlFor={workDescriptionId} style={{ display: 'block', fontSize: '11px', color: C.textSecondary, marginBottom: '3px', fontWeight: 600 }}>Description</label>
135
+ <input id={workDescriptionId} type="text" value={timeDescription} onChange={(e) => setTimeDescription(e.target.value)} placeholder="Travail effectué..." style={{ ...s.input, width: '100%' }} />
128
136
  </div>
129
- <button onClick={handleAddTime} disabled={addingTime || !duration} style={s.btn(C.amber, addingTime || !duration)}>
137
+ <button type="button" onClick={handleAddTime} disabled={addingTime || !duration} style={s.btn(C.amber, addingTime || !duration)}>
130
138
  {addingTime ? '...' : '+ Temps'}
131
139
  </button>
132
140
  {timeSuccess && <span style={{ fontSize: '12px', color: '#16a34a', fontWeight: 600 }}>{timeSuccess}</span>}
@@ -24,9 +24,10 @@ import { useDocumentIdFromUrl } from './hooks/useDocumentIdFromUrl'
24
24
  import { useFeatures } from './hooks/useFeatures'
25
25
  import { SkeletonText } from './SkeletonText'
26
26
  import { RewriteDropdown } from './RewriteDropdown'
27
+ import { AdminErrorBoundary } from '../../views/shared/ErrorBoundary'
27
28
  import '../../styles/theme.css'
28
29
 
29
- const TicketConversation: React.FC = () => {
30
+ const TicketConversationInner: React.FC = () => {
30
31
  const { id } = useDocumentIdFromUrl()
31
32
  const { features } = useFeatures()
32
33
  const [messages, setMessages] = useState<Message[]>([])
@@ -416,6 +417,7 @@ const TicketConversation: React.FC = () => {
416
417
  <div style={{ flex: 1 }}>
417
418
  <input
418
419
  type="text"
420
+ aria-label="Rechercher dans la conversation"
419
421
  value={searchQuery}
420
422
  onChange={(e) => setSearchQuery(e.target.value)}
421
423
  placeholder="Rechercher..."
@@ -442,6 +444,7 @@ const TicketConversation: React.FC = () => {
442
444
  <>
443
445
  {showCollapse && hiddenCount > 0 && (
444
446
  <button
447
+ type="button"
445
448
  onClick={() => setMessagesCollapsed(false)}
446
449
  style={{
447
450
  background: 'none', border: `1px dashed ${C.border}`, borderRadius: '6px',
@@ -454,6 +457,7 @@ const TicketConversation: React.FC = () => {
454
457
  )}
455
458
  {!messagesCollapsed && filtered.length > 1 && !isSearching && (
456
459
  <button
460
+ type="button"
457
461
  onClick={() => setMessagesCollapsed(true)}
458
462
  style={{
459
463
  background: 'none', border: `1px dashed ${C.border}`, borderRadius: '6px',
@@ -501,6 +505,7 @@ const TicketConversation: React.FC = () => {
501
505
  <span style={s.badge(C.emailBg, C.orange)}>Email</span>
502
506
  ) : (
503
507
  <select
508
+ aria-label="Auteur du message"
504
509
  value={msg.authorType}
505
510
  onChange={(e) => handleToggleAuthor(msg.id, e.target.value === 'admin' ? 'client' : 'admin')}
506
511
  disabled={togglingAuthor === msg.id}
@@ -537,6 +542,7 @@ const TicketConversation: React.FC = () => {
537
542
  {/* Split button */}
538
543
  {!msg.isInternal && !(msg as unknown as { deletedAt?: string }).deletedAt && (
539
544
  <button
545
+ type="button"
540
546
  onClick={() => handleSplitMessage(msg.id, ticketSubject)}
541
547
  style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '10px', color: '#6b7280', padding: 0 }}
542
548
  title="Extraire en nouveau ticket"
@@ -579,16 +585,17 @@ const TicketConversation: React.FC = () => {
579
585
  {editingMsg === msg.id ? (
580
586
  <div style={{ marginTop: '6px' }}>
581
587
  <textarea
588
+ aria-label="Editer le message"
582
589
  value={editBody}
583
590
  onChange={(e) => { setEditBody(e.target.value); setEditHtml(e.target.value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br />')) }}
584
591
  rows={4}
585
592
  style={{ ...s.input, width: '100%', resize: 'vertical', fontSize: '13px' }}
586
593
  />
587
594
  <div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
588
- <button onClick={() => handleEditSave(msg.id)} disabled={savingEdit || (!editBody.trim() && !editHtml)} style={{ ...s.btn(C.blue, savingEdit), fontSize: '11px', padding: '5px 12px' }}>
595
+ <button type="button" onClick={() => handleEditSave(msg.id)} disabled={savingEdit || (!editBody.trim() && !editHtml)} style={{ ...s.btn(C.blue, savingEdit), fontSize: '11px', padding: '5px 12px' }}>
589
596
  {savingEdit ? '...' : 'Enregistrer'}
590
597
  </button>
591
- <button onClick={handleEditCancel} style={{ ...s.ghostBtn('#6b7280'), fontSize: '11px', padding: '5px 12px' }}>
598
+ <button type="button" onClick={handleEditCancel} style={{ ...s.ghostBtn('#6b7280'), fontSize: '11px', padding: '5px 12px' }}>
592
599
  Annuler
593
600
  </button>
594
601
  </div>
@@ -775,6 +782,7 @@ const TicketConversation: React.FC = () => {
775
782
  ))}
776
783
  {features.ai && (
777
784
  <button
785
+ type="button"
778
786
  onClick={handleAiSuggestReply}
779
787
  disabled={aiReplying || messages.length === 0}
780
788
  style={{ ...s.outlineBtn('#17807c', aiReplying || messages.length === 0), fontSize: '11px', padding: '3px 10px', borderRadius: '14px' }}
@@ -800,7 +808,7 @@ const TicketConversation: React.FC = () => {
800
808
  />
801
809
  {features.canned && cannedResponses.length > 0 && (
802
810
  <>
803
- <select onChange={handleCannedSelect} style={{ ...s.input, fontSize: '11px', padding: '3px 8px', fontWeight: 600 }}>
811
+ <select aria-label="Reponse rapide" onChange={handleCannedSelect} style={{ ...s.input, fontSize: '11px', padding: '3px 8px', fontWeight: 600 }}>
804
812
  <option value="">Réponse rapide...</option>
805
813
  {cannedResponses.map((cr) => (
806
814
  <option key={cr.id} value={String(cr.id)}>{cr.title}</option>
@@ -818,6 +826,7 @@ const TicketConversation: React.FC = () => {
818
826
  {/* Reply textarea (replacing RichTextEditor) */}
819
827
  <div style={{ border: `1px solid ${C.border}`, borderRadius: '8px', overflow: 'hidden' }}>
820
828
  <textarea
829
+ aria-label="Reponse au client"
821
830
  value={replyBody}
822
831
  onChange={(e) => {
823
832
  const text = e.target.value
@@ -831,7 +840,6 @@ const TicketConversation: React.FC = () => {
831
840
  minHeight: '120px',
832
841
  padding: '12px',
833
842
  border: 'none',
834
- outline: 'none',
835
843
  fontSize: '14px',
836
844
  lineHeight: 1.5,
837
845
  resize: 'vertical',
@@ -848,6 +856,7 @@ const TicketConversation: React.FC = () => {
848
856
  ref={fileInputRef}
849
857
  type="file"
850
858
  multiple
859
+ aria-label="Joindre des fichiers"
851
860
  onChange={handleReplyFileChange}
852
861
  style={{ display: 'none' }}
853
862
  accept="image/*,.pdf,.doc,.docx,.txt,.zip"
@@ -866,6 +875,7 @@ const TicketConversation: React.FC = () => {
866
875
  {'\uD83D\uDCCE'} {file.name}
867
876
  <button
868
877
  type="button"
878
+ aria-label={`Retirer la piece jointe ${file.name}`}
869
879
  onClick={() => setReplyFiles((prev) => prev.filter((_, idx) => idx !== i))}
870
880
  style={{ border: 'none', background: 'none', color: '#ef4444', fontWeight: 700, cursor: 'pointer', fontSize: '14px', lineHeight: 1 }}
871
881
  >
@@ -879,6 +889,7 @@ const TicketConversation: React.FC = () => {
879
889
  {/* Send row */}
880
890
  <div style={{ display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap', marginTop: '8px' }}>
881
891
  <select
892
+ aria-label="Envoyer en tant que"
882
893
  value={sendAsClient ? 'client' : 'admin'}
883
894
  onChange={(e) => setSendAsClient(e.target.value === 'client')}
884
895
  style={{ ...s.input, fontSize: '12px', padding: '6px 8px', fontWeight: 600 }}
@@ -896,11 +907,12 @@ const TicketConversation: React.FC = () => {
896
907
  Envoyer au client
897
908
  </label>
898
909
  )}
899
- <button data-action="send-reply" onClick={handleSendReply} disabled={sending || (!replyBody.trim() && !replyHtml)} style={{ ...s.btn(isInternal ? C.amber : notifyClient ? '#16a34a' : C.blue, sending || (!replyBody.trim() && !replyHtml)), fontSize: '13px', padding: '8px 20px', marginLeft: 'auto' }}>
910
+ <button type="button" data-action="send-reply" onClick={handleSendReply} disabled={sending || (!replyBody.trim() && !replyHtml)} style={{ ...s.btn(isInternal ? C.amber : notifyClient ? '#16a34a' : C.blue, sending || (!replyBody.trim() && !replyHtml)), fontSize: '13px', padding: '8px 20px', marginLeft: 'auto' }}>
900
911
  {sending ? 'Envoi...' : isInternal ? 'Ajouter note' : notifyClient ? 'Envoyer + Notifier' : 'Sauvegarder'}
901
912
  </button>
902
913
  {!isInternal && (
903
914
  <button
915
+ type="button"
904
916
  onClick={() => setShowSchedule(!showSchedule)}
905
917
  disabled={!replyBody.trim() && !replyHtml}
906
918
  style={{ ...s.outlineBtn('#17807c', !replyBody.trim() && !replyHtml), fontSize: '12px', padding: '8px 12px' }}
@@ -915,19 +927,21 @@ const TicketConversation: React.FC = () => {
915
927
  <span style={{ fontSize: '12px', fontWeight: 600, color: '#17807c' }}>Programmer pour :</span>
916
928
  <input
917
929
  type="datetime-local"
930
+ aria-label="Date et heure d'envoi programme"
918
931
  value={scheduleDate}
919
932
  onChange={(e) => setScheduleDate(e.target.value)}
920
933
  min={new Date().toISOString().slice(0, 16)}
921
934
  style={{ ...s.input, fontSize: '12px', width: 'auto' }}
922
935
  />
923
936
  <button
937
+ type="button"
924
938
  onClick={handleScheduleReply}
925
939
  disabled={sending || !scheduleDate || (!replyBody.trim() && !replyHtml)}
926
940
  style={{ ...s.btn('#17807c', sending || !scheduleDate || (!replyBody.trim() && !replyHtml)), fontSize: '12px', padding: '6px 14px' }}
927
941
  >
928
942
  {sending ? '...' : '\u23F0 Programmer'}
929
943
  </button>
930
- <button onClick={() => setShowSchedule(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#94a3b8', fontSize: '14px' }}>{'\u2715'}</button>
944
+ <button type="button" aria-label="Annuler la programmation" onClick={() => setShowSchedule(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#94a3b8', fontSize: '14px' }}>{'\u2715'}</button>
931
945
  </div>
932
946
  )}
933
947
  <div style={{ fontSize: '11px', color: C.textMuted, marginTop: '4px', textAlign: 'right' }}>
@@ -1088,4 +1102,24 @@ const TicketConversation: React.FC = () => {
1088
1102
 
1089
1103
 
1090
1104
 
1105
+ /**
1106
+ * Payload mounts this component straight from the import map as the `ui` field
1107
+ * of the Tickets edit view, so the plugin never gets to render an ancestor for
1108
+ * it: the boundary has to live inside the module itself. Without it a single
1109
+ * render error in any of the twelve sub-components blanks the whole ticket
1110
+ * screen.
1111
+ *
1112
+ * The document id is used as a reset key so that navigating from a ticket that
1113
+ * broke to another one clears the error on its own.
1114
+ */
1115
+ const TicketConversation: React.FC = () => {
1116
+ const { id } = useDocumentIdFromUrl()
1117
+
1118
+ return (
1119
+ <AdminErrorBoundary viewName="TicketConversation" resetKeys={[id]}>
1120
+ <TicketConversationInner />
1121
+ </AdminErrorBoundary>
1122
+ )
1123
+ }
1124
+
1091
1125
  export default TicketConversation
@@ -1,13 +1,37 @@
1
1
  import type { Endpoint } from 'payload'
2
+ import { commitTransaction, initTransaction, killTransaction } from 'payload'
2
3
  import type { CollectionSlugs } from '../utils/slugs'
4
+ import { FIXED_SLUGS } from '../utils/slugs'
3
5
  import { requireClient, handleAuthError } from '../utils/auth'
4
6
  import { dbFind, dbDelete } from '../utils/db'
5
7
 
8
+ interface AttachmentRow {
9
+ file?: number | string | { id?: number | string } | null
10
+ }
11
+
12
+ /** Collect the media ids referenced by an `attachments` array field. */
13
+ function collectAttachmentIds(
14
+ docs: Array<{ attachments?: AttachmentRow[] | null }>,
15
+ into: Set<number | string>,
16
+ ): void {
17
+ for (const doc of docs) {
18
+ for (const attachment of doc.attachments ?? []) {
19
+ const file = attachment?.file
20
+ const id = file != null && typeof file === 'object' ? file.id : file
21
+ if (id != null) into.add(id)
22
+ }
23
+ }
24
+ }
25
+
6
26
  /**
7
27
  * POST /api/support/delete-account
8
28
  * RGPD — Right to erasure (Article 17).
9
- * Allows a support client to request permanent deletion of their account.
10
- * Uses batch deletes via `where` clauses instead of sequential per-document deletion.
29
+ *
30
+ * Erases every row the plugin holds about the caller, and the uploaded files
31
+ * those rows point at. The whole sequence runs inside a single Payload
32
+ * transaction: a failure halfway through must not leave a half-deleted
33
+ * account, which would be worse than not deleting at all — the person would
34
+ * believe their data is gone while some of it is still readable.
11
35
  */
12
36
  export function createDeleteAccountEndpoint(slugs: CollectionSlugs): Endpoint {
13
37
  return {
@@ -29,7 +53,8 @@ export function createDeleteAccountEndpoint(slugs: CollectionSlugs): Endpoint {
29
53
  )
30
54
  }
31
55
 
32
- // Verify password
56
+ // Verify password. Deliberately outside the transaction: it is a read,
57
+ // and `payload.login` writes nothing we would want to roll back.
33
58
  try {
34
59
  await payload.login({
35
60
  collection: slugs.supportClients as any,
@@ -43,59 +68,178 @@ export function createDeleteAccountEndpoint(slugs: CollectionSlugs): Endpoint {
43
68
  }
44
69
 
45
70
  const clientId = req.user.id
71
+ const clientEmail = req.user.email
46
72
 
47
- // 1. Find all ticket IDs for this client
48
- const tickets = await dbFind(payload, slugs.tickets, {
49
- where: { client: { equals: clientId } },
50
- limit: 10000,
51
- depth: 0,
52
- overrideAccess: true,
53
- select: { id: true },
54
- })
55
-
56
- const ticketIds = tickets.docs.map((t) => t.id)
57
-
58
- // 2. Batch delete related data using where clauses
59
- if (ticketIds.length > 0) {
60
- await dbDelete(payload, slugs.ticketMessages, {
61
- where: { ticket: { in: ticketIds } },
62
- overrideAccess: true,
63
- })
73
+ // Several of the collections below only exist when the matching
74
+ // feature flag is on (chat, pendingEmails, ai, timeTracking,
75
+ // authLogs, emailTracking). Addressing one that was never registered
76
+ // throws, and inside a transaction that would roll the whole erasure
77
+ // back — so an install with chat disabled could never delete an
78
+ // account at all.
79
+ const isRegistered = (slug: string): boolean =>
80
+ Boolean((payload.collections as Record<string, unknown> | undefined)?.[slug])
64
81
 
65
- await dbDelete(payload, slugs.ticketActivityLog, {
66
- where: { ticket: { in: ticketIds } },
67
- overrideAccess: true,
68
- })
82
+ // No-op on adapters that do not expose `beginTransaction` — the SQLite
83
+ // adapter only does when the host passes `transactionOptions`. The
84
+ // sequence then runs unwrapped, exactly as it did before.
85
+ const startedTransaction = await initTransaction(req)
69
86
 
70
- await dbDelete(payload, slugs.timeEntries, {
71
- where: { ticket: { in: ticketIds } },
87
+ try {
88
+ // 1. Tickets owned by the client.
89
+ const tickets = await dbFind(payload, slugs.tickets, {
90
+ where: { client: { equals: clientId } },
91
+ limit: 10000,
92
+ depth: 0,
72
93
  overrideAccess: true,
94
+ select: { id: true },
95
+ req,
73
96
  })
74
97
 
75
- // Delete all tickets
76
- await dbDelete(payload, slugs.tickets, {
77
- where: { client: { equals: clientId } },
98
+ const ticketIds = tickets.docs.map((t) => t.id)
99
+
100
+ // 2. Uploaded files. They live in the HOST's media collection, so
101
+ // their ids have to be gathered before the rows that reference them
102
+ // are gone.
103
+ const attachmentIds = new Set<number | string>()
104
+
105
+ if (ticketIds.length > 0) {
106
+ const messages = await dbFind<{ attachments?: AttachmentRow[] | null }>(
107
+ payload,
108
+ slugs.ticketMessages,
109
+ {
110
+ where: { ticket: { in: ticketIds } },
111
+ limit: 10000,
112
+ depth: 0,
113
+ overrideAccess: true,
114
+ req,
115
+ },
116
+ )
117
+ collectAttachmentIds(messages.docs, attachmentIds)
118
+ }
119
+
120
+ if (isRegistered(slugs.pendingEmails)) {
121
+ const pendingEmails = await dbFind<{ attachments?: AttachmentRow[] | null }>(
122
+ payload,
123
+ slugs.pendingEmails,
124
+ {
125
+ where: { client: { equals: clientId } },
126
+ limit: 10000,
127
+ depth: 0,
128
+ overrideAccess: true,
129
+ req,
130
+ },
131
+ )
132
+ collectAttachmentIds(pendingEmails.docs, attachmentIds)
133
+ }
134
+
135
+ // 3. Rows that point at one of those tickets. They must go BEFORE
136
+ // the tickets themselves: their `ticket` relationship is required,
137
+ // so the FK would fail on the ticket delete — and Payload's
138
+ // delete-many collects per-document errors instead of throwing, so
139
+ // the failure would be silent and the account would survive.
140
+ if (ticketIds.length > 0) {
141
+ for (const slug of [FIXED_SLUGS.ticketFeedback, FIXED_SLUGS.ticketCollaborators]) {
142
+ if (!isRegistered(slug)) continue
143
+ await dbDelete(payload, slug, {
144
+ where: {
145
+ or: [
146
+ { ticket: { in: ticketIds } },
147
+ { client: { equals: clientId } },
148
+ ],
149
+ },
150
+ overrideAccess: true,
151
+ req,
152
+ })
153
+ }
154
+ }
155
+
156
+ // 4. The tickets. Their own beforeDelete cascade takes the messages,
157
+ // the activity log, the time entries and the surveys with them.
158
+ if (ticketIds.length > 0) {
159
+ await dbDelete(payload, slugs.tickets, {
160
+ where: { client: { equals: clientId } },
161
+ overrideAccess: true,
162
+ req,
163
+ })
164
+ }
165
+
166
+ // 5. Rows keyed on the client id, whatever ticket they belong to.
167
+ for (const slug of [
168
+ slugs.satisfactionSurveys,
169
+ slugs.chatMessages,
170
+ slugs.notificationQueue,
171
+ slugs.pendingEmails,
172
+ FIXED_SLUGS.clientSummaries,
173
+ FIXED_SLUGS.ticketFeedback,
174
+ ]) {
175
+ if (!isRegistered(slug)) continue
176
+ await dbDelete(payload, slug, {
177
+ where: { client: { equals: clientId } },
178
+ overrideAccess: true,
179
+ req,
180
+ })
181
+ }
182
+
183
+ // Collaborator invitations can predate the account they point at, so
184
+ // they are also matched on the address alone.
185
+ if (isRegistered(FIXED_SLUGS.ticketCollaborators)) {
186
+ await dbDelete(payload, FIXED_SLUGS.ticketCollaborators, {
187
+ where: {
188
+ or: [
189
+ { client: { equals: clientId } },
190
+ ...(clientEmail ? [{ email: { equals: clientEmail } }] : []),
191
+ ],
192
+ },
193
+ overrideAccess: true,
194
+ req,
195
+ })
196
+ }
197
+
198
+ // 6. Journals, keyed on the address only.
199
+ if (clientEmail) {
200
+ if (isRegistered(slugs.authLogs)) {
201
+ await dbDelete(payload, slugs.authLogs, {
202
+ where: { email: { equals: clientEmail } },
203
+ overrideAccess: true,
204
+ req,
205
+ })
206
+ }
207
+
208
+ if (isRegistered(slugs.emailLogs)) {
209
+ await dbDelete(payload, slugs.emailLogs, {
210
+ where: {
211
+ or: [
212
+ { senderEmail: { equals: clientEmail } },
213
+ { recipientEmail: { equals: clientEmail } },
214
+ ],
215
+ },
216
+ overrideAccess: true,
217
+ req,
218
+ })
219
+ }
220
+ }
221
+
222
+ // 7. The uploaded files themselves.
223
+ for (const id of isRegistered(slugs.media) ? attachmentIds : []) {
224
+ await dbDelete(payload, slugs.media, {
225
+ id,
226
+ overrideAccess: true,
227
+ req,
228
+ })
229
+ }
230
+
231
+ // 8. The account.
232
+ await dbDelete(payload, slugs.supportClients, {
233
+ id: clientId,
78
234
  overrideAccess: true,
235
+ req,
79
236
  })
80
- }
81
237
 
82
- // 3. Batch delete satisfaction surveys
83
- await dbDelete(payload, slugs.satisfactionSurveys, {
84
- where: { client: { equals: clientId } },
85
- overrideAccess: true,
86
- })
87
-
88
- // 4. Batch delete chat messages
89
- await dbDelete(payload, slugs.chatMessages, {
90
- where: { client: { equals: clientId } },
91
- overrideAccess: true,
92
- })
93
-
94
- // 5. Delete the client account
95
- await dbDelete(payload, slugs.supportClients, {
96
- id: clientId,
97
- overrideAccess: true,
98
- })
238
+ if (startedTransaction) await commitTransaction(req)
239
+ } catch (err) {
240
+ if (startedTransaction) await killTransaction(req)
241
+ throw err
242
+ }
99
243
 
100
244
  const headers = new Headers({ 'Content-Type': 'application/json' })
101
245
  const secure = process.env.NODE_ENV === 'production'