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.
- package/bin/create-crm-tmp.js +56 -35
- package/package.json +1 -1
- package/template/README.md +230 -115
- package/template/eslint.config.mjs +13 -0
- package/template/next.config.ts +14 -0
- package/template/package.json +15 -2
- package/template/prisma/migrations/20260318095700_init_db/migration.sql +978 -0
- package/template/prisma/migrations/migration_lock.toml +3 -0
- package/template/prisma/schema.prisma +132 -637
- package/template/src/app/(auth)/invite/[token]/page.tsx +10 -8
- package/template/src/app/(auth)/layout.tsx +1 -1
- package/template/src/app/(auth)/reset-password/complete/page.tsx +11 -8
- package/template/src/app/(auth)/reset-password/page.tsx +4 -4
- package/template/src/app/(auth)/reset-password/verify/page.tsx +4 -4
- package/template/src/app/(auth)/signin/page.tsx +14 -6
- package/template/src/app/(dashboard)/agenda/page.tsx +2243 -988
- package/template/src/app/(dashboard)/automatisation/_components/workflow-editor.tsx +18 -104
- package/template/src/app/(dashboard)/automatisation/page.tsx +10 -26
- package/template/src/app/(dashboard)/closing/page.tsx +78 -62
- package/template/src/app/(dashboard)/contacts/[id]/page.tsx +2082 -1080
- package/template/src/app/(dashboard)/contacts/companies/[id]/page.tsx +46 -47
- package/template/src/app/(dashboard)/contacts/page.tsx +1062 -780
- package/template/src/app/(dashboard)/dashboard/page.tsx +533 -37
- package/template/src/app/(dashboard)/dev/page.tsx +1291 -0
- package/template/src/app/(dashboard)/layout.tsx +6 -2
- package/template/src/app/(dashboard)/settings/page.tsx +797 -2582
- package/template/src/app/(dashboard)/templates/page.tsx +55 -54
- package/template/src/app/(dashboard)/users/list/page.tsx +51 -48
- package/template/src/app/(dashboard)/users/page.tsx +1 -1
- package/template/src/app/(dashboard)/users/permissions/page.tsx +2 -2
- package/template/src/app/(dashboard)/users/roles/page.tsx +7 -5
- package/template/src/app/api/agenda/google-events/route.ts +92 -0
- package/template/src/app/api/auth/check-active/route.ts +3 -2
- package/template/src/app/api/auth/google/route.ts +2 -1
- package/template/src/app/api/auth/google/status/route.ts +7 -31
- package/template/src/app/api/companies/[id]/activities/route.ts +1 -3
- package/template/src/app/api/companies/[id]/route.ts +1 -2
- package/template/src/app/api/companies/route.ts +42 -12
- package/template/src/app/api/contacts/[id]/files/[fileId]/preview/route.ts +9 -31
- package/template/src/app/api/contacts/[id]/files/[fileId]/route.ts +14 -32
- package/template/src/app/api/contacts/[id]/files/route.ts +112 -212
- package/template/src/app/api/contacts/[id]/interactions/[interactionId]/route.ts +27 -1
- package/template/src/app/api/contacts/[id]/interactions/route.ts +16 -16
- package/template/src/app/api/contacts/[id]/kyc/route.ts +21 -11
- package/template/src/app/api/contacts/[id]/meet/route.ts +19 -2
- package/template/src/app/api/contacts/[id]/route.ts +106 -34
- package/template/src/app/api/contacts/[id]/send-email/route.ts +27 -11
- package/template/src/app/api/contacts/[id]/workflows/run/route.ts +6 -0
- package/template/src/app/api/contacts/export/route.ts +9 -13
- package/template/src/app/api/contacts/import/route.ts +55 -25
- package/template/src/app/api/contacts/import-preview/route.ts +1 -1
- package/template/src/app/api/contacts/origins/route.ts +63 -0
- package/template/src/app/api/contacts/route.ts +153 -41
- package/template/src/app/api/cron/cleanup-editor-images/route.ts +166 -0
- package/template/src/app/api/dashboard/widgets/[id]/route.ts +44 -0
- package/template/src/app/api/dashboard/widgets/route.ts +181 -0
- package/template/src/app/api/dev/reminders/test/route.ts +114 -0
- package/template/src/app/api/editor/upload-image/route.ts +61 -0
- package/template/src/app/api/integrations/google-sheet/jobs/[jobId]/route.ts +47 -0
- package/template/src/app/api/integrations/google-sheet/jobs/usage/route.ts +50 -0
- package/template/src/app/api/integrations/google-sheet/sync/route.ts +24 -556
- package/template/src/app/api/jobs/google-sheet/process/route.ts +84 -0
- package/template/src/app/api/jobs/google-sheet/schedule/route.ts +50 -0
- package/template/src/app/api/reminders/clear/route.ts +120 -0
- package/template/src/app/api/reminders/clear/undo/route.ts +112 -0
- package/template/src/app/api/reminders/route.ts +164 -39
- package/template/src/app/api/reminders/state/route.ts +164 -0
- package/template/src/app/api/reset-password/request/route.ts +1 -1
- package/template/src/app/api/reset-password/verify/route.ts +1 -1
- package/template/src/app/api/send/route.ts +16 -4
- package/template/src/app/api/settings/google-ads/route.ts +14 -0
- package/template/src/app/api/settings/google-calendar/calendars/route.ts +97 -0
- package/template/src/app/api/settings/google-calendar/route.ts +124 -0
- package/template/src/app/api/settings/google-sheet/[id]/route.ts +28 -0
- package/template/src/app/api/settings/google-sheet/auto-map/route.ts +37 -4
- package/template/src/app/api/settings/google-sheet/preview/route.ts +9 -3
- package/template/src/app/api/settings/google-sheet/route.ts +14 -0
- package/template/src/app/api/settings/integrations/logs/route.ts +93 -0
- package/template/src/app/api/settings/integrations/notifications/route.ts +67 -0
- package/template/src/app/api/settings/meta-leads/[id]/route.ts +0 -1
- package/template/src/app/api/settings/meta-leads/route.ts +14 -2
- package/template/src/app/api/settings/smtp/route.ts +53 -6
- package/template/src/app/api/tasks/[id]/attendees/route.ts +24 -8
- package/template/src/app/api/tasks/[id]/route.ts +234 -58
- package/template/src/app/api/tasks/meet/route.ts +27 -19
- package/template/src/app/api/tasks/route.ts +62 -17
- package/template/src/app/api/users/[id]/route.ts +20 -14
- package/template/src/app/api/users/list/route.ts +57 -19
- package/template/src/app/api/webhooks/google-ads/route.ts +34 -14
- package/template/src/app/api/webhooks/meta-leads/route.ts +32 -12
- package/template/src/app/api/workflows/[id]/route.ts +0 -4
- package/template/src/app/api/workflows/process/route.ts +22 -51
- package/template/src/app/api/workflows/route.ts +0 -4
- package/template/src/app/globals.css +342 -4
- package/template/src/app/layout.tsx +11 -3
- package/template/src/app/page.tsx +1 -1
- package/template/src/components/address-autocomplete.tsx +7 -6
- package/template/src/components/config-error-alert.tsx +46 -0
- package/template/src/components/contacts/filter-bar.tsx +12 -3
- package/template/src/components/contacts/filter-builder.tsx +28 -43
- package/template/src/components/contacts/save-view-dialog.tsx +1 -1
- package/template/src/components/contacts/views-tab-bar.tsx +15 -6
- package/template/src/components/dashboard/activity-chart.tsx +41 -28
- package/template/src/components/dashboard/add-widget-dialog.tsx +157 -0
- package/template/src/components/dashboard/color-picker.tsx +64 -0
- package/template/src/components/dashboard/contacts-chart.tsx +69 -0
- package/template/src/components/dashboard/interactions-by-type-chart.tsx +121 -0
- package/template/src/components/dashboard/recent-activity.tsx +154 -0
- package/template/src/components/dashboard/stat-card.tsx +40 -40
- package/template/src/components/dashboard/status-distribution-chart.tsx +81 -0
- package/template/src/components/dashboard/tasks-pie-chart.tsx +37 -34
- package/template/src/components/dashboard/top-contacts-list.tsx +113 -0
- package/template/src/components/dashboard/upcoming-tasks-list.tsx +72 -81
- package/template/src/components/dashboard/widget-wrapper.tsx +36 -0
- package/template/src/components/date-picker.tsx +9 -6
- package/template/src/components/editor/upload-editor-image.ts +42 -0
- package/template/src/components/editor.tsx +161 -22
- package/template/src/components/email-template.tsx +2 -2
- package/template/src/components/global-search.tsx +30 -28
- package/template/src/components/header.tsx +178 -80
- package/template/src/components/inactive-account-guard.tsx +58 -0
- package/template/src/components/integration-notifications-listener.tsx +12 -0
- package/template/src/components/invitation-email-template.tsx +2 -2
- package/template/src/components/meet-cancellation-email-template.tsx +3 -3
- package/template/src/components/meet-confirmation-email-template.tsx +3 -3
- package/template/src/components/meet-update-email-template.tsx +3 -3
- package/template/src/components/page-header.tsx +5 -5
- package/template/src/components/protected-page.tsx +1 -1
- package/template/src/components/reset-password-email-template.tsx +2 -2
- package/template/src/components/settings/integrations/GoogleAdsIntegration.tsx +428 -0
- package/template/src/components/settings/integrations/GoogleSheetConfigMonitoringModal.tsx +680 -0
- package/template/src/components/settings/integrations/GoogleSheetIntegration.tsx +809 -0
- package/template/src/components/settings/integrations/ImportResultDialog.tsx +124 -0
- package/template/src/components/settings/integrations/IntegrationLogPanel.tsx +57 -0
- package/template/src/components/settings/integrations/IntegrationLogsTable.tsx +186 -0
- package/template/src/components/settings/integrations/MetaLeadIntegration.tsx +451 -0
- package/template/src/components/sidebar.tsx +45 -26
- package/template/src/components/skeleton.tsx +40 -43
- package/template/src/components/ui/accordion.tsx +2 -2
- package/template/src/components/ui/alert-dialog.tsx +1 -1
- package/template/src/components/ui/button.tsx +20 -9
- package/template/src/components/ui/components.tsx +1 -1
- package/template/src/components/ui/date-picker.tsx +422 -0
- package/template/src/components/ui/datetime-picker.tsx +338 -0
- package/template/src/components/ui/status-select.tsx +271 -0
- package/template/src/components/ui/tooltip.tsx +37 -0
- package/template/src/components/view-as-modal.tsx +13 -7
- package/template/src/contexts/app-toast-context.tsx +245 -57
- package/template/src/contexts/dashboard-theme-context.tsx +53 -0
- package/template/src/contexts/sidebar-context.tsx +22 -17
- package/template/src/contexts/task-reminder-context.tsx +134 -160
- package/template/src/contexts/view-as-context.tsx +33 -6
- package/template/src/hooks/use-focus-trap.ts +2 -2
- package/template/src/hooks/useIntegrationNotifications.ts +49 -0
- package/template/src/lib/auth.ts +8 -1
- package/template/src/lib/config-links.ts +14 -0
- package/template/src/lib/contact-duplicate.ts +79 -61
- package/template/src/lib/contact-interactions.ts +21 -21
- package/template/src/lib/contact-view-filters.ts +24 -64
- package/template/src/lib/contacts-list-url.ts +190 -0
- package/template/src/lib/dashboard-stats.ts +65 -7
- package/template/src/lib/dashboard-themes.ts +135 -0
- package/template/src/lib/date-utils.ts +127 -0
- package/template/src/lib/default-widgets.ts +12 -0
- package/template/src/lib/editor-html-image-dimensions.ts +172 -0
- package/template/src/lib/editor-image-limits.ts +19 -0
- package/template/src/lib/email-html-sanitize.ts +19 -0
- package/template/src/lib/encryption.ts +9 -6
- package/template/src/lib/fr-geography.ts +192 -0
- package/template/src/lib/google-calendar-agenda.ts +201 -0
- package/template/src/lib/google-calendar.ts +255 -5
- package/template/src/lib/google-sheet-sync-jobs.ts +96 -0
- package/template/src/lib/google-sheet-sync-runner.ts +514 -0
- package/template/src/lib/integration-import-log.ts +21 -0
- package/template/src/lib/permissions.ts +40 -10
- package/template/src/lib/prisma.ts +4 -1
- package/template/src/lib/qstash.ts +65 -0
- package/template/src/lib/reminder-state-server.ts +80 -0
- package/template/src/lib/reminder-state.ts +29 -0
- package/template/src/lib/supabase-storage.ts +113 -0
- package/template/src/lib/template-variables.ts +164 -23
- package/template/src/lib/utils.ts +45 -0
- package/template/src/lib/widget-registry.ts +173 -0
- package/template/src/lib/workflow-executor.ts +16 -70
- package/template/src/proxy.ts +1 -0
- package/template/vercel.json +3 -10
- package/template/skills-lock.json +0 -25
- package/template/src/components/dashboard/dashboard-content.tsx +0 -79
- package/template/src/lib/google-drive.ts +0 -1101
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|