create-crm-tmp 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/bin/create-crm-tmp.js +56 -35
  2. package/package.json +1 -1
  3. package/template/README.md +230 -115
  4. package/template/eslint.config.mjs +13 -0
  5. package/template/next.config.ts +14 -0
  6. package/template/package.json +15 -2
  7. package/template/prisma/migrations/20260318095700_init_db/migration.sql +978 -0
  8. package/template/prisma/migrations/migration_lock.toml +3 -0
  9. package/template/prisma/schema.prisma +132 -637
  10. package/template/src/app/(auth)/invite/[token]/page.tsx +10 -8
  11. package/template/src/app/(auth)/layout.tsx +1 -1
  12. package/template/src/app/(auth)/reset-password/complete/page.tsx +11 -8
  13. package/template/src/app/(auth)/reset-password/page.tsx +4 -4
  14. package/template/src/app/(auth)/reset-password/verify/page.tsx +4 -4
  15. package/template/src/app/(auth)/signin/page.tsx +14 -6
  16. package/template/src/app/(dashboard)/agenda/page.tsx +2243 -988
  17. package/template/src/app/(dashboard)/automatisation/_components/workflow-editor.tsx +18 -104
  18. package/template/src/app/(dashboard)/automatisation/page.tsx +10 -26
  19. package/template/src/app/(dashboard)/closing/page.tsx +78 -62
  20. package/template/src/app/(dashboard)/contacts/[id]/page.tsx +2082 -1080
  21. package/template/src/app/(dashboard)/contacts/companies/[id]/page.tsx +46 -47
  22. package/template/src/app/(dashboard)/contacts/page.tsx +1062 -780
  23. package/template/src/app/(dashboard)/dashboard/page.tsx +533 -37
  24. package/template/src/app/(dashboard)/dev/page.tsx +1291 -0
  25. package/template/src/app/(dashboard)/layout.tsx +6 -2
  26. package/template/src/app/(dashboard)/settings/page.tsx +797 -2582
  27. package/template/src/app/(dashboard)/templates/page.tsx +55 -54
  28. package/template/src/app/(dashboard)/users/list/page.tsx +51 -48
  29. package/template/src/app/(dashboard)/users/page.tsx +1 -1
  30. package/template/src/app/(dashboard)/users/permissions/page.tsx +2 -2
  31. package/template/src/app/(dashboard)/users/roles/page.tsx +7 -5
  32. package/template/src/app/api/agenda/google-events/route.ts +92 -0
  33. package/template/src/app/api/auth/check-active/route.ts +3 -2
  34. package/template/src/app/api/auth/google/route.ts +2 -1
  35. package/template/src/app/api/auth/google/status/route.ts +7 -31
  36. package/template/src/app/api/companies/[id]/activities/route.ts +1 -3
  37. package/template/src/app/api/companies/[id]/route.ts +1 -2
  38. package/template/src/app/api/companies/route.ts +42 -12
  39. package/template/src/app/api/contacts/[id]/files/[fileId]/preview/route.ts +9 -31
  40. package/template/src/app/api/contacts/[id]/files/[fileId]/route.ts +14 -32
  41. package/template/src/app/api/contacts/[id]/files/route.ts +112 -212
  42. package/template/src/app/api/contacts/[id]/interactions/[interactionId]/route.ts +27 -1
  43. package/template/src/app/api/contacts/[id]/interactions/route.ts +16 -16
  44. package/template/src/app/api/contacts/[id]/kyc/route.ts +21 -11
  45. package/template/src/app/api/contacts/[id]/meet/route.ts +19 -2
  46. package/template/src/app/api/contacts/[id]/route.ts +106 -34
  47. package/template/src/app/api/contacts/[id]/send-email/route.ts +27 -11
  48. package/template/src/app/api/contacts/[id]/workflows/run/route.ts +6 -0
  49. package/template/src/app/api/contacts/export/route.ts +9 -13
  50. package/template/src/app/api/contacts/import/route.ts +55 -25
  51. package/template/src/app/api/contacts/import-preview/route.ts +1 -1
  52. package/template/src/app/api/contacts/origins/route.ts +63 -0
  53. package/template/src/app/api/contacts/route.ts +153 -41
  54. package/template/src/app/api/cron/cleanup-editor-images/route.ts +166 -0
  55. package/template/src/app/api/dashboard/widgets/[id]/route.ts +44 -0
  56. package/template/src/app/api/dashboard/widgets/route.ts +181 -0
  57. package/template/src/app/api/dev/reminders/test/route.ts +114 -0
  58. package/template/src/app/api/editor/upload-image/route.ts +61 -0
  59. package/template/src/app/api/integrations/google-sheet/jobs/[jobId]/route.ts +47 -0
  60. package/template/src/app/api/integrations/google-sheet/jobs/usage/route.ts +50 -0
  61. package/template/src/app/api/integrations/google-sheet/sync/route.ts +24 -556
  62. package/template/src/app/api/jobs/google-sheet/process/route.ts +84 -0
  63. package/template/src/app/api/jobs/google-sheet/schedule/route.ts +50 -0
  64. package/template/src/app/api/reminders/clear/route.ts +120 -0
  65. package/template/src/app/api/reminders/clear/undo/route.ts +112 -0
  66. package/template/src/app/api/reminders/route.ts +164 -39
  67. package/template/src/app/api/reminders/state/route.ts +164 -0
  68. package/template/src/app/api/reset-password/request/route.ts +1 -1
  69. package/template/src/app/api/reset-password/verify/route.ts +1 -1
  70. package/template/src/app/api/send/route.ts +16 -4
  71. package/template/src/app/api/settings/google-ads/route.ts +14 -0
  72. package/template/src/app/api/settings/google-calendar/calendars/route.ts +97 -0
  73. package/template/src/app/api/settings/google-calendar/route.ts +124 -0
  74. package/template/src/app/api/settings/google-sheet/[id]/route.ts +28 -0
  75. package/template/src/app/api/settings/google-sheet/auto-map/route.ts +37 -4
  76. package/template/src/app/api/settings/google-sheet/preview/route.ts +9 -3
  77. package/template/src/app/api/settings/google-sheet/route.ts +14 -0
  78. package/template/src/app/api/settings/integrations/logs/route.ts +93 -0
  79. package/template/src/app/api/settings/integrations/notifications/route.ts +67 -0
  80. package/template/src/app/api/settings/meta-leads/[id]/route.ts +0 -1
  81. package/template/src/app/api/settings/meta-leads/route.ts +14 -2
  82. package/template/src/app/api/settings/smtp/route.ts +53 -6
  83. package/template/src/app/api/tasks/[id]/attendees/route.ts +24 -8
  84. package/template/src/app/api/tasks/[id]/route.ts +234 -58
  85. package/template/src/app/api/tasks/meet/route.ts +27 -19
  86. package/template/src/app/api/tasks/route.ts +62 -17
  87. package/template/src/app/api/users/[id]/route.ts +20 -14
  88. package/template/src/app/api/users/list/route.ts +57 -19
  89. package/template/src/app/api/webhooks/google-ads/route.ts +34 -14
  90. package/template/src/app/api/webhooks/meta-leads/route.ts +32 -12
  91. package/template/src/app/api/workflows/[id]/route.ts +0 -4
  92. package/template/src/app/api/workflows/process/route.ts +22 -51
  93. package/template/src/app/api/workflows/route.ts +0 -4
  94. package/template/src/app/globals.css +342 -4
  95. package/template/src/app/layout.tsx +11 -3
  96. package/template/src/app/page.tsx +1 -1
  97. package/template/src/components/address-autocomplete.tsx +7 -6
  98. package/template/src/components/config-error-alert.tsx +46 -0
  99. package/template/src/components/contacts/filter-bar.tsx +12 -3
  100. package/template/src/components/contacts/filter-builder.tsx +28 -43
  101. package/template/src/components/contacts/save-view-dialog.tsx +1 -1
  102. package/template/src/components/contacts/views-tab-bar.tsx +15 -6
  103. package/template/src/components/dashboard/activity-chart.tsx +41 -28
  104. package/template/src/components/dashboard/add-widget-dialog.tsx +157 -0
  105. package/template/src/components/dashboard/color-picker.tsx +64 -0
  106. package/template/src/components/dashboard/contacts-chart.tsx +69 -0
  107. package/template/src/components/dashboard/interactions-by-type-chart.tsx +121 -0
  108. package/template/src/components/dashboard/recent-activity.tsx +154 -0
  109. package/template/src/components/dashboard/stat-card.tsx +40 -40
  110. package/template/src/components/dashboard/status-distribution-chart.tsx +81 -0
  111. package/template/src/components/dashboard/tasks-pie-chart.tsx +37 -34
  112. package/template/src/components/dashboard/top-contacts-list.tsx +113 -0
  113. package/template/src/components/dashboard/upcoming-tasks-list.tsx +72 -81
  114. package/template/src/components/dashboard/widget-wrapper.tsx +36 -0
  115. package/template/src/components/date-picker.tsx +9 -6
  116. package/template/src/components/editor/upload-editor-image.ts +42 -0
  117. package/template/src/components/editor.tsx +161 -22
  118. package/template/src/components/email-template.tsx +2 -2
  119. package/template/src/components/global-search.tsx +30 -28
  120. package/template/src/components/header.tsx +178 -80
  121. package/template/src/components/inactive-account-guard.tsx +58 -0
  122. package/template/src/components/integration-notifications-listener.tsx +12 -0
  123. package/template/src/components/invitation-email-template.tsx +2 -2
  124. package/template/src/components/meet-cancellation-email-template.tsx +3 -3
  125. package/template/src/components/meet-confirmation-email-template.tsx +3 -3
  126. package/template/src/components/meet-update-email-template.tsx +3 -3
  127. package/template/src/components/page-header.tsx +5 -5
  128. package/template/src/components/protected-page.tsx +1 -1
  129. package/template/src/components/reset-password-email-template.tsx +2 -2
  130. package/template/src/components/settings/integrations/GoogleAdsIntegration.tsx +428 -0
  131. package/template/src/components/settings/integrations/GoogleSheetConfigMonitoringModal.tsx +680 -0
  132. package/template/src/components/settings/integrations/GoogleSheetIntegration.tsx +809 -0
  133. package/template/src/components/settings/integrations/ImportResultDialog.tsx +124 -0
  134. package/template/src/components/settings/integrations/IntegrationLogPanel.tsx +57 -0
  135. package/template/src/components/settings/integrations/IntegrationLogsTable.tsx +186 -0
  136. package/template/src/components/settings/integrations/MetaLeadIntegration.tsx +451 -0
  137. package/template/src/components/sidebar.tsx +45 -26
  138. package/template/src/components/skeleton.tsx +40 -43
  139. package/template/src/components/ui/accordion.tsx +2 -2
  140. package/template/src/components/ui/alert-dialog.tsx +1 -1
  141. package/template/src/components/ui/button.tsx +20 -9
  142. package/template/src/components/ui/components.tsx +1 -1
  143. package/template/src/components/ui/date-picker.tsx +422 -0
  144. package/template/src/components/ui/datetime-picker.tsx +338 -0
  145. package/template/src/components/ui/status-select.tsx +271 -0
  146. package/template/src/components/ui/tooltip.tsx +37 -0
  147. package/template/src/components/view-as-modal.tsx +13 -7
  148. package/template/src/contexts/app-toast-context.tsx +245 -57
  149. package/template/src/contexts/dashboard-theme-context.tsx +53 -0
  150. package/template/src/contexts/sidebar-context.tsx +22 -17
  151. package/template/src/contexts/task-reminder-context.tsx +134 -160
  152. package/template/src/contexts/view-as-context.tsx +33 -6
  153. package/template/src/hooks/use-focus-trap.ts +2 -2
  154. package/template/src/hooks/useIntegrationNotifications.ts +49 -0
  155. package/template/src/lib/auth.ts +8 -1
  156. package/template/src/lib/config-links.ts +14 -0
  157. package/template/src/lib/contact-duplicate.ts +79 -61
  158. package/template/src/lib/contact-interactions.ts +21 -21
  159. package/template/src/lib/contact-view-filters.ts +24 -64
  160. package/template/src/lib/contacts-list-url.ts +190 -0
  161. package/template/src/lib/dashboard-stats.ts +65 -7
  162. package/template/src/lib/dashboard-themes.ts +135 -0
  163. package/template/src/lib/date-utils.ts +127 -0
  164. package/template/src/lib/default-widgets.ts +12 -0
  165. package/template/src/lib/editor-html-image-dimensions.ts +172 -0
  166. package/template/src/lib/editor-image-limits.ts +19 -0
  167. package/template/src/lib/email-html-sanitize.ts +19 -0
  168. package/template/src/lib/encryption.ts +9 -6
  169. package/template/src/lib/fr-geography.ts +192 -0
  170. package/template/src/lib/google-calendar-agenda.ts +201 -0
  171. package/template/src/lib/google-calendar.ts +255 -5
  172. package/template/src/lib/google-sheet-sync-jobs.ts +96 -0
  173. package/template/src/lib/google-sheet-sync-runner.ts +514 -0
  174. package/template/src/lib/integration-import-log.ts +21 -0
  175. package/template/src/lib/permissions.ts +40 -10
  176. package/template/src/lib/prisma.ts +4 -1
  177. package/template/src/lib/qstash.ts +65 -0
  178. package/template/src/lib/reminder-state-server.ts +80 -0
  179. package/template/src/lib/reminder-state.ts +29 -0
  180. package/template/src/lib/supabase-storage.ts +113 -0
  181. package/template/src/lib/template-variables.ts +164 -23
  182. package/template/src/lib/utils.ts +45 -0
  183. package/template/src/lib/widget-registry.ts +173 -0
  184. package/template/src/lib/workflow-executor.ts +16 -70
  185. package/template/src/proxy.ts +1 -0
  186. package/template/vercel.json +3 -10
  187. package/template/skills-lock.json +0 -25
  188. package/template/src/components/dashboard/dashboard-content.tsx +0 -79
  189. package/template/src/lib/google-drive.ts +0 -1101
  190. package/template/src/types/yousign.ts +0 -52
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import { auth } from '@/lib/auth';
4
4
  import { prisma } from '@/lib/prisma';
