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,9 +1,77 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
+ import { z } from 'zod';
2
3
  import { auth } from '@/lib/auth';
3
- import { prisma } from '@/lib/prisma';
4
+ import { prisma, Prisma } from '@/lib/prisma';
5
+ import { checkPermission } from '@/lib/check-permission';
4
6
  import { handleContactDuplicate } from '@/lib/contact-duplicate';
5
7
  import { executeWorkflowsOnContactCreated } from '@/lib/workflow-executor';
6
8
  import { normalizePhoneNumber } from '@/lib/utils';
9
+ import { buildPrismaWhereFromFilters } from '@/lib/contact-view-filters';
10
+ import {
11
+ expandRegionCodesToDepartmentCodes,
12
+ prismaPostalMatchesDepartmentsCondition,
13
+ } from '@/lib/fr-geography';
14
+ import type { ViewFilter, ViewSortConfig } from '@/types/contact-views';
15
+
16
+ const socialNetworkSchema = z.object({
17
+ platform: z.string().trim().min(1),
18
+ url: z.string().trim().url(),
19
+ });
20
+
21
+ const createContactSchema = z.object({
22
+ civility: z.enum(['M', 'MME', 'MLLE']).optional().nullable(),
23
+ firstName: z.string().trim().min(1).optional().nullable(),
24
+ lastName: z.string().trim().min(1).optional().nullable(),
25
+ phone: z.string().trim().min(3, 'Le téléphone est obligatoire'),
26
+ secondaryPhone: z.string().trim().optional().nullable(),
27
+ email: z.string().email().optional().nullable(),
28
+ address: z.string().optional().nullable(),
29
+ city: z.string().optional().nullable(),
30
+ postalCode: z.string().optional().nullable(),
31
+ origin: z.string().optional().nullable(),
32
+ companyName: z.string().trim().optional().nullable(),
33
+ companyId: z.string().optional().nullable(),
34
+ jobTitle: z.string().trim().optional().nullable(),
35
+ website: z.string().trim().url().optional().nullable().or(z.literal('')),
36
+ socialNetworks: z.array(socialNetworkSchema).optional().nullable(),
37
+ statusId: z.string().optional().nullable(),
38
+ closingReason: z.string().optional().nullable(),
39
+ assignedCommercialId: z.string().optional().nullable(),
40
+ assignedTeleproId: z.string().optional().nullable(),
41
+ });
42
+
43
+ function parseContactsListSortDirection(
44
+ sortDir: string | null,
45
+ sortOrder: string | null,
46
+ ): 'asc' | 'desc' | null {
47
+ const raw = sortDir ?? sortOrder;
48
+ return raw === 'asc' || raw === 'desc' ? raw : null;
49
+ }
50
+
51
+ /** Identifiants UI liste → orderBy Prisma (whitelist). */
52
+ function contactListPrismaOrderBy(
53
+ uiField: string,
54
+ direction: 'asc' | 'desc',
55
+ ): Prisma.ContactOrderByWithRelationInput | null {
56
+ switch (uiField) {
57
+ case 'createdAt':
58
+ return { createdAt: direction };
59
+ case 'updatedAt':
60
+ return { updatedAt: direction };
61
+ case 'postalCode':
62
+ return { postalCode: direction };
63
+ case 'status':
64
+ return { status: { name: direction } };
65
+ case 'commercial':
66
+ return { assignedCommercial: { name: direction } };
67
+ case 'telepro':
68
+ return { assignedTelepro: { name: direction } };
69
+ case 'origin':
70
+ return { origin: direction };
71
+ default:
72
+ return null;
73
+ }
74
+ }
7
75
 
8
76
  // GET /api/contacts - Récupérer tous les contacts avec filtres
