create-crm-tmp 2.0.0 → 2.1.0

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 (190) hide show
  1. package/bin/create-crm-tmp.js +56 -35
  2. package/package.json +1 -1
  3. package/template/README.md +230 -115
  4. package/template/eslint.config.mjs +13 -0
  5. package/template/next.config.ts +14 -0
  6. package/template/package.json +15 -2
  7. package/template/prisma/migrations/20260318095700_init_db/migration.sql +978 -0
  8. package/template/prisma/migrations/migration_lock.toml +3 -0
  9. package/template/prisma/schema.prisma +132 -637
  10. package/template/src/app/(auth)/invite/[token]/page.tsx +10 -8
  11. package/template/src/app/(auth)/layout.tsx +1 -1
  12. package/template/src/app/(auth)/reset-password/complete/page.tsx +11 -8
  13. package/template/src/app/(auth)/reset-password/page.tsx +4 -4
  14. package/template/src/app/(auth)/reset-password/verify/page.tsx +4 -4
  15. package/template/src/app/(auth)/signin/page.tsx +14 -6
  16. package/template/src/app/(dashboard)/agenda/page.tsx +2243 -988
  17. package/template/src/app/(dashboard)/automatisation/_components/workflow-editor.tsx +18 -104
  18. package/template/src/app/(dashboard)/automatisation/page.tsx +10 -26
  19. package/template/src/app/(dashboard)/closing/page.tsx +78 -62
  20. package/template/src/app/(dashboard)/contacts/[id]/page.tsx +2082 -1080
  21. package/template/src/app/(dashboard)/contacts/companies/[id]/page.tsx +46 -47
  22. package/template/src/app/(dashboard)/contacts/page.tsx +1062 -780
  23. package/template/src/app/(dashboard)/dashboard/page.tsx +533 -37
  24. package/template/src/app/(dashboard)/dev/page.tsx +1291 -0
  25. package/template/src/app/(dashboard)/layout.tsx +6 -2
  26. package/template/src/app/(dashboard)/settings/page.tsx +797 -2582
  27. package/template/src/app/(dashboard)/templates/page.tsx +55 -54
  28. package/template/src/app/(dashboard)/users/list/page.tsx +51 -48
  29. package/template/src/app/(dashboard)/users/page.tsx +1 -1
  30. package/template/src/app/(dashboard)/users/permissions/page.tsx +2 -2
  31. package/template/src/app/(dashboard)/users/roles/page.tsx +7 -5
  32. package/template/src/app/api/agenda/google-events/route.ts +92 -0
  33. package/template/src/app/api/auth/check-active/route.ts +3 -2
  34. package/template/src/app/api/auth/google/route.ts +2 -1
  35. package/template/src/app/api/auth/google/status/route.ts +7 -31
  36. package/template/src/app/api/companies/[id]/activities/route.ts +1 -3
  37. package/template/src/app/api/companies/[id]/route.ts +1 -2
  38. package/template/src/app/api/companies/route.ts +42 -12
  39. package/template/src/app/api/contacts/[id]/files/[fileId]/preview/route.ts +9 -31
  40. package/template/src/app/api/contacts/[id]/files/[fileId]/route.ts +14 -32
  41. package/template/src/app/api/contacts/[id]/files/route.ts +112 -212
  42. package/template/src/app/api/contacts/[id]/interactions/[interactionId]/route.ts +27 -1
  43. package/template/src/app/api/contacts/[id]/interactions/route.ts +16 -16
  44. package/template/src/app/api/contacts/[id]/kyc/route.ts +21 -11
  45. package/template/src/app/api/contacts/[id]/meet/route.ts +19 -2
  46. package/template/src/app/api/contacts/[id]/route.ts +106 -34
  47. package/template/src/app/api/contacts/[id]/send-email/route.ts +27 -11
  48. package/template/src/app/api/contacts/[id]/workflows/run/route.ts +6 -0
  49. package/template/src/app/api/contacts/export/route.ts +9 -13
  50. package/template/src/app/api/contacts/import/route.ts +55 -25
  51. package/template/src/app/api/contacts/import-preview/route.ts +1 -1
  52. package/template/src/app/api/contacts/origins/route.ts +63 -0
  53. package/template/src/app/api/contacts/route.ts +153 -41
  54. package/template/src/app/api/cron/cleanup-editor-images/route.ts +166 -0
  55. package/template/src/app/api/dashboard/widgets/[id]/route.ts +44 -0
  56. package/template/src/app/api/dashboard/widgets/route.ts +181 -0
  57. package/template/src/app/api/dev/reminders/test/route.ts +114 -0
  58. package/template/src/app/api/editor/upload-image/route.ts +61 -0
  59. package/template/src/app/api/integrations/google-sheet/jobs/[jobId]/route.ts +47 -0
  60. package/template/src/app/api/integrations/google-sheet/jobs/usage/route.ts +50 -0
  61. package/template/src/app/api/integrations/google-sheet/sync/route.ts +24 -556
  62. package/template/src/app/api/jobs/google-sheet/process/route.ts +84 -0
  63. package/template/src/app/api/jobs/google-sheet/schedule/route.ts +50 -0
  64. package/template/src/app/api/reminders/clear/route.ts +120 -0
  65. package/template/src/app/api/reminders/clear/undo/route.ts +112 -0
  66. package/template/src/app/api/reminders/route.ts +164 -39
  67. package/template/src/app/api/reminders/state/route.ts +164 -0
  68. package/template/src/app/api/reset-password/request/route.ts +1 -1
  69. package/template/src/app/api/reset-password/verify/route.ts +1 -1
  70. package/template/src/app/api/send/route.ts +16 -4
  71. package/template/src/app/api/settings/google-ads/route.ts +14 -0
  72. package/template/src/app/api/settings/google-calendar/calendars/route.ts +97 -0
  73. package/template/src/app/api/settings/google-calendar/route.ts +124 -0
  74. package/template/src/app/api/settings/google-sheet/[id]/route.ts +28 -0
  75. package/template/src/app/api/settings/google-sheet/auto-map/route.ts +37 -4
  76. package/template/src/app/api/settings/google-sheet/preview/route.ts +9 -3
  77. package/template/src/app/api/settings/google-sheet/route.ts +14 -0
  78. package/template/src/app/api/settings/integrations/logs/route.ts +93 -0
  79. package/template/src/app/api/settings/integrations/notifications/route.ts +67 -0
  80. package/template/src/app/api/settings/meta-leads/[id]/route.ts +0 -1
  81. package/template/src/app/api/settings/meta-leads/route.ts +14 -2
  82. package/template/src/app/api/settings/smtp/route.ts +53 -6
  83. package/template/src/app/api/tasks/[id]/attendees/route.ts +24 -8
  84. package/template/src/app/api/tasks/[id]/route.ts +234 -58
  85. package/template/src/app/api/tasks/meet/route.ts +27 -19
  86. package/template/src/app/api/tasks/route.ts +62 -17
  87. package/template/src/app/api/users/[id]/route.ts +20 -14
  88. package/template/src/app/api/users/list/route.ts +57 -19
  89. package/template/src/app/api/webhooks/google-ads/route.ts +34 -14
  90. package/template/src/app/api/webhooks/meta-leads/route.ts +32 -12
  91. package/template/src/app/api/workflows/[id]/route.ts +0 -4
  92. package/template/src/app/api/workflows/process/route.ts +22 -51
  93. package/template/src/app/api/workflows/route.ts +0 -4
  94. package/template/src/app/globals.css +342 -4
  95. package/template/src/app/layout.tsx +11 -3
  96. package/template/src/app/page.tsx +1 -1
  97. package/template/src/components/address-autocomplete.tsx +7 -6
  98. package/template/src/components/config-error-alert.tsx +46 -0
  99. package/template/src/components/contacts/filter-bar.tsx +12 -3
  100. package/template/src/components/contacts/filter-builder.tsx +28 -43
  101. package/template/src/components/contacts/save-view-dialog.tsx +1 -1
  102. package/template/src/components/contacts/views-tab-bar.tsx +15 -6
  103. package/template/src/components/dashboard/activity-chart.tsx +41 -28
  104. package/template/src/components/dashboard/add-widget-dialog.tsx +157 -0
  105. package/template/src/components/dashboard/color-picker.tsx +64 -0
  106. package/template/src/components/dashboard/contacts-chart.tsx +69 -0
  107. package/template/src/components/dashboard/interactions-by-type-chart.tsx +121 -0
  108. package/template/src/components/dashboard/recent-activity.tsx +154 -0
  109. package/template/src/components/dashboard/stat-card.tsx +40 -40
  110. package/template/src/components/dashboard/status-distribution-chart.tsx +81 -0
  111. package/template/src/components/dashboard/tasks-pie-chart.tsx +37 -34
  112. package/template/src/components/dashboard/top-contacts-list.tsx +113 -0
  113. package/template/src/components/dashboard/upcoming-tasks-list.tsx +72 -81
  114. package/template/src/components/dashboard/widget-wrapper.tsx +36 -0
  115. package/template/src/components/date-picker.tsx +9 -6
  116. package/template/src/components/editor/upload-editor-image.ts +42 -0
  117. package/template/src/components/editor.tsx +161 -22
  118. package/template/src/components/email-template.tsx +2 -2
  119. package/template/src/components/global-search.tsx +30 -28
  120. package/template/src/components/header.tsx +178 -80
  121. package/template/src/components/inactive-account-guard.tsx +58 -0
  122. package/template/src/components/integration-notifications-listener.tsx +12 -0
  123. package/template/src/components/invitation-email-template.tsx +2 -2
  124. package/template/src/components/meet-cancellation-email-template.tsx +3 -3
  125. package/template/src/components/meet-confirmation-email-template.tsx +3 -3
  126. package/template/src/components/meet-update-email-template.tsx +3 -3
  127. package/template/src/components/page-header.tsx +5 -5
  128. package/template/src/components/protected-page.tsx +1 -1
  129. package/template/src/components/reset-password-email-template.tsx +2 -2
  130. package/template/src/components/settings/integrations/GoogleAdsIntegration.tsx +428 -0
  131. package/template/src/components/settings/integrations/GoogleSheetConfigMonitoringModal.tsx +680 -0
  132. package/template/src/components/settings/integrations/GoogleSheetIntegration.tsx +809 -0
  133. package/template/src/components/settings/integrations/ImportResultDialog.tsx +124 -0
  134. package/template/src/components/settings/integrations/IntegrationLogPanel.tsx +57 -0
  135. package/template/src/components/settings/integrations/IntegrationLogsTable.tsx +186 -0
  136. package/template/src/components/settings/integrations/MetaLeadIntegration.tsx +451 -0
  137. package/template/src/components/sidebar.tsx +45 -26
  138. package/template/src/components/skeleton.tsx +40 -43
  139. package/template/src/components/ui/accordion.tsx +2 -2
  140. package/template/src/components/ui/alert-dialog.tsx +1 -1
  141. package/template/src/components/ui/button.tsx +20 -9
  142. package/template/src/components/ui/components.tsx +1 -1
  143. package/template/src/components/ui/date-picker.tsx +422 -0
  144. package/template/src/components/ui/datetime-picker.tsx +338 -0
  145. package/template/src/components/ui/status-select.tsx +271 -0
  146. package/template/src/components/ui/tooltip.tsx +37 -0
  147. package/template/src/components/view-as-modal.tsx +13 -7
  148. package/template/src/contexts/app-toast-context.tsx +245 -57
  149. package/template/src/contexts/dashboard-theme-context.tsx +53 -0
  150. package/template/src/contexts/sidebar-context.tsx +22 -17
  151. package/template/src/contexts/task-reminder-context.tsx +134 -160
  152. package/template/src/contexts/view-as-context.tsx +33 -6
  153. package/template/src/hooks/use-focus-trap.ts +2 -2
  154. package/template/src/hooks/useIntegrationNotifications.ts +49 -0
  155. package/template/src/lib/auth.ts +8 -1
  156. package/template/src/lib/config-links.ts +14 -0
  157. package/template/src/lib/contact-duplicate.ts +79 -61
  158. package/template/src/lib/contact-interactions.ts +21 -21
  159. package/template/src/lib/contact-view-filters.ts +24 -64
  160. package/template/src/lib/contacts-list-url.ts +190 -0
  161. package/template/src/lib/dashboard-stats.ts +65 -7
  162. package/template/src/lib/dashboard-themes.ts +135 -0
  163. package/template/src/lib/date-utils.ts +127 -0
  164. package/template/src/lib/default-widgets.ts +12 -0
  165. package/template/src/lib/editor-html-image-dimensions.ts +172 -0
  166. package/template/src/lib/editor-image-limits.ts +19 -0
  167. package/template/src/lib/email-html-sanitize.ts +19 -0
  168. package/template/src/lib/encryption.ts +9 -6
  169. package/template/src/lib/fr-geography.ts +192 -0
  170. package/template/src/lib/google-calendar-agenda.ts +201 -0
  171. package/template/src/lib/google-calendar.ts +255 -5
  172. package/template/src/lib/google-sheet-sync-jobs.ts +96 -0
  173. package/template/src/lib/google-sheet-sync-runner.ts +514 -0
  174. package/template/src/lib/integration-import-log.ts +21 -0
  175. package/template/src/lib/permissions.ts +40 -10
  176. package/template/src/lib/prisma.ts +4 -1
  177. package/template/src/lib/qstash.ts +65 -0
  178. package/template/src/lib/reminder-state-server.ts +80 -0
  179. package/template/src/lib/reminder-state.ts +29 -0
  180. package/template/src/lib/supabase-storage.ts +113 -0
  181. package/template/src/lib/template-variables.ts +164 -23
  182. package/template/src/lib/utils.ts +45 -0
  183. package/template/src/lib/widget-registry.ts +173 -0
  184. package/template/src/lib/workflow-executor.ts +16 -70
  185. package/template/src/proxy.ts +1 -0
  186. package/template/vercel.json +3 -10
  187. package/template/skills-lock.json +0 -25
  188. package/template/src/components/dashboard/dashboard-content.tsx +0 -79
  189. package/template/src/lib/google-drive.ts +0 -1101
  190. package/template/src/types/yousign.ts +0 -52
