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,65 @@
|
|
|
1
|
+
import { Client, Receiver } from '@upstash/qstash';
|
|
2
|
+
|
|
3
|
+
type SyncJobPayload = {
|
|
4
|
+
jobId: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export class QstashAuthError extends Error {
|
|
8
|
+
constructor(message: string) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'QstashAuthError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getQstashClient() {
|
|
15
|
+
const token = process.env.QSTASH_TOKEN;
|
|
16
|
+
if (!token) {
|
|
17
|
+
throw new Error('QSTASH_TOKEN manquant');
|
|
18
|
+
}
|
|
19
|
+
return new Client({ token });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getWorkerUrl() {
|
|
23
|
+
const explicit = process.env.GOOGLE_SHEET_SYNC_WORKER_URL;
|
|
24
|
+
if (explicit) return explicit;
|
|
25
|
+
|
|
26
|
+
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? process.env.VERCEL_PROJECT_PRODUCTION_URL;
|
|
27
|
+
if (!appUrl) {
|
|
28
|
+
throw new Error('NEXT_PUBLIC_APP_URL manquant pour publier le job QStash');
|
|
29
|
+
}
|
|
30
|
+
const normalized = appUrl.startsWith('http') ? appUrl : `https://${appUrl}`;
|
|
31
|
+
return `${normalized}/api/jobs/google-sheet/process`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function publishGoogleSheetSyncJob(payload: SyncJobPayload) {
|
|
35
|
+
const client = getQstashClient();
|
|
36
|
+
const workerUrl = getWorkerUrl();
|
|
37
|
+
|
|
38
|
+
await client.publishJSON({
|
|
39
|
+
url: workerUrl,
|
|
40
|
+
body: payload,
|
|
41
|
+
retries: 2,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function verifyQstashRequest(signature: string | null, body: string): Promise<void> {
|
|
46
|
+
const currentKey = process.env.QSTASH_CURRENT_SIGNING_KEY;
|
|
47
|
+
const nextKey = process.env.QSTASH_NEXT_SIGNING_KEY;
|
|
48
|
+
|
|
49
|
+
if (!currentKey || !nextKey) {
|
|
50
|
+
throw new Error('QSTASH_CURRENT_SIGNING_KEY / QSTASH_NEXT_SIGNING_KEY manquants');
|
|
51
|
+
}
|
|
52
|
+
if (!signature) {
|
|
53
|
+
throw new QstashAuthError('Signature QStash manquante');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const receiver = new Receiver({
|
|
57
|
+
currentSigningKey: currentKey,
|
|
58
|
+
nextSigningKey: nextKey,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await receiver.verify({
|
|
62
|
+
signature,
|
|
63
|
+
body,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { Prisma } from '@/lib/prisma';
|
|
3
|
+
import {
|
|
4
|
+
REMINDERS_CLEAR_ALL_ID,
|
|
5
|
+
REMINDER_STATE_TTL_CLEARED_MS,
|
|
6
|
+
REMINDER_STATE_TTL_READ_MS,
|
|
7
|
+
type ReminderClearMetadata,
|
|
8
|
+
} from '@/lib/reminder-state';
|
|
9
|
+
|
|
10
|
+
export type ReminderLogEvent =
|
|
11
|
+
| 'reminders_fetch'
|
|
12
|
+
| 'reminder_state_read'
|
|
13
|
+
| 'reminder_state_upsert'
|
|
14
|
+
| 'reminders_clear_all'
|
|
15
|
+
| 'reminders_clear_undo'
|
|
16
|
+
| 'reminders_fallback_missing_table';
|
|
17
|
+
|
|
18
|
+
export function getRequestId(request: Request): string {
|
|
19
|
+
const fromHeader = request.headers.get('x-request-id')?.trim();
|
|
20
|
+
return fromHeader && fromHeader.length > 0 ? fromHeader : randomUUID();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function logReminderEvent(payload: {
|
|
24
|
+
event: ReminderLogEvent;
|
|
25
|
+
requestId: string;
|
|
26
|
+
userId?: string;
|
|
27
|
+
countCandidates?: number;
|
|
28
|
+
countReturned?: number;
|
|
29
|
+
degraded?: boolean;
|
|
30
|
+
durationMs?: number;
|
|
31
|
+
extra?: Record<string, unknown>;
|
|
32
|
+
}) {
|
|
33
|
+
console.log(
|
|
34
|
+
JSON.stringify({
|
|
35
|
+
...payload,
|
|
36
|
+
ts: new Date().toISOString(),
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isMissingReminderStateTableError(error: unknown): boolean {
|
|
42
|
+
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
|
43
|
+
if (error.code === 'P2021') return true;
|
|
44
|
+
}
|
|
45
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
46
|
+
return /user_reminder_state|relation .* does not exist|table .* does not exist/i.test(msg);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Filtre Prisma : lignes non expirées (ou sans TTL pour rétrocompat). */
|
|
50
|
+
export function reminderStateNotExpired(now: Date): Prisma.UserReminderStateWhereInput {
|
|
51
|
+
return {
|
|
52
|
+
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function computeExpiresAtForUpsert(
|
|
57
|
+
reminderId: string,
|
|
58
|
+
status: 'READ' | 'DISMISSED' | 'CLEARED',
|
|
59
|
+
now: Date,
|
|
60
|
+
): Date | null {
|
|
61
|
+
if (reminderId === REMINDERS_CLEAR_ALL_ID && status === 'CLEARED') {
|
|
62
|
+
return new Date(now.getTime() + REMINDER_STATE_TTL_CLEARED_MS);
|
|
63
|
+
}
|
|
64
|
+
if (status === 'READ' || status === 'DISMISSED') {
|
|
65
|
+
return new Date(now.getTime() + REMINDER_STATE_TTL_READ_MS);
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function parseClearMetadata(raw: Prisma.JsonValue | null | undefined): ReminderClearMetadata | null {
|
|
71
|
+
if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
72
|
+
const o = raw as Record<string, unknown>;
|
|
73
|
+
if (typeof o.undoUntil !== 'string') return null;
|
|
74
|
+
const prev = o.previousClearedAt;
|
|
75
|
+
if (prev != null && typeof prev !== 'string') return null;
|
|
76
|
+
return {
|
|
77
|
+
undoUntil: o.undoUntil,
|
|
78
|
+
previousClearedAt: prev == null ? null : prev,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const REMINDERS_CLEAR_ALL_ID = '__all__';
|
|
2
|
+
|
|
3
|
+
export const REMINDER_READ_STATUSES = new Set(['READ', 'DISMISSED']);
|
|
4
|
+
|
|
5
|
+
/** TTL états READ / DISMISSED par rappel */
|
|
6
|
+
export const REMINDER_STATE_TTL_READ_MS = 30 * 24 * 60 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
/** TTL état global CLEARED (__all__) */
|
|
9
|
+
export const REMINDER_STATE_TTL_CLEARED_MS = 7 * 24 * 60 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
/** Fenêtre d’annulation après « Vider les rappels » */
|
|
12
|
+
export const REMINDERS_CLEAR_UNDO_WINDOW_MS = 15_000;
|
|
13
|
+
|
|
14
|
+
/** Métadonnées JSON sur la ligne __all__ pour l’undo */
|
|
15
|
+
export type ReminderClearMetadata = {
|
|
16
|
+
undoUntil: string;
|
|
17
|
+
previousClearedAt: string | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Polling des rappels (cloche + toasts). Plus court qu’1 min pour éviter d’attendre un refresh manuel. */
|
|
21
|
+
export const REMINDERS_POLL_INTERVAL_MS = 15_000;
|
|
22
|
+
|
|
23
|
+
/** À émettre après création / MAJ de tâche avec rappel pour rafraîchir immédiatement. */
|
|
24
|
+
export const REMINDERS_REFRESH_EVENT = 'reminders:refresh';
|
|
25
|
+
|
|
26
|
+
export function requestRemindersRefresh() {
|
|
27
|
+
globalThis.dispatchEvent(new Event(REMINDERS_REFRESH_EVENT));
|
|
28
|
+
}
|
|
29
|
+
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
|
|
2
|
+
|
|
3
|
+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
|
4
|
+
const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
|
5
|
+
|
|
6
|
+
let _adminClient: SupabaseClient | null = null;
|
|
7
|
+
|
|
8
|
+
function getAdminClient(): SupabaseClient {
|
|
9
|
+
if (!_adminClient) {
|
|
10
|
+
if (!supabaseUrl || !supabaseServiceRoleKey) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
'NEXT_PUBLIC_SUPABASE_URL et SUPABASE_SERVICE_ROLE_KEY doivent être configurés.',
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
_adminClient = createClient(supabaseUrl, supabaseServiceRoleKey, {
|
|
16
|
+
auth: { autoRefreshToken: false, persistSession: false },
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
return _adminClient;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const BUCKETS = {
|
|
23
|
+
CONTACTS: 'contacts',
|
|
24
|
+
EDITOR_IMAGES: 'editor-images',
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
export type BucketName = (typeof BUCKETS)[keyof typeof BUCKETS];
|
|
28
|
+
|
|
29
|
+
function buildStoragePath(prefix: string, entityId: string, fileName: string): string {
|
|
30
|
+
const uuid = crypto.randomUUID();
|
|
31
|
+
const sanitized = fileName.replaceAll(/[^a-zA-Z0-9._-]/g, '_');
|
|
32
|
+
return `${prefix}/${entityId}/${uuid}-${sanitized}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function buildContactFilePath(contactId: string, fileName: string): string {
|
|
36
|
+
return buildStoragePath('files', contactId, fileName);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function buildEditorImagePath(fileName: string): string {
|
|
40
|
+
const uuid = crypto.randomUUID();
|
|
41
|
+
const sanitized = fileName.replaceAll(/[^a-zA-Z0-9._-]/g, '_');
|
|
42
|
+
return `images/${uuid}-${sanitized}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function uploadFile(
|
|
46
|
+
bucket: BucketName,
|
|
47
|
+
path: string,
|
|
48
|
+
data: Buffer | Blob,
|
|
49
|
+
contentType: string,
|
|
50
|
+
): Promise<{ storagePath: string }> {
|
|
51
|
+
const client = getAdminClient();
|
|
52
|
+
const { error } = await client.storage.from(bucket).upload(path, data, {
|
|
53
|
+
contentType,
|
|
54
|
+
upsert: true,
|
|
55
|
+
});
|
|
56
|
+
if (error) throw new Error(`Erreur upload Supabase: ${error.message}`);
|
|
57
|
+
return { storagePath: path };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function createSignedUploadUrl(
|
|
61
|
+
bucket: BucketName,
|
|
62
|
+
path: string,
|
|
63
|
+
): Promise<{ signedUrl: string; token: string; storagePath: string }> {
|
|
64
|
+
const client = getAdminClient();
|
|
65
|
+
const { data, error } = await client.storage.from(bucket).createSignedUploadUrl(path);
|
|
66
|
+
if (error || !data) {
|
|
67
|
+
throw new Error(`Erreur création signed upload URL: ${error?.message ?? 'unknown'}`);
|
|
68
|
+
}
|
|
69
|
+
return { signedUrl: data.signedUrl, token: data.token, storagePath: path };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function createSignedDownloadUrl(
|
|
73
|
+
bucket: BucketName,
|
|
74
|
+
path: string,
|
|
75
|
+
expiresIn = 3600,
|
|
76
|
+
): Promise<string> {
|
|
77
|
+
const client = getAdminClient();
|
|
78
|
+
const { data, error } = await client.storage.from(bucket).createSignedUrl(path, expiresIn);
|
|
79
|
+
if (error || !data?.signedUrl) {
|
|
80
|
+
throw new Error(`Erreur création signed download URL: ${error?.message ?? 'unknown'}`);
|
|
81
|
+
}
|
|
82
|
+
return data.signedUrl;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function downloadFile(
|
|
86
|
+
bucket: BucketName,
|
|
87
|
+
path: string,
|
|
88
|
+
): Promise<{ buffer: Buffer; mimeType: string }> {
|
|
89
|
+
const client = getAdminClient();
|
|
90
|
+
const { data, error } = await client.storage.from(bucket).download(path);
|
|
91
|
+
if (error || !data) {
|
|
92
|
+
throw new Error(`Erreur téléchargement Supabase: ${error?.message ?? 'unknown'}`);
|
|
93
|
+
}
|
|
94
|
+
const buffer = Buffer.from(await data.arrayBuffer());
|
|
95
|
+
return { buffer, mimeType: data.type };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function deleteFile(bucket: BucketName, path: string): Promise<void> {
|
|
99
|
+
const client = getAdminClient();
|
|
100
|
+
const { error } = await client.storage.from(bucket).remove([path]);
|
|
101
|
+
if (error) {
|
|
102
|
+
throw new Error(`Erreur suppression Supabase: ${error.message}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function deleteFiles(bucket: BucketName, paths: string[]): Promise<void> {
|
|
107
|
+
if (paths.length === 0) return;
|
|
108
|
+
const client = getAdminClient();
|
|
109
|
+
const { error } = await client.storage.from(bucket).remove(paths);
|
|
110
|
+
if (error) {
|
|
111
|
+
throw new Error(`Erreur suppression batch Supabase: ${error.message}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -14,19 +14,31 @@ export interface ContactVariables {
|
|
|
14
14
|
city?: string | null;
|
|
15
15
|
postalCode?: string | null;
|
|
16
16
|
companyName?: string | null;
|
|
17
|
-
company?: {
|
|
17
|
+
company?: {
|
|
18
|
+
name?: string | null;
|
|
19
|
+
address?: string | null;
|
|
20
|
+
city?: string | null;
|
|
21
|
+
postalCode?: string | null;
|
|
22
|
+
} | null;
|
|
23
|
+
jobTitle?: string | null;
|
|
24
|
+
website?: string | null;
|
|
25
|
+
origin?: string | null;
|
|
26
|
+
statusName?: string | null;
|
|
27
|
+
assignedCommercialName?: string | null;
|
|
28
|
+
assignedTeleproName?: string | null;
|
|
29
|
+
closingReason?: string | null;
|
|
30
|
+
createdAt?: Date | string | null;
|
|
18
31
|
}
|
|
19
32
|
|
|
20
33
|
export interface TemplateVariable {
|
|
21
34
|
key: string;
|
|
22
35
|
description: string;
|
|
23
|
-
section: 'contact' | '
|
|
36
|
+
section: 'contact' | 'crm';
|
|
24
37
|
}
|
|
25
38
|
|
|
26
39
|
export const VARIABLE_SECTIONS = {
|
|
27
40
|
contact: { label: 'Contact', color: 'indigo' },
|
|
28
|
-
|
|
29
|
-
tournee: { label: 'Tournée', color: 'emerald' },
|
|
41
|
+
crm: { label: 'CRM', color: 'emerald' },
|
|
30
42
|
} as const;
|
|
31
43
|
|
|
32
44
|
export const AVAILABLE_VARIABLES: TemplateVariable[] = [
|
|
@@ -52,33 +64,46 @@ export const AVAILABLE_VARIABLES: TemplateVariable[] = [
|
|
|
52
64
|
description: 'Nom de l’entreprise ou structure du contact.',
|
|
53
65
|
section: 'contact',
|
|
54
66
|
},
|
|
55
|
-
//
|
|
56
|
-
{ key: '{{
|
|
57
|
-
{ key: '{{
|
|
67
|
+
// CRM
|
|
68
|
+
{ key: '{{jobTitle}}', description: 'Poste / fonction du contact.', section: 'crm' },
|
|
69
|
+
{ key: '{{website}}', description: 'Site web du contact ou de son entreprise.', section: 'crm' },
|
|
70
|
+
{ key: '{{origin}}', description: 'Origine ou source du lead.', section: 'crm' },
|
|
71
|
+
{ key: '{{statusName}}', description: 'Nom du statut actuel du contact.', section: 'crm' },
|
|
58
72
|
{
|
|
59
|
-
key: '{{
|
|
60
|
-
description: 'Nom
|
|
61
|
-
section: '
|
|
73
|
+
key: '{{assignedCommercialName}}',
|
|
74
|
+
description: 'Nom du commercial assigné au contact.',
|
|
75
|
+
section: 'crm',
|
|
62
76
|
},
|
|
63
|
-
// Tournée
|
|
64
77
|
{
|
|
65
|
-
key: '{{
|
|
66
|
-
description: '
|
|
67
|
-
section: '
|
|
78
|
+
key: '{{closingReason}}',
|
|
79
|
+
description: 'Motif de fermeture (si le contact a le statut Fermé).',
|
|
80
|
+
section: 'crm',
|
|
68
81
|
},
|
|
69
|
-
// { key: '{{tourNumber}}', description: 'Numéro unique de la tournée.', section: 'tournee' },
|
|
70
82
|
{
|
|
71
|
-
key: '{{
|
|
72
|
-
description: '
|
|
73
|
-
section: '
|
|
83
|
+
key: '{{assignedTeleproName}}',
|
|
84
|
+
description: 'Nom du télépro assigné au contact.',
|
|
85
|
+
section: 'crm',
|
|
74
86
|
},
|
|
75
87
|
{
|
|
76
|
-
key: '{{
|
|
77
|
-
description: 'Date
|
|
78
|
-
section: '
|
|
88
|
+
key: '{{createdAt}}',
|
|
89
|
+
description: 'Date de création du contact (format dd/mm/yyyy).',
|
|
90
|
+
section: 'crm',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
key: '{{companyAddress}}',
|
|
94
|
+
description: 'Adresse de l\'entreprise liée au contact.',
|
|
95
|
+
section: 'crm',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
key: '{{companyCity}}',
|
|
99
|
+
description: 'Ville du siège de l\'entreprise.',
|
|
100
|
+
section: 'crm',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
key: '{{companyPostalCode}}',
|
|
104
|
+
description: 'Code postal de l\'entreprise.',
|
|
105
|
+
section: 'crm',
|
|
79
106
|
},
|
|
80
|
-
{ key: '{{city}}', description: 'Ville où se déroule la tournée.', section: 'tournee' },
|
|
81
|
-
{ key: '{{postalCode}}', description: 'Code postal de la tournée.', section: 'tournee' },
|
|
82
107
|
];
|
|
83
108
|
|
|
84
109
|
/**
|
|
@@ -101,6 +126,48 @@ export function replaceTemplateVariables(template: string, variables: ContactVar
|
|
|
101
126
|
/\{\{companyName\}\}/g,
|
|
102
127
|
variables.company?.name ?? variables.companyName ?? '',
|
|
103
128
|
);
|
|
129
|
+
result = result.replace(/\{\{jobTitle\}\}/g, variables.jobTitle || '');
|
|
130
|
+
result = result.replace(/\{\{website\}\}/g, variables.website || '');
|
|
131
|
+
result = result.replace(/\{\{origin\}\}/g, variables.origin || '');
|
|
132
|
+
result = result.replace(/\{\{statusName\}\}/g, variables.statusName || '');
|
|
133
|
+
result = result.replace(
|
|
134
|
+
/\{\{assignedCommercialName\}\}/g,
|
|
135
|
+
variables.assignedCommercialName || '',
|
|
136
|
+
);
|
|
137
|
+
result = result.replace(
|
|
138
|
+
/\{\{assignedTeleproName\}\}/g,
|
|
139
|
+
variables.assignedTeleproName || '',
|
|
140
|
+
);
|
|
141
|
+
result = result.replace(/\{\{closingReason\}\}/g, variables.closingReason || '');
|
|
142
|
+
result = result.replace(
|
|
143
|
+
/\{\{companyAddress\}\}/g,
|
|
144
|
+
variables.company?.address ?? '',
|
|
145
|
+
);
|
|
146
|
+
result = result.replace(
|
|
147
|
+
/\{\{companyCity\}\}/g,
|
|
148
|
+
variables.company?.city ?? '',
|
|
149
|
+
);
|
|
150
|
+
result = result.replace(
|
|
151
|
+
/\{\{companyPostalCode\}\}/g,
|
|
152
|
+
variables.company?.postalCode ?? '',
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// createdAt formaté (dd/mm/yyyy)
|
|
156
|
+
let createdAtStr = '';
|
|
157
|
+
if (variables.createdAt) {
|
|
158
|
+
const d =
|
|
159
|
+
typeof variables.createdAt === 'string'
|
|
160
|
+
? new Date(variables.createdAt)
|
|
161
|
+
: variables.createdAt;
|
|
162
|
+
if (!isNaN(d.getTime())) {
|
|
163
|
+
createdAtStr = d.toLocaleDateString('fr-FR', {
|
|
164
|
+
day: '2-digit',
|
|
165
|
+
month: '2-digit',
|
|
166
|
+
year: 'numeric',
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
result = result.replace(/\{\{createdAt\}\}/g, createdAtStr);
|
|
104
171
|
|
|
105
172
|
// Variable composée : fullName
|
|
106
173
|
const fullName = [variables.firstName, variables.lastName].filter(Boolean).join(' ') || '';
|
|
@@ -108,3 +175,77 @@ export function replaceTemplateVariables(template: string, variables: ContactVar
|
|
|
108
175
|
|
|
109
176
|
return result;
|
|
110
177
|
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Construit l'objet ContactVariables à partir d'un contact (shape flexible).
|
|
181
|
+
*/
|
|
182
|
+
export function buildContactVariables(contact: {
|
|
183
|
+
firstName?: string | null;
|
|
184
|
+
lastName?: string | null;
|
|
185
|
+
civility?: string | null;
|
|
186
|
+
email?: string | null;
|
|
187
|
+
phone?: string | null;
|
|
188
|
+
secondaryPhone?: string | null;
|
|
189
|
+
address?: string | null;
|
|
190
|
+
city?: string | null;
|
|
191
|
+
postalCode?: string | null;
|
|
192
|
+
company?: {
|
|
193
|
+
name?: string | null;
|
|
194
|
+
address?: string | null;
|
|
195
|
+
city?: string | null;
|
|
196
|
+
postalCode?: string | null;
|
|
197
|
+
} | null;
|
|
198
|
+
companyName?: string | null;
|
|
199
|
+
jobTitle?: string | null;
|
|
200
|
+
website?: string | null;
|
|
201
|
+
origin?: string | null;
|
|
202
|
+
status?: { name?: string | null } | null;
|
|
203
|
+
statusName?: string | null;
|
|
204
|
+
assignedCommercial?: {
|
|
205
|
+
name?: string | null;
|
|
206
|
+
firstName?: string | null;
|
|
207
|
+
lastName?: string | null;
|
|
208
|
+
} | null;
|
|
209
|
+
assignedTelepro?: {
|
|
210
|
+
name?: string | null;
|
|
211
|
+
firstName?: string | null;
|
|
212
|
+
lastName?: string | null;
|
|
213
|
+
} | null;
|
|
214
|
+
closingReason?: string | null;
|
|
215
|
+
createdAt?: Date | string | null;
|
|
216
|
+
}): ContactVariables {
|
|
217
|
+
const commercialName = contact.assignedCommercial
|
|
218
|
+
? (contact.assignedCommercial.name ??
|
|
219
|
+
[contact.assignedCommercial.firstName, contact.assignedCommercial.lastName]
|
|
220
|
+
.filter(Boolean)
|
|
221
|
+
.join(' ')) || ''
|
|
222
|
+
: '';
|
|
223
|
+
const teleproName = contact.assignedTelepro
|
|
224
|
+
? (contact.assignedTelepro.name ??
|
|
225
|
+
[contact.assignedTelepro.firstName, contact.assignedTelepro.lastName]
|
|
226
|
+
.filter(Boolean)
|
|
227
|
+
.join(' ')) || ''
|
|
228
|
+
: '';
|
|
229
|
+
return {
|
|
230
|
+
firstName: contact.firstName || '',
|
|
231
|
+
lastName: contact.lastName || '',
|
|
232
|
+
civility: contact.civility || '',
|
|
233
|
+
email: contact.email || '',
|
|
234
|
+
phone: contact.phone || '',
|
|
235
|
+
secondaryPhone: contact.secondaryPhone || '',
|
|
236
|
+
address: contact.address || '',
|
|
237
|
+
city: contact.city || '',
|
|
238
|
+
postalCode: contact.postalCode || '',
|
|
239
|
+
companyName: contact.company?.name ?? contact.companyName ?? '',
|
|
240
|
+
company: contact.company || null,
|
|
241
|
+
jobTitle: contact.jobTitle || '',
|
|
242
|
+
website: contact.website || '',
|
|
243
|
+
origin: contact.origin || '',
|
|
244
|
+
statusName: contact.status?.name ?? contact.statusName ?? '',
|
|
245
|
+
assignedCommercialName: commercialName,
|
|
246
|
+
assignedTeleproName: teleproName,
|
|
247
|
+
closingReason: contact.closingReason || '',
|
|
248
|
+
createdAt: contact.createdAt || null,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
@@ -58,6 +58,51 @@ export function formatDateTime(dateString: string | null): string {
|
|
|
58
58
|
return DATE_TIME_FORMATTER.format(new Date(dateString));
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Parse une chaîne en Date pour les imports (création de contact).
|
|
63
|
+
* Accepte ISO (yyyy-mm-dd), dd/mm/yyyy, dd-mm-yyyy.
|
|
64
|
+
* Retourne null si invalide ou vide.
|
|
65
|
+
*/
|
|
66
|
+
export function parseImportDate(value: string | null | undefined): Date | null {
|
|
67
|
+
if (value == null || String(value).trim() === '') return null;
|
|
68
|
+
const s = String(value).trim();
|
|
69
|
+
if (/^\d{4}-\d{2}-\d{2}(T|\s|$)/.test(s)) {
|
|
70
|
+
const d = new Date(s);
|
|
71
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
72
|
+
}
|
|
73
|
+
const parts = s.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{2,4})/);
|
|
74
|
+
if (parts) {
|
|
75
|
+
const [, a, b, year] = parts;
|
|
76
|
+
const y = year.length === 2 ? 2000 + parseInt(year, 10) : parseInt(year, 10);
|
|
77
|
+
const n1 = parseInt(a, 10);
|
|
78
|
+
const n2 = parseInt(b, 10);
|
|
79
|
+
const day = n1 > 31 ? n2 : n1;
|
|
80
|
+
const month = n1 > 31 ? n1 : n2;
|
|
81
|
+
const d = new Date(y, month - 1, day);
|
|
82
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
83
|
+
}
|
|
84
|
+
const d = new Date(s);
|
|
85
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Retourne un message toast adapte a l'environnement :
|
|
90
|
+
* - En développement : affiche le message utilisateur + le détail technique
|
|
91
|
+
* - En production : affiche uniquement le message utilisateur
|
|
92
|
+
*/
|
|
93
|
+
export function devToast(userMessage: string, devDetail?: unknown): string {
|
|
94
|
+
if (process.env.NODE_ENV === 'development' && devDetail) {
|
|
95
|
+
const detail =
|
|
96
|
+
devDetail instanceof Error
|
|
97
|
+
? devDetail.message
|
|
98
|
+
: typeof devDetail === 'string'
|
|
99
|
+
? devDetail
|
|
100
|
+
: JSON.stringify(devDetail);
|
|
101
|
+
return `${userMessage}\n\n🔧 ${detail}`;
|
|
102
|
+
}
|
|
103
|
+
return userMessage;
|
|
104
|
+
}
|
|
105
|
+
|
|
61
106
|
export function indexToColumn(index: number): string {
|
|
62
107
|
let col = '';
|
|
63
108
|
let n = index + 1;
|