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
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Utilitaires agenda Google Calendar sans dépendance serveur (importable depuis Client Components).
3
+ */
4
+
5
+ export interface GoogleAgendaEventItem {
6
+ googleEventId: string;
7
+ calendarId: string;
8
+ title: string;
9
+ start: string; // ISO (évènements horodatés) ; pour all-day, voir allDayStartDate / allDayEndExclusive
10
+ end: string; // ISO
11
+ htmlLink?: string | null;
12
+ allDay: boolean;
13
+ /** YYYY-MM-DD (API Google). Présent si allDay. */
14
+ allDayStartDate?: string;
15
+ /** YYYY-MM-DD exclusif (API Google : lendemain du dernier jour inclus). Présent si allDay. */
16
+ allDayEndExclusive?: string;
17
+ }
18
+
19
+ /**
20
+ * Calendriers proposés comme cible à la création (tâche, RDV, Meet) : intersection des calendriers
21
+ * en écriture et des calendriers cochés « Afficher dans l'agenda du CRM » dans les paramètres.
22
+ * Si aucune préférence n'est enregistrée (liste vide côté API), tous les calendriers en écriture sont proposés.
23
+ */
24
+ export function googleCalendarsForEventTargetPicker<T extends { id: string }>(
25
+ writableCalendars: T[],
26
+ agendaVisibleGoogleCalendarIds: string[] | undefined,
27
+ ): T[] {
28
+ const visible = agendaVisibleGoogleCalendarIds ?? [];
29
+ if (visible.length === 0) return writableCalendars;
30
+ return writableCalendars.filter((c) => visible.includes(c.id));
31
+ }
32
+
33
+ /** Clé calendrier locale YYYY-MM-DD (affichage / filtres agenda). */
34
+ export function localCalendarDateKey(d: Date): string {
35
+ const y = d.getFullYear();
36
+ const m = String(d.getMonth() + 1).padStart(2, '0');
37
+ const day = String(d.getDate()).padStart(2, '0');
38
+ return `${y}-${m}-${day}`;
39
+ }
40
+
41
+ /** Bornes locales pour une plage all-day API (start YYYY-MM-DD, end exclusif YYYY-MM-DD). */
42
+ export function googleAllDayLocalBoundsFromApiDates(
43
+ allDayStartDate: string,
44
+ allDayEndExclusive: string,
45
+ ): { start: Date; end: Date } {
46
+ const [sy, sm, sd] = allDayStartDate.split('-').map(Number);
47
+ const start = new Date(sy, sm - 1, sd, 0, 0, 0, 0);
48
+ const [ey, em, ed] = allDayEndExclusive.split('-').map(Number);
49
+ const endExclusiveLocal = new Date(ey, em - 1, ed, 0, 0, 0, 0);
50
+ const end = new Date(endExclusiveLocal.getTime() - 1);
51
+ return { start, end };
52
+ }
53
+
54
+ /**
55
+ * Plage locale pour chevauchement / layout : journée entière = [start 00:00, fin 23:59:59.999]
56
+ * du dernier jour inclus (end exclusif Google).
57
+ */
58
+ export function googleAgendaEventLocalTimeRange(ev: GoogleAgendaEventItem): { start: Date; end: Date } {
59
+ if (ev.allDay && ev.allDayStartDate && ev.allDayEndExclusive) {
60
+ return googleAllDayLocalBoundsFromApiDates(ev.allDayStartDate, ev.allDayEndExclusive);
61
+ }
62
+ return { start: new Date(ev.start), end: new Date(ev.end) };
63
+ }
64
+
65
+ /** true si l'évènement all-day Google doit apparaître sur ce jour civil local. */
66
+ export function googleAllDayCoversLocalDate(ev: GoogleAgendaEventItem, date: Date): boolean {
67
+ if (!ev.allDay || !ev.allDayStartDate || !ev.allDayEndExclusive) return false;
68
+ const k = localCalendarDateKey(date);
69
+ return ev.allDayStartDate <= k && k < ev.allDayEndExclusive;
70
+ }
71
+
72
+ const AGENDA_GOOGLE_COLOR_HEX_RE = /^#[0-9A-Fa-f]{6}$/;
73
+
74
+ /** Bleu par défaut (lisible sur fond clair). */
75
+ export const DEFAULT_GOOGLE_AGENDA_EVENT_COLOR = '#3b82f6';
76
+
77
+ /** Valide / normalise une couleur stockée (#RRGGBB). */
78
+ export function normalizeAgendaGoogleEventColor(input: string | null | undefined): string {
79
+ if (typeof input === 'string' && AGENDA_GOOGLE_COLOR_HEX_RE.test(input.trim())) {
80
+ return input.trim();
81
+ }
82
+ return DEFAULT_GOOGLE_AGENDA_EVENT_COLOR;
83
+ }
84
+
85
+ function hexToRgb(hex: string): { r: number; g: number; b: number } {
86
+ const h = normalizeAgendaGoogleEventColor(hex).slice(1);
87
+ return {
88
+ r: Number.parseInt(h.slice(0, 2), 16),
89
+ g: Number.parseInt(h.slice(2, 4), 16),
90
+ b: Number.parseInt(h.slice(4, 6), 16),
91
+ };
92
+ }
93
+
94
+ function srgbChannelToLinear(v: number): number {
95
+ const x = v / 255;
96
+ return x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
97
+ }
98
+
99
+ /** Luminance relative sRGB (WCAG), pour borner le contraste avec du texte blanc. */
100
+ function relativeLuminance(r: number, g: number, b: number): number {
101
+ const lr = srgbChannelToLinear(r);
102
+ const lg = srgbChannelToLinear(g);
103
+ const lb = srgbChannelToLinear(b);
104
+ return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb;
105
+ }
106
+
107
+ /** Mélange la couleur marque vers le noir (teinte conservée, luminosité baissée). */
108
+ function mixRgbTowardBlack(
109
+ r: number,
110
+ g: number,
111
+ b: number,
112
+ /** 0 = couleur d'origine, 1 = slate très sombre */
113
+ blackWeight: number,
114
+ ): { r: number; g: number; b: number } {
115
+ const k = Math.min(1, Math.max(0, blackWeight));
116
+ const br = 15;
117
+ const bg = 23;
118
+ const bb = 42;
119
+ return {
120
+ r: Math.round(r + (br - r) * k),
121
+ g: Math.round(g + (bg - g) * k),
122
+ b: Math.round(b + (bb - b) * k),
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Assombrit le minimum nécessaire pour que le blanc reste lisible (~contraste 3.2+),
128
+ * sans forcer un mélange noir trop fort (couleurs encore reconnaissables).
129
+ */
130
+ function rgbDarkenedForWhiteText(
131
+ r: number,
132
+ g: number,
133
+ b: number,
134
+ /** luminance max du fond (plus bas = texte blanc plus exigeant) */
135
+ maxBackgroundLuminance: number,
136
+ /** plafond de mélange vers le noir (évite les boues si la couleur est très claire) */
137
+ maxBlackMix: number,
138
+ ): { r: number; g: number; b: number } {
139
+ if (relativeLuminance(r, g, b) <= maxBackgroundLuminance) {
140
+ return { r, g, b };
141
+ }
142
+ let lo = 0;
143
+ let hi = maxBlackMix;
144
+ for (let i = 0; i < 16; i++) {
145
+ const mid = (lo + hi) / 2;
146
+ const c = mixRgbTowardBlack(r, g, b, mid);
147
+ if (relativeLuminance(c.r, c.g, c.b) > maxBackgroundLuminance) {
148
+ lo = mid;
149
+ } else {
150
+ hi = mid;
151
+ }
152
+ }
153
+ let out = mixRgbTowardBlack(r, g, b, hi);
154
+ if (relativeLuminance(out.r, out.g, out.b) > maxBackgroundLuminance) {
155
+ out = mixRgbTowardBlack(r, g, b, maxBlackMix);
156
+ }
157
+ return out;
158
+ }
159
+
160
+ function toRgbString(rgb: { r: number; g: number; b: number }): string {
161
+ return `rgb(${rgb.r},${rgb.g},${rgb.b})`;
162
+ }
163
+
164
+ const GOOGLE_AGENDA_ON_DARK_FG = '#ffffff';
165
+ const GOOGLE_AGENDA_ON_DARK_FG_MUTED = 'rgba(255,255,255,0.92)';
166
+
167
+ /** Pastilles / barres : contraste un peu plus sûr, teinte = couleur choisie (pastilles paramètres). */
168
+ export function googleAgendaEventChromeStyle(hex: string): {
169
+ backgroundColor: string;
170
+ borderColor: string;
171
+ color: string;
172
+ iconColor: string;
173
+ } {
174
+ const { r, g, b } = hexToRgb(hex);
175
+ const bgRgb = rgbDarkenedForWhiteText(r, g, b, 0.26, 0.62);
176
+ const borderRgb = mixRgbTowardBlack(bgRgb.r, bgRgb.g, bgRgb.b, 0.2);
177
+ return {
178
+ backgroundColor: toRgbString(bgRgb),
179
+ borderColor: toRgbString(borderRgb),
180
+ color: GOOGLE_AGENDA_ON_DARK_FG,
181
+ iconColor: GOOGLE_AGENDA_ON_DARK_FG,
182
+ };
183
+ }
184
+
185
+ /** Grandes cartes : un peu plus claires que les pastilles (même teinte, moins de sombrance). */
186
+ export function googleAgendaEventCardChromeStyle(hex: string): {
187
+ backgroundColor: string;
188
+ borderColor: string;
189
+ color: string;
190
+ iconColor: string;
191
+ } {
192
+ const { r, g, b } = hexToRgb(hex);
193
+ const bgRgb = rgbDarkenedForWhiteText(r, g, b, 0.3, 0.62);
194
+ const borderRgb = mixRgbTowardBlack(bgRgb.r, bgRgb.g, bgRgb.b, 0.18);
195
+ return {
196
+ backgroundColor: toRgbString(bgRgb),
197
+ borderColor: toRgbString(borderRgb),
198
+ color: GOOGLE_AGENDA_ON_DARK_FG,
199
+ iconColor: GOOGLE_AGENDA_ON_DARK_FG_MUTED,
200
+ };
201
+ }
@@ -1,10 +1,73 @@
1
1
  /**
2
- * Utilitaires pour gérer l'authentification et les appels à Google Calendar API
2
+ * Utilitaires pour gérer l'authentification et les appels à Google Calendar/Meet API
3
3
  */
4
4
 
5
5
  import { prisma } from './prisma';
6
6
  import { decrypt } from './encryption';
7
7
  import { googleFetch } from './google-fetch';
8
+ import type { GoogleAgendaEventItem } from './google-calendar-agenda';
9
+ import { googleAllDayLocalBoundsFromApiDates } from './google-calendar-agenda';
10
+
11
+ export type { GoogleAgendaEventItem } from './google-calendar-agenda';
12
+ export {
13
+ localCalendarDateKey,
14
+ googleAllDayLocalBoundsFromApiDates,
15
+ googleAgendaEventLocalTimeRange,
16
+ googleAllDayCoversLocalDate,
17
+ } from './google-calendar-agenda';
18
+
19
+ export type GoogleCalendarContactFooterInput = {
20
+ firstName?: string | null;
21
+ lastName?: string | null;
22
+ email: string;
23
+ };
24
+
25
+ /** Ligne de pied de page « Contact » pour la description Google Calendar (texte brut). */
26
+ export function formatGoogleCalendarContactFooter(
27
+ contact: GoogleCalendarContactFooterInput,
28
+ ): string {
29
+ const name =
30
+ [contact.firstName, contact.lastName].filter(Boolean).join(' ').trim() ||
31
+ contact.email.split('@')[0];
32
+ return `Contact : ${name} – ${contact.email}`;
33
+ }
34
+
35
+ /** Ajoute le pied de page contact sans dupliquer s’il est déjà en fin de texte. */
36
+ export function appendGoogleCalendarContactFooter(
37
+ plainText: string,
38
+ contact: GoogleCalendarContactFooterInput,
39
+ ): string {
40
+ const footer = formatGoogleCalendarContactFooter(contact);
41
+ const trimmed = plainText.trimEnd();
42
+ if (!trimmed) return footer;
43
+ if (trimmed === footer || trimmed.endsWith(`\n${footer}`) || trimmed.endsWith(footer)) {
44
+ return trimmed;
45
+ }
46
+ return `${trimmed}\n\n${footer}`;
47
+ }
48
+
49
+ /**
50
+ * Récupère le compte Google de l'administrateur (utilisé pour Calendar, Meet, Sheets).
51
+ */
52
+ export async function getAdminGoogleAccount() {
53
+ const adminUser = await prisma.user.findFirst({
54
+ where: { role: 'ADMIN' },
55
+ include: { googleAccount: true },
56
+ orderBy: { createdAt: 'asc' },
57
+ });
58
+
59
+ if (!adminUser || !adminUser.googleAccount) {
60
+ throw new Error(
61
+ 'Aucun compte Google configuré. Veuillez demander à un administrateur de connecter son compte Google dans Paramètres.',
62
+ );
63
+ }
64
+
65
+ return {
66
+ ...adminUser.googleAccount,
67
+ accessToken: decrypt(adminUser.googleAccount.accessToken),
68
+ refreshToken: decrypt(adminUser.googleAccount.refreshToken),
69
+ };
70
+ }
8
71
 
9
72
  interface GoogleTokenResponse {
10
73
  access_token: string;
@@ -13,6 +76,13 @@ interface GoogleTokenResponse {
13
76
  token_type: string;
14
77
  }
15
78
 
79
+ export interface GoogleCalendarListItem {
80
+ id: string;
81
+ summary: string;
82
+ primary?: boolean;
83
+ accessRole?: string;
84
+ }
85
+
16
86
  interface GoogleCalendarEvent {
17
87
  id?: string;
18
88
  summary: string;
@@ -224,15 +294,192 @@ export async function getValidAccessToken(
224
294
  return accessToken;
225
295
  }
226
296
 
297
+ /** ID calendrier stocké en base (null = ancien comportement = primary). */
298
+ export function resolveTaskGoogleCalendarId(stored: string | null | undefined): string {
299
+ return stored && stored.trim() !== '' ? stored : 'primary';
300
+ }
301
+
302
+ function calendarEventsBaseUrl(calendarId: string): string {
303
+ return `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`;
304
+ }
305
+
306
+ /**
307
+ * Liste les calendriers accessibles (calendarList), avec pagination.
308
+ */
309
+ export async function listGoogleCalendars(accessToken: string): Promise<GoogleCalendarListItem[]> {
310
+ const out: GoogleCalendarListItem[] = [];
311
+ let pageToken: string | undefined;
312
+
313
+ do {
314
+ const params = new URLSearchParams({ minAccessRole: 'freeBusyReader' });
315
+ if (pageToken) params.set('pageToken', pageToken);
316
+
317
+ const response = await googleFetch(
318
+ `https://www.googleapis.com/calendar/v3/users/me/calendarList?${params.toString()}`,
319
+ {
320
+ method: 'GET',
321
+ headers: {
322
+ Authorization: `Bearer ${accessToken}`,
323
+ },
324
+ },
325
+ );
326
+
327
+ if (!response.ok) {
328
+ const error = await response.text();
329
+ throw new Error(`Erreur lors de la liste des calendriers: ${error}`);
330
+ }
331
+
332
+ const data = (await response.json()) as {
333
+ items?: Array<{ id: string; summary?: string; primary?: boolean; accessRole?: string }>;
334
+ nextPageToken?: string;
335
+ };
336
+
337
+ for (const item of data.items ?? []) {
338
+ if (item.id) {
339
+ out.push({
340
+ id: item.id,
341
+ summary: item.summary || item.id,
342
+ primary: item.primary,
343
+ accessRole: item.accessRole,
344
+ });
345
+ }
346
+ }
347
+ pageToken = data.nextPageToken;
348
+ } while (pageToken);
349
+
350
+ return out;
351
+ }
352
+
353
+ const WRITABLE_CALENDAR_ROLES = new Set(['owner', 'writer']);
354
+
355
+ /**
356
+ * Vérifie que l'utilisateur peut créer des événements sur ce calendrier.
357
+ */
358
+ export async function assertWritableGoogleCalendar(
359
+ accessToken: string,
360
+ calendarId: string,
361
+ ): Promise<void> {
362
+ const resolved = calendarId.trim() === '' ? 'primary' : calendarId;
363
+ const response = await googleFetch(
364
+ `https://www.googleapis.com/calendar/v3/users/me/calendarList/${encodeURIComponent(resolved)}`,
365
+ {
366
+ method: 'GET',
367
+ headers: { Authorization: `Bearer ${accessToken}` },
368
+ },
369
+ );
370
+
371
+ if (!response.ok) {
372
+ const error = await response.text();
373
+ throw new Error(`Calendrier Google invalide ou inaccessible: ${error}`);
374
+ }
375
+
376
+ const entry = (await response.json()) as { accessRole?: string };
377
+ if (!entry.accessRole || !WRITABLE_CALENDAR_ROLES.has(entry.accessRole)) {
378
+ throw new Error('Vous n’avez pas la permission d’ajouter des événements sur ce calendrier.');
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Vérifie qu'un ID appartient à la liste (pour préférences agenda / défaut).
384
+ */
385
+ export async function assertKnownGoogleCalendar(
386
+ accessToken: string,
387
+ calendarId: string,
388
+ ): Promise<void> {
389
+ const resolved = calendarId.trim() === '' ? 'primary' : calendarId;
390
+ const response = await googleFetch(
391
+ `https://www.googleapis.com/calendar/v3/users/me/calendarList/${encodeURIComponent(resolved)}`,
392
+ {
393
+ method: 'GET',
394
+ headers: { Authorization: `Bearer ${accessToken}` },
395
+ },
396
+ );
397
+
398
+ if (!response.ok) {
399
+ const error = await response.text();
400
+ throw new Error(`Calendrier Google invalide ou inaccessible: ${error}`);
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Événements sur une plage (pour l'agenda CRM). singleEvents + orderBy=startTime.
406
+ */
407
+ export async function listGoogleCalendarEvents(
408
+ accessToken: string,
409
+ calendarId: string,
410
+ timeMin: Date,
411
+ timeMax: Date,
412
+ ): Promise<GoogleAgendaEventItem[]> {
413
+ const params = new URLSearchParams({
414
+ timeMin: timeMin.toISOString(),
415
+ timeMax: timeMax.toISOString(),
416
+ singleEvents: 'true',
417
+ orderBy: 'startTime',
418
+ maxResults: '2500',
419
+ });
420
+
421
+ const response = await googleFetch(`${calendarEventsBaseUrl(calendarId)}?${params.toString()}`, {
422
+ method: 'GET',
423
+ headers: { Authorization: `Bearer ${accessToken}` },
424
+ });
425
+
426
+ if (!response.ok) {
427
+ const error = await response.text();
428
+ throw new Error(`Erreur lors du chargement des événements: ${error}`);
429
+ }
430
+
431
+ const data = (await response.json()) as {
432
+ items?: Array<{
433
+ id: string;
434
+ summary?: string;
435
+ start?: { dateTime?: string; date?: string; timeZone?: string };
436
+ end?: { dateTime?: string; date?: string; timeZone?: string };
437
+ htmlLink?: string;
438
+ }>;
439
+ };
440
+
441
+ const items: GoogleAgendaEventItem[] = [];
442
+ for (const ev of data.items ?? []) {
443
+ if (!ev.id) continue;
444
+ const allDay = Boolean(ev.start?.date && !ev.start?.dateTime);
445
+ const startRaw = ev.start?.dateTime || ev.start?.date;
446
+ const endRaw = ev.end?.dateTime || ev.end?.date;
447
+ if (!startRaw || !endRaw) continue;
448
+
449
+ let startIso = startRaw;
450
+ let endIso = endRaw;
451
+ /** Pour all-day, Google donne end.date exclusif : bornes locales pour ISO + champs bruts pour filtres. */
452
+ if (allDay) {
453
+ const { start, end } = googleAllDayLocalBoundsFromApiDates(startRaw, endRaw);
454
+ startIso = start.toISOString();
455
+ endIso = end.toISOString();
456
+ }
457
+
458
+ items.push({
459
+ googleEventId: ev.id,
460
+ calendarId,
461
+ title: ev.summary?.trim() ? ev.summary : '(Sans titre)',
462
+ start: startIso,
463
+ end: endIso,
464
+ htmlLink: ev.htmlLink ?? null,
465
+ allDay,
466
+ ...(allDay ? { allDayStartDate: startRaw, allDayEndExclusive: endRaw } : {}),
467
+ });
468
+ }
469
+
470
+ return items;
471
+ }
472
+
227
473
  /**
228
474
  * Crée un évènement Google Calendar avec Google Meet
229
475
  */
230
476
  export async function createGoogleCalendarEvent(
231
477
  accessToken: string,
478
+ calendarId: string,
232
479
  event: GoogleCalendarEvent,
233
480
  ): Promise<GoogleCalendarEventResponse> {
234
481
  const response = await googleFetch(
235
- 'https://www.googleapis.com/calendar/v3/calendars/primary/events?conferenceDataVersion=1',
482
+ `${calendarEventsBaseUrl(calendarId)}?conferenceDataVersion=1`,
236
483
  {
237
484
  method: 'POST',
238
485
  headers: {
@@ -256,11 +503,12 @@ export async function createGoogleCalendarEvent(
256
503
  */
257
504
  export async function updateGoogleCalendarEvent(
258
505
  accessToken: string,
506
+ calendarId: string,
259
507
  eventId: string,
260
508
  event: Partial<GoogleCalendarEvent>,
261
509
  ): Promise<GoogleCalendarEventResponse> {
262
510
  const response = await googleFetch(
263
- `https://www.googleapis.com/calendar/v3/calendars/primary/events/${eventId}?conferenceDataVersion=1`,
511
+ `${calendarEventsBaseUrl(calendarId)}/${encodeURIComponent(eventId)}?conferenceDataVersion=1`,
264
512
  {
265
513
  method: 'PATCH',
266
514
  headers: {
@@ -284,10 +532,11 @@ export async function updateGoogleCalendarEvent(
284
532
  */
285
533
  export async function deleteGoogleCalendarEvent(
286
534
  accessToken: string,
535
+ calendarId: string,
287
536
  eventId: string,
288
537
  ): Promise<void> {
289
538
  const response = await googleFetch(
290
- `https://www.googleapis.com/calendar/v3/calendars/primary/events/${eventId}`,
539
+ `${calendarEventsBaseUrl(calendarId)}/${encodeURIComponent(eventId)}`,
291
540
  {
292
541
  method: 'DELETE',
293
542
  headers: {
@@ -320,10 +569,11 @@ export function extractMeetLink(eventResponse: GoogleCalendarEventResponse): str
320
569
  */
321
570
  export async function getGoogleCalendarEvent(
322
571
  accessToken: string,
572
+ calendarId: string,
323
573
  eventId: string,
324
574
  ): Promise<GoogleCalendarEventResponse> {
325
575
  const response = await googleFetch(
326
- `https://www.googleapis.com/calendar/v3/calendars/primary/events/${eventId}`,
576
+ `${calendarEventsBaseUrl(calendarId)}/${encodeURIComponent(eventId)}`,
327
577
  {
328
578
  method: 'GET',
329
579
  headers: {
@@ -0,0 +1,96 @@
1
+ import { prisma } from '@/lib/prisma';
2
+ import { publishGoogleSheetSyncJob } from '@/lib/qstash';
3
+
4
+ type EnqueueParams = {
5
+ requestedByUserId?: string | null;
6
+ configId?: string | null;
7
+ triggerType?: 'MANUAL' | 'SCHEDULED';
8
+ };
9
+
10
+ const MANUAL_RATE_LIMIT_WINDOW_MS = 60_000;
11
+ const MANUAL_RATE_LIMIT_MAX_PER_WINDOW = 6;
12
+ const STALE_JOB_TIMEOUT_MS = 10 * 60_000; // 10 minutes
13
+
14
+ export class SyncJobRateLimitError extends Error {
15
+ constructor(message: string) {
16
+ super(message);
17
+ this.name = 'SyncJobRateLimitError';
18
+ }
19
+ }
20
+
21
+ export async function enqueueGoogleSheetSyncJob(params: EnqueueParams) {
22
+ const { requestedByUserId = null, configId = null, triggerType = 'MANUAL' } = params;
23
+
24
+ if (triggerType === 'MANUAL' && requestedByUserId) {
25
+ const windowStart = new Date(Date.now() - MANUAL_RATE_LIMIT_WINDOW_MS);
26
+ const manualCount = await prisma.googleSheetSyncJob.count({
27
+ where: {
28
+ triggerType: 'MANUAL',
29
+ requestedByUserId,
30
+ createdAt: {
31
+ gte: windowStart,
32
+ },
33
+ },
34
+ });
35
+ if (manualCount >= MANUAL_RATE_LIMIT_MAX_PER_WINDOW) {
36
+ throw new SyncJobRateLimitError(
37
+ 'Trop de synchronisations demandées en peu de temps. Réessayez dans une minute.',
38
+ );
39
+ }
40
+ }
41
+
42
+ const pendingStatuses = ['QUEUED', 'RUNNING'] as const;
43
+ const staleThreshold = new Date(Date.now() - STALE_JOB_TIMEOUT_MS);
44
+
45
+ // Mark stale jobs as FAILED so they don't block new ones
46
+ await prisma.googleSheetSyncJob.updateMany({
47
+ where: {
48
+ status: { in: [...pendingStatuses] },
49
+ configId: configId ?? null,
50
+ triggerType,
51
+ createdAt: { lt: staleThreshold },
52
+ },
53
+ data: {
54
+ status: 'FAILED',
55
+ finishedAt: new Date(),
56
+ error: 'Job expiré (timeout)',
57
+ },
58
+ });
59
+
60
+ const alreadyPending = await prisma.googleSheetSyncJob.findFirst({
61
+ where: {
62
+ status: { in: [...pendingStatuses] },
63
+ configId: configId ?? null,
64
+ triggerType,
65
+ },
66
+ orderBy: { createdAt: 'desc' },
67
+ });
68
+ if (alreadyPending) {
69
+ return alreadyPending;
70
+ }
71
+
72
+ const job = await prisma.googleSheetSyncJob.create({
73
+ data: {
74
+ requestedByUserId,
75
+ configId,
76
+ triggerType,
77
+ payload: configId ? { configId } : {},
78
+ },
79
+ });
80
+
81
+ try {
82
+ await publishGoogleSheetSyncJob({ jobId: job.id });
83
+ } catch (publishError: any) {
84
+ await prisma.googleSheetSyncJob.update({
85
+ where: { id: job.id },
86
+ data: {
87
+ status: 'FAILED',
88
+ finishedAt: new Date(),
89
+ error: publishError?.message || "Impossible d'envoyer le job à QStash",
90
+ },
91
+ });
92
+ throw publishError;
93
+ }
94
+
95
+ return job;
96
+ }