5
5
  import { checkPermission } from '@/lib/check-permission';
6
+ import { normalizePhoneNumber } from '@/lib/utils';
6
7
 
7
8
  const createCompanySchema = z.object({
8
9
  name: z.string().trim().min(1, 'Le nom est obligatoire'),
@@ -58,13 +59,35 @@ export async function GET(request: NextRequest) {
58
59
  }
59
60
 
60
61
  if (search) {
62
+ const trimmedSearch = search.trim();
63
+ const searchTerms = trimmedSearch.split(/\s+/).filter(Boolean);
64
+ const firstTerm = searchTerms[0] || '';
65
+ const remainingTerms = searchTerms.slice(1).join(' ');
66
+
67
+ const digitsOnly = trimmedSearch.replace(/\D/g, '');
68
+ const looksLikePhone = digitsOnly.length >= 4;
69
+ const normalizedPhone = looksLikePhone ? normalizePhoneNumber(trimmedSearch) : null;
70
+
61
71
  where.AND = where.AND || [];
62
72
  where.AND.push({
63
73
  OR: [
64
- { name: { contains: search, mode: 'insensitive' } },
65
- { email: { contains: search, mode: 'insensitive' } },
66
- { phone: { contains: search, mode: 'insensitive' } },
67
- { siret: { contains: search, mode: 'insensitive' } },
74
+ { name: { contains: trimmedSearch, mode: 'insensitive' } },
75
+ { email: { contains: trimmedSearch, mode: 'insensitive' } },
76
+ { phone: { contains: trimmedSearch, mode: 'insensitive' } },
77
+ { siret: { contains: trimmedSearch, mode: 'insensitive' } },
78
+ ...(normalizedPhone
79
+ ? [{ phone: { contains: normalizedPhone, mode: 'insensitive' as const } }]
80
+ : []),
81
+ ...(remainingTerms
82
+ ? [
83
+ {
84
+ AND: [
85
+ { name: { contains: firstTerm, mode: 'insensitive' } },
86
+ { name: { contains: remainingTerms, mode: 'insensitive' } },
87
+ ],
88
+ },
89
+ ]
90
+ : []),
68
91
  ],
69
92
  });
70
93
  }
@@ -94,15 +117,22 @@ export async function GET(request: NextRequest) {
94
117
  prisma.company.count({ where }),
95
118
  ]);
96
119
 
97
- return NextResponse.json({
98
- companies,
99
- pagination: {
100
- total,
101
- page,
102
- limit,
103
- totalPages: Math.ceil(total / limit),
120
+ return NextResponse.json(
121
+ {
122
+ companies,
123
+ pagination: {
124
+ total,
125
+ page,
126
+ limit,
127
+ totalPages: Math.ceil(total / limit),
128
+ },
104
129
  },
105
- });
130
+ {
131
+ headers: {
132
+ 'Cache-Control': 'private, no-store, max-age=0, must-revalidate',
133
+ },
134
+ },
135
+ );
106
136
  } catch (error) {
107
137
  console.error('Erreur lors de la récupération des entreprises:', error);
108
138
  return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 });