9
77
  export async function GET(request: NextRequest) {
@@ -16,8 +84,26 @@ export async function GET(request: NextRequest) {
16
84
  return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
17
85
  }
18
86
 
87
+ const [canViewAll, canViewOwn, canViewUnassigned] = await Promise.all([
88
+ checkPermission('contacts.view_all'),
89
+ checkPermission('contacts.view_own'),
90
+ checkPermission('contacts.view_unassigned'),
91
+ ]);
92
+
93
+ if (!canViewAll && !canViewOwn) {
94
+ return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
95
+ }
96
+
19
97
  const { searchParams } = new URL(request.url);
20
98
  const search = searchParams.get('search') || '';
99
+ const viewId = searchParams.get('viewId');
100
+ const filtersParam = searchParams.get('filters');
101
+ const statusIds = searchParams.get('statusIds');
102
+ const assignedCommercialIds = searchParams.get('assignedCommercialIds');
103
+ const assignedTeleproIds = searchParams.get('assignedTeleproIds');
104
+ const origins = searchParams.get('origins');
105
+ const departmentCodesParam = searchParams.get('departmentCodes');
106
+ const regionCodesParam = searchParams.get('regionCodes');
21
107
  const statusId = searchParams.get('statusId');
22
108
  const assignedCommercialId = searchParams.get('assignedCommercialId');
23
109
  const assignedTeleproId = searchParams.get('assignedTeleproId');
@@ -26,40 +112,182 @@ export async function GET(request: NextRequest) {
26
112
  const createdAtEnd = searchParams.get('createdAtEnd');
27
113
  const updatedAtStart = searchParams.get('updatedAtStart');
28
114
  const updatedAtEnd = searchParams.get('updatedAtEnd');
29
- // const isCompany = searchParams.get('isCompany');
30
- const page = parseInt(searchParams.get('page') || '1');
31
- const limit = parseInt(searchParams.get('limit') || '50');
115
+ const sortFieldParam = searchParams.get('sortField');
116
+ const sortDirParam = searchParams.get('sortDir');
117
+ const sortOrderParam = searchParams.get('sortOrder');
118
+ const page = Number.parseInt(searchParams.get('page') || '1');
119
+ const limit = Math.min(Number.parseInt(searchParams.get('limit') || '50'), 200);
32
120
  const skip = (page - 1) * limit;
33
121
 
34
- // Construire les filtres
35
122
  const where: any = {};
123
+ let viewSortConfig: ViewSortConfig | null = null;
36
124
 
37
- if (search) {
38
- where.OR = [
39
- { firstName: { contains: search, mode: 'insensitive' } },
40
- { lastName: { contains: search, mode: 'insensitive' } },
41
- { email: { contains: search, mode: 'insensitive' } },
42
- { phone: { contains: search, mode: 'insensitive' } },
125
+ // Resolve view filters if viewId or inline filters are provided
126
+ if (viewId) {
127
+ const view = await prisma.contactView.findUnique({ where: { id: viewId } });
128
+ if (view && (view.userId === session.user.id || view.isPublic)) {
129
+ const viewFilters = (view.filters ?? []) as unknown as ViewFilter[];
130
+ const viewWhere = buildPrismaWhereFromFilters(viewFilters);
131
+ if (Object.keys(viewWhere).length > 0) {
132
+ where.AND = where.AND || [];
133
+ where.AND.push(viewWhere);
134
+ }
135
+ if (view.sortConfig) {
136
+ viewSortConfig = view.sortConfig as unknown as ViewSortConfig;
137
+ }
138
+ }
139
+ } else if (filtersParam) {
140
+ try {
141
+ const parsedFilters = JSON.parse(filtersParam) as ViewFilter[];
142
+ if (Array.isArray(parsedFilters) && parsedFilters.length > 0) {
143
+ const filtersWhere = buildPrismaWhereFromFilters(parsedFilters);
144
+ if (Object.keys(filtersWhere).length > 0) {
145
+ where.AND = where.AND || [];
146
+ where.AND.push(filtersWhere);
147
+ }
148
+ }
149
+ } catch {
150
+ // Invalid JSON, ignore
151
+ }
152
+ }
153
+
154
+ if (!canViewAll && canViewOwn) {
155
+ const ownershipConditions: any[] = [
156
+ { assignedCommercialId: session.user.id },
157
+ { assignedTeleproId: session.user.id },
158
+ { createdById: session.user.id },
43
159
  ];
160
+ if (canViewUnassigned) {
161
+ ownershipConditions.push({
162
+ AND: [{ assignedCommercialId: null }, { assignedTeleproId: null }],
163
+ });
164
+ }
165
+ where.AND = where.AND || [];
166
+ where.AND.push({ OR: ownershipConditions });
167
+ }
168
+
169
+ if (search) {
170
+ const trimmedSearch = search.trim();
171
+ const searchTerms = trimmedSearch.split(/\s+/).filter(Boolean);
172
+ const firstTerm = searchTerms[0] || '';
173
+ const remainingTerms = searchTerms.slice(1).join(' ');
174
+
175
+ // Normaliser le numéro si la recherche ressemble à un téléphone
176
+ const digitsOnly = trimmedSearch.replace(/\D/g, '');
177
+ const looksLikePhone = digitsOnly.length >= 4;
178
+ const normalizedPhone = looksLikePhone ? normalizePhoneNumber(trimmedSearch) : null;
179
+
180
+ where.AND = where.AND || [];
181
+ where.AND.push({
182
+ OR: [
183
+ { firstName: { contains: trimmedSearch, mode: 'insensitive' } },
184
+ { lastName: { contains: trimmedSearch, mode: 'insensitive' } },
185
+ { email: { contains: trimmedSearch, mode: 'insensitive' } },
186
+ { phone: { contains: trimmedSearch, mode: 'insensitive' } },
187
+ { city: { contains: trimmedSearch, mode: 'insensitive' } },
188
+ { postalCode: { contains: trimmedSearch, mode: 'insensitive' } },
189
+ { address: { contains: trimmedSearch, mode: 'insensitive' } },
190
+ { secondaryPhone: { contains: trimmedSearch, mode: 'insensitive' } },
191
+ { origin: { contains: trimmedSearch, mode: 'insensitive' } },
192
+ { jobTitle: { contains: trimmedSearch, mode: 'insensitive' } },
193
+ { companyName: { contains: trimmedSearch, mode: 'insensitive' } },
194
+ { company: { name: { contains: trimmedSearch, mode: 'insensitive' } } },
195
+ ...(normalizedPhone
196
+ ? [
197
+ { phone: { contains: normalizedPhone, mode: 'insensitive' as const } },
198
+ { secondaryPhone: { contains: normalizedPhone, mode: 'insensitive' as const } },
199
+ ]
200
+ : []),
201
+ ...(remainingTerms
202
+ ? [
203
+ {
204
+ AND: [
205
+ { firstName: { contains: firstTerm, mode: 'insensitive' } },
206
+ { lastName: { contains: remainingTerms, mode: 'insensitive' } },
207
+ ],
208
+ },
209
+ {
210
+ AND: [
211
+ { firstName: { contains: remainingTerms, mode: 'insensitive' } },
212
+ { lastName: { contains: firstTerm, mode: 'insensitive' } },
213
+ ],
214
+ },
215
+ ]
216
+ : []),
217
+ ],
218
+ });
44
219
  }
45
220
 
46
- if (statusId) {
221
+ // Legacy query-param filters (rétrocompatibilité)
222
+ if (statusIds) {
223
+ const ids = statusIds.split(',').filter(Boolean);
224
+ where.statusId = ids.length === 1 ? ids[0] : { in: ids };
225
+ } else if (statusId) {
47
226
  where.statusId = statusId;
48
227
  }
49
228
 
50
- if (assignedCommercialId) {
229
+ if (assignedCommercialIds) {
230
+ const ids = assignedCommercialIds.split(',').filter(Boolean);
231
+ const hasUnassigned = ids.includes('UNASSIGNED');
232
+ const realIds = ids.filter((id) => id !== 'UNASSIGNED');
233
+ if (hasUnassigned && realIds.length > 0) {
234
+ where.AND = where.AND || [];
235
+ where.AND.push({
236
+ OR: [{ assignedCommercialId: null }, { assignedCommercialId: { in: realIds } }],
237
+ });
238
+ } else if (hasUnassigned) {
239
+ where.assignedCommercialId = null;
240
+ } else {
241
+ where.assignedCommercialId = realIds.length === 1 ? realIds[0] : { in: realIds };
242
+ }
243
+ } else if (assignedCommercialId) {
51
244
  where.assignedCommercialId = assignedCommercialId;
52
245
  }
53
246
 
54
- if (assignedTeleproId) {
247
+ if (assignedTeleproIds) {
248
+ const ids = assignedTeleproIds.split(',').filter(Boolean);
249
+ const hasUnassigned = ids.includes('UNASSIGNED');
250
+ const realIds = ids.filter((id) => id !== 'UNASSIGNED');
251
+ if (hasUnassigned && realIds.length > 0) {
252
+ where.AND = where.AND || [];
253
+ where.AND.push({
254
+ OR: [{ assignedTeleproId: null }, { assignedTeleproId: { in: realIds } }],
255
+ });
256
+ } else if (hasUnassigned) {
257
+ where.assignedTeleproId = null;
258
+ } else {
259
+ where.assignedTeleproId = realIds.length === 1 ? realIds[0] : { in: realIds };
260
+ }
261
+ } else if (assignedTeleproId) {
55
262
  where.assignedTeleproId = assignedTeleproId;
56
263
  }
57
264
 
58
- if (origin) {
265
+ if (origins) {
266
+ const vals = origins.split(',').filter(Boolean);
267
+ where.origin = vals.length === 1 ? vals[0] : { in: vals };
268
+ } else if (origin) {
59
269
  where.origin = origin;
60
270
  }
61
271
 
62
- // Filtres de date pour createdAt
272
+ if (departmentCodesParam) {
273
+ const codes = departmentCodesParam.split(',').filter(Boolean);
274
+ const geoCond = prismaPostalMatchesDepartmentsCondition(codes);
275
+ if (geoCond) {
276
+ where.AND = where.AND || [];
277
+ where.AND.push(geoCond);
278
+ }
279
+ }
280
+
281
+ if (regionCodesParam) {
282
+ const codes = regionCodesParam.split(',').filter(Boolean);
283
+ const depts = expandRegionCodesToDepartmentCodes(codes);
284
+ const geoCond = prismaPostalMatchesDepartmentsCondition(depts);
285
+ if (geoCond) {
286
+ where.AND = where.AND || [];
287
+ where.AND.push(geoCond);
288
+ }
289
+ }
290
+
63
291
  if (createdAtStart || createdAtEnd) {
64
292
  where.createdAt = {};
65
293
  if (createdAtStart) {
@@ -69,7 +297,6 @@ export async function GET(request: NextRequest) {
69
297
  }
70
298
  }
71
299
  if (createdAtEnd) {
72
- // Ajouter 23h59m59s pour inclure toute la journée
73
300
  const endDate = new Date(createdAtEnd);
74
301
  if (!isNaN(endDate.getTime())) {
75
302
  endDate.setHours(23, 59, 59, 999);
@@ -78,7 +305,6 @@ export async function GET(request: NextRequest) {
78
305
  }
79
306
  }
80
307
 
81
- // Filtres de date pour updatedAt
82
308
  if (updatedAtStart || updatedAtEnd) {
83
309
  where.updatedAt = {};
84
310
  if (updatedAtStart) {
@@ -88,7 +314,6 @@ export async function GET(request: NextRequest) {
88
314
  }
89
315
  }
90
316
  if (updatedAtEnd) {
91
- // Ajouter 23h59m59s pour inclure toute la journée
92
317
  const endDate = new Date(updatedAtEnd);
93
318
  if (!isNaN(endDate.getTime())) {
94
319
  endDate.setHours(23, 59, 59, 999);
@@ -97,21 +322,33 @@ export async function GET(request: NextRequest) {
97
322
  }
98
323
  }
99
324
 
100
- // if (isCompany === 'true') {
101
- // where = { ...where, isCompany: true };
102
- // }
325
+ // Determine sort order: explicit param > view config > default
326
+ let orderBy: Prisma.ContactOrderByWithRelationInput = { createdAt: 'desc' };
327
+ const explicitDirection = parseContactsListSortDirection(sortDirParam, sortOrderParam);
328
+ if (sortFieldParam && explicitDirection) {
329
+ const mapped = contactListPrismaOrderBy(sortFieldParam, explicitDirection);
330
+ if (mapped) {
331
+ orderBy = mapped;
332
+ }
333
+ } else if (viewSortConfig?.field) {
334
+ const dir =
335
+ viewSortConfig.direction === 'asc' || viewSortConfig.direction === 'desc'
336
+ ? viewSortConfig.direction
337
+ : null;
338
+ if (dir) {
339
+ const mapped = contactListPrismaOrderBy(viewSortConfig.field, dir);
340
+ if (mapped) {
341
+ orderBy = mapped;
342
+ }
343
+ }
344
+ }
103
345
 
104
346
  const [contacts, total] = await Promise.all([
105
347
  prisma.contact.findMany({
106
- where: {
107
- ...where,
108
- isCompany: false,
109
- },
348
+ where,
110
349
  include: {
111
350
  status: true,
112
- companyRelation: {
113
- select: { id: true, firstName: true, lastName: true, isCompany: true },
114
- },
351
+ company: { select: { id: true, name: true } },
115
352
  assignedCommercial: {
116
353
  select: { id: true, name: true, email: true },
117
354
  },
@@ -122,22 +359,29 @@ export async function GET(request: NextRequest) {
122
359
  select: { id: true, name: true, email: true },
123
360
  },
124
361
  },
125
- orderBy: { createdAt: 'desc' },
362
+ orderBy,
126
363
  skip,
127
364
  take: limit,
128
365
  }),
129
- prisma.contact.count({ where: { ...where, isCompany: false } }),
366
+ prisma.contact.count({ where }),
130
367
  ]);
131
368
 
132
- return NextResponse.json({
133
- contacts,
134
- pagination: {
135
- page,
136
- limit,
137
- total,
138
- totalPages: Math.ceil(total / limit),
369
+ return NextResponse.json(
370
+ {
371
+ contacts,
372
+ pagination: {
373
+ page,
374
+ limit,
375
+ total,
376
+ totalPages: Math.ceil(total / limit),
377
+ },
139
378
  },
140
- });
379
+ {
380
+ headers: {
381
+ 'Cache-Control': 'private, no-store, max-age=0, must-revalidate',
382
+ },
383
+ },
384
+ );
141
385
  } catch (error: any) {
142
386
  console.error('Erreur lors de la récupération des contacts:', error);
143
387
  return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 });
@@ -155,7 +399,24 @@ export async function POST(request: NextRequest) {
155
399
  return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
156
400
  }
157
401
 
158
- const body = await request.json();
402
+ const canCreate = await checkPermission('contacts.create');
403
+ if (!canCreate) {
404
+ return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
405
+ }
406
+
407
+ const json = await request.json();
408
+ const parseResult = createContactSchema.safeParse(json);
409
+
410
+ if (!parseResult.success) {
411
+ return NextResponse.json(
412
+ {
413
+ error: 'Données invalides',
414
+ details: parseResult.error.flatten(),
415
+ },
416
+ { status: 400 },
417
+ );
418
+ }
419
+
159
420
  const {
160
421
  civility,
161
422
  firstName,
@@ -168,18 +429,15 @@ export async function POST(request: NextRequest) {
168
429
  postalCode,
169
430
  origin,
170
431
  companyName,
171
- isCompany,
172
432
  companyId,
433
+ jobTitle,
434
+ website,
435
+ socialNetworks,
173
436
  statusId,
174
437
  closingReason,
175
438
  assignedCommercialId,
176
439
  assignedTeleproId,
177
- } = body;
178
-
179
- // Validation
180
- if (!phone) {
181
- return NextResponse.json({ error: 'Le téléphone est obligatoire' }, { status: 400 });
182
- }
440
+ } = parseResult.data;
183
441
 
184
442
  // Vérifier si c'est un doublon (nom, prénom ET email)
185
443
  const duplicateContactId = await handleContactDuplicate(
@@ -196,9 +454,7 @@ export async function POST(request: NextRequest) {
196
454
  where: { id: duplicateContactId },
197
455
  include: {
198
456
  status: true,
199
- companyRelation: {
200
- select: { id: true, firstName: true, lastName: true, isCompany: true },
201
- },
457
+ company: { select: { id: true, name: true } },
202
458
  assignedCommercial: {
203
459
  select: { id: true, name: true, email: true },
204
460
  },
@@ -216,7 +472,7 @@ export async function POST(request: NextRequest) {
216
472
  // Sinon, créer un nouveau contact
217
473
  const contact = await prisma.contact.create({
218
474
  data: {
219
- civility: civility || null,
475
+ civility: civility ?? null,
220
476
  firstName: firstName || null,
221
477
  lastName: lastName || null,
222
478
  phone: normalizePhoneNumber(phone),
@@ -226,9 +482,12 @@ export async function POST(request: NextRequest) {
226
482
  city: city || null,
227
483
  postalCode: postalCode || null,
228
484
  origin: origin || null,
229
- companyName: companyName || null,
230
- isCompany: isCompany === true,
485
+ companyName: companyName && !companyId ? companyName : null,
231
486
  companyId: companyId || null,
487
+ jobTitle: jobTitle || null,
488
+ website: website && website.trim() ? website : null,
489
+ socialNetworks:
490
+ socialNetworks && socialNetworks.length > 0 ? socialNetworks : Prisma.JsonNull,
232
491
  statusId: statusId || null,
233
492
  closingReason: closingReason || null,
234
493
  assignedCommercialId: assignedCommercialId || null,
@@ -252,9 +511,7 @@ export async function POST(request: NextRequest) {
252
511
  },
253
512
  include: {
254
513
  status: true,
255
- companyRelation: {
256
- select: { id: true, firstName: true, lastName: true, isCompany: true },
257
- },
514
+ company: { select: { id: true, name: true } },
258
515
  assignedCommercial: {
259
516
  select: { id: true, name: true, email: true },
260
517
  },
@@ -278,6 +535,14 @@ export async function POST(request: NextRequest) {
278
535
  return NextResponse.json(contact, { status: 201 });
279
536
  } catch (error: any) {
280
537
  console.error('Erreur lors de la création du contact:', error);
281
- return NextResponse.json({ error: error.message || 'Erreur serveur' }, { status: 500 });
538
+ return NextResponse.json(
539
+ {
540
+ error:
541
+ process.env.NODE_ENV === 'development'
542
+ ? error.message || 'Erreur serveur'
543
+ : 'Erreur serveur',
544
+ },
545
+ { status: 500 },
546
+ );
282
547
  }
283
548
  }
@@ -0,0 +1,166 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { createClient } from '@supabase/supabase-js';
3
+ import { prisma } from '@/lib/prisma';
4
+ import { BUCKETS } from '@/lib/supabase-storage';
5
+
6
+ const BUCKET = BUCKETS.EDITOR_IMAGES;
7
+ const FOLDER = 'images';
8
+ // Ne supprimer que les images de plus de 24h (laisse le temps de sauvegarder)
9
+ const MIN_AGE_MS = 24 * 60 * 60 * 1000;
10
+ const LIST_LIMIT = 1000;
11
+
12
+ function getAdminClient() {
13
+ return createClient(
14
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
15
+ process.env.SUPABASE_SERVICE_ROLE_KEY!,
16
+ { auth: { autoRefreshToken: false, persistSession: false } },
17
+ );
18
+ }
19
+
20
+ /**
21
+ * Collecte toutes les URLs d'images éditeur référencées en BDD.
22
+ */
23
+ async function getReferencedImageUrls(): Promise<Set<string>> {
24
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
25
+ const prefix = `${supabaseUrl}/storage/v1/object/public/${BUCKET}/`;
26
+
27
+ const urls = new Set<string>();
28
+
29
+ function extractUrls(text: string | null) {
30
+ if (!text) return;
31
+ const regex = new RegExp(
32
+ prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[^"\'\\s<>]+',
33
+ 'g',
34
+ );
35
+ for (const match of text.matchAll(regex)) {
36
+ urls.add(match[0]);
37
+ }
38
+ }
39
+
40
+ // Templates
41
+ const templates = await prisma.template.findMany({ select: { content: true } });
42
+ for (const t of templates) extractUrls(t.content);
43
+
44
+ // Interactions (notes, emails)
45
+ const interactions = await prisma.interaction.findMany({
46
+ where: { content: { contains: BUCKET } },
47
+ select: { content: true },
48
+ });
49
+ for (const i of interactions) extractUrls(i.content);
50
+
51
+ // Tasks
52
+ const tasks = await prisma.task.findMany({
53
+ where: { description: { contains: BUCKET } },
54
+ select: { description: true },
55
+ });
56
+ for (const t of tasks) extractUrls(t.description);
57
+
58
+ // SMTP signatures
59
+ const smtpConfigs = await prisma.smtpConfig.findMany({
60
+ where: { signature: { not: null } },
61
+ select: { signature: true },
62
+ });
63
+ for (const s of smtpConfigs) extractUrls(s.signature);
64
+
65
+ // Workflow actions
66
+ const actions = await prisma.workflowAction.findMany({
67
+ where: {
68
+ OR: [
69
+ { taskDescription: { contains: BUCKET } },
70
+ { smsMessage: { contains: BUCKET } },
71
+ { noteContent: { contains: BUCKET } },
72
+ ],
73
+ },
74
+ select: { taskDescription: true, smsMessage: true, noteContent: true },
75
+ });
76
+ for (const a of actions) {
77
+ extractUrls(a.taskDescription);
78
+ extractUrls(a.smsMessage);
79
+ extractUrls(a.noteContent);
80
+ }
81
+
82
+ return urls;
83
+ }
84
+
85
+ // GET /api/cron/cleanup-editor-images
86
+ export async function GET(request: NextRequest) {
87
+ try {
88
+ const authHeader = request.headers.get('authorization');
89
+ if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
90
+ return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
91
+ }
92
+
93
+ const client = getAdminClient();
94
+ const now = Date.now();
95
+
96
+ // 1. Lister toutes les images du bucket
97
+ const allFiles: { name: string; created_at: string }[] = [];
98
+ let offset = 0;
99
+ while (true) {
100
+ const { data, error } = await client.storage.from(BUCKET).list(FOLDER, {
101
+ limit: LIST_LIMIT,
102
+ offset,
103
+ sortBy: { column: 'created_at', order: 'asc' },
104
+ });
105
+ if (error) throw new Error(`Erreur listing bucket: ${error.message}`);
106
+ if (!data || data.length === 0) break;
107
+ allFiles.push(...data.map((f) => ({ name: f.name, created_at: f.created_at ?? '' })));
108
+ if (data.length < LIST_LIMIT) break;
109
+ offset += LIST_LIMIT;
110
+ }
111
+
112
+ if (allFiles.length === 0) {
113
+ return NextResponse.json({ deleted: 0, total: 0 });
114
+ }
115
+
116
+ // 2. Filtrer : ne garder que les images > 24h
117
+ const oldFiles = allFiles.filter((f) => {
118
+ const createdAt = new Date(f.created_at).getTime();
119
+ return now - createdAt > MIN_AGE_MS;
120
+ });
121
+
122
+ if (oldFiles.length === 0) {
123
+ return NextResponse.json({ deleted: 0, total: allFiles.length, message: 'Aucune image ancienne' });
124
+ }
125
+
126
+ // 3. Récupérer les URLs référencées en BDD
127
+ const referencedUrls = await getReferencedImageUrls();
128
+
129
+ // 4. Identifier les orphelines
130
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
131
+ const orphanPaths: string[] = [];
132
+ for (const file of oldFiles) {
133
+ const fullUrl = `${supabaseUrl}/storage/v1/object/public/${BUCKET}/${FOLDER}/${file.name}`;
134
+ if (!referencedUrls.has(fullUrl)) {
135
+ orphanPaths.push(`${FOLDER}/${file.name}`);
136
+ }
137
+ }
138
+
139
+ // 5. Supprimer par batch de 100
140
+ let deleted = 0;
141
+ for (let i = 0; i < orphanPaths.length; i += 100) {
142
+ const batch = orphanPaths.slice(i, i + 100);
143
+ const { error } = await client.storage.from(BUCKET).remove(batch);
144
+ if (error) {
145
+ console.error(`Erreur suppression batch ${i}:`, error.message);
146
+ } else {
147
+ deleted += batch.length;
148
+ }
149
+ }
150
+
151
+ console.log(
152
+ `[cleanup-editor-images] ${deleted} orphelines supprimées sur ${allFiles.length} total (${referencedUrls.size} référencées)`,
153
+ );
154
+
155
+ return NextResponse.json({
156
+ deleted,
157
+ total: allFiles.length,
158
+ referenced: referencedUrls.size,
159
+ orphans: orphanPaths.length,
160
+ });
161
+ } catch (error: unknown) {
162
+ console.error('[cleanup-editor-images] Erreur:', error);
163
+ const message = error instanceof Error ? error.message : 'Erreur serveur';
164
+ return NextResponse.json({ error: message }, { status: 500 });
165
+ }
166
+ }