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
@@ -2,11 +2,18 @@ import { NextRequest, NextResponse } from 'next/server';
2
2
  import { prisma } from '@/lib/prisma';
3
3
  import { getValidAccessToken, GoogleTokenError } from '@/lib/google-calendar';
4
4
  import { handleContactDuplicate } from '@/lib/contact-duplicate';
5
+ import { googleFetch } from '@/lib/google-fetch';
6
+ import { encrypt, decrypt } from '@/lib/encryption';
7
+ import { auth } from '@/lib/auth';
5
8
 
6
9
  // POST /api/integrations/google-sheet/sync - Synchroniser toutes les configurations actives
7
10
  export async function POST(request: NextRequest) {
8
11
  try {
9
12
  const client = prisma as any;
13
+ const session = await auth.api.getSession({ headers: request.headers });
14
+ if (!session) {
15
+ return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
16
+ }
10
17
 
11
18
  // Récupérer toutes les configurations actives
12
19
  const configs = await client.googleSheetSyncConfig.findMany({
@@ -41,13 +48,14 @@ export async function POST(request: NextRequest) {
41
48
 
42
49
  // Synchroniser chaque configuration
43
50
  for (const config of configs) {
51
+ const lockKey = `google_sheet_sync:${config.id}`;
52
+ let lockAcquired = false;
44
53
  try {
45
- // Récupérer le compte Google de l'utilisateur propriétaire
46
- const googleAccount = await client.userGoogleAccount.findUnique({
47
- where: { userId: config.ownerUserId },
48
- });
49
-
50
- if (!googleAccount) {
54
+ const lockResult = await client.$queryRaw<
55
+ Array<{ locked: boolean }>
56
+ >`SELECT pg_try_advisory_lock(hashtext(${lockKey}), 4261) AS locked`;
57
+ lockAcquired = lockResult?.[0]?.locked === true;
58
+ if (!lockAcquired) {
51
59
  results.push({
52
60
  configId: config.id,
53
61
  configName: config.name,
@@ -55,73 +63,100 @@ export async function POST(request: NextRequest) {
55
63
  updated: 0,
56
64
  skipped: 0,
57
65
  error:
58
- 'Aucun compte Google connecté pour l’utilisateur propriétaire. Veuillez connecter votre compte Google dans les paramètres.',
66
+ 'Une synchronisation est déjà en cours pour cette configuration. Veuillez patienter quelques secondes puis relancer.',
59
67
  });
60
68
  continue;
61
69
  }
62
70
 
63
- let accessToken: string;
64
- try {
65
- accessToken = await getValidAccessToken(
66
- googleAccount.accessToken,
67
- googleAccount.refreshToken,
68
- googleAccount.tokenExpiresAt,
69
- );
71
+ const range = encodeURIComponent(config.sheetName);
72
+ const candidateUserIds = Array.from(
73
+ new Set(
74
+ [config.ownerUserId, config.defaultAssignedUserId, session.user.id].filter(
75
+ (value): value is string => Boolean(value),
76
+ ),
77
+ ),
78
+ );
70
79
 
71
- // Mettre à jour le token si nécessaire
72
- if (accessToken !== googleAccount.accessToken) {
73
- const tokenExpiresAt = new Date();
74
- tokenExpiresAt.setSeconds(tokenExpiresAt.getSeconds() + 3600);
75
- await client.userGoogleAccount.update({
76
- where: { userId: config.ownerUserId },
77
- data: {
78
- accessToken,
79
- tokenExpiresAt,
80
- },
81
- });
82
- }
83
- } catch (error: any) {
84
- // Gérer spécifiquement les erreurs de token expiré/révoqué
85
- if (error instanceof GoogleTokenError && error.isRevoked) {
86
- results.push({
87
- configId: config.id,
88
- configName: config.name,
89
- imported: 0,
90
- updated: 0,
91
- skipped: 0,
92
- error: error.message,
93
- });
80
+ let selectedSheetsResponse: Response | null = null;
81
+ let lastAuthError =
82
+ 'Aucun compte Google compatible trouvé pour cette configuration. Reconnectez Google puis partagez le Sheet avec ce compte.';
83
+
84
+ for (const candidateUserId of candidateUserIds) {
85
+ const googleAccount = await client.userGoogleAccount.findUnique({
86
+ where: { userId: candidateUserId },
87
+ });
88
+
89
+ if (!googleAccount) {
90
+ lastAuthError = 'Aucun compte Google connecté parmi les utilisateurs autorisés.';
94
91
  continue;
95
92
  }
96
- // Relancer l'erreur si ce n'est pas une erreur de token
97
- throw error;
98
- }
99
93
 
100
- const range = encodeURIComponent(config.sheetName);
101
- const response = await fetch(
102
- `https://sheets.googleapis.com/v4/spreadsheets/${config.spreadsheetId}/values/${range}`,
103
- {
104
- headers: {
105
- Authorization: `Bearer ${accessToken}`,
106
- },
107
- },
108
- );
94
+ try {
95
+ const decryptedAccess = decrypt(googleAccount.accessToken);
96
+ const decryptedRefresh = decrypt(googleAccount.refreshToken);
97
+ const accessToken = await getValidAccessToken(
98
+ decryptedAccess,
99
+ decryptedRefresh,
100
+ googleAccount.tokenExpiresAt,
101
+ );
102
+
103
+ if (accessToken !== decryptedAccess) {
104
+ const tokenExpiresAt = new Date();
105
+ tokenExpiresAt.setSeconds(tokenExpiresAt.getSeconds() + 3600);
106
+ await client.userGoogleAccount.update({
107
+ where: { userId: candidateUserId },
108
+ data: {
109
+ accessToken: encrypt(accessToken),
110
+ tokenExpiresAt,
111
+ },
112
+ });
113
+ }
114
+
115
+ const candidateResponse = await googleFetch(
116
+ `https://sheets.googleapis.com/v4/spreadsheets/${config.spreadsheetId}/values/${range}`,
117
+ {
118
+ headers: {
119
+ Authorization: `Bearer ${accessToken}`,
120
+ },
121
+ },
122
+ );
123
+
124
+ if (candidateResponse.ok) {
125
+ selectedSheetsResponse = candidateResponse;
126
+ break;
127
+ }
109
128
 
110
- if (!response.ok) {
111
- const errorText = await response.text();
112
- console.error(`Erreur lors de la lecture du Google Sheet ${config.name}:`, errorText);
129
+ const errorText = await candidateResponse.text();
130
+ lastAuthError =
131
+ candidateResponse.status === 401 || candidateResponse.status === 403
132
+ ? 'Le compte Google connecté n’a pas accès à ce Sheet. Partagez le document avec ce compte ou reconnectez Google.'
133
+ : 'Impossible de lire les données depuis Google Sheets.';
134
+ console.error(
135
+ `Erreur lors de la lecture du Google Sheet ${config.name} (user ${candidateUserId}):`,
136
+ errorText,
137
+ );
138
+ } catch (error: any) {
139
+ if (error instanceof GoogleTokenError && error.isRevoked) {
140
+ lastAuthError = error.message;
141
+ } else {
142
+ lastAuthError = error?.message || 'Erreur lors de la validation du compte Google.';
143
+ }
144
+ }
145
+ }
146
+
147
+ if (!selectedSheetsResponse) {
113
148
  results.push({
114
149
  configId: config.id,
115
150
  configName: config.name,
116
151
  imported: 0,
117
152
  updated: 0,
118
153
  skipped: 0,
119
- error: 'Impossible de lire les données depuis Google Sheets.',
154
+ error: lastAuthError,
120
155
  });
121
156
  continue;
122
157
  }
123
158
 
124
- const data = await response.json();
159
+ const data = await selectedSheetsResponse.json();
125
160
  const values: string[][] = data.values || [];
126
161
 
127
162
  if (!values.length) {
@@ -141,47 +176,6 @@ export async function POST(request: NextRequest) {
141
176
  (config.lastSyncedRow || headerRowIndex) + 1,
142
177
  );
143
178
 
144
- // Si aucune nouvelle ligne à traiter, skip
145
- if (startRowIndex >= values.length) {
146
- results.push({
147
- configId: config.id,
148
- configName: config.name,
149
- imported: 0,
150
- updated: 0,
151
- skipped: 0,
152
- });
153
- continue;
154
- }
155
-
156
- // Réserver atomiquement les lignes pour éviter les imports en double
157
- // (protection contre les synchronisations concurrentes, ex: React Strict Mode, multi-pages)
158
- const claimedMaxRow = values.length - 1;
159
- const claimCondition: Record<string, unknown> = { id: config.id };
160
- if (config.lastSyncedRow !== null && config.lastSyncedRow !== undefined) {
161
- claimCondition.lastSyncedRow = config.lastSyncedRow;
162
- } else {
163
- claimCondition.lastSyncedRow = null;
164
- }
165
-
166
- const claimResult = await client.googleSheetSyncConfig.updateMany({
167
- where: claimCondition,
168
- data: {
169
- lastSyncedRow: claimedMaxRow,
170
- },
171
- });
172
-
173
- if (claimResult.count === 0) {
174
- // Une autre synchronisation concurrente a déjà réservé ces lignes
175
- results.push({
176
- configId: config.id,
177
- configName: config.name,
178
- imported: 0,
179
- updated: 0,
180
- skipped: 0,
181
- });
182
- continue;
183
- }
184
-
185
179
  // Récupérer les headers
186
180
  const headerRow = values[headerRowIndex] || [];
187
181
 
@@ -267,9 +261,92 @@ export async function POST(request: NextRequest) {
267
261
  }
268
262
  }
269
263
 
264
+ // Pré-charger le rôle de l'utilisateur par défaut (constant pour toute la config)
265
+ let assignedCommercialId: string | null = null;
266
+ let assignedTeleproId: string | null = null;
267
+
268
+ if (config.defaultAssignedUserId) {
269
+ const defaultUser = await client.user.findUnique({
270
+ where: { id: config.defaultAssignedUserId },
271
+ select: { role: true },
272
+ });
273
+
274
+ if (defaultUser) {
275
+ if (
276
+ defaultUser.role === 'COMMERCIAL' ||
277
+ defaultUser.role === 'ADMIN' ||
278
+ defaultUser.role === 'MANAGER'
279
+ ) {
280
+ assignedCommercialId = config.defaultAssignedUserId;
281
+ } else if (defaultUser.role === 'TELEPRO') {
282
+ assignedTeleproId = config.defaultAssignedUserId;
283
+ }
284
+ }
285
+ }
286
+
287
+ const escapeHtml = (text: string): string => {
288
+ const map: { [key: string]: string } = {
289
+ '&': '&amp;',
290
+ '<': '&lt;',
291
+ '>': '&gt;',
292
+ '"': '&quot;',
293
+ "'": '&#039;',
294
+ };
295
+ return text.replace(/[&<>"']/g, (m) => map[m]);
296
+ };
297
+
298
+ const formatNoteContent = (noteItems: Array<{ label: string; value: string }>) => {
299
+ const escapedConfigName = escapeHtml(config.name);
300
+
301
+ if (noteItems.length === 0) {
302
+ return `<p style="font-size: 14px; line-height: 1.5; color: #374151; margin-bottom: 0;">Ce contact a été importé automatiquement depuis Google Sheets (${escapedConfigName}).</p>`;
303
+ }
304
+
305
+ let html = `<p style="font-size: 14px; line-height: 1.5; color: #374151; margin-bottom: 16px;">Ce contact a été importé automatiquement depuis Google Sheets (${escapedConfigName}).</p>`;
306
+
307
+ html +=
308
+ '<div style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;">';
309
+
310
+ noteItems.forEach((item) => {
311
+ let formattedValue = item.value;
312
+ try {
313
+ const parsed = JSON.parse(item.value);
314
+ if (Array.isArray(parsed)) {
315
+ formattedValue = parsed.map((v) => String(v)).join(', ');
316
+ }
317
+ } catch {
318
+ // Ce n'est pas du JSON, on garde la valeur telle quelle
319
+ }
320
+
321
+ const humanLabel = item.label
322
+ .split(/(?=[A-Z])/)
323
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
324
+ .join(' ');
325
+
326
+ const escapedLabel = escapeHtml(humanLabel);
327
+ const escapedValue = escapeHtml(String(formattedValue)).replace(/\n/g, '<br>');
328
+
329
+ html += `
330
+ <div style="padding: 12px 14px; background: linear-gradient(to right, #f8fafc 0%, #f1f5f9 100%); border-radius: 10px; border-left: 4px solid #6366f1; box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05); transition: all 0.2s ease;">
331
+ <div style="font-size: 11px; font-weight: 700; color: #64748b; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 6px;">
332
+ ${escapedLabel}
333
+ </div>
334
+ <div style="font-size: 14px; line-height: 1.5; color: #0f172a; font-weight: 500;">
335
+ ${escapedValue}
336
+ </div>
337
+ </div>
338
+ `;
339
+ });
340
+
341
+ html += '</div>';
342
+ return html;
343
+ };
344
+
270
345
  let imported = 0;
271
346
  let updated = 0;
272
347
  let skipped = 0;
348
+ let maxProcessedRow = config.lastSyncedRow || headerRowIndex;
349
+ const seenRowKeys = new Set<string>();
273
350
 
274
351
  for (let rowIndex = startRowIndex; rowIndex < values.length; rowIndex++) {
275
352
  const row = values[rowIndex];
@@ -305,8 +382,22 @@ export async function POST(request: NextRequest) {
305
382
  columnMappings['origin'] !== undefined
306
383
  ? row[columnMappings['origin']]?.trim() || 'Google Sheets'
307
384
  : 'Google Sheets';
385
+ const normalizedEmail = email ? email.trim().toLowerCase() : '';
386
+ const normalizedPhone = phone.replace(/\D/g, '');
387
+ const normalizedFirstName = firstName ? firstName.trim().toLowerCase() : '';
388
+ const normalizedLastName = lastName ? lastName.trim().toLowerCase() : '';
389
+ const rowKey = normalizedEmail
390
+ ? `email:${normalizedEmail}`
391
+ : normalizedPhone
392
+ ? `phone:${normalizedPhone}`
393
+ : `name:${normalizedFirstName}|${normalizedLastName}`;
394
+
395
+ if (seenRowKeys.has(rowKey)) {
396
+ skipped++;
397
+ continue;
398
+ }
399
+ seenRowKeys.add(rowKey);
308
400
 
309
- // Collecter les notes si des colonnes sont configurées comme "note"
310
401
  const noteContents: Array<{ label: string; value: string }> = [];
311
402
  if (noteFields.length > 0) {
312
403
  noteFields.forEach(({ name, index }) => {
@@ -319,93 +410,6 @@ export async function POST(request: NextRequest) {
319
410
  });
320
411
  }
321
412
 
322
- // Fonction pour échapper le HTML
323
- const escapeHtml = (text: string): string => {
324
- const map: { [key: string]: string } = {
325
- '&': '&amp;',
326
- '<': '&lt;',
327
- '>': '&gt;',
328
- '"': '&quot;',
329
- "'": '&#039;',
330
- };
331
- return text.replace(/[&<>"']/g, (m) => map[m]);
332
- };
333
-
334
- // Fonction pour formater le contenu de la note en HTML
335
- const formatNoteContent = (noteItems: Array<{ label: string; value: string }>) => {
336
- const escapedConfigName = escapeHtml(config.name);
337
-
338
- if (noteItems.length === 0) {
339
- return `<p style="font-size: 14px; line-height: 1.5; color: #374151; margin-bottom: 0;">Ce contact a été importé automatiquement depuis Google Sheets (${escapedConfigName}).</p>`;
340
- }
341
-
342
- let html = `<p style="font-size: 14px; line-height: 1.5; color: #374151; margin-bottom: 16px;">Ce contact a été importé automatiquement depuis Google Sheets (${escapedConfigName}).</p>`;
343
-
344
- html +=
345
- '<div style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;">';
346
-
347
- noteItems.forEach((item) => {
348
- // Formater les valeurs qui sont des tableaux JSON
349
- let formattedValue = item.value;
350
- try {
351
- const parsed = JSON.parse(item.value);
352
- if (Array.isArray(parsed)) {
353
- formattedValue = parsed.map((v) => String(v)).join(', ');
354
- }
355
- } catch {
356
- // Ce n'est pas du JSON, on garde la valeur telle quelle
357
- }
358
-
359
- // Capitaliser le label pour le rendre plus humain
360
- const humanLabel = item.label
361
- .split(/(?=[A-Z])/)
362
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
363
- .join(' ');
364
-
365
- // Échapper le HTML pour éviter les injections XSS
366
- const escapedLabel = escapeHtml(humanLabel);
367
- const escapedValue = escapeHtml(String(formattedValue)).replace(/\n/g, '<br>');
368
-
369
- html += `
370
- <div style="padding: 12px 14px; background: linear-gradient(to right, #f8fafc 0%, #f1f5f9 100%); border-radius: 10px; border-left: 4px solid #6366f1; box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05); transition: all 0.2s ease;">
371
- <div style="font-size: 11px; font-weight: 700; color: #64748b; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 6px;">
372
- ${escapedLabel}
373
- </div>
374
- <div style="font-size: 14px; line-height: 1.5; color: #0f172a; font-weight: 500;">
375
- ${escapedValue}
376
- </div>
377
- </div>
378
- `;
379
- });
380
-
381
- html += '</div>';
382
- return html;
383
- };
384
-
385
- // Déterminer l'assignation selon le rôle de l'utilisateur par défaut
386
- let assignedCommercialId: string | null = null;
387
- let assignedTeleproId: string | null = null;
388
-
389
- if (config.defaultAssignedUserId) {
390
- const defaultUser = await client.user.findUnique({
391
- where: { id: config.defaultAssignedUserId },
392
- select: { role: true },
393
- });
394
-
395
- if (defaultUser) {
396
- if (
397
- defaultUser.role === 'COMMERCIAL' ||
398
- defaultUser.role === 'ADMIN' ||
399
- defaultUser.role === 'MANAGER'
400
- ) {
401
- assignedCommercialId = config.defaultAssignedUserId;
402
- } else if (defaultUser.role === 'TELEPRO') {
403
- assignedTeleproId = config.defaultAssignedUserId;
404
- }
405
- // Sinon, on ne assigne pas (null pour les deux)
406
- }
407
- }
408
-
409
413
  // Vérifier si c'est un doublon (nom, prénom ET email)
410
414
  const duplicateContactId = await handleContactDuplicate(
411
415
  firstName,
@@ -514,10 +518,20 @@ export async function POST(request: NextRequest) {
514
518
  },
515
519
  });
516
520
  }
521
+
522
+ if (rowIndex > maxProcessedRow) {
523
+ maxProcessedRow = rowIndex;
524
+ }
517
525
  }
518
526
 
519
- // Pas besoin de mettre à jour lastSyncedRow ici,
520
- // car il a été réservé atomiquement au début du traitement (claimedMaxRow).
527
+ if (maxProcessedRow > (config.lastSyncedRow || headerRowIndex)) {
528
+ await client.googleSheetSyncConfig.update({
529
+ where: { id: config.id },
530
+ data: {
531
+ lastSyncedRow: maxProcessedRow,
532
+ },
533
+ });
534
+ }
521
535
 
522
536
  totalImported += imported;
523
537
  totalUpdated += updated;
@@ -540,6 +554,10 @@ export async function POST(request: NextRequest) {
540
554
  skipped: 0,
541
555
  error: error.message || 'Erreur lors de la synchronisation',
542
556
  });
557
+ } finally {
558
+ if (lockAcquired) {
559
+ await client.$queryRaw`SELECT pg_advisory_unlock(hashtext(${lockKey}), 4261)`;
560
+ }
543
561
  }
544
562
  }
545
563
 
@@ -53,29 +53,26 @@ export async function POST(request: NextRequest) {
53
53
 
54
54
  const newPassword = await hashPassword(password);
55
55
 
56
- // Créer l'Account avec le mot de passe haché
57
- await prisma.account.create({
58
- data: {
59
- id: crypto.randomUUID(),
60
- accountId: user.id,
61
- providerId: 'credential',
62
- userId: user.id,
63
- password: newPassword,
64
- },
65
- });
66
-
67
- // Supprimer le token de vérification
68
- await prisma.verification.delete({
69
- where: { id: verification.id },
70
- });
71
-
72
- // Mettre à jour l'utilisateur comme vérifié
73
- await prisma.user.update({
74
- where: { id: user.id },
75
- data: {
76
- emailVerified: true,
77
- },
78
- });
56
+ await prisma.$transaction([
57
+ prisma.account.create({
58
+ data: {
59
+ id: crypto.randomUUID(),
60
+ accountId: user.id,
61
+ providerId: 'credential',
62
+ userId: user.id,
63
+ password: newPassword,
64
+ },
65
+ }),
66
+ prisma.verification.delete({
67
+ where: { id: verification.id },
68
+ }),
69
+ prisma.user.update({
70
+ where: { id: user.id },
71
+ data: {
72
+ emailVerified: true,
73
+ },
74
+ }),
75
+ ]);
79
76
 
80
77
  return NextResponse.json({
81
78
  success: true,
@@ -51,6 +51,7 @@ export async function GET(request: NextRequest) {
51
51
  orderBy: {
52
52
  scheduledAt: 'asc',
53
53
  },
54
+ take: 100,
54
55
  });
55
56
 
56
57
  // Formater les rappels - inclure toutes les tâches avec rappels
@@ -46,21 +46,19 @@ export async function POST(request: NextRequest) {
46
46
  return NextResponse.json({ error: 'Utilisateur non trouvé' }, { status: 404 });
47
47
  }
48
48
 
49
- // Hasher le nouveau mot de passe
50
49
  const hashedPassword = await hashPassword(password);
51
50
 
52
- // Mettre à jour le mot de passe dans l'Account
53
- await prisma.account.update({
54
- where: { id: user.accounts[0].id },
55
- data: {
56
- password: hashedPassword,
57
- },
58
- });
59
-
60
- // Supprimer le token de vérification
61
- await prisma.verification.delete({
62
- where: { id: verification.id },
63
- });
51
+ await prisma.$transaction([
52
+ prisma.account.update({
53
+ where: { id: user.accounts[0].id },
54
+ data: {
55
+ password: hashedPassword,
56
+ },
57
+ }),
58
+ prisma.verification.delete({
59
+ where: { id: verification.id },
60
+ }),
61
+ ]);
64
62
 
65
63
  return NextResponse.json({
66
64
  success: true,