create-crm-tmp 1.1.3 → 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 (276) hide show
  1. package/bin/create-crm-tmp.js +56 -35
  2. package/package.json +1 -1
  3. package/template/.prettierignore +2 -0
  4. package/template/README.md +230 -115
  5. package/template/components.json +22 -0
  6. package/template/eslint.config.mjs +13 -0
  7. package/template/exemple-contacts.csv +54 -0
  8. package/template/next.config.ts +41 -1
  9. package/template/package.json +63 -15
  10. package/template/prisma/migrations/20260318095700_init_db/migration.sql +978 -0
  11. package/template/prisma/schema.prisma +311 -67
  12. package/template/src/app/(auth)/invite/[token]/page.tsx +28 -29
  13. package/template/src/app/(auth)/layout.tsx +1 -1
  14. package/template/src/app/(auth)/reset-password/complete/page.tsx +21 -27
  15. package/template/src/app/(auth)/reset-password/page.tsx +14 -10
  16. package/template/src/app/(auth)/reset-password/verify/page.tsx +14 -10
  17. package/template/src/app/(auth)/signin/page.tsx +34 -23
  18. package/template/src/app/(dashboard)/agenda/page.tsx +3655 -2357
  19. package/template/src/app/(dashboard)/automatisation/[id]/page.tsx +10 -7
  20. package/template/src/app/(dashboard)/automatisation/_components/workflow-editor.tsx +609 -338
  21. package/template/src/app/(dashboard)/automatisation/new/page.tsx +11 -8
  22. package/template/src/app/(dashboard)/automatisation/page.tsx +463 -186
  23. package/template/src/app/(dashboard)/closing/page.tsx +517 -469
  24. package/template/src/app/(dashboard)/contacts/[id]/page.tsx +6151 -4210
  25. package/template/src/app/(dashboard)/contacts/companies/[id]/page.tsx +1702 -0
  26. package/template/src/app/(dashboard)/contacts/loading.tsx +13 -0
  27. package/template/src/app/(dashboard)/contacts/page.tsx +4124 -2130
  28. package/template/src/app/(dashboard)/dashboard/page.tsx +119 -105
  29. package/template/src/app/(dashboard)/dev/page.tsx +1291 -0
  30. package/template/src/app/(dashboard)/error.tsx +37 -0
  31. package/template/src/app/(dashboard)/layout.tsx +6 -2
  32. package/template/src/app/(dashboard)/loading.tsx +5 -0
  33. package/template/src/app/(dashboard)/settings/loading.tsx +19 -0
  34. package/template/src/app/(dashboard)/settings/page.tsx +1773 -3362
  35. package/template/src/app/(dashboard)/templates/page.tsx +504 -303
  36. package/template/src/app/(dashboard)/users/list/page.tsx +364 -355
  37. package/template/src/app/(dashboard)/users/page.tsx +279 -310
  38. package/template/src/app/(dashboard)/users/permissions/page.tsx +104 -99
  39. package/template/src/app/(dashboard)/users/roles/page.tsx +169 -140
  40. package/template/src/app/api/agenda/google-events/route.ts +92 -0
  41. package/template/src/app/api/audit-logs/route.ts +1 -1
  42. package/template/src/app/api/auth/check-active/route.ts +3 -2
  43. package/template/src/app/api/auth/google/callback/route.ts +8 -5
  44. package/template/src/app/api/auth/google/disconnect/route.ts +2 -2
  45. package/template/src/app/api/auth/google/route.ts +2 -1
  46. package/template/src/app/api/auth/google/status/route.ts +7 -31
  47. package/template/src/app/api/companies/[id]/activities/route.ts +129 -0
  48. package/template/src/app/api/companies/[id]/route.ts +194 -0
  49. package/template/src/app/api/companies/export/route.ts +206 -0
  50. package/template/src/app/api/companies/route.ts +196 -0
  51. package/template/src/app/api/contact-views/[id]/pin/route.ts +69 -0
  52. package/template/src/app/api/contact-views/[id]/route.ts +197 -0
  53. package/template/src/app/api/contact-views/route.ts +146 -0
  54. package/template/src/app/api/contacts/[id]/files/[fileId]/preview/route.ts +55 -0
  55. package/template/src/app/api/contacts/[id]/files/[fileId]/route.ts +20 -48
  56. package/template/src/app/api/contacts/[id]/files/route.ts +125 -186
  57. package/template/src/app/api/contacts/[id]/interactions/[interactionId]/route.ts +27 -1
  58. package/template/src/app/api/contacts/[id]/interactions/route.ts +45 -8
  59. package/template/src/app/api/contacts/[id]/kyc/route.ts +81 -0
  60. package/template/src/app/api/contacts/[id]/meet/route.ts +55 -29
  61. package/template/src/app/api/contacts/[id]/route.ts +184 -21
  62. package/template/src/app/api/contacts/[id]/send-email/route.ts +33 -11
  63. package/template/src/app/api/contacts/[id]/workflows/run/route.ts +67 -0
  64. package/template/src/app/api/contacts/export/route.ts +22 -31
  65. package/template/src/app/api/contacts/import/route.ts +77 -44
  66. package/template/src/app/api/contacts/import-preview/route.ts +139 -0
  67. package/template/src/app/api/contacts/origins/route.ts +63 -0
  68. package/template/src/app/api/contacts/route.ts +322 -57
  69. package/template/src/app/api/cron/cleanup-editor-images/route.ts +166 -0
  70. package/template/src/app/api/dashboard/stats/route.ts +9 -292
  71. package/template/src/app/api/dashboard/widgets/[id]/route.ts +0 -3
  72. package/template/src/app/api/dashboard/widgets/route.ts +19 -19
  73. package/template/src/app/api/dev/reminders/test/route.ts +114 -0
  74. package/template/src/app/api/editor/upload-image/route.ts +61 -0
  75. package/template/src/app/api/integrations/google-sheet/jobs/[jobId]/route.ts +47 -0
  76. package/template/src/app/api/integrations/google-sheet/jobs/usage/route.ts +50 -0
  77. package/template/src/app/api/integrations/google-sheet/sync/route.ts +28 -542
  78. package/template/src/app/api/invite/complete/route.ts +20 -23
  79. package/template/src/app/api/jobs/google-sheet/process/route.ts +84 -0
  80. package/template/src/app/api/jobs/google-sheet/schedule/route.ts +50 -0
  81. package/template/src/app/api/reminders/clear/route.ts +120 -0
  82. package/template/src/app/api/reminders/clear/undo/route.ts +112 -0
  83. package/template/src/app/api/reminders/route.ts +165 -39
  84. package/template/src/app/api/reminders/state/route.ts +164 -0
  85. package/template/src/app/api/reset-password/complete/route.ts +11 -13
  86. package/template/src/app/api/reset-password/request/route.ts +1 -1
  87. package/template/src/app/api/reset-password/verify/route.ts +1 -1
  88. package/template/src/app/api/send/route.ts +25 -47
  89. package/template/src/app/api/settings/closing-reasons/[id]/route.ts +10 -21
  90. package/template/src/app/api/settings/closing-reasons/route.ts +10 -21
  91. package/template/src/app/api/settings/company/route.ts +19 -26
  92. package/template/src/app/api/settings/google-ads/[id]/route.ts +20 -23
  93. package/template/src/app/api/settings/google-ads/route.ts +34 -23
  94. package/template/src/app/api/settings/google-calendar/calendars/route.ts +97 -0
  95. package/template/src/app/api/settings/google-calendar/route.ts +124 -0
  96. package/template/src/app/api/settings/google-sheet/[id]/route.ts +48 -23
  97. package/template/src/app/api/settings/google-sheet/auto-map/route.ts +56 -32
  98. package/template/src/app/api/settings/google-sheet/preview/route.ts +110 -0
  99. package/template/src/app/api/settings/google-sheet/route.ts +34 -23
  100. package/template/src/app/api/settings/integrations/logs/route.ts +93 -0
  101. package/template/src/app/api/settings/integrations/notifications/route.ts +67 -0
  102. package/template/src/app/api/settings/meta-leads/[id]/route.ts +20 -24
  103. package/template/src/app/api/settings/meta-leads/route.ts +34 -25
  104. package/template/src/app/api/settings/smtp/route.ts +53 -6
  105. package/template/src/app/api/settings/statuses/[id]/route.ts +29 -32
  106. package/template/src/app/api/settings/statuses/route.ts +24 -22
  107. package/template/src/app/api/statuses/route.ts +2 -5
  108. package/template/src/app/api/tasks/[id]/attendees/route.ts +36 -13
  109. package/template/src/app/api/tasks/[id]/route.ts +357 -145
  110. package/template/src/app/api/tasks/meet/route.ts +37 -26
  111. package/template/src/app/api/tasks/route.ts +201 -96
  112. package/template/src/app/api/templates/[id]/route.ts +22 -13
  113. package/template/src/app/api/templates/route.ts +22 -5
  114. package/template/src/app/api/users/[id]/resend-invite/route.ts +95 -0
  115. package/template/src/app/api/users/[id]/route.ts +22 -16
  116. package/template/src/app/api/users/commercials/route.ts +38 -0
  117. package/template/src/app/api/users/for-agenda/route.ts +1 -2
  118. package/template/src/app/api/users/list/route.ts +57 -19
  119. package/template/src/app/api/users/route.ts +89 -34
  120. package/template/src/app/api/webhooks/google-ads/route.ts +40 -1
  121. package/template/src/app/api/webhooks/meta-leads/route.ts +38 -1
  122. package/template/src/app/api/workflows/[id]/route.ts +29 -6
  123. package/template/src/app/api/workflows/process/route.ts +505 -170
  124. package/template/src/app/api/workflows/route.ts +42 -4
  125. package/template/src/app/globals.css +512 -32
  126. package/template/src/app/layout.tsx +28 -9
  127. package/template/src/app/page.tsx +37 -7
  128. package/template/src/components/address-autocomplete.tsx +233 -0
  129. package/template/src/components/config-error-alert.tsx +46 -0
  130. package/template/src/components/contacts/filter-bar.tsx +190 -0
  131. package/template/src/components/contacts/filter-builder.tsx +574 -0
  132. package/template/src/components/contacts/save-view-dialog.tsx +160 -0
  133. package/template/src/components/contacts/views-tab-bar.tsx +449 -0
  134. package/template/src/components/dashboard/activity-chart.tsx +6 -1
  135. package/template/src/components/dashboard/add-widget-dialog.tsx +13 -17
  136. package/template/src/components/dashboard/color-picker.tsx +7 -8
  137. package/template/src/components/dashboard/recent-activity.tsx +2 -5
  138. package/template/src/components/dashboard/stat-card.tsx +1 -3
  139. package/template/src/components/dashboard/status-distribution-chart.tsx +0 -1
  140. package/template/src/components/dashboard/top-contacts-list.tsx +7 -13
  141. package/template/src/components/dashboard/upcoming-tasks-list.tsx +2 -5
  142. package/template/src/components/dashboard/widget-wrapper.tsx +3 -6
  143. package/template/src/components/date-picker.tsx +399 -0
  144. package/template/src/components/editor/upload-editor-image.ts +42 -0
  145. package/template/src/components/editor.tsx +188 -35
  146. package/template/src/components/email-template.tsx +4 -2
  147. package/template/src/components/global-search.tsx +360 -0
  148. package/template/src/components/header.tsx +200 -107
  149. package/template/src/components/inactive-account-guard.tsx +58 -0
  150. package/template/src/components/integration-notifications-listener.tsx +12 -0
  151. package/template/src/components/invitation-email-template.tsx +4 -2
  152. package/template/src/components/lazy-editor.tsx +11 -0
  153. package/template/src/components/meet-cancellation-email-template.tsx +11 -3
  154. package/template/src/components/meet-confirmation-email-template.tsx +10 -3
  155. package/template/src/components/meet-update-email-template.tsx +10 -3
  156. package/template/src/components/page-header.tsx +19 -15
  157. package/template/src/components/protected-page.tsx +94 -0
  158. package/template/src/components/reset-password-email-template.tsx +4 -2
  159. package/template/src/components/settings/integrations/GoogleAdsIntegration.tsx +428 -0
  160. package/template/src/components/settings/integrations/GoogleSheetConfigMonitoringModal.tsx +680 -0
  161. package/template/src/components/settings/integrations/GoogleSheetIntegration.tsx +809 -0
  162. package/template/src/components/settings/integrations/ImportResultDialog.tsx +124 -0
  163. package/template/src/components/settings/integrations/IntegrationLogPanel.tsx +57 -0
  164. package/template/src/components/settings/integrations/IntegrationLogsTable.tsx +186 -0
  165. package/template/src/components/settings/integrations/MetaLeadIntegration.tsx +451 -0
  166. package/template/src/components/sidebar.tsx +117 -100
  167. package/template/src/components/skeleton.tsx +128 -45
  168. package/template/src/components/ui/accordion.tsx +64 -0
  169. package/template/src/components/ui/alert-dialog.tsx +139 -0
  170. package/template/src/components/ui/button.tsx +71 -0
  171. package/template/src/components/ui/components.tsx +1 -1
  172. package/template/src/components/ui/date-picker.tsx +422 -0
  173. package/template/src/components/ui/datetime-picker.tsx +338 -0
  174. package/template/src/components/ui/status-select.tsx +271 -0
  175. package/template/src/components/ui/tooltip.tsx +37 -0
  176. package/template/src/components/view-as-banner.tsx +1 -1
  177. package/template/src/components/view-as-modal.tsx +30 -19
  178. package/template/src/config/nav-pages.ts +108 -0
  179. package/template/src/contexts/app-toast-context.tsx +362 -0
  180. package/template/src/contexts/dashboard-theme-context.tsx +2 -7
  181. package/template/src/contexts/sidebar-context.tsx +27 -53
  182. package/template/src/contexts/task-reminder-context.tsx +134 -160
  183. package/template/src/contexts/view-as-context.tsx +32 -10
  184. package/template/src/hooks/use-alert.tsx +65 -0
  185. package/template/src/hooks/use-confirm.tsx +87 -0
  186. package/template/src/hooks/use-contact-views.ts +140 -0
  187. package/template/src/hooks/use-contacts.ts +69 -0
  188. package/template/src/hooks/use-fetch.ts +17 -0
  189. package/template/src/hooks/use-focus-trap.ts +73 -0
  190. package/template/src/hooks/use-statuses.ts +22 -0
  191. package/template/src/hooks/useIntegrationNotifications.ts +49 -0
  192. package/template/src/lib/address-api.ts +155 -0
  193. package/template/src/lib/auth.ts +8 -1
  194. package/template/src/lib/cache.ts +73 -0
  195. package/template/src/lib/check-permission.ts +12 -177
  196. package/template/src/lib/config-links.ts +14 -0
  197. package/template/src/lib/contact-duplicate.ts +79 -61
  198. package/template/src/lib/contact-interactions.ts +24 -22
  199. package/template/src/lib/contact-view-filters.ts +301 -0
  200. package/template/src/lib/contacts-list-url.ts +190 -0
  201. package/template/src/lib/dashboard-stats.ts +282 -0
  202. package/template/src/lib/dashboard-themes.ts +0 -5
  203. package/template/src/lib/date-utils.ts +176 -0
  204. package/template/src/lib/default-widgets.ts +0 -2
  205. package/template/src/lib/editor-html-image-dimensions.ts +172 -0
  206. package/template/src/lib/editor-image-limits.ts +19 -0
  207. package/template/src/lib/email-html-sanitize.ts +19 -0
  208. package/template/src/lib/encryption.ts +9 -6
  209. package/template/src/lib/fr-geography.ts +192 -0
  210. package/template/src/lib/get-auth-user.ts +25 -0
  211. package/template/src/lib/google-calendar-agenda.ts +201 -0
  212. package/template/src/lib/google-calendar.ts +309 -17
  213. package/template/src/lib/google-fetch.ts +63 -0
  214. package/template/src/lib/google-sheet-sync-jobs.ts +96 -0
  215. package/template/src/lib/google-sheet-sync-runner.ts +514 -0
  216. package/template/src/lib/integration-import-log.ts +21 -0
  217. package/template/src/lib/local-storage.ts +34 -0
  218. package/template/src/lib/permissions.ts +268 -40
  219. package/template/src/lib/prisma.ts +15 -12
  220. package/template/src/lib/qstash.ts +65 -0
  221. package/template/src/lib/reminder-state-server.ts +80 -0
  222. package/template/src/lib/reminder-state.ts +29 -0
  223. package/template/src/lib/roles.ts +12 -15
  224. package/template/src/lib/supabase-storage.ts +113 -0
  225. package/template/src/lib/template-variables.ts +204 -29
  226. package/template/src/lib/utils.ts +71 -11
  227. package/template/src/lib/widget-registry.ts +0 -4
  228. package/template/src/lib/workflow-executor.ts +391 -228
  229. package/template/src/proxy.ts +35 -73
  230. package/template/src/types/contact-views.ts +351 -0
  231. package/template/vercel.json +5 -0
  232. package/template/WORKFLOWS_CRON.md +0 -185
  233. package/template/prisma/migrations/20251126144728_init/migration.sql +0 -78
  234. package/template/prisma/migrations/20251126155204_add_user_roles/migration.sql +0 -5
  235. package/template/prisma/migrations/20251128095126_add_company_info/migration.sql +0 -19
  236. package/template/prisma/migrations/20251128123321_add_smtp_config/migration.sql +0 -22
  237. package/template/prisma/migrations/20251128132303_add_status/migration.sql +0 -23
  238. package/template/prisma/migrations/20251201102207_add_user_active/migration.sql +0 -75
  239. package/template/prisma/migrations/20251201105507_add_email_signature/migration.sql +0 -2
  240. package/template/prisma/migrations/20251201151122_add_tasks/migration.sql +0 -45
  241. package/template/prisma/migrations/20251202111854_add_task_reminder/migration.sql +0 -2
  242. package/template/prisma/migrations/20251202135859_add_google_meet_integration/migration.sql +0 -27
  243. package/template/prisma/migrations/20251203103317_add_meta_lead_integration/migration.sql +0 -20
  244. package/template/prisma/migrations/20251203104002_add_google_ads_integration/migration.sql +0 -18
  245. package/template/prisma/migrations/20251203112122_add_google_sheet_integration/migration.sql +0 -32
  246. package/template/prisma/migrations/20251203153853_allow_multiple_integration_configs/migration.sql +0 -20
  247. package/template/prisma/migrations/20251205141705_update_user_roles/migration.sql +0 -12
  248. package/template/prisma/migrations/20251205150000_add_commercial_and_telepro_assignment/migration.sql +0 -21
  249. package/template/prisma/migrations/20251205160000_add_interaction_logging/migration.sql +0 -11
  250. package/template/prisma/migrations/20251208090314_add_automatic_interaction_types/migration.sql +0 -12
  251. package/template/prisma/migrations/20251208094843_mg/migration.sql +0 -14
  252. package/template/prisma/migrations/20251208100000_add_company_support/migration.sql +0 -14
  253. package/template/prisma/migrations/20251208110000_add_templates/migration.sql +0 -26
  254. package/template/prisma/migrations/20251208141304_add_video_conference_task_type/migration.sql +0 -2
  255. package/template/prisma/migrations/20251209104759_add_internal_note_to_task/migration.sql +0 -2
  256. package/template/prisma/migrations/20251209134803_add_company_field/migration.sql +0 -2
  257. package/template/prisma/migrations/20251209150000_rename_company_to_company_name/migration.sql +0 -3
  258. package/template/prisma/migrations/20251209150016_add_email_tracking/migration.sql +0 -21
  259. package/template/prisma/migrations/20251209155908_add_notify_contact_to_task/migration.sql +0 -2
  260. package/template/prisma/migrations/20251210110019_add_appointment_types/migration.sql +0 -10
  261. package/template/prisma/migrations/20251210113928_add_contact_files/migration.sql +0 -26
  262. package/template/prisma/migrations/20251212132339_add_custom_roles/migration.sql +0 -24
  263. package/template/prisma/migrations/20251215104448_add_file_interaction_types/migration.sql +0 -11
  264. package/template/prisma/migrations/20251215145616_add_closing_reasons/migration.sql +0 -12
  265. package/template/prisma/migrations/20251216140850_add_log_users/migration.sql +0 -25
  266. package/template/prisma/migrations/20251216151000_rename_perdu_to_ferme/migration.sql +0 -8
  267. package/template/prisma/migrations/20251216162318_add_column_mappings_to_google_sheet/migration.sql +0 -2
  268. package/template/prisma/migrations/20251216185127_add_workflows/migration.sql +0 -80
  269. package/template/prisma/migrations/20251216192237_add_scheduled_workflow_actions/migration.sql +0 -32
  270. package/template/prisma/migrations/20251220000000_add_task_interaction_type/migration.sql +0 -4
  271. package/template/prisma/migrations/20251221000000_add_task_type/migration.sql +0 -3
  272. package/template/prisma/migrations/20251221000001_add_event_color/migration.sql +0 -23
  273. package/template/prisma/migrations/20260210114913_add_dashboard_widget/migration.sql +0 -20
  274. package/template/prisma/migrations/20260226093949_fix_cascade_on_user_delete/migration.sql +0 -69
  275. package/template/src/app/(dashboard)/users/layout.tsx +0 -30
  276. package/template/src/lib/google-drive.ts +0 -380