@@ -2,18 +2,15 @@ import { NextRequest, NextResponse } from 'next/server';
2
2
  import { auth } from '@/lib/auth';
3
3
  import { prisma } from '@/lib/prisma';
4
4
  import { checkPermission } from '@/lib/check-permission';
5
- import { downloadFileFromDrive } from '@/lib/google-drive';
5
+ import { BUCKETS, createSignedDownloadUrl } from '@/lib/supabase-storage';
6
6
 
7
- // GET /api/contacts/[id]/files/[fileId]/preview - Prévisualiser une image de fichier contact
7
+ // GET /api/contacts/[id]/files/[fileId]/preview redirect to signed URL
8
8
  export async function GET(
9
9
  request: NextRequest,
10
10
  { params }: { params: Promise<{ id: string; fileId: string }> },
11
11
  ) {
12
12
  try {
13
- const session = await auth.api.getSession({
14
- headers: request.headers,
15
- });
16
-
13
+ const session = await auth.api.getSession({ headers: request.headers });
17
14
  if (!session) {
18
15
  return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
19
16
  }
@@ -29,22 +26,14 @@ export async function GET(
29
26
  where: { id: contactId },
30
27
  select: { id: true },
31
28
  });
32
-
33
29
  if (!contact) {
34
30
  return NextResponse.json({ error: 'Contact non trouvé' }, { status: 404 });
35
31
  }
36
32
 
37
33
  const file = await prisma.contactFile.findUnique({
38
34
  where: { id: fileId },
39
- select: {
40
- id: true,
41
- contactId: true,
42
- fileName: true,
43
- mimeType: true,
44
- googleDriveFileId: true,
45
- },
35
+ select: { id: true, contactId: true, fileName: true, mimeType: true, storagePath: true },
46
36
  });
47
-
48
37
  if (!file) {
49
38
  return NextResponse.json({ error: 'Fichier non trouvé' }, { status: 404 });
50
39
  }
@@ -53,24 +42,13 @@ export async function GET(
53
42
  return NextResponse.json({ error: 'Fichier non associé à ce contact' }, { status: 403 });
54
43
  }
55
44
 
56
- if (!file.mimeType.startsWith('image/')) {
57
- return NextResponse.json({ error: 'Prévisualisation disponible uniquement pour les images' }, { status: 404 });
58
- }
59
-
60
- const downloaded = await downloadFileFromDrive(session.user.id, file.googleDriveFileId);
45
+ const signedUrl = await createSignedDownloadUrl(BUCKETS.CONTACTS, file.storagePath, 300);
61
46
 
62
- return new NextResponse(new Uint8Array(downloaded.buffer), {
63
- status: 200,
64
- headers: {
65
- 'Content-Type': file.mimeType,
66
- 'Cache-Control': 'private, max-age=300',
67
- 'Content-Disposition': `inline; filename="${encodeURIComponent(file.fileName)}"`,
68
- },
69
- });
70
- } catch (error: any) {
71
- console.error("Erreur lors de la prévisualisation d'image:", error);
47
+ return NextResponse.redirect(signedUrl);
48
+ } catch (error: unknown) {
49
+ console.error('Erreur lors de la prévisualisation:', error);
72
50
  return NextResponse.json(
73
- { error: error.message || "Erreur lors de la prévisualisation de l'image" },
51
+ { error: error instanceof Error ? error.message : 'Erreur lors de la prévisualisation' },
74
52
  { status: 500 },
75
53
  );
76
54
  }
