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
@@ -1,573 +1,41 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
- import { prisma } from '@/lib/prisma';
3
- import { getValidAccessToken, GoogleTokenError } from '@/lib/google-calendar';
4
- import { handleContactDuplicate } from '@/lib/contact-duplicate';
5
- import { googleFetch } from '@/lib/google-fetch';
6
- import { encrypt, decrypt } from '@/lib/encryption';
7
2
  import { auth } from '@/lib/auth';
3
+ import { enqueueGoogleSheetSyncJob, SyncJobRateLimitError } from '@/lib/google-sheet-sync-jobs';
8
4
 
9
- // POST /api/integrations/google-sheet/sync - Synchroniser toutes les configurations actives
5
+ // POST /api/integrations/google-sheet/sync tout utilisateur connecté peut lancer une synchro.
6
+ // La permission integrations.google_sheets.manage reste requise côté UI / routes settings pour configurer l’intégration.
10
7
  export async function POST(request: NextRequest) {
11
8
  try {
12
- const client = prisma as any;
13
9
  const session = await auth.api.getSession({ headers: request.headers });
14
10
  if (!session) {
15
11
  return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
16
12
  }
17
13
 
18
- // Récupérer toutes les configurations actives
19
- const configs = await client.googleSheetSyncConfig.findMany({
20
- where: { active: true },
21
- include: {
22
- ownerUser: true,
23
- },
24
- });
25
-
26
- if (!configs || configs.length === 0) {
27
- return NextResponse.json({
28
- totalImported: 0,
29
- totalUpdated: 0,
30
- totalSkipped: 0,
31
- results: [],
32
- message: "Aucune configuration Google Sheets active n'a été trouvée.",
33
- });
34
- }
35
-
36
- const results: Array<{
37
- configId: string;
38
- configName: string;
39
- imported: number;
40
- updated: number;
41
- skipped: number;
42
- error?: string;
43
- }> = [];
44
-
45
- let totalImported = 0;
46
- let totalUpdated = 0;
47
- let totalSkipped = 0;
48
-
49
- // Synchroniser chaque configuration
50
- for (const config of configs) {
51
- const lockKey = `google_sheet_sync:${config.id}`;
52
- let lockAcquired = false;
53
- try {
54
- const lockResult = await client.$queryRaw<
55
- Array<{ locked: boolean }>
56
- >`SELECT pg_try_advisory_lock(hashtext(${lockKey}), 4261) AS locked`;
57
- lockAcquired = lockResult?.[0]?.locked === true;
58
- if (!lockAcquired) {
59
- results.push({
60
- configId: config.id,
61
- configName: config.name,
62
- imported: 0,
63
- updated: 0,
64
- skipped: 0,
65
- error:
66
- 'Une synchronisation est déjà en cours pour cette configuration. Veuillez patienter quelques secondes puis relancer.',
67
- });
68
- continue;
69
- }
70
-
71
- const range = encodeURIComponent(config.sheetName);
72
- const candidateUserIds = Array.from(
73
- new Set(
74
- [config.ownerUserId, config.defaultAssignedUserId, session.user.id].filter(
75
- (value): value is string => Boolean(value),
76
- ),
77
- ),
78
- );
79
-
80
- let selectedSheetsResponse: Response | null = null;
81
- let lastAuthError =
82
- 'Aucun compte Google compatible trouvé pour cette configuration. Reconnectez Google puis partagez le Sheet avec ce compte.';
83
-
84
- for (const candidateUserId of candidateUserIds) {
85
- const googleAccount = await client.userGoogleAccount.findUnique({
86
- where: { userId: candidateUserId },
87
- });
88
-
89
- if (!googleAccount) {
90
- lastAuthError = 'Aucun compte Google connecté parmi les utilisateurs autorisés.';
91
- continue;
92
- }
93
-
94
- try {
95
- const decryptedAccess = decrypt(googleAccount.accessToken);
96
- const decryptedRefresh = decrypt(googleAccount.refreshToken);
97
- const accessToken = await getValidAccessToken(
98
- decryptedAccess,
99
- decryptedRefresh,
100
- googleAccount.tokenExpiresAt,
101
- );
102
-
103
- if (accessToken !== decryptedAccess) {
104
- const tokenExpiresAt = new Date();
105
- tokenExpiresAt.setSeconds(tokenExpiresAt.getSeconds() + 3600);
106
- await client.userGoogleAccount.update({
107
- where: { userId: candidateUserId },
108
- data: {
109
- accessToken: encrypt(accessToken),
110
- tokenExpiresAt,
111
- },
112
- });
113
- }
114
-
115
- const candidateResponse = await googleFetch(
116
- `https://sheets.googleapis.com/v4/spreadsheets/${config.spreadsheetId}/values/${range}`,
117
- {
118
- headers: {
119
- Authorization: `Bearer ${accessToken}`,
120
- },
121
- },
122
- );
123
-
124
- if (candidateResponse.ok) {
125
- selectedSheetsResponse = candidateResponse;
126
- break;
127
- }
128
-
129
- const errorText = await candidateResponse.text();
130
- lastAuthError =
131
- candidateResponse.status === 401 || candidateResponse.status === 403
132
- ? 'Le compte Google connecté n’a pas accès à ce Sheet. Partagez le document avec ce compte ou reconnectez Google.'
133
- : 'Impossible de lire les données depuis Google Sheets.';
134
- console.error(
135
- `Erreur lors de la lecture du Google Sheet ${config.name} (user ${candidateUserId}):`,
136
- errorText,
137
- );
138
- } catch (error: any) {
139
- if (error instanceof GoogleTokenError && error.isRevoked) {
140
- lastAuthError = error.message;
141
- } else {
142
- lastAuthError = error?.message || 'Erreur lors de la validation du compte Google.';
143
- }
144
- }
145
- }
146
-
147
- if (!selectedSheetsResponse) {
148
- results.push({
149
- configId: config.id,
150
- configName: config.name,
151
- imported: 0,
152
- updated: 0,
153
- skipped: 0,
154
- error: lastAuthError,
155
- });
156
- continue;
157
- }
158
-
159
- const data = await selectedSheetsResponse.json();
160
- const values: string[][] = data.values || [];
161
-
162
- if (!values.length) {
163
- results.push({
164
- configId: config.id,
165
- configName: config.name,
166
- imported: 0,
167
- updated: 0,
168
- skipped: 0,
169
- });
170
- continue;
171
- }
172
-
173
- const headerRowIndex = config.headerRow - 1;
174
- const startRowIndex = Math.max(
175
- headerRowIndex + 1,
176
- (config.lastSyncedRow || headerRowIndex) + 1,
177
- );
178
-
179
- // Récupérer les headers
180
- const headerRow = values[headerRowIndex] || [];
181
-
182
- // Utiliser le nouveau format columnMappings
183
- let columnMappings: Record<string, number> = {}; // crmField -> index
184
- let noteFields: Array<{ name: string; index: number }> = [];
185
-
186
- if (!config.columnMappings) {
187
- results.push({
188
- configId: config.id,
189
- configName: config.name,
190
- imported: 0,
191
- updated: 0,
192
- skipped: 0,
193
- error:
194
- "La configuration n'utilise pas le nouveau format de mapping. Veuillez reconfigurer cette intégration.",
195
- });
196
- continue;
197
- }
198
-
199
- // Parser les mappings
200
- const mappings =
201
- typeof config.columnMappings === 'string'
202
- ? JSON.parse(config.columnMappings)
203
- : config.columnMappings;
204
-
205
- if (!Array.isArray(mappings)) {
206
- results.push({
207
- configId: config.id,
208
- configName: config.name,
209
- imported: 0,
210
- updated: 0,
211
- skipped: 0,
212
- error: 'Format de mapping invalide.',
213
- });
214
- continue;
215
- }
216
-
217
- mappings.forEach((mapping: any) => {
218
- if (mapping.action === 'map' && mapping.crmField && mapping.columnName) {
219
- // Trouver l'index de la colonne par son nom
220
- const columnIndex = headerRow.findIndex(
221
- (h: string) =>
222
- h && h.trim().toLowerCase() === mapping.columnName.trim().toLowerCase(),
223
- );
224
- if (columnIndex !== -1) {
225
- columnMappings[mapping.crmField] = columnIndex;
226
- }
227
- } else if (mapping.action === 'note' && mapping.columnName) {
228
- const columnIndex = headerRow.findIndex(
229
- (h: string) =>
230
- h && h.trim().toLowerCase() === mapping.columnName.trim().toLowerCase(),
231
- );
232
- if (columnIndex !== -1) {
233
- noteFields.push({ name: mapping.columnName, index: columnIndex });
234
- }
235
- }
236
- });
237
-
238
- // Vérifier que le téléphone est mappé (obligatoire)
239
- if (columnMappings['phone'] === undefined) {
240
- results.push({
241
- configId: config.id,
242
- configName: config.name,
243
- imported: 0,
244
- updated: 0,
245
- skipped: 0,
246
- error: "La colonne téléphone n'est pas correctement mappée.",
247
- });
248
- continue;
249
- }
250
-
251
- const phoneIdx = columnMappings['phone'];
252
-
253
- // Déterminer le statut par défaut à utiliser (configuré ou "Nouveau")
254
- let effectiveDefaultStatusId = config.defaultStatusId || null;
255
- if (!effectiveDefaultStatusId) {
256
- const fallbackStatus = await client.status.findFirst({
257
- where: { name: 'Nouveau' },
258
- });
259
- if (fallbackStatus) {
260
- effectiveDefaultStatusId = fallbackStatus.id;
261
- }
262
- }
263
-
264
- // Pré-charger le rôle de l'utilisateur par défaut (constant pour toute la config)
265
- let assignedCommercialId: string | null = null;
266
- let assignedTeleproId: string | null = null;
267
-
268
- if (config.defaultAssignedUserId) {
269
- const defaultUser = await client.user.findUnique({
270
- where: { id: config.defaultAssignedUserId },
271
- select: { role: true },
272
- });
273
-
274
- if (defaultUser) {
275
- if (
276
- defaultUser.role === 'COMMERCIAL' ||
277
- defaultUser.role === 'ADMIN' ||
278
- defaultUser.role === 'MANAGER'
279
- ) {
280
- assignedCommercialId = config.defaultAssignedUserId;
281
- } else if (defaultUser.role === 'TELEPRO') {
282
- assignedTeleproId = config.defaultAssignedUserId;
283
- }
284
- }
285
- }
286
-
287
- const escapeHtml = (text: string): string => {
288
- const map: { [key: string]: string } = {
289
- '&': '&amp;',
290
- '<': '&lt;',
291
- '>': '&gt;',
292
- '"': '&quot;',
293
- "'": '&#039;',
294
- };
295
- return text.replace(/[&<>"']/g, (m) => map[m]);
296
- };
297
-
298
- const formatNoteContent = (noteItems: Array<{ label: string; value: string }>) => {
299
- const escapedConfigName = escapeHtml(config.name);
300
-
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
-
305
- 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>`;
306
-
307
- html +=
308
- '<div style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;">';
309
-
310
- noteItems.forEach((item) => {
311
- let formattedValue = item.value;
312
- try {
313
- const parsed = JSON.parse(item.value);
314
- if (Array.isArray(parsed)) {
315
- formattedValue = parsed.map((v) => String(v)).join(', ');
316
- }
317
- } catch {
318
- // Ce n'est pas du JSON, on garde la valeur telle quelle
319
- }
320
-
321
- const humanLabel = item.label
322
- .split(/(?=[A-Z])/)
323
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
324
- .join(' ');
325
-
326
- const escapedLabel = escapeHtml(humanLabel);
327
- const escapedValue = escapeHtml(String(formattedValue)).replace(/\n/g, '<br>');
328
-
329
- html += `
330
- <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); transition: all 0.2s ease;">
331
- <div style="font-size: 11px; font-weight: 700; color: #64748b; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 6px;">
332
- ${escapedLabel}
333
- </div>
334
- <div style="font-size: 14px; line-height: 1.5; color: #0f172a; font-weight: 500;">
335
- ${escapedValue}
336
- </div>
337
- </div>
338
- `;
339
- });
340
-
341
- html += '</div>';
342
- return html;
343
- };
344
-
345
- let imported = 0;
346
- let updated = 0;
347
- let skipped = 0;
348
- let maxProcessedRow = config.lastSyncedRow || headerRowIndex;
349
- const seenRowKeys = new Set<string>();
350
-
351
- for (let rowIndex = startRowIndex; rowIndex < values.length; rowIndex++) {
352
- const row = values[rowIndex];
353
- if (!row) continue;
354
-
355
- const phone = row[phoneIdx]?.trim();
356
- if (!phone) {
357
- skipped++;
358
- continue;
359
- }
360
-
361
- const firstName =
362
- columnMappings['firstName'] !== undefined
363
- ? row[columnMappings['firstName']]?.trim() || undefined
364
- : undefined;
365
- const lastName =
366
- columnMappings['lastName'] !== undefined
367
- ? row[columnMappings['lastName']]?.trim() || undefined
368
- : undefined;
369
- const email =
370
- columnMappings['email'] !== undefined
371
- ? row[columnMappings['email']]?.trim() || undefined
372
- : undefined;
373
- const city =
374
- columnMappings['city'] !== undefined
375
- ? row[columnMappings['city']]?.trim() || undefined
376
- : undefined;
377
- const postalCode =
378
- columnMappings['postalCode'] !== undefined
379
- ? row[columnMappings['postalCode']]?.trim() || undefined
380
- : undefined;
381
- const origin =
382
- columnMappings['origin'] !== undefined
383
- ? row[columnMappings['origin']]?.trim() || 'Google Sheets'
384
- : 'Google Sheets';
385
- const normalizedEmail = email ? email.trim().toLowerCase() : '';
386
- const normalizedPhone = phone.replace(/\D/g, '');
387
- const normalizedFirstName = firstName ? firstName.trim().toLowerCase() : '';
388
- const normalizedLastName = lastName ? lastName.trim().toLowerCase() : '';
389
- const rowKey = normalizedEmail
390
- ? `email:${normalizedEmail}`
391
- : normalizedPhone
392
- ? `phone:${normalizedPhone}`
393
- : `name:${normalizedFirstName}|${normalizedLastName}`;
394
-
395
- if (seenRowKeys.has(rowKey)) {
396
- skipped++;
397
- continue;
398
- }
399
- seenRowKeys.add(rowKey);
400
-
401
- const noteContents: Array<{ label: string; value: string }> = [];
402
- if (noteFields.length > 0) {
403
- noteFields.forEach(({ name, index }) => {
404
- if (row[index]) {
405
- noteContents.push({
406
- label: name,
407
- value: row[index].trim(),
408
- });
409
- }
410
- });
411
- }
412
-
413
- // Vérifier si c'est un doublon (nom, prénom ET email)
414
- const duplicateContactId = await handleContactDuplicate(
415
- firstName,
416
- lastName,
417
- email,
418
- origin,
419
- config.defaultAssignedUserId || config.ownerUserId,
420
- );
421
-
422
- let contact;
423
- let isNewContact = false;
424
-
425
- if (duplicateContactId) {
426
- // C'est un doublon, récupérer le contact existant
427
- contact = await client.contact.findUnique({
428
- where: { id: duplicateContactId },
429
- });
430
- updated++;
431
- } else {
432
- // Chercher un contact existant (par téléphone uniquement)
433
- contact =
434
- (email &&
435
- (await client.contact.findFirst({
436
- where: {
437
- OR: [{ email: email.toLowerCase() }, { phone }],
438
- },
439
- }))) ||
440
- (await client.contact.findFirst({
441
- where: { phone },
442
- }));
443
-
444
- if (!contact) {
445
- // Préparer les interactions à créer
446
- const formattedContent = formatNoteContent(noteContents);
447
- const interactionsToCreate: any[] = [
448
- {
449
- type: 'NOTE',
450
- title: `Contact importé depuis Google Sheets: ${config.name}`,
451
- content: formattedContent,
452
- userId: config.defaultAssignedUserId || config.ownerUserId,
453
- date: new Date(),
454
- metadata: {
455
- htmlContent: formattedContent,
456
- isGoogleSheetsImport: true,
457
- },
458
- },
459
- ];
460
-
461
- contact = await client.contact.create({
462
- data: {
463
- firstName: firstName || null,
464
- lastName: lastName || null,
465
- email: email ? email.toLowerCase() : null,
466
- phone,
467
- city: city || null,
468
- postalCode: postalCode || null,
469
- origin,
470
- statusId: effectiveDefaultStatusId,
471
- assignedCommercialId: assignedCommercialId,
472
- assignedTeleproId: assignedTeleproId,
473
- createdById: config.defaultAssignedUserId || config.ownerUserId,
474
- interactions: {
475
- create: interactionsToCreate,
476
- },
477
- },
478
- });
479
- isNewContact = true;
480
- imported++;
481
- } else {
482
- await client.contact.update({
483
- where: { id: contact.id },
484
- data: {
485
- firstName: contact.firstName || firstName || null,
486
- lastName: contact.lastName || lastName || null,
487
- email: contact.email || (email ? email.toLowerCase() : null),
488
- city: contact.city || city || null,
489
- postalCode: contact.postalCode || postalCode || null,
490
- origin: contact.origin || origin,
491
- statusId: contact.statusId || effectiveDefaultStatusId,
492
- // Ne pas écraser les assignations existantes
493
- assignedCommercialId: contact.assignedCommercialId || assignedCommercialId,
494
- assignedTeleproId: contact.assignedTeleproId || assignedTeleproId,
495
- },
496
- });
497
- updated++;
498
- }
499
- }
500
-
501
- // Créer une interaction de log uniquement pour les contacts mis à jour (pas les nouveaux qui ont déjà leur interaction)
502
- if (contact && !isNewContact) {
503
- // Contact mis à jour, créer l'interaction si nécessaire
504
- const formattedContent = formatNoteContent(noteContents);
505
-
506
- await client.interaction.create({
507
- data: {
508
- contactId: contact.id,
509
- type: 'NOTE',
510
- title: `Contact importé depuis Google Sheets: ${config.name}`,
511
- content: formattedContent,
512
- userId: config.defaultAssignedUserId || config.ownerUserId,
513
- date: new Date(),
514
- metadata: {
515
- htmlContent: formattedContent,
516
- isGoogleSheetsImport: true,
517
- },
518
- },
519
- });
520
- }
521
-
522
- if (rowIndex > maxProcessedRow) {
523
- maxProcessedRow = rowIndex;
524
- }
525
- }
526
-
527
- if (maxProcessedRow > (config.lastSyncedRow || headerRowIndex)) {
528
- await client.googleSheetSyncConfig.update({
529
- where: { id: config.id },
530
- data: {
531
- lastSyncedRow: maxProcessedRow,
532
- },
533
- });
534
- }
535
-
536
- totalImported += imported;
537
- totalUpdated += updated;
538
- totalSkipped += skipped;
539
-
540
- results.push({
541
- configId: config.id,
542
- configName: config.name,
543
- imported,
544
- updated,
545
- skipped,
546
- });
547
- } catch (error: any) {
548
- console.error(`Erreur lors de la synchronisation de ${config.name}:`, error);
549
- results.push({
550
- configId: config.id,
551
- configName: config.name,
552
- imported: 0,
553
- updated: 0,
554
- skipped: 0,
555
- error: error.message || 'Erreur lors de la synchronisation',
556
- });
557
- } finally {
558
- if (lockAcquired) {
559
- await client.$queryRaw`SELECT pg_advisory_unlock(hashtext(${lockKey}), 4261)`;
560
- }
561
- }
14
+ let body: { configId?: string } = {};
15
+ try {
16
+ body = await request.json().catch(() => ({}));
17
+ } catch {
18
+ // body optionnel
562
19
  }
20
+ const { configId: requestedConfigId } = body;
563
21
 
564
- return NextResponse.json({
565
- totalImported,
566
- totalUpdated,
567
- totalSkipped,
568
- results,
22
+ const job = await enqueueGoogleSheetSyncJob({
23
+ requestedByUserId: session.user.id,
24
+ configId: requestedConfigId ?? null,
25
+ triggerType: 'MANUAL',
569
26
  });
27
+
28
+ return NextResponse.json(
29
+ {
30
+ jobId: job.id,
31
+ status: job.status,
32
+ },
33
+ { status: 202 },
34
+ );
570
35
  } catch (error: any) {
36
+ if (error instanceof SyncJobRateLimitError) {
37
+ return NextResponse.json({ error: error.message }, { status: 429 });
38
+ }
571
39
  console.error('Erreur lors de la synchronisation Google Sheets:', error);
572
40
  return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 });
573
41
  }
@@ -0,0 +1,84 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { prisma } from '@/lib/prisma';
3
+ import { runGoogleSheetSync } from '@/lib/google-sheet-sync-runner';
4
+ import { QstashAuthError, verifyQstashRequest } from '@/lib/qstash';
5
+
6
+ export async function POST(request: NextRequest) {
7
+ let bodyText = '';
8
+ let parsedJobId: string | null = null;
9
+ try {
10
+ bodyText = await request.text();
11
+ const signature = request.headers.get('upstash-signature');
12
+ await verifyQstashRequest(signature, bodyText);
13
+
14
+ const body = JSON.parse(bodyText) as { jobId?: string };
15
+ const jobId = body.jobId;
16
+ parsedJobId = jobId ?? null;
17
+ if (!jobId) {
18
+ return NextResponse.json({ error: 'jobId manquant' }, { status: 400 });
19
+ }
20
+
21
+ const claimed = await prisma.googleSheetSyncJob.updateMany({
22
+ where: {
23
+ id: jobId,
24
+ status: 'QUEUED',
25
+ },
26
+ data: {
27
+ status: 'RUNNING',
28
+ startedAt: new Date(),
29
+ },
30
+ });
31
+
32
+ if (claimed.count === 0) {
33
+ return NextResponse.json({ ok: true, skipped: true });
34
+ }
35
+
36
+ const job = await prisma.googleSheetSyncJob.findUnique({
37
+ where: { id: jobId },
38
+ select: { requestedByUserId: true, configId: true },
39
+ });
40
+ if (!job) {
41
+ return NextResponse.json({ error: 'Job introuvable' }, { status: 404 });
42
+ }
43
+
44
+ const result = await runGoogleSheetSync({
45
+ sessionUserId: job.requestedByUserId,
46
+ requestedConfigId: job.configId ?? undefined,
47
+ });
48
+
49
+ await prisma.googleSheetSyncJob.update({
50
+ where: { id: jobId },
51
+ data: {
52
+ status: 'SUCCEEDED',
53
+ finishedAt: new Date(),
54
+ result: result as object,
55
+ },
56
+ });
57
+
58
+ return NextResponse.json({ ok: true });
59
+ } catch (error: any) {
60
+ if (error instanceof QstashAuthError) {
61
+ return NextResponse.json({ error: 'Signature QStash invalide' }, { status: 401 });
62
+ }
63
+ console.error('Erreur worker Google Sheet:', error);
64
+ try {
65
+ if (!parsedJobId && bodyText) {
66
+ const parsed = JSON.parse(bodyText) as { jobId?: string };
67
+ parsedJobId = parsed.jobId ?? null;
68
+ }
69
+ if (parsedJobId) {
70
+ await prisma.googleSheetSyncJob.update({
71
+ where: { id: parsedJobId },
72
+ data: {
73
+ status: 'FAILED',
74
+ finishedAt: new Date(),
75
+ error: error?.message || 'Erreur worker',
76
+ },
77
+ });
78
+ }
79
+ } catch {
80
+ // ignore
81
+ }
82
+ return NextResponse.json({ error: 'Erreur worker' }, { status: 500 });
83
+ }
84
+ }