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
@@ -0,0 +1,514 @@
1
+ import { prisma } from '@/lib/prisma';
2
+ import { getValidAccessToken, GoogleTokenError } from '@/lib/google-calendar';
3
+ import { handleContactDuplicate } from '@/lib/contact-duplicate';
4
+ import { normalizePhoneNumber, parseImportDate } from '@/lib/utils';
5
+ import { googleFetch } from '@/lib/google-fetch';
6
+ import { encrypt, decrypt } from '@/lib/encryption';
7
+ import { shouldPersistSyncedIntegrationLog } from '@/lib/integration-import-log';
8
+
9
+ export type GoogleSheetSyncResultItem = {
10
+ configId: string;
11
+ configName: string;
12
+ imported: number;
13
+ updated: number;
14
+ skipped: number;
15
+ duplicates?: number;
16
+ errors?: number;
17
+ error?: string;
18
+ };
19
+
20
+ export type GoogleSheetSyncRunResult = {
21
+ totalImported: number;
22
+ totalUpdated: number;
23
+ totalSkipped: number;
24
+ results: GoogleSheetSyncResultItem[];
25
+ message?: string;
26
+ };
27
+
28
+ /** Timeout transaction interactive Prisma (défaut 5s insuffisant : appels Google + import lignes). ms */
29
+ const GOOGLE_SHEET_SYNC_TX_TIMEOUT_MS = Math.max(
30
+ 60_000,
31
+ Number.parseInt(process.env.GOOGLE_SHEET_SYNC_TRANSACTION_TIMEOUT_MS ?? '', 10) || 15 * 60 * 1000,
32
+ );
33
+
34
+ export async function runGoogleSheetSync(params: {
35
+ sessionUserId?: string | null;
36
+ requestedConfigId?: string;
37
+ }): Promise<GoogleSheetSyncRunResult> {
38
+ const { sessionUserId: rawSessionUserId, requestedConfigId } = params;
39
+ const sessionUserId = rawSessionUserId ?? null;
40
+ const where: { active: true; id?: string } = { active: true };
41
+ if (requestedConfigId) where.id = requestedConfigId;
42
+
43
+ const configs = await prisma.googleSheetSyncConfig.findMany({
44
+ where,
45
+ include: {
46
+ ownerUser: true,
47
+ },
48
+ });
49
+
50
+ if (!configs || configs.length === 0) {
51
+ return {
52
+ totalImported: 0,
53
+ totalUpdated: 0,
54
+ totalSkipped: 0,
55
+ results: [],
56
+ message: "Aucune configuration Google Sheets active n'a été trouvée.",
57
+ };
58
+ }
59
+
60
+ const results: GoogleSheetSyncResultItem[] = [];
61
+ let totalImported = 0;
62
+ let totalUpdated = 0;
63
+ let totalSkipped = 0;
64
+
65
+ type DbClient = Pick<typeof prisma, 'integrationImportLog'>;
66
+ async function createSyncLog(
67
+ config: { id: string; name: string },
68
+ entry: {
69
+ imported?: number;
70
+ updated?: number;
71
+ skipped?: number;
72
+ duplicates?: number;
73
+ error?: string;
74
+ },
75
+ db: DbClient = prisma,
76
+ ) {
77
+ const imported = entry.imported ?? 0;
78
+ const duplicates = entry.duplicates ?? 0;
79
+ const updated = entry.updated ?? 0;
80
+ const hasError = Boolean(entry.error);
81
+
82
+ if (
83
+ !shouldPersistSyncedIntegrationLog({
84
+ totalImported: imported,
85
+ totalDuplicates: duplicates,
86
+ totalUpdated: updated,
87
+ hasError,
88
+ })
89
+ ) {
90
+ return;
91
+ }
92
+
93
+ await db.integrationImportLog.create({
94
+ data: {
95
+ integrationType: 'google_sheet',
96
+ configId: config.id,
97
+ configName: config.name,
98
+ action: sessionUserId ? 'synced_manual' : 'synced_auto',
99
+ actorId: sessionUserId ?? undefined,
100
+ totalImported: imported,
101
+ totalDuplicates: duplicates,
102
+ totalUpdated: updated,
103
+ totalErrors: hasError ? 1 : 0,
104
+ errorDetails: entry.error ? { message: entry.error } : undefined,
105
+ },
106
+ });
107
+ }
108
+
109
+ const GLOBAL_LOCK_KEY = 'google_sheet_sync_global';
110
+ await prisma.$transaction(
111
+ async (tx) => {
112
+ const [globalLock] =
113
+ await tx.$queryRaw<Array<{ locked: boolean }>>`SELECT pg_try_advisory_xact_lock(hashtext(${GLOBAL_LOCK_KEY}), 4261) AS locked`;
114
+ if (!globalLock?.locked) {
115
+ for (const c of configs) {
116
+ const lockError =
117
+ 'Une synchronisation est déjà en cours. Veuillez patienter qu’elle se termine puis relancer.';
118
+ results.push({
119
+ configId: c.id,
120
+ configName: c.name,
121
+ imported: 0,
122
+ updated: 0,
123
+ skipped: 0,
124
+ error: lockError,
125
+ });
126
+ await createSyncLog(c, { error: lockError }, tx);
127
+ }
128
+ return;
129
+ }
130
+
131
+ for (const config of configs) {
132
+ try {
133
+ const range = encodeURIComponent(config.sheetName);
134
+ const candidateUserIds = Array.from(
135
+ new Set(
136
+ [config.ownerUserId, config.defaultAssignedUserId, sessionUserId].filter(
137
+ (value): value is string => Boolean(value),
138
+ ),
139
+ ),
140
+ );
141
+
142
+ let selectedSheetsResponse: Response | null = null;
143
+ let lastAuthError =
144
+ 'Aucun compte Google compatible trouvé pour cette configuration. Reconnectez Google puis partagez le Sheet avec ce compte.';
145
+
146
+ for (const candidateUserId of candidateUserIds) {
147
+ const googleAccount = await tx.userGoogleAccount.findUnique({
148
+ where: { userId: candidateUserId },
149
+ });
150
+ if (!googleAccount) {
151
+ lastAuthError = 'Aucun compte Google connecté parmi les utilisateurs autorisés.';
152
+ continue;
153
+ }
154
+
155
+ try {
156
+ const decryptedAccess = decrypt(googleAccount.accessToken);
157
+ const decryptedRefresh = decrypt(googleAccount.refreshToken);
158
+ const accessToken = await getValidAccessToken(
159
+ decryptedAccess,
160
+ decryptedRefresh,
161
+ googleAccount.tokenExpiresAt,
162
+ );
163
+
164
+ if (accessToken !== decryptedAccess) {
165
+ const tokenExpiresAt = new Date();
166
+ tokenExpiresAt.setSeconds(tokenExpiresAt.getSeconds() + 3600);
167
+ await tx.userGoogleAccount.update({
168
+ where: { userId: candidateUserId },
169
+ data: { accessToken: encrypt(accessToken), tokenExpiresAt },
170
+ });
171
+ }
172
+
173
+ const candidateResponse = await googleFetch(
174
+ `https://sheets.googleapis.com/v4/spreadsheets/${config.spreadsheetId}/values/${range}`,
175
+ {
176
+ headers: {
177
+ Authorization: `Bearer ${accessToken}`,
178
+ },
179
+ },
180
+ );
181
+
182
+ if (candidateResponse.ok) {
183
+ selectedSheetsResponse = candidateResponse;
184
+ break;
185
+ }
186
+
187
+ const errorText = await candidateResponse.text();
188
+ lastAuthError =
189
+ candidateResponse.status === 401 || candidateResponse.status === 403
190
+ ? 'Le compte Google connecté n’a pas accès à ce Sheet. Partagez le document avec ce compte ou reconnectez Google.'
191
+ : 'Impossible de lire les données depuis Google Sheets.';
192
+ console.error(
193
+ `Erreur lors de la lecture du Google Sheet ${config.name} (user ${candidateUserId}):`,
194
+ errorText,
195
+ );
196
+ } catch (error: any) {
197
+ if (error instanceof GoogleTokenError && error.isRevoked) {
198
+ lastAuthError = error.message;
199
+ } else {
200
+ lastAuthError = error?.message || 'Erreur lors de la validation du compte Google.';
201
+ }
202
+ }
203
+ }
204
+
205
+ if (!selectedSheetsResponse) {
206
+ results.push({
207
+ configId: config.id,
208
+ configName: config.name,
209
+ imported: 0,
210
+ updated: 0,
211
+ skipped: 0,
212
+ error: lastAuthError,
213
+ });
214
+ await createSyncLog(config, { error: lastAuthError }, tx);
215
+ continue;
216
+ }
217
+
218
+ const data = await selectedSheetsResponse.json();
219
+ const values: string[][] = data.values || [];
220
+ if (!values.length) {
221
+ results.push({
222
+ configId: config.id,
223
+ configName: config.name,
224
+ imported: 0,
225
+ updated: 0,
226
+ skipped: 0,
227
+ });
228
+ await createSyncLog(config, { imported: 0, updated: 0, skipped: 0 }, tx);
229
+ continue;
230
+ }
231
+
232
+ const headerRowIndex = config.headerRow - 1;
233
+ const startRowIndex = Math.max(headerRowIndex + 1, (config.lastSyncedRow || headerRowIndex) + 1);
234
+ const headerRow = values[headerRowIndex] || [];
235
+ const columnMappings: Record<string, number> = {};
236
+ const noteFields: Array<{ name: string; index: number }> = [];
237
+
238
+ if (!config.columnMappings) {
239
+ const error = "La configuration n'utilise pas le nouveau format de mapping. Veuillez reconfigurer cette intégration.";
240
+ results.push({ configId: config.id, configName: config.name, imported: 0, updated: 0, skipped: 0, error });
241
+ await createSyncLog(config, { error }, tx);
242
+ continue;
243
+ }
244
+
245
+ const mappings = typeof config.columnMappings === 'string' ? JSON.parse(config.columnMappings) : config.columnMappings;
246
+ if (!Array.isArray(mappings)) {
247
+ const error = 'Format de mapping invalide.';
248
+ results.push({ configId: config.id, configName: config.name, imported: 0, updated: 0, skipped: 0, error });
249
+ await createSyncLog(config, { error }, tx);
250
+ continue;
251
+ }
252
+
253
+ const headerIndexMap = new Map<string, number>();
254
+ headerRow.forEach((h: string, i: number) => {
255
+ if (h) headerIndexMap.set(h.trim().toLowerCase(), i);
256
+ });
257
+
258
+ mappings.forEach((mapping: any) => {
259
+ if (mapping.action === 'map' && mapping.crmField && mapping.columnName) {
260
+ const columnIndex = headerIndexMap.get(mapping.columnName.trim().toLowerCase()) ?? -1;
261
+ if (columnIndex !== -1) columnMappings[mapping.crmField] = columnIndex;
262
+ } else if (mapping.action === 'note' && mapping.columnName) {
263
+ const columnIndex = headerIndexMap.get(mapping.columnName.trim().toLowerCase()) ?? -1;
264
+ if (columnIndex !== -1) noteFields.push({ name: mapping.columnName, index: columnIndex });
265
+ }
266
+ });
267
+
268
+ if (columnMappings.phone === undefined) {
269
+ const error = "La colonne téléphone n'est pas correctement mappée.";
270
+ results.push({ configId: config.id, configName: config.name, imported: 0, updated: 0, skipped: 0, error });
271
+ await createSyncLog(config, { error }, tx);
272
+ continue;
273
+ }
274
+
275
+ const phoneIdx = columnMappings.phone;
276
+ let effectiveDefaultStatusId = config.defaultStatusId || null;
277
+ if (!effectiveDefaultStatusId) {
278
+ const fallbackStatus = await tx.status.findFirst({ where: { name: 'Nouveau' } });
279
+ if (fallbackStatus) effectiveDefaultStatusId = fallbackStatus.id;
280
+ }
281
+
282
+ let assignedCommercialId: string | null = null;
283
+ let assignedTeleproId: string | null = null;
284
+ if (config.defaultAssignedUserId) {
285
+ const defaultUser = await tx.user.findUnique({
286
+ where: { id: config.defaultAssignedUserId },
287
+ select: { role: true },
288
+ });
289
+ if (defaultUser?.role === 'COMMERCIAL' || defaultUser?.role === 'ADMIN' || defaultUser?.role === 'MANAGER') {
290
+ assignedCommercialId = config.defaultAssignedUserId;
291
+ } else if (defaultUser?.role === 'TELEPRO') {
292
+ assignedTeleproId = config.defaultAssignedUserId;
293
+ }
294
+ }
295
+
296
+ const escapeHtml = (text: string): string =>
297
+ text.replace(/[&<>"']/g, (m) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[m]!));
298
+
299
+ const formatNoteContent = (noteItems: Array<{ label: string; value: string }>) => {
300
+ const escapedConfigName = escapeHtml(config.name);
301
+ if (noteItems.length === 0) {
302
+ return `<p style="font-size: 14px; line-height: 1.5; color: #374151; margin-bottom: 0;">Ce contact a été importé automatiquement depuis Google Sheets (${escapedConfigName}).</p>`;
303
+ }
304
+ let html = `<p style="font-size: 14px; line-height: 1.5; color: #374151; margin-bottom: 16px;">Ce contact a été importé automatiquement depuis Google Sheets (${escapedConfigName}).</p>`;
305
+ html += '<div style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;">';
306
+ noteItems.forEach((item) => {
307
+ const humanLabel = item.label
308
+ .split(/(?=[A-Z])/)
309
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
310
+ .join(' ');
311
+ const escapedLabel = escapeHtml(humanLabel);
312
+ const escapedValue = escapeHtml(String(item.value)).replace(/\n/g, '<br>');
313
+ html += `<div style="padding: 12px 14px; background: linear-gradient(to right, #f8fafc 0%, #f1f5f9 100%); border-radius: 10px; border-left: 4px solid #6366f1; box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05);"><div style="font-size: 11px; font-weight: 700; color: #64748b; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 6px;">${escapedLabel}</div><div style="font-size: 14px; line-height: 1.5; color: #0f172a; font-weight: 500;">${escapedValue}</div></div>`;
314
+ });
315
+ html += '</div>';
316
+ return html;
317
+ };
318
+
319
+ const createGoogleSheetsImportMetadata = (
320
+ noteItems: Array<{ label: string; value: string }>,
321
+ htmlContent: string,
322
+ ) => ({
323
+ htmlContent,
324
+ isGoogleSheetsImport: true,
325
+ sourceName: config.name,
326
+ importedFields: noteItems,
327
+ });
328
+
329
+ let imported = 0;
330
+ let updated = 0;
331
+ let skipped = 0;
332
+ let duplicateCount = 0;
333
+ let maxProcessedRow = config.lastSyncedRow || headerRowIndex;
334
+ const seenRowKeys = new Set<string>();
335
+
336
+ for (let rowIndex = startRowIndex; rowIndex < values.length; rowIndex++) {
337
+ const row = values[rowIndex];
338
+ if (!row) continue;
339
+ const phone = row[phoneIdx]?.trim();
340
+ if (!phone) {
341
+ skipped++;
342
+ continue;
343
+ }
344
+
345
+ const firstName = columnMappings.firstName !== undefined ? row[columnMappings.firstName]?.trim() || undefined : undefined;
346
+ const lastName = columnMappings.lastName !== undefined ? row[columnMappings.lastName]?.trim() || undefined : undefined;
347
+ const email = columnMappings.email !== undefined ? row[columnMappings.email]?.trim() || undefined : undefined;
348
+ const city = columnMappings.city !== undefined ? row[columnMappings.city]?.trim() || undefined : undefined;
349
+ const postalCodeRaw = columnMappings.postalCode !== undefined ? row[columnMappings.postalCode]?.trim() || undefined : undefined;
350
+ const postalCode = postalCodeRaw ? (postalCodeRaw.match(/\d{4,5}/)?.[0] || postalCodeRaw) : undefined;
351
+ const companyName = columnMappings.companyName !== undefined ? row[columnMappings.companyName]?.trim() || undefined : undefined;
352
+ const origin = columnMappings.origin !== undefined ? row[columnMappings.origin]?.trim() || 'Google Sheets' : 'Google Sheets';
353
+ const website = columnMappings.website !== undefined ? row[columnMappings.website]?.trim() || null : null;
354
+ const civilityRaw = columnMappings.civility !== undefined ? row[columnMappings.civility]?.trim() || null : null;
355
+ const civility = civilityRaw && ['M', 'MME', 'MLLE'].includes(civilityRaw.toUpperCase()) ? (civilityRaw.toUpperCase() as 'M' | 'MME' | 'MLLE') : null;
356
+ const address = columnMappings.address !== undefined ? row[columnMappings.address]?.trim() || null : null;
357
+ const secondaryPhoneRaw = columnMappings.secondaryPhone !== undefined ? row[columnMappings.secondaryPhone]?.trim() || null : null;
358
+ const secondaryPhone = secondaryPhoneRaw ? normalizePhoneNumber(secondaryPhoneRaw) || null : null;
359
+ const jobTitle = columnMappings.jobTitle !== undefined ? row[columnMappings.jobTitle]?.trim() || null : null;
360
+ const createdAtRaw = columnMappings.createdAt !== undefined ? row[columnMappings.createdAt]?.trim() : undefined;
361
+ const createdAtImport = createdAtRaw ? parseImportDate(createdAtRaw) : null;
362
+
363
+ const normalizedEmail = email ? email.trim().toLowerCase() : '';
364
+ const normalizedPhone = normalizePhoneNumber(phone);
365
+ const normalizedFirstName = firstName ? firstName.trim().toLowerCase() : '';
366
+ const normalizedLastName = lastName ? lastName.trim().toLowerCase() : '';
367
+ const rowKey = normalizedEmail
368
+ ? `email:${normalizedEmail}`
369
+ : normalizedPhone
370
+ ? `phone:${normalizedPhone}`
371
+ : `name:${normalizedFirstName}|${normalizedLastName}`;
372
+ if (seenRowKeys.has(rowKey)) {
373
+ skipped++;
374
+ continue;
375
+ }
376
+ seenRowKeys.add(rowKey);
377
+
378
+ const noteContents: Array<{ label: string; value: string }> = [];
379
+ noteFields.forEach(({ name, index }) => {
380
+ if (row[index]) noteContents.push({ label: name, value: row[index].trim() });
381
+ });
382
+
383
+ const assignedUserId = config.defaultAssignedUserId ?? config.ownerUserId ?? sessionUserId ?? '';
384
+ const duplicateContactId = await handleContactDuplicate(firstName, lastName, email, origin, assignedUserId);
385
+
386
+ let contact;
387
+ if (duplicateContactId) {
388
+ contact = await tx.contact.findUnique({ where: { id: duplicateContactId } });
389
+ updated++;
390
+ duplicateCount++;
391
+ } else {
392
+ contact =
393
+ (email &&
394
+ (await tx.contact.findFirst({
395
+ where: {
396
+ OR: [{ email: email.toLowerCase() }, { phone: normalizedPhone }],
397
+ },
398
+ }))) ||
399
+ (await tx.contact.findFirst({
400
+ where: { phone: normalizedPhone },
401
+ }));
402
+
403
+ if (!contact) {
404
+ const formattedContent = formatNoteContent(noteContents);
405
+ contact = await tx.contact.create({
406
+ data: {
407
+ civility: civility ?? undefined,
408
+ firstName: firstName || null,
409
+ lastName: lastName || null,
410
+ email: email ? email.toLowerCase() : null,
411
+ phone: normalizedPhone,
412
+ secondaryPhone: secondaryPhone ?? undefined,
413
+ address: address ?? undefined,
414
+ city: city || null,
415
+ postalCode: postalCode || null,
416
+ companyName: companyName || null,
417
+ origin,
418
+ ...(website && { website }),
419
+ jobTitle: jobTitle ?? undefined,
420
+ ...(createdAtImport && { createdAt: createdAtImport }),
421
+ statusId: effectiveDefaultStatusId,
422
+ assignedCommercialId,
423
+ assignedTeleproId,
424
+ createdById: config.defaultAssignedUserId || config.ownerUserId,
425
+ interactions: {
426
+ create: [
427
+ {
428
+ type: 'NOTE',
429
+ title: `Contact importé depuis Google Sheets: ${config.name}`,
430
+ content: formattedContent,
431
+ userId: config.defaultAssignedUserId || config.ownerUserId,
432
+ date: new Date(),
433
+ metadata: {
434
+ ...createGoogleSheetsImportMetadata(noteContents, formattedContent),
435
+ },
436
+ },
437
+ ],
438
+ },
439
+ },
440
+ });
441
+ imported++;
442
+ } else {
443
+ await tx.contact.update({
444
+ where: { id: contact.id },
445
+ data: {
446
+ ...(columnMappings.civility !== undefined && { civility: civility ?? undefined }),
447
+ firstName: contact.firstName || firstName || null,
448
+ lastName: contact.lastName || lastName || null,
449
+ email: contact.email || (email ? email.toLowerCase() : null),
450
+ ...(columnMappings.secondaryPhone !== undefined && {
451
+ secondaryPhone: secondaryPhone ?? undefined,
452
+ }),
453
+ ...(columnMappings.address !== undefined && { address: address ?? undefined }),
454
+ city: contact.city || city || null,
455
+ postalCode: contact.postalCode || postalCode || null,
456
+ companyName: contact.companyName || (contact.companyId ? null : companyName || null),
457
+ origin: contact.origin || origin,
458
+ ...(columnMappings.website !== undefined && { website: website || null }),
459
+ ...(columnMappings.jobTitle !== undefined && { jobTitle: jobTitle ?? undefined }),
460
+ statusId: contact.statusId || effectiveDefaultStatusId,
461
+ assignedCommercialId: contact.assignedCommercialId || assignedCommercialId,
462
+ assignedTeleproId: contact.assignedTeleproId || assignedTeleproId,
463
+ },
464
+ });
465
+ updated++;
466
+ }
467
+ }
468
+
469
+ if (rowIndex > maxProcessedRow) maxProcessedRow = rowIndex;
470
+ }
471
+
472
+ if (maxProcessedRow > (config.lastSyncedRow || headerRowIndex)) {
473
+ await tx.googleSheetSyncConfig.update({
474
+ where: { id: config.id },
475
+ data: { lastSyncedRow: maxProcessedRow },
476
+ });
477
+ }
478
+
479
+ totalImported += imported;
480
+ totalUpdated += updated;
481
+ totalSkipped += skipped;
482
+ results.push({
483
+ configId: config.id,
484
+ configName: config.name,
485
+ imported,
486
+ updated,
487
+ skipped,
488
+ duplicates: duplicateCount,
489
+ });
490
+ await createSyncLog(config, { imported, updated, skipped, duplicates: duplicateCount }, tx);
491
+ } catch (error: any) {
492
+ console.error(`Erreur lors de la synchronisation de ${config.name}:`, error);
493
+ results.push({
494
+ configId: config.id,
495
+ configName: config.name,
496
+ imported: 0,
497
+ updated: 0,
498
+ skipped: 0,
499
+ error: error.message || 'Erreur lors de la synchronisation',
500
+ });
501
+ await createSyncLog(config, { error: error.message || 'Erreur lors de la synchronisation' }, tx).catch(
502
+ (err) => console.error('Erreur création log sync:', err),
503
+ );
504
+ }
505
+ }
506
+ },
507
+ {
508
+ maxWait: 15_000,
509
+ timeout: GOOGLE_SHEET_SYNC_TX_TIMEOUT_MS,
510
+ },
511
+ );
512
+
513
+ return { totalImported, totalUpdated, totalSkipped, results };
514
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Aligné sur GET /api/settings/integrations/logs : les entrées de synchronisation
3
+ * (`synced`, `synced_manual`, `synced_auto`) avec
4
+ * 0 importés, 0 doublons et 0 mises à jour ne sont pas listées côté UI.
5
+ * Ne pas les persister évite de polluer la table.
6
+ *
7
+ * Les sync en erreur (message d’erreur) restent enregistrées pour le suivi.
8
+ */
9
+ export function shouldPersistSyncedIntegrationLog(params: {
10
+ totalImported?: number;
11
+ totalDuplicates?: number;
12
+ totalUpdated?: number;
13
+ /** Présence d’un message d’erreur (sync échouée, verrou, accès refusé, etc.) */
14
+ hasError?: boolean;
15
+ }): boolean {
16
+ if (params.hasError) return true;
17
+ const i = params.totalImported ?? 0;
18
+ const d = params.totalDuplicates ?? 0;
19
+ const u = params.totalUpdated ?? 0;
20
+ return i > 0 || d > 0 || u > 0;
21
+ }
@@ -32,6 +32,18 @@ export const PERMISSIONS: Permission[] = [
32
32
  description: 'Permet de voir le tableau de bord principal',
33
33
  category: PERMISSION_CATEGORIES.DASHBOARD,
34
34
  },
35
+ {
36
+ code: 'dashboard.widgets.manage',
37
+ name: 'Gérer les widgets du dashboard',
38
+ description: "Permet d'ajouter, supprimer et réorganiser les widgets du tableau de bord",
39
+ category: PERMISSION_CATEGORIES.DASHBOARD,
40
+ },
41
+ {
42
+ code: 'dashboard.widgets.reset',
43
+ name: 'Réinitialiser le dashboard',
44
+ description: 'Permet de réinitialiser le tableau de bord à sa configuration par défaut',
45
+ category: PERMISSION_CATEGORIES.DASHBOARD,
46
+ },
35
47
 
36
48
  // Analytics
37
49
  {
@@ -92,8 +104,16 @@ export const PERMISSIONS: Permission[] = [
92
104
  },
93
105
  {
94
106
  code: 'contacts.assign',
95
- name: 'Assigner des contacts',
96
- description: "Permet d'assigner des contacts à d'autres utilisateurs",
107
+ name: 'Assigner des contacts (tous rôles)',
108
+ description:
109
+ "Permet d'assigner des contacts à tout utilisateur habilité (commercial, télépro, manager, admin)",
110
+ category: PERMISSION_CATEGORIES.CONTACTS,
111
+ },
112
+ {
113
+ code: 'contacts.assign_to_sales',
114
+ name: 'Assigner à un commercial ou télépro',
115
+ description:
116
+ "Permet d'assigner des contacts uniquement à un commercial ou un télépro (pas aux managers/admins)",
97
117
  category: PERMISSION_CATEGORIES.CONTACTS,
98
118
  },
99
119
  {
@@ -271,8 +291,16 @@ export const PERMISSIONS: Permission[] = [
271
291
  },
272
292
  {
273
293
  code: 'tasks.assign',
274
- name: 'Assigner des tâches',
275
- description: "Permet d'assigner des tâches à d'autres utilisateurs",
294
+ name: 'Assigner des tâches (tous rôles)',
295
+ description:
296
+ "Permet d'assigner des tâches à tout utilisateur habilité (commercial, télépro, manager, admin)",
297
+ category: PERMISSION_CATEGORIES.TASKS,
298
+ },
299
+ {
300
+ code: 'tasks.assign_to_sales',
301
+ name: 'Assigner des tâches à un commercial ou télépro',
302
+ description:
303
+ "Permet d'assigner des tâches uniquement à un commercial ou un télépro (pas aux managers/admins)",
276
304
  category: PERMISSION_CATEGORIES.TASKS,
277
305
  },
278
306
 
@@ -512,6 +540,8 @@ export const DEFAULT_ROLES = {
512
540
  description: "Gestion d'équipe et accès étendu aux leads",
513
541
  permissions: [
514
542
  'dashboard.view',
543
+ 'dashboard.widgets.manage',
544
+ 'dashboard.widgets.reset',
515
545
  'analytics.view',
516
546
  'general.view_all_companies',
517
547
  'contacts.view_all',
@@ -548,10 +578,6 @@ export const DEFAULT_ROLES = {
548
578
  'workflows.edit',
549
579
  'workflows.activate',
550
580
  'settings.workflows.manage',
551
- 'workflows.view',
552
- 'workflows.create',
553
- 'workflows.edit',
554
- 'workflows.activate',
555
581
  'templates.view',
556
582
  'templates.create',
557
583
  'templates.edit',
@@ -577,10 +603,12 @@ export const DEFAULT_ROLES = {
577
603
  description: 'Accès de base pour la gestion des leads personnels',
578
604
  permissions: [
579
605
  'dashboard.view',
606
+ 'dashboard.widgets.manage',
580
607
  'contacts.view_own',
581
608
  'contacts.view_unassigned',
582
609
  'contacts.create',
583
610
  'contacts.edit_own',
611
+ 'contacts.assign_to_sales',
584
612
  'contacts.export',
585
613
  'contacts.view_files',
586
614
  'contacts.upload_files',
@@ -599,7 +627,7 @@ export const DEFAULT_ROLES = {
599
627
  'tasks.view_own',
600
628
  'tasks.create',
601
629
  'tasks.edit_own',
602
- 'tasks.assign',
630
+ 'tasks.assign_to_sales',
603
631
  'audit.view_own',
604
632
  'templates.view',
605
633
  'templates.create',
@@ -615,10 +643,12 @@ export const DEFAULT_ROLES = {
615
643
  description: 'Accès limité pour la qualification de leads',
616
644
  permissions: [
617
645
  'dashboard.view',
646
+ 'dashboard.widgets.manage',
618
647
  'contacts.view_own',
619
648
  'contacts.view_unassigned',
620
649
  'contacts.create',
621
650
  'contacts.edit_own',
651
+ 'contacts.assign_to_sales',
622
652
  'contacts.view_files',
623
653
  'contacts.upload_files',
624
654
  'contacts.view_closing_pipeline',
@@ -636,7 +666,7 @@ export const DEFAULT_ROLES = {
636
666
  'tasks.view_own',
637
667
  'tasks.create',
638
668
  'tasks.edit_own',
639
- 'tasks.assign',
669
+ 'tasks.assign_to_sales',
640
670
  'templates.view',
641
671
  'templates.create',
642
672
  'templates.edit',
@@ -1,6 +1,9 @@
1
1
  import 'dotenv/config';
2
2
  import { PrismaPg } from '@prisma/adapter-pg';
3
3
  import { PrismaClient } from '../../generated/prisma/client';
4
+
5
+ export type { Role } from '../../generated/prisma/client';
6
+ export { Prisma } from '../../generated/prisma/client';
4
7
  import { Pool } from 'pg';
5
8
 
6
9
  const connectionString = `${process.env.DATABASE_URL}`;
@@ -13,7 +16,7 @@ const pool = new Pool({
13
16
  connectionTimeoutMillis: 10000, // Timeout de connexion de 10s
14
17
  });
15
18
 
16
- const adapter = new PrismaPg(pool);
19
+ const adapter = new PrismaPg(pool as any);
17
20
 
18
21
  const globalForPrisma = globalThis as unknown as {
19
22
  prisma?: PrismaClient;