create-crm-tmp 1.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 (187) hide show
  1. package/bin/create-crm-tmp.js +93 -0
  2. package/package.json +25 -0
  3. package/template/.prettierignore +33 -0
  4. package/template/.prettierrc.json +25 -0
  5. package/template/README.md +173 -0
  6. package/template/eslint.config.mjs +18 -0
  7. package/template/exemple-contacts.csv +11 -0
  8. package/template/next.config.ts +8 -0
  9. package/template/package.json +64 -0
  10. package/template/postcss.config.mjs +7 -0
  11. package/template/prisma/migrations/20251126144728_init/migration.sql +78 -0
  12. package/template/prisma/migrations/20251126155204_add_user_roles/migration.sql +5 -0
  13. package/template/prisma/migrations/20251128095126_add_company_info/migration.sql +19 -0
  14. package/template/prisma/migrations/20251128123321_add_smtp_config/migration.sql +22 -0
  15. package/template/prisma/migrations/20251128132303_add_status/migration.sql +23 -0
  16. package/template/prisma/migrations/20251201102207_add_user_active/migration.sql +75 -0
  17. package/template/prisma/migrations/20251201105507_add_email_signature/migration.sql +2 -0
  18. package/template/prisma/migrations/20251201151122_add_tasks/migration.sql +45 -0
  19. package/template/prisma/migrations/20251202111854_add_task_reminder/migration.sql +2 -0
  20. package/template/prisma/migrations/20251202135859_add_google_meet_integration/migration.sql +27 -0
  21. package/template/prisma/migrations/20251203103317_add_meta_lead_integration/migration.sql +20 -0
  22. package/template/prisma/migrations/20251203104002_add_google_ads_integration/migration.sql +18 -0
  23. package/template/prisma/migrations/20251203112122_add_google_sheet_integration/migration.sql +32 -0
  24. package/template/prisma/migrations/20251203153853_allow_multiple_integration_configs/migration.sql +20 -0
  25. package/template/prisma/migrations/20251205141705_update_user_roles/migration.sql +12 -0
  26. package/template/prisma/migrations/20251205150000_add_commercial_and_telepro_assignment/migration.sql +21 -0
  27. package/template/prisma/migrations/20251205160000_add_interaction_logging/migration.sql +11 -0
  28. package/template/prisma/migrations/20251208090314_add_automatic_interaction_types/migration.sql +12 -0
  29. package/template/prisma/migrations/20251208094843_mg/migration.sql +14 -0
  30. package/template/prisma/migrations/20251208100000_add_company_support/migration.sql +14 -0
  31. package/template/prisma/migrations/20251208110000_add_templates/migration.sql +26 -0
  32. package/template/prisma/migrations/20251208141304_add_video_conference_task_type/migration.sql +2 -0
  33. package/template/prisma/migrations/20251209104759_add_internal_note_to_task/migration.sql +2 -0
  34. package/template/prisma/migrations/20251209134803_add_company_field/migration.sql +2 -0
  35. package/template/prisma/migrations/20251209150000_rename_company_to_company_name/migration.sql +3 -0
  36. package/template/prisma/migrations/20251209150016_add_email_tracking/migration.sql +21 -0
  37. package/template/prisma/migrations/20251209155908_add_notify_contact_to_task/migration.sql +2 -0
  38. package/template/prisma/migrations/20251210110019_add_appointment_types/migration.sql +10 -0
  39. package/template/prisma/migrations/20251210113928_add_contact_files/migration.sql +26 -0
  40. package/template/prisma/migrations/20251212132339_add_custom_roles/migration.sql +24 -0
  41. package/template/prisma/migrations/20251215104448_add_file_interaction_types/migration.sql +11 -0
  42. package/template/prisma/migrations/20251215145616_add_closing_reasons/migration.sql +12 -0
  43. package/template/prisma/migrations/20251216140850_add_log_users/migration.sql +25 -0
  44. package/template/prisma/migrations/20251216151000_rename_perdu_to_ferme/migration.sql +8 -0
  45. package/template/prisma/migrations/20251216162318_add_column_mappings_to_google_sheet/migration.sql +2 -0
  46. package/template/prisma/migrations/20251216185127_add_workflows/migration.sql +80 -0
  47. package/template/prisma/migrations/20251216192237_add_scheduled_workflow_actions/migration.sql +32 -0
  48. package/template/prisma/migrations/migration_lock.toml +3 -0
  49. package/template/prisma/schema.prisma +582 -0
  50. package/template/prisma.config.ts +14 -0
  51. package/template/src/app/(auth)/invite/[token]/page.tsx +200 -0
  52. package/template/src/app/(auth)/layout.tsx +3 -0
  53. package/template/src/app/(auth)/reset-password/complete/page.tsx +213 -0
  54. package/template/src/app/(auth)/reset-password/page.tsx +146 -0
  55. package/template/src/app/(auth)/reset-password/verify/page.tsx +183 -0
  56. package/template/src/app/(auth)/signin/page.tsx +166 -0
  57. package/template/src/app/(dashboard)/agenda/page.tsx +3051 -0
  58. package/template/src/app/(dashboard)/automatisation/[id]/page.tsx +24 -0
  59. package/template/src/app/(dashboard)/automatisation/_components/workflow-editor.tsx +905 -0
  60. package/template/src/app/(dashboard)/automatisation/new/page.tsx +20 -0
  61. package/template/src/app/(dashboard)/automatisation/page.tsx +337 -0
  62. package/template/src/app/(dashboard)/closing/page.tsx +1052 -0
  63. package/template/src/app/(dashboard)/contacts/[id]/page.tsx +6028 -0
  64. package/template/src/app/(dashboard)/contacts/page.tsx +3713 -0
  65. package/template/src/app/(dashboard)/dashboard/page.tsx +186 -0
  66. package/template/src/app/(dashboard)/layout.tsx +30 -0
  67. package/template/src/app/(dashboard)/settings/page.tsx +4070 -0
  68. package/template/src/app/(dashboard)/templates/page.tsx +567 -0
  69. package/template/src/app/(dashboard)/users/list/page.tsx +507 -0
  70. package/template/src/app/(dashboard)/users/page.tsx +457 -0
  71. package/template/src/app/(dashboard)/users/permissions/page.tsx +181 -0
  72. package/template/src/app/(dashboard)/users/roles/page.tsx +434 -0
  73. package/template/src/app/api/audit-logs/route.ts +57 -0
  74. package/template/src/app/api/auth/[...all]/route.ts +4 -0
  75. package/template/src/app/api/auth/check-active/route.ts +31 -0
  76. package/template/src/app/api/auth/google/callback/route.ts +94 -0
  77. package/template/src/app/api/auth/google/disconnect/route.ts +32 -0
  78. package/template/src/app/api/auth/google/route.ts +34 -0
  79. package/template/src/app/api/auth/google/status/route.ts +32 -0
  80. package/template/src/app/api/closing-reasons/route.ts +27 -0
  81. package/template/src/app/api/contacts/[id]/files/[fileId]/route.ts +94 -0
  82. package/template/src/app/api/contacts/[id]/files/route.ts +269 -0
  83. package/template/src/app/api/contacts/[id]/interactions/[interactionId]/route.ts +91 -0
  84. package/template/src/app/api/contacts/[id]/interactions/route.ts +103 -0
  85. package/template/src/app/api/contacts/[id]/meet/route.ts +296 -0
  86. package/template/src/app/api/contacts/[id]/route.ts +322 -0
  87. package/template/src/app/api/contacts/[id]/send-email/route.ts +254 -0
  88. package/template/src/app/api/contacts/export/route.ts +270 -0
  89. package/template/src/app/api/contacts/import/route.ts +381 -0
  90. package/template/src/app/api/contacts/route.ts +283 -0
  91. package/template/src/app/api/dashboard/stats/route.ts +299 -0
  92. package/template/src/app/api/email/track/[id]/route.ts +68 -0
  93. package/template/src/app/api/integrations/google-sheet/sync/route.ts +526 -0
  94. package/template/src/app/api/invite/complete/route.ts +88 -0
  95. package/template/src/app/api/invite/validate/route.ts +55 -0
  96. package/template/src/app/api/reminders/route.ts +95 -0
  97. package/template/src/app/api/reset-password/complete/route.ts +73 -0
  98. package/template/src/app/api/reset-password/request/route.ts +84 -0
  99. package/template/src/app/api/reset-password/validate/route.ts +49 -0
  100. package/template/src/app/api/reset-password/verify/route.ts +74 -0
  101. package/template/src/app/api/roles/[id]/route.ts +183 -0
  102. package/template/src/app/api/roles/route.ts +140 -0
  103. package/template/src/app/api/send/route.ts +282 -0
  104. package/template/src/app/api/settings/change-password/route.ts +95 -0
  105. package/template/src/app/api/settings/closing-reasons/[id]/route.ts +84 -0
  106. package/template/src/app/api/settings/closing-reasons/route.ts +74 -0
  107. package/template/src/app/api/settings/company/route.ts +121 -0
  108. package/template/src/app/api/settings/google-ads/[id]/route.ts +117 -0
  109. package/template/src/app/api/settings/google-ads/route.ts +122 -0
  110. package/template/src/app/api/settings/google-sheet/[id]/route.ts +230 -0
  111. package/template/src/app/api/settings/google-sheet/auto-map/route.ts +196 -0
  112. package/template/src/app/api/settings/google-sheet/route.ts +254 -0
  113. package/template/src/app/api/settings/meta-leads/[id]/route.ts +123 -0
  114. package/template/src/app/api/settings/meta-leads/route.ts +132 -0
  115. package/template/src/app/api/settings/profile/route.ts +42 -0
  116. package/template/src/app/api/settings/smtp/route.ts +130 -0
  117. package/template/src/app/api/settings/smtp/test/route.ts +121 -0
  118. package/template/src/app/api/settings/statuses/[id]/route.ts +101 -0
  119. package/template/src/app/api/settings/statuses/route.ts +83 -0
  120. package/template/src/app/api/statuses/route.ts +25 -0
  121. package/template/src/app/api/tasks/[id]/attendees/route.ts +76 -0
  122. package/template/src/app/api/tasks/[id]/route.ts +728 -0
  123. package/template/src/app/api/tasks/meet/route.ts +240 -0
  124. package/template/src/app/api/tasks/route.ts +417 -0
  125. package/template/src/app/api/templates/[id]/route.ts +140 -0
  126. package/template/src/app/api/templates/route.ts +91 -0
  127. package/template/src/app/api/users/[id]/route.ts +168 -0
  128. package/template/src/app/api/users/list/route.ts +45 -0
  129. package/template/src/app/api/users/me/route.ts +48 -0
  130. package/template/src/app/api/users/route.ts +250 -0
  131. package/template/src/app/api/webhooks/google-ads/route.ts +208 -0
  132. package/template/src/app/api/webhooks/meta-leads/route.ts +258 -0
  133. package/template/src/app/api/workflows/[id]/route.ts +192 -0
  134. package/template/src/app/api/workflows/process/route.ts +293 -0
  135. package/template/src/app/api/workflows/route.ts +124 -0
  136. package/template/src/app/favicon.ico +0 -0
  137. package/template/src/app/globals.css +1416 -0
  138. package/template/src/app/layout.tsx +31 -0
  139. package/template/src/app/page.tsx +32 -0
  140. package/template/src/components/dashboard/activity-chart.tsx +67 -0
  141. package/template/src/components/dashboard/contacts-chart.tsx +63 -0
  142. package/template/src/components/dashboard/recent-activity.tsx +164 -0
  143. package/template/src/components/dashboard/sales-analytics-chart.tsx +81 -0
  144. package/template/src/components/dashboard/stat-card.tsx +61 -0
  145. package/template/src/components/dashboard/status-distribution-chart.tsx +45 -0
  146. package/template/src/components/dashboard/tasks-pie-chart.tsx +88 -0
  147. package/template/src/components/dashboard/top-contacts-list.tsx +129 -0
  148. package/template/src/components/dashboard/upcoming-tasks-list.tsx +126 -0
  149. package/template/src/components/editor.tsx +856 -0
  150. package/template/src/components/email-template.tsx +35 -0
  151. package/template/src/components/header.tsx +320 -0
  152. package/template/src/components/invitation-email-template.tsx +79 -0
  153. package/template/src/components/meet-cancellation-email-template.tsx +120 -0
  154. package/template/src/components/meet-confirmation-email-template.tsx +156 -0
  155. package/template/src/components/meet-update-email-template.tsx +209 -0
  156. package/template/src/components/page-header.tsx +61 -0
  157. package/template/src/components/reset-password-email-template.tsx +79 -0
  158. package/template/src/components/sidebar.tsx +294 -0
  159. package/template/src/components/skeleton.tsx +380 -0
  160. package/template/src/components/ui/commands.tsx +396 -0
  161. package/template/src/components/ui/components.tsx +150 -0
  162. package/template/src/components/ui/theme.tsx +5 -0
  163. package/template/src/components/view-as-banner.tsx +45 -0
  164. package/template/src/components/view-as-modal.tsx +186 -0
  165. package/template/src/contexts/mobile-menu-context.tsx +31 -0
  166. package/template/src/contexts/sidebar-context.tsx +107 -0
  167. package/template/src/contexts/task-reminder-context.tsx +239 -0
  168. package/template/src/contexts/view-as-context.tsx +84 -0
  169. package/template/src/hooks/use-user-role.ts +82 -0
  170. package/template/src/lib/audit-log.ts +45 -0
  171. package/template/src/lib/auth-client.ts +16 -0
  172. package/template/src/lib/auth.ts +35 -0
  173. package/template/src/lib/check-permission.ts +193 -0
  174. package/template/src/lib/contact-duplicate.ts +112 -0
  175. package/template/src/lib/contact-interactions.ts +371 -0
  176. package/template/src/lib/encryption.ts +99 -0
  177. package/template/src/lib/google-calendar.ts +300 -0
  178. package/template/src/lib/google-drive.ts +372 -0
  179. package/template/src/lib/permissions.ts +412 -0
  180. package/template/src/lib/prisma.ts +32 -0
  181. package/template/src/lib/roles.ts +120 -0
  182. package/template/src/lib/template-variables.ts +76 -0
  183. package/template/src/lib/utils.ts +46 -0
  184. package/template/src/lib/workflow-executor.ts +482 -0
  185. package/template/src/proxy.ts +91 -0
  186. package/template/tsconfig.json +34 -0
  187. package/template/vercel.json +8 -0
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Utilitaires pour gérer l'authentification et les appels à Google Calendar API
3
+ */
4
+
5
+ interface GoogleTokenResponse {
6
+ access_token: string;
7
+ refresh_token?: string;
8
+ expires_in: number;
9
+ token_type: string;
10
+ }
11
+
12
+ interface GoogleCalendarEvent {
13
+ id?: string;
14
+ summary: string;
15
+ description?: string;
16
+ start: {
17
+ dateTime: string;
18
+ timeZone?: string;
19
+ };
20
+ end: {
21
+ dateTime: string;
22
+ timeZone?: string;
23
+ };
24
+ attendees?: Array<{ email: string }>;
25
+ conferenceData?: {
26
+ createRequest: {
27
+ requestId: string;
28
+ conferenceSolutionKey: {
29
+ type: 'hangoutsMeet';
30
+ };
31
+ };
32
+ };
33
+ conferenceDataVersion?: number;
34
+ }
35
+
36
+ interface GoogleCalendarEventResponse {
37
+ id: string;
38
+ summary: string;
39
+ description?: string;
40
+ start: {
41
+ dateTime: string;
42
+ timeZone?: string;
43
+ };
44
+ end: {
45
+ dateTime: string;
46
+ timeZone?: string;
47
+ };
48
+ attendees?: Array<{ email: string }>;
49
+ conferenceData?: {
50
+ createRequest?: {
51
+ requestId: string;
52
+ conferenceSolutionKey: {
53
+ type: 'hangoutsMeet';
54
+ };
55
+ };
56
+ entryPoints?: Array<{
57
+ entryPointType: string;
58
+ uri: string;
59
+ }>;
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Échange un code d'autorisation contre des tokens
65
+ */
66
+ export async function exchangeGoogleCodeForTokens(
67
+ code: string,
68
+ redirectUri: string,
69
+ ): Promise<GoogleTokenResponse> {
70
+ const clientId = process.env.GOOGLE_CLIENT_ID;
71
+ const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
72
+
73
+ if (!clientId || !clientSecret) {
74
+ throw new Error('GOOGLE_CLIENT_ID et GOOGLE_CLIENT_SECRET doivent être configurés');
75
+ }
76
+
77
+ const response = await fetch('https://oauth2.googleapis.com/token', {
78
+ method: 'POST',
79
+ headers: {
80
+ 'Content-Type': 'application/x-www-form-urlencoded',
81
+ },
82
+ body: new URLSearchParams({
83
+ code,
84
+ client_id: clientId,
85
+ client_secret: clientSecret,
86
+ redirect_uri: redirectUri,
87
+ grant_type: 'authorization_code',
88
+ }),
89
+ });
90
+
91
+ if (!response.ok) {
92
+ const error = await response.text();
93
+ throw new Error(`Erreur lors de l'échange du code: ${error}`);
94
+ }
95
+
96
+ return response.json();
97
+ }
98
+
99
+ /**
100
+ * Erreur personnalisée pour les tokens Google invalides
101
+ */
102
+ export class GoogleTokenError extends Error {
103
+ constructor(
104
+ message: string,
105
+ public code?: string,
106
+ public isRevoked: boolean = false,
107
+ ) {
108
+ super(message);
109
+ this.name = 'GoogleTokenError';
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Rafraîchit un access token expiré
115
+ */
116
+ export async function refreshGoogleToken(refreshToken: string): Promise<GoogleTokenResponse> {
117
+ const clientId = process.env.GOOGLE_CLIENT_ID;
118
+ const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
119
+
120
+ if (!clientId || !clientSecret) {
121
+ throw new Error('GOOGLE_CLIENT_ID et GOOGLE_CLIENT_SECRET doivent être configurés');
122
+ }
123
+
124
+ const response = await fetch('https://oauth2.googleapis.com/token', {
125
+ method: 'POST',
126
+ headers: {
127
+ 'Content-Type': 'application/x-www-form-urlencoded',
128
+ },
129
+ body: new URLSearchParams({
130
+ refresh_token: refreshToken,
131
+ client_id: clientId,
132
+ client_secret: clientSecret,
133
+ grant_type: 'refresh_token',
134
+ }),
135
+ });
136
+
137
+ if (!response.ok) {
138
+ let errorData: any;
139
+ try {
140
+ errorData = await response.json();
141
+ } catch {
142
+ const errorText = await response.text();
143
+ throw new Error(`Erreur lors du rafraîchissement du token: ${errorText}`);
144
+ }
145
+
146
+ // Détecter si le token a été révoqué ou a expiré
147
+ if (errorData.error === 'invalid_grant') {
148
+ throw new GoogleTokenError(
149
+ 'Le token Google a expiré ou a été révoqué. Veuillez reconnecter votre compte Google dans les paramètres.',
150
+ 'invalid_grant',
151
+ true,
152
+ );
153
+ }
154
+
155
+ throw new GoogleTokenError(
156
+ `Erreur lors du rafraîchissement du token: ${errorData.error_description || errorData.error || JSON.stringify(errorData)}`,
157
+ errorData.error,
158
+ );
159
+ }
160
+
161
+ return response.json();
162
+ }
163
+
164
+ /**
165
+ * Obtient un access token valide (rafraîchit si nécessaire)
166
+ */
167
+ export async function getValidAccessToken(
168
+ accessToken: string,
169
+ refreshToken: string,
170
+ tokenExpiresAt: Date,
171
+ ): Promise<string> {
172
+ // Si le token expire dans moins de 5 minutes, on le rafraîchit
173
+ const now = new Date();
174
+ const expiresAt = new Date(tokenExpiresAt);
175
+ const fiveMinutesFromNow = new Date(now.getTime() + 5 * 60 * 1000);
176
+
177
+ if (expiresAt <= fiveMinutesFromNow) {
178
+ const newTokens = await refreshGoogleToken(refreshToken);
179
+ return newTokens.access_token;
180
+ }
181
+
182
+ return accessToken;
183
+ }
184
+
185
+ /**
186
+ * Crée un évènement Google Calendar avec Google Meet
187
+ */
188
+ export async function createGoogleCalendarEvent(
189
+ accessToken: string,
190
+ event: GoogleCalendarEvent,
191
+ ): Promise<GoogleCalendarEventResponse> {
192
+ const response = await fetch(
193
+ 'https://www.googleapis.com/calendar/v3/calendars/primary/events?conferenceDataVersion=1',
194
+ {
195
+ method: 'POST',
196
+ headers: {
197
+ Authorization: `Bearer ${accessToken}`,
198
+ 'Content-Type': 'application/json',
199
+ },
200
+ body: JSON.stringify(event),
201
+ },
202
+ );
203
+
204
+ if (!response.ok) {
205
+ const error = await response.text();
206
+ throw new Error(`Erreur lors de la création de l'évènement: ${error}`);
207
+ }
208
+
209
+ return response.json();
210
+ }
211
+
212
+ /**
213
+ * Met à jour un évènement Google Calendar
214
+ */
215
+ export async function updateGoogleCalendarEvent(
216
+ accessToken: string,
217
+ eventId: string,
218
+ event: Partial<GoogleCalendarEvent>,
219
+ ): Promise<GoogleCalendarEventResponse> {
220
+ const response = await fetch(
221
+ `https://www.googleapis.com/calendar/v3/calendars/primary/events/${eventId}?conferenceDataVersion=1`,
222
+ {
223
+ method: 'PATCH',
224
+ headers: {
225
+ Authorization: `Bearer ${accessToken}`,
226
+ 'Content-Type': 'application/json',
227
+ },
228
+ body: JSON.stringify(event),
229
+ },
230
+ );
231
+
232
+ if (!response.ok) {
233
+ const error = await response.text();
234
+ throw new Error(`Erreur lors de la mise à jour de l'évènement: ${error}`);
235
+ }
236
+
237
+ return response.json();
238
+ }
239
+
240
+ /**
241
+ * Supprime un évènement Google Calendar
242
+ */
243
+ export async function deleteGoogleCalendarEvent(
244
+ accessToken: string,
245
+ eventId: string,
246
+ ): Promise<void> {
247
+ const response = await fetch(
248
+ `https://www.googleapis.com/calendar/v3/calendars/primary/events/${eventId}`,
249
+ {
250
+ method: 'DELETE',
251
+ headers: {
252
+ Authorization: `Bearer ${accessToken}`,
253
+ },
254
+ },
255
+ );
256
+
257
+ if (!response.ok) {
258
+ const error = await response.text();
259
+ throw new Error(`Erreur lors de la suppression de l'évènement: ${error}`);
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Extrait le lien Google Meet depuis la réponse de l'API
265
+ */
266
+ export function extractMeetLink(eventResponse: GoogleCalendarEventResponse): string | null {
267
+ if (eventResponse.conferenceData?.entryPoints) {
268
+ const meetEntry = eventResponse.conferenceData.entryPoints.find(
269
+ (entry) => entry.entryPointType === 'video',
270
+ );
271
+ return meetEntry?.uri || null;
272
+ }
273
+ return null;
274
+ }
275
+
276
+ /**
277
+ * Récupère un évènement Google Calendar
278
+ */
279
+ export async function getGoogleCalendarEvent(
280
+ accessToken: string,
281
+ eventId: string,
282
+ ): Promise<GoogleCalendarEventResponse> {
283
+ const response = await fetch(
284
+ `https://www.googleapis.com/calendar/v3/calendars/primary/events/${eventId}`,
285
+ {
286
+ method: 'GET',
287
+ headers: {
288
+ Authorization: `Bearer ${accessToken}`,
289
+ 'Content-Type': 'application/json',
290
+ },
291
+ },
292
+ );
293
+
294
+ if (!response.ok) {
295
+ const error = await response.text();
296
+ throw new Error(`Erreur lors de la récupération de l'évènement: ${error}`);
297
+ }
298
+
299
+ return response.json();
300
+ }
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Utilitaires pour gérer les fichiers avec Google Drive API
3
+ */
4
+
5
+ import { prisma } from '@/lib/prisma';
6
+ import { getValidAccessToken } from './google-calendar';
7
+
8
+ // Nom de l'application (peut être configuré via variable d'environnement)
9
+ const APP_NAME = process.env.APP_NAME || 'CRM Template';
10
+
11
+ /**
12
+ * Crée ou récupère un dossier dans Google Drive
13
+ */
14
+ async function getOrCreateFolder(
15
+ accessToken: string,
16
+ folderName: string,
17
+ parentId?: string,
18
+ ): Promise<string> {
19
+ // Construire la requête de recherche
20
+ // Important: Ne pas encoder le nom dans les guillemets, mais échapper les guillemets simples dans le nom
21
+ // Google Drive API attend le nom tel quel, pas encodé
22
+ const escapedName = folderName.replace(/'/g, "\\'");
23
+ let query = `name='${escapedName}' and mimeType='application/vnd.google-apps.folder' and trashed=false`;
24
+ if (parentId) {
25
+ query += ` and '${parentId}' in parents`;
26
+ } else {
27
+ // Pour la racine, on cherche les dossiers qui n'ont pas de parents (ou qui ont 'root' comme parent)
28
+ query += ` and 'root' in parents`;
29
+ }
30
+
31
+ // Chercher si le dossier existe déjà
32
+ const searchUrl = `https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id,name,parents)&pageSize=10`;
33
+ const searchResponse = await fetch(searchUrl, {
34
+ headers: {
35
+ Authorization: `Bearer ${accessToken}`,
36
+ },
37
+ });
38
+
39
+ if (!searchResponse.ok) {
40
+ const errorText = await searchResponse.text();
41
+ throw new Error(`Erreur lors de la recherche du dossier: ${errorText}`);
42
+ }
43
+
44
+ const searchData = await searchResponse.json();
45
+
46
+ // Si le dossier existe, vérifier qu'il est bien dans le bon parent
47
+ if (searchData.files && searchData.files.length > 0) {
48
+ // Si on cherche à la racine, vérifier que le dossier est bien à la racine
49
+ if (!parentId) {
50
+ // Vérifier que le dossier est bien à la racine (parents contient 'root' ou est vide)
51
+ const rootFolder = searchData.files.find((file: any) => {
52
+ if (!file.parents || file.parents.length === 0) return true;
53
+ // Vérifier si le dossier est directement à la racine
54
+ return file.parents.length === 1 && file.parents[0] === 'root';
55
+ });
56
+
57
+ if (rootFolder) {
58
+ return rootFolder.id;
59
+ }
60
+
61
+ // Si aucun n'est exactement à la racine, prendre le premier
62
+ return searchData.files[0].id;
63
+ }
64
+
65
+ // Si on cherche dans un parent spécifique, vérifier que le dossier est bien dans ce parent
66
+ const matchingFolder = searchData.files.find(
67
+ (file: any) => file.parents && file.parents.includes(parentId),
68
+ );
69
+
70
+ if (matchingFolder) {
71
+ return matchingFolder.id;
72
+ }
73
+
74
+ // Si aucun ne correspond exactement, prendre le premier (cas de migration)
75
+ return searchData.files[0].id;
76
+ }
77
+
78
+ // Sinon, créer le dossier
79
+ const folderData: any = {
80
+ name: folderName,
81
+ mimeType: 'application/vnd.google-apps.folder',
82
+ };
83
+
84
+ if (parentId) {
85
+ folderData.parents = [parentId];
86
+ } else {
87
+ // Pour la racine, on spécifie explicitement 'root' comme parent
88
+ folderData.parents = ['root'];
89
+ }
90
+
91
+ const createResponse = await fetch('https://www.googleapis.com/drive/v3/files', {
92
+ method: 'POST',
93
+ headers: {
94
+ Authorization: `Bearer ${accessToken}`,
95
+ 'Content-Type': 'application/json',
96
+ },
97
+ body: JSON.stringify(folderData),
98
+ });
99
+
100
+ if (!createResponse.ok) {
101
+ const error = await createResponse.json();
102
+ throw new Error(`Erreur lors de la création du dossier: ${JSON.stringify(error)}`);
103
+ }
104
+
105
+ const createdData = await createResponse.json();
106
+
107
+ // Configurer les permissions pour rendre le dossier accessible avec le lien
108
+ try {
109
+ await setFilePublicWithLink(accessToken, createdData.id);
110
+ } catch (permError) {
111
+ console.error('Erreur lors de la configuration des permissions du dossier:', permError);
112
+ // On continue même si la configuration des permissions échoue
113
+ }
114
+
115
+ return createdData.id;
116
+ }
117
+
118
+ /**
119
+ * Configure les permissions d'un fichier/dossier pour le rendre accessible avec le lien
120
+ * Type: 'anyone' avec rôle 'reader' = accessible à quiconque possède le lien
121
+ */
122
+ async function setFilePublicWithLink(accessToken: string, fileId: string): Promise<void> {
123
+ const permissionResponse = await fetch(
124
+ `https://www.googleapis.com/drive/v3/files/${fileId}/permissions`,
125
+ {
126
+ method: 'POST',
127
+ headers: {
128
+ Authorization: `Bearer ${accessToken}`,
129
+ 'Content-Type': 'application/json',
130
+ },
131
+ body: JSON.stringify({
132
+ type: 'anyone',
133
+ role: 'reader',
134
+ }),
135
+ },
136
+ );
137
+
138
+ if (!permissionResponse.ok) {
139
+ const error = await permissionResponse.json();
140
+ throw new Error(`Erreur lors de la configuration des permissions: ${JSON.stringify(error)}`);
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Crée un dossier dans Google Drive pour un contact
146
+ * Structure: CRM Template > Contacts > Contact - [Nom]
147
+ * Retourne l'ID du dossier créé ou existant
148
+ */
149
+ export async function getOrCreateContactFolder(
150
+ userId: string,
151
+ contactId: string,
152
+ contactName: string,
153
+ ): Promise<string> {
154
+ const googleAccount = await prisma.userGoogleAccount.findUnique({
155
+ where: { userId },
156
+ });
157
+
158
+ if (!googleAccount) {
159
+ throw new Error('Aucun compte Google connecté');
160
+ }
161
+
162
+ const accessToken = await getValidAccessToken(
163
+ googleAccount.accessToken,
164
+ googleAccount.refreshToken,
165
+ googleAccount.tokenExpiresAt,
166
+ );
167
+
168
+ // Mettre à jour le token si nécessaire
169
+ if (accessToken !== googleAccount.accessToken) {
170
+ const tokenExpiresAt = new Date();
171
+ tokenExpiresAt.setSeconds(tokenExpiresAt.getSeconds() + 3600);
172
+ await prisma.userGoogleAccount.update({
173
+ where: { userId },
174
+ data: {
175
+ accessToken,
176
+ tokenExpiresAt,
177
+ },
178
+ });
179
+ }
180
+
181
+ // 1. Créer ou récupérer le dossier racine "CRM Template"
182
+ const appFolderId = await getOrCreateFolder(accessToken, APP_NAME);
183
+
184
+ // 2. Créer ou récupérer le dossier "Contacts" dans "CRM Template"
185
+ const contactsFolderId = await getOrCreateFolder(accessToken, 'Contacts', appFolderId);
186
+
187
+ // 3. Créer ou récupérer le dossier du contact dans "Contacts"
188
+ const contactFolderName = `Contact - ${contactName || contactId}`;
189
+ const contactFolderId = await getOrCreateFolder(accessToken, contactFolderName, contactsFolderId);
190
+
191
+ return contactFolderId;
192
+ }
193
+
194
+ /**
195
+ * Upload un fichier vers Google Drive dans le dossier du contact
196
+ */
197
+ export async function uploadFileToDrive(
198
+ userId: string,
199
+ contactId: string,
200
+ contactName: string,
201
+ file: File,
202
+ ): Promise<{ fileId: string; webViewLink: string }> {
203
+ const folderId = await getOrCreateContactFolder(userId, contactId, contactName);
204
+
205
+ const googleAccount = await prisma.userGoogleAccount.findUnique({
206
+ where: { userId },
207
+ });
208
+
209
+ if (!googleAccount) {
210
+ throw new Error('Aucun compte Google connecté');
211
+ }
212
+
213
+ const accessToken = await getValidAccessToken(
214
+ googleAccount.accessToken,
215
+ googleAccount.refreshToken,
216
+ googleAccount.tokenExpiresAt,
217
+ );
218
+
219
+ // Mettre à jour le token si nécessaire
220
+ if (accessToken !== googleAccount.accessToken) {
221
+ const tokenExpiresAt = new Date();
222
+ tokenExpiresAt.setSeconds(tokenExpiresAt.getSeconds() + 3600);
223
+ await prisma.userGoogleAccount.update({
224
+ where: { userId },
225
+ data: {
226
+ accessToken,
227
+ tokenExpiresAt,
228
+ },
229
+ });
230
+ }
231
+
232
+ // Vérifier si un fichier avec le même nom existe déjà dans le dossier
233
+ const searchQuery = `name='${encodeURIComponent(file.name)}' and '${folderId}' in parents and trashed=false`;
234
+ const searchResponse = await fetch(
235
+ `https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(searchQuery)}&fields=files(id,name,webViewLink)`,
236
+ {
237
+ headers: {
238
+ Authorization: `Bearer ${accessToken}`,
239
+ },
240
+ },
241
+ );
242
+
243
+ if (searchResponse.ok) {
244
+ const searchData = await searchResponse.json();
245
+ // Si un fichier avec le même nom existe déjà, le supprimer avant d'uploader le nouveau
246
+ if (searchData.files && searchData.files.length > 0) {
247
+ for (const existingFile of searchData.files) {
248
+ try {
249
+ await fetch(`https://www.googleapis.com/drive/v3/files/${existingFile.id}`, {
250
+ method: 'DELETE',
251
+ headers: {
252
+ Authorization: `Bearer ${accessToken}`,
253
+ },
254
+ });
255
+ } catch (error) {
256
+ // Ignorer l'erreur de suppression du fichier existant
257
+ }
258
+ }
259
+ }
260
+ }
261
+
262
+ // Créer les métadonnées du fichier
263
+ const metadata = {
264
+ name: file.name,
265
+ parents: [folderId],
266
+ };
267
+
268
+ // Créer le FormData pour l'upload multipart
269
+ const formData = new FormData();
270
+ formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
271
+ formData.append('file', file);
272
+
273
+ // Upload le fichier
274
+ const uploadResponse = await fetch(
275
+ 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,webViewLink',
276
+ {
277
+ method: 'POST',
278
+ headers: {
279
+ Authorization: `Bearer ${accessToken}`,
280
+ },
281
+ body: formData,
282
+ },
283
+ );
284
+
285
+ if (!uploadResponse.ok) {
286
+ const error = await uploadResponse.json();
287
+ throw new Error(`Erreur lors de l'upload: ${JSON.stringify(error)}`);
288
+ }
289
+
290
+ const fileData = await uploadResponse.json();
291
+
292
+ // Configurer les permissions pour rendre le fichier accessible avec le lien
293
+ try {
294
+ await setFilePublicWithLink(accessToken, fileData.id);
295
+ } catch (permError) {
296
+ console.error('Erreur lors de la configuration des permissions du fichier:', permError);
297
+ // On continue même si la configuration des permissions échoue
298
+ }
299
+
300
+ return {
301
+ fileId: fileData.id,
302
+ webViewLink: fileData.webViewLink || `https://drive.google.com/file/d/${fileData.id}/view`,
303
+ };
304
+ }
305
+
306
+ /**
307
+ * Récupère les informations d'un fichier depuis Google Drive
308
+ */
309
+ export async function getFileInfo(
310
+ userId: string,
311
+ fileId: string,
312
+ ): Promise<{ name: string; size: string; mimeType: string; webViewLink: string }> {
313
+ const googleAccount = await prisma.userGoogleAccount.findUnique({
314
+ where: { userId },
315
+ });
316
+
317
+ if (!googleAccount) {
318
+ throw new Error('Aucun compte Google connecté');
319
+ }
320
+
321
+ const accessToken = await getValidAccessToken(
322
+ googleAccount.accessToken,
323
+ googleAccount.refreshToken,
324
+ googleAccount.tokenExpiresAt,
325
+ );
326
+
327
+ const response = await fetch(
328
+ `https://www.googleapis.com/drive/v3/files/${fileId}?fields=id,name,size,mimeType,webViewLink`,
329
+ {
330
+ headers: {
331
+ Authorization: `Bearer ${accessToken}`,
332
+ },
333
+ },
334
+ );
335
+
336
+ if (!response.ok) {
337
+ throw new Error('Erreur lors de la récupération du fichier');
338
+ }
339
+
340
+ return await response.json();
341
+ }
342
+
343
+ /**
344
+ * Supprime un fichier de Google Drive
345
+ */
346
+ export async function deleteFileFromDrive(userId: string, fileId: string): Promise<void> {
347
+ const googleAccount = await prisma.userGoogleAccount.findUnique({
348
+ where: { userId },
349
+ });
350
+
351
+ if (!googleAccount) {
352
+ throw new Error('Aucun compte Google connecté');
353
+ }
354
+
355
+ const accessToken = await getValidAccessToken(
356
+ googleAccount.accessToken,
357
+ googleAccount.refreshToken,
358
+ googleAccount.tokenExpiresAt,
359
+ );
360
+
361
+ const response = await fetch(`https://www.googleapis.com/drive/v3/files/${fileId}`, {
362
+ method: 'DELETE',
363
+ headers: {
364
+ Authorization: `Bearer ${accessToken}`,
365
+ },
366
+ });
367
+
368
+ if (!response.ok && response.status !== 404) {
369
+ // 404 signifie que le fichier n'existe plus, ce qui est OK
370
+ throw new Error('Erreur lors de la suppression du fichier');
371
+ }
372
+ }