@@ -2,19 +2,16 @@ import { NextRequest, NextResponse } from 'next/server';
2
2
  import { auth } from '@/lib/auth';
3
3
  import { prisma } from '@/lib/prisma';
4
4
  import { checkPermission } from '@/lib/check-permission';
5
- import { deleteFileFromDrive } from '@/lib/google-drive';
5
+ import { BUCKETS, deleteFile } from '@/lib/supabase-storage';
6
6
  import { logFileDeleted } from '@/lib/contact-interactions';
7
7
 
8
- // DELETE /api/contacts/[id]/files/[fileId] - Supprimer un fichier
8
+ // DELETE /api/contacts/[id]/files/[fileId]
9
9
  export async function DELETE(
10
10
  request: NextRequest,
11
11
  { params }: { params: Promise<{ id: string; fileId: string }> },
12
12
  ) {
13
13
  try {
14
- const session = await auth.api.getSession({
15
- headers: request.headers,
16
- });
17
-
14
+ const session = await auth.api.getSession({ headers: request.headers });
18
15
  if (!session) {
19
16
  return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
20
17
  }
@@ -26,18 +23,12 @@ export async function DELETE(
26
23
 
27
24
  const { id: contactId, fileId } = await params;
28
25
 
29
- const contact = await prisma.contact.findUnique({
30
- where: { id: contactId },
31
- });
32
-
26
+ const contact = await prisma.contact.findUnique({ where: { id: contactId } });
33
27
  if (!contact) {
34
28
  return NextResponse.json({ error: 'Contact non trouvé' }, { status: 404 });
35
29
  }
36
30
 
37
- const file = await prisma.contactFile.findUnique({
38
- where: { id: fileId },
39
- });
40
-
31
+ const file = await prisma.contactFile.findUnique({ where: { id: fileId } });
41
32
  if (!file) {
42
33
  return NextResponse.json({ error: 'Fichier non trouvé' }, { status: 404 });
43
34
  }
@@ -46,39 +37,30 @@ export async function DELETE(
46
37
  return NextResponse.json({ error: 'Fichier non associé à ce contact' }, { status: 403 });
47
38
  }
48
39
 
49
- // Sauvegarder les informations du fichier avant suppression pour l'interaction
50
40
  const fileName = file.fileName;
51
41
  const fileSize = file.fileSize;
52
42
 
53
- // Supprimer le fichier de Google Drive
54
43
  try {
55
- await deleteFileFromDrive(session.user.id, file.googleDriveFileId);
44
+ await deleteFile(BUCKETS.CONTACTS, file.storagePath);
56
45
  } catch (error) {
57
- console.error('Erreur lors de la suppression du fichier de Google Drive:', error);
58
- // On continue quand même pour supprimer l'enregistrement en base
46
+ console.error('Erreur lors de la suppression du fichier du stockage:', error);
59
47
  }
60
48
 
61
- // Supprimer l'enregistrement de la base de données
62
- await prisma.contactFile.delete({
63
- where: { id: fileId },
64
- });
49
+ await prisma.contactFile.delete({ where: { id: fileId } });
65
50
 
66
- // Créer une interaction pour la suppression du fichier
67
51
  try {
68
52
  await logFileDeleted(contactId, fileName, fileSize, session.user.id);
69
- } catch (interactionError: any) {
70
- console.error(
71
- "Erreur lors de la création de l'interaction de suppression:",
72
- interactionError,
73
- );
74
- // On continue même si l'interaction échoue
53
+ } catch (e) {
54
+ console.error('Erreur interaction suppression:', e);
75
55
  }
76
56
 
77
57
  return NextResponse.json({ success: true });
78
- } catch (error: any) {
58
+ } catch (error: unknown) {
79
59
  console.error('Erreur lors de la suppression du fichier:', error);
80
60
  return NextResponse.json(
81
- { error: error.message || 'Erreur lors de la suppression du fichier' },
61
+ {
62
+ error: error instanceof Error ? error.message : 'Erreur lors de la suppression du fichier',
63
+ },
82
64
  { status: 500 },
83
65
  );
84
66
  }
@@ -2,16 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
2
2
  import { auth } from '@/lib/auth';
3
3
  import { prisma } from '@/lib/prisma';
4
4
  import { checkPermission } from '@/lib/check-permission';
5
- import { uploadFileToDrive, getFileInfo } from '@/lib/google-drive';
5
+ import {
6
+ BUCKETS,
7
+ buildContactFilePath,
8
+ createSignedUploadUrl,
9
+ createSignedDownloadUrl,
10
+ } from '@/lib/supabase-storage';
6
11
  import { logFileUploaded, logFileReplaced } from '@/lib/contact-interactions';
7
12
 
8
- // POST /api/contacts/[id]/files - Uploader un fichier
13
+ const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
14
+
15
+ // POST /api/contacts/[id]/files — create-upload-url | finalize
9
16
  export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
10
17
  try {
11
- const session = await auth.api.getSession({
12
- headers: request.headers,
13
- });
14
-
18
+ const session = await auth.api.getSession({ headers: request.headers });
15
19
  if (!session) {
16
20
  return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
17
21
  }
@@ -23,210 +27,132 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
23
27
 
24
28
  const { id: contactId } = await params;
25
29
 
26
- // Vérifier que le contact existe
27
30
  const contact = await prisma.contact.findUnique({
28
31
  where: { id: contactId },
29
- select: {
30
- id: true,
31
- firstName: true,
32
- lastName: true,
33
- },
32
+ select: { id: true },
34
33
  });
35
34
 
36
35
  if (!contact) {
37
36
  return NextResponse.json({ error: 'Contact non trouvé' }, { status: 404 });
38
37
  }
39
38
 
40
- // Vérifier si c'est pour une transaction (via query param)
41
- const { searchParams } = new URL(request.url);
42
- const isForTransaction = searchParams.get('transaction') === 'true';
43
- const isIdentityDocument = searchParams.get('isIdentityDocument') === 'true';
39
+ const body = await request.json();
44
40
 
45
- // Récupérer le compte Google de l'admin (tous les utilisateurs utilisent la config admin)
46
- // Note: uploadFileToDrive utilise déjà getAdminGoogleAccount, donc pas besoin de vérifier ici
41
+ // ── Step 1 : generate a signed upload URL ─────────────────────────
42
+ if (body.action === 'create-upload-url') {
43
+ const { fileName, fileSize, mimeType } = body;
47
44
 
48
- // Récupérer le FormData
49
- const formData = await request.formData();
50
- const file = formData.get('file') as File;
45
+ if (!fileName || typeof fileSize !== 'number' || !mimeType) {
46
+ return NextResponse.json(
47
+ { error: 'Champs requis: fileName, fileSize (number), mimeType' },
48
+ { status: 400 },
49
+ );
50
+ }
51
51
 
52
- if (!file) {
53
- return NextResponse.json({ error: 'Aucun fichier fourni' }, { status: 400 });
54
- }
52
+ if (fileSize > MAX_FILE_SIZE) {
53
+ return NextResponse.json(
54
+ { error: 'Le fichier est trop volumineux. Taille maximale: 100 MB' },
55
+ { status: 400 },
56
+ );
57
+ }
55
58
 
56
- // Vérifier la taille du fichier (max 100MB)
57
- const maxSize = 100 * 1024 * 1024; // 100MB
58
- if (file.size > maxSize) {
59
- return NextResponse.json(
60
- { error: 'Le fichier est trop volumineux. Taille maximale: 100MB' },
61
- { status: 400 },
62
- );
59
+ const storagePath = buildContactFilePath(contactId, fileName);
60
+ const result = await createSignedUploadUrl(BUCKETS.CONTACTS, storagePath);
61
+ return NextResponse.json(result);
63
62
  }
64
63
 
65
- // Nom du contact pour le dossier
66
- const contactName =
67
- `${contact.firstName || ''} ${contact.lastName || ''}`.trim() || `Contact ${contactId}`;
68
-
69
- // Vérifier si un fichier avec le même nom existe déjà pour ce contact
70
- const existingFile = await prisma.contactFile.findFirst({
71
- where: {
72
- contactId,
73
- fileName: file.name,
74
- },
75
- });
76
-
77
- let contactFile;
64
+ // ── Step 2 : finalize after client PUT ────────────────────────────
65
+ if (body.action === 'finalize') {
66
+ const { storagePath, fileName, fileSize, mimeType } = body;
78
67
 
79
- if (existingFile) {
80
- // Si le fichier existe déjà, supprimer l'ancien fichier de Google Drive
81
- try {
82
- const { deleteFileFromDrive } = await import('@/lib/google-drive');
83
- await deleteFileFromDrive(session.user.id, existingFile.googleDriveFileId);
84
- } catch (error) {
85
- console.error("Erreur lors de la suppression de l'ancien fichier:", error);
86
- // On continue même si la suppression échoue
68
+ if (!storagePath || !fileName || typeof fileSize !== 'number' || !mimeType) {
69
+ return NextResponse.json(
70
+ { error: 'Champs requis: storagePath, fileName, fileSize (number), mimeType' },
71
+ { status: 400 },
72
+ );
87
73
  }
88
74
 
89
- // Uploader le nouveau fichier vers Google Drive
90
- const { fileId } = await uploadFileToDrive(
91
- session.user.id,
92
- contactId,
93
- contactName,
94
- file,
95
- isForTransaction,
96
- );
75
+ if (!storagePath.startsWith(`files/${contactId}/`)) {
76
+ return NextResponse.json({ error: 'Chemin de stockage invalide' }, { status: 400 });
77
+ }
97
78
 
98
- // Mettre à jour l'enregistrement existant
99
- contactFile = await prisma.contactFile.update({
100
- where: { id: existingFile.id },
101
- data: {
102
- fileSize: file.size,
103
- mimeType: file.type || 'application/octet-stream',
104
- googleDriveFileId: fileId,
105
- isIdentityDocument,
106
- uploadedById: session.user.id,
107
- updatedAt: new Date(),
108
- },
109
- include: {
110
- uploadedBy: {
111
- select: {
112
- id: true,
113
- name: true,
114
- email: true,
115
- },
116
- },
117
- },
79
+ const existingFile = await prisma.contactFile.findFirst({
80
+ where: { contactId, fileName },
118
81
  });
119
82
 
120
- // Créer une interaction pour le remplacement du fichier
121
- try {
122
- await logFileReplaced(contactId, contactFile.id, file.name, file.size, session.user.id);
123
- } catch (interactionError: any) {
124
- console.error(
125
- "Erreur lors de la création de l'interaction de remplacement:",
126
- interactionError,
127
- );
128
- // On continue même si l'interaction échoue
129
- }
130
- } else {
131
- // Uploader le fichier vers Google Drive
132
- const { fileId } = await uploadFileToDrive(
133
- session.user.id,
134
- contactId,
135
- contactName,
136
- file,
137
- isForTransaction,
138
- );
83
+ let contactFile;
84
+
85
+ if (existingFile) {
86
+ contactFile = await prisma.contactFile.update({
87
+ where: { id: existingFile.id },
88
+ data: {
89
+ fileSize,
90
+ mimeType,
91
+ storagePath,
92
+ uploadedById: session.user.id,
93
+ updatedAt: new Date(),
94
+ },
95
+ include: {
96
+ uploadedBy: { select: { id: true, name: true, email: true } },
97
+ },
98
+ });
139
99
 
140
- // Créer un nouvel enregistrement dans la base de données
141
- contactFile = await prisma.contactFile.create({
142
- data: {
143
- contactId,
144
- fileName: file.name,
145
- fileSize: file.size,
146
- mimeType: file.type || 'application/octet-stream',
147
- googleDriveFileId: fileId,
148
- isIdentityDocument,
149
- uploadedById: session.user.id,
150
- },
151
- include: {
152
- uploadedBy: {
153
- select: {
154
- id: true,
155
- name: true,
156
- email: true,
157
- },
100
+ try {
101
+ await logFileReplaced(contactId, contactFile.id, fileName, fileSize, session.user.id);
102
+ } catch (e) {
103
+ console.error('Erreur interaction remplacement:', e);
104
+ }
105
+ } else {
106
+ contactFile = await prisma.contactFile.create({
107
+ data: {
108
+ contactId,
109
+ fileName,
110
+ fileSize,
111
+ mimeType,
112
+ storagePath,
113
+ uploadedById: session.user.id,
158
114
  },
159
- },
160
- });
115
+ include: {
116
+ uploadedBy: { select: { id: true, name: true, email: true } },
117
+ },
118
+ });
161
119
 
162
- // Créer une interaction pour l'upload du fichier (seulement si c'est un nouveau fichier)
163
- try {
164
- await logFileUploaded(contactId, contactFile.id, file.name, file.size, session.user.id);
165
- } catch (interactionError: any) {
166
- console.error("Erreur lors de la création de l'interaction d'upload:", interactionError);
167
- // On continue même si l'interaction échoue
120
+ try {
121
+ await logFileUploaded(contactId, contactFile.id, fileName, fileSize, session.user.id);
122
+ } catch (e) {
123
+ console.error('Erreur interaction upload:', e);
124
+ }
168
125
  }
169
- }
170
-
171
- // Récupérer le webViewLink pour la réponse
172
- const fileInfo = await getFileInfo(session.user.id, contactFile.googleDriveFileId);
173
- const webViewLink = fileInfo.webViewLink;
174
126
 
175
- // Si c'est pour une transaction, mettre à jour automatiquement idDocumentUrl du contact
176
- if (isForTransaction) {
177
- await prisma.contact.update({
178
- where: { id: contactId },
179
- data: {
180
- idDocumentUrl: webViewLink,
181
- },
127
+ return NextResponse.json({
128
+ id: contactFile.id,
129
+ fileName: contactFile.fileName,
130
+ fileSize: contactFile.fileSize,
131
+ mimeType: contactFile.mimeType,
132
+ storagePath: contactFile.storagePath,
133
+ uploadedBy: contactFile.uploadedBy,
134
+ createdAt: contactFile.createdAt,
135
+ updatedAt: contactFile.updatedAt,
182
136
  });
183
137
  }
184
138
 
185
- return NextResponse.json({
186
- id: contactFile.id,
187
- fileName: contactFile.fileName,
188
- fileSize: contactFile.fileSize,
189
- mimeType: contactFile.mimeType,
190
- isIdentityDocument: contactFile.isIdentityDocument,
191
- webViewLink,
192
- uploadedBy: contactFile.uploadedBy,
193
- createdAt: contactFile.createdAt,
194
- updatedAt: contactFile.updatedAt,
195
- });
196
- } catch (error: any) {
139
+ return NextResponse.json(
140
+ { error: 'Action invalide. Utilisez "create-upload-url" ou "finalize".' },
141
+ { status: 400 },
142
+ );
143
+ } catch (error: unknown) {
197
144
  console.error("Erreur lors de l'upload du fichier:", error);
198
-
199
- // Détecter les erreurs d'authentification Google (token expiré/révoqué)
200
- if (
201
- error.message?.includes('401') ||
202
- error.message?.includes('UNAUTHENTICATED') ||
203
- error.message?.includes('Invalid Credentials') ||
204
- error.message?.includes('invalid_grant')
205
- ) {
206
- return NextResponse.json(
207
- {
208
- error:
209
- '🔒 La connexion Google Drive a expiré. Veuillez demander à un administrateur de reconnecter son compte Google dans Paramètres > Intégrations > Google Drive.',
210
- },
211
- { status: 401 },
212
- );
213
- }
214
-
215
- // Autres erreurs
216
145
  return NextResponse.json(
217
- { error: error.message || "Erreur lors de l'upload du fichier" },
146
+ { error: error instanceof Error ? error.message : "Erreur lors de l'upload du fichier" },
218
147
  { status: 500 },
219
148
  );
220
149
  }
221
150
  }
222
151
 
223
- // GET /api/contacts/[id]/files - Lister les fichiers d'un contact
152
+ // GET /api/contacts/[id]/files lister les fichiers d'un contact
224
153
  export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
225
154
  try {
226
- const session = await auth.api.getSession({
227
- headers: request.headers,
228
- });
229
-
155
+ const session = await auth.api.getSession({ headers: request.headers });
230
156
  if (!session) {
231
157
  return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
232
158
  }
@@ -238,54 +164,26 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
238
164
 
239
165
  const { id: contactId } = await params;
240
166
 
241
- // Vérifier que le contact existe
242
- const contact = await prisma.contact.findUnique({
243
- where: { id: contactId },
244
- });
245
-
167
+ const contact = await prisma.contact.findUnique({ where: { id: contactId } });
246
168
  if (!contact) {
247
169
  return NextResponse.json({ error: 'Contact non trouvé' }, { status: 404 });
248
170
  }
249
171
 
250
- // Récupérer tous les fichiers du contact
251
172
  const files = await prisma.contactFile.findMany({
252
173
  where: { contactId },
253
174
  include: {
254
- uploadedBy: {
255
- select: {
256
- id: true,
257
- name: true,
258
- email: true,
259
- },
260
- },
261
- },
262
- orderBy: {
263
- createdAt: 'desc',
175
+ uploadedBy: { select: { id: true, name: true, email: true } },
264
176
  },
177
+ orderBy: { createdAt: 'desc' },
265
178
  });
266
179
 
267
- // Pour chaque fichier, récupérer le lien de visualisation depuis Google Drive
268
180
  const filesWithLinks = await Promise.all(
269
181
  files.map(async (file) => {
182
+ let downloadUrl: string | null = null;
270
183
  try {
271
- // getFileInfo utilise déjà le compte admin
272
- const fileInfo = await getFileInfo(session.user.id, file.googleDriveFileId);
273
- return {
274
- id: file.id,
275
- fileName: file.fileName,
276
- fileSize: file.fileSize,
277
- mimeType: file.mimeType,
278
- isIdentityDocument: file.isIdentityDocument,
279
- webViewLink: fileInfo.webViewLink,
280
- uploadedBy: file.uploadedBy,
281
- createdAt: file.createdAt,
282
- updatedAt: file.updatedAt,
283
- };
284
- } catch (error) {
285
- console.error(
286
- `Erreur lors de la récupération du lien pour le fichier ${file.id}:`,
287
- error,
288
- );
184
+ downloadUrl = await createSignedDownloadUrl(BUCKETS.CONTACTS, file.storagePath);
185
+ } catch (err) {
186
+ console.error(`Erreur signed URL pour fichier ${file.id}:`, err);
289
187
  }
290
188
 
291
189
  return {
@@ -293,8 +191,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
293
191
  fileName: file.fileName,
294
192
  fileSize: file.fileSize,
295
193
  mimeType: file.mimeType,
296
- isIdentityDocument: file.isIdentityDocument,
297
- webViewLink: `https://drive.google.com/file/d/${file.googleDriveFileId}/view`,
194
+ downloadUrl,
298
195
  uploadedBy: file.uploadedBy,
299
196
  createdAt: file.createdAt,
300
197
  updatedAt: file.updatedAt,
@@ -303,10 +200,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
303
200
  );
304
201
 
305
202
  return NextResponse.json(filesWithLinks);
306
- } catch (error: any) {
203
+ } catch (error: unknown) {
307
204
  console.error('Erreur lors de la récupération des fichiers:', error);
308
205
  return NextResponse.json(
309
- { error: error.message || 'Erreur lors de la récupération des fichiers' },
206
+ {
207
+ error:
208
+ error instanceof Error ? error.message : 'Erreur lors de la récupération des fichiers',
209
+ },
310
210
  { status: 500 },
311
211
  );
312
212
  }