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,9 +1,10 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
+ import * as XLSX from 'xlsx';
2
3
  import { auth } from '@/lib/auth';
3
4
  import { prisma } from '@/lib/prisma';
4
5
  import { checkPermission } from '@/lib/check-permission';
5
- import { handleContactDuplicate } from '@/lib/contact-duplicate';
6
- import { normalizePhoneNumber } from '@/lib/utils';
6
+ import { handleContactDuplicate, markContactAsDuplicate } from '@/lib/contact-duplicate';
7
+ import { normalizePhoneNumber, parseImportDate } from '@/lib/utils';
7
8
 
8
9
  // POST /api/contacts/import - Importer des contacts depuis un fichier CSV/Excel
9
10
  export async function POST(request: NextRequest) {
@@ -74,7 +75,6 @@ export async function POST(request: NextRequest) {
74
75
  rows = parseCSV(text);
75
76
  } else if (fileExtension === 'xlsx' || fileExtension === 'xls') {
76
77
  try {
77
- const XLSX = require('xlsx');
78
78
  const buffer = await file.arrayBuffer();
79
79
  const workbook = XLSX.read(buffer, { type: 'array' });
80
80
  const sheetName =
@@ -151,6 +151,32 @@ export async function POST(request: NextRequest) {
151
151
  assignedCommercialId = defaultCommercialId;
152
152
  }
153
153
 
154
+ // Construire socialNetworks depuis les colonnes mappées (linkedin, facebook, twitter, instagram)
155
+ const socialPlatforms: { platform: string; url: string }[] = [];
156
+ const socialKeys: { key: string; label: string }[] = [
157
+ { key: 'linkedin', label: 'LinkedIn' },
158
+ { key: 'facebook', label: 'Facebook' },
159
+ { key: 'twitter', label: 'Twitter' },
160
+ { key: 'instagram', label: 'Instagram' },
161
+ ];
162
+ for (const { key, label } of socialKeys) {
163
+ const col = mapping[key];
164
+ if (col) {
165
+ const url = getValueFromRow(row, col);
166
+ if (url && String(url).trim()) {
167
+ let href = String(url).trim();
168
+ if (!href.startsWith('http://') && !href.startsWith('https://')) {
169
+ href = `https://${href}`;
170
+ }
171
+ socialPlatforms.push({ platform: label, url: href });
172
+ }
173
+ }
174
+ }
175
+
176
+ const createdAtImport = mapping.createdAt
177
+ ? parseImportDate(getValueFromRow(row, mapping.createdAt))
178
+ : null;
179
+
154
180
  const contactData: any = {
155
181
  phone: normalizedPhone,
156
182
  civility: getValueFromRow(row, mapping.civility) || null,
@@ -163,6 +189,10 @@ export async function POST(request: NextRequest) {
163
189
  address: getValueFromRow(row, mapping.address) || null,
164
190
  city: getValueFromRow(row, mapping.city) || null,
165
191
  postalCode: getValueFromRow(row, mapping.postalCode) || null,
192
+ companyName: getValueFromRow(row, mapping.companyName) || null,
193
+ website: mapping.website ? (getValueFromRow(row, mapping.website) || null) : null,
194
+ jobTitle: getValueFromRow(row, mapping.jobTitle) || null,
195
+ socialNetworks: socialPlatforms.length > 0 ? socialPlatforms : null,
166
196
  origin: origin,
167
197
  statusId: statusId,
168
198
  assignedCommercialId: assignedCommercialId,
@@ -170,6 +200,7 @@ export async function POST(request: NextRequest) {
170
200
  ? getValueFromRow(row, mapping.assignedTeleproId) || null
171
201
  : null,
172
202
  createdById: session.user.id,
203
+ ...(createdAtImport && { createdAt: createdAtImport }),
173
204
  };
174
205
 
175
206
  // Collecter les notes à ajouter
@@ -200,6 +231,7 @@ export async function POST(request: NextRequest) {
200
231
  // Créer les contacts en lot
201
232
  const createdContacts = [];
202
233
  const duplicateErrors = [];
234
+ const duplicateContactIds: string[] = [];
203
235
 
204
236
  for (const contactDataWithNotes of contactsToCreate) {
205
237
  // Extraire les notes et les données du contact
@@ -217,28 +249,10 @@ export async function POST(request: NextRequest) {
217
249
  );
218
250
 
219
251
  if (duplicateContactId) {
220
- // C'est un doublon, récupérer le contact existant
221
- const existingContact = await prisma.contact.findUnique({
222
- where: { id: duplicateContactId },
223
- include: {
224
- status: true,
225
- assignedCommercial: {
226
- select: { id: true, name: true, email: true },
227
- },
228
- assignedTelepro: {
229
- select: { id: true, name: true, email: true },
230
- },
231
- createdBy: {
232
- select: { id: true, name: true, email: true },
233
- },
234
- },
235
- });
236
- if (existingContact) {
237
- createdContacts.push(existingContact);
238
- duplicateErrors.push(
239
- `Contact ${contactData.firstName} ${contactData.lastName} (${contactData.email}) - doublon détecté`,
240
- );
241
- }
252
+ duplicateContactIds.push(duplicateContactId);
253
+ duplicateErrors.push(
254
+ `Contact ${contactData.firstName} ${contactData.lastName} (${contactData.email}) - doublon détecté`,
255
+ );
242
256
  continue;
243
257
  }
244
258
 
@@ -248,6 +262,13 @@ export async function POST(request: NextRequest) {
248
262
  });
249
263
 
250
264
  if (existingByPhone) {
265
+ duplicateContactIds.push(existingByPhone.id);
266
+ await markContactAsDuplicate(
267
+ existingByPhone.id,
268
+ contactData.origin || 'Import CSV/Excel',
269
+ session.user.id,
270
+ prisma,
271
+ );
251
272
  duplicateErrors.push(`Téléphone ${contactData.phone} déjà existant`);
252
273
  continue;
253
274
  }
@@ -310,6 +331,15 @@ export async function POST(request: NextRequest) {
310
331
  }
311
332
  }
312
333
 
334
+ // Bumper le createdAt des doublons APRÈS la création des nouveaux contacts
335
+ // pour qu'ils remontent en haut de la liste (tri createdAt desc)
336
+ if (duplicateContactIds.length > 0) {
337
+ await prisma.contact.updateMany({
338
+ where: { id: { in: duplicateContactIds } },
339
+ data: { createdAt: new Date() },
340
+ });
341
+ }
342
+
313
343
  return NextResponse.json({
314
344
  success: true,
315
345
  imported: createdContacts.length,
@@ -1,4 +1,5 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
+ import * as XLSX from 'xlsx';
2
3
  import { auth } from '@/lib/auth';
3
4
  import { checkPermission } from '@/lib/check-permission';
4
5
 
@@ -45,7 +46,6 @@ function parseExcel(
45
46
  preview: Record<string, string>[];
46
47
  rawRows: string[][];
47
48
  } | null {
48
- const XLSX = require('xlsx');
49
49
  const workbook = XLSX.read(buffer, { type: 'array' });
50
50
  const sheetNames: string[] = workbook.SheetNames;
51
51
 
@@ -0,0 +1,63 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { auth } from '@/lib/auth';
3
+ import { prisma } from '@/lib/prisma';
4
+ import { checkPermission } from '@/lib/check-permission';
5
+
6
+ /** Liste distincte des origines (tous les contacts visibles par l’utilisateur). */
7
+ export async function GET(request: Request) {
8
+ try {
9
+ const session = await auth.api.getSession({
10
+ headers: request.headers,
11
+ });
12
+
13
+ if (!session) {
14
+ return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
15
+ }
16
+
17
+ const [canViewAll, canViewOwn, canViewUnassigned] = await Promise.all([
18
+ checkPermission('contacts.view_all'),
19
+ checkPermission('contacts.view_own'),
20
+ checkPermission('contacts.view_unassigned'),
21
+ ]);
22
+
23
+ if (!canViewAll && !canViewOwn) {
24
+ return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
25
+ }
26
+
27
+ const where: {
28
+ origin: { not: null };
29
+ AND?: object[];
30
+ } = {
31
+ origin: { not: null },
32
+ };
33
+
34
+ if (!canViewAll && canViewOwn) {
35
+ const ownershipConditions: object[] = [
36
+ { assignedCommercialId: session.user.id },
37
+ { assignedTeleproId: session.user.id },
38
+ { createdById: session.user.id },
39
+ ];
40
+ if (canViewUnassigned) {
41
+ ownershipConditions.push({
42
+ AND: [{ assignedCommercialId: null }, { assignedTeleproId: null }],
43
+ });
44
+ }
45
+ where.AND = [{ OR: ownershipConditions }];
46
+ }
47
+
48
+ const grouped = await prisma.contact.groupBy({
49
+ by: ['origin'],
50
+ where,
51
+ });
52
+
53
+ const origins = grouped
54
+ .map((g) => g.origin)
55
+ .filter((o): o is string => Boolean(o && String(o).trim()))
56
+ .sort((a, b) => a.localeCompare(b, 'fr'));
57
+
58
+ return NextResponse.json({ origins });
59
+ } catch (e) {
60
+ console.error('GET /api/contacts/origins:', e);
61
+ return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 });
62
+ }
63
+ }
@@ -1,14 +1,23 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import { z } from 'zod';
3
3
  import { auth } from '@/lib/auth';
4
- import { prisma } from '@/lib/prisma';
4
+ import { prisma, Prisma } from '@/lib/prisma';
5
5
  import { checkPermission } from '@/lib/check-permission';
6
6
  import { handleContactDuplicate } from '@/lib/contact-duplicate';
7
7
  import { executeWorkflowsOnContactCreated } from '@/lib/workflow-executor';
8
8
  import { normalizePhoneNumber } from '@/lib/utils';
9
9
  import { buildPrismaWhereFromFilters } from '@/lib/contact-view-filters';
10
+ import {
11
+ expandRegionCodesToDepartmentCodes,
12
+ prismaPostalMatchesDepartmentsCondition,
13
+ } from '@/lib/fr-geography';
10
14
  import type { ViewFilter, ViewSortConfig } from '@/types/contact-views';
11
15
 
16
+ const socialNetworkSchema = z.object({
17
+ platform: z.string().trim().min(1),
18
+ url: z.string().trim().url(),
19
+ });
20
+
12
21
  const createContactSchema = z.object({
13
22
  civility: z.enum(['M', 'MME', 'MLLE']).optional().nullable(),
14
23
  firstName: z.string().trim().min(1).optional().nullable(),
@@ -20,14 +29,50 @@ const createContactSchema = z.object({
20
29
  city: z.string().optional().nullable(),
21
30
  postalCode: z.string().optional().nullable(),
22
31
  origin: z.string().optional().nullable(),
32
+ companyName: z.string().trim().optional().nullable(),
23
33
  companyId: z.string().optional().nullable(),
24
34
  jobTitle: z.string().trim().optional().nullable(),
35
+ website: z.string().trim().url().optional().nullable().or(z.literal('')),
36
+ socialNetworks: z.array(socialNetworkSchema).optional().nullable(),
25
37
  statusId: z.string().optional().nullable(),
26
38
  closingReason: z.string().optional().nullable(),
27
39
  assignedCommercialId: z.string().optional().nullable(),
28
40
  assignedTeleproId: z.string().optional().nullable(),
29
41
  });
30
42
 
43
+ function parseContactsListSortDirection(
44
+ sortDir: string | null,
45
+ sortOrder: string | null,
46
+ ): 'asc' | 'desc' | null {
47
+ const raw = sortDir ?? sortOrder;
48
+ return raw === 'asc' || raw === 'desc' ? raw : null;
49
+ }
50
+
51
+ /** Identifiants UI liste → orderBy Prisma (whitelist). */
52
+ function contactListPrismaOrderBy(
53
+ uiField: string,
54
+ direction: 'asc' | 'desc',
55
+ ): Prisma.ContactOrderByWithRelationInput | null {
56
+ switch (uiField) {
57
+ case 'createdAt':
58
+ return { createdAt: direction };
59
+ case 'updatedAt':
60
+ return { updatedAt: direction };
61
+ case 'postalCode':
62
+ return { postalCode: direction };
63
+ case 'status':
64
+ return { status: { name: direction } };
65
+ case 'commercial':
66
+ return { assignedCommercial: { name: direction } };
67
+ case 'telepro':
68
+ return { assignedTelepro: { name: direction } };
69
+ case 'origin':
70
+ return { origin: direction };
71
+ default:
72
+ return null;
73
+ }
74
+ }
75
+
31
76
  // GET /api/contacts - Récupérer tous les contacts avec filtres
32
77
  export async function GET(request: NextRequest) {
33
78
  try {
@@ -57,6 +102,8 @@ export async function GET(request: NextRequest) {
57
102
  const assignedCommercialIds = searchParams.get('assignedCommercialIds');
58
103
  const assignedTeleproIds = searchParams.get('assignedTeleproIds');
59
104
  const origins = searchParams.get('origins');
105
+ const departmentCodesParam = searchParams.get('departmentCodes');
106
+ const regionCodesParam = searchParams.get('regionCodes');
60
107
  const statusId = searchParams.get('statusId');
61
108
  const assignedCommercialId = searchParams.get('assignedCommercialId');
62
109
  const assignedTeleproId = searchParams.get('assignedTeleproId');
@@ -67,8 +114,9 @@ export async function GET(request: NextRequest) {
67
114
  const updatedAtEnd = searchParams.get('updatedAtEnd');
68
115
  const sortFieldParam = searchParams.get('sortField');
69
116
  const sortDirParam = searchParams.get('sortDir');
117
+ const sortOrderParam = searchParams.get('sortOrder');
70
118
  const page = Number.parseInt(searchParams.get('page') || '1');
71
- const limit = Number.parseInt(searchParams.get('limit') || '50');
119
+ const limit = Math.min(Number.parseInt(searchParams.get('limit') || '50'), 200);
72
120
  const skip = (page - 1) * limit;
73
121
 
74
122
  const where: any = {};
@@ -119,13 +167,53 @@ export async function GET(request: NextRequest) {
119
167
  }
120
168
 
121
169
  if (search) {
170
+ const trimmedSearch = search.trim();
171
+ const searchTerms = trimmedSearch.split(/\s+/).filter(Boolean);
172
+ const firstTerm = searchTerms[0] || '';
173
+ const remainingTerms = searchTerms.slice(1).join(' ');
174
+
175
+ // Normaliser le numéro si la recherche ressemble à un téléphone
176
+ const digitsOnly = trimmedSearch.replace(/\D/g, '');
177
+ const looksLikePhone = digitsOnly.length >= 4;
178
+ const normalizedPhone = looksLikePhone ? normalizePhoneNumber(trimmedSearch) : null;
179
+
122
180
  where.AND = where.AND || [];
123
181
  where.AND.push({
124
182
  OR: [
125
- { firstName: { contains: search, mode: 'insensitive' } },
126
- { lastName: { contains: search, mode: 'insensitive' } },
127
- { email: { contains: search, mode: 'insensitive' } },
128
- { phone: { contains: search, mode: 'insensitive' } },
183
+ { firstName: { contains: trimmedSearch, mode: 'insensitive' } },
184
+ { lastName: { contains: trimmedSearch, mode: 'insensitive' } },
185
+ { email: { contains: trimmedSearch, mode: 'insensitive' } },
186
+ { phone: { contains: trimmedSearch, mode: 'insensitive' } },
187
+ { city: { contains: trimmedSearch, mode: 'insensitive' } },
188
+ { postalCode: { contains: trimmedSearch, mode: 'insensitive' } },
189
+ { address: { contains: trimmedSearch, mode: 'insensitive' } },
190
+ { secondaryPhone: { contains: trimmedSearch, mode: 'insensitive' } },
191
+ { origin: { contains: trimmedSearch, mode: 'insensitive' } },
192
+ { jobTitle: { contains: trimmedSearch, mode: 'insensitive' } },
193
+ { companyName: { contains: trimmedSearch, mode: 'insensitive' } },
194
+ { company: { name: { contains: trimmedSearch, mode: 'insensitive' } } },
195
+ ...(normalizedPhone
196
+ ? [
197
+ { phone: { contains: normalizedPhone, mode: 'insensitive' as const } },
198
+ { secondaryPhone: { contains: normalizedPhone, mode: 'insensitive' as const } },
199
+ ]
200
+ : []),
201
+ ...(remainingTerms
202
+ ? [
203
+ {
204
+ AND: [
205
+ { firstName: { contains: firstTerm, mode: 'insensitive' } },
206
+ { lastName: { contains: remainingTerms, mode: 'insensitive' } },
207
+ ],
208
+ },
209
+ {
210
+ AND: [
211
+ { firstName: { contains: remainingTerms, mode: 'insensitive' } },
212
+ { lastName: { contains: firstTerm, mode: 'insensitive' } },
213
+ ],
214
+ },
215
+ ]
216
+ : []),
129
217
  ],
130
218
  });
131
219
  }
@@ -181,6 +269,25 @@ export async function GET(request: NextRequest) {
181
269
  where.origin = origin;
182
270
  }
183
271
 
272
+ if (departmentCodesParam) {
273
+ const codes = departmentCodesParam.split(',').filter(Boolean);
274
+ const geoCond = prismaPostalMatchesDepartmentsCondition(codes);
275
+ if (geoCond) {
276
+ where.AND = where.AND || [];
277
+ where.AND.push(geoCond);
278
+ }
279
+ }
280
+
281
+ if (regionCodesParam) {
282
+ const codes = regionCodesParam.split(',').filter(Boolean);
283
+ const depts = expandRegionCodesToDepartmentCodes(codes);
284
+ const geoCond = prismaPostalMatchesDepartmentsCondition(depts);
285
+ if (geoCond) {
286
+ where.AND = where.AND || [];
287
+ where.AND.push(geoCond);
288
+ }
289
+ }
290
+
184
291
  if (createdAtStart || createdAtEnd) {
185
292
  where.createdAt = {};
186
293
  if (createdAtStart) {
@@ -216,11 +323,24 @@ export async function GET(request: NextRequest) {
216
323
  }
217
324
 
218
325
  // Determine sort order: explicit param > view config > default
219
- let orderBy: any = { createdAt: 'desc' as const };
220
- if (sortFieldParam && sortDirParam) {
221
- orderBy = { [sortFieldParam]: sortDirParam };
222
- } else if (viewSortConfig) {
223
- orderBy = { [viewSortConfig.field]: viewSortConfig.direction };
326
+ let orderBy: Prisma.ContactOrderByWithRelationInput = { createdAt: 'desc' };
327
+ const explicitDirection = parseContactsListSortDirection(sortDirParam, sortOrderParam);
328
+ if (sortFieldParam && explicitDirection) {
329
+ const mapped = contactListPrismaOrderBy(sortFieldParam, explicitDirection);
330
+ if (mapped) {
331
+ orderBy = mapped;
332
+ }
333
+ } else if (viewSortConfig?.field) {
334
+ const dir =
335
+ viewSortConfig.direction === 'asc' || viewSortConfig.direction === 'desc'
336
+ ? viewSortConfig.direction
337
+ : null;
338
+ if (dir) {
339
+ const mapped = contactListPrismaOrderBy(viewSortConfig.field, dir);
340
+ if (mapped) {
341
+ orderBy = mapped;
342
+ }
343
+ }
224
344
  }
225
345
 
226
346
  const [contacts, total] = await Promise.all([
@@ -238,28 +358,6 @@ export async function GET(request: NextRequest) {
238
358
  createdBy: {
239
359
  select: { id: true, name: true, email: true },
240
360
  },
241
- tourLinks: {
242
- include: {
243
- tour: {
244
- select: {
245
- id: true,
246
- number: true,
247
- },
248
- },
249
- },
250
- },
251
- transactions: {
252
- select: {
253
- id: true,
254
- status: true,
255
- totalAmountCents: true,
256
- createdAt: true,
257
- },
258
- orderBy: {
259
- createdAt: 'desc',
260
- },
261
- take: 1,
262
- },
263
361
  },
264
362
  orderBy,
265
363
  skip,
@@ -268,15 +366,22 @@ export async function GET(request: NextRequest) {
268
366
  prisma.contact.count({ where }),
269
367
  ]);
270
368
 
271
- return NextResponse.json({
272
- contacts,
273
- pagination: {
274
- page,
275
- limit,
276
- total,
277
- totalPages: Math.ceil(total / limit),
369
+ return NextResponse.json(
370
+ {
371
+ contacts,
372
+ pagination: {
373
+ page,
374
+ limit,
375
+ total,
376
+ totalPages: Math.ceil(total / limit),
377
+ },
278
378
  },
279
- });
379
+ {
380
+ headers: {
381
+ 'Cache-Control': 'private, no-store, max-age=0, must-revalidate',
382
+ },
383
+ },
384
+ );
280
385
  } catch (error: any) {
281
386
  console.error('Erreur lors de la récupération des contacts:', error);
282
387
  return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 });
@@ -323,8 +428,11 @@ export async function POST(request: NextRequest) {
323
428
  city,
324
429
  postalCode,
325
430
  origin,
431
+ companyName,
326
432
  companyId,
327
433
  jobTitle,
434
+ website,
435
+ socialNetworks,
328
436
  statusId,
329
437
  closingReason,
330
438
  assignedCommercialId,
@@ -374,8 +482,12 @@ export async function POST(request: NextRequest) {
374
482
  city: city || null,
375
483
  postalCode: postalCode || null,
376
484
  origin: origin || null,
485
+ companyName: companyName && !companyId ? companyName : null,
377
486
  companyId: companyId || null,
378
487
  jobTitle: jobTitle || null,
488
+ website: website && website.trim() ? website : null,
489
+ socialNetworks:
490
+ socialNetworks && socialNetworks.length > 0 ? socialNetworks : Prisma.JsonNull,
379
491
  statusId: statusId || null,
380
492
  closingReason: closingReason || null,
381
493
  assignedCommercialId: assignedCommercialId || null,
@@ -0,0 +1,166 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { createClient } from '@supabase/supabase-js';
3
+ import { prisma } from '@/lib/prisma';
4
+ import { BUCKETS } from '@/lib/supabase-storage';
5
+
6
+ const BUCKET = BUCKETS.EDITOR_IMAGES;
7
+ const FOLDER = 'images';
8
+ // Ne supprimer que les images de plus de 24h (laisse le temps de sauvegarder)
9
+ const MIN_AGE_MS = 24 * 60 * 60 * 1000;
10
+ const LIST_LIMIT = 1000;
11
+
12
+ function getAdminClient() {
13
+ return createClient(
14
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
15
+ process.env.SUPABASE_SERVICE_ROLE_KEY!,
16
+ { auth: { autoRefreshToken: false, persistSession: false } },
17
+ );
18
+ }
19
+
20
+ /**
21
+ * Collecte toutes les URLs d'images éditeur référencées en BDD.
22
+ */
23
+ async function getReferencedImageUrls(): Promise<Set<string>> {
24
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
25
+ const prefix = `${supabaseUrl}/storage/v1/object/public/${BUCKET}/`;
26
+
27
+ const urls = new Set<string>();
28
+
29
+ function extractUrls(text: string | null) {
30
+ if (!text) return;
31
+ const regex = new RegExp(
32
+ prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[^"\'\\s<>]+',
33
+ 'g',
34
+ );
35
+ for (const match of text.matchAll(regex)) {
36
+ urls.add(match[0]);
37
+ }
38
+ }
39
+
40
+ // Templates
41
+ const templates = await prisma.template.findMany({ select: { content: true } });
42
+ for (const t of templates) extractUrls(t.content);
43
+
44
+ // Interactions (notes, emails)
45
+ const interactions = await prisma.interaction.findMany({
46
+ where: { content: { contains: BUCKET } },
47
+ select: { content: true },
48
+ });
49
+ for (const i of interactions) extractUrls(i.content);
50
+
51
+ // Tasks
52
+ const tasks = await prisma.task.findMany({
53
+ where: { description: { contains: BUCKET } },
54
+ select: { description: true },
55
+ });
56
+ for (const t of tasks) extractUrls(t.description);
57
+
58
+ // SMTP signatures
59
+ const smtpConfigs = await prisma.smtpConfig.findMany({
60
+ where: { signature: { not: null } },
61
+ select: { signature: true },
62
+ });
63
+ for (const s of smtpConfigs) extractUrls(s.signature);
64
+
65
+ // Workflow actions
66
+ const actions = await prisma.workflowAction.findMany({
67
+ where: {
68
+ OR: [
69
+ { taskDescription: { contains: BUCKET } },
70
+ { smsMessage: { contains: BUCKET } },
71
+ { noteContent: { contains: BUCKET } },
72
+ ],
73
+ },
74
+ select: { taskDescription: true, smsMessage: true, noteContent: true },
75
+ });
76
+ for (const a of actions) {
77
+ extractUrls(a.taskDescription);
78
+ extractUrls(a.smsMessage);
79
+ extractUrls(a.noteContent);
80
+ }
81
+
82
+ return urls;
83
+ }
84
+
85
+ // GET /api/cron/cleanup-editor-images
86
+ export async function GET(request: NextRequest) {
87
+ try {
88
+ const authHeader = request.headers.get('authorization');
89
+ if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
90
+ return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
91
+ }
92
+
93
+ const client = getAdminClient();
94
+ const now = Date.now();
95
+
96
+ // 1. Lister toutes les images du bucket
97
+ const allFiles: { name: string; created_at: string }[] = [];
98
+ let offset = 0;
99
+ while (true) {
100
+ const { data, error } = await client.storage.from(BUCKET).list(FOLDER, {
101
+ limit: LIST_LIMIT,
102
+ offset,
103
+ sortBy: { column: 'created_at', order: 'asc' },
104
+ });
105
+ if (error) throw new Error(`Erreur listing bucket: ${error.message}`);
106
+ if (!data || data.length === 0) break;
107
+ allFiles.push(...data.map((f) => ({ name: f.name, created_at: f.created_at ?? '' })));
108
+ if (data.length < LIST_LIMIT) break;
109
+ offset += LIST_LIMIT;
110
+ }
111
+
112
+ if (allFiles.length === 0) {
113
+ return NextResponse.json({ deleted: 0, total: 0 });
114
+ }
115
+
116
+ // 2. Filtrer : ne garder que les images > 24h
117
+ const oldFiles = allFiles.filter((f) => {
118
+ const createdAt = new Date(f.created_at).getTime();
119
+ return now - createdAt > MIN_AGE_MS;
120
+ });
121
+
122
+ if (oldFiles.length === 0) {
123
+ return NextResponse.json({ deleted: 0, total: allFiles.length, message: 'Aucune image ancienne' });
124
+ }
125
+
126
+ // 3. Récupérer les URLs référencées en BDD
127
+ const referencedUrls = await getReferencedImageUrls();
128
+
129
+ // 4. Identifier les orphelines
130
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
131
+ const orphanPaths: string[] = [];
132
+ for (const file of oldFiles) {
133
+ const fullUrl = `${supabaseUrl}/storage/v1/object/public/${BUCKET}/${FOLDER}/${file.name}`;
134
+ if (!referencedUrls.has(fullUrl)) {
135
+ orphanPaths.push(`${FOLDER}/${file.name}`);
136
+ }
137
+ }
138
+
139
+ // 5. Supprimer par batch de 100
140
+ let deleted = 0;
141
+ for (let i = 0; i < orphanPaths.length; i += 100) {
142
+ const batch = orphanPaths.slice(i, i + 100);
143
+ const { error } = await client.storage.from(BUCKET).remove(batch);
144
+ if (error) {
145
+ console.error(`Erreur suppression batch ${i}:`, error.message);
146
+ } else {
147
+ deleted += batch.length;
148
+ }
149
+ }
150
+
151
+ console.log(
152
+ `[cleanup-editor-images] ${deleted} orphelines supprimées sur ${allFiles.length} total (${referencedUrls.size} référencées)`,
153
+ );
154
+
155
+ return NextResponse.json({
156
+ deleted,
157
+ total: allFiles.length,
158
+ referenced: referencedUrls.size,
159
+ orphans: orphanPaths.length,
160
+ });
161
+ } catch (error: unknown) {
162
+ console.error('[cleanup-editor-images] Erreur:', error);
163
+ const message = error instanceof Error ? error.message : 'Erreur serveur';
164
+ return NextResponse.json({ error: message }, { status: 500 });
165
+ }
166
+ }