@@ -1,7 +1,8 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import { auth } from '@/lib/auth';
3
3
  import { prisma } from '@/lib/prisma';
4
- import { getFileInfo } from '@/lib/google-drive';
4
+ import { checkPermission } from '@/lib/check-permission';
5
+ import { BUCKETS, createSignedDownloadUrl } from '@/lib/supabase-storage';
5
6
 
6
7
  // POST /api/contacts/export - Exporter des contacts en CSV ou Excel
7
8
  export async function POST(request: NextRequest) {
@@ -14,17 +15,9 @@ export async function POST(request: NextRequest) {
14
15
  return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
15
16
  }
16
17
 
17
- // Vérifier que l'utilisateur est admin
18
- const user = await prisma.user.findUnique({
19
- where: { id: session.user.id },
20
- select: { role: true },
21
- });
22
-
23
- if (user?.role !== 'ADMIN') {
24
- return NextResponse.json(
25
- { error: 'Accès refusé. Seuls les administrateurs peuvent exporter des contacts.' },
26
- { status: 403 },
27
- );
18
+ const canExport = await checkPermission('contacts.export');
19
+ if (!canExport) {
20
+ return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
28
21
  }
29
22
 
30
23
  const body = await request.json();
@@ -38,14 +31,13 @@ export async function POST(request: NextRequest) {
38
31
  }
39
32
 
40
33
  // Construire la requête pour récupérer les contacts
41
- const where: any = { isCompany: false };
34
+ const where: any =
35
+ contactIds && Array.isArray(contactIds) && contactIds.length > 0
36
+ ? { id: { in: contactIds } }
37
+ : {};
42
38
 
43
- // Si des IDs sont fournis, exporter seulement ces contacts
44
- if (contactIds && Array.isArray(contactIds) && contactIds.length > 0) {
45
- where.id = { in: contactIds };
46
- }
39
+ const MAX_EXPORT = 10_000;
47
40
 
48
- // Récupérer les contacts avec toutes les relations nécessaires (notes et fichiers inclus)
49
41
  const contacts = await prisma.contact.findMany({
50
42
  where,
51
43
  include: {
@@ -72,19 +64,22 @@ export async function POST(request: NextRequest) {
72
64
  },
73
65
  },
74
66
  orderBy: { createdAt: 'desc' },
67
+ take: 50,
75
68
  },
76
69
  files: {
77
70
  select: {
78
71
  fileName: true,
79
- googleDriveFileId: true,
72
+ storagePath: true,
80
73
  fileSize: true,
81
74
  mimeType: true,
82
75
  createdAt: true,
83
76
  },
84
77
  orderBy: { createdAt: 'desc' },
78
+ take: 20,
85
79
  },
86
80
  },
87
81
  orderBy: { createdAt: 'desc' },
82
+ take: MAX_EXPORT,
88
83
  });
