create-crm-tmp 1.1.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/package.json +1 -1
  2. package/template/.prettierignore +2 -0
  3. package/template/README.md +53 -67
  4. package/template/components.json +22 -0
  5. package/template/exemple-contacts.csv +54 -0
  6. package/template/next.config.ts +27 -1
  7. package/template/package.json +64 -27
  8. package/template/prisma/schema.prisma +821 -72
  9. package/template/skills-lock.json +25 -0
  10. package/template/src/app/(auth)/invite/[token]/page.tsx +21 -24
  11. package/template/src/app/(auth)/reset-password/complete/page.tsx +12 -21
  12. package/template/src/app/(auth)/reset-password/page.tsx +12 -8
  13. package/template/src/app/(auth)/reset-password/verify/page.tsx +12 -8
  14. package/template/src/app/(auth)/signin/page.tsx +20 -17
  15. package/template/src/app/(dashboard)/agenda/page.tsx +2231 -2188
  16. package/template/src/app/(dashboard)/automatisation/[id]/page.tsx +10 -7
  17. package/template/src/app/(dashboard)/automatisation/_components/workflow-editor.tsx +680 -323
  18. package/template/src/app/(dashboard)/automatisation/new/page.tsx +11 -8
  19. package/template/src/app/(dashboard)/automatisation/page.tsx +473 -180
  20. package/template/src/app/(dashboard)/closing/page.tsx +500 -468
  21. package/template/src/app/(dashboard)/contacts/[id]/page.tsx +5035 -4126
  22. package/template/src/app/(dashboard)/contacts/companies/[id]/page.tsx +1703 -0
  23. package/template/src/app/(dashboard)/contacts/loading.tsx +13 -0
  24. package/template/src/app/(dashboard)/contacts/page.tsx +3776 -2064
  25. package/template/src/app/(dashboard)/dashboard/page.tsx +37 -519
  26. package/template/src/app/(dashboard)/error.tsx +37 -0
  27. package/template/src/app/(dashboard)/layout.tsx +1 -1
  28. package/template/src/app/(dashboard)/loading.tsx +5 -0
  29. package/template/src/app/(dashboard)/settings/loading.tsx +19 -0
  30. package/template/src/app/(dashboard)/settings/page.tsx +2685 -2489
  31. package/template/src/app/(dashboard)/templates/page.tsx +500 -300
  32. package/template/src/app/(dashboard)/users/list/page.tsx +356 -350
  33. package/template/src/app/(dashboard)/users/page.tsx +279 -310
  34. package/template/src/app/(dashboard)/users/permissions/page.tsx +104 -99
  35. package/template/src/app/(dashboard)/users/roles/page.tsx +164 -137
  36. package/template/src/app/api/audit-logs/route.ts +1 -1
  37. package/template/src/app/api/auth/google/callback/route.ts +8 -5
  38. package/template/src/app/api/auth/google/disconnect/route.ts +2 -2
  39. package/template/src/app/api/companies/[id]/activities/route.ts +131 -0
  40. package/template/src/app/api/companies/[id]/route.ts +195 -0
  41. package/template/src/app/api/companies/export/route.ts +206 -0
  42. package/template/src/app/api/companies/route.ts +166 -0
  43. package/template/src/app/api/contact-views/[id]/pin/route.ts +69 -0
  44. package/template/src/app/api/contact-views/[id]/route.ts +197 -0
  45. package/template/src/app/api/contact-views/route.ts +146 -0
  46. package/template/src/app/api/contacts/[id]/files/[fileId]/preview/route.ts +77 -0
  47. package/template/src/app/api/contacts/[id]/files/[fileId]/route.ts +7 -17
  48. package/template/src/app/api/contacts/[id]/files/route.ts +83 -44
  49. package/template/src/app/api/contacts/[id]/interactions/route.ts +37 -0
  50. package/template/src/app/api/contacts/[id]/kyc/route.ts +71 -0
  51. package/template/src/app/api/contacts/[id]/meet/route.ts +38 -29
  52. package/template/src/app/api/contacts/[id]/route.ts +111 -20
  53. package/template/src/app/api/contacts/[id]/send-email/route.ts +6 -0
  54. package/template/src/app/api/contacts/[id]/workflows/run/route.ts +61 -0
  55. package/template/src/app/api/contacts/export/route.ts +12 -17
  56. package/template/src/app/api/contacts/import/route.ts +22 -19
  57. package/template/src/app/api/contacts/import-preview/route.ts +139 -0
  58. package/template/src/app/api/contacts/route.ts +202 -49
  59. package/template/src/app/api/dashboard/stats/route.ts +9 -292
  60. package/template/src/app/api/integrations/google-sheet/sync/route.ts +203 -185
  61. package/template/src/app/api/invite/complete/route.ts +20 -23
  62. package/template/src/app/api/reminders/route.ts +1 -0
  63. package/template/src/app/api/reset-password/complete/route.ts +11 -13
  64. package/template/src/app/api/send/route.ts +9 -85
  65. package/template/src/app/api/settings/closing-reasons/[id]/route.ts +10 -21
  66. package/template/src/app/api/settings/closing-reasons/route.ts +10 -21
  67. package/template/src/app/api/settings/company/route.ts +19 -26
  68. package/template/src/app/api/settings/google-ads/[id]/route.ts +20 -23
  69. package/template/src/app/api/settings/google-ads/route.ts +20 -23
  70. package/template/src/app/api/settings/google-sheet/[id]/route.ts +20 -23
  71. package/template/src/app/api/settings/google-sheet/auto-map/route.ts +23 -32
  72. package/template/src/app/api/settings/google-sheet/preview/route.ts +104 -0
  73. package/template/src/app/api/settings/google-sheet/route.ts +20 -23
  74. package/template/src/app/api/settings/meta-leads/[id]/route.ts +20 -23
  75. package/template/src/app/api/settings/meta-leads/route.ts +20 -23
  76. package/template/src/app/api/settings/statuses/[id]/route.ts +33 -23
  77. package/template/src/app/api/settings/statuses/route.ts +24 -22
  78. package/template/src/app/api/statuses/route.ts +2 -5
  79. package/template/src/app/api/tasks/[id]/attendees/route.ts +14 -7
  80. package/template/src/app/api/tasks/[id]/route.ts +161 -137
  81. package/template/src/app/api/tasks/meet/route.ts +11 -8
  82. package/template/src/app/api/tasks/route.ts +155 -95
  83. package/template/src/app/api/templates/[id]/route.ts +22 -13
  84. package/template/src/app/api/templates/route.ts +22 -5
  85. package/template/src/app/api/users/[id]/resend-invite/route.ts +95 -0
  86. package/template/src/app/api/users/[id]/route.ts +16 -1
  87. package/template/src/app/api/users/commercials/route.ts +38 -0
  88. package/template/src/app/api/users/for-agenda/route.ts +1 -2
  89. package/template/src/app/api/users/route.ts +94 -55
  90. package/template/src/app/api/webhooks/google-ads/route.ts +20 -1
  91. package/template/src/app/api/webhooks/meta-leads/route.ts +18 -1
  92. package/template/src/app/api/workflows/[id]/route.ts +33 -6
  93. package/template/src/app/api/workflows/process/route.ts +509 -146
  94. package/template/src/app/api/workflows/route.ts +46 -4
  95. package/template/src/app/globals.css +210 -101
  96. package/template/src/app/layout.tsx +19 -8
  97. package/template/src/app/page.tsx +37 -7
  98. package/template/src/components/address-autocomplete.tsx +232 -0
  99. package/template/src/components/contacts/filter-bar.tsx +181 -0
  100. package/template/src/components/contacts/filter-builder.tsx +589 -0
  101. package/template/src/components/contacts/save-view-dialog.tsx +160 -0
  102. package/template/src/components/contacts/views-tab-bar.tsx +440 -0
  103. package/template/src/components/dashboard/activity-chart.tsx +31 -39
  104. package/template/src/components/dashboard/dashboard-content.tsx +79 -0
  105. package/template/src/components/dashboard/stat-card.tsx +40 -42
  106. package/template/src/components/dashboard/tasks-pie-chart.tsx +34 -37
  107. package/template/src/components/dashboard/upcoming-tasks-list.tsx +78 -72
  108. package/template/src/components/date-picker.tsx +396 -0
  109. package/template/src/components/editor.tsx +27 -13
  110. package/template/src/components/email-template.tsx +4 -2
  111. package/template/src/components/global-search.tsx +358 -0
  112. package/template/src/components/header.tsx +57 -62
  113. package/template/src/components/invitation-email-template.tsx +4 -2
  114. package/template/src/components/lazy-editor.tsx +11 -0
  115. package/template/src/components/meet-cancellation-email-template.tsx +11 -3
  116. package/template/src/components/meet-confirmation-email-template.tsx +10 -3
  117. package/template/src/components/meet-update-email-template.tsx +10 -3
  118. package/template/src/components/page-header.tsx +19 -15
  119. package/template/src/components/protected-page.tsx +94 -0
  120. package/template/src/components/reset-password-email-template.tsx +4 -2
  121. package/template/src/components/sidebar.tsx +92 -94
  122. package/template/src/components/skeleton.tsx +128 -42
  123. package/template/src/components/ui/accordion.tsx +64 -0
  124. package/template/src/components/ui/alert-dialog.tsx +139 -0
  125. package/template/src/components/ui/button.tsx +60 -0
  126. package/template/src/components/view-as-banner.tsx +1 -1
  127. package/template/src/components/view-as-modal.tsx +21 -16
  128. package/template/src/config/nav-pages.ts +108 -0
  129. package/template/src/contexts/app-toast-context.tsx +174 -0
  130. package/template/src/contexts/sidebar-context.tsx +16 -47
  131. package/template/src/contexts/task-reminder-context.tsx +6 -6
  132. package/template/src/contexts/view-as-context.tsx +11 -16
  133. package/template/src/hooks/use-alert.tsx +65 -0
  134. package/template/src/hooks/use-confirm.tsx +87 -0
  135. package/template/src/hooks/use-contact-views.ts +140 -0
  136. package/template/src/hooks/use-contacts.ts +69 -0
  137. package/template/src/hooks/use-fetch.ts +17 -0
  138. package/template/src/hooks/use-focus-trap.ts +73 -0
  139. package/template/src/hooks/use-statuses.ts +22 -0
  140. package/template/src/lib/address-api.ts +155 -0
  141. package/template/src/lib/cache.ts +73 -0
  142. package/template/src/lib/check-permission.ts +12 -177
  143. package/template/src/lib/contact-interactions.ts +3 -1
  144. package/template/src/lib/contact-view-filters.ts +341 -0
  145. package/template/src/lib/dashboard-stats.ts +224 -0
  146. package/template/src/lib/date-utils.ts +49 -0
  147. package/template/src/lib/get-auth-user.ts +25 -0
  148. package/template/src/lib/google-calendar.ts +54 -12
  149. package/template/src/lib/google-drive.ts +796 -75
  150. package/template/src/lib/google-fetch.ts +63 -0
  151. package/template/src/lib/local-storage.ts +34 -0
  152. package/template/src/lib/permissions.ts +245 -47
  153. package/template/src/lib/prisma.ts +11 -11
  154. package/template/src/lib/roles.ts +14 -39
  155. package/template/src/lib/template-variables.ts +67 -33
  156. package/template/src/lib/utils.ts +26 -2
  157. package/template/src/lib/workflow-executor.ts +445 -229
  158. package/template/src/proxy.ts +34 -73
  159. package/template/src/types/contact-views.ts +351 -0
  160. package/template/src/types/yousign.ts +52 -0
  161. package/template/vercel.json +12 -0
  162. package/template/WORKFLOWS_CRON.md +0 -185
  163. package/template/prisma/migrations/20251126144728_init/migration.sql +0 -78
  164. package/template/prisma/migrations/20251126155204_add_user_roles/migration.sql +0 -5
  165. package/template/prisma/migrations/20251128095126_add_company_info/migration.sql +0 -19
  166. package/template/prisma/migrations/20251128123321_add_smtp_config/migration.sql +0 -22
  167. package/template/prisma/migrations/20251128132303_add_status/migration.sql +0 -23
  168. package/template/prisma/migrations/20251201102207_add_user_active/migration.sql +0 -75
  169. package/template/prisma/migrations/20251201105507_add_email_signature/migration.sql +0 -2
  170. package/template/prisma/migrations/20251201151122_add_tasks/migration.sql +0 -45
  171. package/template/prisma/migrations/20251202111854_add_task_reminder/migration.sql +0 -2
  172. package/template/prisma/migrations/20251202135859_add_google_meet_integration/migration.sql +0 -27
  173. package/template/prisma/migrations/20251203103317_add_meta_lead_integration/migration.sql +0 -20
  174. package/template/prisma/migrations/20251203104002_add_google_ads_integration/migration.sql +0 -18
  175. package/template/prisma/migrations/20251203112122_add_google_sheet_integration/migration.sql +0 -32
  176. package/template/prisma/migrations/20251203153853_allow_multiple_integration_configs/migration.sql +0 -20
  177. package/template/prisma/migrations/20251205141705_update_user_roles/migration.sql +0 -12
  178. package/template/prisma/migrations/20251205150000_add_commercial_and_telepro_assignment/migration.sql +0 -21
  179. package/template/prisma/migrations/20251205160000_add_interaction_logging/migration.sql +0 -11
  180. package/template/prisma/migrations/20251208090314_add_automatic_interaction_types/migration.sql +0 -12
  181. package/template/prisma/migrations/20251208094843_mg/migration.sql +0 -14
  182. package/template/prisma/migrations/20251208100000_add_company_support/migration.sql +0 -14
  183. package/template/prisma/migrations/20251208110000_add_templates/migration.sql +0 -26
  184. package/template/prisma/migrations/20251208141304_add_video_conference_task_type/migration.sql +0 -2
  185. package/template/prisma/migrations/20251209104759_add_internal_note_to_task/migration.sql +0 -2
  186. package/template/prisma/migrations/20251209134803_add_company_field/migration.sql +0 -2
  187. package/template/prisma/migrations/20251209150000_rename_company_to_company_name/migration.sql +0 -3
  188. package/template/prisma/migrations/20251209150016_add_email_tracking/migration.sql +0 -21
  189. package/template/prisma/migrations/20251209155908_add_notify_contact_to_task/migration.sql +0 -2
  190. package/template/prisma/migrations/20251210110019_add_appointment_types/migration.sql +0 -10
  191. package/template/prisma/migrations/20251210113928_add_contact_files/migration.sql +0 -26
  192. package/template/prisma/migrations/20251212132339_add_custom_roles/migration.sql +0 -24
  193. package/template/prisma/migrations/20251215104448_add_file_interaction_types/migration.sql +0 -11
  194. package/template/prisma/migrations/20251215145616_add_closing_reasons/migration.sql +0 -12
  195. package/template/prisma/migrations/20251216140850_add_log_users/migration.sql +0 -25
  196. package/template/prisma/migrations/20251216151000_rename_perdu_to_ferme/migration.sql +0 -8
  197. package/template/prisma/migrations/20251216162318_add_column_mappings_to_google_sheet/migration.sql +0 -2
  198. package/template/prisma/migrations/20251216185127_add_workflows/migration.sql +0 -80
  199. package/template/prisma/migrations/20251216192237_add_scheduled_workflow_actions/migration.sql +0 -32
  200. package/template/prisma/migrations/20251220000000_add_task_interaction_type/migration.sql +0 -4
  201. package/template/prisma/migrations/20251221000000_add_task_type/migration.sql +0 -3
  202. package/template/prisma/migrations/20251221000001_add_event_color/migration.sql +0 -23
  203. package/template/prisma/migrations/20260210114913_add_dashboard_widget/migration.sql +0 -20
  204. package/template/prisma/migrations/migration_lock.toml +0 -3
  205. package/template/src/app/(dashboard)/users/layout.tsx +0 -30
  206. package/template/src/app/api/dashboard/widgets/[id]/route.ts +0 -47
  207. package/template/src/app/api/dashboard/widgets/route.ts +0 -181
  208. package/template/src/components/dashboard/add-widget-dialog.tsx +0 -161
  209. package/template/src/components/dashboard/color-picker.tsx +0 -65
  210. package/template/src/components/dashboard/contacts-chart.tsx +0 -69
  211. package/template/src/components/dashboard/interactions-by-type-chart.tsx +0 -121
  212. package/template/src/components/dashboard/recent-activity.tsx +0 -157
  213. package/template/src/components/dashboard/sales-analytics-chart.tsx +0 -77
  214. package/template/src/components/dashboard/status-distribution-chart.tsx +0 -82
  215. package/template/src/components/dashboard/top-contacts-list.tsx +0 -119
  216. package/template/src/components/dashboard/widget-wrapper.tsx +0 -39
  217. package/template/src/contexts/dashboard-theme-context.tsx +0 -58
  218. package/template/src/lib/dashboard-themes.ts +0 -140
  219. package/template/src/lib/default-widgets.ts +0 -14
  220. package/template/src/lib/widget-registry.ts +0 -177
