create-crm-tmp 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/bin/create-crm-tmp.js +56 -35
  2. package/package.json +1 -1
  3. package/template/README.md +230 -115
  4. package/template/eslint.config.mjs +13 -0
  5. package/template/next.config.ts +14 -0
  6. package/template/package.json +15 -2
  7. package/template/prisma/migrations/20260318095700_init_db/migration.sql +978 -0
  8. package/template/prisma/migrations/migration_lock.toml +3 -0
  9. package/template/prisma/schema.prisma +132 -637
  10. package/template/src/app/(auth)/invite/[token]/page.tsx +10 -8
  11. package/template/src/app/(auth)/layout.tsx +1 -1
  12. package/template/src/app/(auth)/reset-password/complete/page.tsx +11 -8
  13. package/template/src/app/(auth)/reset-password/page.tsx +4 -4
  14. package/template/src/app/(auth)/reset-password/verify/page.tsx +4 -4
  15. package/template/src/app/(auth)/signin/page.tsx +14 -6
  16. package/template/src/app/(dashboard)/agenda/page.tsx +2243 -988
  17. package/template/src/app/(dashboard)/automatisation/_components/workflow-editor.tsx +18 -104
  18. package/template/src/app/(dashboard)/automatisation/page.tsx +10 -26
  19. package/template/src/app/(dashboard)/closing/page.tsx +78 -62
  20. package/template/src/app/(dashboard)/contacts/[id]/page.tsx +2082 -1080
  21. package/template/src/app/(dashboard)/contacts/companies/[id]/page.tsx +46 -47
  22. package/template/src/app/(dashboard)/contacts/page.tsx +1062 -780
  23. package/template/src/app/(dashboard)/dashboard/page.tsx +533 -37
  24. package/template/src/app/(dashboard)/dev/page.tsx +1291 -0
  25. package/template/src/app/(dashboard)/layout.tsx +6 -2
  26. package/template/src/app/(dashboard)/settings/page.tsx +797 -2582
  27. package/template/src/app/(dashboard)/templates/page.tsx +55 -54
  28. package/template/src/app/(dashboard)/users/list/page.tsx +51 -48
  29. package/template/src/app/(dashboard)/users/page.tsx +1 -1
  30. package/template/src/app/(dashboard)/users/permissions/page.tsx +2 -2
  31. package/template/src/app/(dashboard)/users/roles/page.tsx +7 -5
  32. package/template/src/app/api/agenda/google-events/route.ts +92 -0
  33. package/template/src/app/api/auth/check-active/route.ts +3 -2
  34. package/template/src/app/api/auth/google/route.ts +2 -1
  35. package/template/src/app/api/auth/google/status/route.ts +7 -31
  36. package/template/src/app/api/companies/[id]/activities/route.ts +1 -3
  37. package/template/src/app/api/companies/[id]/route.ts +1 -2
  38. package/template/src/app/api/companies/route.ts +42 -12
  39. package/template/src/app/api/contacts/[id]/files/[fileId]/preview/route.ts +9 -31
  40. package/template/src/app/api/contacts/[id]/files/[fileId]/route.ts +14 -32
  41. package/template/src/app/api/contacts/[id]/files/route.ts +112 -212
  42. package/template/src/app/api/contacts/[id]/interactions/[interactionId]/route.ts +27 -1
  43. package/template/src/app/api/contacts/[id]/interactions/route.ts +16 -16
  44. package/template/src/app/api/contacts/[id]/kyc/route.ts +21 -11
  45. package/template/src/app/api/contacts/[id]/meet/route.ts +19 -2
  46. package/template/src/app/api/contacts/[id]/route.ts +106 -34
  47. package/template/src/app/api/contacts/[id]/send-email/route.ts +27 -11
  48. package/template/src/app/api/contacts/[id]/workflows/run/route.ts +6 -0
  49. package/template/src/app/api/contacts/export/route.ts +9 -13
  50. package/template/src/app/api/contacts/import/route.ts +55 -25
  51. package/template/src/app/api/contacts/import-preview/route.ts +1 -1
  52. package/template/src/app/api/contacts/origins/route.ts +63 -0
  53. package/template/src/app/api/contacts/route.ts +153 -41
  54. package/template/src/app/api/cron/cleanup-editor-images/route.ts +166 -0
  55. package/template/src/app/api/dashboard/widgets/[id]/route.ts +44 -0
  56. package/template/src/app/api/dashboard/widgets/route.ts +181 -0
  57. package/template/src/app/api/dev/reminders/test/route.ts +114 -0
  58. package/template/src/app/api/editor/upload-image/route.ts +61 -0
  59. package/template/src/app/api/integrations/google-sheet/jobs/[jobId]/route.ts +47 -0
  60. package/template/src/app/api/integrations/google-sheet/jobs/usage/route.ts +50 -0
  61. package/template/src/app/api/integrations/google-sheet/sync/route.ts +24 -556
  62. package/template/src/app/api/jobs/google-sheet/process/route.ts +84 -0
  63. package/template/src/app/api/jobs/google-sheet/schedule/route.ts +50 -0
  64. package/template/src/app/api/reminders/clear/route.ts +120 -0
  65. package/template/src/app/api/reminders/clear/undo/route.ts +112 -0
  66. package/template/src/app/api/reminders/route.ts +164 -39
  67. package/template/src/app/api/reminders/state/route.ts +164 -0
  68. package/template/src/app/api/reset-password/request/route.ts +1 -1
  69. package/template/src/app/api/reset-password/verify/route.ts +1 -1
  70. package/template/src/app/api/send/route.ts +16 -4
  71. package/template/src/app/api/settings/google-ads/route.ts +14 -0
  72. package/template/src/app/api/settings/google-calendar/calendars/route.ts +97 -0
  73. package/template/src/app/api/settings/google-calendar/route.ts +124 -0
  74. package/template/src/app/api/settings/google-sheet/[id]/route.ts +28 -0
  75. package/template/src/app/api/settings/google-sheet/auto-map/route.ts +37 -4
  76. package/template/src/app/api/settings/google-sheet/preview/route.ts +9 -3
  77. package/template/src/app/api/settings/google-sheet/route.ts +14 -0
  78. package/template/src/app/api/settings/integrations/logs/route.ts +93 -0
  79. package/template/src/app/api/settings/integrations/notifications/route.ts +67 -0
  80. package/template/src/app/api/settings/meta-leads/[id]/route.ts +0 -1
  81. package/template/src/app/api/settings/meta-leads/route.ts +14 -2
  82. package/template/src/app/api/settings/smtp/route.ts +53 -6
  83. package/template/src/app/api/tasks/[id]/attendees/route.ts +24 -8
  84. package/template/src/app/api/tasks/[id]/route.ts +234 -58
  85. package/template/src/app/api/tasks/meet/route.ts +27 -19
  86. package/template/src/app/api/tasks/route.ts +62 -17
  87. package/template/src/app/api/users/[id]/route.ts +20 -14
  88. package/template/src/app/api/users/list/route.ts +57 -19
  89. package/template/src/app/api/webhooks/google-ads/route.ts +34 -14
  90. package/template/src/app/api/webhooks/meta-leads/route.ts +32 -12
  91. package/template/src/app/api/workflows/[id]/route.ts +0 -4
  92. package/template/src/app/api/workflows/process/route.ts +22 -51
  93. package/template/src/app/api/workflows/route.ts +0 -4
  94. package/template/src/app/globals.css +342 -4
  95. package/template/src/app/layout.tsx +11 -3
  96. package/template/src/app/page.tsx +1 -1
  97. package/template/src/components/address-autocomplete.tsx +7 -6
  98. package/template/src/components/config-error-alert.tsx +46 -0
  99. package/template/src/components/contacts/filter-bar.tsx +12 -3
  100. package/template/src/components/contacts/filter-builder.tsx +28 -43
  101. package/template/src/components/contacts/save-view-dialog.tsx +1 -1
  102. package/template/src/components/contacts/views-tab-bar.tsx +15 -6
  103. package/template/src/components/dashboard/activity-chart.tsx +41 -28
  104. package/template/src/components/dashboard/add-widget-dialog.tsx +157 -0
  105. package/template/src/components/dashboard/color-picker.tsx +64 -0
  106. package/template/src/components/dashboard/contacts-chart.tsx +69 -0
  107. package/template/src/components/dashboard/interactions-by-type-chart.tsx +121 -0
  108. package/template/src/components/dashboard/recent-activity.tsx +154 -0
  109. package/template/src/components/dashboard/stat-card.tsx +40 -40
  110. package/template/src/components/dashboard/status-distribution-chart.tsx +81 -0
  111. package/template/src/components/dashboard/tasks-pie-chart.tsx +37 -34
  112. package/template/src/components/dashboard/top-contacts-list.tsx +113 -0
  113. package/template/src/components/dashboard/upcoming-tasks-list.tsx +72 -81
  114. package/template/src/components/dashboard/widget-wrapper.tsx +36 -0
  115. package/template/src/components/date-picker.tsx +9 -6
  116. package/template/src/components/editor/upload-editor-image.ts +42 -0
  117. package/template/src/components/editor.tsx +161 -22
  118. package/template/src/components/email-template.tsx +2 -2
  119. package/template/src/components/global-search.tsx +30 -28
  120. package/template/src/components/header.tsx +178 -80
  121. package/template/src/components/inactive-account-guard.tsx +58 -0
  122. package/template/src/components/integration-notifications-listener.tsx +12 -0
  123. package/template/src/components/invitation-email-template.tsx +2 -2
  124. package/template/src/components/meet-cancellation-email-template.tsx +3 -3
  125. package/template/src/components/meet-confirmation-email-template.tsx +3 -3
  126. package/template/src/components/meet-update-email-template.tsx +3 -3
  127. package/template/src/components/page-header.tsx +5 -5
  128. package/template/src/components/protected-page.tsx +1 -1
  129. package/template/src/components/reset-password-email-template.tsx +2 -2
  130. package/template/src/components/settings/integrations/GoogleAdsIntegration.tsx +428 -0
  131. package/template/src/components/settings/integrations/GoogleSheetConfigMonitoringModal.tsx +680 -0
  132. package/template/src/components/settings/integrations/GoogleSheetIntegration.tsx +809 -0
  133. package/template/src/components/settings/integrations/ImportResultDialog.tsx +124 -0
  134. package/template/src/components/settings/integrations/IntegrationLogPanel.tsx +57 -0
  135. package/template/src/components/settings/integrations/IntegrationLogsTable.tsx +186 -0
  136. package/template/src/components/settings/integrations/MetaLeadIntegration.tsx +451 -0
  137. package/template/src/components/sidebar.tsx +45 -26
  138. package/template/src/components/skeleton.tsx +40 -43
  139. package/template/src/components/ui/accordion.tsx +2 -2
  140. package/template/src/components/ui/alert-dialog.tsx +1 -1
  141. package/template/src/components/ui/button.tsx +20 -9
  142. package/template/src/components/ui/components.tsx +1 -1
  143. package/template/src/components/ui/date-picker.tsx +422 -0
  144. package/template/src/components/ui/datetime-picker.tsx +338 -0
  145. package/template/src/components/ui/status-select.tsx +271 -0
  146. package/template/src/components/ui/tooltip.tsx +37 -0
  147. package/template/src/components/view-as-modal.tsx +13 -7
  148. package/template/src/contexts/app-toast-context.tsx +245 -57
  149. package/template/src/contexts/dashboard-theme-context.tsx +53 -0
  150. package/template/src/contexts/sidebar-context.tsx +22 -17
  151. package/template/src/contexts/task-reminder-context.tsx +134 -160
  152. package/template/src/contexts/view-as-context.tsx +33 -6
  153. package/template/src/hooks/use-focus-trap.ts +2 -2
  154. package/template/src/hooks/useIntegrationNotifications.ts +49 -0
  155. package/template/src/lib/auth.ts +8 -1
  156. package/template/src/lib/config-links.ts +14 -0
  157. package/template/src/lib/contact-duplicate.ts +79 -61
  158. package/template/src/lib/contact-interactions.ts +21 -21
  159. package/template/src/lib/contact-view-filters.ts +24 -64
  160. package/template/src/lib/contacts-list-url.ts +190 -0
  161. package/template/src/lib/dashboard-stats.ts +65 -7
  162. package/template/src/lib/dashboard-themes.ts +135 -0
  163. package/template/src/lib/date-utils.ts +127 -0
  164. package/template/src/lib/default-widgets.ts +12 -0
  165. package/template/src/lib/editor-html-image-dimensions.ts +172 -0
  166. package/template/src/lib/editor-image-limits.ts +19 -0
  167. package/template/src/lib/email-html-sanitize.ts +19 -0
  168. package/template/src/lib/encryption.ts +9 -6
  169. package/template/src/lib/fr-geography.ts +192 -0
  170. package/template/src/lib/google-calendar-agenda.ts +201 -0
  171. package/template/src/lib/google-calendar.ts +255 -5
  172. package/template/src/lib/google-sheet-sync-jobs.ts +96 -0
  173. package/template/src/lib/google-sheet-sync-runner.ts +514 -0
  174. package/template/src/lib/integration-import-log.ts +21 -0
  175. package/template/src/lib/permissions.ts +40 -10
  176. package/template/src/lib/prisma.ts +4 -1
  177. package/template/src/lib/qstash.ts +65 -0
  178. package/template/src/lib/reminder-state-server.ts +80 -0
  179. package/template/src/lib/reminder-state.ts +29 -0
  180. package/template/src/lib/supabase-storage.ts +113 -0
  181. package/template/src/lib/template-variables.ts +164 -23
  182. package/template/src/lib/utils.ts +45 -0
  183. package/template/src/lib/widget-registry.ts +173 -0
  184. package/template/src/lib/workflow-executor.ts +16 -70
  185. package/template/src/proxy.ts +1 -0
  186. package/template/vercel.json +3 -10
  187. package/template/skills-lock.json +0 -25
  188. package/template/src/components/dashboard/dashboard-content.tsx +0 -79
  189. package/template/src/lib/google-drive.ts +0 -1101
  190. package/template/src/types/yousign.ts +0 -52
