30 Commits

Author SHA1 Message Date
Antigravity
afbb0dfc2d fix(ui): thème, textes et lisibilité restants de la revue
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 6m57s
CI / Deploy production (on server) (push) Successful in 24s
Les boutons suivent la couleur d’apparence, les libellés trop petits ou trop techniques sont clarifiés, et le catalogue des fournisseurs se met à jour tout seul.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 21:13:07 +00:00
Antigravity
ebf6f16fde fix(ui): voix Second Brain à la connexion et libellés du rail
Remplace le titre « notepad » et le jargon (Intelligence OS, SM-2) par des mots lisibles, et aligne la langue des pages d’auth sur le reste du site.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 10:56:00 +00:00
Antigravity
e0fd5393ca fix: note en double au retour et barre Gras/Italique à l’ouverture
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m1s
CI / Deploy production (on server) (push) Successful in 23s
Ne plus réinjecter une note déjà dans la liste, et n’afficher le menu de formatage que sur une vraie sélection de texte.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 08:30:06 +00:00
Antigravity
80ccc1f6de feat: dashboard Second Brain, essai 7 jours et vérification e-mail
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m14s
CI / Deploy production (on server) (push) Successful in 1m25s
Rendre le dashboard actionnable (inbox, peek, carte mentale), aligner la facturation sur l’essai 7 jours, et bloquer le login e-mail tant que l’adresse n’est pas confirmée.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 07:19:36 +00:00
Antigravity
69c99e4f4f feat: page interactive, démos Play/Step et simulateur Carnot
Ajoute le pipeline PageSpec (validation, rendu, publication /p/{slug}),
les démos TipTap /demo, et le simulateur Carnot (modes frigo/PAC/moteur,
énergie kJ vs puissance W, unités K/°C/°F) avec correctifs d’équations KaTeX.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 17:51:43 +00:00
Antigravity
f385d43d5d feat: redirect to AI consent settings when consent is missing
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m27s
CI / Deploy production (on server) (push) Successful in 23s
- requestAiConsent now redirects to /settings/general?highlight=aiConsent
  instead of opening the modal when called outside the settings page
- Settings page highlights the consent section and scrolls to it
- Adds consent.ai.settingsBanner i18n key (fr/en)
- Keeps inline modal on /settings/general so the grant button still works
2026-07-18 17:52:40 +00:00
Antigravity
324bf40658 fix: replace raw ai_consent_required code with translatable message and consent prompt
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m20s
CI / Deploy production (on server) (push) Successful in 24s
- Centralize AI consent 403 responses in server-consent helpers
- Return human-readable message + code + i18n errorKey from all AI APIs
- Update dashboard and AI clients to detect code/errorKey and translate
- Add consent.ai.requiredToast i18n keys (fr/en)
2026-07-18 17:32:00 +00:00
Antigravity
86505659cf fix(extension): masquer le diagnostic en builds Store v0.4.6
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m47s
CI / Deploy production (on server) (push) Successful in 23s
- Ajout du flag ALLOW_DIAGNOSTICS dans sidepanel.js.
- En prod (builds Chrome Store / Firefox) ALLOW_DIAGNOSTICS = false :
  le bouton ? et le panneau diagnostic sont masqués, logDiag reste silencieux.
- Mise à jour des scripts de build pour appliquer ALLOW_DIAGNOSTICS = false.
- Bump version 0.4.6.
2026-07-18 17:21:40 +00:00
Antigravity
4946c0d44a fix(extension): permissions universelles Store + nettoyage article v0.4.5
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m44s
CI / Deploy production (on server) (push) Successful in 24s
- build-chrome-store.mjs / build-firefox.mjs : host_permissions et content_scripts matches
  passent à http://*/* + https://*/* au lieu de memento-note.com uniquement.
  Un Web Clipper a besoin d'accéder à tous les sites ; le manifest Store
  bloquait l'injection du content script sur n'importe quel article.
- extract-article.ts : refonte de cleanClipText en filtrage par ligne avec
  patterns élargis (mis à jour, temps de lecture, aujourd'hui à HH:MM,
  source - date, publié le, etc.).
- Bump version 0.4.5 dans manifest, locales, README et builds.
2026-07-18 17:04:25 +00:00
Antigravity
0ee2afbd62 fix(extension): sync robuste content/sidepanel, diagnostic panel, preview propre v0.4.4
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled
- content.js : écrit la sélection dans chrome.storage.session/local en plus du message runtime
- sidepanel.js : lit le storage en fallback, loggue chaque étape, affiche un diagnostic panel
- sidepanel.js : avertissement explicite si le content script n'est pas détecté
- sidepanel.js : sépare le rendu Markdown du résumé et l'affichage texte brut de l'extrait
- sidepanel.css : agrandit la zone de prévisualisation
- i18n : nouvelles clés contentScriptMissing, diagnosticsTitle, copyDiagnostics
- locales : correction FR (Retour, Enregistrement, Note, etc.) + version 0.4.4 partout
- extract-article.ts : nettoie textContent des métadonnées (dates, temps de lecture, sources)
- analyze/route.ts : excerpt propre via makeExcerpt
- builds Chrome Store et Firefox regénérés en 0.4.4
2026-07-18 16:56:51 +00:00
Antigravity
bff060ad20 feat(extension): rendu Markdown dans la prévisualisation + zone agrandie v0.4.3
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m24s
CI / Deploy production (on server) (push) Successful in 23s
Avant : renderConfirm() échappait le résumé et l'extrait avec escapeHtml(),
donc les **gras**, *italique*, [liens](), listes, titres du Markdown
produit par l'IA s'affichaient en texte brut (littéral « **gras** »).

Maintenant : renderMarkdownSafe() parse le Markdown en HTML limité
et sûr (escapeHtml d'abord, puis regex sur le texte échappé — pas de
HTML brut autorisé).

+ Refonte du panneau de prévisualisation :
  - .preview-block avec header sticky (label Résumé / Extrait)
  - .excerpt-block : min-height 180px, max-height 360px, scroll si long
  - .summary-block : fond bleuté léger, bordure gauche sky
  - markdown-h3/h4/h5 stylés (serif pour titres), strong/em/code/ul/a
  - Polices 14px, line-height 1.7, word-break pour texte long

+ Nouvelle clé i18n summaryLabel dans les 15 locales.
+ Version 0.4.2 → 0.4.3.
2026-07-18 10:35:00 +00:00
Antigravity
1987b084c3 fix(extension): applySelectionFromMessage tolérant + amorçage pageUrl v0.4.2
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled
VRAI bug (que tu as raison de souligner, j'aurais pas dû revert) :
la fonction applySelectionFromMessage rejetait TOUT message si pageUrl
n'était pas encore settée, ou si les URLs différaient sur un trailing
slash / fragment / query. Et pageUrl se populate de façon async via
chrome.tabs.query dans refreshPageContext — donc le premier SELECTION_CHANGED
arrivait avant pageUrl, était rejeté, et la sélection n'était jamais
appliquée.

Fixes dans sidepanel.js :
- applySelectionFromMessage : si pageUrl vide, on amorce depuis le
  message (pageUrl, pageTitle, pageDomain, pageFavicon). Sans ce
  fallback, le bouton « Clipper la sélection » n'apparaît jamais.
- Comparaison d'URL tolérante (origin + pathname, sans trailing slash,
  hash, query).
- refreshPageContext : ne pas marquer tab?.url undefined comme restricted.

Version bumpée 0.4.1 → 0.4.2 (à chaque livraison comme demandé).
2026-07-18 10:30:45 +00:00
Antigravity
07ac42cbee fix(extension): rollback content/background/sidepanel à v0.3.1, version 0.4.1
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled
Le rollback complet de content.js, background.js et sidepanel.js vers
la v0.3.1 (que tu confirmes fonctionner) règle la régression. Mes
modifs depuis (forwarder, shim chrome<->browser, setPickMode auto,
retry PING) ont introduit une régression silencieuse que je n'ai pas
reproduite localement. Je remets le code qui marchait.

Manifest = v0.3.1 + ajout sidebar_action + browser_specific_settings.gecko
pour le build Firefox. Version bumpée 0.3.1 → 0.4.1.
2026-07-18 10:24:47 +00:00
Antigravity
1e9b1b30c9 fix(extension): bannière visible dès l'injection + exclusion store-assets
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled
Bug réel : la bannière 'Surlignez le texte à clipper' n'apparaissait
jamais en pratique parce que le side panel envoyait SET_PICK_MODE
avant que le content script ne soit prêt (race condition MV3 entre
chrome.scripting.executeScript et la mise en place du listener
chrome.runtime.onMessage). Le retry PING dans ensureContentScript
ne suffisait pas : le listener sidepanel peut aussi être en retard.

Fix radical : content.js affiche la bannière IMMÉDIATEMENT à
l'initialisation, sans attendre SET_PICK_MODE. L'utilisateur voit
immédiatement que l'extension est active. Un bouton ✕ permet de
la fermer (state local au tab). Pas de dépendance sur le side panel
pour le feedback visuel.

+ manifest : retrait de optional_host_permissions (redondant avec
  host_permissions http://*/* et https://*/*, peut perturber Chrome
  en dev).
+ bannière rediseignée : max-width 640px responsive, animation
  slideIn, bouton ✕ accessible.
+ nouvelles clés i18n bannerDismiss dans les 15 locales.
+ build : exclusion de store-assets/ du zip et du xpi (sinon le
  package Store faisait 327 Ko au lieu de 47 Ko).
2026-07-18 10:20:31 +00:00
Antigravity
d5c44aafb6 fix(extension): timing bannière pick mode + responsive side panel
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m46s
CI / Deploy production (on server) (push) Successful in 23s
Bannière pick mode :
- ensureContentScript() : retry le PING 8x (avec backoff 80→290ms) après
  chrome.scripting.executeScript, parce que l'injection est async côté page
  et le listener n'est pas enregistré immédiatement.
- Avant : SET_PICK_MODE partait dans le vide, la bannière « surlignez le
  texte à clipper » n'apparaissait jamais.

Responsive :
- Media queries : <380px (Chrome Side Panel narrow), 380-420px (default),
  >420px (Firefox sidebar), <320px (very small).
- Padding/margin/font-size adaptatifs selon la largeur du panneau.
- .selection-body : min-height 80px, plus de max-height fixe, scroll si
  trop long. overflow-wrap: anywhere pour le texte long.
- .btn : padding 16px / min-height 48px pour le tactile.
- .page-card : word-break + overflow-wrap pour les URLs et titres longs.
- Firefox : media query dédié >480px pour aérer la sidebar Firefox
  (plus large que Chrome Side Panel).

Rebuilt :
- memento-web-clipper-chrome-store.zip
- memento-web-clipper-firefox.xpi
2026-07-18 09:48:45 +00:00
Antigravity
9d10815415 fix(extension): forwarder les messages content→sidepanel via le background
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m44s
CI / Deploy production (on server) (push) Successful in 24s
Bug réel : chrome.runtime.sendMessage depuis un content script ne va
QU'au background service worker en MV3. Le side panel qui écoute via
chrome.runtime.onMessage ne reçoit jamais rien directement. Résultat :
la sélection, le titre de la page et le contexte n'atteignaient jamais
le panneau.

Fix :
- background.js : ajouter un onMessage qui forward SELECTION_CHANGED et
  PAGE_CONTEXT_CHANGED vers toutes les surfaces (side panel).
- sidepanel.js applySelectionFromMessage : normaliser les URLs
  (ignorer query/fragment/trailing slash), amorcer pageUrl depuis le
  premier message si tabs.query a échoué, accepter une sélection qui
  arrive avant refreshPageContext.

La v0.3.1 souffrait du même bug — la sélection fonctionnait parfois par
chance quand Chrome routait le message avant que le side panel ne soit
complètement initialisé. Maintenant c'est garanti.
2026-07-18 09:41:27 +00:00
Antigravity
cf3b5483d2 fix(extension): corriger les traductions 'clip' et améliorer le diagnostic 401
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m24s
CI / Deploy production (on server) (push) Successful in 23s
Traductions:
- 'Tondeuse Web' (FR), 'Cortadora web' (ES), 'Schneiden Sie aus' (DE),
  'ritagliare' (IT) etc. — le terme 'clip' était traduit littéralement
  (tondeuse = mower, ausschneiden = cut out, ritagliare = trim).
- Garder 'clip' comme terme technique dans les 15 locales, sauf les
  formulations verbe qui prennent une conjugaison locale correcte.
- 87 chaînes corrigées dans le bundle de traductions embarqué.

Diagnostic 401:
- Le message 'Veuillez vous connecter à Memento' s'affichait même
  quand l'utilisateur était connecté, simplement sur une URL différente
  (localhost vs 127.0.0.1 vs LAN IP — les cookies sont scopés par host).
- Nouveau message multi-ligne incluant l'URL configurée + bouton
  'Ouvrir Memento' qui ouvre l'onglet directement à cette URL.
- Nouvelle clé i18n errLoginHintUrl (placeholder $URL$) traduite
  dans les 15 locales.