89
84
 
90
85
  if (contacts.length === 0) {
@@ -129,7 +124,7 @@ export async function POST(request: NextRequest) {
129
124
  minute: '2-digit',
130
125
  })
131
126
  : '';
132
- const author = interaction.user?.name || 'Ancien utilisateur';
127
+ const author = interaction.user?.name || 'Inconnu';
133
128
  const title = interaction.title ? `${interaction.title}: ` : '';
134
129
  const content = interaction.content || '';
135
130
 
@@ -138,21 +133,17 @@ export async function POST(request: NextRequest) {
138
133
  .join('\n\n');
139
134
  };
140
135
 
141
- // Fonction pour formater les fichiers (essayer de récupérer les liens si possible)
142
- const formatFiles = async (files: any[], userId: string) => {
136
+ const formatFiles = async (files: any[]) => {
143
137
  if (!files || files.length === 0) return '';
144
138
 
145
139
  const fileInfos = await Promise.allSettled(
146
140
  files.map(async (file) => {
141
+ const sizeKB = (file.fileSize / 1024).toFixed(2);
147
142
  try {
148
- // Essayer de récupérer le lien depuis Google Drive
149
- const fileInfo = await getFileInfo(userId, file.googleDriveFileId);
150
- const sizeKB = (file.fileSize / 1024).toFixed(2);
151
- return `${file.fileName} (${sizeKB} KB) - ${fileInfo.webViewLink}`;
152
- } catch (error) {
153
- // Si échec, utiliser un lien basique
154
- const sizeKB = (file.fileSize / 1024).toFixed(2);
155
- return `${file.fileName} (${sizeKB} KB) - https://drive.google.com/file/d/${file.googleDriveFileId}/view`;
143
+ const url = await createSignedDownloadUrl(BUCKETS.CONTACTS, file.storagePath);
144
+ return `${file.fileName} (${sizeKB} KB) - ${url}`;
145
+ } catch {
146
+ return `${file.fileName} (${sizeKB} KB)`;
156
147
  }
157
148
  }),
158
149
  );
@@ -168,7 +159,7 @@ export async function POST(request: NextRequest) {
168
159
  const rows = await Promise.all(
169
160
  contacts.map(async (contact) => {
170
161
  const notes = formatNotes(contact.interactions || []);
171
- const files = await formatFiles(contact.files || [], session.user.id);
162
+ const files = await formatFiles(contact.files || []);
172
163
 
173
164
  return [
174
165
  contact.civility || '',
@@ -1,8 +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
- import { handleContactDuplicate } from '@/lib/contact-duplicate';
5
- import { normalizePhoneNumber } from '@/lib/utils';
5
+ import { checkPermission } from '@/lib/check-permission';
6
+ import { handleContactDuplicate, markContactAsDuplicate } from '@/lib/contact-duplicate';
7
+ import { normalizePhoneNumber, parseImportDate } from '@/lib/utils';
6
8
 
7
9
  // POST /api/contacts/import - Importer des contacts depuis un fichier CSV/Excel
8
10
  export async function POST(request: NextRequest) {
@@ -15,10 +17,18 @@ export async function POST(request: NextRequest) {
15
17
  return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
16
18
  }
17
19
 
20
+ const canImport = await checkPermission('contacts.import');
21
+ if (!canImport) {
22
+ return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
23
+ }
24
+
18
25
  const formData = await request.formData();
19
26
  const file = formData.get('file') as File;
20
27
  const fieldMappingsJson = formData.get('fieldMappings') as string;
21
28
  const skipFirstRow = formData.get('skipFirstRow') === 'true';
29
+ const selectedSheetName = (formData.get('sheetName') as string) || undefined;
30
+ const headerRowStr = formData.get('headerRow') as string | null;
31
+ const headerRow = headerRowStr ? Number.parseInt(headerRowStr, 10) : 0;
22
32
 
23
33
  // Récupérer les valeurs par défaut
24
34
  const defaultStatusId = formData.get('defaultStatusId') as string | null;
@@ -64,14 +74,15 @@ export async function POST(request: NextRequest) {
64
74
  const text = await file.text();
65
75
  rows = parseCSV(text);
66
76
  } else if (fileExtension === 'xlsx' || fileExtension === 'xls') {
67
- // Parser Excel
68
77
  try {
69
- const XLSX = require('xlsx');
70
78
  const buffer = await file.arrayBuffer();
71
79
  const workbook = XLSX.read(buffer, { type: 'array' });
72
- const sheetName = workbook.SheetNames[0];
80
+ const sheetName =
81
+ selectedSheetName && workbook.SheetNames.includes(selectedSheetName)
82
+ ? selectedSheetName
83
+ : workbook.SheetNames[0];
73
84
  const worksheet = workbook.Sheets[sheetName];
74
- rows = XLSX.utils.sheet_to_json(worksheet, { raw: false });
85
+ rows = XLSX.utils.sheet_to_json(worksheet, { raw: false, range: headerRow });
75
86
  } catch (error) {
76
87
  return NextResponse.json(
77
88
  { error: 'Erreur lors du parsing Excel. Assurez-vous que xlsx est installé.' },
@@ -92,7 +103,11 @@ export async function POST(request: NextRequest) {
92
103
  // Ignorer la première ligne si c'est un en-tête
93
104
  const dataRows = skipFirstRow ? rows.slice(1) : rows;
94
105
 
95
- // Valider et mapper les données
106
+ // Pré-charger tous les statuts pour éviter des requêtes N+1 dans la boucle
107
+ const allStatuses = await prisma.status.findMany();
108
+ const statusByName = new Map(allStatuses.map((s) => [s.name, s.id]));
109
+ const defaultNewStatusId = statusByName.get('Nouveau') || null;
110
+
96
111
  const contactsToCreate: any[] = [];
97
112
  const errors: string[] = [];
98
113
  const skipped: number[] = [];
@@ -102,38 +117,26 @@ export async function POST(request: NextRequest) {
102
117
  const rowNumber = skipFirstRow ? i + 2 : i + 1;
103
118
 
104
119
  try {
105
- // Mapper les colonnes selon le mapping fourni
106
120
  const phone = getValueFromRow(row, mapping.phone);
107
121
  if (!phone) {
108
122
  skipped.push(rowNumber);
109
- continue; // Le téléphone est obligatoire
123
+ continue;
110
124
  }
111
125
 
112
- // Normaliser le numéro de téléphone au format : 0X XX XX XX XX
113
126
  const normalizedPhone = normalizePhoneNumber(phone.toString());
114
127
 
115
- // Déterminer le statusId : utiliser le mapping si fourni, sinon le statut par défaut fourni
116
128
  let statusId = null;
117
129
  if (mapping.statusId) {
118
130
  const mappedStatus = getValueFromRow(row, mapping.statusId);
119
131
  if (mappedStatus) {
120
- // Si une valeur est mappée, chercher le statut par nom
121
- const status = await prisma.status.findUnique({
122
- where: { name: mappedStatus },
123
- });
124
- statusId = status?.id || null;
132
+ statusId = statusByName.get(mappedStatus) || null;
125
133
  }
126
134
  }
127
- // Si aucun statut n'a été trouvé via le mapping, utiliser le statut par défaut fourni
128
135
  if (!statusId && defaultStatusId) {
129
136
  statusId = defaultStatusId;
130
137
  }
131
- // Si toujours aucun statut, utiliser "Nouveau" par défaut
132
138
  if (!statusId) {
133
- const nouveauStatus = await prisma.status.findUnique({
134
- where: { name: 'Nouveau' },
135
- });
136
- statusId = nouveauStatus?.id || null;
139
+ statusId = defaultNewStatusId;
137
140
  }
138
141
 
139
142
  // Déterminer l'origine : utiliser le mapping si fourni, sinon la valeur par défaut fournie
@@ -148,6 +151,32 @@ export async function POST(request: NextRequest) {
148
151
  assignedCommercialId = defaultCommercialId;
149
152
  }
150
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
+
151
180
  const contactData: any = {
152
181
  phone: normalizedPhone,
153
182
  civility: getValueFromRow(row, mapping.civility) || null,
@@ -160,6 +189,10 @@ export async function POST(request: NextRequest) {
160
189
  address: getValueFromRow(row, mapping.address) || null,
161
190
  city: getValueFromRow(row, mapping.city) || null,
162
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,
163
196
  origin: origin,
164
197
  statusId: statusId,
165
198
  assignedCommercialId: assignedCommercialId,
@@ -167,6 +200,7 @@ export async function POST(request: NextRequest) {
167
200
  ? getValueFromRow(row, mapping.assignedTeleproId) || null
168
201
  : null,
169
202
  createdById: session.user.id,
203
+ ...(createdAtImport && { createdAt: createdAtImport }),
170
204
  };
171
205
 
172
206
  // Collecter les notes à ajouter
@@ -197,6 +231,7 @@ export async function POST(request: NextRequest) {
197
231
  // Créer les contacts en lot
198
232
  const createdContacts = [];
199
233
  const duplicateErrors = [];
234
+ const duplicateContactIds: string[] = [];
200
235
 
201
236
  for (const contactDataWithNotes of contactsToCreate) {
202
237
  // Extraire les notes et les données du contact
@@ -214,28 +249,10 @@ export async function POST(request: NextRequest) {
214
249
  );
215
250
 
216
251
  if (duplicateContactId) {
217
- // C'est un doublon, récupérer le contact existant
218
- const existingContact = await prisma.contact.findUnique({
219
- where: { id: duplicateContactId },
220
- include: {
221
- status: true,
222
- assignedCommercial: {
223
- select: { id: true, name: true, email: true },
224
- },
225
- assignedTelepro: {
226
- select: { id: true, name: true, email: true },
227
- },
228
- createdBy: {
229
- select: { id: true, name: true, email: true },
230
- },
231
- },
232
- });
233
- if (existingContact) {
234
- createdContacts.push(existingContact);
235
- duplicateErrors.push(
236
- `Contact ${contactData.firstName} ${contactData.lastName} (${contactData.email}) - doublon détecté`,
237
- );
238
- }
252
+ duplicateContactIds.push(duplicateContactId);
253
+ duplicateErrors.push(
254
+ `Contact ${contactData.firstName} ${contactData.lastName} (${contactData.email}) - doublon détecté`,
255
+ );
239
256
  continue;
240
257
  }
241
258
 
@@ -245,6 +262,13 @@ export async function POST(request: NextRequest) {
245
262
  });
246
263
 
247
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
+ );
248
272
  duplicateErrors.push(`Téléphone ${contactData.phone} déjà existant`);
249
273
  continue;
250
274
  }
@@ -307,6 +331,15 @@ export async function POST(request: NextRequest) {
307
331
  }
308
332
  }
309
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
+
310
343
  return NextResponse.json({
311
344
  success: true,
312
345
  imported: createdContacts.length,
@@ -0,0 +1,139 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import * as XLSX from 'xlsx';
3
+ import { auth } from '@/lib/auth';
4
+ import { checkPermission } from '@/lib/check-permission';
5
+
6
+ function parseCsv(
7
+ text: string,
8
+ headerRow = 0,
9
+ ): {
10
+ headers: string[];
11
+ preview: Record<string, string>[];
12
+ rawRows: string[][];
13
+ } | null {
14
+ const lines = text.split('\n').filter((line) => line.trim() !== '');
15
+ if (lines.length === 0) return null;
16
+
17
+ const delimiter = lines[0].includes(';') ? ';' : ',';
18
+ const strip = (s: string) => s.trim().replaceAll(/(^")|("$)/g, '');
19
+
20
+ const rawRows: string[][] = lines.slice(0, 20).map((line) => line.split(delimiter).map(strip));
21
+
22
+ if (headerRow >= lines.length) return null;
23
+
24
+ const headers = lines[headerRow].split(delimiter).map(strip);
25
+
26
+ const preview: Record<string, string>[] = [];
27
+ for (let i = headerRow + 1; i < Math.min(headerRow + 6, lines.length); i++) {
28
+ const values = lines[i].split(delimiter).map(strip);
29
+ const row: Record<string, string> = {};
30
+ headers.forEach((header, index) => {
31
+ row[header] = values[index] || '';
32
+ });
33
+ preview.push(row);
34
+ }
35
+
36
+ return { headers, preview, rawRows };
37
+ }
38
+
39
+ function parseExcel(
40
+ buffer: ArrayBuffer,
41
+ sheetName?: string,
42
+ headerRow = 0,
43
+ ): {
44
+ sheetNames: string[];
45
+ headers: string[];
46
+ preview: Record<string, string>[];
47
+ rawRows: string[][];
48
+ } | null {
49
+ const workbook = XLSX.read(buffer, { type: 'array' });
50
+ const sheetNames: string[] = workbook.SheetNames;
51
+
52
+ const targetSheet = sheetName && sheetNames.includes(sheetName) ? sheetName : sheetNames[0];
53
+ const worksheet = workbook.Sheets[targetSheet];
54
+
55
+ const allRows: string[][] = XLSX.utils.sheet_to_json(worksheet, {
56
+ header: 1,
57
+ raw: false,
58
+ defval: '',
59
+ });
60
+
61
+ if (allRows.length === 0) return null;
62
+
63
+ const rawRows = allRows
64
+ .slice(0, 20)
65
+ .map((row: unknown[]) => row.map((cell) => String(cell ?? '')));
66
+
67
+ if (headerRow >= allRows.length) return null;
68
+
69
+ const headers = allRows[headerRow]
70
+ .map((cell: unknown) => String(cell ?? '').trim())
71
+ .filter(Boolean);
72
+
73
+ const preview: Record<string, string>[] = [];
74
+ for (let i = headerRow + 1; i < Math.min(headerRow + 6, allRows.length); i++) {
75
+ const values = allRows[i];
76
+ const row: Record<string, string> = {};
77
+ headers.forEach((header, index) => {
78
+ row[header] = String(values[index] ?? '');
79
+ });
80
+ preview.push(row);
81
+ }
82
+
83
+ return { sheetNames, headers, preview, rawRows };
84
+ }
85
+
86
+ export async function POST(request: NextRequest) {
87
+ try {
88
+ const session = await auth.api.getSession({ headers: request.headers });
89
+ if (!session) {
90
+ return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
91
+ }
92
+
93
+ const hasPermission = await checkPermission('contacts.create');
94
+ if (!hasPermission) {
95
+ return NextResponse.json({ error: 'Permission refusée' }, { status: 403 });
96
+ }
97
+
98
+ const formData = await request.formData();
99
+ const file = formData.get('file') as File | null;
100
+ const sheetName = (formData.get('sheetName') as string) || undefined;
101
+ const headerRowStr = formData.get('headerRow') as string | null;
102
+ const headerRow = headerRowStr ? Number.parseInt(headerRowStr, 10) : 0;
103
+
104
+ if (!file) {
105
+ return NextResponse.json({ error: 'Aucun fichier fourni' }, { status: 400 });
106
+ }
107
+
108
+ const fileExtension = file.name.toLowerCase().split('.').pop();
109
+
110
+ if (fileExtension === 'csv') {
111
+ const result = parseCsv(await file.text(), headerRow);
112
+ if (!result) {
113
+ return NextResponse.json({ error: 'Le fichier est vide' }, { status: 400 });
114
+ }
115
+ return NextResponse.json(result);
116
+ }
117
+
118
+ if (fileExtension === 'xlsx' || fileExtension === 'xls') {
119
+ try {
120
+ const result = parseExcel(await file.arrayBuffer(), sheetName, headerRow);
121
+ if (!result) {
122
+ return NextResponse.json({ error: 'Le fichier est vide' }, { status: 400 });
123
+ }
124
+ return NextResponse.json(result);
125
+ } catch {
126
+ return NextResponse.json({ error: 'Erreur lors du parsing Excel.' }, { status: 400 });
127
+ }
128
+ }
129
+
130
+ return NextResponse.json(
131
+ { error: 'Format de fichier non supporté. Utilisez CSV ou Excel (.xlsx, .xls)' },
132
+ { status: 400 },
133
+ );
134
+ } catch (error: unknown) {
135
+ console.error('Erreur import-preview:', error);
136
+ const message = error instanceof Error ? error.message : 'Erreur serveur';
137
+ return NextResponse.json({ error: message }, { status: 500 });
138
+ }
139
+ }
@@ -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
+ }