@@ -0,0 +1,50 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { enqueueGoogleSheetSyncJob } from '@/lib/google-sheet-sync-jobs';
3
+ import { QstashAuthError, verifyQstashRequest } from '@/lib/qstash';
4
+
5
+ /**
6
+ * Déclenchement planifié de la synchro Google Sheets.
7
+ *
8
+ * - Planification : **Upstash QStash Schedule** en `POST` avec header `upstash-signature` (corps signé).
9
+ * - Optionnel : déclenchement manuel / secours avec `Authorization: Bearer <CRON_SECRET>` (`CRON_SECRET` sur l’hébergeur).
10
+ */
11
+ export async function POST(request: NextRequest) {
12
+ try {
13
+ const cronSecret = process.env.CRON_SECRET;
14
+ const authHeader = request.headers.get('authorization');
15
+ const expectedBearer = cronSecret ? `Bearer ${cronSecret}` : null;
16
+ const isVercelCron =
17
+ Boolean(expectedBearer && authHeader === expectedBearer);
18
+
19
+ if (!isVercelCron) {
20
+ const signature = request.headers.get('upstash-signature');
21
+ if (signature) {
22
+ const body = await request.text();
23
+ await verifyQstashRequest(signature, body);
24
+ } else {
25
+ return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
26
+ }
27
+ }
28
+
29
+ const job = await enqueueGoogleSheetSyncJob({
30
+ requestedByUserId: null,
31
+ configId: null,
32
+ triggerType: 'SCHEDULED',
33
+ });
34
+
35
+ return NextResponse.json(
36
+ {
37
+ ok: true,
38
+ jobId: job.id,
39
+ status: job.status,
40
+ },
41
+ { status: 202 },
42
+ );
43
+ } catch (error: any) {
44
+ if (error instanceof QstashAuthError) {
45
+ return NextResponse.json({ error: 'Signature QStash invalide' }, { status: 401 });
46
+ }
47
+ console.error('Erreur schedule Google Sheet:', error);
48
+ return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 });
49
+ }
50
+ }
@@ -0,0 +1,120 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { auth } from '@/lib/auth';
3
+ import { prisma, Prisma } from '@/lib/prisma';
4
+ import {
5
+ REMINDERS_CLEAR_ALL_ID,
6
+ REMINDERS_CLEAR_UNDO_WINDOW_MS,
7
+ } from '@/lib/reminder-state';
8
+ import {
9
+ computeExpiresAtForUpsert,
10
+ getRequestId,
11
+ isMissingReminderStateTableError,
12
+ logReminderEvent,
13
+ } from '@/lib/reminder-state-server';
14
+
15
+ export async function POST(request: NextRequest) {
16
+ const started = Date.now();
17
+ const requestId = getRequestId(request);
18
+
19
+ try {
20
+ const session = await auth.api.getSession({ headers: request.headers });
21
+ if (!session) {
22
+ return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
23
+ }
24
+
25
+ const now = new Date();
26
+ const undoUntil = new Date(now.getTime() + REMINDERS_CLEAR_UNDO_WINDOW_MS);
27
+ const expiresAt = computeExpiresAtForUpsert(REMINDERS_CLEAR_ALL_ID, 'CLEARED', now);
28
+
29
+ try {
30
+ const existing = await prisma.userReminderState.findUnique({
31
+ where: {
32
+ userId_reminderId: {
33
+ userId: session.user.id,
34
+ reminderId: REMINDERS_CLEAR_ALL_ID,
35
+ },
36
+ },
37
+ select: {
38
+ status: true,
39
+ clearedCutoffAt: true,
40
+ updatedAt: true,
41
+ },
42
+ });
43
+
44
+ const previousClearedAt =
45
+ existing?.status === 'CLEARED'
46
+ ? (existing.clearedCutoffAt ?? existing.updatedAt).toISOString()
47
+ : null;
48
+
49
+ const metadata: Prisma.InputJsonValue = {
50
+ undoUntil: undoUntil.toISOString(),
51
+ previousClearedAt,
52
+ };
53
+
54
+ const clearState = await prisma.userReminderState.upsert({
55
+ where: {
56
+ userId_reminderId: {
57
+ userId: session.user.id,
58
+ reminderId: REMINDERS_CLEAR_ALL_ID,
59
+ },
60
+ },
61
+ update: {
62
+ status: 'CLEARED',
63
+ clearedCutoffAt: now,
64
+ metadata,
65
+ expiresAt,
66
+ },
67
+ create: {
68
+ userId: session.user.id,
69
+ reminderId: REMINDERS_CLEAR_ALL_ID,
70
+ status: 'CLEARED',
71
+ clearedCutoffAt: now,
72
+ metadata,
73
+ expiresAt,
74
+ },
75
+ select: {
76
+ clearedCutoffAt: true,
77
+ updatedAt: true,
78
+ },
79
+ });
80
+
81
+ const clearedAt = (clearState.clearedCutoffAt ?? clearState.updatedAt).toISOString();
82
+
83
+ logReminderEvent({
84
+ event: 'reminders_clear_all',
85
+ requestId,
86
+ userId: session.user.id,
87
+ degraded: false,
88
+ durationMs: Date.now() - started,
89
+ });
90
+
91
+ return NextResponse.json({
92
+ success: true,
93
+ clearedAt,
94
+ undoUntil: undoUntil.toISOString(),
95
+ degraded: false,
96
+ });
97
+ } catch (err) {
98
+ if (isMissingReminderStateTableError(err)) {
99
+ logReminderEvent({
100
+ event: 'reminders_fallback_missing_table',
101
+ requestId,
102
+ userId: session.user.id,
103
+ degraded: true,
104
+ durationMs: Date.now() - started,
105
+ });
106
+ return NextResponse.json({
107
+ success: true,
108
+ clearedAt: now.toISOString(),
109
+ undoUntil: undoUntil.toISOString(),
110
+ degraded: true,
111
+ });
112
+ }
113
+ throw err;
114
+ }
115
+ } catch (error: unknown) {
116
+ const msg = error instanceof Error ? error.message : 'Erreur serveur';
117
+ console.error('Erreur lors du vidage des rappels:', error);
118
+ return NextResponse.json({ error: msg }, { status: 500 });
119
+ }
120
+ }
@@ -0,0 +1,112 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { auth } from '@/lib/auth';
3
+ import { prisma, Prisma } from '@/lib/prisma';
4
+ import { REMINDERS_CLEAR_ALL_ID } from '@/lib/reminder-state';
5
+ import {
6
+ computeExpiresAtForUpsert,
7
+ getRequestId,
8
+ isMissingReminderStateTableError,
9
+ logReminderEvent,
10
+ parseClearMetadata,
11
+ } from '@/lib/reminder-state-server';
12
+
13
+ export async function POST(request: NextRequest) {
14
+ const started = Date.now();
15
+ const requestId = getRequestId(request);
16
+
17
+ try {
18
+ const session = await auth.api.getSession({ headers: request.headers });
19
+ if (!session) {
20
+ return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
21
+ }
22
+
23
+ const now = new Date();
24
+
25
+ try {
26
+ const row = await prisma.userReminderState.findUnique({
27
+ where: {
28
+ userId_reminderId: {
29
+ userId: session.user.id,
30
+ reminderId: REMINDERS_CLEAR_ALL_ID,
31
+ },
32
+ },
33
+ select: {
34
+ status: true,
35
+ reminderId: true,
36
+ metadata: true,
37
+ },
38
+ });
39
+
40
+ if (!row || row.status !== 'CLEARED') {
41
+ return NextResponse.json({ error: 'Rien à annuler.' }, { status: 400 });
42
+ }
43
+
44
+ const meta = parseClearMetadata(row.metadata);
45
+ if (!meta || now.getTime() > new Date(meta.undoUntil).getTime()) {
46
+ logReminderEvent({
47
+ event: 'reminders_clear_undo',
48
+ requestId,
49
+ userId: session.user.id,
50
+ degraded: false,
51
+ durationMs: Date.now() - started,
52
+ extra: { rejected: true, reason: 'undo_expired' },
53
+ });
54
+ return NextResponse.json({ error: "Délai d'annulation dépassé." }, { status: 400 });
55
+ }
56
+
57
+ if (meta.previousClearedAt === null) {
58
+ await prisma.userReminderState.delete({
59
+ where: {
60
+ userId_reminderId: {
61
+ userId: session.user.id,
62
+ reminderId: REMINDERS_CLEAR_ALL_ID,
63
+ },
64
+ },
65
+ });
66
+ } else {
67
+ const previous = new Date(meta.previousClearedAt);
68
+ const expiresAt = computeExpiresAtForUpsert(REMINDERS_CLEAR_ALL_ID, 'CLEARED', now);
69
+ await prisma.userReminderState.update({
70
+ where: {
71
+ userId_reminderId: {
72
+ userId: session.user.id,
73
+ reminderId: REMINDERS_CLEAR_ALL_ID,
74
+ },
75
+ },
76
+ data: {
77
+ clearedCutoffAt: previous,
78
+ metadata: Prisma.JsonNull,
79
+ expiresAt,
80
+ },
81
+ });
82
+ }
83
+
84
+ logReminderEvent({
85
+ event: 'reminders_clear_undo',
86
+ requestId,
87
+ userId: session.user.id,
88
+ degraded: false,
89
+ durationMs: Date.now() - started,
90
+ extra: { restoredPrevious: meta.previousClearedAt != null },
91
+ });
92
+
93
+ return NextResponse.json({ success: true, degraded: false });
94
+ } catch (err) {
95
+ if (isMissingReminderStateTableError(err)) {
96
+ logReminderEvent({
97
+ event: 'reminders_fallback_missing_table',
98
+ requestId,
99
+ userId: session.user.id,
100
+ degraded: true,
101
+ durationMs: Date.now() - started,
102
+ });
103
+ return NextResponse.json({ success: true, degraded: true });
104
+ }
105
+ throw err;
106
+ }
107
+ } catch (error: unknown) {
108
+ const msg = error instanceof Error ? error.message : 'Erreur serveur';
109
+ console.error('Erreur lors de l’annulation du vidage des rappels:', error);
110
+ return NextResponse.json({ error: msg }, { status: 500 });
111
+ }
112
+ }
@@ -1,9 +1,20 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import { auth } from '@/lib/auth';
3
3
  import { prisma } from '@/lib/prisma';