Rebuilt :
- memento-web-clipper-chrome-store.zip (44 Ko)
- memento-web-clipper-firefox.xpi (44 Ko)
2026-07-18 09:28:08 +00:00
Antigravity
6eb46fd1b7 feat(extension): générer les assets visuels Chrome Web Store (icon, tiles, screenshots, marquee)
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m51s
CI / Deploy production (on server) (push) Successful in 23s
- extension/scripts/generate-store-assets.py : générateur Python/PIL avec
  la palette x.ai premium (BG #0B0A09, TEXT #F4F1EA, OCHRE #D4A373).
- extension/store-assets/ :
  * icon-128.png (128×128)
  * promo-tile-small.png (440×280)
  * promo-tile-large.png (920×680)
  * marquee.png (1400×560)
  * screenshot-1.png (1280×800) — sidebar idle avec sélecteur de carnet
  * screenshot-2.png (1280×800) — confirmation IA avec résumé + tags
  * screenshot-3.png (1280×800) — vue RTL/persan (font DejaVu pour glyphes)

Tous les assets respectent les dimensions Chrome Web Store et sont prêts
pour la soumission.
2026-07-18 09:16:46 +00:00
Antigravity
a7c15e9fc3 feat(privacy+extension): publier la page /privacy et préparer les builds Chrome/Firefox Store
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m49s
CI / Deploy production (on server) (push) Successful in 24s
- Nouvelle page /privacy bilingue (FR/EN) avec sélection par Accept-Language
  ou ?lang=, sections complètes (données collectées, IA/BYOK, RGPD, sécurité,
  contact). Style sombre aligné sur la landing (x.ai premium).
- Liens footer landing.footer.legal.* mis à jour vers /privacy dans les 15 locales.
- Extension Web Clipper rendue cross-browser :
  * background.js détecte chrome.sidePanel vs browser.sidebarAction
  * sidepanel.js ajoute un shim chrome<->browser pour Firefox
  * manifest.json inclut sidebar_action + browser_specific_settings.gecko
    + optional_host_permissions (Chrome les ignore)
- Script scripts/build-firefox.mjs pour produire un .xpi signé pour AMO,
  symétrique de build-chrome-store.mjs. Exclusion mutuelle des artefacts
  Chrome/Firefox pour éviter la cross-contamination.
- Branding corrigé : 176 occurrences de 'Momento' -> 'Memento' dans le
  bundle de traductions embarqué.
- Version bumpée 0.3.1 -> 0.4.0.
2026-07-18 09:00:44 +00:00
Antigravity
88a7d2ad0a fix(audit): nettoyage accès, garde-fous build, unification quotas
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m59s
CI / Deploy production (on server) (push) Successful in 24s
Semaine 1 — fuites et accès :
- /archive rendu accessible via sidebar (était invisible)
- Suppression pages orphelines : /chat, /graph, /support, test-title-suggestions
- Fixes i18n : search-modal (clé non interpolée), sidebar tooltips, export PDF
- 15 locales mises à jour

Semaine 2 — garde-fous :
- 8 erreurs TS fixees → ignoreBuildErrors: false (build type-safe)
- reactStrictMode: true (dev-only, zero impact prod)
- reserveUsageOrThrow → wrapper deprecie vers reserveAiUsageOrThrow
- auth-guard.ts : requireAuthOrResponse() + requireAdminOrResponse()

Tests 212/212 | Lint 0 erreur | Build OK | tsc 0 erreur
2026-07-17 18:47:44 +00:00
Antigravity
e8ee53c815 fix(dashboard): audit Second Brain — pistes, erreur briefing, dark, crédits
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m16s
CI / Deploy production (on server) (push) Has been skipped
Corrige loadPaths sans argument après Echo/IA, bannière si briefing en échec,
checklist connexion inversée, skeletons dark, libellé usage = solde unique.
2026-07-16 21:08:48 +00:00
Antigravity
a77f3ffb6e fix: batch priorités — thème, scroll, wizard, agents, slides, charts, packs
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m15s
CI / Deploy production (on server) (push) Has been skipped
Anti-flash dark (color-scheme + applyDocumentTheme), scroll pages publiques,
prompts wizard/slides moins génériques, cron agents rattrapage nextRun null,
slides carnet via notebookId, graphiques à 2 crédits + hint, audit dashboard,
packs Stripe marqués non configurés sans price ID.
2026-07-16 20:48:55 +00:00
Antigravity
556a0b2f3f feat(credits): solde IA unique (option M), packs Stripe et polish billing
Un pot de crédits global (BASIC 100 / PRO 1 000 / BUSINESS 4 000) remplace
les seaux par fonction côté débit. Packs one-shot S/M/L, admin sans seaux
legacy, usage multi-features, hints de coût, anti-flash thème et hydratation
admin. Inclut aussi le pipeline slides renforcé et la page publique polishée.
2026-07-16 20:43:18 +00:00
Antigravity
704fed1191 fix: Memory Echo sans vol de scroll, badge IA sidebar, wizard langue
- Ne plus auto-scroller vers Memory Echo : pastille bas de page + reset scroll au titre
- Badges notes/carnets générés par l’IA dans la sidebar
- Wizard : langue du contenu = langue de la requête ; refresh carnet après création
- Voix : erreurs not-allowed/no-speech moins bruyantes
2026-07-16 17:25:57 +00:00
Antigravity
45297da333 fix: images, couverture, slash, agents, wizard, Stripe et admin
- Collage/accès images: ownership path userId + meta legacy, 403 non caché
- Couverture note: explicite (choix/aucune), plus d’auto depuis le corps
- Éditeur: suppression image TipTap, sync immédiate, toolbar réordonnée
- Slash: listes par titre (plus d’index), keywords nettoyés
- Agents: nextRun à la réactivation, cron sans stampede null
- Wizard: titres courts + mode Expert plus robuste
- Stripe checkout hosted fiable; admin métriques live; dark mode IA/settings
- Indexation notes courtes + test unit aligné
2026-07-16 16:58:07 +00:00
Antigravity
4fe31ebc99 fix(quotas): unifier le décompte IA (BYOK, rollback) et combler les fuites
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 6m55s
CI / Deploy production (on server) (push) Successful in 36s
Centralise la réserve via ai-quota, corrige admin unavailable (-1), brancher les routes sans quota et le host-pays brainstorm, avec usage-meter élargi, noms de clusters, MCP et ajustements dashboard/insights.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 20:42:25 +00:00
Antigravity
30da592ba2 feat(dashboard): tableau de bord Second Brain configurable avec chargement progressif
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m3s
CI / Deploy production (on server) (push) Successful in 23s
Briefing granulaire, pistes rapides puis enrichissement async, layout persisté v5,
suggestions agents, intégration Gmail et navigation sidebar alignée sur /home.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 16:50:53 +00:00
Antigravity
d38a99586b feat(sidebar): redimensionnement largeur et zone carnets, retrait note du jour
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m5s
CI / Deploy production (on server) (push) Successful in 23s
Ajoute une poignée drag pour la largeur de la sidebar (persistée) et sépare inbox/arbre carnets avec slider vertical ; supprime le bouton Note du jour devenu inutile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 11:35:03 +00:00
Antigravity
6e13b6d207 fix: note supprimée depuis éditeur — disparaît de la liste sans refresh
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m25s
CI / Deploy production (on server) (push) Successful in 1m1s
Root cause: NOTE_CHANGE_EVENT listener dans home-client ne gérait que 'updated'
Quand deleteNote émettait { type: 'deleted' }, l'événement était ignoré.

Fix: listener gère maintenant 'deleted' (removeNoteFromList) + 'created'
2026-07-05 18:54:41 +00:00
Antigravity
4d95234e32 ci: trigger deploy 2026-07-05 18:46:13 +00:00
438 changed files with 76678 additions and 19100 deletions

View File

@@ -78,3 +78,6 @@ TELEGRAM_CHAT_ID=<var>
# Monitoring
METRICS_TOKEN=<secret>
GRAFANA_ADMIN_PASSWORD=<secret>
# Monitoring LAN : définir GRAFANA_BIND et GRAFANA_URL dans .env.docker (voir deploy)
# GRAFANA_BIND=127.0.0.1
# GRAFANA_URL=http://localhost:3002

View File

@@ -174,6 +174,8 @@ jobs:
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
METRICS_TOKEN: ${{ secrets.METRICS_TOKEN }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
GRAFANA_BIND: ${{ vars.GRAFANA_BIND }}
GRAFANA_URL: ${{ vars.GRAFANA_URL }}
MCP_API_KEY: ${{ secrets.MCP_API_KEY }}
run: |
ENV_FILE="/opt/memento/.env.docker"
@@ -232,6 +234,8 @@ jobs:
upsert TELEGRAM_CHAT_ID "$TELEGRAM_CHAT_ID"
upsert METRICS_TOKEN "$METRICS_TOKEN"
upsert GRAFANA_ADMIN_PASSWORD "$GRAFANA_ADMIN_PASSWORD"
upsert GRAFANA_BIND "$GRAFANA_BIND"
upsert GRAFANA_URL "$GRAFANA_URL"
upsert MCP_API_KEY "$MCP_API_KEY"
# Write metrics token file for Prometheus (same secret)
[ -n "$METRICS_TOKEN" ] && echo "$METRICS_TOKEN" > /opt/memento/monitoring/metrics-token && chmod 600 /opt/memento/monitoring/metrics-token || true

View File

@@ -63,6 +63,8 @@ jobs:
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
METRICS_TOKEN: ${{ secrets.METRICS_TOKEN }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
GRAFANA_BIND: ${{ vars.GRAFANA_BIND }}
GRAFANA_URL: ${{ vars.GRAFANA_URL }}
MCP_API_KEY: ${{ secrets.MCP_API_KEY }}
run: |
ENV_FILE="/opt/memento/.env.docker"
@@ -121,6 +123,8 @@ jobs:
upsert TELEGRAM_CHAT_ID "$TELEGRAM_CHAT_ID"
upsert METRICS_TOKEN "$METRICS_TOKEN"
upsert GRAFANA_ADMIN_PASSWORD "$GRAFANA_ADMIN_PASSWORD"
upsert GRAFANA_BIND "$GRAFANA_BIND"
upsert GRAFANA_URL "$GRAFANA_URL"
upsert MCP_API_KEY "$MCP_API_KEY"
[ -n "$METRICS_TOKEN" ] && echo "$METRICS_TOKEN" > /opt/memento/monitoring/metrics-token && chmod 600 /opt/memento/monitoring/metrics-token || true
echo "env.docker sanity-check passed ($(wc -l < "$ENV_FILE") lines)"

45
.impeccable/config.json Normal file
View File

@@ -0,0 +1,45 @@
{
"buildPath": "comp",
"detector": {
"ignoreRules": [],
"ignoreFiles": [],
"ignoreValues": [
{
"rule": "ai-color-palette",
"value": "*",
"files": [
"memento-note/components/intelligence-hub.tsx"
],
"createdAt": "2026-08-26T18:03:40.105Z",
"reason": "Palette violette déjà en place sur le widget ponts du dashboard (incumbent), pas une nouvelle teinte IA."
},
{
"rule": "side-tab",
"value": "*",
"files": [
"memento-note/components/note-editor/note-editor-toolbar.tsx"
],
"createdAt": "2026-08-26T18:14:54.764Z",
"reason": "Bordure existante de la toolbar éditeur (incumbent), pas une nouvelle carte AI."
},
{
"rule": "border-accent-on-rounded",
"value": "*",
"files": [
"memento-note/app/(main)/insights/page.tsx"
],
"createdAt": "2026-08-26T18:23:06.241Z",
"reason": "Styles existants de /insights (incumbent), pas une nouvelle carte."
},
{
"rule": "gray-on-color",
"value": "*",
"files": [
"memento-note/app/(main)/insights/page.tsx"
],
"createdAt": "2026-08-26T18:23:06.270Z",
"reason": "Styles existants de /insights (incumbent), pas un nouveau texte gris."
}
]
}
}

File diff suppressed because one or more lines are too long

88
PRODUCT.md Normal file
View File

@@ -0,0 +1,88 @@
# Product
<!-- impeccable:product-schema 1 -->
## Platform
web
## Users
Personnes qui capturent des idées, notes et savoirs au fil du temps et ont besoin de les retrouver, les relier et les actionner — pas seulement de les stocker.
Audiences confirmées (sans hiérarchie exclusive) :
- étudiants et chercheurs (cours, papers, synthèses, révision) ;
- freelances et professionnels indépendants (projets, briefs, clients) ;
- équipes (partage de contexte, agents, Second Brain collectif selon le palier) ;
- personnes « lambda » qui veulent un second cerveau accessible, pas un outil expert-only.
Situation type : trop de notes / dossiers, recherche trop littérale, idées perdues ; le job est de transformer la capture quotidienne en système vivant (liens, briefing, révision, actions).
## Product Purpose
**Memento** est une application de prise de notes intelligente positionnée comme **Second Brain** : elle relie les idées dans le temps, fait émerger des connexions cachées, et transforme les carnets en système searchable, briefable et actionnable (matin / routines).
Succès produit : lutilisateur écrit dans Memento et le système lui rend de la valeur *pendant* et *après* lécriture (Memory Echo, insights, dashboard, flashcards, agents) — pas une pile morte de fichiers.
## Positioning
Pas « une app de notes de plus ». Mécanisme différenciant : un Second Brain qui **se connecte pendant que vous écrivez** — similarité sémantique réelle, ponts entre notes/carnets, aperçu côte à côte, dashboard Second Brain (briefing, pistes, checklist), agents qui **écrivent dans les carnets**, recherche sémantique, révision espacée — plutôt quun chat jetable ou un classement manuel de dossiers.
Un concurrent « Keep/Notion générique » ne peut pas honnêtement reprendre la promesse *Second Brain vivant qui se connecte tout seul* sans ces mécanismes.
## Operating Context
- App web principale : `memento-note/` (Next.js App Router), URL prod `https://memento-note.com`.
- Capture & édition : notes riche / Markdown, carnets, tags, structured views (table/kanban), peek split note liée, publication web, pages interactives.
- Second Brain au quotidien : dashboard `/home`, Memory Echo dans léditeur, `/insights` (clusters sémantiques), `/revision` (flashcards SM-2), agents, rappels, scan Gmail (vols/colis/abonnements).
- Référence UX métier / prototype : `architectural-grid/` (et base `architectural-grid1/`) — à consulter avant implémentation UI significative ; la vérité produit dinterface suit le prototype quand il y a conflit dinterprétation.
- Extension navigateur (Web Clipper / Side Panel), serveur MCP, intégrations (Calendar/Gmail pattern).
- Companion mobile Expo (`memento-mobile/`) existe ; **lautorité design Impeccable pour ce PRODUCT est le web** (PWA / responsive inclus). Le natif partage la vérité produit mais nimpose pas un langage UI `adaptive` tant quil nest pas le surface principal de craft.
- Auth : priorité Google OAuth ; email/mdp avec email vérifié obligatoire.
- i18n : 15 langues ; calendrier iranien + chiffres persans + RTL pour le persan (et arabe) — app et extension.
- Facturation : paliers BASIC/PRO/BUSINESS/ENTERPRISE, quotas IA, essai abonnement **7 jours** ; BASIC sans MCP ; BYOK possible selon config.
## Capabilities and Constraints
**Capacités confirmées (non exhaustif)** : notes/carnets, recherche sémantique, Memory Echo, insights réseau, dashboard Second Brain configurable, agents, flashcards SM-2, structured views, Web Clipper, MCP (hors BASIC), publication simple/IA, pages interactives, multilinguisme.
**Contraintes durables** :
- Nom produit : **Memento** uniquement (jamais « Momento ») dans libellés, metadata, pages publiques, docs.
- Libellés UI via i18n (`memento-note/locales/*.json`) — pas de texte en dur ; pas de marques tierces dans les libellés utilisateur.
- Qualité UX prioritaire ; solutions long terme plutôt que rustines.
- Pas de perte de données : jamais de reset/drop/truncate DB sans backup vérifié + confirmation explicite (voir `CLAUDE.md` / règles projet).
- Quotas IA : réservation atomique avant appel ; chaque usage IA décompte.
- Une user story / feature à la fois, validation utilisateur avant la suivante.
- Docs de stories souvent périmées — vérifier le code avant de « (re)développer ».
**Ouvert / hors scope Impeccable init** : détails de roadmap feature-par-feature ; choix esthétiques (monde visuel → `new-work` / `document`).
## Brand Commitments
- Marque : **Memento** ; entité visible « Memento Labs » dans certains pied-de-page.
- Promesse marketing : Second Brain — « Il se souvient de ce que vous avez oublié » / se connecte pendant que vous écrivez.
- Voix : claire, concrète, orientée bénéfice utilisateur ; français correct en communication produit FR ; éviter le jargon non expliqué dans lUI.
- Référence prototype `architectural-grid` pour les flux métier (liste/carte notes, recherche, insights, sidebar, etc.) — engagement produit confirmé, pas une esthétique figée dans ce fichier.
## Evidence on Hand
- Copy landing et produit : `memento-note/locales/*.json` (ex. `landing.*`), `components/landing-page.tsx`.
- README produit : `README.fr.md` / `README.md`, guide utilisateur `docs/guide-utilisateur/`.
- Prototypes UI : `architectural-grid/`, `architectural-grid1/`.
- Extension : `memento-note/extension/`.
- Assets promo / démos : `promo-video/` (captures authentifiées, VO EN).
- **Ne pas inventer** : témoignages clients, logos presse, benchmarks chiffrés non présents dans le repo.
## Product Principles
1. **Second Brain dabord** — chaque surface renforce connexion, rappel et action ; pas le stockage pour le stockage.
2. **Valeur pendant lécriture** — Memory Echo, peek, agents et briefing doivent être exploitables dans le flux réel, pas décoratifs.
3. **Accessible à la personne lambda, assez profond pour le chercheur** — clarté et aide contextuelle avant jargon ; puissance sans exiger dêtre power-user dès le jour 1.
4. **Monde multilingue réel** — RTL, calendriers et embeddings inclus ; pas danglais hardcodé quand lUI est dans une autre langue.
5. **Preuve avant promesse** — sappuyer sur features et copy existantes ; ne pas fabriquer de preuves marketing.
## Accessibility & Inclusion
- Multilinguisme et RTL (fa/ar) sont des exigences produit, pas un bonus.
- Aide contextuelle (« ? ») et libellés lisibles attendus là où lUX le demande.
- Standard WCAG formel non fixé dans cet init : à traiter comme **ouvert** ; viser une UI utilisable clavier / contraste correcte par défaut sur le web sans inventer une certification.

View File

@@ -31,3 +31,17 @@ All BYOK items implemented on 2026-05-30:
- **`lib/export/zip-builder.ts` non extrait** — logique inline dans la route ; fonctionnel mais écarte la structure prévue par la story.
- **Rate limiting absent sur `GET /api/user/export`** — vecteur dabus (exports répétés) ; hardening ultérieur.
## Deferred — revue page interactive (2026-07-24)
- Régénération section/démo (§7.3 spec origine) + UI auteur associée + tarif regen.
- Page publique interactive : `ReadingProgress` + `CopyLinkButton` (régression UX vs templates magazine/brief/essay).
- Fallback déterministe : libellés complets dans les 13 autres locales + `dir="rtl"` pour fa/ar (actuellement FR/EN seulement).
- `normalize.ts` : caps 2 démos/5 sections plus restrictifs que le validateur (5/8) — aligner si latence LLM acceptable.
- `validateImageSrc` : autoriser uniquement `/uploads` + https (bloquer http:// et //domaine externe — pixel de tracking sur page publique).
- Quality gate « règle d'or » appliquée aussi au `pageSpec` posté directement à `/api/notes/publish` (pas seulement au flux LLM).
- `resolve.ts` : numérotation des badges par démo (pas réinitialisée par acte).
- `demo-scene-view.tsx:479` : warning eslint exhaustive-deps (`edges` dans useMemo).
- `page-view.tsx` useScrollReveal : scoper les `querySelectorAll` via ref (multi-instance).
- Test `rejects hex color` : le faire échouer via le scan sémantique (pas l'enum Zod) pour prouver le scan.
- Page publiée sans JS : démos à l'état final au premier paint (spec §9.5) — nécessite SSR static + swap hydratation.

View File

@@ -150,7 +150,7 @@ services:
cpus: '0.25'
memory: 128M
healthcheck:
test: ["CMD-SHELL", "wget --header \"x-api-key: $${MCP_API_KEY:-dev-key}\" -q -O /dev/null http://localhost:3001/ || exit 1"]
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:3001/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3

View File

@@ -145,6 +145,7 @@ Dans **Stripe Dashboard** → **Développeurs** → **Webhooks** :
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `customer.subscription.trial_will_end` (rappel email ~3 jours avant fin dessai)
- `invoice.payment_failed`
4. Copier la **signature secrète**`STRIPE_WEBHOOK_SECRET` (commence par `whsec_...`)
@@ -246,11 +247,12 @@ Utilisateur Frontend Backend
| Événement Stripe | Action Memento | Statut Prisma |
|------------------|---------------|---------------|
| `checkout.session.completed` | Upsert subscription avec tier/periode | `ACTIVE` |
| `checkout.session.completed` | Upsert subscription avec tier/periode | Selon Stripe (`TRIALING` ou `ACTIVE`) |
| `customer.subscription.created` | Upsert (nouvelle souscription) | Selon Stripe |
| `customer.subscription.updated` | Upsert (changement de plan, etc.) | Selon Stripe |
| `customer.subscription.deleted` | Marquer annulé | `CANCELED` |
| `invoice.payment_failed` | Sync subscription (passage en `PAST_DUE`) | `PAST_DUE` |
| `customer.subscription.trial_will_end` | Sync + email rappel fin dessai | `TRIALING` |
### 3.3 Mapping statuts Stripe → Prisma

View File

@@ -6,7 +6,7 @@ Model Context Protocol (MCP) server for integrating Memento note-taking app with
## Features
-**22 Tools** for notes, notebooks, labels, and reminders
-**29 Tools** for notes, notebooks, labels, reminders, semantic similarity, Memory Echo insights, and statistics
- 🔒 **API Key Authentication** with secure storage
- 🚀 **Performance Optimized** with connection pooling and caching
- 📊 **Observability** with Prometheus metrics export
@@ -61,7 +61,7 @@ Generate API keys from the Memento web UI: **Settings > MCP**.
curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health
```
## Available Tools (22)
## Available Tools (29)
### Notes (13)
@@ -81,7 +81,7 @@ curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health
| `batch_move_notes` | Move multiple notes at once |
| `batch_delete_notes` | Delete multiple notes at once |
### Notebooks (6)
### Notebooks (7)
| Tool | Description |
|------|-------------|
@@ -102,11 +102,28 @@ curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health
| `update_label` | Update a label |
| `delete_label` | Delete a label |
### Reminders (1)
### Reminders (3)
| Tool | Description |
|------|-------------|
| `get_due_reminders` | Get due reminders |
| `get_upcoming_reminders` | Get reminders due in the next N hours |
| `update_reminder` | Set, update, or clear a note reminder |
### Semantic & AI (3)
| Tool | Description |
|------|-------------|
| `find_similar_notes` | Find notes semantically similar to a given note |
| `get_memory_echo_insights` | List AI-discovered note connections |
| `dismiss_memory_echo_insight` | Mark a Memory Echo insight as viewed/dismissed |
### Filters & Statistics (2)
| Tool | Description |
|------|-------------|
| `get_notes_by_label` | List notes by label/tag |
| `get_note_statistics` | Get counts and breakdowns for notes, notebooks, labels |
### Utilities (2)

View File

@@ -28,6 +28,28 @@ function setCachedKey(keyHash, data) {
keyCache.set(keyHash, { data, timestamp: Date.now() });
}
async function getEffectiveTier(prisma, userId) {
if (!userId) return 'BASIC';
const sub = await prisma.subscription.findUnique({
where: { userId },
select: { tier: true, status: true, currentPeriodEnd: true },
});
if (!sub) return 'BASIC';
if (sub.status === 'ACTIVE' || sub.status === 'TRIALING') return sub.tier;
if (
(sub.status === 'PAST_DUE' || sub.status === 'CANCELED') &&
sub.currentPeriodEnd &&
new Date() < new Date(sub.currentPeriodEnd)
) {
return sub.tier;
}
return 'BASIC';
}
function isMcpTierAllowed(tier) {
return tier !== 'BASIC';
}
/**
* Generate a new API key.
* @param {import('@prisma/client').PrismaClient} prisma
@@ -111,6 +133,9 @@ export async function validateApiKey(prisma, rawKey) {
const info = JSON.parse(entry.value);
if (info.keyHash !== keyHash || !info.active) return null;
const tier = await getEffectiveTier(prisma, info.userId);
if (!isMcpTierAllowed(tier)) return null;
info.lastUsedAt = new Date().toISOString();
prisma.systemConfig.update({
where: { key: configKey },

View File

@@ -197,8 +197,9 @@ export function validateConfig() {
}
}
// Port validation
const portValidation = validatePort(config.port);
// Port validation (check raw value before clamping)
const rawPort = Number.parseInt(env('PORT', '3001'), 10);
const portValidation = validatePort(Number.isNaN(rawPort) ? config.port : rawPort);
if (!portValidation.valid) {
errors.push({ key: 'PORT', message: portValidation.error, critical: true });
}

View File

@@ -394,7 +394,7 @@ app.get('/', (req, res) => {
sessions: '/sessions',
},
auth: { enabled: config.requireAuth },
tools: 22,
tools: 29,
uptime: process.uptime(),
});
});
@@ -445,25 +445,23 @@ app.all(
}
// Validate tool input if present
if (req.body?.method) {
const toolName = req.body.method;
if (req.body?.params) {
const validation = validateAndSanitize(toolName, req.body.params);
if (!validation.success) {
log('warn', `Validation failed for ${toolName}:`, validation.errors);
return res
.status(400)
.json(
mcpError(McpErrors.INVALID_PARAMS.code, {
detail: 'Input validation failed',
field: validation.errors[0]?.field,
context: { toolName, errors: validation.errors },
})
);
}
// Update request with sanitized data
req.body.params = validation.data;
if (req.body?.method === 'tools/call' && req.body?.params?.name && req.body?.params?.arguments) {
const toolName = req.body.params.name;
const validation = validateAndSanitize(toolName, req.body.params.arguments);
if (!validation.success) {
log('warn', `Validation failed for ${toolName}:`, validation.errors);
return res
.status(400)
.json(
mcpError(McpErrors.INVALID_PARAMS.code, {
detail: 'Input validation failed',
field: validation.errors[0]?.field,
context: { toolName, errors: validation.errors },
})
);
}
// Update request with sanitized data
req.body.params.arguments = validation.data;
}
const ctx = { userId: req.userSession?.userId || null };
@@ -539,7 +537,7 @@ async function main() {
Auth: ${config.requireAuth ? 'ENABLED' : 'DISABLED (dev)'}
Timeout: ${config.requestTimeout}ms
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
Tools: 22
Tools: 29
Features: ${config.enableMetrics ? 'Metrics' : ''}${config.enableAuditLog ? ', Audit Log' : ''}
`);
});

View File

@@ -176,7 +176,7 @@ async function main() {
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
User: ${config.userId || 'all'}
Log Level: ${config.logLevel}
Tools: 22
Tools: 29
`);
}

View File

@@ -1,7 +1,7 @@
{
"name": "memento-mcp-server",
"version": "3.2.0",
"description": "MCP Server for Memento - AI-powered note-taking app. Enhanced with error handling, metrics, rate limiting, and input validation. Provides 22 tools for notes, notebooks, labels, and reminders.",
"description": "MCP Server for Memento - AI-powered note-taking app. Enhanced with error handling, metrics, rate limiting, and input validation. Provides 29 tools for notes, notebooks, labels, reminders, semantic similarity, Memory Echo insights, and statistics.",
"type": "module",
"main": "index.js",
"scripts": {

View File

@@ -5,7 +5,7 @@
* Run with: npm test
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
@@ -124,9 +124,8 @@ describe('MCP Server - Input Validation', () => {
it('should allow safe HTML', () => {
const xss = checkXSS({ content: 'Hello <em>world</em>' });
// This will be true because we check for any HTML tags
// In production, you might want more sophisticated checking
expect(xss).toBe(true);
// Safe formatting tags are not flagged as XSS
expect(xss).toBe(false);
});
it('should sanitize input', () => {
@@ -206,11 +205,18 @@ describe('MCP Server - Tool Definitions', () => {
'update_label',
'delete_label',
'get_due_reminders',
'get_upcoming_reminders',
'update_reminder',
'find_similar_notes',
'get_memory_echo_insights',
'dismiss_memory_echo_insight',
'get_notes_by_label',
'get_note_statistics',
'export_notes',
'import_notes',
];
it('should have all expected tools with schemas', () => {
it('should have all expected tools with schemas', async () => {
const { toolSchemas } = await import('../validation.js');
for (const toolName of toolNames) {

View File

@@ -19,7 +19,7 @@ const DEFAULT_NOTES_LIMIT = 100;
const MAX_NOTES_LIMIT = 500;
const NOTE_COLORS = 'default, red, orange, yellow, green, teal, blue, purple, pink, gray';
const LABEL_COLORS = 'red, orange, yellow, green, teal, blue, purple, pink, gray';
const LABEL_COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray'];
export function parseNote(dbNote) {
if (!dbNote) return null;
@@ -463,6 +463,100 @@ const toolDefinitions = [
description: 'Get notes with due reminders. Designed for cron/automation.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'get_upcoming_reminders',
description: 'Get notes with reminders due in the next N hours (default 24).',
inputSchema: {
type: 'object',
properties: {
hours: { type: 'number', description: 'Number of hours ahead to look', default: 24 },
includeDone: { type: 'boolean', description: 'Include already-done reminders', default: false },
limit: { type: 'number', description: 'Max results', default: 100 },
},
},
},
{
name: 'update_reminder',
description: 'Set, update, or clear a reminder on a note.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Note ID' },
reminder: { type: 'string', description: 'ISO 8601 datetime, or null to clear', nullable: true },
isReminderDone: { type: 'boolean' },
reminderRecurrence: { type: 'string', description: 'daily, weekly, monthly, yearly', nullable: true },
reminderLocation: { type: 'string', nullable: true },
},
required: ['id'],
},
},
// ═══ SEMANTIC / AI ═══
{
name: 'find_similar_notes',
description: 'Find notes semantically similar to a given note using its vector embedding. Requires the note to have an embedding.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Source note ID' },
limit: { type: 'number', description: 'Max results', default: 10 },
threshold: { type: 'number', description: 'Minimum cosine similarity (0-1)', default: 0.7 },
},
required: ['id'],
},
},
{
name: 'get_memory_echo_insights',
description: 'List Memory Echo insights: AI-discovered connections between notes.',
inputSchema: {
type: 'object',
properties: {
includeViewed: { type: 'boolean', description: 'Include already viewed insights', default: false },
includeDismissed: { type: 'boolean', description: 'Include dismissed insights', default: false },
limit: { type: 'number', description: 'Max results', default: 20 },
},
},
},
{
name: 'dismiss_memory_echo_insight',
description: 'Mark a Memory Echo insight as viewed or dismissed.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Insight ID' },
viewed: { type: 'boolean', description: 'Mark as viewed', default: true },
dismissed: { type: 'boolean', description: 'Mark as dismissed', default: true },
},
required: ['id'],
},
},
// ═══ FILTERS & STATISTICS ═══
{
name: 'get_notes_by_label',
description: 'List notes that have a specific label/tag.',
inputSchema: {
type: 'object',
properties: {
label: { type: 'string', description: 'Label text to match' },
notebookId: { type: 'string', description: 'Filter by notebook', nullable: true },
includeArchived: { type: 'boolean', default: false },
limit: { type: 'number', description: 'Max results', default: 100 },
},
required: ['label'],
},
},
{
name: 'get_note_statistics',
description: 'Get statistics about notes, notebooks, labels, reminders, and trash.',
inputSchema: {
type: 'object',
properties: {
includeArchived: { type: 'boolean', description: 'Include archived notes in counts', default: false },
includeTrashed: { type: 'boolean', description: 'Include trashed notes in counts', default: false },
},
},
},
];
// ─── Tool Handlers ──────────────────────────────────────────────────────────
@@ -502,7 +596,7 @@ export function registerTools(server, prisma) {
title: args.title || null,
content: args.content,
color: args.color || 'default',
type: args.type || 'text',
type: args.type || 'richtext',
checkItems: args.checkItems ?? null,
labels: args.labels ?? null,
isPinned: args.isPinned || false,
@@ -1003,6 +1097,201 @@ export function registerTools(server, prisma) {
return textResult({ count: reminders.length, reminders });
}
case 'get_upcoming_reminders': {
const now = new Date();
const horizon = new Date(now.getTime() + (args.hours || 24) * 60 * 60 * 1000);
const extra = {
reminder: { not: null, gte: now, lte: horizon },
isArchived: false,
};
if (!args.includeDone) extra.isReminderDone = false;
const reminders = await prisma.note.findMany({
where: noteWhere(uid, extra),
select: { id: true, title: true, content: true, reminder: true, isReminderDone: true, notebookId: true },
orderBy: { reminder: 'asc' },
take: args.limit || 100,
});
return textResult({ count: reminders.length, horizon, reminders });
}
case 'update_reminder': {
const d = { updatedAt: new Date() };
if ('reminder' in args) d.reminder = args.reminder ? new Date(args.reminder) : null;
if ('isReminderDone' in args) d.isReminderDone = args.isReminderDone;
if ('reminderRecurrence' in args) d.reminderRecurrence = args.reminderRecurrence || null;
if ('reminderLocation' in args) d.reminderLocation = args.reminderLocation || null;
const note = await prisma.note.update({
where: { id: args.id, userId: uid, trashedAt: null },
data: d,
});
return textResult(parseNote(note));
}
// ═══ SEMANTIC / AI ═══
case 'find_similar_notes': {
const source = await prisma.note.findUnique({
where: { id: args.id, userId: uid, trashedAt: null },
select: { id: true, title: true },
});
if (!source) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
const sourceEmbedding = await prisma.noteEmbedding.findUnique({
where: { noteId: args.id },
select: { embedding: true },
});
if (!sourceEmbedding) {
return textResult({
noteId: args.id,
error: 'Source note has no embedding. Index it first via the Memento app.',
results: [],
});
}
const embeddingArray = Array.isArray(sourceEmbedding.embedding)
? sourceEmbedding.embedding
: String(sourceEmbedding.embedding).replace(/^\[|\]$/g, '').split(',').map(Number);
const vecStr = `[${embeddingArray.join(',')}]`;
const limit = Math.min(args.limit || 10, 50);
const threshold = args.threshold ?? 0.7;
const rows = await prisma.$queryRawUnsafe(
`SELECT n.id, n.title, n.content, n.color, n.type, n."isPinned", n."isArchived",
n."notebookId", n."createdAt", n."updatedAt",
1 - (e."embedding"::vector <=> $1::vector) AS similarity
FROM "Note" n
INNER JOIN "NoteEmbedding" e ON e."noteId" = n.id
WHERE n."trashedAt" IS NULL
AND n."isArchived" = false
AND n."userId" = $2
AND n.id != $3
AND 1 - (e."embedding"::vector <=> $1::vector) >= $4
ORDER BY e."embedding"::vector <=> $1::vector ASC
LIMIT $5`,
vecStr,
uid,
args.id,
threshold,
limit,
);
return textResult({
noteId: args.id,
sourceTitle: source.title || 'Untitled',
count: rows.length,
results: rows.map(r => ({ ...parseNoteLightweight(r), similarity: Number(r.similarity) })),
});
}
case 'get_memory_echo_insights': {
const where = { userId: uid };
if (!args.includeViewed) where.viewed = false;
if (!args.includeDismissed) where.dismissed = false;
const insights = await prisma.memoryEchoInsight.findMany({
where,
orderBy: { similarityScore: 'desc' },
take: Math.min(args.limit || 20, 100),
include: {
note1: { select: { id: true, title: true } },
note2: { select: { id: true, title: true } },
},
});
return textResult({
count: insights.length,
insights: insights.map(i => ({
id: i.id,
insight: i.insight,
similarityScore: i.similarityScore,
insightDate: i.insightDate,
viewed: i.viewed,
dismissed: i.dismissed,
note1: i.note1,
note2: i.note2,
})),
});
}
case 'dismiss_memory_echo_insight': {
const d = {};
if ('viewed' in args) d.viewed = args.viewed;
if ('dismissed' in args) d.dismissed = args.dismissed;
const insight = await prisma.memoryEchoInsight.update({
where: { id: args.id, userId: uid },
data: d,
});
return textResult({ success: true, id: insight.id, viewed: insight.viewed, dismissed: insight.dismissed });
}
// ═══ FILTERS & STATISTICS ═══
case 'get_notes_by_label': {
const label = args.label.trim();
const extra = {};
if (!args.includeArchived) extra.isArchived = false;
if (args.notebookId) {
extra.notebookId = args.notebookId === 'inbox' ? null : args.notebookId;
}
// Labels are stored as a JSON string like ["tag1","tag2"].
const notes = await prisma.note.findMany({
where: {
...noteWhere(uid, extra),
labels: { contains: label },
},
orderBy: [{ isPinned: 'desc' }, { updatedAt: 'desc' }],
take: Math.min(args.limit || 100, MAX_NOTES_LIMIT),
});
return textResult({ label, count: notes.length, notes: notes.map(parseNoteLightweight) });
}
case 'get_note_statistics': {
const baseWhere = { userId: uid };
const activeWhere = { ...baseWhere, trashedAt: null };
const [
totalNotes,
archivedNotes,
trashedNotes,
notebookCount,
labelCount,
notesWithReminders,
notesWithImages,
notesWithCheckItems,
notesByType,
notesByColor,
] = await Promise.all([
prisma.note.count({ where: activeWhere }),
prisma.note.count({ where: { ...activeWhere, isArchived: true } }),
prisma.note.count({ where: { ...baseWhere, trashedAt: { not: null } } }),
prisma.notebook.count({ where: { userId: uid } }),
prisma.label.count({ where: { notebook: { userId: uid } } }),
prisma.note.count({ where: { ...activeWhere, reminder: { not: null } } }),
prisma.note.count({ where: { ...activeWhere, images: { not: null } } }),
prisma.note.count({ where: { ...activeWhere, checkItems: { not: null } } }),
prisma.note.groupBy({ by: ['type'], where: activeWhere, _count: { type: true } }),
prisma.note.groupBy({ by: ['color'], where: activeWhere, _count: { color: true } }),
]);
return textResult({
totalNotes,
archivedNotes,
trashedNotes,
activeNotes: totalNotes - archivedNotes,
notebookCount,
labelCount,
notesWithReminders,
notesWithImages,
notesWithCheckItems,
byType: notesByType.map(t => ({ type: t.type, count: t._count.type })),
byColor: notesByColor.map(c => ({ color: c.color, count: c._count.color })),
});
}
// ═══ TRASH ═══
case 'trash_note': {
const note = await prisma.note.update({

View File

@@ -179,7 +179,6 @@ export const getNotesSchema = z.object({
notebookId: idSchema.optional().nullable(),
fullDetails: boolSchema(false),
limit: z.number().int().min(1).max(500).default(100),
offset: z.number().int().min(0).default(0),
});
/**
@@ -225,7 +224,6 @@ export const deleteNoteSchema = z.object({
*/
export const searchNotesSchema = z.object({
query: z.string().min(1).max(500),
limit: z.number().int().min(1).max(100).default(50),
notebookId: idSchema.optional().nullable(),
includeArchived: boolSchema(false),
});
@@ -243,7 +241,6 @@ export const moveNoteSchema = z.object({
*/
export const togglePinSchema = z.object({
id: idSchema,
pinned: z.boolean().optional(),
});
/**
@@ -251,14 +248,13 @@ export const togglePinSchema = z.object({
*/
export const toggleArchiveSchema = z.object({
id: idSchema,
archived: z.boolean().optional(),
});
/**
* batch_move_notes input schema
*/
export const batchMoveNotesSchema = z.object({
noteIds: z.array(idSchema).min(1).max(100),
ids: z.array(idSchema).min(1).max(100),
notebookId: idSchema.optional().nullable(),
});
@@ -266,7 +262,7 @@ export const batchMoveNotesSchema = z.object({
* batch_delete_notes input schema
*/
export const batchDeleteNotesSchema = z.object({
noteIds: z.array(idSchema).min(1).max(100),
ids: z.array(idSchema).min(1).max(100),
});
/**
@@ -274,18 +270,16 @@ export const batchDeleteNotesSchema = z.object({
*/
export const createNotebookSchema = z.object({
name: z.string().min(1).max(200),
color: colorSchema.default('default'),
color: z.string().max(50).default('#3B82F6'),
icon: z.string().max(50).optional().nullable(),
parentId: idSchema.optional().nullable(),
order: z.number().int().optional(),
});
/**
* get_notebooks input schema
*/
export const getNotebooksSchema = z.object({
includeHierarchy: boolSchema(false),
includeTrashed: boolSchema(false),
});
export const getNotebooksSchema = z.object({});
/**
* get_notebook input schema
@@ -300,8 +294,9 @@ export const getNotebookSchema = z.object({
export const updateNotebookSchema = z.object({
id: idSchema,
name: z.string().min(1).max(200).optional(),
color: colorSchema.optional(),
color: z.string().max(50).optional().nullable(),
icon: z.string().max(50).optional().nullable(),
order: z.number().int().optional(),
parentId: idSchema.optional().nullable(),
});
@@ -332,14 +327,15 @@ export const getNotebookHierarchySchema = z.object({
*/
export const createLabelSchema = z.object({
name: z.string().min(1).max(100),
color: colorSchema.default('default'),
color: z.string().max(50).optional().nullable(),
notebookId: idSchema,
});
/**
* get_labels input schema
*/
export const getLabelsSchema = z.object({
limit: z.number().int().min(1).max(500).default(100),
notebookId: idSchema.optional().nullable(),
});
/**
@@ -361,37 +357,90 @@ export const deleteLabelSchema = z.object({
/**
* get_due_reminders input schema
*/
export const getDueRemindersSchema = z.object({
before: isoDateSchema.optional().nullable(),
after: isoDateSchema.optional().nullable(),
includeDone: boolSchema(false),
limit: z.number().int().min(1).max(500).default(100),
});
export const getDueRemindersSchema = z.object({});
/**
* export_notes input schema
*/
export const exportNotesSchema = z.object({
notebookId: idSchema.optional().nullable(),
includeArchived: boolSchema(false),
format: z.enum(['json', 'markdown']).default('json'),
});
export const exportNotesSchema = z.object({});
/**
* import_notes input schema
*/
export const importNotesSchema = z.object({
notes: z.array(
z.object({
title: z.string().optional(),
content: z.string(),
color: colorSchema.optional(),
labels: labelsSchema,
notebookId: idSchema.optional().nullable(),
})
).min(1).max(100),
data: z.object({
version: z.string().optional(),
data: z.object({
notes: z.array(z.any()).optional(),
labels: z.array(z.any()).optional(),
notebooks: z.array(z.any()).optional(),
}).optional(),
}),
});
/**
* find_similar_notes input schema
*/
export const findSimilarNotesSchema = z.object({
id: idSchema,
limit: z.number().int().min(1).max(50).default(10),
threshold: z.number().min(0).max(1).default(0.7),
});
/**
* get_memory_echo_insights input schema
*/
export const getMemoryEchoInsightsSchema = z.object({
includeViewed: boolSchema(false),
includeDismissed: boolSchema(false),
limit: z.number().int().min(1).max(100).default(20),
});
/**
* dismiss_memory_echo_insight input schema
*/
export const dismissMemoryEchoInsightSchema = z.object({
id: idSchema,
viewed: boolSchema(true),
dismissed: boolSchema(true),
});
/**
* get_notes_by_label input schema
*/
export const getNotesByLabelSchema = z.object({
label: z.string().min(1).max(100),
notebookId: idSchema.optional().nullable(),
overwrite: z.boolean().optional().default(false),
includeArchived: boolSchema(false),
limit: z.number().int().min(1).max(500).default(100),
});
/**
* get_note_statistics input schema
*/
export const getNoteStatisticsSchema = z.object({
includeArchived: boolSchema(false),
includeTrashed: boolSchema(false),
});
/**
* get_upcoming_reminders input schema
*/
export const getUpcomingRemindersSchema = z.object({
hours: z.number().int().min(1).max(168).default(24),
includeDone: boolSchema(false),
limit: z.number().int().min(1).max(500).default(100),
});
/**
* update_reminder input schema
*/
export const updateReminderSchema = z.object({
id: idSchema,
reminder: isoDateSchema,
isReminderDone: z.boolean().optional(),
reminderRecurrence: recurrenceSchema,
reminderLocation: z.string().max(500).optional().nullable(),
});
// ═══════════════════════════════════════════════════════════════
@@ -427,6 +476,13 @@ export const toolSchemas = {
get_due_reminders: getDueRemindersSchema,
export_notes: exportNotesSchema,
import_notes: importNotesSchema,
find_similar_notes: findSimilarNotesSchema,
get_memory_echo_insights: getMemoryEchoInsightsSchema,
dismiss_memory_echo_insight: dismissMemoryEchoInsightSchema,
get_notes_by_label: getNotesByLabelSchema,
get_note_statistics: getNoteStatisticsSchema,
get_upcoming_reminders: getUpcomingRemindersSchema,
update_reminder: updateReminderSchema,
};
// ═══════════════════════════════════════════════════════════════
@@ -457,7 +513,7 @@ export function validateToolInput(toolName, input) {
if (error instanceof z.ZodError) {
return {
success: false,
errors: error.errors.map((e) => ({
errors: error.issues.map((e) => ({
field: e.path.join('.'),
message: e.message,
code: e.code,

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/test.js', 'test/**/*.test.js'],
},
});

View File

@@ -1,24 +1,41 @@
'use client'
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { updateBillingConfig, updatePlanEntitlement } from '@/app/actions/admin-billing'
import { updateBillingConfig } from '@/app/actions/admin-billing'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { CreditCard, Gauge, Settings2 } from 'lucide-react'
import {
CreditCard,
Gauge,
Coins,
Package,
Activity,
CheckCircle2,
XCircle,
AlertTriangle,
Users,
BookOpen,
ChevronDown,
ChevronUp,
} from 'lucide-react'
import { cn } from '@/lib/utils'
type EntitlementRow = {
tier: string
feature: string
limitValue: number | null
mode: 'limited' | 'unlimited' | 'unavailable'
type PriceHealth = {
key: string
priceId: string
configured: boolean
source: string
stripeAmount: number | null
stripeCurrency: string | null
stripeActive: boolean | null
error: string | null
}
type BillingAdminData = {
entitlements: EntitlementRow[]
billingConfig: Record<string, string>
usageOverview: {
period: string
@@ -26,34 +43,93 @@ type BillingAdminData = {
byFeature: Array<{ feature: string; requests: number; tokens: number; users: number }>
topUsers: Array<{ userId: string; email: string; name: string | null; requests: number }>
}
features: string[]
tiers: string[]
creditAllocations?: Array<{
tier: string
monthlyCredits: number | null
unlimited: boolean
}>
creditCosts?: Record<string, number>
creditPacks?: Array<{
id: string
credits: number
defaultDisplay: string
}>
stripeHealth?: {
secretConfigured: boolean
secretMode: 'test' | 'live' | 'missing' | 'placeholder'
publishableConfigured: boolean
webhookSecretConfigured: boolean
billingEnabled: boolean
trialDays: number
prices: PriceHealth[]
}
subscriptionStats?: {
byTier: Record<string, number>
byStatus: Record<string, number>
cancelAtPeriodEnd: number
usersWithoutSub: number
trialing: number
pastDue: number
paidActive: number
recent: Array<{
email: string
name: string | null
tier: string
status: string
trialEndsAt: string | null
currentPeriodEnd: string | null
cancelAtPeriodEnd: boolean
hasStripeSub: boolean
updatedAt: string
}>
}
}
function getEntitlement(
entitlements: EntitlementRow[],
tier: string,
feature: string,
): EntitlementRow | undefined {
return entitlements.find((e) => e.tier === tier && e.feature === feature)
const SUBSCRIPTION_PRICE_KEYS = [
'STRIPE_PRICE_PRO_MONTHLY',
'STRIPE_PRICE_PRO_ANNUAL',
'STRIPE_PRICE_BUSINESS_MONTHLY',
'STRIPE_PRICE_BUSINESS_ANNUAL',
] as const
const PACK_PRICE_KEYS = [
'STRIPE_PRICE_CREDITS_S',
'STRIPE_PRICE_CREDITS_M',
'STRIPE_PRICE_CREDITS_L',
] as const
function formatStableUtc(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC`
}
function StatusDot({ ok, warn }: { ok: boolean; warn?: boolean }) {
if (ok) return <CheckCircle2 className="h-4 w-4 text-emerald-600 shrink-0" />
if (warn) return <AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
return <XCircle className="h-4 w-4 text-rose-500 shrink-0" />
}
export function BillingAdminClient({ initialData }: { initialData: BillingAdminData }) {
const { t } = useLanguage()
const [activeTier, setActiveTier] = useState(initialData.tiers[0] ?? 'BASIC')
const [billingEnabled, setBillingEnabled] = useState(initialData.billingConfig.BILLING_ENABLED === 'true')
const [isSavingBilling, setIsSavingBilling] = useState(false)
const [savingCell, setSavingCell] = useState<string | null>(null)
const [showTestGuide, setShowTestGuide] = useState(true)
const health = initialData.stripeHealth
const stats = initialData.subscriptionStats
const priceMap = Object.fromEntries((health?.prices ?? []).map((p) => [p.key, p]))
const handleSaveBilling = async (formData: FormData) => {
setIsSavingBilling(true)
try {
const data: Record<string, string> = {
BILLING_ENABLED: billingEnabled ? 'true' : 'false',
STRIPE_PRICE_PRO_MONTHLY: String(formData.get('STRIPE_PRICE_PRO_MONTHLY') ?? ''),
STRIPE_PRICE_PRO_ANNUAL: String(formData.get('STRIPE_PRICE_PRO_ANNUAL') ?? ''),
STRIPE_PRICE_BUSINESS_MONTHLY: String(formData.get('STRIPE_PRICE_BUSINESS_MONTHLY') ?? ''),
STRIPE_PRICE_BUSINESS_ANNUAL: String(formData.get('STRIPE_PRICE_BUSINESS_ANNUAL') ?? ''),
}
for (const key of [...SUBSCRIPTION_PRICE_KEYS, ...PACK_PRICE_KEYS]) {
data[key] = String(formData.get(key) ?? '')
}
await updateBillingConfig(data)
toast.success(t('admin.billing.configSaved'))
@@ -64,22 +140,14 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
}
}
const handleEntitlementChange = async (
feature: string,
mode: 'unavailable' | 'unlimited' | 'limited',
limitValue?: number,
) => {
const key = `${activeTier}:${feature}`
setSavingCell(key)
try {
await updatePlanEntitlement(activeTier, feature, mode, limitValue)
toast.success(t('admin.billing.limitSaved'))
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : t('admin.billing.limitFailed'))
} finally {
setSavingCell(null)
}
}
const modeLabel =
health?.secretMode === 'test'
? t('admin.billing.modeTest')
: health?.secretMode === 'live'
? t('admin.billing.modeLive')
: health?.secretMode === 'placeholder'
? t('admin.billing.modePlaceholder')
: t('admin.billing.modeMissing')
return (
<div className="space-y-8">
@@ -88,6 +156,246 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
<p className="text-sm text-muted-foreground mt-1">{t('admin.billing.description')}</p>
</div>
{/* Stripe health */}
{health && (
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<Activity className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold">{t('admin.billing.healthTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.billing.healthDescription')}</p>
</div>
</div>
<div className="p-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
<StatusDot ok={health.secretConfigured} warn={health.secretMode === 'placeholder'} />
<div className="min-w-0">
<p className="text-xs font-semibold">{t('admin.billing.healthSecret')}</p>
<p className="text-[11px] text-muted-foreground">{modeLabel}</p>
</div>
</div>
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
<StatusDot ok={health.publishableConfigured} />
<div>
<p className="text-xs font-semibold">{t('admin.billing.healthPublishable')}</p>
<p className="text-[11px] text-muted-foreground">
{health.publishableConfigured ? t('admin.billing.configured') : t('admin.billing.missing')}
</p>
</div>
</div>
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
<StatusDot ok={health.webhookSecretConfigured} />
<div>
<p className="text-xs font-semibold">{t('admin.billing.healthWebhook')}</p>
<p className="text-[11px] text-muted-foreground">
{health.webhookSecretConfigured ? t('admin.billing.configured') : t('admin.billing.missing')}
</p>
</div>
</div>
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
<StatusDot ok={health.billingEnabled} warn={!health.billingEnabled} />
<div>
<p className="text-xs font-semibold">{t('admin.billing.healthBillingFlag')}</p>
<p className="text-[11px] text-muted-foreground">
{health.billingEnabled ? t('admin.billing.enabled') : t('admin.billing.disabled')}
</p>
</div>
</div>
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
<StatusDot ok />
<div>
<p className="text-xs font-semibold">{t('admin.billing.healthTrial')}</p>
<p className="text-[11px] text-muted-foreground">
{t('admin.billing.trialDaysValue', { days: health.trialDays })}
</p>
</div>
</div>
</div>
<div className="px-6 pb-6">
<h3 className="text-sm font-medium mb-3">{t('admin.billing.priceStatusTitle')}</h3>
<div className="overflow-x-auto rounded-xl border border-border/60">
<table className="w-full text-xs">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colKey')}</th>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colPriceId')}</th>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colSource')}</th>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colStripe')}</th>
</tr>
</thead>
<tbody>
{(health.prices ?? []).map((row) => (
<tr key={row.key} className="border-t border-border/50">
<td className="p-2.5 font-mono">{t(`admin.billing.${row.key}`)}</td>
<td className="p-2.5 font-mono truncate max-w-[180px]">
{row.configured ? row.priceId : '—'}
</td>
<td className="p-2.5">{row.source}</td>
<td className="p-2.5">
{!row.configured ? (
<span className="text-rose-600">{t('admin.billing.missing')}</span>
) : row.error ? (
<span className="text-rose-600" title={row.error}>{t('admin.billing.priceError')}</span>
) : row.stripeAmount != null ? (
<span className={cn(row.stripeActive === false && 'text-amber-600')}>
{row.stripeAmount.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} {row.stripeCurrency}
{row.stripeActive === false ? ` (${t('admin.billing.inactive')})` : ''}
</span>
) : (
<span className="text-muted-foreground">{t('admin.billing.notChecked')}</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Subscription stats */}
{stats && (
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<Users className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold">{t('admin.billing.subsTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.billing.subsDescription')}</p>
</div>
</div>
<div className="p-6 grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statPaid')}</p>
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.paidActive}</p>
</div>
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statTrialing')}</p>
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.trialing}</p>
</div>
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statPastDue')}</p>
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.pastDue}</p>
</div>
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statCanceling')}</p>
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.cancelAtPeriodEnd}</p>
</div>
</div>
<div className="px-6 pb-4 grid gap-4 sm:grid-cols-2">
<div>
<h3 className="text-sm font-medium mb-2">{t('admin.billing.byTier')}</h3>
<ul className="space-y-1.5 text-sm">
{Object.entries(stats.byTier).map(([tier, count]) => (
<li key={tier} className="flex justify-between border-b border-border/40 pb-1">
<span>{tier}</span>
<span className="tabular-nums text-muted-foreground">{count}</span>
</li>
))}
<li className="flex justify-between text-xs text-muted-foreground pt-1">
<span>{t('admin.billing.usersWithoutSub')}</span>
<span className="tabular-nums">{stats.usersWithoutSub}</span>
</li>
</ul>
</div>
<div>
<h3 className="text-sm font-medium mb-2">{t('admin.billing.byStatus')}</h3>
<ul className="space-y-1.5 text-sm">
{Object.keys(stats.byStatus).length === 0 ? (
<li className="text-muted-foreground text-xs">{t('admin.billing.noSubs')}</li>
) : (
Object.entries(stats.byStatus).map(([status, count]) => (
<li key={status} className="flex justify-between border-b border-border/40 pb-1">
<span>{status}</span>
<span className="tabular-nums text-muted-foreground">{count}</span>
</li>
))
)}
</ul>
</div>
</div>
<div className="px-6 pb-6">
<h3 className="text-sm font-medium mb-2">{t('admin.billing.recentSubs')}</h3>
{stats.recent.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('admin.billing.noSubs')}</p>
) : (
<div className="overflow-x-auto rounded-xl border border-border/60">
<table className="w-full text-xs">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colUser')}</th>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colTier')}</th>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colStatus')}</th>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colPeriod')}</th>
</tr>
</thead>
<tbody>
{stats.recent.map((row) => (
<tr key={`${row.email}-${row.updatedAt}`} className="border-t border-border/50">
<td className="p-2.5 truncate max-w-[200px]">{row.email}</td>
<td className="p-2.5">{row.tier}</td>
<td className="p-2.5">
{row.status}
{row.cancelAtPeriodEnd ? ` · ${t('admin.billing.canceling')}` : ''}
{!row.hasStripeSub ? ` · ${t('admin.billing.manualTier')}` : ''}
</td>
<td className="p-2.5 text-muted-foreground">
{row.trialEndsAt
? t('admin.billing.trialUntil', { date: formatStableUtc(row.trialEndsAt) })
: row.currentPeriodEnd
? formatStableUtc(row.currentPeriodEnd)
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
{/* How to test Stripe */}
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<button
type="button"
onClick={() => setShowTestGuide((v) => !v)}
className="w-full flex items-center gap-3 p-6 border-b border-border text-start hover:bg-muted/20 transition-colors"
>
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<BookOpen className="h-5 w-5" />
</div>
<div className="flex-1">
<h2 className="font-semibold">{t('admin.billing.testGuideTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.billing.testGuideDescription')}</p>
</div>
{showTestGuide ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
</button>
{showTestGuide && (
<div className="p-6 space-y-3 text-sm text-muted-foreground leading-relaxed">
<ol className="list-decimal ps-5 space-y-2">
<li>{t('admin.billing.testStep1')}</li>
<li>{t('admin.billing.testStep2')}</li>
<li>
<code className="text-[11px] bg-muted px-1.5 py-0.5 rounded">stripe listen --forward-to localhost:3000/api/billing/webhook</code>
{' — '}{t('admin.billing.testStep3')}
</li>
<li>{t('admin.billing.testStep4')}</li>
<li>{t('admin.billing.testStep5')}</li>
<li>{t('admin.billing.testStep6')}</li>
</ol>
<p className="text-xs border-t border-border/50 pt-3">
{t('admin.billing.testCardHint')}
</p>
</div>
)}
</div>
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
@@ -98,7 +406,7 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
<p className="text-sm text-muted-foreground">{t('admin.billing.stripeConfigDescription')}</p>
</div>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleSaveBilling(new FormData(e.currentTarget)) }} className="p-6 space-y-4">
<form onSubmit={(e) => { e.preventDefault(); handleSaveBilling(new FormData(e.currentTarget)) }} className="p-6 space-y-6">
<div className="flex items-center space-x-2">
<Checkbox
id="BILLING_ENABLED"
@@ -107,18 +415,52 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
/>
<Label htmlFor="BILLING_ENABLED">{t('admin.billing.enableBilling')}</Label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{(['STRIPE_PRICE_PRO_MONTHLY', 'STRIPE_PRICE_PRO_ANNUAL', 'STRIPE_PRICE_BUSINESS_MONTHLY', 'STRIPE_PRICE_BUSINESS_ANNUAL'] as const).map((key) => (
<div key={key} className="space-y-2">
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
<Input
id={key}
name={key}
defaultValue={initialData.billingConfig[key] ?? ''}
placeholder="price_..."
/>
</div>
))}
<div>
<h3 className="text-sm font-medium mb-3">{t('admin.billing.subscriptionPricesTitle')}</h3>
<div className="grid gap-4 sm:grid-cols-2">
{SUBSCRIPTION_PRICE_KEYS.map((key) => {
const meta = priceMap[key]
return (
<div key={key} className="space-y-2">
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
<Input
id={key}
name={key}
defaultValue={initialData.billingConfig[key] ?? ''}
placeholder="price_..."
/>
{meta?.stripeAmount != null && (
<p className="text-[11px] text-muted-foreground">
Stripe: {meta.stripeAmount.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} {meta.stripeCurrency}
</p>
)}
{meta?.error && (
<p className="text-[11px] text-rose-600">{meta.error}</p>
)}
</div>
)
})}
</div>
</div>
<div className="border-t border-border/50 pt-4">
<div className="flex items-center gap-2 mb-1">
<Package className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">{t('admin.billing.packPricesTitle')}</h3>
</div>
<p className="text-xs text-muted-foreground mb-3">{t('admin.billing.packPricesDescription')}</p>
<div className="grid gap-4 sm:grid-cols-3">
{PACK_PRICE_KEYS.map((key) => (
<div key={key} className="space-y-2">
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
<Input
id={key}
name={key}
defaultValue={initialData.billingConfig[key] ?? ''}
placeholder="price_..."
/>
</div>
))}
</div>
</div>
<p className="text-xs text-muted-foreground">{t('admin.billing.secretsNote')}</p>
<Button type="submit" disabled={isSavingBilling}>{t('admin.billing.saveConfig')}</Button>
@@ -128,60 +470,60 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<Settings2 className="h-5 w-5" />
<Coins className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold">{t('admin.billing.limitsTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.billing.limitsDescription')}</p>
<h2 className="font-semibold">{t('admin.billing.creditsTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.billing.creditsDescription')}</p>
</div>
</div>
<div className="p-6 space-y-4">
<div className="flex flex-wrap gap-2">
{initialData.tiers.map((tier) => (
<button
key={tier}
type="button"
onClick={() => setActiveTier(tier)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
activeTier === tier
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:bg-muted'
}`}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{(initialData.creditAllocations ?? []).map((row) => (
<div
key={row.tier}
className="rounded-xl border border-border/60 bg-muted/30 p-4 space-y-1"
>
{tier}
</button>
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{row.tier}
</p>
<p className="text-2xl font-semibold tabular-nums">
{row.unlimited
? t('admin.billing.modeUnlimited')
: row.monthlyCredits?.toLocaleString('fr-FR')}
</p>
{!row.unlimited && (
<p className="text-[11px] text-muted-foreground">
{t('admin.billing.creditsPerMonth')}
</p>
)}
</div>
))}
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4">{t('admin.billing.feature')}</th>
<th className="py-2 pr-4">{t('admin.billing.mode')}</th>
<th className="py-2 pr-4">{t('admin.billing.monthlyLimit')}</th>
<th className="py-2">{t('admin.billing.actions')}</th>
</tr>
</thead>
<tbody>
{initialData.features.map((feature) => {
const row = getEntitlement(initialData.entitlements, activeTier, feature)
const mode = row?.mode ?? 'unavailable'
const cellKey = `${activeTier}:${feature}`
return (
<EntitlementRowEditor
key={`${activeTier}:${feature}`}
feature={feature}
mode={mode}
limitValue={row?.limitValue ?? null}
isSaving={savingCell === cellKey}
onSave={handleEntitlementChange}
t={t}
/>
)
})}
</tbody>
</table>
</div>
{(initialData.creditPacks?.length ?? 0) > 0 && (
<div className="border-t border-border/40 pt-3">
<p className="text-xs font-medium mb-2">{t('admin.billing.packsCatalogTitle')}</p>
<div className="flex flex-wrap gap-2">
{(initialData.creditPacks ?? []).map((pack) => (
<span
key={pack.id}
className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-muted/20 px-3 py-1.5 text-xs"
>
<span className="font-semibold uppercase">{pack.id}</span>
<span className="text-muted-foreground">
{pack.credits.toLocaleString('fr-FR')} cr. · {pack.defaultDisplay}
</span>
</span>
))}
</div>
</div>
)}
<p className="text-xs text-muted-foreground leading-relaxed border-t border-border/40 pt-3">
{t('admin.billing.creditsCostsNote')}
</p>
<p className="text-xs text-muted-foreground">
{t('admin.billing.creditsResetHint')}
</p>
</div>
</div>
@@ -195,7 +537,9 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
<p className="text-sm text-muted-foreground">
{t('admin.billing.usagePeriod', { period: initialData.usageOverview.period })}
{initialData.usageOverview.lastSyncedAt
? ` · ${t('admin.billing.lastSync', { date: new Date(initialData.usageOverview.lastSyncedAt).toLocaleString() })}`
? ` · ${t('admin.billing.lastSync', {
date: formatStableUtc(initialData.usageOverview.lastSyncedAt),
})}`
: ` · ${t('admin.billing.notSynced')}`}
</p>
</div>
@@ -236,68 +580,3 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
</div>
)
}
function EntitlementRowEditor({
feature,
mode: initialMode,
limitValue: initialLimit,
isSaving,
onSave,
t,
}: {
feature: string
mode: 'unavailable' | 'unlimited' | 'limited'
limitValue: number | null
isSaving: boolean
onSave: (feature: string, mode: 'unavailable' | 'unlimited' | 'limited', limit?: number) => Promise<void>
t: (key: string, params?: Record<string, string | number>) => string
}) {
const [mode, setMode] = useState(initialMode)
const [limit, setLimit] = useState(initialLimit != null ? String(initialLimit) : '50')
useEffect(() => {
setMode(initialMode)
setLimit(initialLimit != null ? String(initialLimit) : '50')
}, [initialMode, initialLimit])
return (
<tr className="border-b border-border/30">
<td className="py-3 pr-4 font-mono text-xs">{feature}</td>
<td className="py-3 pr-4">
<select
value={mode}
onChange={(e) => setMode(e.target.value as typeof mode)}
className="h-9 rounded-md border border-input bg-background px-2 text-xs"
>
<option value="unavailable">{t('admin.billing.modeUnavailable')}</option>
<option value="limited">{t('admin.billing.modeLimited')}</option>
<option value="unlimited">{t('admin.billing.modeUnlimited')}</option>
</select>
</td>
<td className="py-3 pr-4">
{mode === 'limited' ? (
<Input
type="number"
min={0}
value={limit}
onChange={(e) => setLimit(e.target.value)}
className="h-9 w-24 text-xs"
/>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</td>
<td className="py-3">
<Button
type="button"
size="sm"
variant="outline"
disabled={isSaving}
onClick={() => onSave(feature, mode, mode === 'limited' ? parseInt(limit, 10) : undefined)}
>
{isSaving ? '…' : t('admin.billing.saveLimit')}
</Button>
</td>
</tr>
)
}

View File

@@ -1,58 +1,184 @@
import { getUsers } from '@/app/actions/admin'
import { AdminMetrics } from '@/components/admin-metrics'
import { Users, Activity, Database, Zap } from 'lucide-react'
import { Users, Activity, Database, Zap, CreditCard, Bot } from 'lucide-react'
import prisma from '@/lib/prisma'
import Link from 'next/link'
async function getAdminDashboardStats() {
const now = new Date()
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000)
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
const [
userCount,
noteCount,
notebookCount,
agentsEnabled,
agentRunsToday,
paidSubs,
recentUsers,
] = await Promise.all([
prisma.user.count().catch(() => 0),
prisma.note.count({ where: { trashedAt: null } }).catch(() => 0),
prisma.notebook.count().catch(() => 0),
prisma.agent.count({ where: { isEnabled: true } }).catch(() => 0),
prisma.agentAction.count({ where: { createdAt: { gte: dayAgo } } }).catch(() => 0),
prisma.subscription.count({
where: { tier: { in: ['PRO', 'BUSINESS', 'ENTERPRISE'] }, status: 'ACTIVE' },
}).catch(() => 0),
prisma.user.findMany({
orderBy: { createdAt: 'desc' },
take: 8,
select: { id: true, name: true, email: true, createdAt: true, role: true },
}).catch(() => []),
])
// AI usage this month
let aiRequestsMonth = 0
try {
aiRequestsMonth = await prisma.usageLog.aggregate({
where: { periodStart: { gte: monthStart } },
_sum: { requestsCount: true },
}).then((r) => r._sum.requestsCount ?? 0)
} catch {
try {
aiRequestsMonth = await prisma.agentAction.count({
where: { createdAt: { gte: monthStart } },
})
} catch {
aiRequestsMonth = 0
}
}
return {
userCount,
noteCount,
notebookCount,
agentsEnabled,
agentRunsToday,
paidSubs,
aiRequestsMonth,
recentUsers,
}
}
export default async function AdminPage() {
const users = await getUsers()
const [users, stats] = await Promise.all([getUsers(), getAdminDashboardStats()])
// Mock metrics data - in a real app, these would come from analytics
const metrics = [
{
title: 'Total Users',
value: users.length,
trend: { value: 12, isPositive: true },
title: 'Utilisateurs',
value: stats.userCount,
trend: undefined as { value: number; isPositive: boolean } | undefined,
icon: <Users className="h-5 w-5 text-primary dark:text-primary-foreground" />,
},
{
title: 'Active Sessions',
value: '24',
trend: { value: 8, isPositive: true },
icon: <Activity className="h-5 w-5 text-green-600 dark:text-green-400" />,
},
{
title: 'Total Notes',
value: '1,234',
trend: { value: 24, isPositive: true },
title: 'Notes',
value: stats.noteCount.toLocaleString('fr-FR'),
icon: <Database className="h-5 w-5 text-purple-600 dark:text-purple-400" />,
},
{
title: 'AI Requests',
value: '856',
trend: { value: 5, isPositive: false },
title: 'Abonnements payants',
value: stats.paidSubs,
icon: <CreditCard className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />,
},
{
title: 'Agents actifs',
value: stats.agentsEnabled,
icon: <Bot className="h-5 w-5 text-blue-600 dark:text-blue-400" />,
},
{
title: 'Exécutions agents (24h)',
value: stats.agentRunsToday,
icon: <Activity className="h-5 w-5 text-green-600 dark:text-green-400" />,
},
{
title: 'Usage IA (mois)',
value: stats.aiRequestsMonth.toLocaleString('fr-FR'),
icon: <Zap className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />,
},
]
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Dashboard
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Overview of your application metrics
</p>
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3">
<div>
<h1 className="text-3xl font-bold text-foreground">
Administration
</h1>
<p className="text-muted-foreground mt-1">
Vue d&apos;ensemble en temps réel {stats.notebookCount} carnets · {users.length} comptes listés
</p>
</div>
<div className="flex flex-wrap gap-2">
<Link
href="/admin/billing"
className="text-xs font-medium px-3 py-2 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
>
Facturation & quotas
</Link>
<Link
href="/admin/ai"
className="text-xs font-medium px-3 py-2 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
>
Config IA
</Link>
<Link
href="/admin/users"
className="text-xs font-medium px-3 py-2 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
>
Utilisateurs
</Link>
<Link
href="/admin/settings"
className="text-xs font-medium px-3 py-2 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
>
Paramètres
</Link>
</div>
</div>
<AdminMetrics metrics={metrics} />
<div className="bg-card rounded-lg shadow-sm overflow-hidden border border-border p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Recent Activity
</h2>
<p className="text-gray-600 dark:text-gray-400">
Recent activity will be displayed here.
</p>
<div className="grid md:grid-cols-2 gap-6">
<div className="bg-card rounded-lg shadow-sm overflow-hidden border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-4">
Derniers utilisateurs
</h2>
{stats.recentUsers.length === 0 ? (
<p className="text-muted-foreground text-sm">Aucun utilisateur.</p>
) : (
<ul className="divide-y divide-border">
{stats.recentUsers.map((u) => (
<li key={u.id} className="py-2.5 flex items-center justify-between gap-3 text-sm">
<div className="min-w-0">
<p className="font-medium text-foreground truncate">{u.name || u.email}</p>
<p className="text-xs text-muted-foreground truncate">{u.email}</p>
</div>
<div className="text-right shrink-0">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">{u.role}</span>
<p className="text-[10px] text-muted-foreground">
{u.createdAt ? new Date(u.createdAt).toLocaleDateString('fr-FR') : '—'}
</p>
</div>
</li>
))}
</ul>
)}
</div>
<div className="bg-card rounded-lg shadow-sm overflow-hidden border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-4">
Raccourcis ops
</h2>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>· Configurer les Price IDs Stripe dans <Link href="/admin/billing" className="text-primary underline">Facturation</Link></li>
<li>· Vérifier les quotas IA par feature (BASIC / PRO / BUSINESS)</li>
<li>· Tester les providers dans <Link href="/admin/ai" className="text-primary underline">IA</Link></li>
<li>· CRON_SECRET requis pour agents planifiés (entrypoint Docker)</li>
<li>· Notes indexées via embeddings + fragments (y compris notes courtes)</li>
</ul>
</div>
</div>
</div>
)

View File

@@ -139,11 +139,11 @@ const SUGGESTED_EMBEDDINGS: Record<string, string[]> = {
zai: ['text-embedding-3-small', 'text-embedding-3-large'],
}
type ModelPurpose = 'tags' | 'embeddings' | 'chat'
type ModelPurpose = 'tags' | 'embeddings' | 'chat' | 'slides'
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
const { t } = useLanguage()
const [activeAiTab, setActiveAiTab] = useState<'tags' | 'embeddings' | 'chat'>('tags')
const [activeAiTab, setActiveAiTab] = useState<'tags' | 'embeddings' | 'chat' | 'slides'>('tags')
const [isSaving, setIsSaving] = useState(false)
const [isTesting, setIsTesting] = useState(false)
@@ -168,11 +168,15 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
return PROVIDERS_WITHOUT_EMBEDDINGS.includes(v) ? 'ollama' : v
})
const [chatProvider, setChatProvider] = useState<AIProvider>((config.AI_PROVIDER_CHAT as AIProvider) || 'ollama')
const [slidesProvider, setSlidesProvider] = useState<AIProvider>(
(config.AI_PROVIDER_SLIDES as AIProvider) || (config.AI_PROVIDER_CHAT as AIProvider) || 'ollama',
)
// Selected Models State
const [selectedTagsModel, setSelectedTagsModel] = useState<string>(config.AI_MODEL_TAGS || '')
const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState<string>(config.AI_MODEL_EMBEDDING || '')
const [selectedChatModel, setSelectedChatModel] = useState<string>(config.AI_MODEL_CHAT || '')
const [selectedSlidesModel, setSelectedSlidesModel] = useState<string>(config.AI_MODEL_SLIDES || '')
const [tagsFallbackProvider, setTagsFallbackProvider] = useState<string>(config.AI_PROVIDER_TAGS_FALLBACK || '')
const [embedFallbackProvider, setEmbedFallbackProvider] = useState<string>(config.AI_PROVIDER_EMBEDDING_FALLBACK || '')
@@ -186,11 +190,13 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
tags: [],
embeddings: [],
chat: [],
slides: [],
})
const [isLoadingModels, setIsLoadingModels] = useState<Record<ModelPurpose, boolean>>({
tags: false,
embeddings: false,
chat: false,
slides: false,
})
// Fetch models from local providers (Ollama, LM Studio) or cloud providers with /v1/models
@@ -268,6 +274,16 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
const key = config[API_KEY_CONFIG[chatProvider]] || ''
if (url && key) await fetchModels('chat', chatProvider, url, key)
}
if (slidesProvider === 'ollama') {
await fetchModels('slides', 'ollama', config.OLLAMA_BASE_URL_CHAT || config.OLLAMA_BASE_URL || 'http://localhost:11434')
} else if (slidesProvider === 'lmstudio') {
await fetchModels('slides', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
} else if (PROVIDER_META[slidesProvider]?.hasApiKey && slidesProvider !== 'anthropic_custom') {
const url = DEFAULT_BASE_URLS[slidesProvider]
const key = config[API_KEY_CONFIG[slidesProvider]] || ''
if (url && key) await fetchModels('slides', slidesProvider, url, key)
}
}
fetchInitial()
@@ -408,6 +424,32 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
}
}
// Slides provider (dedicated model for deck generation — never auto-renamed)
const slidesProv = formData.get('AI_PROVIDER_SLIDES') as string
data.AI_PROVIDER_SLIDES = slidesProv?.trim() ?? ''
const slidesModel = formData.get('AI_MODEL_SLIDES') as string
data.AI_MODEL_SLIDES = slidesModel?.trim() ?? ''
if (slidesProv) {
if (slidesProv === 'ollama') {
const ollamaUrl = formData.get('BASE_URL_ollama_slides') as string
if (ollamaUrl) data.OLLAMA_BASE_URL_CHAT = ollamaUrl
} else if (slidesProv === 'lmstudio') {
const url = formData.get('BASE_URL_lmstudio_slides') as string
if (url) data.LMSTUDIO_BASE_URL = url
} else {
const apiKeyConfigKey = API_KEY_CONFIG[slidesProv as AIProvider]
if (apiKeyConfigKey) {
const apiKey = formData.get(`API_KEY_${slidesProv}_slides`) as string
if (apiKey) data[apiKeyConfigKey] = apiKey
}
const baseUrlConfigKey = BASE_URL_CONFIG[slidesProv as AIProvider]
if (baseUrlConfigKey) {
const baseUrl = formData.get(`BASE_URL_${slidesProv}_slides`) as string
if (baseUrl) data[baseUrlConfigKey] = baseUrl
}
}
}
const result = await updateSystemConfig(data)
setIsSaving(false)
@@ -740,6 +782,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
<button type="button" onClick={() => setActiveAiTab('tags')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'tags' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>🏷 Tags</button>
<button type="button" onClick={() => setActiveAiTab('embeddings')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'embeddings' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>🔍 Embeddings</button>
<button type="button" onClick={() => setActiveAiTab('chat')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'chat' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>💬 Chat</button>
<button type="button" onClick={() => setActiveAiTab('slides')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'slides' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>📑 {t('admin.ai.slidesTab')}</button>
</div>
</div>
<div className="p-6 space-y-6">
@@ -945,6 +988,44 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
/>
</div>
</div>
{/* Slides Provider — dedicated model for deck generation */}
<div className={`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 ${activeAiTab === 'slides' ? 'block' : 'hidden'}`}>
<h3 className="text-base font-semibold flex items-center gap-2">
<span>📑</span> {t('admin.ai.slidesProvider')}
</h3>
<p className="text-xs text-muted-foreground">
{t('admin.ai.slidesDescription')}
</p>
<div className="space-y-2">
<Label htmlFor="AI_PROVIDER_SLIDES">{t('admin.ai.provider')}</Label>
<select
id="AI_PROVIDER_SLIDES"
name="AI_PROVIDER_SLIDES"
value={slidesProvider}
onChange={(e) => {
setSlidesProvider(e.target.value as AIProvider)
setSelectedSlidesModel('')
setDynamicModels((prev) => ({ ...prev, slides: [] }))
}}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{providerOptions.map((opt) => (
<option key={`slides-${opt.value}`} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<input type="hidden" name="AI_MODEL_SLIDES" value={selectedSlidesModel} />
{renderProviderConfig(slidesProvider, 'slides', selectedSlidesModel, setSelectedSlidesModel)}
<p className="text-xs text-muted-foreground border-t border-border/40 pt-3">
{t('admin.ai.slidesQuotaNote')}
</p>
</div>
</div>
<div className="px-6 pb-6 flex flex-col sm:flex-row gap-3 sm:justify-between pt-6">
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>

View File

@@ -3,9 +3,9 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { Button } from '@/components/ui/button'
import { deleteUser, updateUserRole, updateUserSubscription } from '@/app/actions/admin'
import { deleteUser, updateUserRole, updateUserSubscription, resetUserQuotas } from '@/app/actions/admin'
import { toast } from 'sonner'
import { Trash2, Shield, ShieldOff, Crown, ChevronDown } from 'lucide-react'
import { Trash2, Shield, ShieldOff, Crown, ChevronDown, RotateCcw } from 'lucide-react'
import { format } from 'date-fns'
import { useLanguage } from '@/lib/i18n'
@@ -91,6 +91,7 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
const { t } = useLanguage()
const [users, setUsers] = useState(initialUsers)
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
const [resettingId, setResettingId] = useState<string | null>(null)
const handleDelete = async (id: string) => {
if (!confirm(t('admin.users.confirmDelete'))) return
@@ -103,6 +104,27 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
}
}
const handleResetQuotas = async (user: { id: string; email?: string | null; name?: string | null }) => {
const label = user.email || user.name || user.id
if (!confirm(t('admin.users.confirmResetQuotas', { user: label }))) return
setResettingId(user.id)
try {
const result = await resetUserQuotas(user.id)
toast.success(
t('admin.users.resetQuotasSuccess', {
period: result.period,
count: result.featuresReset.length,
}),
)
} catch (e) {
toast.error(
e instanceof Error ? e.message : t('admin.users.resetQuotasFailed'),
)
} finally {
setResettingId(null)
}
}
const handleRoleToggle = async (user: any) => {
const newRole = user.role === 'ADMIN' ? 'USER' : 'ADMIN'
try {
@@ -163,6 +185,16 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
<td className="p-4 align-middle">{format(new Date(user.createdAt), 'PP')}</td>
<td className="p-4 align-middle text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleResetQuotas(user)}
disabled={resettingId === user.id}
title={t('admin.users.resetQuotas')}
className="text-amber-700 hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-200"
>
<RotateCcw className={`h-4 w-4 ${resettingId === user.id ? 'animate-spin' : ''}`} />
</Button>
<Button
variant="ghost"
size="sm"

View File

@@ -0,0 +1,92 @@
'use client'
import { Suspense, useState } from 'react'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { Mail, ArrowLeft, Sparkles } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { resendSignupVerification } from '@/app/actions/auth-verify'
import { toast } from 'sonner'
function CheckEmailContent() {
const { t, language } = useLanguage()
const searchParams = useSearchParams()
const initialEmail = searchParams.get('email') ?? ''
const [email, setEmail] = useState(initialEmail)
const [sending, setSending] = useState(false)
const handleResend = async () => {
const target = email.trim()
if (!target) {
toast.error(t('auth.verifyMissingEmail'))
return
}
setSending(true)
const result = await resendSignupVerification(target, language)
setSending(false)
if (result.success) {
toast.success(t('auth.verifyResent'))
} else {
toast.error(t('auth.verifyResendFailed'))
}
}
return (
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl">
<div className="space-y-8 text-center">
<div className="w-14 h-14 mx-auto rounded-2xl bg-[var(--color-brand-accent)]/10 flex items-center justify-center">
<Mail size={28} className="text-[var(--color-brand-accent)]" />
</div>
<div className="space-y-2">
<h1 className="text-2xl md:text-3xl font-serif font-bold">
{t('auth.checkEmailTitle')}
</h1>
<p className="text-[var(--muted-foreground)] text-sm font-light leading-relaxed">
{initialEmail
? t('auth.checkEmailDescription', { email: initialEmail })
: t('auth.checkEmailDescriptionGeneric')}
</p>
</div>
<div className="space-y-1.5 text-start">
<label htmlFor="email" className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
{t('auth.email')}
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('auth.emailPlaceholder')}
className="w-full bg-slate-50 dark:bg-white/5 border border-[var(--border)] rounded-2xl py-4 px-4 text-sm outline-none focus:border-[var(--color-brand-accent)] focus:ring-4 ring-[var(--color-brand-accent)]/5 transition-all"
/>
</div>
<button
type="button"
onClick={handleResend}
disabled={sending}
className="w-full bg-[var(--foreground)] text-[var(--background)] py-4 rounded-2xl font-bold uppercase tracking-[0.2em] text-[10px] flex items-center justify-center gap-3 transition-all hover:shadow-xl hover:shadow-black/10 active:scale-[0.98] disabled:opacity-50"
>
{sending ? <Sparkles size={16} className="animate-spin" /> : t('auth.resendVerification')}
</button>
<Link
href="/login"
className="inline-flex items-center gap-2 text-xs text-[var(--muted-foreground)] hover:text-[var(--color-brand-accent)] transition-colors"
>
<ArrowLeft size={14} />
{t('auth.backToLogin')}
</Link>
</div>
</div>
)
}
export default function CheckEmailPage() {
return (
<Suspense fallback={<div className="p-10 text-center text-sm text-muted-foreground"></div>}>
<CheckEmailContent />
</Suspense>
)
}

View File

@@ -1,49 +1,22 @@
'use client';
import { headers } from 'next/headers';
import { detectUserLanguage, parseAcceptLanguage } from '@/lib/i18n/detect-user-language';
import { loadTranslations } from '@/lib/i18n/load-translations';
import { LanguageProvider } from '@/lib/i18n/LanguageProvider';
import Link from 'next/link';
import { Globe } from 'lucide-react';
import { AuthShell } from '@/components/auth-shell';
export default function AuthLayout({
export default async function AuthLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const headersList = await headers();
const browserLang = parseAcceptLanguage(headersList.get('accept-language'));
const initialLanguage = await detectUserLanguage(browserLang);
const initialTranslations = await loadTranslations(initialLanguage);
return (
<LanguageProvider>
<div className="min-h-screen bg-[#FDFCFB] dark:bg-[#0D0D0D] flex flex-col relative overflow-hidden">
<div className="absolute top-[-10%] right-[-10%] w-[50%] h-[50%] bg-[var(--color-brand-accent)]/5 blur-[120px] rounded-full pointer-events-none" />
<div className="absolute bottom-[-10%] left-[-10%] w-[50%] h-[50%] bg-[#D4A373]/5 blur-[120px] rounded-full pointer-events-none" />
<header className="p-6 md:p-8 flex justify-between items-center relative z-10">
<Link
href="/"
className="flex items-center gap-2 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors group"
>
<div className="w-8 h-8 rounded-full border border-[var(--border)] flex items-center justify-center group-hover:border-[var(--color-brand-accent)] transition-colors">
<Globe size={14} className="group-hover:rotate-12 transition-transform" />
</div>
</Link>
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-[var(--foreground)] text-[var(--background)] rounded-xl flex items-center justify-center shadow-lg">
<span className="font-serif font-bold text-xl">M</span>
</div>
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
</Link>
<div className="w-8" />
</header>
<main className="flex-1 flex items-center justify-center p-4 md:p-6 relative z-10">
<div className="w-full max-w-md">
{children}
<p className="text-center mt-8 text-[9px] text-[var(--muted-foreground)] font-bold uppercase tracking-[0.3em] opacity-40 select-none">
© 2025 Memento Labs
</p>
</div>
</main>
</div>
<LanguageProvider initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
<AuthShell>{children}</AuthShell>
</LanguageProvider>
);
}

View File

@@ -0,0 +1,97 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { CheckCircle2, AlertCircle, Sparkles } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { confirmEmail } from '@/app/actions/auth-verify'
function VerifyEmailContent() {
const { t } = useLanguage()
const searchParams = useSearchParams()
const token = searchParams.get('token')
const [status, setStatus] = useState<'loading' | 'ok' | 'invalid' | 'expired'>('loading')
useEffect(() => {
if (!token) {
setStatus('invalid')
return
}
let cancelled = false
;(async () => {
const result = await confirmEmail(token)
if (cancelled) return
if (result.success) setStatus('ok')
else setStatus(result.error === 'expired' ? 'expired' : 'invalid')
})()
return () => {
cancelled = true
}
}, [token])
if (status === 'loading') {
return (
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-4">
<Sparkles size={28} className="mx-auto animate-spin text-[var(--color-brand-accent)]" />
<p className="text-sm text-[var(--muted-foreground)]">{t('auth.verifyLoading')}</p>
</div>
)
}
if (status === 'ok') {
return (
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-6">
<div className="w-14 h-14 mx-auto rounded-2xl bg-emerald-500/10 flex items-center justify-center">
<CheckCircle2 size={28} className="text-emerald-600" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-serif font-bold">{t('auth.verifySuccessTitle')}</h1>
<p className="text-sm text-[var(--muted-foreground)]">{t('auth.verifySuccessDescription')}</p>
</div>
<Link href="/login?verified=1" className="block">
<button
type="button"
className="w-full bg-[var(--foreground)] text-[var(--background)] py-4 rounded-2xl font-bold uppercase tracking-[0.2em] text-[10px] transition-all hover:shadow-xl active:scale-[0.98]"
>
{t('auth.signIn')}
</button>
</Link>
</div>
)
}
return (
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-6">
<div className="w-14 h-14 mx-auto rounded-2xl bg-red-500/10 flex items-center justify-center">
<AlertCircle size={28} className="text-red-500" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-serif font-bold">
{status === 'expired' ? t('auth.verifyExpiredTitle') : t('auth.verifyInvalidTitle')}
</h1>
<p className="text-sm text-[var(--muted-foreground)]">
{status === 'expired'
? t('auth.verifyExpiredDescription')
: t('auth.verifyInvalidDescription')}
</p>
</div>
<Link href="/check-email" className="block">
<button
type="button"
className="w-full bg-[var(--foreground)] text-[var(--background)] py-4 rounded-2xl font-bold uppercase tracking-[0.2em] text-[10px] transition-all hover:shadow-xl active:scale-[0.98]"
>
{t('auth.resendVerification')}
</button>
</Link>
</div>
)
}
export default function VerifyEmailPage() {
return (
<Suspense fallback={<div className="p-10 text-center text-sm text-muted-foreground"></div>}>
<VerifyEmailContent />
</Suspense>
)
}

View File

@@ -1,44 +0,0 @@
import { Metadata } from 'next'
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import { ChatContainer } from '@/components/chat/chat-container'
import { getConversations } from '@/app/actions/chat-actions'
import { getSystemConfig } from '@/lib/config'
export const metadata: Metadata = {
title: 'Chat IA | Memento',
description: 'Discutez avec vos notes et vos agents IA',
}
export default async function ChatPage() {
const session = await auth()
if (!session?.user?.id) redirect('/login')
const userId = session.user.id
// Fetch initial data
const [conversations, notebooks, config] = await Promise.all([
getConversations(),
prisma.notebook.findMany({
where: { userId },
orderBy: { order: 'asc' }
}),
getSystemConfig(),
])
// Check if web search tools are configured
const webSearchAvailable = !!(
config.WEB_SEARCH_PROVIDER || config.BRAVE_SEARCH_API_KEY || config.SEARXNG_URL || config.JINA_API_KEY
)
return (
<div className="flex-1 flex flex-col h-full bg-white dark:bg-[#1a1c22]">
<ChatContainer
initialConversations={conversations}
notebooks={notebooks}
webSearchAvailable={webSearchAvailable}
/>
</div>
)
}

View File

@@ -0,0 +1,23 @@
import { notFound } from 'next/navigation'
import { PageView } from '@/components/interactive-page/page-view'
import { validateInteractivePage } from '@/lib/interactive-page'
import thermoFixture from '@/lib/interactive-page/fixtures/thermo-page.json'
/**
* Dev preview of PageView + thermo fixture (spec §8.3).
* Only available outside production.
*/
export default function InteractivePagePreviewPage() {
if (process.env.NODE_ENV === 'production') notFound()
const parsed = validateInteractivePage(thermoFixture)
if (!parsed.ok) {
return (
<div className="p-8 text-sm text-destructive">
Fixture invalide : {parsed.issues[0]?.message}
</div>
)
}
return <PageView page={parsed.page} demoMode="interactive" />
}

View File

@@ -1,17 +0,0 @@
'use client'
import dynamic from 'next/dynamic'
// D3 uses browser APIs — must be loaded client-side only
const NoteGraphView = dynamic(
() => import('@/components/note-graph-view').then(m => m.NoteGraphView),
{ ssr: false }
)
export default function GraphPage() {
return (
<div className="h-screen overflow-hidden">
<NoteGraphView />
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -35,13 +35,13 @@ export default async function MainLayout({
initialTranslations={initialTranslations}
initialAiProcessingConsent={aiSettings?.aiProcessingConsent === true}
>
{/* No top-bar header — sidebar-only navigation (architectural-grid design) */}
<div className="flex h-screen overflow-hidden bg-memento-desk dark:bg-background">
<Suspense fallback={<div className="hidden w-80 md:w-[26rem] 2xl:w-[30rem] shrink-0 md:block h-full self-stretch" />}>
{/* Fond via --background (suit .dark) — évite le flash beige/clair au changement de route */}
<div className="flex h-screen overflow-hidden bg-background">
<Suspense fallback={<div className="hidden w-80 md:w-[26rem] 2xl:w-[30rem] shrink-0 md:block h-full self-stretch bg-sidebar" />}>
<Sidebar user={session?.user} />
</Suspense>
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth bg-memento-paper dark:bg-background" id="main-content">
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth bg-background" id="main-content">
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:z-[9999] focus:top-2 focus:left-2 focus:bg-brand-accent focus:text-white focus:px-4 focus:py-2 focus:rounded-lg focus:text-sm focus:font-bold">
Skip to content
</a>

View File

@@ -6,7 +6,7 @@ import { updateUserSettings } from '@/app/actions/user-settings'
import { useLanguage } from '@/lib/i18n'
import { toast } from 'sonner'
import { Palette, Type, LayoutGrid, Maximize } from 'lucide-react'
import { applyDocumentTheme, normalizeThemeId, type ThemeId } from '@/lib/apply-document-theme'
import { persistThemePreference, normalizeThemeId, type ThemeId } from '@/lib/apply-document-theme'
import {
NOTES_LAYOUT_STORAGE_KEY,
parseNotesLayoutMode,
@@ -65,8 +65,7 @@ export function AppearanceSettingsClient({
const handleThemeChange = async (value: string) => {
const next = normalizeThemeId(value)
setTheme(next)
localStorage.setItem('theme-preference', next)
applyDocumentTheme(next)
persistThemePreference(next)
await updateUserSettings({ theme: next })
toast.success(t('settings.settingsSaved'))
}

View File

@@ -5,7 +5,7 @@ import { BillingPlans } from '@/components/settings/billing-plans';
export const dynamic = 'force-dynamic';
export const metadata = {
title: 'Billing',
title: 'Facturation — Memento',
};
function Fallback() {

View File

@@ -1,11 +1,11 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useRef } from 'react'
import Link from 'next/link'
import { useLanguage } from '@/lib/i18n'
import { updateAISettings } from '@/app/actions/ai-settings'
import { toast } from 'sonner'
import { useRouter } from 'next/navigation'
import { useRouter, useSearchParams } from 'next/navigation'
import { Globe, Bell, Shield, Brain, HelpCircle } from 'lucide-react'
import { motion } from 'motion/react'
import { openCookiePreferences } from '@/lib/consent/cookie-consent'
@@ -27,6 +27,20 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
const { t, setLanguage: setContextLanguage } = useLanguage()
const { hasAiConsent, revokeConsent, requestAiConsent } = useAiConsent()
const router = useRouter()
const searchParams = useSearchParams()
const aiConsentSectionRef = useRef<HTMLDivElement>(null)
const highlightAiConsent = searchParams?.get('highlight') === 'aiConsent'
const [showConsentHighlight, setShowConsentHighlight] = useState(highlightAiConsent)
useEffect(() => {
if (highlightAiConsent && aiConsentSectionRef.current) {
setShowConsentHighlight(true)
aiConsentSectionRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
const timer = setTimeout(() => setShowConsentHighlight(false), 4000)
return () => clearTimeout(timer)
}
}, [highlightAiConsent])
const [language, setLanguage] = useState(initialSettings.preferredLanguage || 'auto')
const [emailNotifications, setEmailNotifications] = useState(initialSettings.emailNotifications ?? false)
const [desktopNotifications, setDesktopNotifications] = useState(initialSettings.desktopNotifications ?? false)
@@ -76,6 +90,12 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
{t('generalSettings.description')}
</h3>
{highlightAiConsent && (
<div className="rounded-xl border border-brand-accent/30 bg-brand-accent/10 p-4 text-sm font-medium text-ink">
{t('consent.ai.settingsBanner')}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6">
<div className="flex items-center gap-5">
@@ -202,7 +222,15 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
</div>
</div>
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6 md:col-span-2">
<div
ref={aiConsentSectionRef}
className={cn(
'bg-white/40 dark:bg-white/5 border rounded-xl p-8 space-y-6 md:col-span-2 transition-all duration-500',
showConsentHighlight
? 'border-brand-accent ring-2 ring-brand-accent/40 shadow-lg shadow-brand-accent/10'
: 'border-border'
)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-5">
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-concrete border border-border">

View File

@@ -1,11 +1,13 @@
'use client'
import { useState, useEffect } from 'react'
import { BookOpen, Loader2, Check, X, RefreshCw, Trash2, CalendarDays } from 'lucide-react'
import { BookOpen, Loader2, Check, X, RefreshCw, Trash2, CalendarDays, Mail } from 'lucide-react'
import { toast } from 'sonner'
import { SettingsHelpBox } from '@/components/settings/settings-help-box'
import { useLanguage } from '@/lib/i18n'
export default function IntegrationsPage() {
const { t } = useLanguage()
// ── Readwise ───────────────────────────────────────────────────────────
const [rwToken, setRwToken] = useState('')
const [rwConnected, setRwConnected] = useState(false)
@@ -19,32 +21,47 @@ export default function IntegrationsPage() {
const [calEvents, setCalEvents] = useState<any[]>([])
const [calFetching, setCalFetching] = useState(false)
// ── Gmail ────────────────────────────────────────────────────────────
const [gmailConnected, setGmailConnected] = useState(false)
const [gmailLoading, setGmailLoading] = useState(true)
const [gmailRecent, setGmailRecent] = useState(0)
const [gmailScanning, setGmailScanning] = useState(false)
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
fetch('/api/integrations/readwise').then((r) => r.json()),
fetch('/api/integrations/calendar').then((r) => r.json()),
]).then(([rw, cal]) => {
fetch('/api/integrations/gmail').then((r) => r.json()),
]).then(([rw, cal, gmail]) => {
setRwConnected(rw.connected)
setCalConnected(cal.connected)
setGmailConnected(gmail.connected)
setGmailRecent(gmail.recentCaptures ?? 0)
}).finally(() => {
setLoading(false)
setCalLoading(false)
setGmailLoading(false)
})
// Handle redirect params
const params = new URLSearchParams(window.location.search)
if (params.get('connected') === 'calendar') {
setCalConnected(true)
toast.success('Google Calendar connecté !')
toast.success(t('integrations.calendarConnected'))
window.history.replaceState({}, '', '/settings/integrations')
}
if (params.get('connected') === 'gmail') {
setGmailConnected(true)
toast.success(t('integrations.gmail.connected'))
window.history.replaceState({}, '', '/settings/integrations')
}
if (params.get('error')) {
toast.error(`Erreur: ${params.get('error')}`)
toast.error(t('integrations.errorWith', { error: params.get('error') || '' }))
window.history.replaceState({}, '', '/settings/integrations')
}
}, [])
}, [t])
// ── Readwise handlers ──────────────────────────────────────────────────
const handleRwConnect = async () => {
@@ -57,12 +74,12 @@ export default function IntegrationsPage() {
body: JSON.stringify({ token: rwToken.trim() }),
})
const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur Readwise'); return }
if (!res.ok) { toast.error(data.error || t('integrations.readwiseError')); return }
setRwConnected(true)
setRwToken('')
setRwLastSync({ created: data.created, updated: data.updated })
toast.success(`Readwise connecté — ${data.created} notes créées, ${data.updated} mises à jour`)
} catch { toast.error('Erreur de connexion Readwise') } finally { setRwConnecting(false) }
toast.success(t('integrations.readwiseConnected', { created: data.created, updated: data.updated }))
} catch { toast.error(t('integrations.readwiseConnectError')) } finally { setRwConnecting(false) }
}
const handleRwSync = async () => {
@@ -74,16 +91,16 @@ export default function IntegrationsPage() {
body: JSON.stringify({}),
})
const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur de sync'); return }
if (!res.ok) { toast.error(data.error || t('integrations.syncError')); return }
setRwLastSync({ created: data.created, updated: data.updated })
toast.success(`Sync Readwise — ${data.created} créées, ${data.updated} mises à jour`)
} catch { toast.error('Erreur de synchronisation') } finally { setRwSyncing(false) }
toast.success(t('integrations.readwiseSynced', { created: data.created, updated: data.updated }))
} catch { toast.error(t('integrations.syncFailed')) } finally { setRwSyncing(false) }
}
const handleRwDisconnect = async () => {
await fetch('/api/integrations/readwise', { method: 'DELETE' })
setRwConnected(false)
toast.success('Readwiseconnecté')
toast.success(t('integrations.readwiseDisconnected'))
}
// ── Calendar handlers ──────────────────────────────────────────────────
@@ -96,10 +113,10 @@ export default function IntegrationsPage() {
try {
const res = await fetch('/api/integrations/calendar?events=1')
const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur'); return }
if (!res.ok) { toast.error(data.error || t('common.error')); return }
setCalEvents(data.events ?? [])
if (data.events.length === 0) toast.info('Aucun événement aujourd\'hui')
} catch { toast.error('Erreur de chargement des événements') } finally { setCalFetching(false) }
if (data.events.length === 0) toast.info(t('integrations.noEvents'))
} catch { toast.error(t('integrations.eventsLoadError')) } finally { setCalFetching(false) }
}
const handleCreateMeetingNote = async (event: any) => {
@@ -110,11 +127,11 @@ export default function IntegrationsPage() {
})
const data = await res.json()
if (data.success) {
toast.success(`Note de réunion créée : ${event.summary}`, {
action: { label: 'Ouvrir', onClick: () => window.location.href = `/home?openNote=${data.note.id}` },
toast.success(t('integrations.meetingNoteCreated', { summary: event.summary }), {
action: { label: t('integrations.open'), onClick: () => window.location.href = `/home?openNote=${data.note.id}` },
})
} else {
toast.error('Erreur lors de la création de la note')
toast.error(t('integrations.createNoteError'))
}
}
@@ -122,25 +139,114 @@ export default function IntegrationsPage() {
await fetch('/api/integrations/calendar', { method: 'DELETE' })
setCalConnected(false)
setCalEvents([])
toast.success('Google Calendarconnecté')
toast.success(t('integrations.calendarDisconnected'))
}
// ── Gmail handlers ─────────────────────────────────────────────────────
const handleGmailConnect = () => {
window.location.href = '/api/integrations/gmail?connect=1'
}
const handleGmailScan = async () => {
setGmailScanning(true)
try {
const res = await fetch('/api/integrations/gmail?scan=1')
const data = await res.json()
if (!res.ok) { toast.error(data.error || t('common.error')); return }
setGmailRecent(prev => prev + (data.created ?? 0))
toast.success(t('integrations.gmail.scanDone', { created: data.created ?? 0 }))
} catch {
toast.error(t('integrations.gmailScanError'))
} finally {
setGmailScanning(false)
}
}
const handleGmailDisconnect = async () => {
await fetch('/api/integrations/gmail', { method: 'DELETE' })
setGmailConnected(false)
setGmailRecent(0)
toast.success(t('integrations.gmail.disconnected'))
}
const StatusBadge = ({ connected }: { connected: boolean }) =>
connected ? (
<span className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-200 dark:border-emerald-800/50 rounded-full px-2.5 py-1">
<Check size={11} /> Connecté
<Check size={11} /> {t('integrations.connected')}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] font-semibold text-concrete bg-border/20 border border-border/40 rounded-full px-2.5 py-1">
<X size={11} /> Non connecté
<X size={11} /> {t('integrations.notConnected')}
</span>
)
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-serif font-medium text-ink italic tracking-tight">Intégrations</h2>
<p className="text-sm text-concrete mt-1">Connectez des services externes à Memento.</p>
<h2 className="text-2xl font-serif font-medium text-ink italic tracking-tight">{t('settings.integrationsTitle')}</h2>
<p className="text-sm text-concrete mt-1">{t('settings.integrationsDesc')}</p>
</div>
{/* ── Gmail ──────────────────────────────────────────────────────── */}
<div className="border border-border/40 rounded-2xl p-6 bg-paper space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<Mail size={20} className="text-red-600 dark:text-red-400" />
</div>
<div>
<h3 className="font-semibold text-ink text-sm">{t('integrations.gmail.title')}</h3>
<p className="text-[12px] text-concrete">{t('integrations.gmail.description')}</p>
</div>
</div>
{gmailLoading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={gmailConnected} />}
</div>
{!gmailConnected ? (
<div className="space-y-3">
<SettingsHelpBox
title={t('integrations.gmail.helpTitle')}
steps={[
{ text: t('integrations.gmail.helpStep1') },
{ text: t('integrations.gmail.helpStep2') },
{ text: t('integrations.gmail.helpStep3') },
{ text: t('integrations.gmail.helpStep4') },
]}
/>
<button
onClick={handleGmailConnect}
className="px-4 py-2 text-sm font-semibold bg-ink text-paper rounded-xl hover:bg-ink/80 transition-all flex items-center gap-2"
>
<Mail size={14} />
{t('integrations.gmail.connect')}
</button>
</div>
) : (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<button
onClick={handleGmailScan}
disabled={gmailScanning}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold border border-border/40 rounded-xl hover:bg-ink/5 transition-all disabled:opacity-50"
>
{gmailScanning ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
{t('integrations.gmail.scanNow')}
</button>
<button
onClick={handleGmailDisconnect}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/40 rounded-xl hover:bg-rose-50 dark:hover:bg-rose-950/20 transition-all"
>
<Trash2 size={14} />
{t('integrations.gmail.disconnect')}
</button>
</div>
{gmailRecent > 0 && (
<p className="text-[11px] text-concrete">
{t('integrations.gmail.recentCaptures', { count: gmailRecent })}
</p>
)}
</div>
)}
</div>
{/* ── Google Calendar ────────────────────────────────────────────── */}
@@ -152,7 +258,7 @@ export default function IntegrationsPage() {
</div>
<div>
<h3 className="font-semibold text-ink text-sm">Google Calendar</h3>
<p className="text-[12px] text-concrete">Accédez à vos événements et créez des notes de réunion</p>
<p className="text-[12px] text-concrete">{t('integrations.calendarDesc')}</p>
</div>
</div>
{calLoading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={calConnected} />}
@@ -161,12 +267,12 @@ export default function IntegrationsPage() {
{!calConnected ? (
<div className="space-y-3">
<SettingsHelpBox
title="Comment fonctionne Google Calendar ?"
title={t('integrations.calendarInfo')}
steps={[
{ text: 'Cliquez "Connecter Google Calendar" — vous serez redirigé vers Google pour autoriser l\'accès.' },
{ text: 'Une fois connecté, revenez ici et cliquez "Événements aujourd\'hui" pour voir votre agenda.' },
{ text: 'Sur chaque événement, cliquez "+ Note" pour créer automatiquement une note de réunion avec template (Ordre du jour / Notes / Actions).' },
{ text: 'La note s\'ouvre directement dans Memento — ajoutez vos notes en temps réel pendant la réunion.' },
{ text: t('integrations.calendarHelpStep1') },
{ text: t('integrations.calendarHelpStep2') },
{ text: t('integrations.calendarHelpStep3') },
{ text: t('integrations.calendarHelpStep4') },
]}
/>
<button
@@ -174,7 +280,7 @@ export default function IntegrationsPage() {
className="px-4 py-2 text-sm font-semibold bg-ink text-paper rounded-xl hover:bg-ink/80 transition-all flex items-center gap-2"
>
<CalendarDays size={14} />
Connecter Google Calendar
{t('integrations.connectCalendar')}
</button>
</div>
) : (
@@ -186,14 +292,14 @@ export default function IntegrationsPage() {
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold border border-border/40 rounded-xl hover:bg-ink/5 transition-all disabled:opacity-50"
>
{calFetching ? <Loader2 size={14} className="animate-spin" /> : <CalendarDays size={14} />}
Événements aujourd&apos;hui
{t('integrations.todayEvents')}
</button>
<button
onClick={handleCalDisconnect}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/40 rounded-xl hover:bg-rose-50 dark:hover:bg-rose-950/20 transition-all"
>
<Trash2 size={14} />
Déconnecter
{t('integrations.disconnect')}
</button>
</div>
@@ -211,7 +317,7 @@ export default function IntegrationsPage() {
onClick={() => handleCreateMeetingNote(ev)}
className="shrink-0 text-[11px] font-semibold px-3 py-1.5 border border-border/40 rounded-full hover:bg-ink/5 transition-all"
>
+ Note
{t('integrations.addNote')}
</button>
</div>
))}
@@ -230,20 +336,20 @@ export default function IntegrationsPage() {
</div>
<div>
<h3 className="font-semibold text-ink text-sm">Readwise</h3>
<p className="text-[12px] text-concrete">Importez vos surlignages de livres, articles et Kindle</p>
<p className="text-[12px] text-concrete">{t('integrations.readwiseDesc')}</p>
</div>
</div>
{loading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={rwConnected} />}
</div>
<SettingsHelpBox
title="Comment fonctionne Readwise ?"
title={t('integrations.readwiseInfo')}
steps={[
{ text: 'Copiez votre token d\'accès Readwise.', link: { label: 'readwise.io/access_token', href: 'https://readwise.io/access_token' } },
{ text: 'Collez-le dans le champ ci-dessous et cliquez "Connecter". La première synchronisation importe tous vos livres et articles.' },
{ text: 'Chaque livre devient une note dans un carnet "Readwise 📚" — avec tous vos surlignages organisés.' },
{ text: 'Pour mettre à jour avec de nouveaux surlignages, revenez ici et cliquez "Synchroniser maintenant".' },
{ icon: '💡', text: 'Astuce : créez des flashcards IA depuis une note Readwise (bouton 🎓 dans l\'éditeur) pour réviser vos lectures.' },
{ text: t('integrations.readwiseHelpStep1'), link: { label: 'readwise.io/access_token', href: 'https://readwise.io/access_token' } },
{ text: t('integrations.readwiseHelpStep2') },
{ text: t('integrations.readwiseHelpStep3') },
{ text: t('integrations.readwiseHelpStep4') },
{ icon: '💡', text: t('integrations.readwiseHelpStep5') },
]}
/>
@@ -254,7 +360,7 @@ export default function IntegrationsPage() {
type="password"
value={rwToken}
onChange={(e) => setRwToken(e.target.value)}
placeholder="Token Readwise…"
placeholder={t('integrations.readwiseTokenPlaceholder')}
className="flex-1 text-sm border border-border/40 rounded-xl px-3 py-2 bg-paper text-ink placeholder:text-concrete/50 focus:outline-none focus:ring-1 focus:ring-brand-accent/40"
onKeyDown={(e) => e.key === 'Enter' && handleRwConnect()}
/>
@@ -264,7 +370,7 @@ export default function IntegrationsPage() {
className="px-4 py-2 text-sm font-semibold bg-ink text-paper rounded-xl hover:bg-ink/80 transition-all disabled:opacity-50 flex items-center gap-2"
>
{rwConnecting ? <Loader2 size={14} className="animate-spin" /> : null}
Connecter
{t('integrations.connect')}
</button>
</div>
</div>
@@ -278,28 +384,28 @@ export default function IntegrationsPage() {
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold border border-border/40 rounded-xl hover:bg-ink/5 transition-all disabled:opacity-50"
>
{rwSyncing ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
Synchroniser maintenant
{t('integrations.syncNow')}
</button>
<button
onClick={handleRwDisconnect}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/40 rounded-xl hover:bg-rose-50 dark:hover:bg-rose-950/20 transition-all"
>
<Trash2 size={14} />
Déconnecter
{t('integrations.disconnect')}
</button>
</div>
)}
{rwLastSync && (
<p className="text-[11px] text-concrete">
Dernière sync : <strong>{rwLastSync.created}</strong> notes créées, <strong>{rwLastSync.updated}</strong> mises à jour
{t('integrations.lastSync')} <strong>{rwLastSync.created}</strong> {t('integrations.createdLabel')}, <strong>{rwLastSync.updated}</strong> {t('integrations.updatedLabel')}
</p>
)}
</div>
{/* Placeholder */}
<div className="border border-dashed border-border/40 rounded-2xl p-6 text-center">
<p className="text-sm text-concrete italic">D'autres intégrations arrivent bientôt Zapier, GitHub, Notion import</p>
<p className="text-sm text-concrete italic">{t('integrations.moreComing')}</p>
</div>
</div>
)

View File

@@ -2,6 +2,7 @@
import { Menu } from 'lucide-react'
import { SettingsNav } from '@/components/settings'
import { SettingsDocumentTitle } from '@/components/settings/settings-document-title'
import { useLanguage } from '@/lib/i18n'
export default function SettingsLayout({
@@ -11,21 +12,22 @@ export default function SettingsLayout({
}) {
const { t } = useLanguage()
return (
<div className="flex flex-col h-full bg-[#F2F0E9] dark:bg-dark-paper">
<header className="px-4 sm:px-8 md:px-12 pt-8 sm:pt-14 md:pt-20 pb-6 sm:pb-10 md:pb-16 space-y-6 sm:space-y-10 md:space-y-12 shrink-0">
<div className="flex items-start gap-3">
<div className="flex flex-col h-full bg-background">
<SettingsDocumentTitle />
<header className="px-4 sm:px-8 md:px-12 pt-6 sm:pt-8 md:pt-10 pb-4 sm:pb-6 shrink-0">
<div className="flex items-start gap-3 mb-6">
<button
className="md:hidden p-2 -ms-1 text-ink/70 hover:bg-ink/5 rounded-lg transition-colors shrink-0 mt-1"
className="md:hidden p-2 -ms-1 text-ink dark:text-zinc-200 hover:bg-ink/5 dark:hover:bg-white/10 rounded-lg transition-colors shrink-0 mt-1"
onClick={() => window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
aria-label={t('settings.title')}
>
<Menu size={22} />
</button>
<div>
<h1 className="text-3xl sm:text-5xl md:text-[64px] font-serif text-ink tracking-tight leading-none italic font-medium">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-serif text-ink dark:text-zinc-50 tracking-tight leading-tight font-medium">
{t('settings.title')}
</h1>
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60 mt-4">
<p className="text-sm text-concrete dark:text-zinc-400 mt-1.5">
{t('settings.description')}
</p>
</div>

View File

@@ -0,0 +1,22 @@
'use client'
import { useLanguage } from '@/lib/i18n'
import { SettingsHelpBox } from '@/components/settings/settings-help-box'
export function McpSettingsHeader() {
const { t } = useLanguage()
return (
<SettingsHelpBox
title={t('mcpSettings.helpBox.title')}
defaultOpen={true}
steps={[
{ text: t('mcpSettings.helpBox.step1') },
{ text: t('mcpSettings.helpBox.step2') },
{ text: t('mcpSettings.helpBox.step3') },
{ text: t('mcpSettings.helpBox.step4'), link: { label: t('mcpSettings.helpBox.step4Link'), href: 'https://modelcontextprotocol.io/docs' } },
{ text: t('mcpSettings.helpBox.step5') },
]}
/>
)
}

View File

@@ -1,32 +1,23 @@
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { McpSettingsPanel } from '@/components/mcp/mcp-settings-panel'
import { listMcpKeys, getMcpServerStatus } from '@/app/actions/mcp-keys'
import { SettingsHelpBox } from '@/components/settings/settings-help-box'
import { listMcpKeys, getMcpServerStatus, getMcpAccessStatus } from '@/app/actions/mcp-keys'
import { McpSettingsHeader } from './mcp-settings-header'
export default async function McpSettingsPage() {
const session = await auth()
if (!session?.user) redirect('/api/auth/signin')
const [keys, serverStatus] = await Promise.all([
const [keys, serverStatus, access] = await Promise.all([
listMcpKeys(),
getMcpServerStatus(),
getMcpAccessStatus(),
])
return (
<div className="space-y-8">
<SettingsHelpBox
title="Qu'est-ce que MCP (Model Context Protocol) ?"
defaultOpen={true}
steps={[
{ text: 'MCP est un protocole qui permet aux agents IA de Memento de se connecter à des outils externes (bases de données, APIs, fichiers, etc.).' },
{ text: 'Memento expose un serveur MCP avec 22 outils — vos agents peuvent lire/créer des notes, chercher dans votre base, gérer les carnets, etc.' },
{ text: 'Créez une clé API ici, puis configurez-la dans votre client MCP (Claude Desktop, Cursor, Continue.dev…) avec l\'URL du serveur.' },
{ text: 'Format de configuration : URL du serveur MCP + votre clé dans le header Authorization.', link: { label: 'Documentation MCP', href: 'https://modelcontextprotocol.io/docs' } },
{ icon: '⚡', text: 'Cas d\'usage : demandez à Claude Desktop d\'écrire une note dans Memento, de chercher dans vos carnets, ou de créer un agent.' },
]}
/>
<McpSettingsPanel initialKeys={keys} serverStatus={serverStatus} />
<McpSettingsHeader />
<McpSettingsPanel initialKeys={keys} serverStatus={serverStatus} mcpAllowed={access.allowed} tier={access.tier} />
</div>
)
}

View File

@@ -1,156 +0,0 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useLanguage } from '@/lib/i18n';
export default function SupportPage() {
const { t } = useLanguage();
return (
<div className="container mx-auto py-10 max-w-4xl">
<div className="text-center mb-10">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold mb-4">
{t('support.title')}
</h1>
<p className="text-muted-foreground text-lg">
{t('support.description')}
</p>
</div>
<div className="grid gap-6 md:grid-cols-2 mb-10">
<Card className="border-2 border-primary">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl"></span>
{t('support.buyMeACoffee')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-4">
{t('support.donationDescription')}
</p>
<Button asChild className="w-full">
<a href="https://ko-fi.com/yourusername" target="_blank" rel="noopener noreferrer">
{t('support.donateOnKofi')}
</a>
</Button>
<p className="text-xs text-muted-foreground mt-2">
{t('support.kofiDescription')}
</p>
</CardContent>
</Card>
<Card className="border-2 border-primary">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">💚</span>
{t('support.sponsorOnGithub')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-4">
{t('support.sponsorDescription')}
</p>
<Button asChild variant="outline" className="w-full">
<a href="https://github.com/sponsors/yourusername" target="_blank" rel="noopener noreferrer">
{t('support.sponsorOnGithub')}
</a>
</Button>
<p className="text-xs text-muted-foreground mt-2">
{t('support.githubDescription')}
</p>
</CardContent>
</Card>
</div>
<Card className="mb-10">
<CardHeader>
<CardTitle>{t('support.howSupportHelps')}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid md:grid-cols-2 gap-4">
<div>
<h3 className="font-semibold mb-2">💰 {t('support.directImpact')}</h3>
<ul className="space-y-2 text-sm">
<li> Keeps me fueled with coffee</li>
<li>🐛 Covers hosting and server costs</li>
<li> Funds development of new features</li>
<li>📚 Improves documentation</li>
<li>🌍 Keeps Memento 100% open-source</li>
</ul>
</div>
<div>
<h3 className="font-semibold mb-2">🎁 {t('support.sponsorPerks')}</h3>
<ul className="space-y-2 text-sm">
<li>🥉 $5/month - Bronze: Name in supporters list</li>
<li>🥈 $15/month - Silver: Priority feature requests</li>
<li>🥇 $50/month - Gold: Logo in footer, priority support</li>
<li>💎 $100/month - Platinum: Custom features, consulting</li>
</ul>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>💡 {t('support.transparency')}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm mb-4">
{t('support.transparencyDescription')}
</p>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span>{t('support.hostingServers')}</span>
<span className="font-mono">~$20/month</span>
</div>
<div className="flex justify-between">
<span>{t('support.domainSSL')}</span>
<span className="font-mono">~$15/year</span>
</div>
<div className="flex justify-between">
<span>{t('support.aiApiCosts')}</span>
<span className="font-mono">~$30/month</span>
</div>
<div className="flex justify-between border-t pt-2">
<span className="font-semibold">{t('support.totalExpenses')}</span>
<span className="font-mono font-semibold">~$50/month</span>
</div>
</div>
<p className="text-xs text-muted-foreground mt-4">
Any amount beyond these costs goes directly into improving Memento
and funding new features. Thank you for your support! 💚
</p>
</CardContent>
</Card>
<div className="mt-10 text-center">
<h2 className="text-2xl font-bold mb-4">{t('support.otherWaysTitle')}</h2>
<div className="flex flex-wrap justify-center gap-4">
<Button variant="outline" asChild>
<a href="https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
{t('support.starGithub')}
</a>
</Button>
<Button variant="outline" asChild>
<a href="https://github.com/yourusername/memento/issues" target="_blank" rel="noopener noreferrer">
🐛 {t('support.reportBug')}
</a>
</Button>
<Button variant="outline" asChild>
<a href="https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
📝 {t('support.contributeCode')}
</a>
</Button>
<Button variant="outline" asChild>
<a href="https://twitter.com/intent/tweet?text=Check%20out%20Memento%20-%20a%20great%20open-source%20note-taking%20app!%20https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
🐦 {t('support.shareTwitter')}
</a>
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,16 +1,12 @@
'use client'
import { motion } from 'motion/react'
/**
* Plus danimation dentrée (opacity / translate) :
* ça laissait voir le fond clair une fraction de seconde en mode sombre.
* Conteneur neutre uniquement.
*/
export default function MainTemplate({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3 }}
className="flex-1 flex flex-col min-h-0"
>
<div className="flex min-h-0 flex-1 flex-col bg-background text-foreground">
{children}
</motion.div>
</div>
)
}

View File

@@ -3,6 +3,12 @@ import { detectUserLanguage, parseAcceptLanguage } from '@/lib/i18n/detect-user-
import { loadTranslations } from '@/lib/i18n/load-translations'
import { PublicProviders } from '@/components/public-providers'
/**
* Le body racine est en hauteur décran avec débordement masqué
* (pour lapplication connectée). Les pages publiques (accueil, tarifs,
* notes publiées) doivent pouvoir défiler : on crée un conteneur de
* défilement qui couvre tout lécran, indépendant du body.
*/
export default async function PublicLayout({ children }: { children: React.ReactNode }) {
const headersList = await headers()
const browserLang = parseAcceptLanguage(headersList.get('accept-language'))
@@ -11,7 +17,13 @@ export default async function PublicLayout({ children }: { children: React.React
return (
<PublicProviders initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
{children}
<div
data-public-scroll-root
className="fixed inset-0 z-[1] h-[100dvh] w-full overflow-y-auto overflow-x-hidden overscroll-y-contain"
style={{ WebkitOverflowScrolling: 'touch', touchAction: 'pan-y' }}
>
{children}
</div>
</PublicProviders>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,89 +1,160 @@
'use client'
import { motion } from 'motion/react'
import { Shield } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useLanguage } from '@/lib/i18n'
import { Check } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useLanguage } from '@/lib/i18n'
import { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants'
export default function PricingPage() {
const { t } = useLanguage()
const router = useRouter()
const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly')
const trialDays = SUBSCRIPTION_TRIAL_DAYS
const { data: byokCatalog } = useQuery({
queryKey: ['public', 'byok-catalog'],
queryFn: async () => {
const res = await fetch('/api/public/byok-catalog')
if (!res.ok) throw new Error('catalog')
return res.json() as Promise<{ providers: { id: string }[] }>
},
staleTime: 60_000,
})
const providerCount = byokCatalog?.providers.length || '…'
const PLANS = [
{ key: 'basic', popular: false, price: t('landing.pricing.basicPrice'), period: '' },
{ key: 'pro', popular: true, price: billingInterval === 'monthly' ? '9,90€' : '7,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') },
{ key: 'business', popular: false, price: billingInterval === 'monthly' ? '29,90€' : '23,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') },
{ key: 'enterprise', popular: false, price: billingInterval === 'monthly' ? '49,90€' : '39,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perUser') : t('landing.pricing.perUserAnnual') },
const plans = [
{ key: 'basic', popular: false, hasTrial: false, price: t('landing.pricing.basicPrice'), period: '' },
{
key: 'pro',
popular: true,
hasTrial: true,
price: billingInterval === 'monthly' ? t('landing.pricing.proMonthly') : t('landing.pricing.proAnnualMonthly'),
period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual'),
},
{
key: 'business',
popular: false,
hasTrial: true,
price: billingInterval === 'monthly' ? t('landing.pricing.businessMonthly') : t('landing.pricing.businessAnnualMonthly'),
period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual'),
},
{
key: 'enterprise',
popular: false,
hasTrial: false,
price: t('landing.pricing.enterprisePrice'),
period: '',
},
]
return (
<main className="min-h-screen bg-paper">
<section className="py-32 px-8">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-12">
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.pricing.label')}</span>
<h2 className="text-4xl md:text-5xl font-serif tracking-tight text-ink mb-6">{t('landing.pricing.title')}</h2>
<p className="text-concrete font-light max-w-xl mx-auto mb-12">{t('landing.pricing.desc')}</p>
<main className="min-h-screen bg-[#0B0A09] text-[#F4F1EA] font-[family-name:var(--font-manrope)] selection:bg-[#D4A373]/40 selection:text-white">
<nav className="sticky top-0 z-[100] px-5 sm:px-8 py-4 flex items-center justify-between bg-[#0B0A09]/70 backdrop-blur-xl border-b border-white/[0.06]">
<Link href="/" className="flex items-center gap-2.5 group">
<div className="w-9 h-9 bg-[#F4F1EA] text-[#0B0A09] flex items-center justify-center rounded-lg">
<span className="font-serif text-xl font-bold leading-none">M</span>
</div>
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
</Link>
<div className="flex items-center gap-2 sm:gap-3">
<Link href="/login" className="text-[13px] text-white/75 hover:text-white transition-colors px-2">
{t('landing.nav.login')}
</Link>
<Link
href="/register"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-[#F4F1EA] text-[#0B0A09] text-[13px] font-semibold hover:bg-white transition-colors"
>
{t('landing.nav.cta')}
</Link>
</div>
</nav>
<div className="flex items-center justify-center gap-10 mb-8">
<button onClick={() => setBillingInterval('monthly')} className={`group relative py-2 px-1 transition-all ${billingInterval === 'monthly' ? 'text-ink' : 'text-concrete/40 hover:text-concrete'}`}>
<span className="text-xs font-black uppercase tracking-[0.2em]">{t('landing.pricing.monthly')}</span>
{billingInterval === 'monthly' && (
<motion.div layoutId="interval-active-pricing" className="absolute -inset-x-1 -inset-y-0.5 border border-ochre/60" transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }} />
)}
<section className="px-5 sm:px-8 py-28">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-[#D4A373] mb-4 block">
{t('landing.pricing.label')}
</span>
<h1 className="font-serif text-3xl sm:text-5xl tracking-tight mb-4">{t('landing.pricing.title')}</h1>
<p className="text-white/70 mb-8">{t('landing.pricing.desc')}</p>
<div className="inline-flex p-1 rounded-full border border-white/10 bg-white/[0.03]">
<button
type="button"
onClick={() => setBillingInterval('monthly')}
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all ${billingInterval === 'monthly' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/70'}`}
>
{t('landing.pricing.monthly')}
</button>
<button
type="button"
onClick={() => setBillingInterval('annual')}
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all relative ${billingInterval === 'annual' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/70'}`}
>
{t('landing.pricing.annual')}
<span className="absolute -top-3 -right-1 text-[10px] text-[#D4A373] whitespace-nowrap">
{t('landing.pricing.savePercent')}
</span>
</button>
<div className="relative">
<button onClick={() => setBillingInterval('annual')} className={`group relative py-2 px-1 transition-all ${billingInterval === 'annual' ? 'text-ink' : 'text-concrete/40 hover:text-concrete'}`}>
<span className="text-xs font-black uppercase tracking-[0.2em]">{t('landing.pricing.annual')}</span>
{billingInterval === 'annual' && (
<motion.div layoutId="interval-active-pricing" className="absolute -inset-x-1 -inset-y-0.5 border border-ochre/60" transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }} />
)}
</button>
<div className="absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap">
<span className="text-[9px] font-bold text-ochre uppercase tracking-widest italic animate-pulse">(-20%)</span>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch">
{PLANS.map((plan) => (
<div key={plan.key} className={`relative p-8 rounded-[32px] border flex flex-col transition-all duration-300 hover:shadow-2xl hover:shadow-ink/5 ${plan.popular ? 'bg-ink text-paper border-ink ring-4 ring-ochre/20' : 'bg-white border-border text-ink'}`}>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
{plans.map((plan) => (
<div
key={plan.key}
className={`rounded-2xl border p-6 flex flex-col ${
plan.popular
? 'border-[#D4A373]/50 bg-[#D4A373]/10'
: 'border-white/[0.08] bg-white/[0.02]'
}`}
>
{plan.popular && (
<div className="absolute -top-4 left-1/2 -translate-x-1/2 px-4 py-1 bg-ochre text-ink text-[10px] font-bold uppercase tracking-widest rounded-full">
<span className="text-[10px] font-bold uppercase tracking-widest text-[#D4A373] mb-3">
{t('landing.pricing.popular')}
</div>
</span>
)}
<div className="mb-8">
<h4 className="text-[11px] font-bold uppercase tracking-widest mb-2 opacity-60">{t(`landing.pricing.${plan.key}.name`)}</h4>
<div className="flex items-baseline gap-1 mb-4">
<span className="text-4xl font-serif font-medium">{plan.price}</span>
{plan.period && <span className="text-xs opacity-60">{plan.period}</span>}
</div>
<p className="text-sm font-light leading-relaxed opacity-80">{t(`landing.pricing.${plan.key}.desc`)}</p>
<h2 className="text-[13px] font-medium tracking-wide text-white/80 mb-2">
{t(`landing.pricing.${plan.key}.name`)}
</h2>
<div className="flex items-baseline gap-1 mb-2">
<span className="text-3xl font-serif">{plan.price}</span>
{plan.period && <span className="text-sm text-white/70">{plan.period}</span>}
</div>
<div className="flex-1 space-y-4 mb-10">
{[0, 1, 2, 3, 4, 5].map(j => {
const feat = t(`landing.pricing.${plan.key}.feature${j}`)
if (!feat || feat === `landing.pricing.${plan.key}.feature${j}`) return null
{plan.hasTrial && (
<p className="text-[11px] font-semibold text-[#D4A373] mb-3">
{t('landing.pricing.trialBadge', { days: trialDays })}
</p>
)}
<p className="text-sm text-white/70 mb-6">{t(`landing.pricing.${plan.key}.desc`)}</p>
<ul className="space-y-2.5 mb-8 flex-1">
{plan.hasTrial && (
<li className="flex gap-2 text-xs text-[#D4A373]/90">
<Check size={12} className="text-[#D4A373] mt-0.5 shrink-0" />
{t('landing.pricing.trialFeature', { days: trialDays })}
</li>
)}
{[0, 1, 2, 3, 4, 5].map((j) => {
const feat = t(`landing.pricing.${plan.key}.feature${j}`, { count: providerCount })
if (!feat || feat.startsWith('landing.')) return null
return (
<div key={j} className="flex items-start gap-3">
<div className={`mt-1 rounded-full p-0.5 ${plan.popular ? 'bg-ochre text-ink' : 'bg-brand-accent/10 text-brand-accent'}`}>
<Shield size={10} fill="currentColor" />
</div>
<span className="text-xs font-light">{feat}</span>
</div>
<li key={j} className="flex gap-2 text-sm text-white/80">
<Check size={12} className="text-[#D4A373] mt-0.5 shrink-0" />
{feat}
</li>
)
})}
</div>
<button
onClick={() => router.push('/register')}
className={`w-full py-4 rounded-2xl text-xs font-bold uppercase tracking-widest transition-all ${plan.popular ? 'bg-ochre text-ink hover:opacity-90' : 'bg-ink text-paper hover:bg-ink/90'}`}
</ul>
<Link
href="/register"
className={`py-3 rounded-xl text-center text-[13px] font-semibold transition-colors ${
plan.popular
? 'bg-[#F4F1EA] text-[#0B0A09] hover:bg-white'
: 'bg-white/10 text-white hover:bg-white/15'
}`}
>
{t(`landing.pricing.${plan.key}.cta`)}
</button>
{plan.hasTrial
? t('landing.pricing.trialCta', { days: trialDays })
: t(`landing.pricing.${plan.key}.cta`)}
</Link>
</div>
))}
</div>

View File

@@ -0,0 +1,411 @@
import { headers } from 'next/headers'
import Link from 'next/link'
import { parseAcceptLanguage } from '@/lib/i18n/detect-user-language'
export async function generateMetadata({
searchParams,
}: {
searchParams?: Promise<{ lang?: string | string[] }>
}) {
const sp = searchParams ? (await searchParams) : undefined
const locale = await pickLocale(sp?.lang)
return {
title: locale === 'fr' ? 'Politique de confidentialité — Memento' : 'Privacy Policy — Memento',
description: locale === 'fr'
? "Comment Memento gère vos données, l'extension de capture web Chrome et Firefox, et les fonctionnalités d'IA."
: 'How Memento handles your data, the Chrome and Firefox web clipper extension, and AI features.',
alternates: { canonical: 'https://memento-note.com/privacy' },
robots: { index: true, follow: true },
}
}
type Locale = 'en' | 'fr'
const SUPPORTED: Locale[] = ['en', 'fr']
const RTL: Locale[] = []
async function pickLocale(searchLang?: string | string[]): Promise<Locale> {
// 1. Explicit ?lang= override
const raw = Array.isArray(searchLang) ? searchLang[0] : searchLang
if (raw && SUPPORTED.includes(raw.toLowerCase() as Locale)) return raw.toLowerCase() as Locale
// 2. Accept-Language header
try {
const h = await headers()
const accept = parseAcceptLanguage(h.get('accept-language'))
const first = accept?.[0]?.toLowerCase()
if (first && SUPPORTED.includes(first as Locale)) return first as Locale
if (first && first.startsWith('fr')) return 'fr'
} catch {
/* fall through */
}
return 'en'
}
type Section = { id: string; title: string; body: string[] }
type Doc = {
title: string
intro: string
lastUpdated: string
sections: Section[]
}
const DOCS: Record<Locale, Doc> = {
fr: {
title: 'Politique de confidentialité',
intro:
'Cette politique décrit comment Memento (Memento Labs) collecte, utilise et protège vos données, y compris via l\'application web, l\'extension navigateur Web Clipper et les fonctionnalités d\'intelligence artificielle. Nous concevons Memento comme un Second Brain personnel : par défaut, vos données vous appartiennent et ne sont pas partagées.',
lastUpdated: 'Dernière mise à jour : juillet 2026',
sections: [
{
id: 'data-collected',
title: '1. Données collectées',
body: [
'Compte et authentification : adresse e-mail, nom facultatif, mot de passe haché (bcrypt), ou identifiant Google via OAuth (Google Auth Platform).',
'Carnets et notes : contenu, titre, étiquettes, images jointes, pièces jointes (PDF, images, documents), métadonnées (dates, ordre, couleur, épinglé, archivé), et historique des versions (snapshots).',
'Rappels et notifications : date/heure, récurrence, lieu facultatif, état (fait/non fait).',
'Données IA : titre et corps des notes envoyés à votre fournisseur d\'IA configuré pour générer résumés, tags, embeddings, Memory Echo, reformulations, traduction, suggestions. Avec BYOK (votre propre clé), l\'envoi va directement vers votre fournisseur ; sinon, il transite par notre routeur IA mutualisé.',
'Extension Web Clipper (Chrome / Firefox) : lorsque vous cliquez explicitement « Clipper », l\'extension lit l\'URL active, le titre de la page, la sélection de texte et (selon votre choix) le HTML complet de la page. Aucune lecture du contenu de page n\'a lieu tant que vous n\'initiez pas l\'action.',
'Cookies techniques et de session : strictement nécessaires à l\'authentification (cookies NextAuth) et aux préférences (langue, thème, panneau de configuration de l\'extension). Aucun cookie publicitaire, aucun tracker tiers par défaut.',
'Facturation et quotas : pour les tiers payants, référence de commande et jetons d\'utilisation IA stockés pour appliquer les quotas (réservation atomique). Aucune donnée bancaire (traitée par Stripe).',
'Métadonnées techniques : adresse IP (journalisée ~30 jours à des fins anti-abus), user-agent, version de l\'extension, codes d\'erreur anonymisés.',
],
},
{
id: 'usage',
title: '2. Usage des données',
body: [
'Fournir le service : synchronisation de vos carnets, recherche, Memory Echo, Memory Echo insights, agents, publication web de notes, clips depuis l\'extension.',
'Amélioration : journaux d\'erreur anonymisés et métriques agrégées (Prometheus + dashboards Grafana internes) pour diagnostiquer les pannes et améliorer les performances.',
'Sécurité : détection d\'abus, protection contre le credential stuffing et les dépassements de quotas.',
'Nous ne vendons jamais vos données. Nous ne les partageons pas avec des annonceurs. Aucun profilage publicitaire.',
],
},
{
id: 'third-parties',
title: '3. Services tiers et sous-traitants',
body: [
'Google OAuth : authentification optionnelle (Branding → Audience « In production »).',
'Google Cloud Storage (GCP) : hébergement des fichiers joints et des pièces jointes, région Europe (eu-west-3 / europe-west9).',
'PostgreSQL (Docker self-hosted) : base principale, sauvegardes chiffrées au repos.',
'Redis (Docker self-hosted) : cache de session, rate limiting, files de tâches.',
'Fournisseurs d\'IA (OpenAI, Anthropic, Google Gemini, Mistral, OpenRouter, ou votre propre clé BYOK) : envoi du titre et du contenu de la note sélectionnée pour les fonctionnalités IA. Aucun entraînement n\'est effectué sur vos données par notre intermédiaire ; voir la politique du fournisseur retenu.',
'Stripe : facturation des abonnements. Les données de paiement sont gérées exclusivement par Stripe (PCI-DSS).',
'Resend : e-mails transactionnels (vérification de compte, réinitialisation de mot de passe, alertes quota).',
'Google Fonts (CDN fonts.googleapis.com) : polices web sur la landing page et le side panel de l\'extension — adresse IP communiquée à Google.',
'Google Favicons (google.com/s2/favicons) : récupération d\'icônes de sites dans l\'extension — l\'URL du domaine est communiquée à Google.',
'Grafana Cloud (monitoring optionnel) : métriques agrégées anonymisées.',
],
},
{
id: 'extension',
title: '4. Permissions de l\'extension navigateur',
body: [
'activeTab : lecture du contenu de l\'onglet actif uniquement lorsque vous cliquez le bouton « Clipper ». Aucune lecture passive.',
'scripting : injection d\'un petit script de capture de sélection après votre clic explicite.',
'storage : mémorisation de l\'URL de votre serveur Memento et du dernier carnet utilisé.',
'sidePanel (Chrome) / sidebar_action (Firefox) : ouverture du panneau latéral pour afficher l\'interface du clipper.',
'tabs : récupération de l\'URL et du titre de l\'onglet actif pour le contexte du clip.',
'host_permissions : limitées à memento-note.com en production. Les hôtes étendus (localhost / LAN) sont disponibles uniquement en build développeur.',
'L\'extension ne lit jamais l\'historique de navigation. Elle ne communique avec aucun serveur autre que l\'instance Memento que vous avez explicitement configurée.',
],
},
{
id: 'ai',
title: '5. Intelligence artificielle et BYOK',
body: [
'Les fonctionnalités IA (résumé, tags, Memory Echo, embeddings sémantiques, agents) consomment un quota mesuré en tokens. Le quota est réservé atomiquement avant chaque appel.',
'Vous pouvez fournir votre propre clé d\'API (BYOK) pour contourner les quotas mutualisés et faire transiter les appels directement par votre fournisseur. La clé est chiffrée au repos (AES-256-GCM) et n\'est jamais affichée en clair après saisie.',
'Aucun entraînement de modèle n\'est effectué sur vos données par notre intermédiaire.',
'Vous pouvez désactiver individuellement chaque fonctionnalité IA dans Réglages → IA.',
],
},
{
id: 'retention',
title: '6. Conservation des données',
body: [
'Carnets et notes : conservés tant que votre compte est actif. Suppression définitive dans les 30 jours suivant la suppression du compte (sauf obligation légale contraire).',
'Corbeille : notes supprimées conservées 30 jours avant purge définitive.',
'Journaux d\'erreur : 90 jours maximum, agrégés ensuite.',
'Sauvegardes base de données : conservées 30 jours en rotation chiffrée.',
'Métadonnées de facturation : conservées 10 ans (obligation comptable).',
],
},
{
id: 'rights',
title: '7. Vos droits (RGPD / CCPA)',
body: [
'Accès : export complet de vos notes, carnets, étiquettes et paramètres via Réglages → Données.',
'Rectification : édition directe dans l\'interface.',
'Effacement : suppression de compte dans Réglages → Données. Effacement définitif sous 30 jours.',
'Portabilité : export JSON des notes, carnets et étiquettes (équivalent de l\'outil `export_notes` du serveur MCP).',
'Opposition au traitement IA : désactivation des fonctionnalités IA dans Réglages → IA.',
'Réclamation : vous pouvez saisir la CNIL (France) ou l\'autorité de contrôle de votre lieu de résidence.',
'Pour exercer vos droits : privacy@memento-note.com.',
],
},
{
id: 'security',
title: '8. Sécurité',
body: [
'Transport : HTTPS obligatoire, HSTS activé, en-têtes de sécurité (CSP, X-Frame-Options, X-Content-Type-Options).',
'Stockage : mots de passe hachés bcrypt, clés API chiffrées AES-256-GCM, secrets en variables d\'environnement chiffrées au repos.',
'Authentification : cookies de session NextAuth, JWT signés, expiration glissante.',
'Audit : journaux d\'audit log pour les actions sensibles (changement de mot de passe, export de données, suppression de compte).',
'Bug bounty : security@memento-note.com pour signaler une vulnérabilité. Programme responsable de divulgation.',
],
},
{
id: 'minors',
title: '9. Mineurs',
body: [
'Memento n\'est pas destiné aux enfants de moins de 16 ans (RGPD) ou 13 ans (COPPA). Nous ne collectons pas sciemment de données auprès de mineurs. Si vous pensez qu\'un mineur a fourni des données, contactez privacy@memento-note.com pour suppression.',
],
},
{
id: 'changes',
title: '10. Modifications de cette politique',
body: [
'Toute modification substantielle est annoncée par e-mail au moins 30 jours à l\'avance et par bannière dans l\'application. La date de « dernière mise à jour » en haut de cette page est mise à jour à chaque révision.',
'Les modifications non substantielles (corrections de formulation, liens mis à jour) sont publiées sans préavis. La version précédente reste accessible sur demande.',
],
},
{
id: 'contact',
title: '11. Contact et responsable de traitement',
body: [
'Memento Labs — Responsable de traitement : privacy@memento-note.com',
'Délégué à la protection des données (DPO) : dpo@memento-note.com',
'Adresse postale : Memento Labs, [adresse], France',
'Hébergeur des données : serveur self-hosted sur infrastructure Memento Labs (PostgreSQL + Redis Docker, région Europe).',
],
},
],
},
en: {
title: 'Privacy Policy',
intro:
'This policy describes how Memento (Memento Labs) collects, uses and protects your data, including through the web app, the Web Clipper browser extension, and AI features. We design Memento as a personal Second Brain: by default your data belongs to you and is not shared.',
lastUpdated: 'Last updated: July 2026',
sections: [
{
id: 'data-collected',
title: '1. Data we collect',
body: [
'Account and authentication: email address, optional name, hashed password (bcrypt), or Google identifier via OAuth (Google Auth Platform).',
'Notebooks and notes: content, title, labels, attached images and files (PDF, images, documents), metadata (dates, order, color, pinned, archived), and version history (snapshots).',
'Reminders and notifications: date/time, recurrence, optional location, status (done/not done).',
'AI data: note title and body sent to your configured AI provider to generate summaries, tags, embeddings, Memory Echo, reformulations, translations, suggestions. With BYOK (your own key), requests go directly to your provider; otherwise they transit through our shared AI router.',
'Web Clipper extension (Chrome / Firefox): when you explicitly click "Clip", the extension reads the active URL, page title, text selection and (depending on your choice) the full page HTML. No page content is read unless you initiate the action.',
'Technical and session cookies: strictly necessary for authentication (NextAuth cookies) and preferences (language, theme, extension settings panel). No advertising cookies, no third-party trackers by default.',
'Billing and quotas: for paid tiers, order reference and AI usage tokens stored to enforce quotas (atomic reservation). No bank card data (handled by Stripe).',
'Technical metadata: IP address (logged ~30 days for anti-abuse), user agent, extension version, anonymized error codes.',
],
},
{
id: 'usage',
title: '2. How we use data',
body: [
'Provide the service: notebook sync, search, Memory Echo, Memory Echo insights, agents, web publishing of notes, clips from the extension.',
'Improvement: anonymized error logs and aggregated metrics (internal Prometheus + Grafana dashboards) to diagnose failures and improve performance.',
'Security: abuse detection, protection against credential stuffing and quota overruns.',
'We never sell your data. We never share it with advertisers. No advertising profiling.',
],
},
{
id: 'third-parties',
title: '3. Third parties and sub-processors',
body: [
'Google OAuth: optional authentication (Branding → Audience "In production").',
'Google Cloud Storage (GCP): hosting of attached files and attachments, Europe region (eu-west-3 / europe-west9).',
'PostgreSQL (Docker self-hosted): main database, encrypted backups at rest.',
'Redis (Docker self-hosted): session cache, rate limiting, job queues.',
'AI providers (OpenAI, Anthropic, Google Gemini, Mistral, OpenRouter, or your own BYOK key): the title and content of the selected note are sent for AI features. No training is performed on your data through us; see the chosen provider\'s policy.',
'Stripe: subscription billing. Payment data is handled exclusively by Stripe (PCI-DSS).',
'Resend: transactional emails (account verification, password reset, quota alerts).',
'Google Fonts (CDN fonts.googleapis.com): web fonts on the landing page and the extension side panel — IP address is shared with Google.',
'Google Favicons (google.com/s2/favicons): site icon retrieval in the extension — the domain URL is shared with Google.',
'Grafana Cloud (optional monitoring): anonymized aggregated metrics.',
],
},
{
id: 'extension',
title: '4. Browser extension permissions',
body: [
'activeTab: read the active tab content only when you click the "Clip" button. No passive reading.',
'scripting: inject a small selection-capture script after your explicit click.',
'storage: remember your Memento server URL and the last-used notebook.',
'sidePanel (Chrome) / sidebar_action (Firefox): open the side panel to display the clipper UI.',
'tabs: retrieve the active tab URL and title for the clip context.',
'host_permissions: limited to memento-note.com in production. Extended hosts (localhost / LAN) are only available in the developer build.',
'The extension never reads your browsing history. It never communicates with any server other than the Memento instance you explicitly configured.',
],
},
{
id: 'ai',
title: '5. Artificial intelligence and BYOK',
body: [
'AI features (summary, tags, Memory Echo, semantic embeddings, agents) consume a token-based quota. The quota is atomically reserved before each call.',
'You can provide your own API key (BYOK) to bypass shared quotas and route calls directly through your provider. The key is encrypted at rest (AES-256-GCM) and is never displayed in clear text after entry.',
'No model training is performed on your data through us.',
'You can disable each AI feature individually in Settings → AI.',
],
},
{
id: 'retention',
title: '6. Data retention',
body: [
'Notebooks and notes: kept as long as your account is active. Permanently deleted within 30 days of account deletion (unless a legal obligation requires otherwise).',
'Trash: deleted notes kept for 30 days before permanent purge.',
'Error logs: maximum 90 days, then aggregated.',
'Database backups: kept 30 days in encrypted rotation.',
'Billing metadata: kept 10 years (accounting obligation).',
],
},
{
id: 'rights',
title: '7. Your rights (GDPR / CCPA)',
body: [
'Access: full export of your notes, notebooks, labels and settings via Settings → Data.',
'Rectification: edit directly in the interface.',
'Erasure: account deletion in Settings → Data. Definitive erasure within 30 days.',
'Portability: JSON export of notes, notebooks and labels (equivalent of the `export_notes` MCP server tool).',
'Opt-out of AI processing: disable AI features in Settings → AI.',
'Complaint: you may contact the CNIL (France) or the supervisory authority of your place of residence.',
'To exercise your rights: privacy@memento-note.com.',
],
},
{
id: 'security',
title: '8. Security',
body: [
'Transport: HTTPS enforced, HSTS enabled, security headers (CSP, X-Frame-Options, X-Content-Type-Options).',
'Storage: bcrypt-hashed passwords, AES-256-GCM-encrypted API keys, secrets in encrypted environment variables at rest.',
'Authentication: NextAuth session cookies, signed JWT, sliding expiration.',
'Audit: audit logs for sensitive actions (password change, data export, account deletion).',
'Bug bounty: security@memento-note.com to report a vulnerability. Responsible disclosure program.',
],
},
{
id: 'minors',
title: '9. Minors',
body: [
'Memento is not intended for children under 16 (GDPR) or 13 (COPPA). We do not knowingly collect data from minors. If you believe a minor has provided data, contact privacy@memento-note.com for deletion.',
],
},
{
id: 'changes',
title: '10. Changes to this policy',
body: [
'Any substantial change is announced by email at least 30 days in advance and via an in-app banner. The "Last updated" date at the top of this page is updated with each revision.',
'Non-substantial changes (wording fixes, updated links) are published without notice. The previous version remains accessible on request.',
],
},
{
id: 'contact',
title: '11. Contact and data controller',
body: [
'Memento Labs — Data controller: privacy@memento-note.com',
'Data Protection Officer (DPO): dpo@memento-note.com',
'Postal address: Memento Labs, [address], France',
'Data hosting: self-hosted on Memento Labs infrastructure (PostgreSQL + Redis Docker, Europe region).',
],
},
],
},
}
export default async function PrivacyPage({
searchParams,
}: {
searchParams?: Promise<{ lang?: string | string[] }>
}) {
const sp = searchParams ? (await searchParams) : undefined
const locale = await pickLocale(sp?.lang)
const doc = DOCS[locale]
const isRtl = RTL.includes(locale)
return (
<main
lang={locale}
dir={isRtl ? 'rtl' : 'ltr'}
className="min-h-screen bg-[#0B0A09] text-[#F4F1EA] font-[family-name:var(--font-manrope)] selection:bg-[#D4A373]/40 selection:text-white"
>
<header className="px-5 sm:px-8 py-6 border-b border-white/[0.06] flex items-center justify-between">
<Link href="/" className="flex items-center gap-2.5">
<div className="w-9 h-9 bg-[#F4F1EA] text-[#0B0A09] flex items-center justify-center rounded-lg">
<span className="font-serif text-xl font-bold leading-none">M</span>
</div>
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
</Link>
<nav className="flex items-center gap-3">
<a
href="?lang=fr"
aria-current={locale === 'fr' ? 'true' : undefined}
className={`text-xs uppercase tracking-widest px-3 py-1 rounded-full border transition-colors ${
locale === 'fr'
? 'bg-[#F4F1EA] text-[#0B0A09] border-[#F4F1EA]'
: 'border-white/15 text-white/55 hover:border-white/40 hover:text-white'
}`}
>
FR
</a>
<a
href="?lang=en"
aria-current={locale === 'en' ? 'true' : undefined}
className={`text-xs uppercase tracking-widest px-3 py-1 rounded-full border transition-colors ${
locale === 'en'
? 'bg-[#F4F1EA] text-[#0B0A09] border-[#F4F1EA]'
: 'border-white/15 text-white/55 hover:border-white/40 hover:text-white'
}`}
>
EN
</a>
</nav>
</header>
<article className="max-w-3xl mx-auto px-5 sm:px-8 py-16 sm:py-24">
<p className="text-[11px] uppercase tracking-[0.3em] text-[#D4A373] mb-4">
{locale === 'fr' ? 'Confidentialité' : 'Privacy'}
</p>
<h1 className="font-serif text-4xl sm:text-5xl tracking-tight mb-4">{doc.title}</h1>
<p className="text-sm text-white/70 mb-3">{doc.lastUpdated}</p>
<p className="text-white/65 leading-relaxed text-lg mb-12">{doc.intro}</p>
<nav aria-label="Sommaire" className="mb-12 p-5 rounded-xl bg-white/[0.03] border border-white/[0.06]">
<p className="text-[13px] font-medium tracking-wide text-white/70 mb-3">
{locale === 'fr' ? 'Sommaire' : 'Contents'}
</p>
<ul className="space-y-1.5 text-sm">
{doc.sections.map((s) => (
<li key={s.id}>
<a href={`#${s.id}`} className="text-white/65 hover:text-[#D4A373] transition-colors">
{s.title}
</a>
</li>
))}
</ul>
</nav>
<div className="space-y-12">
{doc.sections.map((s) => (
<section key={s.id} id={s.id} className="scroll-mt-24">
<h2 className="font-serif text-2xl tracking-tight mb-4 text-[#F4F1EA]">{s.title}</h2>
<div className="space-y-3 text-white/70 leading-relaxed">
{s.body.map((p, i) => (
<p key={i}>{p}</p>
))}
</div>
</section>
))}
</div>
<footer className="mt-20 pt-8 border-t border-white/[0.06] text-xs text-white/35 flex flex-wrap items-center justify-between gap-4">
<span>© 2026 Memento Labs</span>
<Link href="/" className="hover:text-white transition-colors">
{locale === 'fr' ? 'Retour à l\'accueil' : 'Back to home'}
</Link>
</footer>
</article>
</main>
)
}

View File

@@ -7,6 +7,7 @@ import { VALID_FEATURES, getCurrentPeriodKey } from '@/lib/quota-utils'
import {
getAllEntitlementsForAdmin,
invalidateEntitlementCache,
ENTITLEMENT_UNAVAILABLE,
type SubscriptionTier as TierType,
} from '@/lib/plan-entitlements'
import { logAuditEventAsync } from '@/lib/audit-log'
@@ -18,6 +19,9 @@ const BILLING_CONFIG_KEYS = [
'STRIPE_PRICE_PRO_ANNUAL',
'STRIPE_PRICE_BUSINESS_MONTHLY',
'STRIPE_PRICE_BUSINESS_ANNUAL',
'STRIPE_PRICE_CREDITS_S',
'STRIPE_PRICE_CREDITS_M',
'STRIPE_PRICE_CREDITS_L',
] as const
const TIERS: TierType[] = ['BASIC', 'PRO', 'BUSINESS', 'ENTERPRISE']
@@ -42,6 +46,152 @@ function assertValidTier(tier: string): asserts tier is TierType {
}
}
async function getStripeHealth(billingConfig: Record<string, string>) {
const secret = process.env.STRIPE_SECRET_KEY ?? ''
const publishable = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? ''
const webhook = process.env.STRIPE_WEBHOOK_SECRET ?? ''
let secretMode: 'test' | 'live' | 'missing' | 'placeholder' = 'missing'
if (!secret) secretMode = 'missing'
else if (secret === 'sk_test_placeholder' || secret.includes('placeholder')) secretMode = 'placeholder'
else if (secret.startsWith('sk_live_')) secretMode = 'live'
else if (secret.startsWith('sk_test_')) secretMode = 'test'
else secretMode = 'placeholder'
const { isBillingEnabled } = await import('@/lib/billing/stripe-prices')
const { SUBSCRIPTION_TRIAL_DAYS } = await import('@/lib/billing/trial-constants')
const billingEnabled = await isBillingEnabled()
const priceKeys = [
...BILLING_CONFIG_KEYS.filter((k) => k.startsWith('STRIPE_PRICE_')),
] as string[]
const canCallStripe = secretMode === 'test' || secretMode === 'live'
let stripe: Awaited<ReturnType<typeof import('@/lib/stripe').getStripe>> | null = null
if (canCallStripe) {
try {
const { getStripe } = await import('@/lib/stripe')
stripe = getStripe()
} catch {
stripe = null
}
}
const prices = await Promise.all(
priceKeys.map(async (key) => {
const priceId = (billingConfig[key] || process.env[key] || '').trim()
const configured = Boolean(priceId) && !priceId.startsWith('price_mock_')
let stripeAmount: number | null = null
let stripeCurrency: string | null = null
let stripeActive: boolean | null = null
let error: string | null = null
if (configured && stripe) {
try {
const price = await stripe.prices.retrieve(priceId)
stripeAmount = price.unit_amount != null ? price.unit_amount / 100 : null
stripeCurrency = price.currency?.toUpperCase() ?? null
stripeActive = price.active
} catch (e) {
error = e instanceof Error ? e.message : 'Stripe price lookup failed'
}
}
return {
key,
priceId: configured ? priceId : '',
configured,
source: billingConfig[key] ? 'db' : process.env[key] ? 'env' : 'missing',
stripeAmount,
stripeCurrency,
stripeActive,
error,
}
}),
)
return {
secretConfigured: secretMode === 'test' || secretMode === 'live',
secretMode,
publishableConfigured: Boolean(publishable) && publishable.startsWith('pk_'),
webhookSecretConfigured: Boolean(webhook) && webhook.startsWith('whsec_'),
billingEnabled,
trialDays: SUBSCRIPTION_TRIAL_DAYS,
prices,
}
}
async function getSubscriptionStats() {
const [byTierRaw, byStatusRaw, cancelAtPeriodEnd, recent] = await Promise.all([
prisma.subscription.groupBy({
by: ['tier'],
_count: { _all: true },
}),
prisma.subscription.groupBy({
by: ['status'],
_count: { _all: true },
}),
prisma.subscription.count({ where: { cancelAtPeriodEnd: true } }),
prisma.subscription.findMany({
where: {
OR: [
{ tier: { in: ['PRO', 'BUSINESS', 'ENTERPRISE'] } },
{ status: { in: ['TRIALING', 'PAST_DUE', 'ACTIVE'] } },
],
},
orderBy: { updatedAt: 'desc' },
take: 15,
select: {
tier: true,
status: true,
trialEndsAt: true,
currentPeriodEnd: true,
cancelAtPeriodEnd: true,
updatedAt: true,
stripeSubscriptionId: true,
user: { select: { email: true, name: true } },
},
}),
])
const byTier: Record<string, number> = Object.fromEntries(TIERS.map((t) => [t, 0]))
for (const row of byTierRaw) {
byTier[row.tier] = row._count._all
}
const byStatus: Record<string, number> = {}
for (const row of byStatusRaw) {
byStatus[row.status] = row._count._all
}
const usersWithoutSub = await prisma.user.count({
where: { subscription: null },
})
return {
byTier,
byStatus,
cancelAtPeriodEnd,
usersWithoutSub,
trialing: byStatus.TRIALING ?? 0,
pastDue: byStatus.PAST_DUE ?? 0,
paidActive:
(byStatus.ACTIVE ?? 0) +
(byStatus.TRIALING ?? 0),
recent: recent.map((s) => ({
email: s.user.email,
name: s.user.name,
tier: s.tier,
status: s.status,
trialEndsAt: s.trialEndsAt?.toISOString() ?? null,
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? null,
cancelAtPeriodEnd: s.cancelAtPeriodEnd,
hasStripeSub: Boolean(s.stripeSubscriptionId),
updatedAt: s.updatedAt.toISOString(),
})),
}
}
export async function getBillingAdminData() {
await checkAdmin()
const { getSystemConfig } = await import('@/lib/config')
@@ -53,7 +203,49 @@ export async function getBillingAdminData() {
BILLING_CONFIG_KEYS.map((key) => [key, config[key] ?? '']),
)
return { entitlements, billingConfig, usageOverview, features: [...VALID_FEATURES], tiers: TIERS }
const { CREDIT_ALLOCATIONS, CREDIT_COSTS, slideGenerateCreditCost } = await import('@/lib/credits')
const { CREDIT_PACKS, CREDIT_PACK_IDS } = await import('@/lib/billing/credit-packs')
const creditAllocations = TIERS.map((tier) => {
const raw = CREDIT_ALLOCATIONS[tier]
return {
tier,
monthlyCredits: raw === 'unlimited' ? null : raw,
unlimited: raw === 'unlimited',
}
})
const creditCosts = {
...CREDIT_COSTS,
slide_generate_example: slideGenerateCreditCost(7),
}
const creditPacks = CREDIT_PACK_IDS.map((id) => {
const p = CREDIT_PACKS[id]
return {
id: p.id,
credits: p.credits,
defaultDisplay: p.defaultDisplay,
}
})
const [stripeHealth, subscriptionStats] = await Promise.all([
getStripeHealth(billingConfig),
getSubscriptionStats(),
])
return {
entitlements,
billingConfig,
usageOverview,
features: [...VALID_FEATURES],
tiers: TIERS,
creditAllocations,
creditCosts,
creditPacks,
stripeHealth,
subscriptionStats,
}
}
export async function updatePlanEntitlement(
@@ -73,9 +265,20 @@ export async function updatePlanEntitlement(
}
if (mode === 'unavailable') {
await prisma.planEntitlement.deleteMany({
where: { tier: tier as SubscriptionTier, feature },
})
await prisma.planEntitlement.upsert({
where: {
tier_feature: {
tier: tier as SubscriptionTier,
feature,
},
},
update: { limitValue: ENTITLEMENT_UNAVAILABLE },
create: {
tier: tier as SubscriptionTier,
feature,
limitValue: ENTITLEMENT_UNAVAILABLE,
},
});
} else {
await prisma.planEntitlement.upsert({
where: {
@@ -158,7 +361,7 @@ export async function updateBillingConfig(data: Record<string, string>) {
async function getUsageOverviewInternal() {
const period = getCurrentPeriodKey()
const periodStart = new Date(`${period}-01`)
const periodStart = new Date(`${period}-01T00:00:00.000Z`)
const aggregated = await prisma.usageLog.groupBy({
by: ['feature'],

View File

@@ -42,24 +42,47 @@ export async function getSystemConfig() {
return getCachedConfig()
}
/** Keys that may be cleared (empty string) to fall back to chat / defaults. */
const CLEARABLE_CONFIG_KEYS = new Set([
'AI_PROVIDER_SLIDES',
'AI_MODEL_SLIDES',
])
export async function updateSystemConfig(data: Record<string, string>) {
await checkAdmin()
try {
// Filter out empty values but keep 'false' as valid
const filteredData = Object.fromEntries(
Object.entries(data).filter(([key, value]) => value !== '' && value !== null && value !== undefined)
)
// Filter out empty values but keep 'false' as valid.
// Slides provider/model may be empty intentionally (= use Chat fallback).
const toUpsert: Record<string, string> = {}
const toDelete: string[] = []
const operations = Object.entries(filteredData).map(([key, value]) =>
prisma.systemConfig.upsert({
where: { key },
update: { value },
create: { key, value }
})
)
for (const [key, value] of Object.entries(data)) {
if (value === null || value === undefined) continue
if (value === '' && CLEARABLE_CONFIG_KEYS.has(key)) {
toDelete.push(key)
continue
}
if (value === '') continue
toUpsert[key] = value
}
await prisma.$transaction(operations)
const operations = [
...Object.entries(toUpsert).map(([key, value]) =>
prisma.systemConfig.upsert({
where: { key },
update: { value },
create: { key, value },
}),
),
...toDelete.map((key) =>
prisma.systemConfig.deleteMany({ where: { key } }),
),
]
if (operations.length > 0) {
await prisma.$transaction(operations)
}
return { success: true }
} catch (error) {

View File

@@ -6,6 +6,7 @@ import { auth } from '@/auth'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
import { SubscriptionTier, SubscriptionStatus } from '@prisma/client'
import { addUtcMonths } from '@/lib/billing/period'
// Schema pour la création d'utilisateur
const CreateUserSchema = z.object({
@@ -152,8 +153,7 @@ export async function updateUserSubscription(userId: string, tier: string) {
const oldTier = existing?.tier ?? 'BASIC'
const now = new Date()
const periodEnd = new Date(now)
periodEnd.setFullYear(periodEnd.getFullYear() + 1)
const periodEnd = addUtcMonths(now, 1)
await prisma.subscription.upsert({
where: { userId },
@@ -186,3 +186,60 @@ export async function updateUserSubscription(userId: string, tier: string) {
throw new Error('Failed to update subscription')
}
}
/**
* Remet à zéro les crédits IA + anciens compteurs feature du mois pour un utilisateur.
* @param feature (optionnel, legacy) — le reset crédits est global ; feature ignorée pour le solde.
*/
export async function resetUserQuotas(userId: string, feature?: string) {
const session = await checkAdmin()
if (!userId?.trim()) {
throw new Error('Identifiant utilisateur manquant')
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, email: true },
})
if (!user) {
throw new Error('Utilisateur introuvable')
}
// Nouveau système : solde de crédits global
const { adminResetCredits } = await import('@/lib/credits')
const balance = await adminResetCredits(userId, { clearPurchased: false })
// Ancien système feature (compat / métriques)
const { resetUserUsageForCurrentPeriod } = await import('@/lib/entitlements')
const legacy = await resetUserUsageForCurrentPeriod(
userId,
feature ? { features: [feature] } : undefined,
)
const { logAuditEventAsync } = await import('@/lib/audit-log')
await logAuditEventAsync({
userId: session.user?.id,
action: 'QUOTA_RESET',
resource: userId,
metadata: {
targetUserId: userId,
targetEmail: user.email,
period: balance.period,
creditsRemaining: balance.unlimited ? 'unlimited' : balance.totalRemaining,
legacyFeaturesReset: legacy.featuresReset,
scope: feature || 'all',
},
})
revalidatePath('/admin')
revalidatePath('/admin/users')
return {
success: true as const,
period: balance.period,
featuresReset: legacy.featuresReset,
redisDeleted: legacy.redisDeleted,
dbUpdated: legacy.dbUpdated,
creditsRemaining: balance.unlimited ? null : balance.totalRemaining,
}
}

View File

@@ -240,10 +240,10 @@ export async function runAgent(id: string) {
return { success: false, agentId: id, error: 'Agent introuvable' }
}
// Fire and forget — return immediately so the UI doesn't block
import('@/lib/ai/services/agent-executor.service')
.then(({ executeAgent }) => executeAgent(id, userId))
.then(() => { /* revalidation is handled client-side via polling */ })
// Load module first so import failures surface immediately (not silently after response)
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
void executeAgent(id, userId)
.catch(err => console.error('[runAgent] Background error:', err))
return { success: true, agentId: id, status: 'running' }
@@ -319,9 +319,35 @@ export async function toggleAgent(id: string, isEnabled: boolean) {
}
try {
const existing = await prisma.agent.findFirst({
where: { id, userId: session.user.id },
select: {
id: true,
frequency: true,
scheduledTime: true,
scheduledDay: true,
timezone: true,
nextRun: true,
},
})
if (!existing) throw new Error('Agent introuvable')
const data: { isEnabled: boolean; nextRun?: Date | null } = { isEnabled }
// When re-enabling a scheduled agent, ensure nextRun is set (was often null → never cron'd)
if (isEnabled && existing.frequency !== 'manual' && existing.frequency !== 'one-shot') {
const nextRun = calculateNextRun({
frequency: existing.frequency,
scheduledTime: existing.scheduledTime,
scheduledDay: existing.scheduledDay,
timezone: existing.timezone,
})
data.nextRun = nextRun
}
const agent = await prisma.agent.update({
where: { id, userId: session.user.id },
data: { isEnabled }
data,
})
return { success: true, agent }
} catch (error) {

View File

@@ -0,0 +1,11 @@
'use server'
import { verifyEmailToken, resendVerificationEmail } from '@/lib/auth/email-verification'
export async function confirmEmail(token: string) {
return verifyEmailToken(token)
}
export async function resendSignupVerification(email: string, locale?: string) {
return resendVerificationEmail(email, locale)
}

View File

@@ -2,20 +2,49 @@
import { signIn } from '@/auth';
import { AuthError } from 'next-auth';
import bcrypt from 'bcryptjs';
import prisma from '@/lib/prisma';
export async function authenticate(
prevState: string | undefined,
formData: FormData,
) {
const emailRaw = formData.get('email');
const passwordRaw = formData.get('password');
const email = typeof emailRaw === 'string' ? emailRaw.toLowerCase().trim() : '';
const password = typeof passwordRaw === 'string' ? passwordRaw : '';
// Surface a clear message when credentials are valid but email is unverified.
if (email && password.length >= 6) {
try {
const user = await prisma.user.findUnique({ where: { email } });
if (user?.password) {
const match = await bcrypt.compare(password, user.password);
if (match && !user.emailVerified) {
return 'EMAIL_NOT_VERIFIED';
}
}
} catch (preCheckErr) {
console.error('[authenticate] emailVerified pre-check failed:', preCheckErr);
}
}
try {
await signIn('credentials', {
email: formData.get('email'),
password: formData.get('password'),
email,
password,
redirectTo: '/home',
});
} catch (error) {
if (error instanceof AuthError) {
console.error('AuthError details:', error.type, error.message);
if (
error.type === 'CredentialsSignin' &&
(error.message?.includes('EMAIL_NOT_VERIFIED') ||
(error.cause as { err?: Error } | undefined)?.err?.message === 'EMAIL_NOT_VERIFIED')
) {
return 'EMAIL_NOT_VERIFIED';
}
switch (error.type) {
case 'CredentialsSignin':
return 'Invalid credentials.';

View File

@@ -2,7 +2,6 @@
import { chatService } from '@/lib/ai/services'
import { auth } from '@/auth'
import { revalidatePath } from 'next/cache'
import { prisma } from '@/lib/prisma'
/**
@@ -38,7 +37,6 @@ export async function sendChatMessage(
try {
const result = await chatService.chat(message, conversationId, notebookId)
revalidatePath('/chat')
return { success: true, ...result }
} catch (error: any) {
console.error('[ChatAction] Error:', error)

View File

@@ -4,6 +4,8 @@ import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { createHash, randomBytes } from 'crypto'
import { assertMcpAccess, isMcpTierAllowed } from '@/lib/mcp-access'
import { getEffectiveTier } from '@/lib/entitlements'
const KEY_PREFIX = 'mcp_key_'
@@ -62,6 +64,8 @@ export async function generateMcpKey(name: string): Promise<{ rawKey: string; in
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
await assertMcpAccess(session.user.id)
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { id: true, name: true, email: true },
@@ -155,6 +159,13 @@ export type McpServerStatus = {
url: string | null
}
export async function getMcpAccessStatus(): Promise<{ allowed: boolean; tier: string }> {
const session = await auth()
if (!session?.user?.id) return { allowed: false, tier: 'BASIC' }
const tier = await getEffectiveTier(session.user.id)
return { allowed: isMcpTierAllowed(tier), tier }
}
/**
* Get MCP server status — mode and URL.
*/

View File

@@ -1082,6 +1082,27 @@ export async function getRecentNotes(limit: number = 3) {
}
}
// Get count of unassigned notes (Inbox count) — notes without a notebook
export async function getInboxCount() {
const session = await auth()
if (!session?.user?.id) return 0
try {
const count = await prisma.note.count({
where: {
userId: session.user.id,
notebookId: null,
isArchived: false,
trashedAt: null,
},
})
return count
} catch (error) {
console.error('Error counting inbox notes:', error)
return 0
}
}
// Dismiss a note from Recent section
export async function dismissFromRecent(id: string) {
const session = await auth();

View File

@@ -5,6 +5,8 @@ import prisma from '@/lib/prisma'
import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { revalidatePath } from 'next/cache'
import { withAiQuota } from '@/lib/ai-quota'
import { QuotaExceededError } from '@/lib/entitlements'
export interface OrganizationNote {
id: string
@@ -134,8 +136,16 @@ Format de réponse JSON attendu:
let rawResponse: string
try {
rawResponse = await provider.generateText(prompt)
rawResponse = await withAiQuota(
session.user.id,
'reformulate',
() => provider.generateText(prompt),
{ lane: 'chat' },
)
} catch (aiErr) {
if (aiErr instanceof QuotaExceededError) {
return { success: false, error: 'Quota IA épuisé pour cette fonctionnalité.' }
}
console.error('[organize-notebook] AI generateText failed:', aiErr)
return { success: false, error: `L'IA n'a pas pu répondre : ${(aiErr as Error).message}` }
}

View File

@@ -5,6 +5,7 @@ import prisma from '@/lib/prisma';
import { z } from 'zod';
import { redirect } from 'next/navigation';
import { getSystemConfig } from '@/lib/config';
import { sendVerificationEmail } from '@/lib/auth/email-verification';
const RegisterSchema = z.object({
email: z.string().email(),
@@ -17,9 +18,8 @@ const RegisterSchema = z.object({
});
export async function register(prevState: string | undefined, formData: FormData) {
// Check if registration is allowed
const config = await getSystemConfig();
const allowRegister = config.ALLOW_REGISTRATION !== 'false' || process.env.ALLOW_REGISTRATION !== 'false';
const allowRegister = config.ALLOW_REGISTRATION !== 'false' && process.env.ALLOW_REGISTRATION !== 'false';
if (!allowRegister) {
return 'Registration is currently disabled by the administrator.';
@@ -37,36 +37,48 @@ export async function register(prevState: string | undefined, formData: FormData
}
const { email, password, name } = validatedFields.data;
const normalizedEmail = email.toLowerCase();
const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase();
const isAdmin = Boolean(adminEmail && normalizedEmail === adminEmail);
try {
const existingUser = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
const existingUser = await prisma.user.findUnique({ where: { email: normalizedEmail } });
if (existingUser) {
return 'User already exists.';
}
const hashedPassword = await bcrypt.hash(password, 10);
const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase();
const role = adminEmail && email.toLowerCase() === adminEmail ? 'ADMIN' : 'USER';
const role = isAdmin ? 'ADMIN' : 'USER';
await prisma.user.create({
data: {
email: email.toLowerCase(),
email: normalizedEmail,
password: hashedPassword,
name,
role,
// Admin bootstrap + Google OAuth are trusted; everyone else must verify.
emailVerified: isAdmin ? new Date() : null,
},
});
// Attempt to sign in immediately after registration
// We cannot import signIn here directly if it causes circular deps or issues,
// but usually it works. If not, redirecting to login is fine.
// Let's stick to redirecting to login but with a clear success message?
// Or better: lowercase the email to fix the potential bug.
if (!isAdmin) {
const mailResult = await sendVerificationEmail({
email: normalizedEmail,
name,
});
if (!mailResult.success) {
console.error('[register] verification email failed:', mailResult.error);
}
}
} catch (error) {
console.error('Registration Error:', error);
return 'Database Error: Failed to create user.';
}
redirect('/login');
if (isAdmin) {
redirect('/login?verified=1');
}
redirect(`/check-email?email=${encodeURIComponent(normalizedEmail)}`);
}

View File

@@ -1,8 +1,17 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { QuotaExceededError } from '@/lib/entitlements'
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
import { slideGenerateCreditCost, notebookSlideCreditCost, resolveCreditCost } from '@/lib/credits'
import type { FeatureName } from '@/lib/quota-utils'
import { logAuditEvent, getClientIp } from '@/lib/audit-log'
import {
encodeSlideIntentDescription,
normalizeSlideIntent,
type SlideAudience,
type SlidePurpose,
} from '@/lib/ai/services/slide-intent'
type GenerateType = 'slide-generator' | 'excalidraw-generator'
@@ -12,9 +21,10 @@ const TYPE_DEFAULTS: Record<GenerateType, {
maxSteps: number
}> = {
'slide-generator': {
role: 'Génère une présentation professionnelle à partir du contenu de la note fournie. Appelle generate_slides avec un objet JSON structuré {title, theme, slides:[...]}.',
tools: ['note_search', 'note_read', 'generate_slides'],
maxSteps: 6,
// tools unused: slide-generator runs a structured-output pipeline (no function calling)
role: 'Génère un deck exécutif de haute qualité (titres d\'action, une idée par slide, pas de filler, graphiques uniquement avec vrais chiffres).',
tools: [],
maxSteps: 1,
},
'excalidraw-generator': {
role: 'Génère un diagramme Excalidraw clair et professionnel à partir du contenu de la note fournie.',
@@ -32,36 +42,100 @@ export async function POST(req: NextRequest) {
const userId = session.user.id
const body = await req.json()
const { noteId, type, theme, style, language, template } = body as {
noteId: string
const { noteId, notebookId, type, theme, style, language, template, purpose, audience, slideCount } = body as {
noteId?: string
/** Si fourni (slides), génère un deck multi-notes du carnet */
notebookId?: string
type: GenerateType
theme?: string
style?: string
language?: string
template?: string
purpose?: SlidePurpose
audience?: SlideAudience
slideCount?: number
}
if (!noteId || !type || !TYPE_DEFAULTS[type]) {
if ((!noteId && !notebookId) || !type || !TYPE_DEFAULTS[type]) {
return NextResponse.json({ error: 'Paramètres invalides' }, { status: 400 })
}
if (notebookId && type !== 'slide-generator') {
return NextResponse.json({ error: 'notebookId réservé aux présentations' }, { status: 400 })
}
// Quota check — feature key depends on generation type
const featureKey = type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate'
let noteCountForPack = 1
if (notebookId) {
noteCountForPack = await prisma.note.count({
where: {
notebookId,
userId,
isArchived: false,
trashedAt: null,
},
})
if (noteCountForPack < 1) {
return NextResponse.json({ error: 'Carnet vide ou introuvable' }, { status: 404 })
}
}
// Crédits globaux (respecte le BYOK) — slides note = 1+N ; carnet = coût notebook
const featureKey = (type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate') as FeatureName
const creditCost =
type === 'slide-generator'
? notebookId
? notebookSlideCreditCost({ slideCount, noteCount: noteCountForPack })
: slideGenerateCreditCost(slideCount)
: resolveCreditCost(featureKey)
try {
await reserveUsageOrThrow(userId, featureKey)
await reserveAiUsageOrThrow(userId, featureKey, {
amount: creditCost,
slideCount: type === 'slide-generator' ? slideCount : undefined,
metadata: {
noteId: noteId ?? null,
notebookId: notebookId ?? null,
type,
slideCount: slideCount ?? null,
noteCount: noteCountForPack,
},
lane: 'chat',
})
} catch (e) {
if (e instanceof QuotaExceededError) {
return NextResponse.json({ error: e.message }, { status: 402 })
return NextResponse.json(
{
error: e.message,
code: 'QUOTA_EXCEEDED',
feature: featureKey,
creditCost,
},
{ status: 402 },
)
}
throw e
}
const note = await prisma.note.findFirst({
where: { id: noteId, userId },
select: { id: true, title: true, notebookId: true },
})
if (!note) {
return NextResponse.json({ error: 'Note introuvable' }, { status: 404 })
let note: { id: string; title: string | null; notebookId: string | null } | null = null
if (noteId) {
note = await prisma.note.findFirst({
where: { id: noteId, userId },
select: { id: true, title: true, notebookId: true },
})
if (!note) {
return NextResponse.json({ error: 'Note introuvable' }, { status: 404 })
}
} else if (notebookId) {
const notebook = await prisma.notebook.findFirst({
where: { id: notebookId, userId },
select: { id: true, name: true },
})
if (!notebook) {
return NextResponse.json({ error: 'Carnet introuvable' }, { status: 404 })
}
note = {
id: notebookId,
title: notebook.name,
notebookId,
}
}
const defaults = TYPE_DEFAULTS[type]
@@ -71,31 +145,62 @@ export async function POST(req: NextRequest) {
if (isEn) {
if (type === 'slide-generator') {
const recipeHint = (theme && theme !== 'auto') ? ` Use theme:"${theme}".` : ''
role = `Generate a professional presentation from the provided note content.${recipeHint} Call generate_slides with structured JSON {title, theme, slides:[...]}.`
role = `Generate a high-quality executive deck (action titles, one idea per slide, no filler, charts only with real numbers).${recipeHint}`
} else {
role = 'Generate a clear and professional Excalidraw diagram from the provided note content.'
}
} else if (type === 'slide-generator') {
const recipeHint = (theme && theme !== 'auto') ? ` Utilise le thème "${theme}".` : ''
role = `Génère une présentation professionnelle à partir du contenu de la note fournie.${recipeHint} Appelle generate_slides avec le JSON structuré {title, theme, slides:[...]}.`
role = `Génère un deck exécutif de haute qualité (titres d'action, une idée par slide, pas de filler, graphiques uniquement avec vrais chiffres).${recipeHint}`
}
if (!note) {
return NextResponse.json({ error: 'Source introuvable' }, { status: 404 })
}
const agentName = type === 'slide-generator'
? `${isEn ? 'Slides' : 'Présentation'}${(note.title || 'Note').substring(0, 40)}`
: `${isEn ? 'Diagram' : 'Diagramme'}${(note.title || 'Note').substring(0, 40)}`
const slideIntent =
type === 'slide-generator'
? normalizeSlideIntent({
purpose,
audience,
slideCount,
template: template || 'auto',
})
: null
// Map purpose → legacy template when user picks intent without template
if (slideIntent && (!slideIntent.template || slideIntent.template === 'auto')) {
const purposeToTemplate: Record<string, string> = {
board: 'board-update',
project: 'project-status',
strategy: 'strategy-review',
}
if (slideIntent.purpose && purposeToTemplate[slideIntent.purpose]) {
slideIntent.template = purposeToTemplate[slideIntent.purpose]
}
}
const sourceNotebookId = notebookId || note.notebookId || undefined
const agent = await prisma.agent.create({
data: {
name: agentName,
type,
role,
description: (type === 'slide-generator' && template && template !== 'auto') ? `template:${template}` : undefined,
description:
type === 'slide-generator' && slideIntent
? encodeSlideIntentDescription(slideIntent)
: undefined,
tools: JSON.stringify(defaults.tools),
maxSteps: defaults.maxSteps,
frequency: 'one-shot',
isEnabled: true,
sourceNoteIds: JSON.stringify([noteId]),
targetNotebookId: note.notebookId ?? undefined,
sourceNoteIds: noteId ? JSON.stringify([noteId]) : JSON.stringify([]),
sourceNotebookId: notebookId ? notebookId : undefined,
targetNotebookId: sourceNotebookId,
slideTheme: theme ?? 'keynote',
slideStyle: style ?? 'soft',
userId,
@@ -104,19 +209,31 @@ export async function POST(req: NextRequest) {
// ── Fire and forget — do NOT await so the HTTP response returns immediately ──
// In Node.js / Docker self-hosted, the process keeps running after response.
// skipQuota: units already reserved above (avoids double-charge in executeAgent).
import('@/lib/ai/services/agent-executor.service')
.then(({ executeAgent }) => executeAgent(agent.id, userId))
.then(({ executeAgent }) => executeAgent(agent.id, userId, undefined, { skipQuota: true }))
.catch(err => console.error('[run-for-note] Background agent error:', err))
logAuditEvent({
userId,
action: 'AI_REQUEST',
resource: featureKey,
metadata: { agentId: agent.id, noteId, featureKey },
metadata: {
agentId: agent.id,
noteId: noteId ?? null,
notebookId: notebookId ?? null,
featureKey,
creditCost,
},
ip: getClientIp(req),
})
return NextResponse.json({ success: true, agentId: agent.id, status: 'running' })
return NextResponse.json({
success: true,
agentId: agent.id,
status: 'running',
creditCost,
})
}
// ─── GET : poll current agent status ──────────────────────────────────────

View File

@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const result = await agentSuggestionService.accept(session.user.id, id)
if (!result) {
return NextResponse.json({ error: 'Suggestion introuvable' }, { status: 404 })
}
return NextResponse.json({ success: true, agentId: result.agentId })
}

View File

@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const result = await agentSuggestionService.dismiss(session.user.id, id)
if (!result) {
return NextResponse.json({ error: 'Suggestion introuvable' }, { status: 404 })
}
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const suggestions = await agentSuggestionService.getPending(session.user.id, 3)
return NextResponse.json({
suggestions: suggestions.map(s => ({
id: s.id,
topic: s.topic,
reason: s.reason,
suggestedType: s.suggestedType,
suggestedFrequency: s.suggestedFrequency,
relatedNoteCount: (() => {
try {
return JSON.parse(s.relatedNoteIds).length
} catch {
return 0
}
})(),
clusterId: s.clusterId,
createdAt: s.createdAt.toISOString(),
})),
})
}

View File

@@ -3,7 +3,8 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { autoLabelCreationService } from '@/lib/ai/services'
import { getAISettings } from '@/app/actions/ai-settings'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenJson } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
/**
* POST /api/ai/auto-labels - Suggest new labels for a notebook
@@ -18,13 +19,11 @@ export async function POST(request: NextRequest) {
{ status: 401 }
)
}
const userId = session.user.id
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return NextResponse.json(
{ success: false, error: 'ai_consent_required' },
{ status: 403 }
)
return aiConsentForbiddenJson()
}
// Respect user's autoLabeling toggle
@@ -59,11 +58,16 @@ export async function POST(request: NextRequest) {
)
}
// Get label suggestions
const suggestions = await autoLabelCreationService.suggestLabels(
notebookId,
session.user.id,
language
const suggestions = await withAiQuota(
userId,
'auto_tag',
() =>
autoLabelCreationService.suggestLabels(
notebookId,
userId,
language,
),
{ lane: 'tags' },
)
if (!suggestions) {
@@ -79,8 +83,10 @@ export async function POST(request: NextRequest) {
data: suggestions,
})
} catch (error) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[/api/ai/auto-labels] POST failed:', error)
return NextResponse.json({ success: true, data: null })
return NextResponse.json({ success: false, error: 'auto_labels_failed' }, { status: 500 })
}
}

View File

@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { batchOrganizationService } from '@/lib/ai/services'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenJson } from '@/lib/consent/server-consent'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
/**
* POST /api/ai/batch-organize - Create organization plan for notes in Inbox
@@ -19,10 +20,7 @@ export async function POST(request: NextRequest) {
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return NextResponse.json(
{ success: false, error: 'ai_consent_required' },
{ status: 403 }
)
return aiConsentForbiddenJson()
}
// Get language from request headers or body
@@ -40,6 +38,8 @@ export async function POST(request: NextRequest) {
}
}
await reserveUsageOrThrow(session.user.id, 'reformulate')
// Create organization plan
const plan = await batchOrganizationService.createOrganizationPlan(
session.user.id,

View File

@@ -2,7 +2,8 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getAISettings } from '@/app/actions/ai-settings'
import { describeImages } from '@/lib/ai/services/image-description.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(req: NextRequest) {
try {
@@ -11,9 +12,8 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const userSettings = await getAISettings(session.user.id)
@@ -27,23 +27,31 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'imageUrls must be a non-empty array' }, { status: 400 })
}
const result = await describeImages(
imageUrls,
mode === 'title' ? 'title' : 'description',
language || 'fr'
const isTitleMode = mode === 'title'
const feature = isTitleMode ? 'auto_title' : 'reformulate'
const lane = isTitleMode ? 'tags' : 'chat'
const result = await withAiQuota(
session.user.id,
feature,
() => describeImages(
imageUrls,
isTitleMode ? 'title' : 'description',
language || 'fr'
),
{ lane },
)
// For title mode, return suggestions in same format as /api/ai/title-suggestions
if (mode === 'title' && result.suggestions) {
if (isTitleMode && result.suggestions) {
return NextResponse.json({ suggestions: result.suggestions })
}
return NextResponse.json(result)
} catch (error: any) {
} catch (error: unknown) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[describe-image] Error:', error)
return NextResponse.json(
{ error: error.message || 'Failed to describe image' },
{ status: 500 }
)
const message = error instanceof Error ? error.message : 'Failed to describe image'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
/**
* GET /api/ai/echo/connections?noteId={id}&page={page}&limit={limit}
@@ -19,10 +19,7 @@ export async function GET(req: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json(
{ error: 'ai_consent_required' },
{ status: 403 }
)
return aiConsentForbiddenResponse()
}
// Get query parameters

View File

@@ -3,7 +3,8 @@ import { auth } from '@/auth'
import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import prisma from '@/lib/prisma'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
/**
* POST /api/ai/echo/fusion
@@ -21,7 +22,7 @@ export async function POST(req: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const body = await req.json()
@@ -34,7 +35,6 @@ export async function POST(req: NextRequest) {
)
}
// Fetch the notes
const notes = await prisma.note.findMany({
where: {
id: { in: noteIds },
@@ -55,11 +55,9 @@ export async function POST(req: NextRequest) {
)
}
// Get AI provider
const config = await getSystemConfig()
const provider = getChatProvider(config)
// Build fusion prompt
const notesDescriptions = notes.map((note, index) => {
return `Note ${index + 1}: "${note.title || 'Untitled'}"
${note.content}`
@@ -90,21 +88,21 @@ Output format:
Begin:`
try {
const fusedContent = await provider.generateText(fusionPrompt)
return NextResponse.json({
fusedNote: fusedContent,
notesCount: notes.length
})
} catch (error) {
return NextResponse.json(
{ error: 'Failed to generate fusion' },
{ status: 500 }
)
}
const fusedContent = await withAiQuota(
session.user.id,
'reformulate',
() => provider.generateText(fusionPrompt),
{ lane: 'chat' },
)
return NextResponse.json({
fusedNote: fusedContent,
notesCount: notes.length
})
} catch (error) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[/api/ai/echo/fusion] Error:', error)
return NextResponse.json(
{ error: 'Failed to process fusion request' },
{ status: 500 }

View File

@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
/**
* GET /api/ai/echo
@@ -20,13 +21,11 @@ export async function GET(req: NextRequest) {
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return NextResponse.json(
{ error: 'ai_consent_required' },
{ status: 403 }
)
return aiConsentForbiddenResponse()
}
// Get next insight (respects frequency limits)
await reserveUsageOrThrow(session.user.id, 'reformulate')
const insight = await memoryEchoService.getNextInsight(session.user.id)
if (!insight) {
@@ -41,6 +40,9 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ insight })
} catch (error) {
if (error instanceof QuotaExceededError) {
return NextResponse.json(error.toJSON(), { status: 429 })
}
console.error('[/api/ai/echo] GET error:', error)
return NextResponse.json(
{ error: 'Failed to fetch Memory Echo insight' },

View File

@@ -2,7 +2,8 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getSystemConfig } from '@/lib/config'
import { getTagsProvider } from '@/lib/ai/factory'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(request: NextRequest) {
try {
@@ -12,7 +13,7 @@ export async function POST(request: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const { existingContent, resourceText, mode, language, format } = await request.json()
@@ -32,7 +33,6 @@ export async function POST(request: NextRequest) {
let prompt: string
if (mode === 'complete') {
// Add missing info from resource without rewriting the existing note
prompt = `You are an expert note editor. Your task is to enrich an existing note by adding relevant information from a provided resource, WITHOUT modifying or rewriting the existing content.
LANGUAGE RULE: Respond in ${lang}. Match the language of the existing note.
@@ -56,7 +56,6 @@ INSTRUCTIONS:
- Format the new content consistently with the existing note style and the requested FORMAT RULE
- Respond ONLY with the enriched note content, no explanations`
} else {
// Merge: intelligently rewrite integrating both sources
prompt = `You are an expert note writer. Your task is to intelligently merge an existing note with a resource into a single, coherent, well-structured document.
LANGUAGE RULE: Respond in ${lang}. Match the language of the existing note.
@@ -81,14 +80,19 @@ INSTRUCTIONS:
- Respond ONLY with the merged content, no meta-commentary or explanations`
}
const enrichedContent = await provider.generateText(prompt)
const enrichedContent = await withAiQuota(
session.user.id,
'reformulate',
() => provider.generateText(prompt),
{ lane: 'chat' },
)
return NextResponse.json({ enrichedContent: enrichedContent.trim() })
} catch (error: any) {
} catch (error: unknown) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[enrich-from-resource] Error:', error)
return NextResponse.json(
{ error: error.message || 'Failed to enrich content' },
{ status: 500 }
)
const message = error instanceof Error ? error.message : 'Failed to enrich content'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -0,0 +1,125 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { auth } from '@/auth'
import { getSystemConfig } from '@/lib/config'
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { getSlidesProvider } from '@/lib/ai/factory'
import { generateInteractiveDemoFromContent } from '@/lib/ai/services/interactive-demo-generate.service'
export const maxDuration = 180
const requestSchema = z.object({
content: z.string().min(20),
selection: z.string().optional().nullable(),
lang: z.string().optional(),
noteId: z.string().optional(),
})
function stripHtml(html: string): string {
return html
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim()
}
export async function POST(req: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!(await hasUserAiConsent())) {
return aiConsentForbiddenResponse()
}
const body = await req.json()
const parsed = requestSchema.parse(body)
// Keep raw HTML/markdown so formula extractors see $...$ / KaTeX like slides
const sourceRaw = parsed.selection?.trim() || parsed.content
const sourcePlain = stripHtml(sourceRaw)
const wordCount = sourcePlain.split(/\s+/).filter(Boolean).length
if (wordCount < 20) {
return NextResponse.json(
{
error:
'Sélectionne au moins ~20 mots de contenu pour générer une démo interactive',
},
{ status: 400 }
)
}
try {
await reserveAiUsageOrThrow(session.user.id, 'interactive_demo', {
lane: 'chat',
})
} catch (err) {
if (err instanceof QuotaExceededError) {
return NextResponse.json(err.toJSON(), { status: 402 })
}
if (
err instanceof QuotaServiceUnavailableError ||
process.env.NODE_ENV === 'production'
) {
return NextResponse.json(
{ error: 'QUOTA_SERVICE_UNAVAILABLE' },
{ status: 503 }
)
}
console.error('[/api/ai/interactive-demo] Quota check error (fail-open):', err)
}
const config = await getSystemConfig()
const lang = parsed.lang || 'fr'
// Same admin model as slide decks (AI_PROVIDER_SLIDES / AI_MODEL_SLIDES → chat fallback)
const provider = getSlidesProvider(config)
const result = await generateInteractiveDemoFromContent({
content: sourceRaw,
lang,
provider,
})
if (!result.ok) {
const first = result.issues[0]
const detail = first
? `${first.path ? first.path + ': ' : ''}${first.message}`
: ''
console.error(
'[/api/ai/interactive-demo] validation failed after repairs',
result.issues.slice(0, 10)
)
return NextResponse.json(
{
error: detail
? `Démo invalide après correction — ${detail}`
: 'La génération a produit un JSON invalide après correction',
issues: result.issues.slice(0, 10),
attempts: result.attempts,
},
{ status: 422 }
)
}
return NextResponse.json({
demo: result.demo,
attempts: result.attempts,
})
} catch (error: unknown) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.issues }, { status: 400 })
}
const message =
error instanceof Error ? error.message : 'Erreur génération interactive demo'
console.error('[/api/ai/interactive-demo]', error)
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -0,0 +1,214 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { auth } from '@/auth'
import { getSystemConfig } from '@/lib/config'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { getSlidesProvider } from '@/lib/ai/factory'
import { generateInteractivePageFromContent } from '@/lib/ai/services/interactive-page-generate.service'
import {
generatePagePlan,
generatePageSection,
} from '@/lib/ai/services/interactive-page-llm.service'
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
export const maxDuration = 60
const sectionPlanSchema = z.object({
title: z.string().min(1),
goal: z.string().min(1),
demoKind: z.enum(['steps', 'svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none']),
demoGoal: z.string().nullish(),
})
const requestSchema = z.object({
/** undefined = legacy deterministic full-page (fallback path) */
action: z.enum(['plan', 'section']).optional(),
content: z.string().min(40).max(500_000),
lang: z.string().optional(),
noteId: z.string().optional(),
notebookId: z.string().optional(),
pageTitle: z.string().optional(),
sectionId: z.string().optional(),
section: sectionPlanSchema.optional(),
})
export async function POST(req: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!(await hasUserAiConsent())) {
return aiConsentForbiddenResponse()
}
const body = await req.json().catch(() => null)
if (!body || typeof body !== 'object') {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const parsed = requestSchema.parse(body)
const wordCount = parsed.content
.replace(/<[^>]+>/g, ' ')
.split(/\s+/)
.filter(Boolean).length
if (wordCount < 40) {
return NextResponse.json(
{ error: 'Contenu trop court pour une page interactive (~40 mots min.)' },
{ status: 400 }
)
}
const config = await getSystemConfig()
const provider = getSlidesProvider(config)
const lang = parsed.lang || 'fr'
// ── LLM plan (billed 5/20 crédits — le reste est facturé par section) ──
if (parsed.action === 'plan') {
try {
await reserveAiUsageOrThrow(session.user.id, 'interactive_page', {
lane: 'chat',
amount: 5,
})
} catch (err) {
if (err instanceof QuotaExceededError) {
return NextResponse.json(err.toJSON(), { status: 402 })
}
if (
err instanceof QuotaServiceUnavailableError ||
process.env.NODE_ENV === 'production'
) {
return NextResponse.json(
{ error: 'QUOTA_SERVICE_UNAVAILABLE' },
{ status: 503 }
)
}
console.error('[/api/ai/interactive-page] Quota check error (fail-open):', err)
}
const result = await generatePagePlan({
content: parsed.content,
lang,
provider,
})
if (!result.ok) {
return NextResponse.json(
{ error: result.error, reason: result.reason, attempts: result.attempts },
{ status: result.error === 'unsuitable_content' ? 422 : 502 }
)
}
return NextResponse.json({ plan: result.plan, attempts: result.attempts })
}
// ── LLM single section (billed 3/20 crédits per section — no free LLM) ──
if (parsed.action === 'section') {
if (!parsed.section || !parsed.sectionId || !parsed.pageTitle) {
return NextResponse.json(
{ error: 'section, sectionId and pageTitle are required' },
{ status: 400 }
)
}
try {
await reserveAiUsageOrThrow(session.user.id, 'interactive_page', {
lane: 'chat',
amount: 3,
})
} catch (err) {
if (err instanceof QuotaExceededError) {
return NextResponse.json(err.toJSON(), { status: 402 })
}
if (
err instanceof QuotaServiceUnavailableError ||
process.env.NODE_ENV === 'production'
) {
return NextResponse.json(
{ error: 'QUOTA_SERVICE_UNAVAILABLE' },
{ status: 503 }
)
}
console.error('[/api/ai/interactive-page] Quota check error (fail-open):', err)
}
const result = await generatePageSection({
content: parsed.content,
lang,
provider,
pageTitle: parsed.pageTitle,
sectionId: parsed.sectionId,
section: parsed.section,
})
if (!result.ok) {
return NextResponse.json(
{
error: 'section_generation_failed',
issues: result.issues?.slice(0, 12),
attempts: result.attempts,
},
{ status: 422 }
)
}
return NextResponse.json({ section: result.section, attempts: result.attempts })
}
// ── Legacy deterministic full page (fallback, no LLM → no quota) ─────
const result = await generateInteractivePageFromContent({
content: parsed.content,
lang,
provider,
})
if (!result.ok) {
if (result.error === 'unsuitable_content') {
return NextResponse.json(
{
error: 'unsuitable_content',
reason: result.reason,
attempts: result.attempts,
},
{ status: 422 }
)
}
const first = result.issues?.[0]
const reason =
result.reason ||
(first
? `${first.path ? first.path + ': ' : ''}${first.message}`
: undefined)
return NextResponse.json(
{
error:
result.error === 'timeout'
? 'La génération a pris trop de temps — réessayez'
: reason ||
'La génération a produit une page invalide',
reason,
issues: result.issues?.slice(0, 12),
attempts: result.attempts,
},
{ status: 422 }
)
}
return NextResponse.json({
page: result.page,
attempts: result.attempts,
})
} catch (error: unknown) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: 'Requête invalide',
issues: error.issues.slice(0, 10).map((i) => ({
path: i.path.join('.'),
message: i.message,
})),
},
{ status: 400 }
)
}
const message =
error instanceof Error ? error.message : 'Erreur génération interactive page'
console.error('[/api/ai/interactive-page]', error)
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { generateSlideDeck } from '@/lib/ai/services/slide-deck-generator.service'
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!(await hasUserAiConsent())) {
return aiConsentForbiddenResponse()
}
const userId = session.user.id
const body = await request.json()
const { notebookId, theme, purpose, audience, slideCount, language } = body
if (!notebookId || typeof notebookId !== 'string') {
return NextResponse.json({ error: 'notebookId is required' }, { status: 400 })
}
const intent =
purpose || audience || slideCount
? {
purpose: purpose || 'auto',
audience: audience || 'auto',
slideCount: typeof slideCount === 'number' ? slideCount : undefined,
template: 'auto' as const,
}
: null
const result = await withAiQuota(
userId,
'slide_generate',
() =>
generateSlideDeck({
userId,
sourceNotebookId: notebookId,
theme: theme && theme !== 'auto' ? theme : null,
lang: language === 'en' ? 'en' : 'fr',
intent,
}),
{ lane: 'chat' },
)
if (!result.success) {
return NextResponse.json({ error: result.error || 'Failed to generate slides' }, { status: 500 })
}
return NextResponse.json(result)
} catch (error: unknown) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[NotebookSlides] Error:', error)
const message = error instanceof Error ? error.message : 'Failed to generate slides'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -2,7 +2,8 @@ import { rateLimit } from '@/lib/rate-limit'
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { notebookSummaryService } from '@/lib/ai/services'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenJson } from '@/lib/consent/server-consent'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
/**
* POST /api/ai/notebook-summary - Generate summary for a notebook
@@ -19,10 +20,7 @@ export async function POST(request: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json(
{ success: false, error: 'ai_consent_required' },
{ status: 403 }
)
return aiConsentForbiddenJson()
}
const body = await request.json()
@@ -51,6 +49,8 @@ export async function POST(request: NextRequest) {
)
}
await reserveUsageOrThrow(session.user.id, 'reformulate')
// Generate summary
const summary = await notebookSummaryService.generateSummary(
notebookId,

View File

@@ -33,19 +33,35 @@ export async function POST(request: NextRequest) {
throw err
}
// language = UI locale (fallback only). Content language = language of the topic string.
const result = await notebookWizardService.generateCarnet(
profile as WizardProfile,
topic,
{ level, count, language: language || 'fr' }
)
// Prefer AI short title over raw user prompt (notebookName from client is often the full message)
const resolvedName = (
result.notebookName?.trim()
|| (notebookName && notebookName.trim().length <= 60 ? notebookName.trim() : '')
|| result.notes[0]?.title?.trim()
|| topic.trim().slice(0, 60)
).slice(0, 80)
// Place new notebook at top of manual order (before existing min order)
const minOrder = await prisma.notebook.aggregate({
where: { userId: session.user.id, trashedAt: null },
_min: { order: true },
})
const nextOrder = (minOrder._min.order ?? 0) - 1
// 1. Create notebook
const notebook = await prisma.notebook.create({
data: {
name: notebookName || topic,
name: resolvedName,
icon: notebookIcon || '📚',
userId: session.user.id,
order: 0,
order: nextOrder,
},
})
@@ -87,6 +103,8 @@ export async function POST(request: NextRequest) {
notebookId: notebook.id,
type: 'richtext',
order: noteIndex++,
autoGenerated: true,
aiProvider: 'notebook-wizard',
},
})

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { notebookOrganizerService } from '@/lib/ai/services/notebook-organizer.service'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
import { syncNoteLabels } from '@/app/actions/notes'
export async function POST(request: NextRequest) {
@@ -17,19 +17,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'notebookId is required' }, { status: 400 })
}
try {
await reserveUsageOrThrow(session.user.id, 'reformulate')
} catch (err) {
if (err instanceof QuotaExceededError) {
const isTierLocked = err.currentQuota === 0
return NextResponse.json(
{ error: isTierLocked ? 'feature_locked' : 'quota_exceeded', errorKey: isTierLocked ? 'ai.featureLocked' : 'ai.quotaExceeded' },
{ status: 402 },
)
}
throw err
}
const notes = await prisma.note.findMany({
where: { notebookId, trashedAt: null, userId: session.user.id },
select: { id: true, title: true, content: true },
@@ -46,13 +33,20 @@ export async function POST(request: NextRequest) {
contentPreview: n.content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 300),
}))
const result = await notebookOrganizerService.analyze(notesForAnalysis)
const result = await withAiQuota(
session.user.id,
'reformulate',
() => notebookOrganizerService.analyze(notesForAnalysis),
{ lane: 'chat' },
)
return NextResponse.json(result)
} catch (error: any) {
} catch (error: unknown) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[Notebook Organizer] Error:', error)
return NextResponse.json({ error: error.message || 'Failed to organize notebook' }, { status: 500 })
const message = error instanceof Error ? error.message : 'Failed to organize notebook'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -3,7 +3,7 @@ import { auth } from '@/auth'
import { getSystemConfig } from '@/lib/config'
import { getTagsProvider } from '@/lib/ai/factory'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
export type PersonaId = 'engineer' | 'financial' | 'customer' | 'skeptic' | 'optimist'
@@ -70,7 +70,7 @@ export async function POST(request: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const { content, title, personaId } = await request.json()

View File

@@ -3,7 +3,7 @@ import { auth } from '@/auth'
import { paragraphRefactorService } from '@/lib/ai/services/paragraph-refactor.service'
import { getAISettings } from '@/app/actions/ai-settings'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
export async function POST(request: NextRequest) {
try {
@@ -14,7 +14,7 @@ export async function POST(request: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
// Respect user's paragraphRefactor toggle (Assistant IA)

View File

@@ -1,7 +1,7 @@
import { rateLimit } from '@/lib/rate-limit'
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { searchOverviewService } from '@/lib/ai/services/search-overview.service'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(request: NextRequest) {
try {
@@ -15,11 +15,19 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'query and results required' }, { status: 400 })
}
const overview = await searchOverviewService.generate(query, results, language)
const overview = await withAiQuota(
session.user.id,
'semantic_search',
() => searchOverviewService.generate(query, results, language),
{ lane: 'chat' },
)
return NextResponse.json(overview)
} catch (error: any) {
} catch (error: unknown) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[Search Overview] Error:', error)
return NextResponse.json({ error: error.message, hasRelevantInfo: false, answer: '' }, { status: 500 })
const message = error instanceof Error ? error.message : 'Search overview failed'
return NextResponse.json({ error: message, hasRelevantInfo: false, answer: '' }, { status: 500 })
}
}

View File

@@ -5,7 +5,7 @@ import { getSystemConfig } from '@/lib/config'
import { prisma } from '@/lib/prisma'
import { auth } from '@/auth'
import { reserveUsageOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
export const maxDuration = 30
@@ -41,10 +41,7 @@ export async function POST(req: Request) {
}
if (!(await hasUserAiConsent())) {
return new Response(JSON.stringify({ error: 'ai_consent_required' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
})
return aiConsentForbiddenResponse()
}
const userId = session.user.id

View File

@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { notebookSuggestionService } from '@/lib/ai/services/notebook-suggestion.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(req: NextRequest) {
try {
@@ -9,9 +10,10 @@ export async function POST(req: NextRequest) {
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const body = await req.json()
@@ -21,7 +23,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'noteContent is required' }, { status: 400 })
}
// Minimum content length for suggestion (20 words as per specs)
const wordCount = noteContent.trim().split(/\s+/).length
if (wordCount < 20) {
return NextResponse.json({
@@ -31,18 +32,25 @@ export async function POST(req: NextRequest) {
})
}
// Get suggestion from AI service
const suggestedNotebook = await notebookSuggestionService.suggestNotebook(
noteContent,
session.user.id,
language
const suggestedNotebook = await withAiQuota(
userId,
'auto_tag',
() => notebookSuggestionService.suggestNotebook(
noteContent,
userId,
language
),
{ lane: 'tags' },
)
return NextResponse.json({
suggestion: suggestedNotebook,
confidence: suggestedNotebook ? 0.8 : 0 // Placeholder confidence score
confidence: suggestedNotebook ? 0.8 : 0
})
} catch (error) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[/api/ai/suggest-notebook] Error:', error)
return NextResponse.json(
{ error: 'Failed to generate suggestion' },
{ status: 500 }

View File

@@ -6,7 +6,7 @@ import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-fo
import { getSystemConfig } from '@/lib/config';
import { z } from 'zod';
import { checkEntitlementOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements';
import { hasUserAiConsent } from '@/lib/consent/server-consent';
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent';
import { getAISettings } from '@/app/actions/ai-settings';
@@ -25,7 +25,7 @@ export async function POST(req: NextRequest) {
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 });
return aiConsentForbiddenResponse();
}
const userSettings = await getAISettings(session.user.id);

View File

@@ -1,31 +1,19 @@
import { rateLimit } from '@/lib/rate-limit'
import { NextRequest, NextResponse } from 'next/server'
import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user'
import { runLaneWithBillingUser } from '@/lib/ai/provider-for-user'
import { getSystemConfig } from '@/lib/config'
import { auth } from '@/auth'
import { getAISettings } from '@/app/actions/ai-settings'
import { reserveUsageOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
import { z } from 'zod'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { countPlainWords, MIN_WORDS_FOR_TITLE_SUGGESTION, stripHtmlToPlainText } from '@/lib/text/plain-text'
const requestSchema = z.object({
content: z.string().min(1, "Le contenu ne peut pas être vide"),
})
/** Supprime les balises HTML pour extraire le texte brut */
function stripHtml(html: string): string {
return html
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim()
}
export async function POST(req: NextRequest) {
try {
// Check authentication and user setting
@@ -36,7 +24,7 @@ export async function POST(req: NextRequest) {
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const settings = await getAISettings(session.user.id)
@@ -47,33 +35,23 @@ export async function POST(req: NextRequest) {
const body = await req.json()
const { content: rawContent } = requestSchema.parse(body)
// Nettoyer le HTML (l'éditeur TipTap envoie du HTML)
const content = stripHtml(rawContent)
const content = stripHtmlToPlainText(rawContent)
// Vérifier qu'il y a au moins 10 mots
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
if (wordCount < 10) {
return NextResponse.json(
{ error: 'Le contenu doit avoir au moins 10 mots' },
{ status: 400 }
)
if (countPlainWords(content) < MIN_WORDS_FOR_TITLE_SUGGESTION) {
return NextResponse.json({ suggestions: [] })
}
const config = await getSystemConfig()
const { usedByok: willUseByok } = await willUseByokForLane('tags', config, session.user.id)
if (!willUseByok) {
try {
await reserveUsageOrThrow(session.user.id, 'auto_title')
} catch (err) {
if (err instanceof QuotaExceededError) {
return NextResponse.json(err.toJSON(), { status: 402 })
}
if (err instanceof QuotaServiceUnavailableError || process.env.NODE_ENV === 'production') {
return NextResponse.json({ error: 'QUOTA_SERVICE_UNAVAILABLE' }, { status: 503 })
}
console.error('[/api/ai/title-suggestions] Quota check error (fail-open):', err)
try {
await reserveAiUsageOrThrow(session.user.id, 'auto_title', { lane: 'tags' })
} catch (err) {
if (err instanceof QuotaExceededError) {
return NextResponse.json(err.toJSON(), { status: 402 })
}
if (err instanceof QuotaServiceUnavailableError || process.env.NODE_ENV === 'production') {
return NextResponse.json({ error: 'QUOTA_SERVICE_UNAVAILABLE' }, { status: 503 })
}
console.error('[/api/ai/title-suggestions] Quota check error (fail-open):', err)
}
// Détecter la langue du contenu (simple détection basée sur les caractères et mots)

View File

@@ -2,7 +2,8 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(request: NextRequest) {
try {
@@ -13,17 +14,15 @@ export async function POST(request: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const { text } = await request.json()
// Validation
if (!text || typeof text !== 'string') {
return NextResponse.json({ error: 'Text is required' }, { status: 400 })
}
// Validate word count
const wordCount = text.split(/\s+/).length
if (wordCount < 10) {
return NextResponse.json(
@@ -42,11 +41,9 @@ export async function POST(request: NextRequest) {
const config = await getSystemConfig()
const provider = getChatProvider(config)
// Detect language from text
const hasFrench = /[àâäéèêëïîôùûüÿç]/i.test(text)
const responseLanguage = hasFrench ? 'French' : 'English'
// Build prompt to transform text to Markdown
const prompt = hasFrench
? `Tu es un expert en Markdown. Transforme ce texte ${responseLanguage} en Markdown bien formaté.
@@ -77,19 +74,22 @@ ${text}
Respond ONLY with the transformed Markdown text, no explanations.`
const transformedText = await provider.generateText(prompt)
const transformedText = await withAiQuota(
session.user.id,
'reformulate',
() => provider.generateText(prompt),
{ lane: 'chat' },
)
return NextResponse.json({
originalText: text,
transformedText: transformedText,
transformedText,
language: responseLanguage
})
} catch (error: any) {
return NextResponse.json(
{ error: error.message || 'Failed to transform text to Markdown' },
{ status: 500 }
)
} catch (error: unknown) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
const message = error instanceof Error ? error.message : 'Failed to transform text to Markdown'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -2,7 +2,8 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getTagsProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
export async function POST(request: NextRequest) {
try {
@@ -12,7 +13,7 @@ export async function POST(request: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const { text, targetLanguage } = await request.json()
@@ -24,12 +25,18 @@ export async function POST(request: NextRequest) {
const config = await getSystemConfig()
const provider = getTagsProvider(config)
await reserveUsageOrThrow(session.user.id, 'reformulate')
const prompt = `Translate the following text to ${targetLanguage}. Return ONLY the translated text, no explanation, no preamble, no quotes:\n\n${text}`
const translatedText = await provider.generateText(prompt)
return NextResponse.json({ translatedText: translatedText.trim() })
} catch (error: any) {
return NextResponse.json({ error: error.message || 'Translation failed' }, { status: 500 })
} catch (error: unknown) {
if (error instanceof QuotaExceededError) {
return NextResponse.json(error.toJSON(), { status: 429 })
}
const message = error instanceof Error ? error.message : 'Translation failed'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -1,13 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { stripe } from '@/lib/stripe';
import { resolvePriceId } from '@/lib/billing/stripe-prices';
import { isBillingEnabled, resolvePriceId } from '@/lib/billing/stripe-prices';
import {
shouldOfferSubscriptionTrial,
SUBSCRIPTION_TRIAL_DAYS,
} from '@/lib/billing/trial';
import { prisma } from '@/lib/prisma';
import { z } from 'zod';
const bodySchema = z.object({
tier: z.enum(['PRO', 'BUSINESS']),
interval: z.enum(['month', 'year']),
/** Prefer hosted redirect when embedded checkout is unavailable */
mode: z.enum(['hosted', 'embedded']).optional(),
});
export async function POST(req: NextRequest) {
@@ -16,17 +22,46 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (!(await isBillingEnabled())) {
return NextResponse.json({ error: 'Billing is not enabled' }, { status: 403 });
}
const secret = process.env.STRIPE_SECRET_KEY;
if (!secret || secret === 'sk_test_placeholder') {
return NextResponse.json(
{ error: 'Stripe is not configured (STRIPE_SECRET_KEY missing)' },
{ status: 503 },
);
}
const parsed = bodySchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
const { tier, interval } = parsed.data;
const preferredMode = parsed.data.mode ?? 'hosted';
const userId = session.user.id;
const userEmail = session.user.email;
try {
const priceId = await resolvePriceId(tier, interval);
let priceId: string;
try {
priceId = await resolvePriceId(tier, interval);
} catch (e) {
console.error('[billing/create-checkout] price resolve failed:', e);
return NextResponse.json(
{ error: 'Stripe price IDs not configured. Set them in Admin > Billing.' },
{ status: 503 },
);
}
if (priceId.startsWith('price_mock_')) {
return NextResponse.json(
{ error: 'Stripe price IDs not configured. Set them in Admin > Billing.' },
{ status: 503 },
);
}
const subscription = await prisma.subscription.findUnique({ where: { userId } });
let customerId = subscription?.stripeCustomerId ?? undefined;
@@ -41,8 +76,7 @@ export async function POST(req: NextRequest) {
metadata: { userId },
});
customerId = customer.id;
// Update DB to save the real Stripe customer ID
await prisma.subscription.upsert({
where: { userId },
update: { stripeCustomerId: customerId },
@@ -52,8 +86,8 @@ export async function POST(req: NextRequest) {
tier: 'BASIC',
status: 'ACTIVE',
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 3600 * 1000), // temp basic dates
}
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 3600 * 1000),
},
});
}
@@ -61,29 +95,67 @@ export async function POST(req: NextRequest) {
const proto = req.headers.get('x-forwarded-proto') ?? 'http';
const origin = `${proto}://${host}`;
const sessionParams = {
customer: customerId,
mode: 'subscription' as const,
line_items: [{ price: priceId, quantity: 1 }],
ui_mode: 'embedded_page',
return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
const offerTrial = await shouldOfferSubscriptionTrial(userId);
const subscriptionData: {
metadata: { userId: string; tier: string };
trial_period_days?: number;
} = {
metadata: { userId, tier },
subscription_data: { metadata: { userId, tier } },
customer_update: { address: 'auto' },
allow_promotion_codes: true,
};
const checkoutSession = await stripe.checkout.sessions.create(sessionParams as any);
if (checkoutSession.client_secret) {
return NextResponse.json({
clientSecret: checkoutSession.client_secret,
sessionId: checkoutSession.id,
});
if (offerTrial) {
subscriptionData.trial_period_days = SUBSCRIPTION_TRIAL_DAYS;
}
return NextResponse.json({ url: checkoutSession.url });
// Hosted Checkout is the most reliable path (redirect). Embedded is optional.
if (preferredMode === 'embedded') {
try {
const embedded = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
ui_mode: 'embedded' as any,
return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
metadata: { userId, tier, trial: offerTrial ? '1' : '0' },
subscription_data: subscriptionData,
customer_update: { address: 'auto' },
allow_promotion_codes: true,
} as any);
if (embedded.client_secret) {
return NextResponse.json({
clientSecret: embedded.client_secret,
sessionId: embedded.id,
trialDays: offerTrial ? SUBSCRIPTION_TRIAL_DAYS : 0,
});
}
} catch (embeddedErr) {
console.warn('[billing/create-checkout] embedded failed, falling back to hosted:', embeddedErr);
}
}
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/settings/billing?canceled=1`,
metadata: { userId, tier, trial: offerTrial ? '1' : '0' },
subscription_data: subscriptionData,
customer_update: { address: 'auto' },
allow_promotion_codes: true,
});
if (!checkoutSession.url) {
return NextResponse.json({ error: 'Checkout session has no URL' }, { status: 500 });
}
return NextResponse.json({
url: checkoutSession.url,
sessionId: checkoutSession.id,
trialDays: offerTrial ? SUBSCRIPTION_TRIAL_DAYS : 0,
});
} catch (error) {
console.error('[billing/create-checkout]', error);
return NextResponse.json({ error: 'Failed to create checkout session' }, { status: 500 });
const msg = error instanceof Error ? error.message : 'Failed to create checkout session';
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View File

@@ -0,0 +1,135 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { stripe } from '@/lib/stripe'
import { isBillingEnabled } from '@/lib/billing/stripe-prices'
import {
isCreditPackId,
resolvePackPriceId,
getCreditPack,
} from '@/lib/billing/credit-packs'
import { prisma } from '@/lib/prisma'
import { z } from 'zod'
const bodySchema = z.object({
packId: z.enum(['S', 'M', 'L']),
})
export async function POST(req: NextRequest) {
const session = await auth()
if (!session?.user?.id || !session.user.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!(await isBillingEnabled())) {
return NextResponse.json({ error: 'Billing is not enabled' }, { status: 403 })
}
const secret = process.env.STRIPE_SECRET_KEY
if (!secret || secret === 'sk_test_placeholder') {
return NextResponse.json(
{ error: 'Stripe is not configured (STRIPE_SECRET_KEY missing)' },
{ status: 503 },
)
}
const parsed = bodySchema.safeParse(await req.json())
if (!parsed.success || !isCreditPackId(parsed.data.packId)) {
return NextResponse.json({ error: 'Invalid pack' }, { status: 400 })
}
const packId = parsed.data.packId
const pack = getCreditPack(packId)
const userId = session.user.id
const userEmail = session.user.email
try {
let priceId: string
try {
priceId = await resolvePackPriceId(packId)
} catch (e) {
console.error('[billing/create-pack-checkout] price resolve failed:', e)
return NextResponse.json(
{ error: 'Credit pack price IDs not configured. Set them in Admin > Billing.' },
{ status: 503 },
)
}
if (priceId.startsWith('price_mock_')) {
return NextResponse.json(
{ error: 'Credit pack price IDs not configured. Set them in Admin > Billing.' },
{ status: 503 },
)
}
const subscription = await prisma.subscription.findUnique({ where: { userId } })
let customerId = subscription?.stripeCustomerId ?? undefined
if (customerId && customerId.startsWith('cus_mock')) {
customerId = undefined
}
if (!customerId) {
const customer = await stripe.customers.create({
email: userEmail,
metadata: { userId },
})
customerId = customer.id
await prisma.subscription.upsert({
where: { userId },
update: { stripeCustomerId: customerId },
create: {
userId,
stripeCustomerId: customerId,
tier: 'BASIC',
status: 'ACTIVE',
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 3600 * 1000),
},
})
}
const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host') ?? 'localhost:3000'
const proto = req.headers.get('x-forwarded-proto') ?? 'http'
const origin = `${proto}://${host}`
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'payment',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}&pack=1`,
cancel_url: `${origin}/settings/billing?canceled=1`,
metadata: {
userId,
type: 'credit_pack',
packId,
credits: String(pack.credits),
},
payment_intent_data: {
metadata: {
userId,
type: 'credit_pack',
packId,
credits: String(pack.credits),
},
},
customer_update: { address: 'auto' },
allow_promotion_codes: true,
})
if (!checkoutSession.url) {
return NextResponse.json({ error: 'Checkout session has no URL' }, { status: 500 })
}
return NextResponse.json({
url: checkoutSession.url,
sessionId: checkoutSession.id,
packId,
credits: pack.credits,
})
} catch (error) {
console.error('[billing/create-pack-checkout]', error)
const msg = error instanceof Error ? error.message : 'Failed to create pack checkout'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@@ -2,8 +2,10 @@ import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
import { stripe } from '@/lib/stripe';
import type Stripe from 'stripe';
import { priceIdToTier, getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices';
import { getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices';
import { syncSubscriptionFromStripe } from '@/lib/billing/sync-subscription-from-stripe';
import { shouldOfferSubscriptionTrial, SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial';
import { periodsDiffer, rollBillingPeriod } from '@/lib/billing/period';
export const dynamic = 'force-dynamic';
@@ -24,68 +26,107 @@ export async function GET(req: NextRequest) {
if (checkoutSession.subscription && checkoutSession.status === 'complete') {
const subId = typeof checkoutSession.subscription === 'string'
? checkoutSession.subscription
: (checkoutSession.subscription as any).id;
: (checkoutSession.subscription as { id: string }).id;
const sub = await stripe.subscriptions.retrieve(subId) as any;
const priceId = sub.items.data[0].price.id;
const tier = (await priceIdToTier(priceId)) || (checkoutSession.metadata?.tier as any) || 'PRO';
const currentPeriodStartTimestamp =
sub.current_period_start ??
sub.items?.data?.[0]?.current_period_start ??
sub.start_date ??
Math.floor(Date.now() / 1000);
const currentPeriodEndTimestamp =
sub.current_period_end ??
sub.items?.data?.[0]?.current_period_end ??
(currentPeriodStartTimestamp + 30 * 24 * 3600);
await prisma.subscription.upsert({
where: { userId },
update: {
tier,
status: 'ACTIVE',
stripeCustomerId: checkoutSession.customer as string,
stripeSubscriptionId: sub.id,
stripePriceId: priceId,
currentPeriodStart: new Date(currentPeriodStartTimestamp * 1000),
currentPeriodEnd: new Date(currentPeriodEndTimestamp * 1000),
canceledAt: sub.canceled_at ? new Date(sub.canceled_at * 1000) : null,
cancelAtPeriodEnd: sub.cancel_at_period_end,
},
create: {
userId,
tier,
status: 'ACTIVE',
stripeCustomerId: checkoutSession.customer as string,
stripeSubscriptionId: sub.id,
stripePriceId: priceId,
currentPeriodStart: new Date(currentPeriodStartTimestamp * 1000),
currentPeriodEnd: new Date(currentPeriodEndTimestamp * 1000),
},
});
const sub = await stripe.subscriptions.retrieve(subId);
const syncUserId =
(checkoutSession.metadata?.userId as string | undefined) ??
(sub.metadata?.userId as string | undefined) ??
userId;
if (syncUserId === userId) {
await syncSubscriptionFromStripe(sub, userId);
}
}
} catch (err) {
console.error('[billing/status] Failed to sync Stripe session:', err);
}
}
try {
let subscription = await prisma.subscription.findUnique({ where: { userId } });
const stripeSecret = process.env.STRIPE_SECRET_KEY;
const canTalkToStripe = !!stripeSecret && stripeSecret !== 'sk_test_placeholder';
if (
subscription?.stripeSubscriptionId
&& !subscription.stripeSubscriptionId.startsWith('sub_mock')
&& canTalkToStripe
) {
try {
const live = await stripe.subscriptions.retrieve(subscription.stripeSubscriptionId);
await syncSubscriptionFromStripe(live, userId);
subscription = await prisma.subscription.findUnique({ where: { userId } });
} catch (syncErr) {
console.error('[billing/status] live Stripe period sync failed:', syncErr);
}
} else if (subscription && !subscription.cancelAtPeriodEnd) {
const rolled = rollBillingPeriod(subscription.currentPeriodStart);
if (periodsDiffer(rolled, subscription)) {
subscription = await prisma.subscription.update({
where: { userId },
data: {
currentPeriodStart: rolled.currentPeriodStart,
currentPeriodEnd: rolled.currentPeriodEnd,
},
});
}
}
const { tier, status, currentPeriodEnd } = await getUserInfo(userId);
const effectiveTier = await getEffectiveTier(userId);
const subscription = await prisma.subscription.findUnique({ where: { userId } });
const prices = await getDynamicPrices();
const billingEnabled = await isBillingEnabled();
const { getPackPublicPrices } = await import('@/lib/billing/credit-packs');
const creditPacks = await getPackPublicPrices();
// Si retour checkout pack : créditer au cas où le webhook n'a pas encore tourné
if (sessionId && sessionId.startsWith('cs_')) {
try {
const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId);
if (
checkoutSession.mode === 'payment' &&
checkoutSession.status === 'complete' &&
(checkoutSession.metadata?.type === 'credit_pack' || checkoutSession.metadata?.packId) &&
checkoutSession.metadata?.userId === userId
) {
const { addPurchasedCredits } = await import('@/lib/credits');
const {
getCreditPack,
isCreditPackId,
} = await import('@/lib/billing/credit-packs');
const packId = checkoutSession.metadata?.packId;
let credits = Number(checkoutSession.metadata?.credits ?? 0);
if (packId && isCreditPackId(packId) && (!Number.isFinite(credits) || credits <= 0)) {
credits = getCreditPack(packId).credits;
}
if (Number.isFinite(credits) && credits > 0) {
await addPurchasedCredits(userId, credits, {
stripeSessionId: checkoutSession.id,
packId: packId ?? null,
type: 'credit_pack',
source: 'billing_status_sync',
});
}
}
} catch (packSyncErr) {
console.error('[billing/status] pack sync failed:', packSyncErr);
}
}
const trialEligible = await shouldOfferSubscriptionTrial(userId);
return NextResponse.json({
tier,
effectiveTier,
status,
currentPeriodEnd: currentPeriodEnd ?? null,
currentPeriodStart: subscription?.currentPeriodStart ?? null,
currentPeriodEnd: subscription?.currentPeriodEnd?.toISOString() ?? currentPeriodEnd?.toISOString() ?? null,
currentPeriodStart: subscription?.currentPeriodStart?.toISOString() ?? null,
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
hasStripeSubscription: !!subscription?.stripeSubscriptionId,
trialEndsAt: subscription?.trialEndsAt?.toISOString() ?? null,
trialEligible,
trialDays: trialEligible ? SUBSCRIPTION_TRIAL_DAYS : 0,
prices,
creditPacks,
billingEnabled,
});
} catch (error) {

View File

@@ -6,11 +6,81 @@ import {
handleSubscriptionDeleted,
resolveUserIdFromStripeEvent,
} from '@/lib/billing/sync-subscription-from-stripe';
import { addPurchasedCredits } from '@/lib/credits';
import {
getCreditPack,
isCreditPackId,
resolvePackFromPriceId,
} from '@/lib/billing/credit-packs';
import { sendTrialEndingReminder } from '@/lib/billing/trial-reminder-email';
import { prisma } from '@/lib/prisma';
import type Stripe from 'stripe';
export const runtime = 'nodejs';
async function fulfillCreditPackPurchase(session: Stripe.Checkout.Session) {
if (session.mode !== 'payment') return;
if (session.payment_status && session.payment_status !== 'paid' && session.payment_status !== 'no_payment_required') {
console.warn('[billing/webhook] pack checkout not paid yet', session.id, session.payment_status);
return;
}
const userId = session.metadata?.userId as string | undefined;
if (!userId) {
console.warn('[billing/webhook] credit pack: no userId', session.id);
return;
}
let packId = session.metadata?.packId as string | undefined;
let credits = Number(session.metadata?.credits ?? 0);
if ((!packId || !Number.isFinite(credits) || credits <= 0) && session.line_items == null) {
try {
const full = await stripe.checkout.sessions.retrieve(session.id, {
expand: ['line_items.data.price'],
});
const priceId = full.line_items?.data?.[0]?.price?.id;
if (priceId) {
const resolved = await resolvePackFromPriceId(priceId);
if (resolved) {
packId = resolved.packId;
credits = resolved.credits;
}
}
} catch (err) {
console.error('[billing/webhook] expand line_items failed', err);
}
}
if (packId && isCreditPackId(packId) && (!Number.isFinite(credits) || credits <= 0)) {
credits = getCreditPack(packId).credits;
}
if (!Number.isFinite(credits) || credits <= 0) {
console.warn('[billing/webhook] credit pack: invalid credits', session.id, { packId, credits });
return;
}
const result = await addPurchasedCredits(userId, credits, {
stripeSessionId: session.id,
packId: packId ?? null,
type: 'credit_pack',
paymentIntent:
typeof session.payment_intent === 'string'
? session.payment_intent
: session.payment_intent?.id ?? null,
});
if (result.applied) {
console.info('[billing/webhook] credited pack', {
userId,
credits: result.units,
packId,
sessionId: session.id,
});
}
}
export async function POST(req: NextRequest) {
const body = await req.text();
const headersList = await headers();
@@ -49,6 +119,11 @@ export async function POST(req: NextRequest) {
} else {
console.warn('[billing/webhook] checkout.session.completed: no userId in metadata', session.id);
}
} else if (
session.mode === 'payment' &&
(session.metadata?.type === 'credit_pack' || session.metadata?.packId)
) {
await fulfillCreditPackPurchase(session);
}
break;
}
@@ -86,6 +161,48 @@ export async function POST(req: NextRequest) {
break;
}
case 'customer.subscription.trial_will_end': {
const subscription = event.data.object as Stripe.Subscription;
const userId = await resolveUserIdFromStripeEvent(subscription);
if (!userId) {
console.warn('[billing/webhook] trial_will_end: no userId', subscription.id);
break;
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
email: true,
name: true,
aiSettings: { select: { preferredLanguage: true } },
},
});
if (!user?.email) break;
await syncSubscriptionFromStripe(subscription, userId);
const trialEndsAt = subscription.trial_end
? new Date(subscription.trial_end * 1000)
: new Date(Date.now() + 3 * 24 * 3600 * 1000);
const appUrl =
process.env.NEXTAUTH_URL?.replace(/\/$/, '') ||
process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, '') ||
'https://memento-note.com';
const preferred = user.aiSettings?.preferredLanguage ?? 'en';
const mailResult = await sendTrialEndingReminder({
to: user.email,
name: user.name,
trialEndsAt,
billingUrl: `${appUrl}/settings/billing`,
locale: preferred === 'auto' ? 'en' : preferred,
});
if (!mailResult.success) {
console.error('[billing/webhook] trial reminder email failed:', mailResult.error);
}
break;
}
default:
break;
}

View File

@@ -3,7 +3,8 @@ import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { runLaneWithBillingUser } from '@/lib/ai/provider-for-user'
import { getSystemConfig } from '@/lib/config'
import { verifyParticipant } from '@/lib/brainstorm-collab'
import { billingOwnerFromSession, verifyParticipant } from '@/lib/brainstorm-collab'
import { withSessionAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
function localeToLanguageName(locale?: string): string {
const map: Record<string, string> = {
@@ -53,6 +54,11 @@ export async function POST(
return NextResponse.json({ error: 'No ideas to summarize' }, { status: 400 })
}
const { billingOwnerId, isGuestActor } = billingOwnerFromSession(
brainstormSession.userId,
session.user.id,
)
const ideasText = ideas
.map((idea, i) => `${i + 1}. [Wave ${idea.waveNumber}] ${idea.title}: ${idea.description}`)
.join('\n')
@@ -79,15 +85,27 @@ Write a concise synthesis (4-6 sentences) that:
Be direct and action-oriented. No bullet points, just flowing prose.`
const config = await getSystemConfig()
const { result: summary } = await runLaneWithBillingUser(
'tags',
config,
const summary = await withSessionAiQuota(
billingOwnerId,
session.user.id,
(provider) => provider.generateText(prompt),
isGuestActor,
'brainstorm_enrich',
async () => {
const { result } = await runLaneWithBillingUser(
'tags',
config,
billingOwnerId,
(provider) => provider.generateText(prompt),
)
return result
},
{ lane: 'tags' },
)
return NextResponse.json({ success: true, data: { summary: summary.trim() } })
} catch (error) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('Error summarizing brainstorm:', error)
return NextResponse.json({ error: 'Failed to summarize' }, { status: 500 })
}

View File

@@ -0,0 +1,204 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
import { bridgeNotesService } from '@/lib/ai/services/bridge-notes.service'
import { buildDashboardPaths, buildOpenLoops } from '@/lib/dashboard/paths.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { getSystemConfig } from '@/lib/config'
import { getChatProvider } from '@/lib/ai/factory'
function excerptNoteContent(content: string | null | undefined, max = 200): string {
if (!content) return ''
const plain = content.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
if (plain.length <= max) return plain
return `${plain.slice(0, max)}`
}
interface PathsBriefingSnapshot {
recentNote?: {
id: string
title: string | null
content: string
notebookId: string | null
} | null
inboxCount?: number
dueFlashcards?: number
insights?: Array<{
id: string
insight: string
score: number
viewed: boolean
note1: { id: string; title: string | null }
note2: { id: string; title: string | null }
note1Excerpt?: string
note2Excerpt?: string
}>
bridgeSuggestions?: Array<{
clusterAId: number
clusterBId: number
clusterAName: string
clusterBName: string
suggestedTitle: string
suggestedContent: string
justification: string
}>
agentSuggestions?: Array<{
id: string
topic: string
reason: string
clusterId: number | null
}>
includeOpenLoops?: boolean
}
async function resolvePathsPayload(userId: string, snapshot?: PathsBriefingSnapshot | null) {
const now = new Date()
const hasSnapshot = !!snapshot?.recentNote
const needsAgents = !snapshot?.agentSuggestions
const needsBridges = !snapshot?.bridgeSuggestions
const needsInsights = !snapshot?.insights
const needsCounts = snapshot?.inboxCount === undefined || snapshot?.dueFlashcards === undefined
const needsFocusNote = !snapshot?.recentNote
const [
aiConsent,
aiSettings,
systemConfig,
recentNotes,
inboxCount,
dueFlashcards,
unviewedInsights,
agentSuggestions,
bridgeSuggestions,
] = await Promise.all([
hasUserAiConsent(),
prisma.userAISettings.findUnique({
where: { userId },
select: { memoryEcho: true },
}),
getSystemConfig(),
needsFocusNote
? prisma.note.findMany({
where: { userId, trashedAt: null, isArchived: false },
select: { id: true, title: true, content: true, notebookId: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
take: 1,
})
: Promise.resolve([]),
needsCounts
? prisma.note.count({
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
})
: Promise.resolve(snapshot!.inboxCount!),
needsCounts
? prisma.flashcard.count({
where: { deck: { userId }, nextReviewAt: { lte: now } },
})
: Promise.resolve(snapshot!.dueFlashcards!),
needsInsights
? prisma.memoryEchoInsight.findMany({
where: { userId, dismissed: false },
select: {
id: true, insight: true, similarityScore: true, viewed: true,
note1: { select: { id: true, title: true, content: true } },
note2: { select: { id: true, title: true, content: true } },
},
orderBy: { insightDate: 'desc' },
take: 5,
}).catch(() => [])
: Promise.resolve([]),
needsAgents
? agentSuggestionService.getPending(userId, 3).catch(() => [])
: Promise.resolve([]),
needsBridges
? bridgeNotesService.getBridgeSuggestions(userId).catch(() => [])
: Promise.resolve([]),
])
const aiProviderReady = !!getChatProvider(systemConfig)
const memoryEchoEnabled = aiSettings?.memoryEcho ?? true
const aiActive = aiConsent && aiProviderReady && memoryEchoEnabled
const focusNotes = hasSnapshot && snapshot?.recentNote
? [{
...snapshot.recentNote,
updatedAt: now,
}]
: recentNotes
const insights = snapshot?.insights ?? unviewedInsights.map(i => ({
id: i.id,
insight: i.insight,
score: i.similarityScore,
viewed: i.viewed,
note1: { id: i.note1.id, title: i.note1.title },
note2: { id: i.note2.id, title: i.note2.title },
note1Excerpt: excerptNoteContent(i.note1.content),
note2Excerpt: excerptNoteContent(i.note2.content),
}))
const resolvedBridges = snapshot?.bridgeSuggestions
?? bridgeSuggestions.slice(0, 3).map(s => ({
clusterAId: s.clusterAId,
clusterBId: s.clusterBId,
clusterAName: s.clusterAName,
clusterBName: s.clusterBName,
suggestedTitle: s.suggestedTitle,
suggestedContent: s.suggestedContent,
justification: s.justification,
}))
const resolvedAgents = snapshot?.agentSuggestions
?? agentSuggestions.map(s => ({
id: s.id,
topic: s.topic,
reason: s.reason,
clusterId: s.clusterId,
}))
const paths = await buildDashboardPaths({
userId,
aiActive,
recentNotes: focusNotes,
inboxCount: needsCounts ? inboxCount : snapshot!.inboxCount!,
dueFlashcards: needsCounts ? dueFlashcards : snapshot!.dueFlashcards!,
insights,
bridgeSuggestions: resolvedBridges,
agentSuggestions: resolvedAgents,
}).catch(() => [])
const openLoops = snapshot?.includeOpenLoops === false
? []
: await buildOpenLoops(userId).catch(() => [])
return { paths, openLoops }
}
/**
* GET /api/briefing/paths — pistes enrichies (fallback sans snapshot client).
*/
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const payload = await resolvePathsPayload(session.user.id)
return NextResponse.json(payload)
}
/**
* POST /api/briefing/paths — enrichissement avec snapshot briefing (évite re-fetch agents/ponts).
*/
export async function POST(request: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const snapshot = await request.json().catch(() => null) as PathsBriefingSnapshot | null
const payload = await resolvePathsPayload(session.user.id, snapshot)
return NextResponse.json(payload)
}

View File

@@ -0,0 +1,260 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
import { bridgeNotesService } from '@/lib/ai/services/bridge-notes.service'
import { gmailScannerService } from '@/lib/integrations/gmail-scanner.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { getSystemConfig } from '@/lib/config'
import { getChatProvider } from '@/lib/ai/factory'
function excerptNoteContent(content: string | null | undefined, max = 200): string {
if (!content) return ''
const plain = content.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
if (plain.length <= max) return plain
return `${plain.slice(0, max)}`
}
async function fetchWritingActivity(userId: string, since: Date) {
const rows = await prisma.$queryRaw<Array<{ date: string; count: number }>>`
SELECT to_char("updatedAt" AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS date,
COUNT(*)::int AS count
FROM "Note"
WHERE "userId" = ${userId}
AND "trashedAt" IS NULL
AND "updatedAt" >= ${since}
GROUP BY 1
ORDER BY 1 ASC
`
return rows.map(r => ({ date: r.date, count: Number(r.count) }))
}
/**
* GET /api/briefing — agrégat rapide pour le tableau de bord.
* Pas de génération IA synchrone ; les pistes sont dans GET /api/briefing/paths.
*/
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const now = new Date()
const ninetyDaysAgo = new Date(now)
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90)
const [aiConsent, aiSettings, systemConfig] = await Promise.all([
hasUserAiConsent(),
prisma.userAISettings.findUnique({
where: { userId },
select: { memoryEcho: true },
}),
getSystemConfig(),
])
const aiProviderReady = !!getChatProvider(systemConfig)
const memoryEchoEnabled = aiSettings?.memoryEcho ?? true
const aiActive = aiConsent && aiProviderReady && memoryEchoEnabled
const [
recentNotes,
inboxCount,
inboxPreview,
dueFlashcards,
upcomingReminders,
unviewedInsights,
recentAgentActions,
pinnedNotes,
writingActivity,
gmailStatus,
agentSuggestions,
bridgeSuggestions,
] = await Promise.all([
prisma.note.findMany({
where: { userId, trashedAt: null, isArchived: false },
select: {
id: true, title: true, content: true, color: true,
notebookId: true, updatedAt: true, createdAt: true,
},
orderBy: { updatedAt: 'desc' },
take: 12,
}),
prisma.note.count({
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
}),
prisma.note.findMany({
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
select: { id: true, title: true, content: true, notebookId: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
take: 3,
}),
prisma.flashcard.count({
where: { deck: { userId }, nextReviewAt: { lte: now } },
}),
prisma.note.findMany({
where: {
userId, reminder: { gte: now }, isReminderDone: false, trashedAt: null,
},
select: { id: true, title: true, reminder: true, notebookId: true },
orderBy: { reminder: 'asc' },
take: 5,
}),
prisma.memoryEchoInsight.findMany({
where: { userId, viewed: false, dismissed: false },
select: {
id: true, insight: true, similarityScore: true,
insightDate: true, viewed: true,
note1: { select: { id: true, title: true, content: true } },
note2: { select: { id: true, title: true, content: true } },
},
orderBy: { insightDate: 'desc' },
take: 5,
}).catch(() => []),
prisma.agentAction.findMany({
where: {
agent: { userId },
status: 'completed',
createdAt: { gte: new Date(now.getTime() - 48 * 60 * 60 * 1000) },
},
select: {
id: true, result: true, createdAt: true,
agent: { select: { id: true, name: true, type: true } },
},
orderBy: { createdAt: 'desc' },
take: 3,
}).catch(() => []),
prisma.note.findMany({
where: { userId, isPinned: true, trashedAt: null, isArchived: false },
select: { id: true, title: true, content: true, notebookId: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
take: 5,
}),
fetchWritingActivity(userId, ninetyDaysAgo).catch(() => []),
gmailScannerService.getStatus(userId).catch(() => ({
connected: false,
recentCaptures: 0,
})),
agentSuggestionService.getPending(userId, 3).catch(() => []),
bridgeNotesService.getBridgeSuggestions(userId).catch(() => []),
])
let insightRows = unviewedInsights
if (insightRows.length < 5) {
const viewedRecent = await prisma.memoryEchoInsight.findMany({
where: {
userId,
dismissed: false,
viewed: true,
id: { notIn: insightRows.map(i => i.id) },
},
select: {
id: true, insight: true, similarityScore: true,
insightDate: true, viewed: true,
note1: { select: { id: true, title: true, content: true } },
note2: { select: { id: true, title: true, content: true } },
},
orderBy: { insightDate: 'desc' },
take: 5 - insightRows.length,
}).catch(() => [])
insightRows = [...insightRows, ...viewedRecent]
}
const notebookIds = [...new Set([
...recentNotes.map(n => n.notebookId),
...pinnedNotes.map(n => n.notebookId),
].filter(Boolean))] as string[]
const notebooks = notebookIds.length > 0
? await prisma.notebook.findMany({
where: { id: { in: notebookIds } },
select: { id: true, name: true, color: true, icon: true },
})
: []
const notebookMap = new Map(notebooks.map(n => [n.id, n]))
return NextResponse.json({
recentNotes: recentNotes.map(n => ({
...n,
notebook: n.notebookId ? notebookMap.get(n.notebookId) || null : null,
})),
inboxCount,
inboxPreview: inboxPreview.map(n => ({
id: n.id,
title: n.title,
excerpt: excerptNoteContent(n.content, 80),
notebookId: n.notebookId,
updatedAt: n.updatedAt.toISOString(),
})),
dueFlashcards,
upcomingReminders: upcomingReminders.map(r => ({
id: r.id,
title: r.title,
reminder: r.reminder?.toISOString() || null,
notebookId: r.notebookId,
})),
insights: insightRows.map(i => ({
id: i.id,
insight: i.insight,
score: i.similarityScore,
date: i.insightDate.toISOString(),
viewed: i.viewed,
note1: { id: i.note1.id, title: i.note1.title },
note2: { id: i.note2.id, title: i.note2.title },
note1Excerpt: excerptNoteContent(i.note1.content),
note2Excerpt: excerptNoteContent(i.note2.content),
})),
bridgeSuggestions: bridgeSuggestions.slice(0, 3).map(s => ({
clusterAId: s.clusterAId,
clusterBId: s.clusterBId,
clusterAName: s.clusterAName,
clusterBName: s.clusterBName,
suggestedTitle: s.suggestedTitle,
suggestedContent: s.suggestedContent,
justification: s.justification,
})),
ai: {
consent: aiConsent,
memoryEchoEnabled,
providerReady: aiProviderReady,
active: aiActive,
},
agentActions: recentAgentActions.map(a => ({
id: a.id,
agentName: a.agent?.name || 'Agent',
agentType: a.agent?.type || 'custom',
result: a.result ? String(a.result).slice(0, 200) : null,
createdAt: a.createdAt.toISOString(),
})),
gmail: gmailStatus,
pinnedNotes: pinnedNotes.map(n => ({
id: n.id,
title: n.title,
excerpt: excerptNoteContent(n.content, 80),
notebookId: n.notebookId,
updatedAt: n.updatedAt.toISOString(),
notebook: n.notebookId ? notebookMap.get(n.notebookId) || null : null,
})),
writingActivity,
agentSuggestions: agentSuggestions.map(s => ({
id: s.id,
topic: s.topic,
reason: s.reason,
suggestedType: s.suggestedType,
suggestedFrequency: s.suggestedFrequency,
relatedNoteCount: (() => {
try { return JSON.parse(s.relatedNoteIds).length } catch { return 0 }
})(),
clusterId: s.clusterId,
})),
})
}

View File

@@ -0,0 +1,112 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { redis } from '@/lib/redis'
import { detectUserLanguage } from '@/lib/i18n/detect-user-language'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
import { QuotaExceededError } from '@/lib/entitlements'
const CACHE_TTL_SEC = 3600
/**
* GET /api/briefing/sentiment
* Analyzes the emotional tone of recent notes (last 7 days) using LLM.
*/
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const locale = await detectUserLanguage()
const cacheKey = `briefing:sentiment:${userId}:${locale}:v2`
try {
const cached = await redis.get(cacheKey)
if (cached) return NextResponse.json(JSON.parse(cached))
} catch {}
const weekAgo = new Date()
weekAgo.setDate(weekAgo.getDate() - 7)
const recentNotes = await prisma.note.findMany({
where: {
userId,
trashedAt: null,
isArchived: false,
updatedAt: { gte: weekAgo },
},
select: { id: true, title: true, content: true, notebookId: true },
take: 20,
orderBy: { updatedAt: 'desc' },
})
if (recentNotes.length < 3) {
return NextResponse.json({
available: false,
reason: 'Not enough notes this week',
})
}
const snippets = recentNotes.map(n => {
const text = n.content.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
return `${n.title || ''}: ${text.slice(0, 200)}`
}).join('\n---\n')
try {
const config = await getSystemConfig()
const provider = getChatProvider(config)
if (!provider) {
return NextResponse.json({ available: false, reason: 'No AI provider' })
}
const localeLabel = locale === 'fr' ? 'French' : locale === 'en' ? 'English' : locale
const voice = locale === 'fr'
? 'Write summary in French, second person singular (tu). Example: « Cette semaine, tu as surtout… ». Never write « the person », « the user », or third person.'
: `Write summary in ${localeLabel}, second person ("you"). Never write "the person", "the user", or third person.`
const prompt = `Analyze the emotional patterns in these notes from the past week. Return ONLY valid JSON (no markdown, no code fences).
${voice}
Keep dominantEmotion keys in English as listed. One short sentence for summary. Do not quote secrets, passwords, or URLs.
{"dominantEmotion":"focused|curious|enthusiastic|frustrated|calm|anxious|creative|reflective","sentimentScore":number from -1 to 1,"emotions":{"focused":number,"curious":number,"enthusiastic":number,"frustrated":number,"calm":number,"anxious":number,"creative":number,"reflective":number},"summary":"one sentence","topTopic":"most discussed topic"}
Notes:
${snippets.slice(0, 3000)}`
const result = await withAiQuota(
userId,
'reformulate',
() => provider.generateText(prompt),
{ lane: 'chat' },
)
const jsonMatch = result.match(/\{[\s\S]*\}/)
if (!jsonMatch) {
return NextResponse.json({ available: false, reason: 'Parse error' })
}
const parsed = JSON.parse(jsonMatch[0])
const relatedNotes = recentNotes.slice(0, 2).map(n => {
const title = n.title?.trim()
const plain = n.content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
return {
id: n.id,
title: title || (plain ? (plain.length > 80 ? `${plain.slice(0, 80).trim()}` : plain) : null),
notebookId: n.notebookId,
}
})
const payload = { available: true, ...parsed, relatedNotes }
try { await redis.setex(cacheKey, CACHE_TTL_SEC, JSON.stringify(payload)) } catch {}
return NextResponse.json(payload)
} catch (error) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
if (error instanceof QuotaExceededError) {
return NextResponse.json(error.toJSON(), { status: 402 })
}
console.error('[briefing/sentiment]', error)
return NextResponse.json({ available: false, reason: 'Analysis failed' })
}
}

View File

@@ -1,15 +1,16 @@
import { streamText, UIMessage, stepCountIs } from 'ai'
import { resolveAiRouteWithTiming, formatAiRouteDebug } from '@/lib/ai/router'
import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user'
import { runLaneWithBillingUser } from '@/lib/ai/provider-for-user'
import { getSystemConfig } from '@/lib/config'
import { getChatProvider } from '@/lib/ai/factory'
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
import { prisma } from '@/lib/prisma'
import { auth } from '@/auth'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
import { toolRegistry } from '@/lib/ai/tools'
import { reserveUsageOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
import { reserveAiUsageOrThrow, handleQuotaHttpError } from '@/lib/ai-quota'
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
import { ByokUnavailableError } from '@/lib/byok'
import { trackFeatureUsage } from '@/lib/usage-tracker'
import { readFile } from 'fs/promises'
@@ -64,19 +65,12 @@ export async function POST(req: Request) {
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return new Response(JSON.stringify({ error: 'ai_consent_required' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
})
return aiConsentForbiddenResponse()
}
// 1.5 Quota check (per-provider BYOK bypass — only when BYOK will be used for resolved provider)
try {
const sysConfigEarly = await getSystemConfig()
const { usedByok: willUseByok } = await willUseByokForLane('chat', sysConfigEarly, userId)
if (!willUseByok) {
await reserveUsageOrThrow(userId, 'chat')
}
await reserveAiUsageOrThrow(userId, 'chat', { lane: 'chat' })
} catch (err) {
if (err instanceof QuotaExceededError) {
return Response.json(err.toJSON(), { status: 402 })

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { extractArticleFromHtml } from '@/lib/clip/extract-article'
import { extractArticleFromHtml, makeExcerpt } from '@/lib/clip/extract-article'
import { analyzeClipContent } from '@/lib/clip/analyze-clip'
import { resolveClipLocale, wrapClipPlainParagraph } from '@/lib/clip/rtl-content'
@@ -92,7 +92,7 @@ export async function POST(request: NextRequest) {
tags: analysis.tags,
readingTime: analysis.readingTimeMinutes,
content: contentHtml,
excerpt: textContent.slice(0, 500),
excerpt: makeExcerpt(textContent, 500),
})
} catch (error) {
console.error('[POST /api/clip/analyze]', error)

View File

@@ -18,10 +18,63 @@ export async function GET(request: NextRequest) {
const userId = session.user.id
// Fix any leftover "Cluster N" placeholders from pre-save naming bug
await clusteringService.ensureClusterNames(userId)
// Check for stored results (even if stale/périmés)
const stored = await clusteringService.getStoredClusters(userId)
const lite = request.nextUrl.searchParams.get('lite') === '1'
if (stored) {
const cached = stored.clusters
if (lite) {
const [bridgeNotesData, totalNotes] = await Promise.all([
prisma.bridgeNote.findMany({
where: { userId },
orderBy: { bridgeScore: 'desc' },
take: 5,
select: { noteId: true, bridgeScore: true, clustersConnected: true },
}),
prisma.note.count({ where: { userId, trashedAt: null } }),
])
let enrichedBridgeNotes: object[] = []
if (bridgeNotesData.length > 0) {
const bridgeNoteDetails = await prisma.note.findMany({
where: { id: { in: bridgeNotesData.map(b => b.noteId) } },
select: { id: true, title: true },
})
const bridgeNoteDetailsMap = new Map(bridgeNoteDetails.map(n => [n.id, n]))
enrichedBridgeNotes = bridgeNotesData.map(b => {
const clustersConnected = JSON.parse(b.clustersConnected) as number[]
return {
noteId: b.noteId,
bridgeScore: b.bridgeScore,
clustersConnected,
clusterNames: clustersConnected.map(
cid =>
clusteringService.displayName(
cached.find(c => c.clusterId === cid)?.name,
cid,
),
),
note: bridgeNoteDetailsMap.get(b.noteId),
}
})
}
return NextResponse.json({
clusters: cached,
bridgeNotes: enrichedBridgeNotes,
cached: true,
stale: stored.stale,
lastCalculated: stored.lastCalculated,
totalNotes,
})
}
// Fetch notes with their cluster assignments
const notes = await prisma.note.findMany({
where: { userId, trashedAt: null },
@@ -66,8 +119,11 @@ export async function GET(request: NextRequest) {
noteId: b.noteId,
bridgeScore: b.bridgeScore,
clustersConnected,
clusterNames: clustersConnected.map(
cid => cached.find(c => c.clusterId === cid)?.name || `Cluster ${cid}`
clusterNames: clustersConnected.map(cid =>
clusteringService.displayName(
cached.find(c => c.clusterId === cid)?.name,
cid,
),
),
note: bridgeNoteDetailsMap.get(b.noteId)
}
@@ -153,12 +209,10 @@ export async function POST(request: NextRequest) {
})
}
// 2. Generate cluster names with AI
for (const cluster of results.clusters) {
cluster.name = await clusteringService.generateClusterName(cluster.clusterId, userId)
}
// 2. Name from in-memory members (DB members do not exist until save)
await clusteringService.nameClustersFromResults(userId, results)
// 3. Save clustering results
// 3. Save clustering results (with real names)
await clusteringService.saveClusteringResults(userId, results)
// 4. Detect and save bridge notes

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
export const dynamic = 'force-dynamic'
function authorizeCron(request: NextRequest): boolean {
const cronSecret = process.env.CRON_SECRET
if (!cronSecret) return false
const authHeader = request.headers.get('authorization')
const bearer = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
const querySecret = new URL(request.url).searchParams.get('secret')
return bearer === cronSecret || querySecret === cronSecret
}
/**
* POST /api/cron/agent-suggestions
* Génère des suggestions d'agents à partir des clusters sémantiques existants.
*/
export async function POST(request: NextRequest) {
if (!authorizeCron(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const users = await prisma.user.findMany({
where: { noteClusters: { some: {} } },
select: { id: true },
take: 30,
})
let total = 0
for (const user of users) {
try {
total += await agentSuggestionService.generateForUser(user.id)
} catch (err) {
console.error('[CronAgentSuggestions] user', user.id, err)
}
}
return NextResponse.json({ success: true, users: users.length, suggestionsUpserted: total })
}

View File

@@ -27,10 +27,12 @@ export async function POST(request: NextRequest) {
try {
const now = new Date()
// Due = nextRun in the past; also backfill agents whose nextRun was never set
// (legacy / toggle bug) — but only schedule them, don't stampede-execute all nulls
const dueAgents = await prisma.agent.findMany({
where: {
isEnabled: true,
frequency: { not: 'manual' },
frequency: { notIn: ['manual', 'one-shot'] },
nextRun: { lte: now },
},
select: {
@@ -40,23 +42,77 @@ export async function POST(request: NextRequest) {
scheduledTime: true,
scheduledDay: true,
timezone: true,
nextRun: true,
},
orderBy: { nextRun: 'asc' },
})
if (dueAgents.length === 0) {
return NextResponse.json({ success: true, executed: 0 })
// Repair: set nextRun for enabled scheduled agents stuck with null
const unscheduled = await prisma.agent.findMany({
where: {
isEnabled: true,
frequency: { notIn: ['manual', 'one-shot'] },
nextRun: null,
},
select: {
id: true,
userId: true,
frequency: true,
scheduledTime: true,
scheduledDay: true,
timezone: true,
},
take: 50,
})
const repairedDue: typeof dueAgents = []
for (const a of unscheduled) {
const nextRun = calculateNextRun({
frequency: a.frequency,
scheduledTime: a.scheduledTime,
scheduledDay: a.scheduledDay,
timezone: a.timezone,
})
// Si le créneau calculé est déjà passé / immédiat → exécuter ce cycle
const effective = !nextRun || nextRun <= now ? now : nextRun
await prisma.agent.update({ where: { id: a.id }, data: { nextRun: effective } })
if (effective <= now) {
repairedDue.push({
id: a.id,
userId: a.userId,
frequency: a.frequency,
scheduledTime: a.scheduledTime,
scheduledDay: a.scheduledDay,
timezone: a.timezone,
nextRun: effective,
})
}
}
const queue = [...dueAgents]
const seen = new Set(dueAgents.map((a) => a.id))
for (const a of repairedDue) {
if (!seen.has(a.id)) {
queue.push(a)
seen.add(a.id)
}
}
if (queue.length === 0) {
return NextResponse.json({
success: true,
executed: 0,
repairedSchedules: unscheduled.length,
})
}
const results: { id: string; success: boolean; error?: string }[] = []
// Execute agents sequentially (max 3 per cycle)
for (const agent of dueAgents.slice(0, 3)) {
// Execute agents sequentially (max 5 per cycle — rattrapage + due)
for (const agent of queue.slice(0, 5)) {
try {
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
const result = await executeAgent(agent.id, agent.userId)
// Calculate and set next run
const nextRun = calculateNextRun({
frequency: agent.frequency,
scheduledTime: agent.scheduledTime,
@@ -64,7 +120,6 @@ export async function POST(request: NextRequest) {
timezone: agent.timezone,
})
await prisma.agent.update({
where: { id: agent.id },
data: { nextRun },
@@ -75,7 +130,6 @@ export async function POST(request: NextRequest) {
const msg = error instanceof Error ? error.message : 'Unknown error'
console.error(`[CronAgents] Agent ${agent.id} failed:`, msg)
// Still schedule next run even on failure
const nextRun = calculateNextRun({
frequency: agent.frequency,
scheduledTime: agent.scheduledTime,

View File

@@ -102,10 +102,8 @@ export async function GET(request: NextRequest) {
continue
}
// Generate cluster names
for (const cluster of clusterResults.clusters) {
cluster.name = await clusteringService.generateClusterName(cluster.clusterId, user.id)
}
// Name from in-memory members (before save — generateClusterName needs DB rows)
await clusteringService.nameClustersFromResults(user.id, clusterResults)
// Save results
await clusteringService.saveClusteringResults(user.id, clusterResults)
@@ -174,9 +172,9 @@ export async function POST(request: NextRequest) {
if (userId) {
// Process specific user
const clusterResults = await clusteringService.clusterNotes(userId)
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(userId)
await clusteringService.nameClustersFromResults(userId, clusterResults)
await clusteringService.saveClusteringResults(userId, clusterResults)
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(userId)
await bridgeNotesService.saveBridgeNotes(userId, bridgeNotes)
return NextResponse.json({
@@ -202,9 +200,9 @@ export async function POST(request: NextRequest) {
for (const user of users) {
try {
const clusterResults = await clusteringService.clusterNotes(user.id)
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(user.id)
await clusteringService.nameClustersFromResults(user.id, clusterResults)
await clusteringService.saveClusteringResults(user.id, clusterResults)
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(user.id)
await bridgeNotesService.saveBridgeNotes(user.id, bridgeNotes)
results.processed++

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { gmailScannerService } from '@/lib/integrations/gmail-scanner.service'
export const dynamic = 'force-dynamic'
function authorize(request: NextRequest): boolean {
const cronSecret = process.env.CRON_SECRET
if (!cronSecret) return false
const authHeader = request.headers.get('authorization')
const bearer = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
return bearer === cronSecret
}
/** POST /api/cron/gmail — scan Gmail for connected users (1×/jour en prod via entrypoint) */
export async function POST(request: NextRequest) {
if (!authorize(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const users = await prisma.userAISettings.findMany({
select: { userId: true, integrationTokens: true },
take: 200,
})
const connected = users.filter(u => {
if (!u.integrationTokens) return false
try {
const meta = typeof u.integrationTokens === 'string'
? JSON.parse(u.integrationTokens)
: u.integrationTokens
return !!(meta?.gmailAccessToken && meta?.gmailRefreshToken)
} catch {
return false
}
})
let totalCreated = 0
for (const row of connected) {
try {
const { created } = await gmailScannerService.scanUser(row.userId)
totalCreated += created
} catch (err) {
console.error('[CronGmail]', row.userId, err)
}
}
return NextResponse.json({ success: true, users: connected.length, notesCreated: totalCreated })
}

View File

@@ -77,12 +77,12 @@ export async function POST(req: NextRequest) {
redis.get(`${key}:tokens`),
]);
const periodStart = new Date(`${period}-01`);
const periodEnd = new Date(
periodStart.getFullYear(),
periodStart.getMonth() + 1,
const periodStart = new Date(`${period}-01T00:00:00.000Z`);
const periodEnd = new Date(Date.UTC(
periodStart.getUTCFullYear(),
periodStart.getUTCMonth() + 1,
1,
);
));
await prisma.usageLog.upsert({
where: {

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import { Prisma } from '@prisma/client'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import {
getDefaultDashboardLayout,
normalizeDashboardLayout,
type DashboardLayout,
} from '@/lib/dashboard/layout'
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const settings = await prisma.userAISettings.findUnique({
where: { userId: session.user.id },
select: { dashboardLayout: true },
})
const layout = normalizeDashboardLayout(settings?.dashboardLayout ?? getDefaultDashboardLayout())
return NextResponse.json({ layout })
}
export async function PUT(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
let body: { layout?: DashboardLayout }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const layout = normalizeDashboardLayout(body.layout ?? getDefaultDashboardLayout())
const layoutJson = layout as unknown as Prisma.InputJsonValue
await prisma.userAISettings.upsert({
where: { userId: session.user.id },
create: { userId: session.user.id, dashboardLayout: layoutJson },
update: { dashboardLayout: layoutJson },
})
return NextResponse.json({ layout, ok: true })
}

View File

@@ -3,7 +3,7 @@ import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { getAISettings } from '@/app/actions/ai-settings'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { generateFlashcardsFromNote, type FlashcardStyle } from '@/lib/flashcards/generate-flashcards'
import { stripHtmlToText } from '@/lib/flashcards/deck-utils'
@@ -15,7 +15,7 @@ export async function POST(request: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const userSettings = await getAISettings(session.user.id)

View File

@@ -1,195 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
// ── Stopwords FR + EN ─────────────────────────────────────────────────────────
const STOPWORDS = new Set([
'le','la','les','de','du','des','un','une','et','en','au','aux','ce','se',
'sa','son','ses','mon','ma','mes','ton','ta','tes','que','qui','quoi','dont',
'il','elle','ils','elles','nous','vous','je','tu','on','par','pour','sur',
'sous','avec','dans','est','sont','pas','ne','plus','très','tout','comme',
'mais','donc','car','cet','cette','ces','leur','leurs','note','notes',
'the','a','an','and','or','but','in','on','at','to','for','of','with','by',
'from','is','are','was','were','be','been','have','has','had','do','does',
'did','will','would','could','should','may','might','this','that','these',
'those','it','its','they','them','their','he','she','we','you','not','no',
'so','if','as','up','out','about','also','just','can','all','any','get',
])
function stripHtml(html: string): string {
return html.replace(/<[^>]+>/g, ' ').replace(/&\w+;/g, ' ')
}
function extractKeywords(text: string): Set<string> {
return new Set(
stripHtml(text)
.toLowerCase()
.split(/[\s\p{P}]+/u)
.filter(w => w.length >= 3 && !STOPWORDS.has(w) && !/^\d+$/.test(w))
)
}
function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
if (a.size === 0 || b.size === 0) return 0
let intersection = 0
for (const w of a) if (b.has(w)) intersection++
return intersection / (a.size + b.size - intersection)
}
type EdgeType = 'title_mention' | 'shared_label' | 'jaccard' | 'explicit_link' | 'semantic_echo'
interface GraphEdge { source: string; target: string; weight: number; type: EdgeType }
// GET /api/graph — connexions automatiques à 3 niveaux
export async function GET(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const userId = session.user.id
const { searchParams } = new URL(request.url)
const notebookId = searchParams.get('notebookId') || undefined
const notes = await prisma.note.findMany({
where: { userId, trashedAt: null, ...(notebookId ? { notebookId } : {}) },
select: {
id: true, title: true, content: true, notebookId: true, createdAt: true,
labelRelations: { select: { id: true } },
notebook: { select: { id: true, name: true } },
},
take: 500,
orderBy: { updatedAt: 'desc' },
})
if (notes.length === 0) return NextResponse.json({ nodes: [], edges: [] })
const ids = notes.map(n => n.id)
// Query NoteLink manually created relationships
const noteLinks = await (prisma as any).noteLink.findMany({
where: {
sourceNoteId: { in: ids },
targetNoteId: { in: ids }
},
select: {
sourceNoteId: true,
targetNoteId: true,
contextSnippet: true
}
})
// Query MemoryEchoInsight semantic relationships
const echoInsights = await (prisma as any).memoryEchoInsight.findMany({
where: {
userId,
dismissed: false,
note1Id: { in: ids },
note2Id: { in: ids }
},
select: {
note1Id: true,
note2Id: true,
similarityScore: true,
insight: true
}
})
// Pré-calcul
const keywordsMap = new Map<string, Set<string>>()
const labelMap = new Map<string, Set<string>>()
for (const note of notes) {
keywordsMap.set(note.id, extractKeywords(`${note.title ?? ''} ${note.content}`))
labelMap.set(note.id, new Set(note.labelRelations.map((l: any) => l.id)))
}
const EDGE_TYPE_PRIORITY: Record<EdgeType, number> = {
explicit_link: 5,
semantic_echo: 4,
title_mention: 3,
shared_label: 2,
jaccard: 1,
}
const edgeMap = new Map<string, GraphEdge>()
function upsertEdge(a: string, b: string, weight: number, type: EdgeType) {
const key = a < b ? `${a}--${b}` : `${b}--${a}`
const ex = edgeMap.get(key)
if (!ex) {
edgeMap.set(key, { source: a < b ? a : b, target: a < b ? b : a, weight, type })
} else {
const exPriority = EDGE_TYPE_PRIORITY[ex.type] || 0
const curPriority = EDGE_TYPE_PRIORITY[type] || 0
if (curPriority > exPriority || (curPriority === exPriority && weight > ex.weight)) {
edgeMap.set(key, { source: a < b ? a : b, target: a < b ? b : a, weight, type })
}
}
}
// ── Niveau 1 : Title Mention (comme Obsidian "unlinked mentions") ──────────
for (const noteA of notes) {
const title = (noteA.title ?? '').trim().toLowerCase()
if (title.length < 3) continue
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const re = new RegExp(`(?<!\\p{L})${escaped}(?!\\p{L})`, 'ui')
for (const noteB of notes) {
if (noteA.id === noteB.id) continue
if (re.test(stripHtml(noteB.content))) upsertEdge(noteA.id, noteB.id, 1.0, 'title_mention')
}
}
// ── Niveau 2 : Labels partagés ────────────────────────────────────────────
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
const la = labelMap.get(ids[i])!
const lb = labelMap.get(ids[j])!
const shared = [...la].filter(l => lb.has(l)).length
if (shared > 0) upsertEdge(ids[i], ids[j], Math.min(0.5 + shared * 0.15, 0.9), 'shared_label')
}
}
// ── Niveau 3 : Jaccard (désactivé > 500 notes) ───────────────────────────
if (notes.length <= 500) {
for (let i = 0; i < ids.length; i++) {
const kwI = keywordsMap.get(ids[i])!
const candidates: { j: number; score: number }[] = []
for (let j = i + 1; j < ids.length; j++) {
const score = jaccardSimilarity(kwI, keywordsMap.get(ids[j])!)
if (score >= 0.12) candidates.push({ j, score })
}
candidates.sort((a, b) => b.score - a.score).slice(0, 10)
.forEach(({ j, score }) => upsertEdge(ids[i], ids[j], score * 0.8, 'jaccard'))
}
}
// ── Niveau 4 : WikiLinks explicites (NoteLink) ─────────────────────────────
for (const link of noteLinks) {
upsertEdge(link.sourceNoteId, link.targetNoteId, 1.0, 'explicit_link')
}
// ── Niveau 5 : Échos sémantiques IA (MemoryEchoInsight) ────────────────────
for (const echo of echoInsights) {
upsertEdge(echo.note1Id, echo.note2Id, echo.similarityScore, 'semantic_echo')
}
const degreeMap = new Map<string, number>()
for (const e of edgeMap.values()) {
degreeMap.set(e.source, (degreeMap.get(e.source) ?? 0) + 1)
degreeMap.set(e.target, (degreeMap.get(e.target) ?? 0) + 1)
}
const nodes = notes.map(n => ({
id: n.id,
title: n.title || 'Sans titre',
notebookId: n.notebookId,
createdAt: n.createdAt,
degree: degreeMap.get(n.id) ?? 0,
}))
// Build clusters (notebooks)
const notebookMap = new Map<string, string>()
for (const n of notes) {
if (n.notebook) notebookMap.set(n.notebook.id, n.notebook.name)
}
const clusters = [...notebookMap.entries()].map(([id, name]) => ({ id, name }))
return NextResponse.json({ nodes, edges: Array.from(edgeMap.values()), clusters })
}

Some files were not shown because too many files have changed in this diff Show More