feat: UX improvements, webhooks, invite management
- Photo lightbox: ArrowLeft/Right/Escape keyboard navigation - Growth chart: persistent WHO percentile legend (not hover-only) - Sleep stats: configurable window selector (18h/19h/20h/21h) - Quick-add FAB: now visible on desktop (bottom-right corner) - Invite code reset button in settings (non-admin, PARENT role only) - Pending email invites: tracked per-token with 7-day expiry, revocable from settings UI; new /auth/invite/t/[token] accept page - Webhooks: Prisma model, CRUD API, HMAC-SHA256 dispatcher wired into event.created / growth.created / doctor_note.created; settings UI - Docker cron service: medication reminders every 15min, milk-expiry and daily-digest daily; secured with CRON_SECRET - Photo uploads persist across redeploys via Docker named volume Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ services:
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||
VAPID_SUBJECT: ${VAPID_SUBJECT:-}
|
||||
CRON_SECRET: ${CRON_SECRET:-}
|
||||
|
||||
migrate:
|
||||
build:
|
||||
@@ -45,6 +46,22 @@ services:
|
||||
DATABASE_URL: "postgresql://grow:${POSTGRES_PASSWORD}@db:5432/grow"
|
||||
restart: "no"
|
||||
|
||||
cron:
|
||||
image: alpine:3.19
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- app
|
||||
environment:
|
||||
APP_URL: "http://app:3000"
|
||||
CRON_SECRET: ${CRON_SECRET:-}
|
||||
command: >
|
||||
sh -c "
|
||||
echo '*/15 * * * * wget -qO- --header=\"Authorization: Bearer $$CRON_SECRET\" --post-data= $$APP_URL/api/reminders/check > /dev/null 2>&1' > /etc/crontabs/root &&
|
||||
echo '0 8 * * * wget -qO- --header=\"Authorization: Bearer $$CRON_SECRET\" --post-data= $$APP_URL/api/cron/milk-expiry > /dev/null 2>&1' >> /etc/crontabs/root &&
|
||||
echo '0 7 * * * wget -qO- --header=\"Authorization: Bearer $$CRON_SECRET\" --post-data= $$APP_URL/api/cron/daily-digest > /dev/null 2>&1' >> /etc/crontabs/root &&
|
||||
crond -f -l 8
|
||||
"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
uploads_data:
|
||||
|
||||
@@ -92,12 +92,36 @@ model Family {
|
||||
babies Baby[]
|
||||
eventTemplates EventTemplate[]
|
||||
apiKeys ApiKey[]
|
||||
webhooks Webhook[]
|
||||
pendingInvites PendingInvite[]
|
||||
pinnedNote String?
|
||||
pinnedNoteUpdatedAt DateTime?
|
||||
pinnedNoteAuthor String?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model PendingInvite {
|
||||
id String @id @default(cuid())
|
||||
familyId String
|
||||
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
|
||||
email String
|
||||
role UserRole @default(PARENT)
|
||||
token String @unique @default(cuid())
|
||||
expiresAt DateTime
|
||||
sentAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model Webhook {
|
||||
id String @id @default(cuid())
|
||||
familyId String
|
||||
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
|
||||
url String
|
||||
secret String
|
||||
events String[]
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model ApiKey {
|
||||
id String @id @default(cuid())
|
||||
familyId String
|
||||
|
||||
@@ -471,7 +471,6 @@ export default function GrowthPage() {
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-3 h-0.5 bg-slate-300 rounded" />
|
||||
OMS P3–P97
|
||||
<span className="text-slate-300 dark:text-slate-600 text-[10px]" title="P50 = médiane : 50% des bébés ont un poids inférieur. P3–P97 représente la plage normale OMS.">ⓘ</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -487,6 +486,9 @@ export default function GrowthPage() {
|
||||
<Line dataKey="actualWeight" stroke="#4f46e5" strokeWidth={2.5} dot={{ r: 4, fill: "#4f46e5", strokeWidth: 2, stroke: "#fff" }} connectNulls={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<p className="text-[10px] text-slate-400 dark:text-slate-500 mt-2">
|
||||
P50 = médiane OMS (50% des bébés en dessous). P3–P97 = plage normale. Hors de cette plage ne signifie pas un problème — consultez votre pédiatre.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useBaby } from "@/contexts/baby-context";
|
||||
import { EVENT_CONFIG, EventType } from "@/lib/event-config";
|
||||
@@ -68,6 +68,18 @@ export default function PhotosPage() {
|
||||
|
||||
const current = lightbox ? lightbox.events[lightbox.index] : null;
|
||||
|
||||
const handleKey = useCallback((e: KeyboardEvent) => {
|
||||
if (!lightbox) return;
|
||||
if (e.key === "ArrowLeft") { e.preventDefault(); prev(); }
|
||||
else if (e.key === "ArrowRight") { e.preventDefault(); next(); }
|
||||
else if (e.key === "Escape") setLightbox(null);
|
||||
}, [lightbox]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [handleKey]);
|
||||
|
||||
async function deletePhoto(eventId: string) {
|
||||
const ev = allEvents.find((e) => e.id === eventId);
|
||||
const existingMeta: Record<string, unknown> = ev ? { ...ev.metadata } : {};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { signOut } from "next-auth/react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useBaby } from "@/contexts/baby-context";
|
||||
import { getThresholds, saveThresholds, requestPermission } from "@/lib/notifications";
|
||||
import { Copy, Check, Download, LogOut, Bell, Baby, Users, ChevronRight, ChevronDown, FileText, Plus, Mail, Send, Key, Trash2, Eye, EyeOff, ExternalLink } from "lucide-react";
|
||||
import { Copy, Check, Download, LogOut, Bell, Baby, Users, ChevronRight, ChevronDown, FileText, Plus, Mail, Send, Key, Trash2, Eye, EyeOff, ExternalLink, Webhook, ToggleLeft, ToggleRight, RefreshCw } from "lucide-react";
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -192,6 +192,241 @@ function ApiKeysSection() {
|
||||
);
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = { PARENT: "Parent", READONLY: "Lecture", DOCTOR: "Médecin" };
|
||||
|
||||
interface PendingInvite {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
expiresAt: string;
|
||||
sentAt: string;
|
||||
}
|
||||
|
||||
function PendingInvitesList() {
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery<{ invites: PendingInvite[] }>({
|
||||
queryKey: ["pending-invites"],
|
||||
queryFn: () => fetch("/api/invite/pending").then((r) => r.json()),
|
||||
});
|
||||
const invites = data?.invites ?? [];
|
||||
|
||||
async function revoke(id: string, email: string) {
|
||||
if (!confirm(`Révoquer l'invitation de ${email} ?`)) return;
|
||||
await fetch(`/api/invite/pending/${id}`, { method: "DELETE" });
|
||||
qc.invalidateQueries({ queryKey: ["pending-invites"] });
|
||||
}
|
||||
|
||||
if (isLoading || invites.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">Invitations en attente</p>
|
||||
<div className="divide-y divide-slate-100 dark:divide-slate-700 border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||||
{invites.map((inv) => {
|
||||
const expired = new Date(inv.expiresAt) < new Date();
|
||||
const expiresLabel = expired
|
||||
? "Expirée"
|
||||
: `Expire le ${new Date(inv.expiresAt).toLocaleDateString("fr-FR", { day: "numeric", month: "short" })}`;
|
||||
return (
|
||||
<div key={inv.id} className="flex items-center gap-3 px-3 py-2.5 bg-white dark:bg-slate-800">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-200 truncate">{inv.email}</p>
|
||||
<p className="text-[10px] text-slate-400 dark:text-slate-500">
|
||||
{ROLE_LABELS[inv.role] ?? inv.role} · <span className={expired ? "text-red-500" : ""}>{expiresLabel}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => revoke(inv.id, inv.email)}
|
||||
className="p-1.5 rounded text-slate-300 dark:text-slate-600 hover:text-red-500 transition"
|
||||
title="Révoquer"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResetInviteButton({ onReset }: { onReset: () => void }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function reset() {
|
||||
if (!confirm("Réinitialiser le code d'invitation ? Les anciens liens ne fonctionneront plus.")) return;
|
||||
setLoading(true);
|
||||
const res = await fetch("/api/family/reset-invite", { method: "POST" });
|
||||
if (res.ok) onReset();
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={reset}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1 text-xs text-slate-400 hover:text-red-500 dark:hover:text-red-400 transition disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${loading ? "animate-spin" : ""}`} />
|
||||
Réinitialiser
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const WEBHOOK_EVENTS = [
|
||||
{ value: "event.created", label: "Événement créé" },
|
||||
{ value: "growth.created", label: "Mesure de croissance" },
|
||||
{ value: "doctor_note.created", label: "Note médecin" },
|
||||
{ value: "milestone.created", label: "Étape créée" },
|
||||
];
|
||||
|
||||
interface WebhookRecord {
|
||||
id: string;
|
||||
url: string;
|
||||
events: string[];
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function WebhooksSection() {
|
||||
const qc = useQueryClient();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newUrl, setNewUrl] = useState("");
|
||||
const [newEvents, setNewEvents] = useState<string[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [revealedSecret, setRevealedSecret] = useState<{ id: string; secret: string } | null>(null);
|
||||
|
||||
const { data } = useQuery<{ webhooks: WebhookRecord[] }>({
|
||||
queryKey: ["webhooks"],
|
||||
queryFn: () => fetch("/api/webhooks").then((r) => r.json()),
|
||||
});
|
||||
const hooks = data?.webhooks ?? [];
|
||||
|
||||
function toggleEvent(v: string) {
|
||||
setNewEvents((prev) => prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v]);
|
||||
}
|
||||
|
||||
async function createHook(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newUrl.trim() || newEvents.length === 0) return;
|
||||
setSaving(true);
|
||||
const res = await fetch("/api/webhooks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: newUrl.trim(), events: newEvents }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.webhook?.secret) setRevealedSecret({ id: json.webhook.id, secret: json.webhook.secret });
|
||||
qc.invalidateQueries({ queryKey: ["webhooks"] });
|
||||
setNewUrl("");
|
||||
setNewEvents([]);
|
||||
setCreating(false);
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function toggleActive(hook: WebhookRecord) {
|
||||
await fetch(`/api/webhooks/${hook.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ active: !hook.active }),
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ["webhooks"] });
|
||||
}
|
||||
|
||||
async function deleteHook(id: string) {
|
||||
if (!confirm("Supprimer ce webhook ?")) return;
|
||||
await fetch(`/api/webhooks/${id}`, { method: "DELETE" });
|
||||
qc.invalidateQueries({ queryKey: ["webhooks"] });
|
||||
if (revealedSecret?.id === id) setRevealedSecret(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard title="Webhooks">
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Notifications HTTP vers vos services (n8n, Home Assistant, Zapier…) à chaque événement.
|
||||
</p>
|
||||
|
||||
{revealedSecret && (
|
||||
<div className="bg-green-50 dark:bg-green-950/40 border border-green-200 dark:border-green-800 rounded-lg p-3">
|
||||
<p className="text-xs font-semibold text-green-700 dark:text-green-400 mb-1">Secret — copiez-le maintenant, il ne sera plus affiché.</p>
|
||||
<code className="block text-[10px] bg-white dark:bg-slate-900 border border-green-200 dark:border-green-800 rounded px-2 py-1.5 break-all text-green-800 dark:text-green-300 select-all">
|
||||
{revealedSecret.secret}
|
||||
</code>
|
||||
<p className="text-[10px] text-green-600 dark:text-green-500 mt-1.5">
|
||||
Vérifiez la signature : <code>X-Grow-Signature: sha256=HMAC(secret, body)</code>
|
||||
</p>
|
||||
<button onClick={() => setRevealedSecret(null)} className="text-[10px] text-green-600 dark:text-green-500 mt-1 hover:underline">Fermer</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hooks.length > 0 && (
|
||||
<div className="divide-y divide-slate-100 dark:divide-slate-700 border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||||
{hooks.map((hook) => (
|
||||
<div key={hook.id} className="flex items-center gap-3 px-3 py-2.5 bg-white dark:bg-slate-800">
|
||||
<Webhook className="w-4 h-4 text-slate-400 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-200 truncate">{hook.url}</p>
|
||||
<p className="text-[10px] text-slate-400 dark:text-slate-500">{hook.events.join(", ")}</p>
|
||||
</div>
|
||||
<button onClick={() => toggleActive(hook)} className="text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition">
|
||||
{hook.active
|
||||
? <ToggleRight className="w-5 h-5 text-indigo-600 dark:text-indigo-400" />
|
||||
: <ToggleLeft className="w-5 h-5" />}
|
||||
</button>
|
||||
<button onClick={() => deleteHook(hook.id)} className="p-1.5 rounded text-slate-300 dark:text-slate-600 hover:text-red-500 transition">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{creating ? (
|
||||
<form onSubmit={createHook} className="space-y-3 border border-slate-200 dark:border-slate-700 rounded-lg p-3">
|
||||
<input
|
||||
value={newUrl}
|
||||
onChange={(e) => setNewUrl(e.target.value)}
|
||||
placeholder="https://example.com/webhook"
|
||||
className={inputCls}
|
||||
required
|
||||
type="url"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-600 dark:text-slate-300 mb-2">Événements</p>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{WEBHOOK_EVENTS.map((ev) => (
|
||||
<label key={ev.value} className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={newEvents.includes(ev.value)} onChange={() => toggleEvent(ev.value)}
|
||||
className="rounded border-slate-300 text-indigo-600" />
|
||||
<span className="text-xs text-slate-600 dark:text-slate-300">{ev.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={saving || !newUrl.trim() || newEvents.length === 0}
|
||||
className="flex-1 py-2 bg-indigo-600 text-white rounded-lg text-xs font-medium disabled:opacity-50">
|
||||
{saving ? "..." : "Créer"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setCreating(false)}
|
||||
className="px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-lg text-xs text-slate-500">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button onClick={() => setCreating(true)}
|
||||
className="flex items-center gap-2 text-sm text-indigo-600 dark:text-indigo-400 hover:underline">
|
||||
<Plus className="w-4 h-4" />
|
||||
Nouveau webhook
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const qc = useQueryClient();
|
||||
const { selectedBaby, setSelectedBabyId } = useBaby();
|
||||
@@ -546,7 +781,10 @@ export default function SettingsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-400 mt-1">Le lien n'expire pas — réinitialisez-le si compromis.</p>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<p className="text-xs text-slate-400">Le lien n'expire pas — réinitialisez-le si compromis.</p>
|
||||
<ResetInviteButton onReset={() => qc.invalidateQueries({ queryKey: ["family"] })} />
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-slate-100 dark:border-slate-700 mt-3">
|
||||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-3">Inviter par email</p>
|
||||
@@ -584,6 +822,7 @@ export default function SettingsPage() {
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
<PendingInvitesList />
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
@@ -715,6 +954,9 @@ export default function SettingsPage() {
|
||||
{/* API Keys */}
|
||||
<ApiKeysSection />
|
||||
|
||||
{/* Webhooks */}
|
||||
<WebhooksSection />
|
||||
|
||||
{/* Sign out */}
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/auth/login" })}
|
||||
|
||||
@@ -223,14 +223,21 @@ function FeedHeatmap({ events, isDark }: { events: Event[]; isDark: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
const SLEEP_WINDOW_OPTIONS = [
|
||||
{ label: "18h – 10h", startH: 18 },
|
||||
{ label: "19h – 11h", startH: 19 },
|
||||
{ label: "20h – 12h", startH: 20 },
|
||||
{ label: "21h – 13h", startH: 21 },
|
||||
];
|
||||
|
||||
function SleepTimeline({ events }: { events: Event[] }) {
|
||||
const WIN_START_H = 19;
|
||||
const WIN_HOURS = 16; // 19:00 → 11:00 next day
|
||||
const [winStartH, setWinStartH] = useState(19);
|
||||
const WIN_HOURS = 16;
|
||||
const WIN_MS = WIN_HOURS * 3600000;
|
||||
|
||||
const rows = Array.from({ length: 7 }, (_, i) => {
|
||||
const day = startOfDay(subDays(new Date(), 6 - i));
|
||||
const winStart = day.getTime() + WIN_START_H * 3600000;
|
||||
const winStart = day.getTime() + winStartH * 3600000;
|
||||
const winEnd = winStart + WIN_MS;
|
||||
|
||||
const blocks = events
|
||||
@@ -250,15 +257,36 @@ function SleepTimeline({ events }: { events: Event[] }) {
|
||||
};
|
||||
});
|
||||
|
||||
const markers = [20, 22, 0, 2, 4, 6, 8, 10].map((h) => ({
|
||||
const endH = (winStartH + WIN_HOURS) % 24;
|
||||
const markerHours = Array.from({ length: WIN_HOURS / 2 }, (_, i) => (winStartH + 1 + i * 2) % 24);
|
||||
const markers = markerHours.map((h) => ({
|
||||
label: h === 0 ? "00h" : `${h}h`,
|
||||
pos: ((h < WIN_START_H ? h + 24 : h) - WIN_START_H) / WIN_HOURS * 100,
|
||||
pos: ((h < winStartH ? h + 24 : h) - winStartH) / WIN_HOURS * 100,
|
||||
}));
|
||||
|
||||
const currentOption = SLEEP_WINDOW_OPTIONS.find((o) => o.startH === winStartH);
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4">
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-200 mb-0.5">Nuits — 7 derniers jours</p>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 mb-4">Fenêtre 19h – 11h</p>
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">Nuits — 7 derniers jours</p>
|
||||
<div className="flex gap-1">
|
||||
{SLEEP_WINDOW_OPTIONS.map((o) => (
|
||||
<button
|
||||
key={o.startH}
|
||||
onClick={() => setWinStartH(o.startH)}
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-medium transition ${
|
||||
o.startH === winStartH
|
||||
? "bg-indigo-600 text-white"
|
||||
: "text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300"
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 mb-4">Fenêtre {currentOption?.label ?? `${winStartH}h – ${endH}h`}</p>
|
||||
<div className="relative h-4 ml-10 mb-0.5">
|
||||
{markers.map((m) => (
|
||||
<span key={m.label} className="absolute text-[9px] text-slate-400 dark:text-slate-500 -translate-x-1/2" style={{ left: `${m.pos}%` }}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { dispatchWebhook } from "@/lib/webhooks";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const session = await auth();
|
||||
@@ -53,5 +54,12 @@ export async function POST(req: Request) {
|
||||
include: { user: { select: { id: true, name: true } } },
|
||||
});
|
||||
|
||||
const familyId = (session.user as { familyId?: string }).familyId;
|
||||
if (familyId) {
|
||||
dispatchWebhook(familyId, "doctor_note.created", {
|
||||
note: { id: note.id, babyId: note.babyId, date: note.date, reason: note.reason },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return NextResponse.json(note, { status: 201 });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAudit } from "@/lib/audit";
|
||||
import { dispatchWebhook } from "@/lib/webhooks";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const session = await auth();
|
||||
@@ -66,5 +67,12 @@ export async function POST(req: Request) {
|
||||
|
||||
await logAudit(session, { action: "event.create", targetId: event.id, detail: `${type} pour bébé ${babyId}` });
|
||||
|
||||
const familyId = (session.user as { familyId?: string }).familyId;
|
||||
if (familyId) {
|
||||
dispatchWebhook(familyId, "event.created", {
|
||||
event: { id: event.id, type: event.type, startedAt: event.startedAt, endedAt: event.endedAt, babyId: event.babyId },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return NextResponse.json(event, { status: 201 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function generateInviteCode(): string {
|
||||
return crypto.randomUUID().replace(/-/g, "").slice(0, 25);
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { familyId: true, role: true },
|
||||
});
|
||||
|
||||
if (!user?.familyId) return NextResponse.json({ error: "Famille introuvable" }, { status: 404 });
|
||||
if (user.role !== "PARENT") return NextResponse.json({ error: "Réservé aux parents" }, { status: 403 });
|
||||
|
||||
const inviteCode = generateInviteCode();
|
||||
|
||||
await prisma.family.update({
|
||||
where: { id: user.familyId },
|
||||
data: { inviteCode },
|
||||
});
|
||||
|
||||
return NextResponse.json({ inviteCode });
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { dispatchWebhook } from "@/lib/webhooks";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const session = await auth();
|
||||
@@ -39,5 +40,12 @@ export async function POST(req: Request) {
|
||||
},
|
||||
});
|
||||
|
||||
const familyId = (session.user as { familyId?: string }).familyId;
|
||||
if (familyId) {
|
||||
dispatchWebhook(familyId, "growth.created", {
|
||||
log: { id: log.id, babyId: log.babyId, date: log.date, weight: log.weight, height: log.height },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return NextResponse.json(log, { status: 201 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { familyId: true },
|
||||
});
|
||||
if (!user?.familyId) return NextResponse.json({ error: "Famille introuvable" }, { status: 404 });
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const invite = await prisma.pendingInvite.findFirst({
|
||||
where: { id, familyId: user.familyId },
|
||||
});
|
||||
if (!invite) return NextResponse.json({ error: "Introuvable" }, { status: 404 });
|
||||
|
||||
await prisma.pendingInvite.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { familyId: true },
|
||||
});
|
||||
if (!user?.familyId) return NextResponse.json({ invites: [] });
|
||||
|
||||
const invites = await prisma.pendingInvite.findMany({
|
||||
where: { familyId: user.familyId },
|
||||
orderBy: { sentAt: "desc" },
|
||||
select: { id: true, email: true, role: true, expiresAt: true, sentAt: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ invites });
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { name, email, password, token } = await req.json();
|
||||
|
||||
if (!name || !email || !password || !token) {
|
||||
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
|
||||
}
|
||||
|
||||
const invite = await prisma.pendingInvite.findUnique({ where: { token } });
|
||||
if (!invite) return NextResponse.json({ error: "Lien d'invitation invalide" }, { status: 404 });
|
||||
if (invite.expiresAt < new Date()) return NextResponse.json({ error: "Lien d'invitation expiré" }, { status: 410 });
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
if (existing) return NextResponse.json({ error: "Email déjà utilisé" }, { status: 409 });
|
||||
|
||||
const hashed = await bcrypt.hash(password, 12);
|
||||
const user = await prisma.user.create({
|
||||
data: { name, email, password: hashed, familyId: invite.familyId, role: invite.role },
|
||||
});
|
||||
|
||||
await prisma.pendingInvite.delete({ where: { token } });
|
||||
|
||||
return NextResponse.json({ id: user.id, email: user.email, name: user.name, role: user.role });
|
||||
}
|
||||
@@ -31,9 +31,14 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
const origin = req.headers.get("origin") ?? process.env.NEXTAUTH_URL ?? "";
|
||||
const inviteUrl = `${origin}/auth/invite/${user.family.inviteCode}?role=${role}`;
|
||||
const assignedRole = role as Role;
|
||||
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 3600 * 1000);
|
||||
const pending = await prisma.pendingInvite.create({
|
||||
data: { familyId: user.family.id, email, role: assignedRole, expiresAt },
|
||||
});
|
||||
const inviteUrl = `${origin}/auth/invite/t/${pending.token}`;
|
||||
|
||||
try {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const token = new URL(req.url).searchParams.get("token");
|
||||
if (!token) return NextResponse.json({ error: "Token manquant" }, { status: 400 });
|
||||
|
||||
const invite = await prisma.pendingInvite.findUnique({
|
||||
where: { token },
|
||||
include: { family: { select: { name: true } } },
|
||||
});
|
||||
|
||||
if (!invite) return NextResponse.json({ error: "Introuvable" }, { status: 404 });
|
||||
if (invite.expiresAt < new Date()) return NextResponse.json({ error: "Expiré" }, { status: 410 });
|
||||
|
||||
return NextResponse.json({
|
||||
email: invite.email,
|
||||
role: invite.role,
|
||||
expiresAt: invite.expiresAt,
|
||||
familyName: invite.family.name,
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,20 @@ import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { notifyFamilyFeedAlert } from "@/lib/push-notify";
|
||||
|
||||
export async function POST() {
|
||||
async function getCronSecret(): Promise<string> {
|
||||
const row = await prisma.systemConfig.findUnique({ where: { key: "cron_secret" } });
|
||||
return row?.value || process.env.CRON_SECRET || "";
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const secret = await getCronSecret();
|
||||
if (secret) {
|
||||
const authHeader = req.headers.get("authorization") ?? "";
|
||||
if (authHeader !== `Bearer ${secret}`) {
|
||||
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const reminders = await prisma.medicationReminder.findMany({
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await auth();
|
||||
const familyId = (session?.user as { familyId?: string } | undefined)?.familyId;
|
||||
if (!session?.user?.id || !familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
|
||||
const hook = await prisma.webhook.findFirst({ where: { id, familyId } });
|
||||
if (!hook) return NextResponse.json({ error: "Introuvable" }, { status: 404 });
|
||||
|
||||
const updated = await prisma.webhook.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(body.url !== undefined && { url: body.url }),
|
||||
...(body.events !== undefined && { events: body.events }),
|
||||
...(body.active !== undefined && { active: body.active }),
|
||||
},
|
||||
select: { id: true, url: true, events: true, active: true, createdAt: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ webhook: updated });
|
||||
}
|
||||
|
||||
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await auth();
|
||||
const familyId = (session?.user as { familyId?: string } | undefined)?.familyId;
|
||||
if (!session?.user?.id || !familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const hook = await prisma.webhook.findFirst({ where: { id, familyId } });
|
||||
if (!hook) return NextResponse.json({ error: "Introuvable" }, { status: 404 });
|
||||
|
||||
await prisma.webhook.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { generateWebhookSecret } from "@/lib/webhooks";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
const familyId = (session?.user as { familyId?: string } | undefined)?.familyId;
|
||||
if (!session?.user?.id || !familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const webhooks = await prisma.webhook.findMany({
|
||||
where: { familyId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { id: true, url: true, events: true, active: true, createdAt: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ webhooks });
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await auth();
|
||||
const familyId = (session?.user as { familyId?: string } | undefined)?.familyId;
|
||||
if (!session?.user?.id || !familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const { url, events } = await req.json();
|
||||
if (!url || !events?.length) return NextResponse.json({ error: "url et events requis" }, { status: 400 });
|
||||
|
||||
try { new URL(url); } catch { return NextResponse.json({ error: "URL invalide" }, { status: 400 }); }
|
||||
|
||||
const secret = generateWebhookSecret();
|
||||
|
||||
const webhook = await prisma.webhook.create({
|
||||
data: { familyId, url, events, secret },
|
||||
select: { id: true, url: true, events: true, active: true, createdAt: true, secret: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ webhook }, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { useState, use, useEffect } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Leaf, Check, AlertTriangle } from "lucide-react";
|
||||
|
||||
const ROLE_META: Record<string, { label: string; description: string; color: string }> = {
|
||||
PARENT: {
|
||||
label: "Accès parent",
|
||||
description: "toutes les fonctionnalités",
|
||||
color: "bg-indigo-50 dark:bg-indigo-950 text-indigo-700 dark:text-indigo-300 border-indigo-200 dark:border-indigo-800",
|
||||
},
|
||||
READONLY: {
|
||||
label: "Accès consultation",
|
||||
description: "lecture seule",
|
||||
color: "bg-slate-50 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-700",
|
||||
},
|
||||
DOCTOR: {
|
||||
label: "Accès médecin",
|
||||
description: "consultation + notes cliniques",
|
||||
color: "bg-emerald-50 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800",
|
||||
},
|
||||
};
|
||||
|
||||
const inputCls =
|
||||
"w-full px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-400 transition text-sm";
|
||||
|
||||
interface InviteInfo {
|
||||
email: string;
|
||||
role: string;
|
||||
expiresAt: string;
|
||||
familyName?: string;
|
||||
}
|
||||
|
||||
function TokenInviteForm({ token }: { token: string }) {
|
||||
const router = useRouter();
|
||||
const [info, setInfo] = useState<InviteInfo | null>(null);
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
const [form, setForm] = useState({ name: "", email: "", password: "" });
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/invite/token-info?token=${encodeURIComponent(token)}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.error || !data.email) { setInvalid(true); return; }
|
||||
setInfo(data);
|
||||
setForm((f) => ({ ...f, email: data.email }));
|
||||
})
|
||||
.catch(() => setInvalid(true));
|
||||
}, [token]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
const res = await fetch("/api/invite/redeem", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...form, token }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
setError(data.error ?? "Erreur lors de l'inscription");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await signIn("credentials", { email: form.email, password: form.password, redirect: false });
|
||||
router.push("/dashboard");
|
||||
}
|
||||
|
||||
if (invalid) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 p-4">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-red-100 dark:bg-red-950 rounded-2xl mb-4">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100 mb-2">Lien invalide ou expiré</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mb-6">Ce lien d'invitation n'est plus valide. Demandez un nouvel e-mail d'invitation.</p>
|
||||
<Link href="/auth/login" className="text-indigo-600 dark:text-indigo-400 text-sm font-medium hover:underline">Retour à la connexion</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!info) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950">
|
||||
<div className="w-6 h-6 border-2 border-indigo-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = ROLE_META[info.role] ?? ROLE_META.PARENT;
|
||||
const expiresDate = new Date(info.expiresAt).toLocaleDateString("fr-FR", { day: "numeric", month: "long" });
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-indigo-600 rounded-2xl mb-4">
|
||||
<Leaf className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Grow</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">Suivi nouveau-né</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-slate-900 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 p-8">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-1">Rejoindre la famille</h2>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 mb-5">Lien valide jusqu'au {expiresDate}</p>
|
||||
|
||||
<div className={`flex items-center gap-2 px-3 py-2.5 rounded-xl border mb-6 ${meta.color}`}>
|
||||
<Check className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="text-sm font-medium">
|
||||
{meta.label} <span className="font-normal opacity-75">· {meta.description}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Prénom</label>
|
||||
<input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required className={inputCls} placeholder="Thomas" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Email</label>
|
||||
<input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} required className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Mot de passe</label>
|
||||
<input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required minLength={8} className={inputCls} placeholder="8 caractères minimum" />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-900 px-4 py-3 rounded-xl text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={loading}
|
||||
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300 dark:disabled:bg-indigo-900 text-white font-semibold py-3 rounded-xl transition text-sm">
|
||||
{loading ? "Rejoindre..." : "Rejoindre la famille"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-slate-500 dark:text-slate-400 mt-6">
|
||||
Déjà un compte ?{" "}
|
||||
<Link href="/auth/login" className="text-indigo-600 dark:text-indigo-400 font-medium hover:underline">Se connecter</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TokenInvitePage({ params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = use(params);
|
||||
return <TokenInviteForm token={token} />;
|
||||
}
|
||||
@@ -39,25 +39,25 @@ export function QuickAddFab() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* FAB — only on mobile, hidden on md+ (sidebar handles desktop) */}
|
||||
{/* FAB — mobile: above nav bar; desktop: bottom-right corner */}
|
||||
<button
|
||||
onClick={() => setSheetOpen((v) => !v)}
|
||||
aria-label="Enregistrer une action"
|
||||
className="md:hidden fixed bottom-[calc(env(safe-area-inset-bottom)+4.5rem)] right-4 z-40 w-14 h-14 rounded-full bg-indigo-600 text-white shadow-lg flex items-center justify-center active:scale-95 transition-transform"
|
||||
className="fixed bottom-[calc(env(safe-area-inset-bottom)+4.5rem)] right-4 md:bottom-8 md:right-8 z-40 w-14 h-14 rounded-full bg-indigo-600 text-white shadow-lg flex items-center justify-center active:scale-95 transition-transform"
|
||||
>
|
||||
{sheetOpen ? <X className="w-6 h-6" /> : <Plus className="w-6 h-6" />}
|
||||
</button>
|
||||
|
||||
{/* Sheet */}
|
||||
{/* Sheet — bottom sheet on both mobile and desktop */}
|
||||
{sheetOpen && (
|
||||
<div className="md:hidden fixed inset-0 z-30 flex flex-col justify-end">
|
||||
<div className="fixed inset-0 z-30 flex flex-col justify-end">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40"
|
||||
onClick={() => setSheetOpen(false)}
|
||||
/>
|
||||
{/* Panel */}
|
||||
<div className="relative bg-white dark:bg-slate-900 rounded-t-2xl pb-[calc(env(safe-area-inset-bottom)+5rem)] z-10">
|
||||
<div className="relative bg-white dark:bg-slate-900 rounded-t-2xl pb-[calc(env(safe-area-inset-bottom)+5rem)] md:pb-6 z-10 md:max-w-lg md:mx-auto md:w-full md:mb-0 md:rounded-2xl md:shadow-2xl">
|
||||
<div className="px-4 pt-4 pb-3 flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200">
|
||||
Enregistrer
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createHmac, randomBytes } from "crypto";
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
export type WebhookEventType =
|
||||
| "event.created"
|
||||
| "growth.created"
|
||||
| "doctor_note.created"
|
||||
| "milestone.created"
|
||||
| "medication.due";
|
||||
|
||||
export function generateWebhookSecret(): string {
|
||||
return randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
export async function dispatchWebhook(
|
||||
familyId: string,
|
||||
eventType: WebhookEventType,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
const hooks = await prisma.webhook.findMany({
|
||||
where: { familyId, active: true, events: { has: eventType } },
|
||||
});
|
||||
if (hooks.length === 0) return;
|
||||
|
||||
const body = JSON.stringify({ event: eventType, ...payload });
|
||||
|
||||
await Promise.allSettled(
|
||||
hooks.map(async (hook) => {
|
||||
const sig = createHmac("sha256", hook.secret).update(body).digest("hex");
|
||||
await fetch(hook.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Grow-Signature": `sha256=${sig}`,
|
||||
"X-Grow-Event": eventType,
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user