4
+ import { REMINDERS_CLEAR_ALL_ID, REMINDER_READ_STATUSES } from '@/lib/reminder-state';
5
+ import {
6
+ getRequestId,
7
+ isMissingReminderStateTableError,
8
+ logReminderEvent,
9
+ reminderStateNotExpired,
10
+ } from '@/lib/reminder-state-server';
4
11
 
5
12
  // GET /api/reminders - Récupérer tous les rappels de l'utilisateur
6
13
  export async function GET(request: NextRequest) {
14
+ const started = Date.now();
15
+ const requestId = getRequestId(request);
16
+ let degraded = false;
17
+
7
18
  try {
8
19
  const session = await auth.api.getSession({
9
20
  headers: request.headers,
@@ -14,20 +25,19 @@ export async function GET(request: NextRequest) {
14
25
  }
15
26
 
16
27
  const now = new Date();
17
- const future = new Date(now);
18
- future.setDate(future.getDate() + 7); // 7 jours à venir
28
+ const start = new Date(now);
29
+ start.setDate(start.getDate() - 7);
30
+ const end = new Date(now);
31
+ end.setDate(end.getDate() + 1);
19
32
 
20
- // Récupérer toutes les tâches non complétées de l'utilisateur avec rappels
33
+ // Récupérer les tâches non complétées de l'utilisateur dans une fenêtre proche
21
34
  const tasks = await prisma.task.findMany({
22
35
  where: {
23
36
  assignedUserId: session.user.id,
24
37
  completed: false,
25
38
  scheduledAt: {
26
- gte: now,
27
- lte: future,
28
- },
29
- reminderMinutesBefore: {
30
- not: null,
39
+ gte: start,
40
+ lte: end,
31
41
  },
32
42
  },
33
43
  include: {
@@ -54,45 +64,160 @@ export async function GET(request: NextRequest) {
54
64
  take: 100,
55
65
  });
56
66
 
57
- // Formater les rappels - inclure toutes les tâches avec rappels
58
- const reminders = tasks
59
- .filter((task) => {
60
- if (!task.reminderMinutesBefore) return false;
67
+ const reminderCandidates = tasks
68
+ .flatMap((task) => {
61
69
  const scheduled = new Date(task.scheduledAt);
62
- const reminderTime = new Date(scheduled.getTime() - task.reminderMinutesBefore * 60 * 1000);
63
- // Inclure les rappels qui sont passés mais pas encore à l'heure de la tâche
64
- // ou les rappels qui sont à venir (dans les 7 prochains jours)
65
- const timeUntilReminder = reminderTime.getTime() - now.getTime();
66
- return (
67
- (reminderTime <= now && now < scheduled) ||
68
- (timeUntilReminder > 0 && timeUntilReminder < 7 * 24 * 60 * 60 * 1000)
69
- );
70
+ const entries: Array<{
71
+ id: string;
72
+ taskId: string;
73
+ kind: 'due' | 'reminder';
74
+ type: string;
75
+ title: string | null;
76
+ description: string;
77
+ priority: string;
78
+ scheduledAt: Date;
79
+ reminderTime: string;
80
+ reminderMinutesBefore: number | null;
81
+ contact: typeof task.contact;
82
+ assignedUser: typeof task.assignedUser;
83
+ }> = [];
84
+
85
+ const diffMs = now.getTime() - scheduled.getTime();
86
+ if (diffMs >= 0) {
87
+ entries.push({
88
+ id: `${task.id}-due`,
89
+ taskId: task.id,
90
+ kind: 'due',
91
+ type: task.type,
92
+ title: task.title,
93
+ description: task.description,
94
+ priority: task.priority,
95
+ scheduledAt: task.scheduledAt,
96
+ reminderTime: scheduled.toISOString(),
97
+ reminderMinutesBefore: task.reminderMinutesBefore,
98
+ contact: task.contact,
99
+ assignedUser: task.assignedUser,
100
+ });
101
+ }
102
+
103
+ if (task.reminderMinutesBefore != null && task.reminderMinutesBefore > 0) {
104
+ const reminderTime = new Date(scheduled.getTime() - task.reminderMinutesBefore * 60 * 1000);
105
+ const diffReminderMs = now.getTime() - reminderTime.getTime();
106
+ if (diffReminderMs >= 0 && now < scheduled) {
107
+ entries.push({
108
+ id: `${task.id}-reminder`,
109
+ taskId: task.id,
110
+ kind: 'reminder',
111
+ type: task.type,
112
+ title: task.title,
113
+ description: task.description,
114
+ priority: task.priority,
115
+ scheduledAt: task.scheduledAt,
116
+ reminderTime: reminderTime.toISOString(),
117
+ reminderMinutesBefore: task.reminderMinutesBefore,
118
+ contact: task.contact,
119
+ assignedUser: task.assignedUser,
120
+ });
121
+ }
122
+ }
123
+
124
+ return entries;
125
+ });
126
+
127
+ const reminderIds = reminderCandidates.map((reminder) => reminder.id);
128
+ const countCandidates = reminderCandidates.length;
129
+
130
+ let reminderStates: Array<{
131
+ reminderId: string;
132
+ status: string;
133
+ updatedAt: Date;
134
+ clearedCutoffAt: Date | null;
135
+ }> = [];
136
+
137
+ try {
138
+ reminderStates = await prisma.userReminderState.findMany({
139
+ where: {
140
+ userId: session.user.id,
141
+ AND: [
142
+ {
143
+ OR: [{ reminderId: { in: reminderIds } }, { reminderId: REMINDERS_CLEAR_ALL_ID }],
144
+ },
145
+ reminderStateNotExpired(now),
146
+ ],
147
+ },
148
+ select: {
149
+ reminderId: true,
150
+ status: true,
151
+ updatedAt: true,
152
+ clearedCutoffAt: true,
153
+ },
154
+ });
155
+ } catch (err) {
156
+ if (isMissingReminderStateTableError(err)) {
157
+ degraded = true;
158
+ logReminderEvent({
159
+ event: 'reminders_fallback_missing_table',
160
+ requestId,
161
+ userId: session.user.id,
162
+ degraded: true,
163
+ durationMs: Date.now() - started,
164
+ });
165
+ } else {
166
+ throw err;
167
+ }
168
+ }
169
+
170
+ const clearState = reminderStates.find(
171
+ (state) => state.reminderId === REMINDERS_CLEAR_ALL_ID && state.status === 'CLEARED',
172
+ );
173
+ const clearedAt = clearState
174
+ ? (clearState.clearedCutoffAt ?? clearState.updatedAt)
175
+ : null;
176
+ const stateByReminderId = new Map(
177
+ reminderStates
178
+ .filter((state) => state.reminderId !== REMINDERS_CLEAR_ALL_ID)
179
+ .map((state) => [state.reminderId, state]),
180
+ );
181
+
182
+ const reminders = reminderCandidates
183
+ .filter((reminder) => {
184
+ if (!clearedAt) return true;
185
+ return new Date(reminder.reminderTime).getTime() > clearedAt.getTime();
70
186
  })
71
- .map((task) => {
72
- const scheduled = new Date(task.scheduledAt);
73
- const reminderTime = new Date(
74
- scheduled.getTime() - (task.reminderMinutesBefore || 0) * 60 * 1000,
187
+ .map((reminder) => {
188
+ const state = stateByReminderId.get(reminder.id);
189
+ const isRead = Boolean(state && REMINDER_READ_STATUSES.has(state.status as 'READ' | 'DISMISSED'));
190
+ const isDismissed = state?.status === 'DISMISSED';
191
+ const isClearedByCutoff = Boolean(
192
+ clearedAt && new Date(reminder.reminderTime).getTime() <= clearedAt.getTime(),
75
193
  );
76
194
 
77
195
  return {
78
- id: `${task.id}-reminder`,
79
- taskId: task.id,
80
- type: task.type,
81
- title: task.title,
82
- description: task.description,
83
- priority: task.priority,
84
- scheduledAt: task.scheduledAt,
85
- reminderTime: reminderTime.toISOString(),
86
- reminderMinutesBefore: task.reminderMinutesBefore,
87
- contact: task.contact,
88
- assignedUser: task.assignedUser,
196
+ ...reminder,
197
+ isRead,
198
+ isDismissed,
199
+ isClearedByCutoff,
89
200
  };
90
201
  })
91
- .sort((a, b) => new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime());
202
+ .sort((a, b) => new Date(b.reminderTime).getTime() - new Date(a.reminderTime).getTime());
203
+
204
+ logReminderEvent({
205
+ event: 'reminders_fetch',
206
+ requestId,
207
+ userId: session.user.id,
208
+ countCandidates,
209
+ countReturned: reminders.length,
210
+ degraded,
211
+ durationMs: Date.now() - started,
212
+ });
213
+
214
+ const headers: Record<string, string> = {};
215
+ if (degraded) headers['x-reminders-degraded'] = '1';
92
216
 
93
- return NextResponse.json(reminders);
94
- } catch (error: any) {
217
+ return NextResponse.json(reminders, { headers });
218
+ } catch (error: unknown) {
219
+ const msg = error instanceof Error ? error.message : 'Erreur serveur';
95
220
  console.error('Erreur lors de la récupération des rappels:', error);
96
- return NextResponse.json({ error: error.message || 'Erreur serveur' }, { status: 500 });
221
+ return NextResponse.json({ error: msg }, { status: 500 });
97
222
  }
98
223
  }
@@ -0,0 +1,164 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { auth } from '@/lib/auth';
3
+ import { prisma } from '@/lib/prisma';
4
+ import { REMINDERS_CLEAR_ALL_ID } from '@/lib/reminder-state';
5
+ import {
6
+ computeExpiresAtForUpsert,
7
+ getRequestId,
8
+ isMissingReminderStateTableError,
9
+ logReminderEvent,
10
+ reminderStateNotExpired,
11
+ } from '@/lib/reminder-state-server';
12
+
13
+ export async function GET(request: NextRequest) {
14
+ const started = Date.now();
15
+ const requestId = getRequestId(request);
16
+ let degraded = false;
17
+
18
+ try {
19
+ const session = await auth.api.getSession({ headers: request.headers });
20
+ if (!session) {
21
+ return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
22
+ }
23
+
24
+ const now = new Date();
25
+ let states: Array<{ reminderId: string; status: string; updatedAt: Date }> = [];
26
+ let clearAllState: { status: string; updatedAt: Date; clearedCutoffAt: Date | null } | undefined;
27
+
28
+ try {
29
+ const rows = await prisma.userReminderState.findMany({
30
+ where: {
31
+ userId: session.user.id,
32
+ AND: [reminderStateNotExpired(now)],
33
+ },
34
+ select: {
35
+ reminderId: true,
36
+ status: true,
37
+ updatedAt: true,
38
+ clearedCutoffAt: true,
39
+ },
40
+ });
41
+ states = rows;
42
+ clearAllState = rows.find((state) => state.reminderId === REMINDERS_CLEAR_ALL_ID);
43
+ } catch (err) {
44
+ if (isMissingReminderStateTableError(err)) {
45
+ degraded = true;
46
+ logReminderEvent({
47
+ event: 'reminders_fallback_missing_table',
48
+ requestId,
49
+ userId: session.user.id,
50
+ degraded: true,
51
+ durationMs: Date.now() - started,
52
+ });
53
+ } else {
54
+ throw err;
55
+ }
56
+ }
57
+
58
+ const clearedAtIso =
59
+ clearAllState?.status === 'CLEARED'
60
+ ? (clearAllState.clearedCutoffAt ?? clearAllState.updatedAt).toISOString()
61
+ : null;
62
+
63
+ logReminderEvent({
64
+ event: 'reminder_state_read',
65
+ requestId,
66
+ userId: session.user.id,
67
+ countReturned: states.filter((s) => s.reminderId !== REMINDERS_CLEAR_ALL_ID).length,
68
+ degraded,
69
+ durationMs: Date.now() - started,
70
+ });
71
+
72
+ return NextResponse.json({
73
+ states: states.filter((state) => state.reminderId !== REMINDERS_CLEAR_ALL_ID),
74
+ clearedAt: clearedAtIso,
75
+ degraded,
76
+ });
77
+ } catch (error: unknown) {
78
+ const msg = error instanceof Error ? error.message : 'Erreur serveur';
79
+ console.error("Erreur lors de la récupération de l'état des rappels:", error);
80
+ return NextResponse.json({ error: msg }, { status: 500 });
81
+ }
82
+ }
83
+
84
+ export async function POST(request: NextRequest) {
85
+ const started = Date.now();
86
+ const requestId = getRequestId(request);
87
+
88
+ try {
89
+ const session = await auth.api.getSession({ headers: request.headers });
90
+ if (!session) {
91
+ return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
92
+ }
93
+
94
+ const body = await request.json().catch(() => ({}));
95
+ const reminderId = typeof body.reminderId === 'string' ? body.reminderId.trim() : '';
96
+ const status = typeof body.status === 'string' ? body.status.trim().toUpperCase() : '';
97
+ if (!reminderId) {
98
+ return NextResponse.json({ error: 'reminderId requis.' }, { status: 400 });
99
+ }
100
+ if (!['READ', 'DISMISSED'].includes(status)) {
101
+ return NextResponse.json({ error: 'status invalide.' }, { status: 400 });
102
+ }
103
+
104
+ const now = new Date();
105
+ const expiresAt = computeExpiresAtForUpsert(reminderId, status as 'READ' | 'DISMISSED', now);
106
+
107
+ try {
108
+ const state = await prisma.userReminderState.upsert({
109
+ where: {
110
+ userId_reminderId: {
111
+ userId: session.user.id,
112
+ reminderId,
113
+ },
114
+ },
115
+ update: {
116
+ status: status as 'READ' | 'DISMISSED',
117
+ expiresAt,
118
+ },
119
+ create: {
120
+ userId: session.user.id,
121
+ reminderId,
122
+ status: status as 'READ' | 'DISMISSED',
123
+ expiresAt,
124
+ },
125
+ select: {
126
+ reminderId: true,
127
+ status: true,
128
+ updatedAt: true,
129
+ },
130
+ });
131
+
132
+ logReminderEvent({
133
+ event: 'reminder_state_upsert',
134
+ requestId,
135
+ userId: session.user.id,
136
+ degraded: false,
137
+ durationMs: Date.now() - started,
138
+ extra: { reminderId, status },
139
+ });
140
+
141
+ return NextResponse.json({ success: true, state, degraded: false });
142
+ } catch (err) {
143
+ if (isMissingReminderStateTableError(err)) {
144
+ logReminderEvent({
145
+ event: 'reminders_fallback_missing_table',
146
+ requestId,
147
+ userId: session.user.id,
148
+ degraded: true,
149
+ durationMs: Date.now() - started,
150
+ });
151
+ return NextResponse.json({
152
+ success: true,
153
+ state: { reminderId, status, updatedAt: now.toISOString() },
154
+ degraded: true,
155
+ });
156
+ }
157
+ throw err;
158
+ }
159
+ } catch (error: unknown) {
160
+ const msg = error instanceof Error ? error.message : 'Erreur serveur';
161
+ console.error("Erreur lors de la mise à jour de l'état rappel:", error);
162
+ return NextResponse.json({ error: msg }, { status: 500 });
163
+ }
164
+ }
@@ -31,7 +31,7 @@ export async function POST(request: NextRequest) {
31
31
  }
32
32
 
33
33
  // Générer un code à 6 chiffres
34
- const code = Math.floor(100000 + Math.random() * 900000).toString();
34
+ const code = String(crypto.getRandomValues(new Uint32Array(1))[0] % 900000 + 100000);
35
35
 
36
36
  // Créer ou mettre à jour le token de vérification
37
37
  const expiresAt = new Date();