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