@@ -47,3 +47,130 @@ export function getWednesdayBeforeISOWeek(date: Date, weeksBefore: number = 4):
47
47
 
48
48
  return target;
49
49
  }
50
+
51
+ /** Motif pour une valeur issue de `<input type="datetime-local">` : `YYYY-MM-DDTHH:mm` ou avec secondes. */
52
+ const DATETIME_LOCAL_PAYLOAD_RE =
53
+ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/;
54
+
55
+ /**
56
+ * Date du calendrier local (sans fuseau explicite dans la chaîne).
57
+ */
58
+ export function toLocalDateInput(value: Date | string): string {
59
+ const date = typeof value === 'string' ? new Date(value) : value;
60
+ const year = date.getFullYear();
61
+ const month = String(date.getMonth() + 1).padStart(2, '0');
62
+ const day = String(date.getDate()).padStart(2, '0');
63
+ return `${year}-${month}-${day}`;
64
+ }
65
+
66
+ /**
67
+ * Valeur pour `<input type="datetime-local">` à partir d’une Date ou d’une ISO renvoyée par l’API.
68
+ */
69
+ export function toLocalDateTimeInput(value: Date | string): string {
70
+ const date = typeof value === 'string' ? new Date(value) : value;
71
+ const localDate = toLocalDateInput(date);
72
+ const hours = String(date.getHours()).padStart(2, '0');
73
+ const minutes = String(date.getMinutes()).padStart(2, '0');
74
+ return `${localDate}T${hours}:${minutes}`;
75
+ }
76
+
77
+ /**
78
+ * Convertit une date/heure saisie en local (`YYYY-MM-DDTHH:mm`) en chaîne ISO UTC pour les APIs.
79
+ * Si la chaîne contient déjà un fuseau (`Z` ou `±hh:mm`), utilise le parseur natif.
80
+ */
81
+ export function scheduledAtPayloadToIso(value: string): string {
82
+ const trimmed = value.trim();
83
+ const m = DATETIME_LOCAL_PAYLOAD_RE.exec(trimmed);
84
+ if (m) {
85
+ const year = Number(m[1]);
86
+ const month = Number(m[2]) - 1;
87
+ const day = Number(m[3]);
88
+ const hour = Number(m[4]);
89
+ const minute = Number(m[5]);
90
+ const second = m[6] === undefined ? 0 : Number(m[6]);
91
+ const local = new Date(year, month, day, hour, minute, second, 0);
92
+ if (Number.isNaN(local.getTime())) {
93
+ throw new TypeError('Date/heure invalide');
94
+ }
95
+ return local.toISOString();
96
+ }
97
+ const parsed = new Date(trimmed);
98
+ if (Number.isNaN(parsed.getTime())) {
99
+ throw new TypeError('Date/heure invalide');
100
+ }
101
+ return parsed.toISOString();
102
+ }
103
+
104
+ /** Fuseau pour formater les textes d’activité côté serveur (Vercel = UTC par défaut). Dom Tom : surcharger APP_DISPLAY_TIMEZONE. */
105
+ const DEFAULT_APP_DISPLAY_TIMEZONE = 'Europe/Paris';
106
+
107
+ /**
108
+ * Date/heure d’un RDV pour les chaînes enregistrées en base (serveur).
109
+ * Utilise APP_DISPLAY_TIMEZONE ou Europe/Paris par défaut.
110
+ */
111
+ export function formatFrenchAppointmentDateTime(
112
+ date: Date,
113
+ timeZone: string = process.env.APP_DISPLAY_TIMEZONE ?? DEFAULT_APP_DISPLAY_TIMEZONE,
114
+ ): string {
115
+ return new Intl.DateTimeFormat('fr-FR', {
116
+ day: 'numeric',
117
+ month: 'long',
118
+ year: 'numeric',
119
+ hour: '2-digit',
120
+ minute: '2-digit',
121
+ timeZone,
122
+ }).format(date);
123
+ }
124
+
125
+ /**
126
+ * Même rendu que l’agenda / navigateur : fuseau local du client.
127
+ */
128
+ export function formatFrenchAppointmentDateTimeLocal(date: Date | string): string {
129
+ const d = typeof date === 'string' ? new Date(date) : date;
130
+ return new Intl.DateTimeFormat('fr-FR', {
131
+ day: 'numeric',
132
+ month: 'long',
133
+ year: 'numeric',
134
+ hour: '2-digit',
135
+ minute: '2-digit',
136
+ }).format(d);
137
+ }
138
+
139
+ const APPOINTMENT_INTERACTION_TYPES = new Set([
140
+ 'APPOINTMENT_CREATED',
141
+ 'APPOINTMENT_CHANGED',
142
+ 'APPOINTMENT_DELETED',
143
+ ]);
144
+
145
+ /**
146
+ * Corps d’affichage pour cartes activités RDV : heure locale navigateur, corrige l’historique UTC en base.
147
+ */
148
+ export function formatAppointmentInteractionBodyForDisplay(interaction: {
149
+ type: string;
150
+ content: string;
151
+ date: string | null;
152
+ metadata?: unknown;
153
+ }): string {
154
+ if (!APPOINTMENT_INTERACTION_TYPES.has(interaction.type) || !interaction.date) {
155
+ return interaction.content;
156
+ }
157
+ const at = formatFrenchAppointmentDateTimeLocal(interaction.date);
158
+ const meta =
159
+ interaction.metadata &&
160
+ typeof interaction.metadata === 'object' &&
161
+ interaction.metadata !== null &&
162
+ 'isGoogleMeet' in interaction.metadata
163
+ ? Boolean((interaction.metadata as { isGoogleMeet?: boolean }).isGoogleMeet)
164
+ : false;
165
+ const label = meta ? 'Google Meet' : 'Rendez-vous';
166
+ switch (interaction.type) {
167
+ case 'APPOINTMENT_CREATED':
168
+ return `Rendez-vous programmé le ${at}`;
169
+ case 'APPOINTMENT_CHANGED':
170
+ return `${label} programmé le ${at} a été modifié.`;
171
+ case 'APPOINTMENT_DELETED':
172
+ return `${label} prévu le ${at} a été annulé.`;
173
+ default:
174
+ return interaction.content;
175
+ }
176
+ }
@@ -0,0 +1,12 @@
1
+ export const DEFAULT_WIDGETS = [
2
+ { type: 'stat_total_contacts', x: 0, y: 0, w: 3, h: 2 },
3
+ { type: 'stat_new_contacts', x: 3, y: 0, w: 3, h: 2 },
4
+ { type: 'stat_completed_tasks', x: 6, y: 0, w: 3, h: 2 },
5
+ { type: 'stat_pending_tasks', x: 9, y: 0, w: 3, h: 2 },
6
+ { type: 'contacts_chart', x: 0, y: 2, w: 6, h: 4 },
7
+ { type: 'top_contacts', x: 6, y: 2, w: 6, h: 4 },
8
+ { type: 'activity_chart', x: 0, y: 6, w: 8, h: 4 },
9
+ { type: 'tasks_pie', x: 8, y: 6, w: 4, h: 4 },
10
+ { type: 'upcoming_tasks', x: 0, y: 10, w: 6, h: 5 },
11
+ { type: 'recent_activity', x: 6, y: 10, w: 6, h: 5 },
12
+ ];
@@ -0,0 +1,172 @@
1
+ /**
2
+ * LexKit : export HTML correct (figure.align-left, width/height…), mais importFromHTML
3
+ * - ne remet pas __width/__height sur le nœud image ;
4
+ * - pour <figure>, force __alignment à "center" (bug / choix amont), d’où un logo visuellement centré
5
+ * dans l’éditeur alors que le HTML sauvegardé reste à gauche.
6
+ * Ces helpers réappliquent taille + alignement après import, d’après le HTML réel.
7
+ */
8
+
9
+ import type { LexicalEditor, LexicalNode } from 'lexical';
10
+ import { $getRoot, $isElementNode } from 'lexical';
11
+
12
+ function parsePositiveInt(value: string | null | undefined): number | null {
13
+ if (value == null || value === '') return null;
14
+ const n = Number.parseInt(String(value).trim(), 10);
15
+ return Number.isFinite(n) && n > 0 ? n : null;
16
+ }
17
+
18
+ function parsePxFromStyle(style: string, prop: 'width' | 'height'): number | null {
19
+ const re = new RegExp(`${prop}\\s*:\\s*(\\d+)\\s*px`, 'i');
20
+ const m = re.exec(style);
21
+ if (!m) return null;
22
+ const n = Number.parseInt(m[1], 10);
23
+ return Number.isFinite(n) && n > 0 ? n : null;
24
+ }
25
+
26
+ export type ImgDimensions = { w: number; h: number };
27
+ export type ImgAlignment = 'left' | 'center' | 'right' | 'none';
28
+
29
+ function extractImgAlignment(img: HTMLImageElement): ImgAlignment {
30
+ const fig = img.closest('figure');
31
+ const classBlob = [fig?.className, img.className].filter(Boolean).join(' ');
32
+ const styleBlob = [fig?.getAttribute('style'), img.getAttribute('style')]
33
+ .filter(Boolean)
34
+ .join(';');
35
+
36
+ if (/\balign-left\b/.test(classBlob)) return 'left';
37
+ if (/\balign-right\b/.test(classBlob)) return 'right';
38
+ if (/\balign-center\b/.test(classBlob)) return 'center';
39
+ if (/\balign-none\b/.test(classBlob)) return 'none';
40
+
41
+ if (/float\s*:\s*left/i.test(styleBlob)) return 'left';
42
+ if (/float\s*:\s*right/i.test(styleBlob)) return 'right';
43
+ if (/text-align\s*:\s*left/i.test(styleBlob)) return 'left';
44
+ if (/text-align\s*:\s*right/i.test(styleBlob)) return 'right';
45
+ if (/text-align\s*:\s*center/i.test(styleBlob)) return 'center';
46
+
47
+ return 'none';
48
+ }
49
+
50
+ function extractImgDimensions(img: HTMLImageElement): ImgDimensions | null {
51
+ let w = parsePositiveInt(img.getAttribute('width'));
52
+ let h = parsePositiveInt(img.getAttribute('height'));
53
+ const st = img.getAttribute('style') || '';
54
+ if (w == null) w = parsePxFromStyle(st, 'width');
55
+ if (h == null) h = parsePxFromStyle(st, 'height');
56
+ if (w != null && h != null) return { w, h };
57
+ return null;
58
+ }
59
+
60
+ export function extractOrderedImgLayout(html: string): {
61
+ dimensions: Array<ImgDimensions | null>;
62
+ alignments: ImgAlignment[];
63
+ } {
64
+ if (!html.includes('<img')) {
65
+ return { dimensions: [], alignments: [] };
66
+ }
67
+ try {
68
+ const doc = new DOMParser().parseFromString(
69
+ `<div id="__editor_img_layout__">${html}</div>`,
70
+ 'text/html',
71
+ );
72
+ const root = doc.getElementById('__editor_img_layout__');
73
+ if (!root) return { dimensions: [], alignments: [] };
74
+
75
+ const dimensions: Array<ImgDimensions | null> = [];
76
+ const alignments: ImgAlignment[] = [];
77
+ root.querySelectorAll('img').forEach((img) => {
78
+ dimensions.push(extractImgDimensions(img));
79
+ alignments.push(extractImgAlignment(img));
80
+ });
81
+ return { dimensions, alignments };
82
+ } catch {
83
+ return { dimensions: [], alignments: [] };
84
+ }
85
+ }
86
+
87
+ /** Copie width/height attributs → style inline (meilleure compat import LexKit + clients mail). */
88
+ export function mergeImgDimensionAttrsIntoStyle(html: string): string {
89
+ if (!html.includes('<img')) return html;
90
+ try {
91
+ const doc = new DOMParser().parseFromString(
92
+ `<div id="__editor_sig_root__">${html}</div>`,
93
+ 'text/html',
94
+ );
95
+ const root = doc.getElementById('__editor_sig_root__');
96
+ if (!root) return html;
97
+
98
+ root.querySelectorAll('img').forEach((img) => {
99
+ const wAttr = parsePositiveInt(img.getAttribute('width'));
100
+ const hAttr = parsePositiveInt(img.getAttribute('height'));
101
+ const style = (img.getAttribute('style') || '').trim();
102
+
103
+ const parts: string[] = [];
104
+ if (wAttr != null && !/\bwidth\s*:/i.test(style)) {
105
+ parts.push(`width:${wAttr}px`);
106
+ }
107
+ if (hAttr != null && !/\bheight\s*:/i.test(style)) {
108
+ parts.push(`height:${hAttr}px`);
109
+ }
110
+ if (parts.length === 0) return;
111
+
112
+ const merged = [style.replace(/;+\s*$/, ''), ...parts].filter(Boolean).join(';');
113
+ img.setAttribute('style', merged);
114
+ });
115
+
116
+ return root.innerHTML;
117
+ } catch {
118
+ return html;
119
+ }
120
+ }
121
+
122
+ type ImageNodeWithLayout = {
123
+ getType(): string;
124
+ setWidthAndHeight(width: number, height: number): void;
125
+ setAlignment(alignment: ImgAlignment): void;
126
+ };
127
+
128
+ function collectImageNodesInDocumentOrder(node: LexicalNode): ImageNodeWithLayout[] {
129
+ const list: ImageNodeWithLayout[] = [];
130
+
131
+ const visit = (n: LexicalNode) => {
132
+ if (n.getType() === 'image') {
133
+ list.push(n as unknown as ImageNodeWithLayout);
134
+ }
135
+ if ($isElementNode(n)) {
136
+ n.getChildren().forEach(visit);
137
+ }
138
+ };
139
+
140
+ visit(node);
141
+ return list;
142
+ }
143
+
144
+ /** Après importFromHTML : restaure __width/__height et __alignment (corrige le « center » forcé par LexKit sur <figure>). */
145
+ export function applyOrderedImageLayoutAfterImport(editor: LexicalEditor, html: string): void {
146
+ if (!html.includes('<img')) return;
147
+
148
+ const { dimensions, alignments } = extractOrderedImgLayout(html);
149
+ if (dimensions.length === 0 || alignments.length === 0) return;
150
+
151
+ editor.update(() => {
152
+ const images = collectImageNodesInDocumentOrder($getRoot());
153
+ const count = Math.min(images.length, dimensions.length, alignments.length);
154
+ for (let i = 0; i < count; i++) {
155
+ const node = images[i];
156
+ const d = dimensions[i];
157
+ const a = alignments[i];
158
+ if (d) {
159
+ try {
160
+ node.setWidthAndHeight(d.w, d.h);
161
+ } catch {
162
+ /* ignore */
163
+ }
164
+ }
165
+ try {
166
+ node.setAlignment(a);
167
+ } catch {
168
+ /* ignore */
169
+ }
170
+ }
171
+ });
172
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Taille max recommandée pour un logo en signature (HTML en data URL = base64,
3
+ * donc ~33 % plus lourd que le fichier binaire — mieux vaut rester léger).
4
+ */
5
+ export const SIGNATURE_MAX_IMAGE_BYTES = 350 * 1024;
6
+
7
+ export function formatImageFileSize(bytes: number): string {
8
+ if (bytes < 1024) return `${bytes} o`;
9
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} Ko`;
10
+ return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
11
+ }
12
+
13
+ /** Lève une erreur avec un message utilisateur si le fichier dépasse la limite. */
14
+ export function assertImageFileWithinLimit(file: File, maxBytes: number): void {
15
+ if (file.size <= maxBytes) return;
16
+ throw new Error(
17
+ `Image trop lourde (maximum ${formatImageFileSize(maxBytes)}, fichier : ${formatImageFileSize(file.size)}). Compressez ou réduisez le logo.`,
18
+ );
19
+ }
@@ -0,0 +1,19 @@
1
+ import sanitize from 'sanitize-html';
2
+
3
+ export function sanitizeEmailHtml(input: string): string {
4
+ if (!input) return '';
5
+ return sanitize(input, {
6
+ allowedTags: [
7
+ 'p', 'br', 'strong', 'em', 'u', 'a', 'div', 'span',
8
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
9
+ 'ul', 'ol', 'li', 'blockquote', 'pre', 'code',
10
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td',
11
+ 'img', 'hr', 'sub', 'sup', 's',
12
+ ],
13
+ allowedAttributes: {
14
+ a: ['href', 'title', 'target', 'rel'],
15
+ img: ['src', 'alt', 'width', 'height'],
16
+ '*': ['style', 'class'],
17
+ },
18
+ });
19
+ }
@@ -33,11 +33,12 @@ export function encrypt(text: string): string {
33
33
 
34
34
  const key = getEncryptionKey();
35
35
 
36
- // Si la clé n'est pas définie, retourner le texte en clair (avec un avertissement)
36
+ // Si la clé n'est pas définie, lever une erreur en production
37
37
  if (!key) {
38
- console.warn(
39
- '⚠️ Chiffrement désactivé : ENCRYPTION_KEY non définie. Le mot de passe sera stocké en clair.',
40
- );
38
+ if (process.env.NODE_ENV === 'production') {
39
+ throw new Error('ENCRYPTION_KEY must be configured in production');
40
+ }
41
+ console.warn('⚠️ Chiffrement désactivé : ENCRYPTION_KEY non définie...');
41
42
  return text;
42
43
  }
43
44
 
@@ -92,8 +93,10 @@ export function decrypt(encryptedData: string): string {
92
93
 
93
94
  return decrypted;
94
95
  } catch (error) {
95
- // Si le déchiffrement échoue, retourner tel quel (pour la compatibilité)
96
- console.error('Erreur lors du déchiffrement:', error);
96
+ if (process.env.NODE_ENV === 'production') {
97
+ throw new Error('Decryption failed - data may be corrupted');
98
+ }
99
+ console.warn('Decryption failed, returning raw data:', error);
97
100
  return encryptedData;
98
101
  }
99
102
  }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Filtres liste contacts par région / département (dérivés du code postal).
3
+ * Codes région = INSEE (11, 24, 27, …). Codes département = 01–95, 2A, 2B, 971–976, etc.
4
+ */
5
+
6
+ /** Préfixe `reg:` évite les collisions avec les codes département (ex. région 93 vs dept 93). */
7
+ export const FR_REGIONS: { code: string; name: string }[] = [
8
+ { code: 'reg:01', name: 'Guadeloupe' },
9
+ { code: 'reg:02', name: 'Martinique' },
10
+ { code: 'reg:03', name: 'Guyane' },
11
+ { code: 'reg:04', name: 'La Réunion' },
12
+ { code: 'reg:06', name: 'Mayotte' },
13
+ { code: 'reg:11', name: 'Île-de-France' },
14
+ { code: 'reg:24', name: 'Centre-Val de Loire' },
15
+ { code: 'reg:27', name: 'Bourgogne-Franche-Comté' },
16
+ { code: 'reg:28', name: 'Normandie' },
17
+ { code: 'reg:32', name: 'Hauts-de-France' },
18
+ { code: 'reg:44', name: 'Grand Est' },
19
+ { code: 'reg:52', name: 'Pays de la Loire' },
20
+ { code: 'reg:53', name: 'Bretagne' },
21
+ { code: 'reg:75', name: 'Nouvelle-Aquitaine' },
22
+ { code: 'reg:76', name: 'Occitanie' },
23
+ { code: 'reg:84', name: 'Auvergne-Rhône-Alpes' },
24
+ { code: 'reg:93', name: "Provence-Alpes-Côte d'Azur" },
25
+ { code: 'reg:94', name: 'Corse' },
26
+ ];
27
+
28
+ /** Clé = code dans FR_REGIONS (reg:xx) → codes département. */
29
+ export const REGION_TO_DEPARTMENT_CODES: Record<string, string[]> = {
30
+ 'reg:01': ['971'],
31
+ 'reg:02': ['972'],
32
+ 'reg:03': ['973'],
33
+ 'reg:04': ['974'],
34
+ 'reg:06': ['976'],
35
+ 'reg:11': ['75', '77', '78', '91', '92', '93', '94', '95'],
36
+ 'reg:24': ['18', '28', '36', '37', '41', '45'],
37
+ 'reg:27': ['21', '25', '39', '58', '70', '71', '89', '90'],
38
+ 'reg:28': ['14', '27', '50', '61', '76'],
39
+ 'reg:32': ['02', '59', '60', '62', '80'],
40
+ 'reg:44': ['08', '10', '51', '52', '54', '55', '57', '67', '68', '88'],
41
+ 'reg:52': ['44', '49', '53', '72', '85'],
42
+ 'reg:53': ['22', '29', '35', '56'],
43
+ 'reg:75': ['16', '17', '19', '23', '24', '33', '40', '47', '64', '79', '86', '87'],
44
+ 'reg:76': ['09', '11', '12', '30', '31', '32', '34', '46', '48', '65', '66', '81', '82'],
45
+ 'reg:84': ['01', '03', '07', '15', '26', '38', '42', '43', '63', '69', '73', '74'],
46
+ 'reg:93': ['04', '05', '06', '13', '83', '84'],
47
+ 'reg:94': ['2A', '2B'],
48
+ };
49
+
50
+ export const FR_DEPARTMENTS: { code: string; name: string }[] = [
51
+ { code: '01', name: 'Ain' },
52
+ { code: '02', name: 'Aisne' },
53
+ { code: '03', name: 'Allier' },
54
+ { code: '04', name: 'Alpes-de-Haute-Provence' },
55
+ { code: '05', name: 'Hautes-Alpes' },
56
+ { code: '06', name: 'Alpes-Maritimes' },
57
+ { code: '07', name: 'Ardèche' },
58
+ { code: '08', name: 'Ardennes' },
59
+ { code: '09', name: 'Ariège' },
60
+ { code: '10', name: 'Aube' },
61
+ { code: '11', name: 'Aude' },
62
+ { code: '12', name: 'Aveyron' },
63
+ { code: '13', name: 'Bouches-du-Rhône' },
64
+ { code: '14', name: 'Calvados' },
65
+ { code: '15', name: 'Cantal' },
66
+ { code: '16', name: 'Charente' },
67
+ { code: '17', name: 'Charente-Maritime' },
68
+ { code: '18', name: 'Cher' },
69
+ { code: '19', name: 'Corrèze' },
70
+ { code: '2A', name: 'Corse-du-Sud' },
71
+ { code: '2B', name: 'Haute-Corse' },
72
+ { code: '21', name: "Côte-d'Or" },
73
+ { code: '22', name: "Côtes-d'Armor" },
74
+ { code: '23', name: 'Creuse' },
75
+ { code: '24', name: 'Dordogne' },
76
+ { code: '25', name: 'Doubs' },
77
+ { code: '26', name: 'Drôme' },
78
+ { code: '27', name: 'Eure' },
79
+ { code: '28', name: 'Eure-et-Loir' },
80
+ { code: '29', name: 'Finistère' },
81
+ { code: '30', name: 'Gard' },
82
+ { code: '31', name: 'Haute-Garonne' },
83
+ { code: '32', name: 'Gers' },
84
+ { code: '33', name: 'Gironde' },
85
+ { code: '34', name: 'Hérault' },
86
+ { code: '35', name: 'Ille-et-Vilaine' },
87
+ { code: '36', name: 'Indre' },
88
+ { code: '37', name: 'Indre-et-Loire' },
89
+ { code: '38', name: 'Isère' },
90
+ { code: '39', name: 'Jura' },
91
+ { code: '40', name: 'Landes' },
92
+ { code: '41', name: 'Loir-et-Cher' },
93
+ { code: '42', name: 'Loire' },
94
+ { code: '43', name: 'Haute-Loire' },
95
+ { code: '44', name: 'Loire-Atlantique' },
96
+ { code: '45', name: 'Loiret' },
97
+ { code: '46', name: 'Lot' },
98
+ { code: '47', name: 'Lot-et-Garonne' },
99
+ { code: '48', name: 'Lozère' },
100
+ { code: '49', name: 'Maine-et-Loire' },
101
+ { code: '50', name: 'Manche' },
102
+ { code: '51', name: 'Marne' },
103
+ { code: '52', name: 'Haute-Marne' },
104
+ { code: '53', name: 'Mayenne' },
105
+ { code: '54', name: 'Meurthe-et-Moselle' },
106
+ { code: '55', name: 'Meuse' },
107
+ { code: '56', name: 'Morbihan' },
108
+ { code: '57', name: 'Moselle' },
109
+ { code: '58', name: 'Nièvre' },
110
+ { code: '59', name: 'Nord' },
111
+ { code: '60', name: 'Oise' },
112
+ { code: '61', name: 'Orne' },
113
+ { code: '62', name: 'Pas-de-Calais' },
114
+ { code: '63', name: 'Puy-de-Dôme' },
115
+ { code: '64', name: 'Pyrénées-Atlantiques' },
116
+ { code: '65', name: 'Hautes-Pyrénées' },
117
+ { code: '66', name: 'Pyrénées-Orientales' },
118
+ { code: '67', name: 'Bas-Rhin' },
119
+ { code: '68', name: 'Haut-Rhin' },
120
+ { code: '69', name: 'Rhône' },
121
+ { code: '70', name: 'Haute-Saône' },
122
+ { code: '71', name: 'Saône-et-Loire' },
123
+ { code: '72', name: 'Sarthe' },
124
+ { code: '73', name: 'Savoie' },
125
+ { code: '74', name: 'Haute-Savoie' },
126
+ { code: '75', name: 'Paris' },
127
+ { code: '76', name: 'Seine-Maritime' },
128
+ { code: '77', name: 'Seine-et-Marne' },
129
+ { code: '78', name: 'Yvelines' },
130
+ { code: '79', name: 'Deux-Sèvres' },
131
+ { code: '80', name: 'Somme' },
132
+ { code: '81', name: 'Tarn' },
133
+ { code: '82', name: 'Tarn-et-Garonne' },
134
+ { code: '83', name: 'Var' },
135
+ { code: '84', name: 'Vaucluse' },
136
+ { code: '85', name: 'Vendée' },
137
+ { code: '86', name: 'Vienne' },
138
+ { code: '87', name: 'Haute-Vienne' },
139
+ { code: '88', name: 'Vosges' },
140
+ { code: '89', name: 'Yonne' },
141
+ { code: '90', name: 'Territoire de Belfort' },
142
+ { code: '91', name: 'Essonne' },
143
+ { code: '92', name: 'Hauts-de-Seine' },
144
+ { code: '93', name: 'Seine-Saint-Denis' },
145
+ { code: '94', name: 'Val-de-Marne' },
146
+ { code: '95', name: "Val-d'Oise" },
147
+ { code: '971', name: 'Guadeloupe' },
148
+ { code: '972', name: 'Martinique' },
149
+ { code: '973', name: 'Guyane' },
150
+ { code: '974', name: 'La Réunion' },
151
+ { code: '976', name: 'Mayotte' },
152
+ ];
153
+
154
+ export function getPostalPrefixesForDepartment(code: string): string[] {
155
+ const c = code.trim().toUpperCase();
156
+ if (c === '2A') return ['200', '201'];
157
+ if (c === '2B') return ['202'];
158
+ if (/^97[1-6]$/.test(c)) return [c];
159
+ if (/^\d{3}$/.test(c) && (c.startsWith('97') || c.startsWith('98'))) return [c];
160
+ if (/^\d{1,2}$/.test(c)) {
161
+ const n = Number.parseInt(c, 10);
162
+ if (n >= 1 && n <= 95) return [String(n).padStart(2, '0')];
163
+ }
164
+ if (/^0\d$/.test(c)) return [c];
165
+ return [c];
166
+ }
167
+
168
+ export function expandRegionCodesToDepartmentCodes(regionCodes: string[]): string[] {
169
+ const out = new Set<string>();
170
+ for (const r of regionCodes) {
171
+ const depts = REGION_TO_DEPARTMENT_CODES[r.trim()];
172
+ if (depts) {
173
+ for (const d of depts) out.add(d);
174
+ }
175
+ }
176
+ return [...out];
177
+ }
178
+
179
+ /** Prisma: contact dont le code postal commence par un préfixe du département. */
180
+ export function prismaPostalMatchesDepartmentsCondition(departmentCodes: string[]): {
181
+ OR: Record<string, unknown>[];
182
+ } | null {
183
+ if (departmentCodes.length === 0) return null;
184
+ const ors: Record<string, unknown>[] = [];
185
+ for (const code of departmentCodes) {
186
+ for (const prefix of getPostalPrefixesForDepartment(code)) {
187
+ ors.push({ postalCode: { startsWith: prefix, mode: 'insensitive' as const } });
188
+ }
189
+ }
190
+ if (ors.length === 0) return null;
191
+ return { OR: ors };
192
+ }