create-crm-tmp 1.1.2 → 2.0.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 (220) hide show
  1. package/package.json +1 -1
  2. package/template/.prettierignore +2 -0
  3. package/template/README.md +53 -67
  4. package/template/components.json +22 -0
  5. package/template/exemple-contacts.csv +54 -0
  6. package/template/next.config.ts +27 -1
  7. package/template/package.json +64 -27
  8. package/template/prisma/schema.prisma +821 -72
  9. package/template/skills-lock.json +25 -0
  10. package/template/src/app/(auth)/invite/[token]/page.tsx +21 -24
  11. package/template/src/app/(auth)/reset-password/complete/page.tsx +12 -21
  12. package/template/src/app/(auth)/reset-password/page.tsx +12 -8
  13. package/template/src/app/(auth)/reset-password/verify/page.tsx +12 -8
  14. package/template/src/app/(auth)/signin/page.tsx +20 -17
  15. package/template/src/app/(dashboard)/agenda/page.tsx +2231 -2188
  16. package/template/src/app/(dashboard)/automatisation/[id]/page.tsx +10 -7
  17. package/template/src/app/(dashboard)/automatisation/_components/workflow-editor.tsx +680 -323
  18. package/template/src/app/(dashboard)/automatisation/new/page.tsx +11 -8
  19. package/template/src/app/(dashboard)/automatisation/page.tsx +473 -180
  20. package/template/src/app/(dashboard)/closing/page.tsx +500 -468
  21. package/template/src/app/(dashboard)/contacts/[id]/page.tsx +5035 -4126
  22. package/template/src/app/(dashboard)/contacts/companies/[id]/page.tsx +1703 -0
  23. package/template/src/app/(dashboard)/contacts/loading.tsx +13 -0
  24. package/template/src/app/(dashboard)/contacts/page.tsx +3776 -2064
  25. package/template/src/app/(dashboard)/dashboard/page.tsx +37 -519
  26. package/template/src/app/(dashboard)/error.tsx +37 -0
  27. package/template/src/app/(dashboard)/layout.tsx +1 -1
  28. package/template/src/app/(dashboard)/loading.tsx +5 -0
  29. package/template/src/app/(dashboard)/settings/loading.tsx +19 -0
  30. package/template/src/app/(dashboard)/settings/page.tsx +2685 -2489
  31. package/template/src/app/(dashboard)/templates/page.tsx +500 -300
  32. package/template/src/app/(dashboard)/users/list/page.tsx +356 -350
  33. package/template/src/app/(dashboard)/users/page.tsx +279 -310
  34. package/template/src/app/(dashboard)/users/permissions/page.tsx +104 -99
  35. package/template/src/app/(dashboard)/users/roles/page.tsx +164 -137
  36. package/template/src/app/api/audit-logs/route.ts +1 -1
  37. package/template/src/app/api/auth/google/callback/route.ts +8 -5
  38. package/template/src/app/api/auth/google/disconnect/route.ts +2 -2
  39. package/template/src/app/api/companies/[id]/activities/route.ts +131 -0
  40. package/template/src/app/api/companies/[id]/route.ts +195 -0
  41. package/template/src/app/api/companies/export/route.ts +206 -0
  42. package/template/src/app/api/companies/route.ts +166 -0
  43. package/template/src/app/api/contact-views/[id]/pin/route.ts +69 -0
  44. package/template/src/app/api/contact-views/[id]/route.ts +197 -0
  45. package/template/src/app/api/contact-views/route.ts +146 -0
  46. package/template/src/app/api/contacts/[id]/files/[fileId]/preview/route.ts +77 -0
  47. package/template/src/app/api/contacts/[id]/files/[fileId]/route.ts +7 -17
  48. package/template/src/app/api/contacts/[id]/files/route.ts +83 -44
  49. package/template/src/app/api/contacts/[id]/interactions/route.ts +37 -0
  50. package/template/src/app/api/contacts/[id]/kyc/route.ts +71 -0
  51. package/template/src/app/api/contacts/[id]/meet/route.ts +38 -29
  52. package/template/src/app/api/contacts/[id]/route.ts +111 -20
  53. package/template/src/app/api/contacts/[id]/send-email/route.ts +6 -0
  54. package/template/src/app/api/contacts/[id]/workflows/run/route.ts +61 -0
  55. package/template/src/app/api/contacts/export/route.ts +12 -17
  56. package/template/src/app/api/contacts/import/route.ts +22 -19
  57. package/template/src/app/api/contacts/import-preview/route.ts +139 -0
  58. package/template/src/app/api/contacts/route.ts +202 -49
  59. package/template/src/app/api/dashboard/stats/route.ts +9 -292
  60. package/template/src/app/api/integrations/google-sheet/sync/route.ts +203 -185
  61. package/template/src/app/api/invite/complete/route.ts +20 -23
  62. package/template/src/app/api/reminders/route.ts +1 -0
  63. package/template/src/app/api/reset-password/complete/route.ts +11 -13
  64. package/template/src/app/api/send/route.ts +9 -85
  65. package/template/src/app/api/settings/closing-reasons/[id]/route.ts +10 -21
  66. package/template/src/app/api/settings/closing-reasons/route.ts +10 -21
  67. package/template/src/app/api/settings/company/route.ts +19 -26
  68. package/template/src/app/api/settings/google-ads/[id]/route.ts +20 -23
  69. package/template/src/app/api/settings/google-ads/route.ts +20 -23
  70. package/template/src/app/api/settings/google-sheet/[id]/route.ts +20 -23
  71. package/template/src/app/api/settings/google-sheet/auto-map/route.ts +23 -32
  72. package/template/src/app/api/settings/google-sheet/preview/route.ts +104 -0
  73. package/template/src/app/api/settings/google-sheet/route.ts +20 -23
  74. package/template/src/app/api/settings/meta-leads/[id]/route.ts +20 -23
  75. package/template/src/app/api/settings/meta-leads/route.ts +20 -23
  76. package/template/src/app/api/settings/statuses/[id]/route.ts +33 -23
  77. package/template/src/app/api/settings/statuses/route.ts +24 -22
  78. package/template/src/app/api/statuses/route.ts +2 -5
  79. package/template/src/app/api/tasks/[id]/attendees/route.ts +14 -7
  80. package/template/src/app/api/tasks/[id]/route.ts +161 -137
  81. package/template/src/app/api/tasks/meet/route.ts +11 -8
  82. package/template/src/app/api/tasks/route.ts +155 -95
  83. package/template/src/app/api/templates/[id]/route.ts +22 -13
  84. package/template/src/app/api/templates/route.ts +22 -5
  85. package/template/src/app/api/users/[id]/resend-invite/route.ts +95 -0
  86. package/template/src/app/api/users/[id]/route.ts +16 -1
  87. package/template/src/app/api/users/commercials/route.ts +38 -0
  88. package/template/src/app/api/users/for-agenda/route.ts +1 -2
  89. package/template/src/app/api/users/route.ts +94 -55
  90. package/template/src/app/api/webhooks/google-ads/route.ts +20 -1
  91. package/template/src/app/api/webhooks/meta-leads/route.ts +18 -1
  92. package/template/src/app/api/workflows/[id]/route.ts +33 -6
  93. package/template/src/app/api/workflows/process/route.ts +509 -146
  94. package/template/src/app/api/workflows/route.ts +46 -4
  95. package/template/src/app/globals.css +210 -101
  96. package/template/src/app/layout.tsx +19 -8
  97. package/template/src/app/page.tsx +37 -7
  98. package/template/src/components/address-autocomplete.tsx +232 -0
  99. package/template/src/components/contacts/filter-bar.tsx +181 -0
  100. package/template/src/components/contacts/filter-builder.tsx +589 -0
  101. package/template/src/components/contacts/save-view-dialog.tsx +160 -0
  102. package/template/src/components/contacts/views-tab-bar.tsx +440 -0
  103. package/template/src/components/dashboard/activity-chart.tsx +31 -39
  104. package/template/src/components/dashboard/dashboard-content.tsx +79 -0
  105. package/template/src/components/dashboard/stat-card.tsx +40 -42
  106. package/template/src/components/dashboard/tasks-pie-chart.tsx +34 -37
  107. package/template/src/components/dashboard/upcoming-tasks-list.tsx +78 -72
  108. package/template/src/components/date-picker.tsx +396 -0
  109. package/template/src/components/editor.tsx +27 -13
  110. package/template/src/components/email-template.tsx +4 -2
  111. package/template/src/components/global-search.tsx +358 -0
  112. package/template/src/components/header.tsx +57 -62
  113. package/template/src/components/invitation-email-template.tsx +4 -2
  114. package/template/src/components/lazy-editor.tsx +11 -0
  115. package/template/src/components/meet-cancellation-email-template.tsx +11 -3
  116. package/template/src/components/meet-confirmation-email-template.tsx +10 -3
  117. package/template/src/components/meet-update-email-template.tsx +10 -3
  118. package/template/src/components/page-header.tsx +19 -15
  119. package/template/src/components/protected-page.tsx +94 -0
  120. package/template/src/components/reset-password-email-template.tsx +4 -2
  121. package/template/src/components/sidebar.tsx +92 -94
  122. package/template/src/components/skeleton.tsx +128 -42
  123. package/template/src/components/ui/accordion.tsx +64 -0
  124. package/template/src/components/ui/alert-dialog.tsx +139 -0
  125. package/template/src/components/ui/button.tsx +60 -0
  126. package/template/src/components/view-as-banner.tsx +1 -1
  127. package/template/src/components/view-as-modal.tsx +21 -16
  128. package/template/src/config/nav-pages.ts +108 -0
  129. package/template/src/contexts/app-toast-context.tsx +174 -0
  130. package/template/src/contexts/sidebar-context.tsx +16 -47
  131. package/template/src/contexts/task-reminder-context.tsx +6 -6
  132. package/template/src/contexts/view-as-context.tsx +11 -16
  133. package/template/src/hooks/use-alert.tsx +65 -0
  134. package/template/src/hooks/use-confirm.tsx +87 -0
  135. package/template/src/hooks/use-contact-views.ts +140 -0
  136. package/template/src/hooks/use-contacts.ts +69 -0
  137. package/template/src/hooks/use-fetch.ts +17 -0
  138. package/template/src/hooks/use-focus-trap.ts +73 -0
  139. package/template/src/hooks/use-statuses.ts +22 -0
  140. package/template/src/lib/address-api.ts +155 -0
  141. package/template/src/lib/cache.ts +73 -0
  142. package/template/src/lib/check-permission.ts +12 -177
  143. package/template/src/lib/contact-interactions.ts +3 -1
  144. package/template/src/lib/contact-view-filters.ts +341 -0
  145. package/template/src/lib/dashboard-stats.ts +224 -0
  146. package/template/src/lib/date-utils.ts +49 -0
  147. package/template/src/lib/get-auth-user.ts +25 -0
  148. package/template/src/lib/google-calendar.ts +54 -12
  149. package/template/src/lib/google-drive.ts +796 -75
  150. package/template/src/lib/google-fetch.ts +63 -0
  151. package/template/src/lib/local-storage.ts +34 -0
  152. package/template/src/lib/permissions.ts +245 -47
  153. package/template/src/lib/prisma.ts +11 -11
  154. package/template/src/lib/roles.ts +14 -39
  155. package/template/src/lib/template-variables.ts +67 -33
  156. package/template/src/lib/utils.ts +26 -2
  157. package/template/src/lib/workflow-executor.ts +445 -229
  158. package/template/src/proxy.ts +34 -73
  159. package/template/src/types/contact-views.ts +351 -0
  160. package/template/src/types/yousign.ts +52 -0
  161. package/template/vercel.json +12 -0
  162. package/template/WORKFLOWS_CRON.md +0 -185
  163. package/template/prisma/migrations/20251126144728_init/migration.sql +0 -78
  164. package/template/prisma/migrations/20251126155204_add_user_roles/migration.sql +0 -5
  165. package/template/prisma/migrations/20251128095126_add_company_info/migration.sql +0 -19
  166. package/template/prisma/migrations/20251128123321_add_smtp_config/migration.sql +0 -22
  167. package/template/prisma/migrations/20251128132303_add_status/migration.sql +0 -23
  168. package/template/prisma/migrations/20251201102207_add_user_active/migration.sql +0 -75
  169. package/template/prisma/migrations/20251201105507_add_email_signature/migration.sql +0 -2
  170. package/template/prisma/migrations/20251201151122_add_tasks/migration.sql +0 -45
  171. package/template/prisma/migrations/20251202111854_add_task_reminder/migration.sql +0 -2
  172. package/template/prisma/migrations/20251202135859_add_google_meet_integration/migration.sql +0 -27
  173. package/template/prisma/migrations/20251203103317_add_meta_lead_integration/migration.sql +0 -20
  174. package/template/prisma/migrations/20251203104002_add_google_ads_integration/migration.sql +0 -18
  175. package/template/prisma/migrations/20251203112122_add_google_sheet_integration/migration.sql +0 -32
  176. package/template/prisma/migrations/20251203153853_allow_multiple_integration_configs/migration.sql +0 -20
  177. package/template/prisma/migrations/20251205141705_update_user_roles/migration.sql +0 -12
  178. package/template/prisma/migrations/20251205150000_add_commercial_and_telepro_assignment/migration.sql +0 -21
  179. package/template/prisma/migrations/20251205160000_add_interaction_logging/migration.sql +0 -11
  180. package/template/prisma/migrations/20251208090314_add_automatic_interaction_types/migration.sql +0 -12
  181. package/template/prisma/migrations/20251208094843_mg/migration.sql +0 -14
  182. package/template/prisma/migrations/20251208100000_add_company_support/migration.sql +0 -14
  183. package/template/prisma/migrations/20251208110000_add_templates/migration.sql +0 -26
  184. package/template/prisma/migrations/20251208141304_add_video_conference_task_type/migration.sql +0 -2
  185. package/template/prisma/migrations/20251209104759_add_internal_note_to_task/migration.sql +0 -2
  186. package/template/prisma/migrations/20251209134803_add_company_field/migration.sql +0 -2
  187. package/template/prisma/migrations/20251209150000_rename_company_to_company_name/migration.sql +0 -3
  188. package/template/prisma/migrations/20251209150016_add_email_tracking/migration.sql +0 -21
  189. package/template/prisma/migrations/20251209155908_add_notify_contact_to_task/migration.sql +0 -2
  190. package/template/prisma/migrations/20251210110019_add_appointment_types/migration.sql +0 -10
  191. package/template/prisma/migrations/20251210113928_add_contact_files/migration.sql +0 -26
  192. package/template/prisma/migrations/20251212132339_add_custom_roles/migration.sql +0 -24
  193. package/template/prisma/migrations/20251215104448_add_file_interaction_types/migration.sql +0 -11
  194. package/template/prisma/migrations/20251215145616_add_closing_reasons/migration.sql +0 -12
  195. package/template/prisma/migrations/20251216140850_add_log_users/migration.sql +0 -25
  196. package/template/prisma/migrations/20251216151000_rename_perdu_to_ferme/migration.sql +0 -8
  197. package/template/prisma/migrations/20251216162318_add_column_mappings_to_google_sheet/migration.sql +0 -2
  198. package/template/prisma/migrations/20251216185127_add_workflows/migration.sql +0 -80
  199. package/template/prisma/migrations/20251216192237_add_scheduled_workflow_actions/migration.sql +0 -32
  200. package/template/prisma/migrations/20251220000000_add_task_interaction_type/migration.sql +0 -4
  201. package/template/prisma/migrations/20251221000000_add_task_type/migration.sql +0 -3
  202. package/template/prisma/migrations/20251221000001_add_event_color/migration.sql +0 -23
  203. package/template/prisma/migrations/20260210114913_add_dashboard_widget/migration.sql +0 -20
  204. package/template/prisma/migrations/migration_lock.toml +0 -3
  205. package/template/src/app/(dashboard)/users/layout.tsx +0 -30
  206. package/template/src/app/api/dashboard/widgets/[id]/route.ts +0 -47
  207. package/template/src/app/api/dashboard/widgets/route.ts +0 -181
  208. package/template/src/components/dashboard/add-widget-dialog.tsx +0 -161
  209. package/template/src/components/dashboard/color-picker.tsx +0 -65
  210. package/template/src/components/dashboard/contacts-chart.tsx +0 -69
  211. package/template/src/components/dashboard/interactions-by-type-chart.tsx +0 -121
  212. package/template/src/components/dashboard/recent-activity.tsx +0 -157
  213. package/template/src/components/dashboard/sales-analytics-chart.tsx +0 -77
  214. package/template/src/components/dashboard/status-distribution-chart.tsx +0 -82
  215. package/template/src/components/dashboard/top-contacts-list.tsx +0 -119
  216. package/template/src/components/dashboard/widget-wrapper.tsx +0 -39
  217. package/template/src/contexts/dashboard-theme-context.tsx +0 -58
  218. package/template/src/lib/dashboard-themes.ts +0 -140
  219. package/template/src/lib/default-widgets.ts +0 -14
  220. package/template/src/lib/widget-registry.ts +0 -177