@@ -1,16 +1,16 @@
1
1
  /**
2
2
  * Utilitaires pour gérer les fichiers avec Google Drive API
3
- *
4
- * Google Drive est configuré de manière centralisée :
5
- * un seul administrateur connecte son compte Google Drive,
6
- * et tous les utilisateurs uploadent dans ce même Drive.
7
3
  */
8
4
 
9
5
  import { prisma } from '@/lib/prisma';
10
6
  import { getValidAccessToken } from './google-calendar';
7
+ import { encrypt, decrypt } from './encryption';
8
+ import { googleFetch } from './google-fetch';
11
9
 
12
10
  // Nom de l'application (peut être configuré via variable d'environnement)
13
- const APP_NAME = process.env.APP_NAME || 'CRM Template';
11
+ const APP_NAME = process.env.APP_NAME || 'Gold Blessing';
12
+
13
+ const folderIdCache = new Map<string, string>();
14
14
 
15
15
  /**
16
16
  * Récupère le compte Google de l'administrateur
@@ -29,11 +29,15 @@ export async function getAdminGoogleAccount() {
29
29
 
30
30
  if (!adminUser || !adminUser.googleAccount) {
31
31
  throw new Error(
32
- 'Aucun compte Google Drive configuré. Veuillez demander à un administrateur de connecter son compte Google Drive dans les paramètres.',
32
+ 'Aucun compte Google configuré. Veuillez demander à un administrateur de connecter son compte Google dans Paramètres > Général > Profil.',
33
33
  );
34
34
  }
35
35
 
36
- return adminUser.googleAccount;
36
+ return {
37
+ ...adminUser.googleAccount,
38
+ accessToken: decrypt(adminUser.googleAccount.accessToken),
39
+ refreshToken: decrypt(adminUser.googleAccount.refreshToken),
40
+ };
37
41
  }
38
42
 
39
43
  /**
@@ -44,9 +48,10 @@ async function getOrCreateFolder(
44
48
  folderName: string,
45
49
  parentId?: string,
46
50
  ): Promise<string> {
47
- // Construire la requête de recherche
48
- // Important: Ne pas encoder le nom dans les guillemets, mais échapper les guillemets simples dans le nom
49
- // Google Drive API attend le nom tel quel, pas encodé
51
+ const cacheKey = `${parentId || 'root'}:${folderName}`;
52
+ const cached = folderIdCache.get(cacheKey);
53
+ if (cached) return cached;
54
+
50
55
  const escapedName = folderName.replace(/'/g, "\\'");
51
56
  let query = `name='${escapedName}' and mimeType='application/vnd.google-apps.folder' and trashed=false`;
52
57
  if (parentId) {
@@ -58,7 +63,7 @@ async function getOrCreateFolder(
58
63
 
59
64
  // Chercher si le dossier existe déjà
60
65
  const searchUrl = `https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id,name,parents)&pageSize=10`;
61
- const searchResponse = await fetch(searchUrl, {
66
+ const searchResponse = await googleFetch(searchUrl, {
62
67
  headers: {
63
68
  Authorization: `Bearer ${accessToken}`,
64
69
  },
@@ -73,50 +78,33 @@ async function getOrCreateFolder(
73
78
 
74
79
  // Si le dossier existe, vérifier qu'il est bien dans le bon parent
75
80
  if (searchData.files && searchData.files.length > 0) {
76
- // Si on cherche à la racine, vérifier que le dossier est bien à la racine
81
+ let folderId: string;
82
+
77
83
  if (!parentId) {
78
- // Vérifier que le dossier est bien à la racine (parents contient 'root' ou est vide)
79
84
  const rootFolder = searchData.files.find((file: any) => {
80
85
  if (!file.parents || file.parents.length === 0) return true;
81
- // Vérifier si le dossier est directement à la racine
82
86
  return file.parents.length === 1 && file.parents[0] === 'root';
83
87
  });
84
-
85
- if (rootFolder) {
86
- return rootFolder.id;
87
- }
88
-
89
- // Si aucun n'est exactement à la racine, prendre le premier
90
- return searchData.files[0].id;
88
+ folderId = rootFolder ? rootFolder.id : searchData.files[0].id;
89
+ } else {
90
+ const matchingFolder = searchData.files.find(
91
+ (file: any) => file.parents && file.parents.includes(parentId),
92
+ );
93
+ folderId = matchingFolder ? matchingFolder.id : searchData.files[0].id;
91
94
  }
92
95
 
93
- // Si on cherche dans un parent spécifique, vérifier que le dossier est bien dans ce parent
94
- const matchingFolder = searchData.files.find(
95
- (file: any) => file.parents && file.parents.includes(parentId),
96
- );
97
-
98
- if (matchingFolder) {
99
- return matchingFolder.id;
100
- }
101
-
102
- // Si aucun ne correspond exactement, prendre le premier (cas de migration)
103
- return searchData.files[0].id;
96
+ folderIdCache.set(cacheKey, folderId);
97
+ return folderId;
104
98
  }
105
99
 
106
100
  // Sinon, créer le dossier
107
101
  const folderData: any = {
108
102
  name: folderName,
109
103
  mimeType: 'application/vnd.google-apps.folder',
104
+ parents: [parentId || 'root'],
110
105
  };
111
106
 
112
- if (parentId) {
113
- folderData.parents = [parentId];
114
- } else {
115
- // Pour la racine, on spécifie explicitement 'root' comme parent
116
- folderData.parents = ['root'];
117
- }
118
-
119
- const createResponse = await fetch('https://www.googleapis.com/drive/v3/files', {
107
+ const createResponse = await googleFetch('https://www.googleapis.com/drive/v3/files', {
120
108
  method: 'POST',
121
109
  headers: {
122
110
  Authorization: `Bearer ${accessToken}`,
@@ -132,14 +120,13 @@ async function getOrCreateFolder(
132
120
 
133
121
  const createdData = await createResponse.json();
134
122
 
135
- // Configurer les permissions pour rendre le dossier accessible avec le lien
136
123
  try {
137
124
  await setFilePublicWithLink(accessToken, createdData.id);
138
125
  } catch (permError) {
139
126
  console.error('Erreur lors de la configuration des permissions du dossier:', permError);
140
- // On continue même si la configuration des permissions échoue
141
127
  }
142
128
 
129
+ folderIdCache.set(cacheKey, createdData.id);
143
130
  return createdData.id;
144
131
  }
145
132
 
@@ -148,7 +135,7 @@ async function getOrCreateFolder(
148
135
  * Type: 'anyone' avec rôle 'reader' = accessible à quiconque possède le lien
149
136
  */
