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
|
@@ -1,573 +1,41 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
-
import { prisma } from '@/lib/prisma';
|
|
3
|
-
import { getValidAccessToken, GoogleTokenError } from '@/lib/google-calendar';
|
|
4
|
-
import { handleContactDuplicate } from '@/lib/contact-duplicate';
|
|
5
|
-
import { googleFetch } from '@/lib/google-fetch';
|
|
6
|
-
import { encrypt, decrypt } from '@/lib/encryption';
|
|
7
2
|
import { auth } from '@/lib/auth';
|
|
3
|
+
import { enqueueGoogleSheetSyncJob, SyncJobRateLimitError } from '@/lib/google-sheet-sync-jobs';
|
|
8
4
|
|
|
9
|
-
// POST /api/integrations/google-sheet/sync
|
|
5
|
+
// POST /api/integrations/google-sheet/sync — tout utilisateur connecté peut lancer une synchro.
|
|
6
|
+
// La permission integrations.google_sheets.manage reste requise côté UI / routes settings pour configurer l’intégration.
|
|
10
7
|
export async function POST(request: NextRequest) {
|
|
11
8
|
try {
|
|
12
|
-
const client = prisma as any;
|
|
13
9
|
const session = await auth.api.getSession({ headers: request.headers });
|
|
14
10
|
if (!session) {
|
|
15
11
|
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
|
16
12
|
}
|
|
17
13
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
},
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
if (!configs || configs.length === 0) {
|
|
27
|
-
return NextResponse.json({
|
|
28
|
-
totalImported: 0,
|
|
29
|
-
totalUpdated: 0,
|
|
30
|
-
totalSkipped: 0,
|
|
31
|
-
results: [],
|
|
32
|
-
message: "Aucune configuration Google Sheets active n'a été trouvée.",
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const results: Array<{
|
|
37
|
-
configId: string;
|
|
38
|
-
configName: string;
|
|
39
|
-
imported: number;
|
|
40
|
-
updated: number;
|
|
41
|
-
skipped: number;
|
|
42
|
-
error?: string;
|
|
43
|
-
}> = [];
|
|
44
|
-
|
|
45
|
-
let totalImported = 0;
|
|
46
|
-
let totalUpdated = 0;
|
|
47
|
-
let totalSkipped = 0;
|
|
48
|
-
|
|
49
|
-
// Synchroniser chaque configuration
|
|
50
|
-
for (const config of configs) {
|
|
51
|
-
const lockKey = `google_sheet_sync:${config.id}`;
|
|
52
|
-
let lockAcquired = false;
|
|
53
|
-
try {
|
|
54
|
-
const lockResult = await client.$queryRaw<
|
|
55
|
-
Array<{ locked: boolean }>
|
|
56
|
-
>`SELECT pg_try_advisory_lock(hashtext(${lockKey}), 4261) AS locked`;
|
|
57
|
-
lockAcquired = lockResult?.[0]?.locked === true;
|
|
58
|
-
if (!lockAcquired) {
|
|
59
|
-
results.push({
|
|
60
|
-
configId: config.id,
|
|
61
|
-
configName: config.name,
|
|
62
|
-
imported: 0,
|
|
63
|
-
updated: 0,
|
|
64
|
-
skipped: 0,
|
|
65
|
-
error:
|
|
66
|
-
'Une synchronisation est déjà en cours pour cette configuration. Veuillez patienter quelques secondes puis relancer.',
|
|
67
|
-
});
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const range = encodeURIComponent(config.sheetName);
|
|
72
|
-
const candidateUserIds = Array.from(
|
|
73
|
-
new Set(
|
|
74
|
-
[config.ownerUserId, config.defaultAssignedUserId, session.user.id].filter(
|
|
75
|
-
(value): value is string => Boolean(value),
|
|
76
|
-
),
|
|
77
|
-
),
|
|
78
|
-
);
|
|
79
|
-
|
|
80
|
-
let selectedSheetsResponse: Response | null = null;
|
|
81
|
-
let lastAuthError =
|
|
82
|
-
'Aucun compte Google compatible trouvé pour cette configuration. Reconnectez Google puis partagez le Sheet avec ce compte.';
|
|
83
|
-
|
|
84
|
-
for (const candidateUserId of candidateUserIds) {
|
|
85
|
-
const googleAccount = await client.userGoogleAccount.findUnique({
|
|
86
|
-
where: { userId: candidateUserId },
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
if (!googleAccount) {
|
|
90
|
-
lastAuthError = 'Aucun compte Google connecté parmi les utilisateurs autorisés.';
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
try {
|
|
95
|
-
const decryptedAccess = decrypt(googleAccount.accessToken);
|
|
96
|
-
const decryptedRefresh = decrypt(googleAccount.refreshToken);
|
|
97
|
-
const accessToken = await getValidAccessToken(
|
|
98
|
-
decryptedAccess,
|
|
99
|
-
decryptedRefresh,
|
|
100
|
-
googleAccount.tokenExpiresAt,
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
if (accessToken !== decryptedAccess) {
|
|
104
|
-
const tokenExpiresAt = new Date();
|
|
105
|
-
tokenExpiresAt.setSeconds(tokenExpiresAt.getSeconds() + 3600);
|
|
106
|
-
await client.userGoogleAccount.update({
|
|
107
|
-
where: { userId: candidateUserId },
|
|
108
|
-
data: {
|
|
109
|
-
accessToken: encrypt(accessToken),
|
|
110
|
-
tokenExpiresAt,
|
|
111
|
-
},
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const candidateResponse = await googleFetch(
|
|
116
|
-
`https://sheets.googleapis.com/v4/spreadsheets/${config.spreadsheetId}/values/${range}`,
|
|
117
|
-
{
|
|
118
|
-
headers: {
|
|
119
|
-
Authorization: `Bearer ${accessToken}`,
|
|
120
|
-
},
|
|
121
|
-
},
|
|
122
|
-
);
|
|
123
|
-
|
|
124
|
-
if (candidateResponse.ok) {
|
|
125
|
-
selectedSheetsResponse = candidateResponse;
|
|
126
|
-
break;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const errorText = await candidateResponse.text();
|
|
130
|
-
lastAuthError =
|
|
131
|
-
candidateResponse.status === 401 || candidateResponse.status === 403
|
|
132
|
-
? 'Le compte Google connecté n’a pas accès à ce Sheet. Partagez le document avec ce compte ou reconnectez Google.'
|
|
133
|
-
: 'Impossible de lire les données depuis Google Sheets.';
|
|
134
|
-
console.error(
|
|
135
|
-
`Erreur lors de la lecture du Google Sheet ${config.name} (user ${candidateUserId}):`,
|
|
136
|
-
errorText,
|
|
137
|
-
);
|
|
138
|
-
} catch (error: any) {
|
|
139
|
-
if (error instanceof GoogleTokenError && error.isRevoked) {
|
|
140
|
-
lastAuthError = error.message;
|
|
141
|
-
} else {
|
|
142
|
-
lastAuthError = error?.message || 'Erreur lors de la validation du compte Google.';
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
if (!selectedSheetsResponse) {
|
|
148
|
-
results.push({
|
|
149
|
-
configId: config.id,
|
|
150
|
-
configName: config.name,
|
|
151
|
-
imported: 0,
|
|
152
|
-
updated: 0,
|
|
153
|
-
skipped: 0,
|
|
154
|
-
error: lastAuthError,
|
|
155
|
-
});
|
|
156
|
-
continue;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const data = await selectedSheetsResponse.json();
|
|
160
|
-
const values: string[][] = data.values || [];
|
|
161
|
-
|
|
162
|
-
if (!values.length) {
|
|
163
|
-
results.push({
|
|
164
|
-
configId: config.id,
|
|
165
|
-
configName: config.name,
|
|
166
|
-
imported: 0,
|
|
167
|
-
updated: 0,
|
|
168
|
-
skipped: 0,
|
|
169
|
-
});
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const headerRowIndex = config.headerRow - 1;
|
|
174
|
-
const startRowIndex = Math.max(
|
|
175
|
-
headerRowIndex + 1,
|
|
176
|
-
(config.lastSyncedRow || headerRowIndex) + 1,
|
|
177
|
-
);
|
|
178
|
-
|
|
179
|
-
// Récupérer les headers
|
|
180
|
-
const headerRow = values[headerRowIndex] || [];
|
|
181
|
-
|
|
182
|
-
// Utiliser le nouveau format columnMappings
|
|
183
|
-
let columnMappings: Record<string, number> = {}; // crmField -> index
|
|
184
|
-
let noteFields: Array<{ name: string; index: number }> = [];
|
|
185
|
-
|
|
186
|
-
if (!config.columnMappings) {
|
|
187
|
-
results.push({
|
|
188
|
-
configId: config.id,
|
|
189
|
-
configName: config.name,
|
|
190
|
-
imported: 0,
|
|
191
|
-
updated: 0,
|
|
192
|
-
skipped: 0,
|
|
193
|
-
error:
|
|
194
|
-
"La configuration n'utilise pas le nouveau format de mapping. Veuillez reconfigurer cette intégration.",
|
|
195
|
-
});
|
|
196
|
-
continue;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Parser les mappings
|
|
200
|
-
const mappings =
|
|
201
|
-
typeof config.columnMappings === 'string'
|
|
202
|
-
? JSON.parse(config.columnMappings)
|
|
203
|
-
: config.columnMappings;
|
|
204
|
-
|
|
205
|
-
if (!Array.isArray(mappings)) {
|
|
206
|
-
results.push({
|
|
207
|
-
configId: config.id,
|
|
208
|
-
configName: config.name,
|
|
209
|
-
imported: 0,
|
|
210
|
-
updated: 0,
|
|
211
|
-
skipped: 0,
|
|
212
|
-
error: 'Format de mapping invalide.',
|
|
213
|
-
});
|
|
214
|
-
continue;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
mappings.forEach((mapping: any) => {
|
|
218
|
-
if (mapping.action === 'map' && mapping.crmField && mapping.columnName) {
|
|
219
|
-
// Trouver l'index de la colonne par son nom
|
|
220
|
-
const columnIndex = headerRow.findIndex(
|
|
221
|
-
(h: string) =>
|
|
222
|
-
h && h.trim().toLowerCase() === mapping.columnName.trim().toLowerCase(),
|
|
223
|
-
);
|
|
224
|
-
if (columnIndex !== -1) {
|
|
225
|
-
columnMappings[mapping.crmField] = columnIndex;
|
|
226
|
-
}
|
|
227
|
-
} else if (mapping.action === 'note' && mapping.columnName) {
|
|
228
|
-
const columnIndex = headerRow.findIndex(
|
|
229
|
-
(h: string) =>
|
|
230
|
-
h && h.trim().toLowerCase() === mapping.columnName.trim().toLowerCase(),
|
|
231
|
-
);
|
|
232
|
-
if (columnIndex !== -1) {
|
|
233
|
-
noteFields.push({ name: mapping.columnName, index: columnIndex });
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
// Vérifier que le téléphone est mappé (obligatoire)
|
|
239
|
-
if (columnMappings['phone'] === undefined) {
|
|
240
|
-
results.push({
|
|
241
|
-
configId: config.id,
|
|
242
|
-
configName: config.name,
|
|
243
|
-
imported: 0,
|
|
244
|
-
updated: 0,
|
|
245
|
-
skipped: 0,
|
|
246
|
-
error: "La colonne téléphone n'est pas correctement mappée.",
|
|
247
|
-
});
|
|
248
|
-
continue;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const phoneIdx = columnMappings['phone'];
|
|
252
|
-
|
|
253
|
-
// Déterminer le statut par défaut à utiliser (configuré ou "Nouveau")
|
|
254
|
-
let effectiveDefaultStatusId = config.defaultStatusId || null;
|
|
255
|
-
if (!effectiveDefaultStatusId) {
|
|
256
|
-
const fallbackStatus = await client.status.findFirst({
|
|
257
|
-
where: { name: 'Nouveau' },
|
|
258
|
-
});
|
|
259
|
-
if (fallbackStatus) {
|
|
260
|
-
effectiveDefaultStatusId = fallbackStatus.id;
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// Pré-charger le rôle de l'utilisateur par défaut (constant pour toute la config)
|
|
265
|
-
let assignedCommercialId: string | null = null;
|
|
266
|
-
let assignedTeleproId: string | null = null;
|
|
267
|
-
|
|
268
|
-
if (config.defaultAssignedUserId) {
|
|
269
|
-
const defaultUser = await client.user.findUnique({
|
|
270
|
-
where: { id: config.defaultAssignedUserId },
|
|
271
|
-
select: { role: true },
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
if (defaultUser) {
|
|
275
|
-
if (
|
|
276
|
-
defaultUser.role === 'COMMERCIAL' ||
|
|
277
|
-
defaultUser.role === 'ADMIN' ||
|
|
278
|
-
defaultUser.role === 'MANAGER'
|
|
279
|
-
) {
|
|
280
|
-
assignedCommercialId = config.defaultAssignedUserId;
|
|
281
|
-
} else if (defaultUser.role === 'TELEPRO') {
|
|
282
|
-
assignedTeleproId = config.defaultAssignedUserId;
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
const escapeHtml = (text: string): string => {
|
|
288
|
-
const map: { [key: string]: string } = {
|
|
289
|
-
'&': '&',
|
|
290
|
-
'<': '<',
|
|
291
|
-
'>': '>',
|
|
292
|
-
'"': '"',
|
|
293
|
-
"'": ''',
|
|
294
|
-
};
|
|
295
|
-
return text.replace(/[&<>"']/g, (m) => map[m]);
|
|
296
|
-
};
|
|
297
|
-
|
|
298
|
-
const formatNoteContent = (noteItems: Array<{ label: string; value: string }>) => {
|
|
299
|
-
const escapedConfigName = escapeHtml(config.name);
|
|
300
|
-
|
|
301
|
-
if (noteItems.length === 0) {
|
|
302
|
-
return `<p style="font-size: 14px; line-height: 1.5; color: #374151; margin-bottom: 0;">Ce contact a été importé automatiquement depuis Google Sheets (${escapedConfigName}).</p>`;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
let html = `<p style="font-size: 14px; line-height: 1.5; color: #374151; margin-bottom: 16px;">Ce contact a été importé automatiquement depuis Google Sheets (${escapedConfigName}).</p>`;
|
|
306
|
-
|
|
307
|
-
html +=
|
|
308
|
-
'<div style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;">';
|
|
309
|
-
|
|
310
|
-
noteItems.forEach((item) => {
|
|
311
|
-
let formattedValue = item.value;
|
|
312
|
-
try {
|
|
313
|
-
const parsed = JSON.parse(item.value);
|
|
314
|
-
if (Array.isArray(parsed)) {
|
|
315
|
-
formattedValue = parsed.map((v) => String(v)).join(', ');
|
|
316
|
-
}
|
|
317
|
-
} catch {
|
|
318
|
-
// Ce n'est pas du JSON, on garde la valeur telle quelle
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
const humanLabel = item.label
|
|
322
|
-
.split(/(?=[A-Z])/)
|
|
323
|
-
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
324
|
-
.join(' ');
|
|
325
|
-
|
|
326
|
-
const escapedLabel = escapeHtml(humanLabel);
|
|
327
|
-
const escapedValue = escapeHtml(String(formattedValue)).replace(/\n/g, '<br>');
|
|
328
|
-
|
|
329
|
-
html += `
|
|
330
|
-
<div style="padding: 12px 14px; background: linear-gradient(to right, #f8fafc 0%, #f1f5f9 100%); border-radius: 10px; border-left: 4px solid #6366f1; box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05); transition: all 0.2s ease;">
|
|
331
|
-
<div style="font-size: 11px; font-weight: 700; color: #64748b; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 6px;">
|
|
332
|
-
${escapedLabel}
|
|
333
|
-
</div>
|
|
334
|
-
<div style="font-size: 14px; line-height: 1.5; color: #0f172a; font-weight: 500;">
|
|
335
|
-
${escapedValue}
|
|
336
|
-
</div>
|
|
337
|
-
</div>
|
|
338
|
-
`;
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
html += '</div>';
|
|
342
|
-
return html;
|
|
343
|
-
};
|
|
344
|
-
|
|
345
|
-
let imported = 0;
|
|
346
|
-
let updated = 0;
|
|
347
|
-
let skipped = 0;
|
|
348
|
-
let maxProcessedRow = config.lastSyncedRow || headerRowIndex;
|
|
349
|
-
const seenRowKeys = new Set<string>();
|
|
350
|
-
|
|
351
|
-
for (let rowIndex = startRowIndex; rowIndex < values.length; rowIndex++) {
|
|
352
|
-
const row = values[rowIndex];
|
|
353
|
-
if (!row) continue;
|
|
354
|
-
|
|
355
|
-
const phone = row[phoneIdx]?.trim();
|
|
356
|
-
if (!phone) {
|
|
357
|
-
skipped++;
|
|
358
|
-
continue;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
const firstName =
|
|
362
|
-
columnMappings['firstName'] !== undefined
|
|
363
|
-
? row[columnMappings['firstName']]?.trim() || undefined
|
|
364
|
-
: undefined;
|
|
365
|
-
const lastName =
|
|
366
|
-
columnMappings['lastName'] !== undefined
|
|
367
|
-
? row[columnMappings['lastName']]?.trim() || undefined
|
|
368
|
-
: undefined;
|
|
369
|
-
const email =
|
|
370
|
-
columnMappings['email'] !== undefined
|
|
371
|
-
? row[columnMappings['email']]?.trim() || undefined
|
|
372
|
-
: undefined;
|
|
373
|
-
const city =
|
|
374
|
-
columnMappings['city'] !== undefined
|
|
375
|
-
? row[columnMappings['city']]?.trim() || undefined
|
|
376
|
-
: undefined;
|
|
377
|
-
const postalCode =
|
|
378
|
-
columnMappings['postalCode'] !== undefined
|
|
379
|
-
? row[columnMappings['postalCode']]?.trim() || undefined
|
|
380
|
-
: undefined;
|
|
381
|
-
const origin =
|
|
382
|
-
columnMappings['origin'] !== undefined
|
|
383
|
-
? row[columnMappings['origin']]?.trim() || 'Google Sheets'
|
|
384
|
-
: 'Google Sheets';
|
|
385
|
-
const normalizedEmail = email ? email.trim().toLowerCase() : '';
|
|
386
|
-
const normalizedPhone = phone.replace(/\D/g, '');
|
|
387
|
-
const normalizedFirstName = firstName ? firstName.trim().toLowerCase() : '';
|
|
388
|
-
const normalizedLastName = lastName ? lastName.trim().toLowerCase() : '';
|
|
389
|
-
const rowKey = normalizedEmail
|
|
390
|
-
? `email:${normalizedEmail}`
|
|
391
|
-
: normalizedPhone
|
|
392
|
-
? `phone:${normalizedPhone}`
|
|
393
|
-
: `name:${normalizedFirstName}|${normalizedLastName}`;
|
|
394
|
-
|
|
395
|
-
if (seenRowKeys.has(rowKey)) {
|
|
396
|
-
skipped++;
|
|
397
|
-
continue;
|
|
398
|
-
}
|
|
399
|
-
seenRowKeys.add(rowKey);
|
|
400
|
-
|
|
401
|
-
const noteContents: Array<{ label: string; value: string }> = [];
|
|
402
|
-
if (noteFields.length > 0) {
|
|
403
|
-
noteFields.forEach(({ name, index }) => {
|
|
404
|
-
if (row[index]) {
|
|
405
|
-
noteContents.push({
|
|
406
|
-
label: name,
|
|
407
|
-
value: row[index].trim(),
|
|
408
|
-
});
|
|
409
|
-
}
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// Vérifier si c'est un doublon (nom, prénom ET email)
|
|
414
|
-
const duplicateContactId = await handleContactDuplicate(
|
|
415
|
-
firstName,
|
|
416
|
-
lastName,
|
|
417
|
-
email,
|
|
418
|
-
origin,
|
|
419
|
-
config.defaultAssignedUserId || config.ownerUserId,
|
|
420
|
-
);
|
|
421
|
-
|
|
422
|
-
let contact;
|
|
423
|
-
let isNewContact = false;
|
|
424
|
-
|
|
425
|
-
if (duplicateContactId) {
|
|
426
|
-
// C'est un doublon, récupérer le contact existant
|
|
427
|
-
contact = await client.contact.findUnique({
|
|
428
|
-
where: { id: duplicateContactId },
|
|
429
|
-
});
|
|
430
|
-
updated++;
|
|
431
|
-
} else {
|
|
432
|
-
// Chercher un contact existant (par téléphone uniquement)
|
|
433
|
-
contact =
|
|
434
|
-
(email &&
|
|
435
|
-
(await client.contact.findFirst({
|
|
436
|
-
where: {
|
|
437
|
-
OR: [{ email: email.toLowerCase() }, { phone }],
|
|
438
|
-
},
|
|
439
|
-
}))) ||
|
|
440
|
-
(await client.contact.findFirst({
|
|
441
|
-
where: { phone },
|
|
442
|
-
}));
|
|
443
|
-
|
|
444
|
-
if (!contact) {
|
|
445
|
-
// Préparer les interactions à créer
|
|
446
|
-
const formattedContent = formatNoteContent(noteContents);
|
|
447
|
-
const interactionsToCreate: any[] = [
|
|
448
|
-
{
|
|
449
|
-
type: 'NOTE',
|
|
450
|
-
title: `Contact importé depuis Google Sheets: ${config.name}`,
|
|
451
|
-
content: formattedContent,
|
|
452
|
-
userId: config.defaultAssignedUserId || config.ownerUserId,
|
|
453
|
-
date: new Date(),
|
|
454
|
-
metadata: {
|
|
455
|
-
htmlContent: formattedContent,
|
|
456
|
-
isGoogleSheetsImport: true,
|
|
457
|
-
},
|
|
458
|
-
},
|
|
459
|
-
];
|
|
460
|
-
|
|
461
|
-
contact = await client.contact.create({
|
|
462
|
-
data: {
|
|
463
|
-
firstName: firstName || null,
|
|
464
|
-
lastName: lastName || null,
|
|
465
|
-
email: email ? email.toLowerCase() : null,
|
|
466
|
-
phone,
|
|
467
|
-
city: city || null,
|
|
468
|
-
postalCode: postalCode || null,
|
|
469
|
-
origin,
|
|
470
|
-
statusId: effectiveDefaultStatusId,
|
|
471
|
-
assignedCommercialId: assignedCommercialId,
|
|
472
|
-
assignedTeleproId: assignedTeleproId,
|
|
473
|
-
createdById: config.defaultAssignedUserId || config.ownerUserId,
|
|
474
|
-
interactions: {
|
|
475
|
-
create: interactionsToCreate,
|
|
476
|
-
},
|
|
477
|
-
},
|
|
478
|
-
});
|
|
479
|
-
isNewContact = true;
|
|
480
|
-
imported++;
|
|
481
|
-
} else {
|
|
482
|
-
await client.contact.update({
|
|
483
|
-
where: { id: contact.id },
|
|
484
|
-
data: {
|
|
485
|
-
firstName: contact.firstName || firstName || null,
|
|
486
|
-
lastName: contact.lastName || lastName || null,
|
|
487
|
-
email: contact.email || (email ? email.toLowerCase() : null),
|
|
488
|
-
city: contact.city || city || null,
|
|
489
|
-
postalCode: contact.postalCode || postalCode || null,
|
|
490
|
-
origin: contact.origin || origin,
|
|
491
|
-
statusId: contact.statusId || effectiveDefaultStatusId,
|
|
492
|
-
// Ne pas écraser les assignations existantes
|
|
493
|
-
assignedCommercialId: contact.assignedCommercialId || assignedCommercialId,
|
|
494
|
-
assignedTeleproId: contact.assignedTeleproId || assignedTeleproId,
|
|
495
|
-
},
|
|
496
|
-
});
|
|
497
|
-
updated++;
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
// Créer une interaction de log uniquement pour les contacts mis à jour (pas les nouveaux qui ont déjà leur interaction)
|
|
502
|
-
if (contact && !isNewContact) {
|
|
503
|
-
// Contact mis à jour, créer l'interaction si nécessaire
|
|
504
|
-
const formattedContent = formatNoteContent(noteContents);
|
|
505
|
-
|
|
506
|
-
await client.interaction.create({
|
|
507
|
-
data: {
|
|
508
|
-
contactId: contact.id,
|
|
509
|
-
type: 'NOTE',
|
|
510
|
-
title: `Contact importé depuis Google Sheets: ${config.name}`,
|
|
511
|
-
content: formattedContent,
|
|
512
|
-
userId: config.defaultAssignedUserId || config.ownerUserId,
|
|
513
|
-
date: new Date(),
|
|
514
|
-
metadata: {
|
|
515
|
-
htmlContent: formattedContent,
|
|
516
|
-
isGoogleSheetsImport: true,
|
|
517
|
-
},
|
|
518
|
-
},
|
|
519
|
-
});
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
if (rowIndex > maxProcessedRow) {
|
|
523
|
-
maxProcessedRow = rowIndex;
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
if (maxProcessedRow > (config.lastSyncedRow || headerRowIndex)) {
|
|
528
|
-
await client.googleSheetSyncConfig.update({
|
|
529
|
-
where: { id: config.id },
|
|
530
|
-
data: {
|
|
531
|
-
lastSyncedRow: maxProcessedRow,
|
|
532
|
-
},
|
|
533
|
-
});
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
totalImported += imported;
|
|
537
|
-
totalUpdated += updated;
|
|
538
|
-
totalSkipped += skipped;
|
|
539
|
-
|
|
540
|
-
results.push({
|
|
541
|
-
configId: config.id,
|
|
542
|
-
configName: config.name,
|
|
543
|
-
imported,
|
|
544
|
-
updated,
|
|
545
|
-
skipped,
|
|
546
|
-
});
|
|
547
|
-
} catch (error: any) {
|
|
548
|
-
console.error(`Erreur lors de la synchronisation de ${config.name}:`, error);
|
|
549
|
-
results.push({
|
|
550
|
-
configId: config.id,
|
|
551
|
-
configName: config.name,
|
|
552
|
-
imported: 0,
|
|
553
|
-
updated: 0,
|
|
554
|
-
skipped: 0,
|
|
555
|
-
error: error.message || 'Erreur lors de la synchronisation',
|
|
556
|
-
});
|
|
557
|
-
} finally {
|
|
558
|
-
if (lockAcquired) {
|
|
559
|
-
await client.$queryRaw`SELECT pg_advisory_unlock(hashtext(${lockKey}), 4261)`;
|
|
560
|
-
}
|
|
561
|
-
}
|
|
14
|
+
let body: { configId?: string } = {};
|
|
15
|
+
try {
|
|
16
|
+
body = await request.json().catch(() => ({}));
|
|
17
|
+
} catch {
|
|
18
|
+
// body optionnel
|
|
562
19
|
}
|
|
20
|
+
const { configId: requestedConfigId } = body;
|
|
563
21
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
results,
|
|
22
|
+
const job = await enqueueGoogleSheetSyncJob({
|
|
23
|
+
requestedByUserId: session.user.id,
|
|
24
|
+
configId: requestedConfigId ?? null,
|
|
25
|
+
triggerType: 'MANUAL',
|
|
569
26
|
});
|
|
27
|
+
|
|
28
|
+
return NextResponse.json(
|
|
29
|
+
{
|
|
30
|
+
jobId: job.id,
|
|
31
|
+
status: job.status,
|
|
32
|
+
},
|
|
33
|
+
{ status: 202 },
|
|
34
|
+
);
|
|
570
35
|
} catch (error: any) {
|
|
36
|
+
if (error instanceof SyncJobRateLimitError) {
|
|
37
|
+
return NextResponse.json({ error: error.message }, { status: 429 });
|
|
38
|
+
}
|
|
571
39
|
console.error('Erreur lors de la synchronisation Google Sheets:', error);
|
|
572
40
|
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 });
|
|
573
41
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { prisma } from '@/lib/prisma';
|
|
3
|
+
import { runGoogleSheetSync } from '@/lib/google-sheet-sync-runner';
|
|
4
|
+
import { QstashAuthError, verifyQstashRequest } from '@/lib/qstash';
|
|
5
|
+
|
|
6
|
+
export async function POST(request: NextRequest) {
|
|
7
|
+
let bodyText = '';
|
|
8
|
+
let parsedJobId: string | null = null;
|
|
9
|
+
try {
|
|
10
|
+
bodyText = await request.text();
|
|
11
|
+
const signature = request.headers.get('upstash-signature');
|
|
12
|
+
await verifyQstashRequest(signature, bodyText);
|
|
13
|
+
|
|
14
|
+
const body = JSON.parse(bodyText) as { jobId?: string };
|
|
15
|
+
const jobId = body.jobId;
|
|
16
|
+
parsedJobId = jobId ?? null;
|
|
17
|
+
if (!jobId) {
|
|
18
|
+
return NextResponse.json({ error: 'jobId manquant' }, { status: 400 });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const claimed = await prisma.googleSheetSyncJob.updateMany({
|
|
22
|
+
where: {
|
|
23
|
+
id: jobId,
|
|
24
|
+
status: 'QUEUED',
|
|
25
|
+
},
|
|
26
|
+
data: {
|
|
27
|
+
status: 'RUNNING',
|
|
28
|
+
startedAt: new Date(),
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (claimed.count === 0) {
|
|
33
|
+
return NextResponse.json({ ok: true, skipped: true });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const job = await prisma.googleSheetSyncJob.findUnique({
|
|
37
|
+
where: { id: jobId },
|
|
38
|
+
select: { requestedByUserId: true, configId: true },
|
|
39
|
+
});
|
|
40
|
+
if (!job) {
|
|
41
|
+
return NextResponse.json({ error: 'Job introuvable' }, { status: 404 });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const result = await runGoogleSheetSync({
|
|
45
|
+
sessionUserId: job.requestedByUserId,
|
|
46
|
+
requestedConfigId: job.configId ?? undefined,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
await prisma.googleSheetSyncJob.update({
|
|
50
|
+
where: { id: jobId },
|
|
51
|
+
data: {
|
|
52
|
+
status: 'SUCCEEDED',
|
|
53
|
+
finishedAt: new Date(),
|
|
54
|
+
result: result as object,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return NextResponse.json({ ok: true });
|
|
59
|
+
} catch (error: any) {
|
|
60
|
+
if (error instanceof QstashAuthError) {
|
|
61
|
+
return NextResponse.json({ error: 'Signature QStash invalide' }, { status: 401 });
|
|
62
|
+
}
|
|
63
|
+
console.error('Erreur worker Google Sheet:', error);
|
|
64
|
+
try {
|
|
65
|
+
if (!parsedJobId && bodyText) {
|
|
66
|
+
const parsed = JSON.parse(bodyText) as { jobId?: string };
|
|
67
|
+
parsedJobId = parsed.jobId ?? null;
|
|
68
|
+
}
|
|
69
|
+
if (parsedJobId) {
|
|
70
|
+
await prisma.googleSheetSyncJob.update({
|
|
71
|
+
where: { id: parsedJobId },
|
|
72
|
+
data: {
|
|
73
|
+
status: 'FAILED',
|
|
74
|
+
finishedAt: new Date(),
|
|
75
|
+
error: error?.message || 'Erreur worker',
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// ignore
|
|
81
|
+
}
|
|
82
|
+
return NextResponse.json({ error: 'Erreur worker' }, { status: 500 });
|
|
83
|
+
}
|
|
84
|
+
}
|