@@ -1,8 +1,12 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import { auth } from '@/lib/auth';
3
3
  import { prisma } from '@/lib/prisma';
4
+ import { checkPermission } from '@/lib/check-permission';
4
5
  import { logStatusChange, logContactUpdate, logAssignmentChange } from '@/lib/contact-interactions';
5
- import { executeWorkflowsOnStatusChanged } from '@/lib/workflow-executor';
6
+ import {
7
+ executeWorkflowsOnStatusChanged,
8
+ executeWorkflowsOnAssignmentChanged,
9
+ } from '@/lib/workflow-executor';
6
10
  import { normalizePhoneNumber } from '@/lib/utils';
7
11
 
8
12
  // GET /api/contacts/[id] - Récupérer un contact spécifique avec ses interactions
@@ -16,18 +20,22 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
16
20
  return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
17
21
  }
18
22
 
23
+ const [canViewAll, canViewOwn] = await Promise.all([
24
+ checkPermission('contacts.view_all'),
25
+ checkPermission('contacts.view_own'),
26
+ ]);
27
+
28
+ if (!canViewAll && !canViewOwn) {
29
+ return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
30
+ }
31
+
19
32
  const { id } = await params;
20
33
 
21
34
  const contact = await prisma.contact.findUnique({
22
35
  where: { id },
23
36
  include: {
24
37
  status: true,
25
- companyRelation: {
26
- select: { id: true, firstName: true, lastName: true, isCompany: true },
27
- },
28
- contacts: {
29
- select: { id: true, firstName: true, lastName: true, isCompany: true },
30
- },
38
+ company: { select: { id: true, name: true, phone: true, email: true } },
31
39
  assignedCommercial: {
32
40
  select: { id: true, name: true, email: true },
33
41
  },
@@ -37,6 +45,27 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
37
45
  createdBy: {
38
46
  select: { id: true, name: true, email: true },
39
47
  },
48
+ tourLinks: {
49
+ include: {
50
+ tour: {
51
+ select: {
52
+ id: true,
53
+ number: true,
54
+ },
55
+ },
56
+ },
57
+ },
58
+ transactions: {
59
+ select: {
60
+ id: true,
61
+ status: true,
62
+ totalAmountCents: true,
63
+ signedAt: true,
64
+ createdAt: true,
65
+ updatedAt: true,
66
+ },
67
+ orderBy: { createdAt: 'desc' },
68
+ },
40
69
  interactions: {
41
70
  include: {
42
71
  user: {
@@ -60,6 +89,19 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
60
89
  return NextResponse.json({ error: 'Contact non trouvé' }, { status: 404 });
61
90
  }
62
91
 
92
+ // Vérifier l'accès si l'utilisateur ne peut voir que ses propres contacts
93
+ if (!canViewAll && canViewOwn) {
94
+ const isOwner =
95
+ contact.assignedCommercialId === session.user.id ||
96
+ contact.assignedTeleproId === session.user.id ||
97
+ contact.createdById === session.user.id;
98
+ const isUnassigned = !contact.assignedCommercialId && !contact.assignedTeleproId;
99
+ const canViewUnassigned = await checkPermission('contacts.view_unassigned');
100
+ if (!isOwner && !(isUnassigned && canViewUnassigned)) {
101
+ return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
102
+ }
103
+ }
104
+
63
105
  return NextResponse.json(contact);
64
106
  } catch (error: any) {
65
107
  console.error('Erreur lors de la récupération du contact:', error);
@@ -78,6 +120,15 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
78
120
  return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
79
121
  }
80
122
 
123
+ const [canEditAll, canEditOwn] = await Promise.all([
124
+ checkPermission('contacts.edit_all'),
125
+ checkPermission('contacts.edit_own'),
126
+ ]);
127
+
128
+ if (!canEditAll && !canEditOwn) {
129
+ return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
130
+ }
131
+
81
132
  const { id } = await params;
82
133
  const body = await request.json();
83
134
  const {
@@ -91,9 +142,8 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
91
142
  city,
92
143
  postalCode,
93
144
  origin,
94
- companyName,
95
- isCompany,
96
145
  companyId,
146
+ jobTitle,
97
147
  statusId,
98
148
  closingReason,
99
149
  assignedCommercialId,
@@ -105,9 +155,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
105
155
  where: { id },
106
156
  include: {
107
157
  status: true,
108
- companyRelation: {
109
- select: { id: true, firstName: true, lastName: true, isCompany: true },
110
- },
158
+ company: { select: { id: true, name: true, phone: true, email: true } },
111
159
  assignedCommercial: {
112
160
  select: { id: true, name: true, email: true },
113
161
  },
@@ -121,6 +169,17 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
121
169
  return NextResponse.json({ error: 'Contact non trouvé' }, { status: 404 });
122
170
  }
123
171
 
172
+ // Vérifier la propriété si l'utilisateur ne peut modifier que ses contacts
173
+ if (!canEditAll && canEditOwn) {
174
+ const isOwner =
175
+ existing.assignedCommercialId === session.user.id ||
176
+ existing.assignedTeleproId === session.user.id ||
177
+ existing.createdById === session.user.id;
178
+ if (!isOwner) {
179
+ return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
180
+ }
181
+ }
182
+
124
183
  // Validation : si phone est fourni, il ne peut pas être vide
125
184
  if (phone !== undefined && !phone) {
126
185
  return NextResponse.json({ error: 'Le téléphone ne peut pas être vide' }, { status: 400 });
@@ -141,9 +200,8 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
141
200
  city: city !== undefined ? city || null : existing.city,
142
201
  postalCode: postalCode !== undefined ? postalCode || null : existing.postalCode,
143
202
  origin: origin !== undefined ? origin || null : existing.origin,
144
- companyName: companyName !== undefined ? companyName || null : existing.companyName,
145
- isCompany: isCompany !== undefined ? isCompany === true : existing.isCompany,
146
203
  companyId: companyId !== undefined ? companyId || null : existing.companyId,
204
+ jobTitle: jobTitle !== undefined ? jobTitle || null : existing.jobTitle,
147
205
  statusId: statusId !== undefined ? statusId || null : existing.statusId,
148
206
  closingReason: closingReason !== undefined ? closingReason || null : existing.closingReason,
149
207
  assignedCommercialId:
@@ -188,8 +246,8 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
188
246
  if (origin !== undefined && origin !== existing.origin) {
189
247
  changes.origin = { old: existing.origin, new: origin };
190
248
  }
191
- if (companyName !== undefined && companyName !== existing.companyName) {
192
- changes.companyName = { old: existing.companyName, new: companyName };
249
+ if (jobTitle !== undefined && jobTitle !== existing.jobTitle) {
250
+ changes.jobTitle = { old: existing.jobTitle, new: jobTitle };
193
251
  }
194
252
  if (closingReason !== undefined && closingReason !== existing.closingReason) {
195
253
  changes.closingReason = { old: existing.closingReason, new: closingReason };
@@ -272,6 +330,21 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
272
330
  );
273
331
  }
274
332
 
333
+ // Déclencher les workflows CONTACT_ASSIGNMENT_CHANGED si l'assignation a changé
334
+ const commercialChanged =
335
+ assignedCommercialId !== undefined &&
336
+ normalizedExistingCommercial !== normalizedNewCommercial;
337
+ const teleproChanged =
338
+ assignedTeleproId !== undefined && normalizedExistingTelepro !== normalizedNewTelepro;
339
+
340
+ if (commercialChanged || teleproChanged) {
341
+ try {
342
+ await executeWorkflowsOnAssignmentChanged(id);
343
+ } catch (workflowError) {
344
+ console.error("Erreur lors de l'exécution des workflows assignation:", workflowError);
345
+ }
346
+ }
347
+
275
348
  // Changements de champs de contact
276
349
  if (Object.keys(changes).length > 0) {
277
350
  await logContactUpdate(id, changes, session.user.id);
@@ -294,13 +367,21 @@ export async function DELETE(
294
367
  { params }: { params: Promise<{ id: string }> },
295
368
  ) {
296
369
  try {
297
- // Vérifier que l'utilisateur est administrateur
298
- const { requireAdmin } = await import('@/lib/roles');
299
- await requireAdmin(request.headers);
370
+ const session = await auth.api.getSession({
371
+ headers: request.headers,
372
+ });
373
+
374
+ if (!session) {
375
+ return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
376
+ }
377
+
378
+ const canDelete = await checkPermission('contacts.delete');
379
+ if (!canDelete) {
380
+ return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
381
+ }
300
382
 
301
383
  const { id } = await params;
302
384
 
303
- // Vérifier que le contact existe
304
385
  const existing = await prisma.contact.findUnique({
305
386
  where: { id },
306
387
  });
@@ -309,6 +390,16 @@ export async function DELETE(
309
390
  return NextResponse.json({ error: 'Contact non trouvé' }, { status: 404 });
310
391
  }
311
392
 
393
+ const transactionsCount = await prisma.transaction.count({
394
+ where: { contactId: id },
395
+ });
396
+ if (transactionsCount > 0) {
397
+ return NextResponse.json(
398
+ { error: 'Impossible de supprimer un contact ayant des transactions' },
399
+ { status: 400 },
400
+ );
401
+ }
402
+
312
403
  await prisma.contact.delete({
313
404
  where: { id },
314
405
  });
@@ -1,6 +1,7 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import { auth } from '@/lib/auth';
3
3
  import { prisma } from '@/lib/prisma';
4
+ import { checkPermission } from '@/lib/check-permission';
4
5
  import nodemailer from 'nodemailer';
5
6
  import { decrypt } from '@/lib/encryption';
6
7
 
@@ -25,6 +26,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
25
26
  return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
26
27
  }
27
28
 
29
+ const canSendEmail = await checkPermission('contacts.send_email');
30
+ if (!canSendEmail) {
31
+ return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
32
+ }
33
+
28
34
  const { id } = await params;
29
35
 
30
36
  // Récupérer FormData
@@ -0,0 +1,61 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { auth } from '@/lib/auth';
3
+ import { prisma } from '@/lib/prisma';
4
+ import { executeWorkflowManually } from '@/lib/workflow-executor';
5
+
6
+ /**
7
+ * POST /api/contacts/[id]/workflows/run
8
+ * Déclenche manuellement un workflow sur un contact
9
+ * Body: { workflowId: string }
10
+ */
11
+ export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
12
+ try {
13
+ const session = await auth.api.getSession({ headers: request.headers });
14
+ if (!session) {
15
+ return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
16
+ }
17
+
18
+ const { id: contactId } = await params;
19
+ const { workflowId } = await request.json();
20
+
21
+ if (!workflowId) {
22
+ return NextResponse.json({ error: 'workflowId est requis' }, { status: 400 });
23
+ }
24
+
25
+ const contact = await prisma.contact.findUnique({ where: { id: contactId } });
26
+ if (!contact) {
27
+ return NextResponse.json({ error: 'Contact introuvable' }, { status: 404 });
28
+ }
29
+
30
+ await executeWorkflowManually(workflowId, contactId);
31
+
32
+ return NextResponse.json({ success: true, message: 'Workflow exécuté avec succès' });
33
+ } catch (error: any) {
34
+ console.error('Erreur lors du déclenchement manuel du workflow:', error);
35
+ return NextResponse.json({ error: error.message || 'Erreur serveur' }, { status: 500 });
36
+ }
37
+ }
38
+
39
+ /**
40
+ * GET /api/contacts/[id]/workflows/run
41
+ * Liste les workflows MANUAL actifs disponibles
42
+ */
43
+ export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
44
+ try {
45
+ const session = await auth.api.getSession({ headers: request.headers });
46
+ if (!session) {
47
+ return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
48
+ }
49
+
50
+ const workflows = await prisma.workflow.findMany({
51
+ where: { active: true, triggerType: 'MANUAL' },
52
+ select: { id: true, name: true, description: true },
53
+ orderBy: { name: 'asc' },
54
+ });
55
+
56
+ return NextResponse.json(workflows);
57
+ } catch (error: any) {
58
+ console.error('Erreur lors de la récupération des workflows manuels:', error);
59
+ return NextResponse.json({ error: error.message || 'Erreur serveur' }, { status: 500 });
60
+ }
61
+ }
@@ -1,6 +1,7 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import { auth } from '@/lib/auth';
3
3
  import { prisma } from '@/lib/prisma';
4
+ import { checkPermission } from '@/lib/check-permission';
4
5
  import { getFileInfo } from '@/lib/google-drive';
5
6
 
6
7
  // POST /api/contacts/export - Exporter des contacts en CSV ou Excel
@@ -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,6 +64,7 @@ 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: {
@@ -82,9 +75,11 @@ export async function POST(request: NextRequest) {
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) {
@@ -1,6 +1,7 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import { auth } from '@/lib/auth';
3
3
  import { prisma } from '@/lib/prisma';
4
+ import { checkPermission } from '@/lib/check-permission';
4
5
  import { handleContactDuplicate } from '@/lib/contact-duplicate';
5
6
  import { normalizePhoneNumber } from '@/lib/utils';
6
7
 
@@ -15,10 +16,18 @@ export async function POST(request: NextRequest) {
15
16
  return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
16
17
  }
17
18
 
19
+ const canImport = await checkPermission('contacts.import');
20
+ if (!canImport) {
21
+ return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
22
+ }
23
+
18
24
  const formData = await request.formData();
19
25
  const file = formData.get('file') as File;
20
26
  const fieldMappingsJson = formData.get('fieldMappings') as string;
21
27
  const skipFirstRow = formData.get('skipFirstRow') === 'true';
28
+ const selectedSheetName = (formData.get('sheetName') as string) || undefined;
29
+ const headerRowStr = formData.get('headerRow') as string | null;
30
+ const headerRow = headerRowStr ? Number.parseInt(headerRowStr, 10) : 0;
22
31
 
23
32
  // Récupérer les valeurs par défaut
24
33
  const defaultStatusId = formData.get('defaultStatusId') as string | null;
@@ -64,14 +73,16 @@ export async function POST(request: NextRequest) {
64
73
  const text = await file.text();
65
74
  rows = parseCSV(text);
66
75
  } else if (fileExtension === 'xlsx' || fileExtension === 'xls') {
67
- // Parser Excel
68
76
  try {
69
77
  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
@@ -0,0 +1,139 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { auth } from '@/lib/auth';
3
+ import { checkPermission } from '@/lib/check-permission';
4
+
5
+ function parseCsv(
6
+ text: string,
7
+ headerRow = 0,
8
+ ): {
9
+ headers: string[];
10
+ preview: Record<string, string>[];
11
+ rawRows: string[][];
12
+ } | null {
13
+ const lines = text.split('\n').filter((line) => line.trim() !== '');
14
+ if (lines.length === 0) return null;
15
+
16
+ const delimiter = lines[0].includes(';') ? ';' : ',';
17
+ const strip = (s: string) => s.trim().replaceAll(/(^")|("$)/g, '');
18
+
19
+ const rawRows: string[][] = lines.slice(0, 20).map((line) => line.split(delimiter).map(strip));
20
+
21
+ if (headerRow >= lines.length) return null;
22
+
23
+ const headers = lines[headerRow].split(delimiter).map(strip);
24
+
25
+ const preview: Record<string, string>[] = [];
26
+ for (let i = headerRow + 1; i < Math.min(headerRow + 6, lines.length); i++) {
27
+ const values = lines[i].split(delimiter).map(strip);
28
+ const row: Record<string, string> = {};
29
+ headers.forEach((header, index) => {
30
+ row[header] = values[index] || '';
31
+ });
32
+ preview.push(row);
33
+ }
34
+
35
+ return { headers, preview, rawRows };
36
+ }
37
+
38
+ function parseExcel(
39
+ buffer: ArrayBuffer,
40
+ sheetName?: string,
41
+ headerRow = 0,
42
+ ): {
43
+ sheetNames: string[];
44
+ headers: string[];
45
+ preview: Record<string, string>[];
46
+ rawRows: string[][];
47
+ } | null {
48
+ const XLSX = require('xlsx');
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
+ }