150
137
  async function setFilePublicWithLink(accessToken: string, fileId: string): Promise<void> {
151
- const permissionResponse = await fetch(
138
+ const permissionResponse = await googleFetch(
152
139
  `https://www.googleapis.com/drive/v3/files/${fileId}/permissions`,
153
140
  {
154
141
  method: 'POST',
@@ -171,9 +158,8 @@ async function setFilePublicWithLink(accessToken: string, fileId: string): Promi
171
158
 
172
159
  /**
173
160
  * Crée un dossier dans Google Drive pour un contact
174
- * Structure: CRM Template > Contacts > Contact - [Nom]
161
+ * Structure: Gold Blessing > Contacts > Contact - [Nom]
175
162
  * Retourne l'ID du dossier créé ou existant
176
- * Utilise le compte Google Drive de l'administrateur
177
163
  */
178
164
  export async function getOrCreateContactFolder(
179
165
  userId: string,
@@ -195,16 +181,16 @@ export async function getOrCreateContactFolder(
195
181
  await prisma.userGoogleAccount.update({
196
182
  where: { userId: googleAccount.userId },
197
183
  data: {
198
- accessToken,
184
+ accessToken: encrypt(accessToken),
199
185
  tokenExpiresAt,
200
186
  },
201
187
  });
202
188
  }
203
189
 
204
- // 1. Créer ou récupérer le dossier racine "CRM Template"
190
+ // 1. Créer ou récupérer le dossier racine "Gold Blessing"
205
191
  const appFolderId = await getOrCreateFolder(accessToken, APP_NAME);
206
192
 
207
- // 2. Créer ou récupérer le dossier "Contacts" dans "CRM Template"
193
+ // 2. Créer ou récupérer le dossier "Contacts" dans "Gold Blessing"
208
194
  const contactsFolderId = await getOrCreateFolder(accessToken, 'Contacts', appFolderId);
209
195
 
210
196
  // 3. Créer ou récupérer le dossier du contact dans "Contacts"
@@ -214,17 +200,106 @@ export async function getOrCreateContactFolder(
214
200
  return contactFolderId;
215
201
  }
216
202
 
203
+ /**
204
+ * Crée ou récupère un dossier dans Google Drive pour une tournée
205
+ * Structure: [Nom du projet] > Tournées > [Nom] [Numéro]
206
+ */
207
+ export async function getOrCreateTourFolder(
208
+ userId: string,
209
+ tourId: string,
210
+ tourName: string,
211
+ tourNumber?: string | null,
212
+ ): Promise<string> {
213
+ const googleAccount = await getAdminGoogleAccount();
214
+
215
+ const accessToken = await getValidAccessToken(
216
+ googleAccount.accessToken,
217
+ googleAccount.refreshToken,
218
+ googleAccount.tokenExpiresAt,
219
+ );
220
+
221
+ // Mettre à jour le token si nécessaire
222
+ if (accessToken !== googleAccount.accessToken) {
223
+ const tokenExpiresAt = new Date();
224
+ tokenExpiresAt.setSeconds(tokenExpiresAt.getSeconds() + 3600);
225
+ await prisma.userGoogleAccount.update({
226
+ where: { userId: googleAccount.userId },
227
+ data: {
228
+ accessToken: encrypt(accessToken),
229
+ tokenExpiresAt,
230
+ },
231
+ });
232
+ }
233
+
234
+ // 1. Dossier racine "[Nom du projet]"
235
+ const appFolderId = await getOrCreateFolder(accessToken, APP_NAME);
236
+
237
+ // 2. Dossier "Tournées"
238
+ const toursFolderId = await getOrCreateFolder(accessToken, 'Tournées', appFolderId);
239
+
240
+ // 3. Dossier de la tournée : "[Nom] [Numéro]"
241
+ const tourFolderName = tourNumber
242
+ ? `${tourName || tourId} ${tourNumber}`
243
+ : tourName || `Tournée ${tourId}`;
244
+ const tourFolderId = await getOrCreateFolder(accessToken, tourFolderName, toursFolderId);
245
+
246
+ return tourFolderId;
247
+ }
248
+
249
+ /**
250
+ * Crée ou récupère le dossier "Transactions" dans le dossier du contact
251
+ */
252
+ export async function getOrCreateTransactionsFolder(
253
+ userId: string,
254
+ contactId: string,
255
+ contactName: string,
256
+ ): Promise<string> {
257
+ const contactFolderId = await getOrCreateContactFolder(userId, contactId, contactName);
258
+
259
+ const googleAccount = await getAdminGoogleAccount();
260
+
261
+ const accessToken = await getValidAccessToken(
262
+ googleAccount.accessToken,
263
+ googleAccount.refreshToken,
264
+ googleAccount.tokenExpiresAt,
265
+ );
266
+
267
+ // Mettre à jour le token si nécessaire
268
+ if (accessToken !== googleAccount.accessToken) {
269
+ const tokenExpiresAt = new Date();
270
+ tokenExpiresAt.setSeconds(tokenExpiresAt.getSeconds() + 3600);
271
+ await prisma.userGoogleAccount.update({
272
+ where: { userId: googleAccount.userId },
273
+ data: {
274
+ accessToken: encrypt(accessToken),
275
+ tokenExpiresAt,
276
+ },
277
+ });
278
+ }
279
+
280
+ // Créer ou récupérer le dossier "Transactions" dans le dossier du contact
281
+ const transactionsFolderId = await getOrCreateFolder(
282
+ accessToken,
283
+ 'Transactions',
284
+ contactFolderId,
285
+ );
286
+
287
+ return transactionsFolderId;
288
+ }
289
+
217
290
  /**
218
291
  * Upload un fichier vers Google Drive dans le dossier du contact
219
- * Utilise le compte Google Drive de l'administrateur
220
292
  */
221
293
  export async function uploadFileToDrive(
222
294
  userId: string,
223
295
  contactId: string,
224
296
  contactName: string,
225
297
  file: File,
298
+ useTransactionsFolder: boolean = false,
226
299
  ): Promise<{ fileId: string; webViewLink: string }> {
227
- const folderId = await getOrCreateContactFolder(userId, contactId, contactName);
300
+ const folderId = useTransactionsFolder
301
+ ? await getOrCreateTransactionsFolder(userId, contactId, contactName)
302
+ : await getOrCreateContactFolder(userId, contactId, contactName);
228
303
 
229
304
  const googleAccount = await getAdminGoogleAccount();
230
305
 
@@ -241,7 +316,7 @@ export async function uploadFileToDrive(
241
316
  await prisma.userGoogleAccount.update({
242
317
  where: { userId: googleAccount.userId },
243
318
  data: {
244
- accessToken,
319
+ accessToken: encrypt(accessToken),
245
320
  tokenExpiresAt,
246
321
  },
247
322
  });
@@ -249,7 +324,7 @@ export async function uploadFileToDrive(
249
324
 
250
325
  // Vérifier si un fichier avec le même nom existe déjà dans le dossier
251
326
  const searchQuery = `name='${encodeURIComponent(file.name)}' and '${folderId}' in parents and trashed=false`;
252
- const searchResponse = await fetch(
327
+ const searchResponse = await googleFetch(
253
328
  `https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(searchQuery)}&fields=files(id,name,webViewLink)`,
254
329
  {
255
330
  headers: {
@@ -264,7 +339,7 @@ export async function uploadFileToDrive(
264
339
  if (searchData.files && searchData.files.length > 0) {
265
340
  for (const existingFile of searchData.files) {
266
341
  try {
267
- await fetch(`https://www.googleapis.com/drive/v3/files/${existingFile.id}`, {
342
+ await googleFetch(`https://www.googleapis.com/drive/v3/files/${existingFile.id}`, {
268
343
  method: 'DELETE',
269
344
  headers: {
270
345
  Authorization: `Bearer ${accessToken}`,
@@ -289,7 +364,7 @@ export async function uploadFileToDrive(
289
364
  formData.append('file', file);
290
365
 
291
366
  // Upload le fichier
292
- const uploadResponse = await fetch(
367
+ const uploadResponse = await googleFetch(
293
368
  'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,webViewLink',
294
369
  {
295
370
  method: 'POST',
@@ -322,13 +397,17 @@ export async function uploadFileToDrive(
322
397
  }
323
398
 
324
399
  /**
325
- * Récupère les informations d'un fichier depuis Google Drive
326
- * Utilise le compte Google Drive de l'administrateur
400
+ * Crée ou récupère un sous-dossier dans le dossier de la tournée
327
401
  */
328
- export async function getFileInfo(
402
+ export async function getOrCreateTourSubFolder(
329
403
  userId: string,
330
- fileId: string,
331
- ): Promise<{ name: string; size: string; mimeType: string; webViewLink: string }> {
404
+ tourId: string,
405
+ tourName: string,
406
+ subFolderName: 'mairie' | 'lieu' | 'campagne',
407
+ tourNumber?: string | null,
408
+ ): Promise<string> {
409
+ const tourFolderId = await getOrCreateTourFolder(userId, tourId, tourName, tourNumber);
410
+
332
411
  const googleAccount = await getAdminGoogleAccount();
333
412
 
334
413
  const accessToken = await getValidAccessToken(
@@ -337,27 +416,120 @@ export async function getFileInfo(
337
416
  googleAccount.tokenExpiresAt,
338
417
  );
339
418
 
340
- const response = await fetch(
341
- `https://www.googleapis.com/drive/v3/files/${fileId}?fields=id,name,size,mimeType,webViewLink`,
419
+ if (accessToken !== googleAccount.accessToken) {
420
+ const tokenExpiresAt = new Date();
421
+ tokenExpiresAt.setSeconds(tokenExpiresAt.getSeconds() + 3600);
422
+ await prisma.userGoogleAccount.update({
423
+ where: { userId: googleAccount.userId },
424
+ data: {
425
+ accessToken: encrypt(accessToken),
426
+ tokenExpiresAt,
427
+ },
428
+ });
429
+ }
430
+
431
+ // Capitaliser la première lettre pour le nom du dossier
432
+ const folderName = subFolderName.charAt(0).toUpperCase() + subFolderName.slice(1);
433
+ const subFolderId = await getOrCreateFolder(accessToken, folderName, tourFolderId);
434
+
435
+ return subFolderId;
436
+ }
437
+
438
+ /**
439
+ * Upload un fichier vers Google Drive dans le dossier de la tournée
440
+ */
441
+ export async function uploadTourFileToDrive(
442
+ userId: string,
443
+ tourId: string,
444
+ tourName: string,
445
+ file: File,
446
+ tourNumber?: string | null,
447
+ subFolder?: 'mairie' | 'lieu' | 'campagne',
448
+ ): Promise<{ fileId: string; webViewLink: string }> {
449
+ const folderId = subFolder
450
+ ? await getOrCreateTourSubFolder(userId, tourId, tourName, subFolder, tourNumber)
451
+ : await getOrCreateTourFolder(userId, tourId, tourName, tourNumber);
452
+
453
+ const googleAccount = await getAdminGoogleAccount();
454
+
455
+ const accessToken = await getValidAccessToken(
456
+ googleAccount.accessToken,
457
+ googleAccount.refreshToken,
458
+ googleAccount.tokenExpiresAt,
459
+ );
460
+
461
+ if (accessToken !== googleAccount.accessToken) {
462
+ const tokenExpiresAt = new Date();
463
+ tokenExpiresAt.setSeconds(tokenExpiresAt.getSeconds() + 3600);
464
+ await prisma.userGoogleAccount.update({
465
+ where: { userId: googleAccount.userId },
466
+ data: {
467
+ accessToken: encrypt(accessToken),
468
+ tokenExpiresAt,
469
+ },
470
+ });
471
+ }
472
+
473
+ const metadata = {
474
+ name: file.name,
475
+ parents: [folderId],
476
+ };
477
+
478
+ const formData = new FormData();
479
+ formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
480
+ formData.append('file', file);
481
+
482
+ const uploadResponse = await googleFetch(
483
+ 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,webViewLink',
342
484
  {
485
+ method: 'POST',
343
486
  headers: {
344
487
  Authorization: `Bearer ${accessToken}`,
345
488
  },
489
+ body: formData,
346
490
  },
347
491
  );
348
492
 
349
- if (!response.ok) {
350
- throw new Error('Erreur lors de la récupération du fichier');
493
+ if (!uploadResponse.ok) {
494
+ const error = await uploadResponse.json();
495
+ throw new Error(`Erreur lors de l'upload: ${JSON.stringify(error)}`);
351
496
  }
352
497
 
353
- return await response.json();
498
+ const fileData = await uploadResponse.json();
499
+
500
+ try {
501
+ await setFilePublicWithLink(accessToken, fileData.id);
502
+ } catch (permError) {
503
+ console.error(
504
+ 'Erreur lors de la configuration des permissions du fichier (tournée):',
505
+ permError,
506
+ );
507
+ }
508
+
509
+ return {
510
+ fileId: fileData.id,
511
+ webViewLink: fileData.webViewLink || `https://drive.google.com/file/d/${fileData.id}/view`,
512
+ };
354
513
  }
355
514
 
356
515
  /**
357
- * Supprime un fichier de Google Drive
358
- * Utilise le compte Google Drive de l'administrateur
516
+ * Upload un buffer (PDF) vers Google Drive dans le dossier de la tournée
517
+ * Utilisé pour uploader des fichiers générés comme le CERFA
359
518
  */
360
- export async function deleteFileFromDrive(userId: string, fileId: string): Promise<void> {
519
+ export async function uploadTourBufferToDrive(
520
+ userId: string,
521
+ tourId: string,
522
+ tourName: string,
523
+ buffer: Buffer,
524
+ fileName: string,
525
+ mimeType: string = 'application/pdf',
526
+ tourNumber?: string | null,
527
+ subFolder?: 'mairie' | 'lieu' | 'campagne',
528
+ ): Promise<{ fileId: string; webViewLink: string }> {
529
+ const folderId = subFolder
530
+ ? await getOrCreateTourSubFolder(userId, tourId, tourName, subFolder, tourNumber)
531
+ : await getOrCreateTourFolder(userId, tourId, tourName, tourNumber);
532
+
361
533
  const googleAccount = await getAdminGoogleAccount();
362
534
 
363
535
  const accessToken = await getValidAccessToken(
@@ -366,15 +538,564 @@ export async function deleteFileFromDrive(userId: string, fileId: string): Promi
366
538
  googleAccount.tokenExpiresAt,
367
539
  );
368
540
 
369
- const response = await fetch(`https://www.googleapis.com/drive/v3/files/${fileId}`, {
370
- method: 'DELETE',
371
- headers: {
372
- Authorization: `Bearer ${accessToken}`,
541
+ if (accessToken !== googleAccount.accessToken) {
542
+ const tokenExpiresAt = new Date();
543
+ tokenExpiresAt.setSeconds(tokenExpiresAt.getSeconds() + 3600);
544
+ await prisma.userGoogleAccount.update({
545
+ where: { userId: googleAccount.userId },
546
+ data: {
547
+ accessToken,
548
+ tokenExpiresAt,
549
+ },
550
+ });
551
+ }
552
+
553
+ // Vérifier si un fichier avec le même nom existe déjà dans le dossier
554
+ const searchQuery = `name='${fileName.replace(/'/g, "\\'")}' and '${folderId}' in parents and trashed=false`;
555
+ const searchResponse = await googleFetch(
556
+ `https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(searchQuery)}&fields=files(id,name,webViewLink)`,
557
+ {
558
+ headers: {
559
+ Authorization: `Bearer ${accessToken}`,
560
+ },
373
561
  },
374
- });
562
+ );
375
563
 
376
- if (!response.ok && response.status !== 404) {
377
- // 404 signifie que le fichier n'existe plus, ce qui est OK
378
- throw new Error('Erreur lors de la suppression du fichier');
564
+ if (searchResponse.ok) {
565
+ const searchData = await searchResponse.json();
566
+ // Si un fichier avec le même nom existe déjà, le supprimer avant d'uploader le nouveau
567
+ if (searchData.files && searchData.files.length > 0) {
568
+ for (const existingFile of searchData.files) {
569
+ try {
570
+ await googleFetch(`https://www.googleapis.com/drive/v3/files/${existingFile.id}`, {
571
+ method: 'DELETE',
572
+ headers: {
573
+ Authorization: `Bearer ${accessToken}`,
574
+ },
575
+ });
576
+ } catch (error) {
577
+ console.error('Erreur lors de la suppression du fichier existant:', error);
578
+ }
579
+ }
580
+ }
581
+ }
582
+
583
+ const metadata = {
584
+ name: fileName,
585
+ parents: [folderId],
586
+ };
587
+
588
+ // Créer un Blob à partir du Buffer (convertir en Uint8Array pour compatibilité TypeScript)
589
+ const uint8Array = new Uint8Array(buffer);
590
+ const blob = new Blob([uint8Array], { type: mimeType });
591
+
592
+ const formData = new FormData();
593
+ formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
594
+ formData.append('file', blob, fileName);
595
+
596
+ const uploadResponse = await googleFetch(
597
+ 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,webViewLink',
598
+ {
599
+ method: 'POST',
600
+ headers: {
601
+ Authorization: `Bearer ${accessToken}`,
602
+ },
603
+ body: formData,
604
+ },
605
+ );
606
+
607
+ if (!uploadResponse.ok) {
608
+ const error = await uploadResponse.json();
609
+ throw new Error(`Erreur lors de l'upload: ${JSON.stringify(error)}`);
610
+ }
611
+
612
+ const fileData = await uploadResponse.json();
613
+
614
+ try {
615
+ await setFilePublicWithLink(accessToken, fileData.id);
616
+ } catch (permError) {
617
+ console.error(
618
+ 'Erreur lors de la configuration des permissions du fichier (tournée):',
619
+ permError,
620
+ );
379
621
  }
622
+
623
+ return {
624
+ fileId: fileData.id,
625
+ webViewLink: fileData.webViewLink || `https://drive.google.com/file/d/${fileData.id}/view`,
626
+ };
627
+ }
628
+
629
+ /**
630
+ * Crée ou récupère un dossier pour les documents requis de la mairie
631
+ * Structure: [Nom du projet] > Paramètres > Documents requis mairie
632
+ */
633
+ export async function getOrCreateCityHallDocumentsFolder(userId: string): Promise<string> {
634
+ const googleAccount = await getAdminGoogleAccount();
635
+
636
+ const accessToken = await getValidAccessToken(
637
+ googleAccount.accessToken,
638
+ googleAccount.refreshToken,
639
+ googleAccount.tokenExpiresAt,
640
+ );
641
+
642
+ if (accessToken !== googleAccount.accessToken) {
643
+ const tokenExpiresAt = new Date();
644
+ tokenExpiresAt.setSeconds(tokenExpiresAt.getSeconds() + 3600);
645
+ await prisma.userGoogleAccount.update({
646
+ where: { userId: googleAccount.userId },
647
+ data: {
648
+ accessToken,
649
+ tokenExpiresAt,
650
+ },
651
+ });
652
+ }
653
+
654
+ // 1. Créer ou récupérer le dossier racine
655
+ const appFolderId = await getOrCreateFolder(accessToken, APP_NAME);
656
+
657
+ // 2. Créer ou récupérer le dossier "Paramètres"
658
+ const settingsFolderId = await getOrCreateFolder(accessToken, 'Paramètres', appFolderId);
659
+
660
+ // 3. Créer ou récupérer le dossier "Documents requis mairie"
661
+ const documentsFolderId = await getOrCreateFolder(
662
+ accessToken,
663
+ 'Documents requis mairie',
664
+ settingsFolderId,
665
+ );
666
+
667
+ return documentsFolderId;
668
+ }
669
+
670
+ /**
671
+ * Upload un fichier vers Google Drive dans le dossier des documents requis mairie
672
+ */
673
+ export async function uploadCityHallRequiredDocument(
674
+ userId: string,
675
+ file: File,
676
+ ): Promise<{ fileId: string; webViewLink: string }> {
677
+ const folderId = await getOrCreateCityHallDocumentsFolder(userId);
678
+
679
+ const googleAccount = await getAdminGoogleAccount();
680
+
681
+ const accessToken = await getValidAccessToken(
682
+ googleAccount.accessToken,
683
+ googleAccount.refreshToken,
684
+ googleAccount.tokenExpiresAt,
685
+ );
686
+
687
+ if (accessToken !== googleAccount.accessToken) {
688
+ const tokenExpiresAt = new Date();
689
+ tokenExpiresAt.setSeconds(tokenExpiresAt.getSeconds() + 3600);
690
+ await prisma.userGoogleAccount.update({
691
+ where: { userId: googleAccount.userId },
692
+ data: {
693
+ accessToken,
694
+ tokenExpiresAt,
695
+ },
696
+ });
697
+ }
698
+
699
+ // Vérifier si un fichier avec le même nom existe déjà
700
+ const searchQuery = `name='${encodeURIComponent(file.name)}' and '${folderId}' in parents and trashed=false`;
701
+ const searchResponse = await googleFetch(
702
+ `https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(searchQuery)}&fields=files(id,name,webViewLink)`,
703
+ {
704
+ headers: {
705
+ Authorization: `Bearer ${accessToken}`,
706
+ },
707
+ },
708
+ );
709
+
710
+ if (searchResponse.ok) {
711
+ const searchData = await searchResponse.json();
712
+ if (searchData.files && searchData.files.length > 0) {
713
+ for (const existingFile of searchData.files) {
714
+ try {
715
+ await googleFetch(`https://www.googleapis.com/drive/v3/files/${existingFile.id}`, {
716
+ method: 'DELETE',
717
+ headers: {
718
+ Authorization: `Bearer ${accessToken}`,
719
+ },
720
+ });
721
+ } catch (error) {
722
+ // Ignorer l'erreur
723
+ }
724
+ }
725
+ }
726
+ }
727
+
728
+ const metadata = {
729
+ name: file.name,
730
+ parents: [folderId],
731
+ };
732
+
733
+ const formData = new FormData();
734
+ formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
735
+ formData.append('file', file);
736
+
737
+ const uploadResponse = await googleFetch(
738
+ 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,webViewLink',
739
+ {
740
+ method: 'POST',
741
+ headers: {
742
+ Authorization: `Bearer ${accessToken}`,
743
+ },
744
+ body: formData,
745
+ },
746
+ );
747
+
748
+ if (!uploadResponse.ok) {
749
+ const error = await uploadResponse.json();
750
+ throw new Error(`Erreur lors de l'upload: ${JSON.stringify(error)}`);
751
+ }
752
+
753
+ const fileData = await uploadResponse.json();
754
+
755
+ try {
756
+ await setFilePublicWithLink(accessToken, fileData.id);
757
+ } catch (permError) {
758
+ console.error('Erreur lors de la configuration des permissions:', permError);
759
+ }
760
+
761
+ return {
762
+ fileId: fileData.id,
763
+ webViewLink: fileData.webViewLink || `https://drive.google.com/file/d/${fileData.id}/view`,
764
+ };
765
+ }
766
+
767
+ /**
768
+ * Récupère les informations d'un fichier depuis Google Drive
769
+ */
770
+ export async function getFileInfo(
771
+ userId: string,
772
+ fileId: string,
773
+ ): Promise<{ name: string; size: string; mimeType: string; webViewLink: string }> {
774
+ const googleAccount = await getAdminGoogleAccount();
775
+
776
+ const accessToken = await getValidAccessToken(
777
+ googleAccount.accessToken,
778
+ googleAccount.refreshToken,
779
+ googleAccount.tokenExpiresAt,
780
+ );
781
+
782
+ const response = await googleFetch(
783
+ `https://www.googleapis.com/drive/v3/files/${fileId}?fields=id,name,size,mimeType,webViewLink`,
784
+ {
785
+ headers: {
786
+ Authorization: `Bearer ${accessToken}`,
787
+ },
788
+ },
789
+ );
790
+
791
+ if (!response.ok) {
792
+ throw new Error('Erreur lors de la récupération du fichier');
793
+ }
794
+
795
+ return await response.json();
796
+ }
797
+
798
+ /**
799
+ * Télécharge un fichier depuis Google Drive
800
+ */
801
+ export async function downloadFileFromDrive(
802
+ userId: string,
803
+ fileId: string,
804
+ ): Promise<{ buffer: Buffer; fileName: string; mimeType: string }> {
805
+ const googleAccount = await getAdminGoogleAccount();
806
+
807
+ const accessToken = await getValidAccessToken(
808
+ googleAccount.accessToken,
809
+ googleAccount.refreshToken,
810
+ googleAccount.tokenExpiresAt,
811
+ );
812
+
813
+ // Récupérer les métadonnées du fichier
814
+ const metadataResponse = await googleFetch(
815
+ `https://www.googleapis.com/drive/v3/files/${fileId}?fields=id,name,mimeType`,
816
+ {
817
+ headers: {
818
+ Authorization: `Bearer ${accessToken}`,
819
+ },
820
+ },
821
+ );
822
+
823
+ if (!metadataResponse.ok) {
824
+ const errorText = await metadataResponse.text();
825
+
826
+ // Erreur d'authentification (token expiré/révoqué)
827
+ if (metadataResponse.status === 401) {
828
+ throw new Error(
829
+ 'La connexion Google Drive a expiré. Veuillez demander à un administrateur de reconnecter son compte Google dans Paramètres > Intégrations > Google Drive.',
830
+ );
831
+ }
832
+
833
+ // Fichier non trouvé
834
+ if (metadataResponse.status === 404) {
835
+ throw new Error(
836
+ "Le fichier n'a pas été trouvé sur Google Drive. Veuillez vérifier que le template CERFA est bien uploadé dans Paramètres > App > Modèle CERFA.",
837
+ );
838
+ }
839
+
840
+ throw new Error(`Erreur lors de la récupération des métadonnées du fichier: ${errorText}`);
841
+ }
842
+
843
+ const metadata = await metadataResponse.json();
844
+
845
+ // Télécharger le contenu du fichier
846
+ const downloadResponse = await googleFetch(
847
+ `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`,
848
+ {
849
+ headers: {
850
+ Authorization: `Bearer ${accessToken}`,
851
+ },
852
+ },
853
+ );
854
+
855
+ if (!downloadResponse.ok) {
856
+ const errorText = await downloadResponse.text();
857
+
858
+ // Erreur d'authentification
859
+ if (downloadResponse.status === 401) {
860
+ throw new Error(
861
+ 'La connexion Google Drive a expiré. Veuillez demander à un administrateur de reconnecter son compte Google dans Paramètres > Intégrations > Google Drive.',
862
+ );
863
+ }
864
+
865
+ throw new Error(`Erreur lors du téléchargement du fichier: ${errorText}`);
866
+ }
867
+
868
+ const arrayBuffer = await downloadResponse.arrayBuffer();
869
+ const buffer = Buffer.from(arrayBuffer);
870
+
871
+ return {
872
+ buffer,
873
+ fileName: metadata.name,
874
+ mimeType: metadata.mimeType || 'application/pdf',
875
+ };
876
+ }
877
+
878
+ /**
879
+ * Supprime un fichier de Google Drive
880
+ */
881
+ export async function deleteFileFromDrive(userId: string, fileId: string): Promise<void> {
882
+ const googleAccount = await getAdminGoogleAccount();
883
+
884
+ const accessToken = await getValidAccessToken(
885
+ googleAccount.accessToken,
886
+ googleAccount.refreshToken,
887
+ googleAccount.tokenExpiresAt,
888
+ );
889
+
890
+ const response = await googleFetch(`https://www.googleapis.com/drive/v3/files/${fileId}`, {
891
+ method: 'DELETE',
892
+ headers: {
893
+ Authorization: `Bearer ${accessToken}`,
894
+ },
895
+ });
896
+
897
+ if (!response.ok && response.status !== 404) {
898
+ // 404 signifie que le fichier n'existe plus, ce qui est OK
899
+ throw new Error('Erreur lors de la suppression du fichier');
900
+ }
901
+ }
902
+
903
+ /**
904
+ * Upload le template PDF Cerfa dans le dossier Paramètres
905
+ * Structure: [Nom du projet] > Paramètres
906
+ */
907
+ export async function uploadCerfaTemplate(
908
+ userId: string,
909
+ file: File,
910
+ ): Promise<{ fileId: string; webViewLink: string }> {
911
+ const googleAccount = await getAdminGoogleAccount();
912
+
913
+ const accessToken = await getValidAccessToken(
914
+ googleAccount.accessToken,
915
+ googleAccount.refreshToken,
916
+ googleAccount.tokenExpiresAt,
917
+ );
918
+
919
+ if (accessToken !== googleAccount.accessToken) {
920
+ const tokenExpiresAt = new Date();
921
+ tokenExpiresAt.setSeconds(tokenExpiresAt.getSeconds() + 3600);
922
+ await prisma.userGoogleAccount.update({
923
+ where: { userId: googleAccount.userId },
924
+ data: {
925
+ accessToken,
926
+ tokenExpiresAt,
927
+ },
928
+ });
929
+ }
930
+
931
+ // 1. Créer ou récupérer le dossier racine
932
+ const appFolderId = await getOrCreateFolder(accessToken, APP_NAME);
933
+
934
+ // 2. Créer ou récupérer le dossier "Paramètres"
935
+ const settingsFolderId = await getOrCreateFolder(accessToken, 'Paramètres', appFolderId);
936
+
937
+ // Vérifier si un fichier avec le même nom existe déjà
938
+ const searchQuery = `name='${encodeURIComponent(file.name)}' and '${settingsFolderId}' in parents and trashed=false`;
939
+ const searchResponse = await googleFetch(
940
+ `https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(searchQuery)}&fields=files(id,name,webViewLink)`,
941
+ {
942
+ headers: {
943
+ Authorization: `Bearer ${accessToken}`,
944
+ },
945
+ },
946
+ );
947
+
948
+ if (searchResponse.ok) {
949
+ const searchData = await searchResponse.json();
950
+ // Si un fichier avec le même nom existe déjà, le supprimer avant d'uploader le nouveau
951
+ if (searchData.files && searchData.files.length > 0) {
952
+ for (const existingFile of searchData.files) {
953
+ await googleFetch(`https://www.googleapis.com/drive/v3/files/${existingFile.id}`, {
954
+ method: 'DELETE',
955
+ headers: {
956
+ Authorization: `Bearer ${accessToken}`,
957
+ },
958
+ });
959
+ }
960
+ }
961
+ }
962
+
963
+ // Uploader le fichier
964
+ const metadata = {
965
+ name: file.name,
966
+ parents: [settingsFolderId],
967
+ };
968
+
969
+ const formData = new FormData();
970
+ formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
971
+ formData.append('file', file);
972
+
973
+ const uploadResponse = await googleFetch(
974
+ 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,webViewLink',
975
+ {
976
+ method: 'POST',
977
+ headers: {
978
+ Authorization: `Bearer ${accessToken}`,
979
+ },
980
+ body: formData,
981
+ },
982
+ );
983
+
984
+ if (!uploadResponse.ok) {
985
+ const error = await uploadResponse.json();
986
+ throw new Error(`Erreur lors de l'upload: ${JSON.stringify(error)}`);
987
+ }
988
+
989
+ const fileData = await uploadResponse.json();
990
+
991
+ try {
992
+ await setFilePublicWithLink(accessToken, fileData.id);
993
+ } catch (permError) {
994
+ console.error('Erreur lors de la configuration des permissions:', permError);
995
+ }
996
+
997
+ return {
998
+ fileId: fileData.id,
999
+ webViewLink: fileData.webViewLink || `https://drive.google.com/file/d/${fileData.id}/view`,
1000
+ };
1001
+ }
1002
+
1003
+ /**
1004
+ * Upload un template de contrat de rachat dans Google Drive
1005
+ * Similaire à uploadCerfaTemplate mais pour les contrats de transaction
1006
+ */
1007
+ export async function uploadContractTemplate(
1008
+ userId: string,
1009
+ file: File,
1010
+ ): Promise<{ fileId: string; webViewLink: string }> {
1011
+ const googleAccount = await getAdminGoogleAccount();
1012
+
1013
+ const accessToken = await getValidAccessToken(
1014
+ googleAccount.accessToken,
1015
+ googleAccount.refreshToken,
1016
+ googleAccount.tokenExpiresAt,
1017
+ );
1018
+
1019
+ if (accessToken !== googleAccount.accessToken) {
1020
+ const tokenExpiresAt = new Date();
1021
+ tokenExpiresAt.setSeconds(tokenExpiresAt.getSeconds() + 3600);
1022
+ await prisma.userGoogleAccount.update({
1023
+ where: { userId: googleAccount.userId },
1024
+ data: {
1025
+ accessToken,
1026
+ tokenExpiresAt,
1027
+ },
1028
+ });
1029
+ }
1030
+
1031
+ // 1. Créer ou récupérer le dossier racine
1032
+ const appFolderId = await getOrCreateFolder(accessToken, APP_NAME);
1033
+
1034
+ // 2. Créer ou récupérer le dossier "Paramètres"
1035
+ const settingsFolderId = await getOrCreateFolder(accessToken, 'Paramètres', appFolderId);
1036
+
1037
+ // Vérifier si un fichier avec le même nom existe déjà
1038
+ const searchQuery = `name='${encodeURIComponent(file.name)}' and '${settingsFolderId}' in parents and trashed=false`;
1039
+ const searchResponse = await googleFetch(
1040
+ `https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(searchQuery)}&fields=files(id,name,webViewLink)`,
1041
+ {
1042
+ headers: {
1043
+ Authorization: `Bearer ${accessToken}`,
1044
+ },
1045
+ },
1046
+ );
1047
+
1048
+ if (searchResponse.ok) {
1049
+ const searchData = await searchResponse.json();
1050
+ // Si un fichier avec le même nom existe déjà, le supprimer avant d'uploader le nouveau
1051
+ if (searchData.files && searchData.files.length > 0) {
1052
+ for (const existingFile of searchData.files) {
1053
+ await googleFetch(`https://www.googleapis.com/drive/v3/files/${existingFile.id}`, {
1054
+ method: 'DELETE',
1055
+ headers: {
1056
+ Authorization: `Bearer ${accessToken}`,
1057
+ },
1058
+ });
1059
+ }
1060
+ }
1061
+ }
1062
+
1063
+ // Uploader le fichier
1064
+ const metadata = {
1065
+ name: file.name,
1066
+ parents: [settingsFolderId],
1067
+ };
1068
+
1069
+ const formData = new FormData();
1070
+ formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
1071
+ formData.append('file', file);
1072
+
1073
+ const uploadResponse = await googleFetch(
1074
+ 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,webViewLink',
1075
+ {
1076
+ method: 'POST',
1077
+ headers: {
1078
+ Authorization: `Bearer ${accessToken}`,
1079
+ },
1080
+ body: formData,
1081
+ },
1082
+ );
1083
+
1084
+ if (!uploadResponse.ok) {
1085
+ const error = await uploadResponse.json();
1086
+ throw new Error(`Erreur lors de l'upload: ${JSON.stringify(error)}`);
1087
+ }
1088
+
1089
+ const fileData = await uploadResponse.json();
1090
+
1091
+ try {
1092
+ await setFilePublicWithLink(accessToken, fileData.id);
1093
+ } catch (permError) {
1094
+ console.error('Erreur lors de la configuration des permissions:', permError);
1095
+ }
1096
+
1097
+ return {
1098
+ fileId: fileData.id,
1099
+ webViewLink: fileData.webViewLink || `https://drive.google.com/file/d/${fileData.id}/view`,
1100
+ };
380
1101
  }