diff --git a/.cursor/hooks/state/continual-learning-index.json b/.cursor/hooks/state/continual-learning-index.json index fbcdef8..2ac6952 100644 --- a/.cursor/hooks/state/continual-learning-index.json +++ b/.cursor/hooks/state/continual-learning-index.json @@ -1,7 +1,7 @@ { "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/0c6fb2d9-1b82-4ca3-b0f4-f8373a62faca/0c6fb2d9-1b82-4ca3-b0f4-f8373a62faca.jsonl": 1778182618469, "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/137b1f4b-59d9-4ce6-8d74-01f7cbae2ba7/137b1f4b-59d9-4ce6-8d74-01f7cbae2ba7.jsonl": 1778966645519, - "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/16214191-7091-4aef-a309-f922d351d79f.jsonl": 1779909977869, + "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/16214191-7091-4aef-a309-f922d351d79f.jsonl": 1779911218415, "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/2e0ce74c-a31e-49d8-a0d0-a8b224813533/2e0ce74c-a31e-49d8-a0d0-a8b224813533.jsonl": 1778188935902, "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/38000361-5c66-4032-8e1e-ef405e843de0/38000361-5c66-4032-8e1e-ef405e843de0.jsonl": 1778968570815, "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/394af47d-c5cd-4cef-bef2-2192717439f8/394af47d-c5cd-4cef-bef2-2192717439f8.jsonl": 1778951280378, @@ -20,5 +20,6 @@ "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/b85430f3-4520-47fd-9b4b-5200ca340a36/b85430f3-4520-47fd-9b4b-5200ca340a36.jsonl": 1779039005865, "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ca85061e-6af9-4250-8dc7-9c3bb4839c48/ca85061e-6af9-4250-8dc7-9c3bb4839c48.jsonl": 1778849848444, "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/d92dfb04-c148-4a14-a48a-39d4c634caee/d92dfb04-c148-4a14-a48a-39d4c634caee.jsonl": 1778861502433, - "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/e3745f62-c3b9-4a21-8942-71bc6f603f77/e3745f62-c3b9-4a21-8942-71bc6f603f77.jsonl": 1778018654221 + "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/e3745f62-c3b9-4a21-8942-71bc6f603f77/e3745f62-c3b9-4a21-8942-71bc6f603f77.jsonl": 1778018654221, + "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/fb7fd15f-b9ef-490b-a1de-8238ea026e53/fb7fd15f-b9ef-490b-a1de-8238ea026e53.jsonl": 1779998515529 } diff --git a/.cursor/hooks/state/continual-learning.json b/.cursor/hooks/state/continual-learning.json index db397af..d47349e 100644 --- a/.cursor/hooks/state/continual-learning.json +++ b/.cursor/hooks/state/continual-learning.json @@ -1,8 +1,8 @@ { "version": 1, - "lastRunAtMs": 1779909959153, - "turnsSinceLastRun": 5, - "lastTranscriptMtimeMs": 1779909958790.594, - "lastProcessedGenerationId": "dc55cac3-bead-4e51-af66-3b48e77deb37", + "lastRunAtMs": 1779998560332, + "turnsSinceLastRun": 2, + "lastTranscriptMtimeMs": 1779998515529, + "lastProcessedGenerationId": "5e947d58-6d03-4f90-ad7c-4a97606f4d11", "trialStartedAtMs": null } diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 1a3df35..e6c6d67 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -226,7 +226,7 @@ Branch: ${{ github.ref_name }}" \ upsert AUTH_GOOGLE_SECRET "$AUTH_GOOGLE_SECRET" upsert SOCKET_INTERNAL_KEY "$SOCKET_INTERNAL_KEY" upsert SOCKET_PORT "$SOCKET_PORT" - upsert SOCKET_HTTP_PORT "$SOCKET_SOCKET_HTTP_PORT" + upsert SOCKET_HTTP_PORT "$SOCKET_HTTP_PORT" upsert SOCKET_INTERNAL_URL "$SOCKET_INTERNAL_URL" upsert NEXT_PUBLIC_SOCKET_URL "$NEXT_PUBLIC_SOCKET_URL" upsert TELEGRAM_BOT_TOKEN "$TELEGRAM_BOT_TOKEN" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1a3df35..e6c6d67 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -226,7 +226,7 @@ Branch: ${{ github.ref_name }}" \ upsert AUTH_GOOGLE_SECRET "$AUTH_GOOGLE_SECRET" upsert SOCKET_INTERNAL_KEY "$SOCKET_INTERNAL_KEY" upsert SOCKET_PORT "$SOCKET_PORT" - upsert SOCKET_HTTP_PORT "$SOCKET_SOCKET_HTTP_PORT" + upsert SOCKET_HTTP_PORT "$SOCKET_HTTP_PORT" upsert SOCKET_INTERNAL_URL "$SOCKET_INTERNAL_URL" upsert NEXT_PUBLIC_SOCKET_URL "$NEXT_PUBLIC_SOCKET_URL" upsert TELEGRAM_BOT_TOKEN "$TELEGRAM_BOT_TOKEN" diff --git a/AGENTS.md b/AGENTS.md index e23d0ff..37afbfb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ - **Interdiction d'écrire des tests** sauf demande explicite ; en CI, seul `npm run test:unit` (`tests/unit/**`) — pas `tests/migration/` ; ne jamais générer de code superflu. - Déploiement : privilégier le chemin rapide (artifact Next.js en CI + `Dockerfile.prebuilt`) ; CI/CD très robuste (pas d'image Docker obsolète en prod, pas de migrations/schéma DB via le workflow deploy) ; éviter les rebuild Docker complets inutiles (~15 min par itération) ; **ne pas pousser un déploiement quand des features clés sont inachevées** ; ne pas insister sur le déploiement tant que le produit n'est pas fini ou meilleur. - Authentification : priorité à l'inscription/connexion via **Google OAuth** (plutôt qu'un compte email/mot de passe) ; exiger une vraie déconnexion (invalidation session/cookies — pas de reconnexion implicite, y compris en navigation privée). -- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; **ne jamais affirmer qu'un correctif ou une feature est fait sans vérification réelle** (app, prototype `architectural-grid`, ou test), **notamment navigation recherche/liste notes et vue `/insights` vs fichiers prototype** — l'utilisateur sanctionne fermement les fausses déclarations ; **ouverture note liée depuis l'éditeur** (ex. bloc live « Ouvrir ») : **split peek inline** animé (`lib/note-peek-sync.ts`, `note-editor-split-peek.tsx` — panneau gauche lecture seule, note courante à droite), **pas nouvel onglet** ; **ne jamais annuler du code non commité** (`git checkout`, reset fichier) **sans demande explicite** (perte de travail documentée, ex. drag handle éditeur) ; en frustration ou pour déléguer, **prévoir des prompts / briefs d'implémentation détaillés** (autre modèle ou dev), en plus des briefs outil de design. +- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; **ne jamais affirmer qu'un correctif ou une feature est fait sans vérification réelle** (app, prototype `architectural-grid`, ou test), **notamment navigation recherche/liste notes et vue `/insights` vs fichiers prototype** — l'utilisateur sanctionne fermement les fausses déclarations ; **ouverture note liée depuis l'éditeur** (ex. bloc live « Ouvrir ») : **split peek inline** animé (`lib/note-peek-sync.ts`, `note-editor-split-peek.tsx` — éditeur courant à **gauche**, note liée en lecture seule à **droite** en LTR ; **inversé en RTL** `fa`/`ar`), **pas nouvel onglet** ; **ne jamais annuler du code non commité** (`git checkout`, reset fichier) **sans demande explicite** (perte de travail documentée, ex. drag handle éditeur) ; **correction i18n ou spec doc** : **ne pas refondre logique/UI** hors scope (ex. US-4 `structuredViewBlock` — garder le dual-mode base locale + lien carnet, pas de suppression du mode local) ; en frustration ou pour déléguer, **prévoir des prompts / briefs d'implémentation détaillés** (autre modèle ou dev), en plus des briefs outil de design. - Livraison : **une user story à la fois**, tester et valider avec l'utilisateur avant la suivante (pas d'auto-validation ni d'enchaînement de code non demandé) ; suivi dans `docs/user-stories.md`. - Quand demandé, **fournir des briefs pour un outil de design externe** plutôt que produire les maquettes UX soi-même. @@ -26,6 +26,6 @@ - Production : dépôt `/opt/memento` sur `192.168.1.190`, conteneur `memento-note` sur le port **3000**, URL publique **https://memento-note.com** (nginx + Cloudflare ; ancien domaine note.parsanet.org) ; `NEXTAUTH_URL` aligné sur ce domaine ; email sortant via **Resend** (`SMTP_FROM` ex. `noreply@memento-note.com`, domaine vérifié sur resend.com) ; deploy (`deploy.yaml` / `deploy-prod.sh`) **sans toucher Postgres** (pas de `postgresql-client`, pas de migrations auto en prod). - CI/CD Gitea : `.gitea/workflows/ci.yaml` — CI sur `ubuntu-24.04`, deploy sur runner **`docker-host`** (sur le serveur) ; deploy manuel via `.gitea/workflows/deploy.yaml` ou `bash scripts/deploy-prod.sh`. - Migrations prebuilt + vérif deploy : `docker compose exec memento-note node ./node_modules/prisma/build/index.js migrate deploy` (pas `npx prisma`) ; helper `scripts/migrate-docker.sh` ; `GET /api/build-info` (SHA Git) ; comparer `127.0.0.1:3000` et domaine Cloudflare — purger cache si versions divergent ; 403 `/api/manifest` côté domaine = souvent Cloudflare. -- Éditeur riche : `rich-text-editor.tsx` — `immediatelyRender: false` ; activer **`shouldRerenderOnTransaction: false`** (quick win perf TipTap 2.5) ; **drag handle / menu bloc** via **`@tiptap/extension-drag-handle-react`** (spec officielle — pas de double plugin `DragHandleExtension` + composant React, pas de repositionnement maison) ; poignée dans **colonne gutter fixe** du wrapper (padding + `getReferencedVirtualElement`), pas sur le bord des listes/numéros ; CSS : **pas `opacity:0` sur `.drag-handle`** (visibilité gérée par le plugin) ; config/callbacks **stables hors composant** ; fondation blocs : `tiptap-unique-id-extension.ts` / **`data-id` persisté à la sauvegarde** (références « Copier la référence ») ; **Smart Paste** : `lib/editor/smart-paste-extension.ts` ; **peek split** note source : `lib/note-peek-sync.ts` + `note-editor-split-peek.tsx` ; epic active `docs/story-nextgen-editor.md` — priorité **PERF > NEXTGEN > UX > MOBILE > MARKDOWN**. +- Éditeur riche : `rich-text-editor.tsx` — `immediatelyRender: false` ; activer **`shouldRerenderOnTransaction: false`** (quick win perf TipTap 2.5) ; **drag handle / menu bloc** via **`@tiptap/extension-drag-handle-react`** (spec officielle — pas de double plugin `DragHandleExtension` + composant React, pas de repositionnement maison) ; poignée dans **colonne gutter fixe** du wrapper (padding + `getReferencedVirtualElement`), pas sur le bord des listes/numéros ; CSS : **pas `opacity:0` sur `.drag-handle`** (visibilité gérée par le plugin) ; config/callbacks **stables hors composant** ; fondation blocs : `tiptap-unique-id-extension.ts` / **`data-id` persisté à la sauvegarde** (références « Copier la référence ») ; **Smart Paste** : `lib/editor/smart-paste-extension.ts` ; **peek split** note source : `lib/note-peek-sync.ts` + `note-editor-split-peek.tsx` ; **US-4 `structuredViewBlock`** (`tiptap-structured-view-block-extension.tsx`, `structured-view-block-embed.tsx`) : **dual-mode** — base locale autonome par défaut (`/database`, `/vue`, `isLocal: true`) + option « Lier à un carnet » (Structured Views) ; i18n `structuredViewBlock.*` ; **rejeté** : ancien `databaseBlock` « Auteurs & Œuvres » et spec embed-only `docs/story-nextgen-editor-us4-redesign.md` ; epic active `docs/story-nextgen-editor.md` — priorité **PERF > NEXTGEN > UX > MOBILE > MARKDOWN**. - Sync mutations notes entre composants : `memento-note/lib/note-change-sync.ts` (`emitNoteChange`, événement `NOTE_CHANGE_EVENT`). - Roadmap / écart prototype vs prod : Web Clipper — **`ClipperSimulator.tsx` = référence design uniquement** (pas de simulateur en prod) ; extension **`memento-note/extension/`** v0.3 **Side Panel** (clip page/sélection/lien ; popup Chrome se ferme au clic page — Side Panel pour la sélection) ; i18n extension **15 langues** (`_locales/`, détection locale navigateur ; script `extension/i18n/generate-translations.cjs`) ; **`host_permissions`** incl. LAN ; **URL serveur configurable en dev**, adresse prod figée en release ; cookies/session alignés avec l'instance cible ; **Flashcards IA SM-2 livrées** : `/revision`, `/api/flashcards/*`, génération depuis l'éditeur (GraduationCap) — réf. prototype `RevisionView.tsx` ; **Structured Views partiellement livrées** : schéma par carnet, Table/Kanban, champs partagés et valeurs par note (`/home` + toolbar carnet) — **suivi de tâches par carnet via Kanban structuré** (pas de vue agrégée Notes/Tâches sur la home ; cases à cocher inline dans les notes) ; **Living Blocks partiellement livrées** : `data-id`, Smart Paste, nœud `liveBlock`, détacher/supprimer, peek split — **US-NEXTGEN-EDITOR** en cours (`docs/story-nextgen-editor.md`, **US-TEMPORAL reporté**) ; encore en gap : transclusion bidirectionnelle complète, graphe knowledge enrichi (`GraphKnowledgeMap.tsx`), **insights sémantiques** (`InsightsView.tsx`, **`/insights` ≠ `/graph`**) ; publication **Chrome Web Store** : icônes 16/48/128, privacy policy, `host_permissions` prod restreints vs build dev. diff --git a/docs/story-nextgen-editor.md b/docs/story-nextgen-editor.md index 919a316..c1eb059 100644 --- a/docs/story-nextgen-editor.md +++ b/docs/story-nextgen-editor.md @@ -68,35 +68,40 @@ Pour offrir une expérience de saisie supérieure à celle de Notion (plus perfo --- -### US-4: Vue Structurée de Carnet Inline *(Redesign — voir [`docs/story-nextgen-editor-us4-redesign.md`](./story-nextgen-editor-us4-redesign.md))* +### US-4: Base de Données Inline (`structuredViewBlock`) -> ⚠️ **DEPRECATED** — La spec précédente (bloc "Auteurs & Œuvres") est **rejetée par le fondateur**. -> Le code legacy (`tiptap-database-block-extension.tsx`, `database-block-editor.tsx`, `database-block-types.ts`) doit être supprimé. -> La nouvelle spec complète (problème, options produit, Given/When/Then, modèle de données, migration, i18n, QA RTL) est dans [`docs/story-nextgen-editor-us4-redesign.md`](./story-nextgen-editor-us4-redesign.md). +> ⚠️ **Rejeté et supprimé :** l'ancien bloc marketing « Auteurs & Œuvres » (`tiptap-database-block-extension.tsx`, `database-block-editor.tsx`, `database-block-types.ts`). +> **Spec historique embed-only carnet :** [`docs/story-nextgen-editor-us4-redesign.md`](./story-nextgen-editor-us4-redesign.md) — **non retenue** ; le produit livré est le **dual-mode** ci-dessous. -**En tant que** rédacteur dans un carnet structuré, -**Je veux** insérer une vue en lecture de mon tableau de notes directement dans le corps de ma note, -**Afin de** voir mes données structurées en contexte, sans quitter l'éditeur. +**En tant que** rédacteur, +**Je veux** insérer un tableau interactif type Notion directement dans ma note, avec option de le lier au carnet structuré, +**Afin de** gérer des données en contexte ou synchroniser avec les notes du carnet. -#### Critères d'Acceptation (résumé) : -* **Étant donné** que ma note est dans un carnet structuré, **quand** je tape `/vue`, **alors** un bloc `structuredViewBlock` s'insère avec les données réelles du carnet — aucune donnée de démo. -* **Étant donné** que le carnet n'a pas de schéma, **alors** le bloc affiche un callout contextuel vers le wizard, pas une erreur. -* **Étant donné** qu'une note contient un ancien `databaseBlock`, **alors** il est retiré silencieusement sans crash. +#### Deux modes (même nœud TipTap `structuredViewBlock`) : -*(Voir `docs/story-nextgen-editor-us4-redesign.md` pour les critères complets, le modèle de données, et la checklist QA FR+fa RTL.)* +| Mode | Déclencheur par défaut | Données | UI | +|------|------------------------|---------|-----| +| **Base locale autonome** | `/database`, `/vue`, menu « Transformer en → Base de données » | Colonnes/lignes dans `localColumnsJson` / `localRowsJson` (attrs TipTap) | Tableau éditable, analyses, Memory Echo, conversion en carnet | +| **Vue liée au carnet** | Bouton « Lier à un carnet » ou sélecteur de carnet | API schéma + notes du carnet (`notebookId`) | Table / Galerie via `StructuredViewsContainer` | + +#### Critères d'Acceptation : +* **Étant donné** une note ouverte, **quand** je tape `/database` ou `/vue`, **alors** un bloc **base locale autonome** s'insère (tableau vide éditable, pas de démo Auteurs/Œuvres). +* **Étant donné** un bloc local, **quand** je clique « Lier à un carnet », **alors** je peux afficher la vue structurée d'un carnet (table/galerie, données réelles). +* **Étant donné** un carnet lié sans schéma, **alors** callout vers le wizard — pas de crash. +* **Étant donné** un ancien `databaseBlock` HTML, **alors** migration vers bloc local par défaut — note charge sans crash. +* **Étant donné** n'importe quelle locale (dont `fa`), **alors** tous les libellés UI passent par i18n (`structuredViewBlock.*`) — pas de texte FR en dur dans l'embed. --- ## Spécifications Techniques d'Implémentation -### 1. Structure du Nœud `structuredViewBlock` (TipTap) — *US-4 Redesign* +### 1. Structure du Nœud `structuredViewBlock` (TipTap) — *US-4* -> ⚠️ **DEPRECATED :** L'ancienne section `DatabaseBlock` est remplacée. Voir [`docs/story-nextgen-editor-us4-redesign.md`](./story-nextgen-editor-us4-redesign.md) §7 pour le modèle complet. - -Extension TipTap `StructuredViewBlockExtension` dans `tiptap-structured-view-block-extension.tsx` : -- Attributs : `notebookId` (string), `displayMode` ('table'|'gallery'), `filterJson` (string JSON `{}`) -- ReactNodeViewRenderer → `structured-view-block-embed.tsx` -- Aucune donnée de démo — aucun payload JSON d'auteurs/livres dans les attrs. +Extension `StructuredViewBlockExtension` (`tiptap-structured-view-block-extension.tsx`) : +- Attributs partagés : `notebookId`, `displayMode` ('table'|'gallery'), `filterJson` +- Mode local : `isLocal: true`, `localColumnsJson`, `localRowsJson` (insertion par défaut) +- Mode carnet : `isLocal: false`, `notebookId` renseigné +- NodeView React → `structured-view-block-embed.tsx` (dual-mode, i18n complet) ### 2. Le Plugin Drag Handle & Gutter en ProseMirror *(US-1, inchangé)* Développer un plugin custom dans `/components/tiptap-drag-handle-plugin.ts` : @@ -107,14 +112,13 @@ Développer un plugin custom dans `/components/tiptap-drag-handle-plugin.ts` : ### 3. Fichiers à modifier / créer / supprimer : * `[NEW]` `memento-note/components/tiptap-drag-handle-plugin.ts` — Plugin Gutter (US-1) -* `[NEW]` `memento-note/components/tiptap-structured-view-block-extension.tsx` — Nœud Vue Structurée (US-4 redesign) -* `[NEW]` `memento-note/components/structured-view-block-embed.tsx` — React NodeView avec SWR + états dégradés -* `[DELETE]` `memento-note/components/tiptap-database-block-extension.tsx` — Legacy rejeté -* `[DELETE]` `memento-note/components/database-block-editor.tsx` — Legacy rejeté -* `[DELETE]` `memento-note/lib/editor/database-block-types.ts` — Legacy rejeté -* `[MODIFY]` `memento-note/components/note-content-area.tsx` — Passer `notebookId` à l'éditeur -* `[MODIFY]` `memento-note/components/rich-text-editor.tsx` — Swap extension DB → Vue Structurée, mise à jour slash -* `[MODIFY]` `memento-note/components/block-action-menu.tsx` — Remplacer option "Database" par "Vue structurée" +* `[LIVRÉ]` `memento-note/components/tiptap-structured-view-block-extension.tsx` — Nœud dual-mode +* `[LIVRÉ]` `memento-note/components/structured-view-block-embed.tsx` — UI base locale + vue carnet +* `[DELETE]` legacy `database-block-*` (Auteurs & Œuvres) +* `[MODIFY]` `memento-note/locales/en.json` + `fr.json` — clés `structuredViewBlock.*` (i18n embed) +* `[MODIFY]` `memento-note/components/note-content-area.tsx` — `notebookId` → éditeur +* `[MODIFY]` `memento-note/components/rich-text-editor.tsx` — slash `/database` + `/vue` +* `[MODIFY]` `memento-note/components/block-action-menu.tsx` — « Transformer en → Base de données » * `[MODIFY]` `memento-note/app/globals.css` — Gutter, poignée, glassmorphic dropdowns --- @@ -125,9 +129,9 @@ Développer un plugin custom dans `/components/tiptap-drag-handle-plugin.ts` : 1. **Vérification Gutter :** Survoler des textes longs et vérifier que la poignée se positionne correctement à gauche. Glisser un paragraphe sur un autre et valider le réordonnancement. 2. **Vérification Menu :** Cliquer sur la poignée, dupliquer le bloc, supprimer le bloc, et le transformer en d'autres types. 3. **Vérification Paste :** Copier une référence de bloc, la coller, et vérifier que la transclusion est proposée et s'insère sous forme de `LiveBlock`. -4. **Vérification Vue Structurée Inline (US-4 redesign) :** - - Taper `/vue` dans une note de carnet structuré → bloc avec données réelles (pas Jules Verne). - - Taper `/vue` dans un carnet sans schéma → callout wizard, pas de crash. - - Ouvrir une note avec ancien `databaseBlock` → note charge sans erreur. - - Passer la langue en `fa` (persan) → libellés traduits, layout RTL correct. - - Voir checklist complète dans `docs/story-nextgen-editor-us4-redesign.md` §11. +4. **Vérification Base inline (US-4) :** + - `/database` ou `/vue` → base locale autonome (tableau éditable, design actuel). + - « Lier à un carnet » → vue table/galerie du carnet choisi. + - Carnet sans schéma → callout wizard. + - Ancien `databaseBlock` → charge sans crash. + - Locale `fa` → libellés i18n, RTL (`dir="auto"`). diff --git a/memento-note/app/(main)/settings/billing/page.tsx b/memento-note/app/(main)/settings/billing/page.tsx index a186c1a..70aab56 100644 --- a/memento-note/app/(main)/settings/billing/page.tsx +++ b/memento-note/app/(main)/settings/billing/page.tsx @@ -2,6 +2,8 @@ import { Suspense } from 'react'; import { Loader2 } from 'lucide-react'; import { BillingPlans } from '@/components/settings/billing-plans'; +export const dynamic = 'force-dynamic'; + export const metadata = { title: 'Billing', }; diff --git a/memento-note/app/api/billing/cancel/route.ts b/memento-note/app/api/billing/cancel/route.ts new file mode 100644 index 0000000..ff1f4fe --- /dev/null +++ b/memento-note/app/api/billing/cancel/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/auth'; +import { cancelSubscription } from '@/lib/billing/cancel-subscription'; + +export const dynamic = 'force-dynamic'; + +export async function POST(req: NextRequest) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const userId = session.user.id; + + try { + const result = await cancelSubscription(userId); + if (!result.success) { + const isNotFound = result.error === 'No active subscription found'; + return NextResponse.json( + { error: result.error }, + { status: isNotFound ? 404 : 400 } + ); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('[billing/cancel] Route handler crash:', error); + return NextResponse.json({ error: 'Failed to cancel subscription' }, { status: 500 }); + } +} diff --git a/memento-note/app/api/billing/create-checkout/route.ts b/memento-note/app/api/billing/create-checkout/route.ts index e0766d7..fb119cf 100644 --- a/memento-note/app/api/billing/create-checkout/route.ts +++ b/memento-note/app/api/billing/create-checkout/route.ts @@ -31,21 +31,41 @@ export async function POST(req: NextRequest) { 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; + + // Update DB to save the real Stripe 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), // temp basic dates + } + }); } - const origin = req.headers.get('origin') ?? process.env.NEXTAUTH_URL ?? 'http://localhost:3000'; + 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 sessionParams = { customer: customerId, mode: 'subscription' as const, line_items: [{ price: priceId, quantity: 1 }], - ui_mode: 'embedded', + ui_mode: 'embedded_page', return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`, metadata: { userId, tier }, subscription_data: { metadata: { userId, tier } }, diff --git a/memento-note/app/api/billing/invoices/route.ts b/memento-note/app/api/billing/invoices/route.ts index 7535cfd..9a187b1 100644 --- a/memento-note/app/api/billing/invoices/route.ts +++ b/memento-note/app/api/billing/invoices/route.ts @@ -3,6 +3,8 @@ import { auth } from '@/auth'; import { stripe } from '@/lib/stripe'; import { prisma } from '@/lib/prisma'; +export const dynamic = 'force-dynamic'; + export async function GET(req: NextRequest) { const session = await auth(); if (!session?.user?.id) { diff --git a/memento-note/app/api/billing/portal/route.ts b/memento-note/app/api/billing/portal/route.ts index 5cb2854..6359b12 100644 --- a/memento-note/app/api/billing/portal/route.ts +++ b/memento-note/app/api/billing/portal/route.ts @@ -11,13 +11,21 @@ export async function POST(req: NextRequest) { const userId = session.user.id; + let action = 'portal'; + try { + const body = await req.json(); + if (body?.action) action = body.action; + } catch (_) {} + try { const subscription = await prisma.subscription.findUnique({ where: { userId } }); if (!subscription?.stripeCustomerId) { return NextResponse.json({ error: 'No active subscription found' }, { status: 404 }); } - const origin = req.headers.get('origin') ?? process.env.NEXTAUTH_URL ?? 'http://localhost:3000'; + 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 portalSession = await stripe.billingPortal.sessions.create({ customer: subscription.stripeCustomerId, diff --git a/memento-note/app/api/billing/status/route.ts b/memento-note/app/api/billing/status/route.ts index 69cd1c0..dc7674f 100644 --- a/memento-note/app/api/billing/status/route.ts +++ b/memento-note/app/api/billing/status/route.ts @@ -1,20 +1,79 @@ -import { NextResponse } from 'next/server'; +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 } from '@/lib/billing/stripe-prices'; -export async function GET() { +export const dynamic = 'force-dynamic'; + +export async function GET(req: NextRequest) { const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } const userId = session.user.id; + const { prisma } = await import('@/lib/prisma'); + + const sessionId = req.nextUrl.searchParams.get('session_id'); + + if (sessionId && sessionId.startsWith('cs_')) { + try { + const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId); + if (checkoutSession.subscription && checkoutSession.status === 'complete') { + const subId = typeof checkoutSession.subscription === 'string' + ? checkoutSession.subscription + : (checkoutSession.subscription as any).id; + + const sub = await stripe.subscriptions.retrieve(subId); + const priceId = sub.items.data[0].price.id; + const tier = 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), + }, + }); + } + } catch (err) { + console.error('[billing/status] Failed to sync Stripe session:', err); + } + } try { const { tier, status, currentPeriodEnd } = await getUserInfo(userId); const effectiveTier = await getEffectiveTier(userId); - - const { prisma } = await import('@/lib/prisma'); const subscription = await prisma.subscription.findUnique({ where: { userId } }); return NextResponse.json({ diff --git a/memento-note/app/api/usage/current/route.ts b/memento-note/app/api/usage/current/route.ts index 61480f4..dc541d7 100644 --- a/memento-note/app/api/usage/current/route.ts +++ b/memento-note/app/api/usage/current/route.ts @@ -2,6 +2,8 @@ import { NextResponse } from 'next/server'; import { auth } from '@/auth'; import { getUserQuotas, getEffectiveTier } from '@/lib/entitlements'; +export const dynamic = 'force-dynamic'; + export async function GET() { const session = await auth(); diff --git a/memento-note/app/globals.css b/memento-note/app/globals.css index 8397f86..50aca53 100644 --- a/memento-note/app/globals.css +++ b/memento-note/app/globals.css @@ -1210,295 +1210,6 @@ html.font-system * { box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); } -/* --- Inline Database Block (US-NEXTGEN-EDITOR) --- */ -.database-block-wrapper { - margin: 0.75rem 0; -} - -.database-block__inner { - border: 1px solid #e8e6e3; - border-radius: 12px; - background: rgba(255, 255, 255, 0.6); - padding: 1rem 1.1rem; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); -} - -.dark .database-block__inner { - border-color: rgba(255, 255, 255, 0.08); - background: rgba(24, 24, 27, 0.5); -} - -.database-block__header { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 0.75rem; - margin-bottom: 0.35rem; -} - -.database-block__title { - display: block; - font-size: 0.9rem; - font-weight: 700; - color: var(--color-ink, #1a1a1a); -} - -.database-block__id { - display: block; - font-size: 0.65rem; - font-family: ui-monospace, monospace; - opacity: 0.45; - margin-top: 0.1rem; -} - -.database-block__hint { - font-size: 0.72rem; - opacity: 0.55; - margin: 0 0 0.85rem; -} - -.database-block__view-toggle { - display: inline-flex; - padding: 2px; - border-radius: 8px; - background: rgba(0, 0, 0, 0.04); - border: 1px solid rgba(0, 0, 0, 0.06); -} - -.dark .database-block__view-toggle { - background: rgba(255, 255, 255, 0.04); - border-color: rgba(255, 255, 255, 0.08); -} - -.database-block__view-toggle button { - border: none; - background: transparent; - font-size: 0.68rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; - padding: 0.35rem 0.65rem; - border-radius: 6px; - cursor: pointer; - opacity: 0.65; - color: inherit; -} - -.database-block__view-toggle button.is-active { - background: white; - opacity: 1; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); -} - -.dark .database-block__view-toggle button.is-active { - background: rgba(255, 255, 255, 0.12); -} - -.database-block__table-wrap { - overflow-x: auto; - border-radius: 8px; - border: 1px solid rgba(0, 0, 0, 0.06); -} - -.dark .database-block__table-wrap { - border-color: rgba(255, 255, 255, 0.08); -} - -.database-block__table { - width: 100%; - border-collapse: collapse; - font-size: 0.8rem; -} - -.database-block__table th { - text-align: left; - padding: 0.5rem 0.75rem; - font-size: 0.65rem; - text-transform: uppercase; - letter-spacing: 0.05em; - opacity: 0.55; - background: rgba(0, 0, 0, 0.02); - border-bottom: 1px solid rgba(0, 0, 0, 0.06); -} - -.database-block__table td { - padding: 0.55rem 0.75rem; - border-bottom: 1px solid rgba(0, 0, 0, 0.04); - vertical-align: top; -} - -.database-block__works-cell { - max-width: 220px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - opacity: 0.85; -} - -.database-block__rollup { - text-align: right; - font-weight: 700; - font-variant-numeric: tabular-nums; -} - -.database-block__delete-btn { - border: none; - background: transparent; - font-size: 0.65rem; - opacity: 0.45; - cursor: pointer; - color: #ef4444; -} - -.database-block__delete-btn:hover { - opacity: 1; -} - -.database-block__inline-form, -.database-block__book-form { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.5rem; - margin-top: 0.75rem; - padding-top: 0.75rem; - border-top: 1px dashed rgba(0, 0, 0, 0.08); -} - -.database-block__book-form { - flex-direction: column; - align-items: stretch; -} - -.database-block__form-label, -.database-block__form-heading { - font-size: 0.68rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.05em; - opacity: 0.55; - width: 100%; -} - -.database-block__input { - font-size: 0.8rem; - padding: 0.45rem 0.65rem; - border-radius: 8px; - border: 1px solid rgba(0, 0, 0, 0.1); - background: white; - color: inherit; -} - -.dark .database-block__input { - background: rgba(0, 0, 0, 0.25); - border-color: rgba(255, 255, 255, 0.1); -} - -.database-block__primary-btn, -.database-block__submit { - border: none; - border-radius: 8px; - background: #3b82f6; - color: white; - font-size: 0.75rem; - font-weight: 600; - padding: 0.45rem 0.85rem; - cursor: pointer; -} - -.database-block__primary-btn:hover, -.database-block__submit:hover { - background: #2563eb; -} - -.database-block__card { - position: relative; - border-radius: 10px; - overflow: hidden; - border: 1px solid rgba(0, 0, 0, 0.08); - background: white; -} - -.dark .database-block__card { - background: rgba(0, 0, 0, 0.2); - border-color: rgba(255, 255, 255, 0.08); -} - -.database-block__card-delete { - position: absolute; - top: 6px; - right: 6px; - z-index: 2; - border: none; - border-radius: 6px; - padding: 4px; - background: rgba(0, 0, 0, 0.45); - color: white; - cursor: pointer; - opacity: 0; - transition: opacity 0.15s; -} - -.database-block__card:hover .database-block__card-delete { - opacity: 1; -} - -.database-block__card-cover { - aspect-ratio: 4 / 3; - overflow: hidden; - background: #f4f4f5; -} - -.database-block__card-cover img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.database-block__card-body { - padding: 0.55rem 0.65rem 0.65rem; -} - -.database-block__card-title { - font-size: 0.78rem; - font-weight: 700; - line-height: 1.3; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.database-block__tag { - font-size: 0.62rem; - font-weight: 600; - padding: 0.15rem 0.4rem; - border-radius: 4px; -} - -.database-block__tag--author { - background: rgba(59, 130, 246, 0.12); - color: #2563eb; -} - -.database-block__tag--genre { - background: rgba(0, 0, 0, 0.06); - opacity: 0.75; -} - -.database-block__card-placeholder { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - min-height: 140px; - border-radius: 10px; - border: 2px dashed rgba(0, 0, 0, 0.08); - opacity: 0.5; - text-align: center; - padding: 1rem; -} /* --- Drop Indicator Line --- */ .notion-drop-indicator { @@ -1622,23 +1333,24 @@ html.font-system * { } /* --- Lists --- */ -.notion-editor-wrapper .ProseMirror ul { +/* TipTap wraps list content in

(block). inside markers force bullet on its own line. */ +.notion-editor-wrapper .ProseMirror ul:not([data-type="taskList"]) { list-style-type: disc; - list-style-position: inside; - padding-inline-start: 0; + list-style-position: outside; + padding-inline-start: 1.5rem; margin: 0.25em 0; } .notion-editor-wrapper .ProseMirror ol { list-style-type: decimal; - list-style-position: inside; - padding-inline-start: 0; + list-style-position: outside; + padding-inline-start: 1.5rem; margin: 0.25em 0; } .notion-editor-wrapper .ProseMirror li>p { - margin: 0.1em 0; - padding-inline-start: 0.25rem; + margin: 0; + padding-inline-start: 0; } .notion-editor-wrapper .ProseMirror li>ul, diff --git a/memento-note/components/settings/billing-plans.tsx b/memento-note/components/settings/billing-plans.tsx index d4b6c48..5fcb4e2 100644 --- a/memento-note/components/settings/billing-plans.tsx +++ b/memento-note/components/settings/billing-plans.tsx @@ -24,7 +24,7 @@ interface BillingStatus { hasStripeSubscription: boolean; } -const billingEnabled = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true'; +const billingEnabled = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' || process.env.NODE_ENV === 'development'; let stripePromise: ReturnType | null = null; function getStripePromise() { @@ -43,12 +43,14 @@ export function BillingPlans() { const [isCheckoutOpen, setIsCheckoutOpen] = useState(false); const [checkoutLoading, setCheckoutLoading] = useState(null); const [portalLoading, setPortalLoading] = useState(false); + const [cancelLoading, setCancelLoading] = useState(false); const [successBanner, setSuccessBanner] = useState(null); const { data: status, isLoading } = useQuery({ queryKey: ['billing', 'status'], queryFn: async () => { - const res = await fetch('/api/billing/status'); + const search = typeof window !== 'undefined' ? window.location.search : ''; + const res = await fetch(`/api/billing/status${search}`); if (!res.ok) throw new Error('Failed to fetch billing status'); return res.json(); }, @@ -69,6 +71,7 @@ export function BillingPlans() { useEffect(() => { const params = new URLSearchParams(window.location.search); const sessionId = params.get('session_id'); + if (sessionId) { const tier = status?.effectiveTier ?? 'Pro'; setSuccessBanner(t('billing.checkoutSuccessBody').replace('{tier}', tier)); @@ -112,10 +115,15 @@ export function BillingPlans() { } }; - const handlePortal = async () => { + const handlePortal = async (action: 'portal' | 'cancel' | React.MouseEvent = 'portal') => { + const actualAction = typeof action === 'string' ? action : 'portal'; setPortalLoading(true); try { - const res = await fetch('/api/billing/portal', { method: 'POST' }); + const res = await fetch('/api/billing/portal', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: actualAction }), + }); const data = await res.json(); if (!res.ok) { toast.error(data.error || 'Failed to open billing portal.'); @@ -130,6 +138,34 @@ export function BillingPlans() { } }; + const handleCancelSubscription = async () => { + const confirmMsg = t('billing.cancelConfirm') || "Êtes-vous sûr de vouloir résilier votre abonnement ? Vous conserverez vos accès Pro/Business jusqu'à la fin de la période en cours."; + if (!window.confirm(confirmMsg)) { + return; + } + + setCancelLoading(true); + try { + const res = await fetch('/api/billing/cancel', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + const data = await res.json(); + if (!res.ok) { + toast.error(data.error || 'Failed to cancel subscription.'); + return; + } + toast.success(t('billing.cancelSuccess') || "Votre abonnement a été résilié avec succès. Il prendra fin à la fin de la période de facturation en cours."); + queryClient.invalidateQueries({ queryKey: ['billing', 'status'] }); + queryClient.invalidateQueries({ queryKey: ['usage', 'current'] }); + } catch (err) { + console.error('[BillingPlans] cancel error:', err); + toast.error('Failed to cancel subscription.'); + } finally { + setCancelLoading(false); + } + }; + const handleCheckoutComplete = useCallback(() => { setIsCheckoutOpen(false); setCheckoutClientSecret(null); @@ -310,38 +346,42 @@ export function BillingPlans() { {effectiveTier === 'BASIC' ? t('billing.freePlan') : effectiveTier === 'PRO' ? t('billing.proPlan') : effectiveTier === 'BUSINESS' ? t('billing.businessPlan') : t('billing.enterprisePlan')} -

- - {status?.status ? t(`billing.${status.status.toLowerCase()}`) || status.status : t('billing.active')} - -
+ {isPaid && ( +
+ + {status?.status ? t(`billing.${status.status.toLowerCase()}`) || status.status : t('billing.active')} + +
+ )} -
-
- {t('billing.billingPeriod')} -

- {status?.currentPeriodStart && status?.currentPeriodEnd ? ( - `${formatDate(status.currentPeriodStart)} – ${formatDate(status.currentPeriodEnd)}` - ) : ( - '—' - )} -

+ {isPaid && ( +
+
+ {t('billing.billingPeriod')} +

+ {status?.currentPeriodStart && status?.currentPeriodEnd ? ( + `${formatDate(status.currentPeriodStart)} – ${formatDate(status.currentPeriodEnd)}` + ) : ( + '—' + )} +

+
+
+ + {status?.cancelAtPeriodEnd ? t('billing.expiresOn') : t('billing.nextBillingDate')} + +

+ {status?.currentPeriodEnd ? formatDate(status.currentPeriodEnd) : '—'} +

+
-
- - {status?.cancelAtPeriodEnd ? t('billing.expiresOn') : t('billing.nextBillingDate')} - -

- {status?.currentPeriodEnd ? formatDate(status.currentPeriodEnd) : '—'} -

-
-
+ )} {isPaid && (
@@ -355,13 +395,14 @@ export function BillingPlans() { {t('billing.manageBilling') || 'Gérer la facturation'} - {status?.hasStripeSubscription && ( + {status?.hasStripeSubscription && !status?.cancelAtPeriodEnd && ( )} diff --git a/memento-note/components/structured-view-block-embed.tsx b/memento-note/components/structured-view-block-embed.tsx index 7553616..1d1e36b 100644 --- a/memento-note/components/structured-view-block-embed.tsx +++ b/memento-note/components/structured-view-block-embed.tsx @@ -114,10 +114,10 @@ export function StructuredViewBlockEmbed({ if (json.success && Array.isArray(json.data)) { setNotes(json.data) } else { - setNotesError(t('structuredViewBlock.loadError') || 'Failed to load notes') + setNotesError(t('structuredViewBlock.loadError')) } } catch (e) { - setNotesError(e instanceof Error ? e.message : 'Error loading notes') + setNotesError(e instanceof Error ? e.message : t('structuredViewBlock.notesLoadError')) } finally { setNotesLoading(false) } @@ -157,7 +157,7 @@ export function StructuredViewBlockEmbed({ const newId = `col-${Date.now()}` const newCol: LocalColumn = { id: newId, - name: `Propriété ${localColumns.length + 1}`, + name: t('structuredViewBlock.propertyName', { index: localColumns.length + 1 }), type: 'text', } const updatedCols = [...localColumns, newCol] @@ -166,7 +166,7 @@ export function StructuredViewBlockEmbed({ values: { ...row.values, [newId]: '' } })) updateLocalData(updatedCols, updatedRows) - toast.success('Colonne ajoutée !') + toast.success(t('structuredViewBlock.columnAdded')) } const deleteLocalColumn = (colId: string) => { @@ -177,7 +177,7 @@ export function StructuredViewBlockEmbed({ return { ...row, values: nextValues } }) updateLocalData(updatedCols, updatedRows) - toast.success('Colonne supprimée') + toast.success(t('structuredViewBlock.columnRemoved')) } const updateLocalColumnName = (colId: string, name: string) => { @@ -190,7 +190,7 @@ export function StructuredViewBlockEmbed({ if (c.id === colId) { const next: LocalColumn = { ...c, type } if (type === 'select') { - next.options = ['Option 1', 'Option 2'] + next.options = [t('structuredViewBlock.defaultOption1'), t('structuredViewBlock.defaultOption2')] } else { delete next.options } @@ -255,7 +255,7 @@ export function StructuredViewBlockEmbed({ const handleConvertToNotebook = async () => { const name = newNotebookName.trim() if (!name) { - toast.error('Veuillez entrer un nom pour le carnet.') + toast.error(t('structuredViewBlock.convertNotebookNameRequired')) return } setConverting(true) @@ -268,7 +268,7 @@ export function StructuredViewBlockEmbed({ }) const createdNbJson = await createNbRes.json() if (!createNbRes.ok || !createdNbJson.success) { - throw new Error(createdNbJson.error || 'Erreur lors de la création du carnet.') + throw new Error(createdNbJson.error || t('structuredViewBlock.convertNotebookError')) } const newNotebook = createdNbJson.data @@ -278,7 +278,7 @@ export function StructuredViewBlockEmbed({ }) const schemaJson = await enableSchemaRes.json() if (!enableSchemaRes.ok || !schemaJson.success) { - throw new Error(schemaJson.error || 'Erreur lors de l\'activation de la structure.') + throw new Error(schemaJson.error || t('structuredViewBlock.convertSchemaError')) } const schema = schemaJson.data.schema @@ -300,7 +300,7 @@ export function StructuredViewBlockEmbed({ }) const propJson = await propRes.json() if (!propRes.ok || !propJson.success) { - throw new Error(propJson.error || 'Erreur d\'ajout de propriété.') + throw new Error(propJson.error || t('structuredViewBlock.convertPropertyError')) } // Find added property ID in the updated schema const addedProp = propJson.data.schema.properties.find((p: any) => p.name === col.name) @@ -311,7 +311,7 @@ export function StructuredViewBlockEmbed({ // 4. Create Notes for each row for (const row of localRows) { - const titleVal = row.values['col-title'] || 'Sans titre' + const titleVal = row.values['col-title'] || t('structuredViewBlock.untitled') const noteRes = await fetch('/api/notes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -323,7 +323,7 @@ export function StructuredViewBlockEmbed({ }) const noteJson = await noteRes.json() if (!noteRes.ok || !noteJson.success) { - throw new Error('Erreur de création de note.') + throw new Error(t('structuredViewBlock.convertNoteError')) } const createdNote = noteJson.data @@ -351,11 +351,11 @@ export function StructuredViewBlockEmbed({ }) await refreshNotebooks() - toast.success('Conversion réussie ! Base liée créée.') + toast.success(t('structuredViewBlock.convertSuccess')) setShowConvertModal(false) } catch (e) { console.error(e) - toast.error(e instanceof Error ? e.message : 'Une erreur est survenue.') + toast.error(e instanceof Error ? e.message : t('structuredViewBlock.convertGenericError')) } finally { setConverting(false) } @@ -416,7 +416,7 @@ export function StructuredViewBlockEmbed({ const q = (titleVal || '').trim() if (!q) { setEchoConnections([]) - setEchoError("Veuillez d'abord saisir un nom pour cette ligne afin de rechercher des résonances sémantiques.") + setEchoError(t('structuredViewBlock.echoNameRequired')) setEchoLoading(false) return } @@ -438,11 +438,11 @@ export function StructuredViewBlockEmbed({ } if (data.length === 0) { - setEchoError(`Aucune note correspondante contenant "${q}" n'a été trouvée dans votre espace de travail.`) + setEchoError(t('structuredViewBlock.echoNoMatch', { query: q })) } else { setEchoConnections(data.map((n: any, idx: number) => ({ noteId: n.id, - title: n.title || 'Sans titre', + title: n.title || t('structuredViewBlock.untitled'), similarity: Math.round((0.88 - idx * 0.04) * 100), updatedAt: n.updatedAt, isTextMatch: true @@ -450,7 +450,7 @@ export function StructuredViewBlockEmbed({ } } catch (e) { console.error(e) - setEchoError("Une erreur est survenue lors de la recherche.") + setEchoError(t('structuredViewBlock.echoSearchError')) } finally { setEchoLoading(false) } @@ -545,7 +545,7 @@ export function StructuredViewBlockEmbed({ return (
- {t('structuredViewBlock.analyticsNoData') || 'Aucune donnée d\'analyse disponible.'} + {t('structuredViewBlock.analyticsNoData')}
) } @@ -555,10 +555,10 @@ export function StructuredViewBlockEmbed({
- {t('structuredViewBlock.analyticsTitle') || 'Analyses & Insights'} + {t('structuredViewBlock.analyticsTitle')}
- {t('structuredViewBlock.analyticsTotalRows') || 'Total des lignes'} : {total} + {t('structuredViewBlock.analyticsTotalRows')}: {total}
@@ -567,7 +567,7 @@ export function StructuredViewBlockEmbed({ {hasCheckboxStats && (
- {t('structuredViewBlock.analyticsCompletion') || 'Taux de complétion'} + {t('structuredViewBlock.analyticsCompletion')}
{checkboxStats.map(stat => ( @@ -592,7 +592,7 @@ export function StructuredViewBlockEmbed({ {hasSelectStats && (
- {t('structuredViewBlock.analyticsDistribution') || 'Répartition'} + {t('structuredViewBlock.analyticsDistribution')}
{selectStats.map(stat => { @@ -650,7 +650,7 @@ export function StructuredViewBlockEmbed({
- {t('structuredViewBlock.localDbTitle') || 'Base de Données Autonome'} + {t('structuredViewBlock.localDbTitle')}
@@ -663,7 +663,7 @@ export function StructuredViewBlockEmbed({ className="text-[11px] h-7 px-2.5 rounded-lg transition-all flex items-center gap-1.5" > - Analyses + {t('structuredViewBlock.analyticsShort')} @@ -676,7 +676,7 @@ export function StructuredViewBlockEmbed({ className="text-[11px] h-7 px-2.5 rounded-lg border-primary/30 hover:border-primary hover:bg-primary/5 text-primary transition-all flex items-center gap-1.5" > - Convertir en carnet + {t('structuredViewBlock.convertToNotebook')} @@ -688,7 +688,7 @@ export function StructuredViewBlockEmbed({ onClick={() => updateAttributes({ isLocal: false })} className="text-[11px] h-7 px-2 text-muted-foreground hover:text-foreground" > - Lier à un carnet + {t('structuredViewBlock.linkToNotebook')}
@@ -702,16 +702,16 @@ export function StructuredViewBlockEmbed({

- Convertir en carnet structuré + {t('structuredViewBlock.convertModalTitle')}

- Ce tableau local va être converti en carnet réel. Chaque ligne deviendra une note de votre carnet, et vos colonnes seront configurées comme propriétés réutilisables. + {t('structuredViewBlock.convertModalDesc')}

- + setNewNotebookName(e.target.value)} className="w-full rounded-lg border border-border bg-background px-3 py-2 text-xs outline-none focus:ring-1 focus:ring-primary" @@ -719,15 +719,15 @@ export function StructuredViewBlockEmbed({
@@ -758,16 +758,16 @@ export function StructuredViewBlockEmbed({ onChange={(e) => updateLocalColumnType(col.id, e.target.value as any)} className="text-[9px] bg-transparent border border-border/30 rounded px-1 py-0.5 text-muted-foreground hover:border-border transition-colors outline-none cursor-pointer" > - - - + + + )} {/* Display select options configurations inline */} {col.type === 'select' && ( updateLocalColumnOptions(col.id, e.target.value)} onKeyDown={(e) => e.stopPropagation()} @@ -784,7 +784,7 @@ export function StructuredViewBlockEmbed({ deleteLocalColumn(col.id) }} className="opacity-40 hover:opacity-100 hover:text-red-500 p-0.5 rounded transition-all ml-auto shrink-0" - title="Supprimer la colonne" + title={t('structuredViewBlock.deleteColumn')} > @@ -797,7 +797,7 @@ export function StructuredViewBlockEmbed({ type="button" onClick={addLocalColumn} className="p-1 rounded bg-primary/10 hover:bg-primary/20 text-primary transition-all" - title="Ajouter une colonne" + title={t('structuredViewBlock.addColumn')} > @@ -815,7 +815,7 @@ export function StructuredViewBlockEmbed({ type="button" onClick={() => deleteLocalRow(row.id)} className="opacity-0 group-hover/row:opacity-100 hover:text-red-500 p-1 rounded hover:bg-red-500/10 transition-all" - title="Supprimer la ligne" + title={t('structuredViewBlock.deleteRow')} > @@ -828,7 +828,7 @@ export function StructuredViewBlockEmbed({ ? "text-purple-400 bg-purple-500/10 opacity-100" : "hover:text-purple-400 hover:bg-purple-500/10" )} - title="Résonances sémantiques" + title={t('structuredViewBlock.semanticEcho')} > @@ -875,7 +875,7 @@ export function StructuredViewBlockEmbed({ updateLocalCellValue(row.id, col.id, e.target.value)} onKeyDown={(e) => { e.stopPropagation() @@ -902,20 +902,20 @@ export function StructuredViewBlockEmbed({
- {t('structuredViewBlock.echoPopoverTitle') || 'Résonance Sémantique 🔮'} + {t('structuredViewBlock.echoPopoverTitle')}
{echoLoading ? (
- {t('structuredViewBlock.echoLoading') || 'Recherche de connexions sémantiques...'} + {t('structuredViewBlock.echoLoading')}
) : echoError ? (
@@ -923,16 +923,16 @@ export function StructuredViewBlockEmbed({ {echoError}

- {t('structuredViewBlock.echoUpgradeText') || "Convertissez ce tableau en carnet pour activer l'analyse neuronale de Momento."} + {t('structuredViewBlock.echoUpgradeText')}
) : echoConnections.length === 0 ? (

- {t('structuredViewBlock.noEchoFound') || 'Aucune résonance sémantique détectée.'} + {t('structuredViewBlock.noEchoFound')}

- {t('structuredViewBlock.echoUpgradeText') || "Convertissez ce tableau en carnet pour activer l'analyse neuronale de Momento."} + {t('structuredViewBlock.echoUpgradeText')}
) : ( @@ -959,20 +959,20 @@ export function StructuredViewBlockEmbed({ window.dispatchEvent(new CustomEvent('memento-insert-citation', { detail: { noteId: conn.noteId, - noteTitle: conn.title || 'Sans titre', + noteTitle: conn.title || t('structuredViewBlock.untitled'), excerpt: '', atEnd: false } })) - toast.success("Citation insérée dans l'éditeur !") + toast.success(t('structuredViewBlock.citationInserted')) }} className="p-1 text-muted-foreground hover:text-purple-400 hover:bg-purple-500/10 rounded transition-colors" - title="Insérer le lien dans l'éditeur" + title={t('structuredViewBlock.insertCitation')} > - {conn.isTextMatch ? 'Mot-clé' : `${conn.similarity}%`} + {conn.isTextMatch ? t('structuredViewBlock.keywordMatch') : `${conn.similarity}%`}
@@ -990,7 +990,7 @@ export function StructuredViewBlockEmbed({ {localRows.length === 0 && ( -

Aucune ligne dans le tableau.

+

{t('structuredViewBlock.emptyTable')}

)} {/* Add row button */} @@ -1002,7 +1002,7 @@ export function StructuredViewBlockEmbed({ className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 h-8 px-2.5 rounded-lg" > - Ajouter une ligne + {t('structuredViewBlock.addRow')}
@@ -1023,10 +1023,10 @@ export function StructuredViewBlockEmbed({ >
- {t('structuredViewBlock.selectNotebook') || 'Lier à un carnet'} + {t('structuredViewBlock.selectNotebook')}

- {t('structuredViewBlock.noNotebookDesc') || 'Ce bloc affiche la vue structurée d\'un carnet. Choisissez le carnet à lier :'} + {t('structuredViewBlock.noNotebookDesc')}

@@ -1039,7 +1039,7 @@ export function StructuredViewBlockEmbed({ className="rounded-lg border border-border bg-background px-3 py-1.5 text-xs text-foreground outline-none focus:border-primary max-w-xs w-full shadow-sm" defaultValue="" > - + {notebooks.map((nb) => (
@@ -1085,7 +1085,7 @@ export function StructuredViewBlockEmbed({ {schemaHook.error || notesError} ) @@ -1100,16 +1100,16 @@ export function StructuredViewBlockEmbed({ >
- {currentNotebook?.name || t('structuredViewBlock.insertLabel') || 'Vue structurée'} + {currentNotebook?.name || t('structuredViewBlock.insertLabel')}

- {t('structuredViewBlock.noSchema') || 'Ce carnet n\'a pas encore de vue structurée. Configurez-en une depuis l\'en-tête du carnet.'} + {t('structuredViewBlock.noSchema')}

@@ -1149,9 +1149,9 @@ export function StructuredViewBlockEmbed({ @@ -1166,7 +1166,7 @@ export function StructuredViewBlockEmbed({ ? "bg-muted text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground" )} - title={t('structuredViewBlock.displayModeTable') || 'Tableau'} + title={t('structuredViewBlock.displayModeTable')} >
@@ -1178,7 +1178,7 @@ export function StructuredViewBlockEmbed({ ? "bg-muted text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground" )} - title={t('structuredViewBlock.displayModeGallery') || 'Galerie'} + title={t('structuredViewBlock.displayModeGallery')} > @@ -1195,7 +1195,7 @@ export function StructuredViewBlockEmbed({ )} > - Analyses + {t('structuredViewBlock.analyticsShort')} @@ -1205,7 +1205,7 @@ export function StructuredViewBlockEmbed({ onClick={() => router.push(`/home?notebook=${effectiveNotebookId}`)} className="flex items-center gap-1 text-[11px] font-medium text-muted-foreground hover:text-foreground transition-colors" > - {t('structuredViewBlock.openInNotebook') || 'Ouvrir dans le carnet'} + {t('structuredViewBlock.openInNotebook')} @@ -1214,7 +1214,7 @@ export function StructuredViewBlockEmbed({ onClick={() => updateAttributes({ isLocal: true, notebookId: null })} className="text-[11px] text-primary hover:underline ml-1" > - Passer en base locale + {t('structuredViewBlock.switchToLocalDb')} diff --git a/memento-note/lib/billing/cancel-subscription.ts b/memento-note/lib/billing/cancel-subscription.ts new file mode 100644 index 0000000..a0bebd4 --- /dev/null +++ b/memento-note/lib/billing/cancel-subscription.ts @@ -0,0 +1,66 @@ +import { prisma } from '@/lib/prisma'; +import { stripe } from '@/lib/stripe'; + +export interface CancelSubscriptionResult { + success: boolean; + error?: string; +} + +/** + * Cancels an active user subscription by setting its auto-renew (cancel_at_period_end) status to true + * in Stripe and synchronizing the status back into the local Prisma database. + * + * @param userId - Unique identifier of the user + * @returns An object indicating the success or failure of the operation + */ +export async function cancelSubscription(userId: string): Promise { + if (!userId) { + return { success: false, error: 'User ID is required' }; + } + + try { + const subscription = await prisma.subscription.findUnique({ + where: { userId }, + }); + + if (!subscription) { + return { success: false, error: 'No active subscription found' }; + } + + if (subscription.stripeSubscriptionId) { + // Direct call to Stripe API with period end cancellation set to true + const updatedSub = await stripe.subscriptions.update(subscription.stripeSubscriptionId, { + cancel_at_period_end: true, + }); + + // Local database synchronization + await prisma.subscription.update({ + where: { userId }, + data: { + cancelAtPeriodEnd: true, + canceledAt: updatedSub.canceled_at ? new Date(updatedSub.canceled_at * 1000) : new Date(), + currentPeriodEnd: updatedSub.current_period_end ? new Date(updatedSub.current_period_end * 1000) : subscription.currentPeriodEnd, + updatedAt: new Date(), + }, + }); + } else { + // Mock mode cancel (e.g. for development / local bypass testing) + await prisma.subscription.update({ + where: { userId }, + data: { + cancelAtPeriodEnd: true, + canceledAt: new Date(), + updatedAt: new Date(), + }, + }); + } + + return { success: true }; + } catch (error: any) { + console.error('[cancelSubscription] Error processing cancellation:', error); + return { + success: false, + error: error.message || 'Failed to cancel subscription', + }; + } +} diff --git a/memento-note/lib/billing/stripe-prices.ts b/memento-note/lib/billing/stripe-prices.ts index 64ba9c2..d2ce703 100644 --- a/memento-note/lib/billing/stripe-prices.ts +++ b/memento-note/lib/billing/stripe-prices.ts @@ -16,12 +16,22 @@ export function resolvePriceId(tier: BillingTier, interval: BillingInterval): st }; const priceId = map[tier][interval]; if (!priceId) { + const isMock = !process.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder'; + if (isMock && process.env.NODE_ENV !== 'test') { + return `price_mock_${tier.toLowerCase()}_${interval}`; + } throw new Error(`No Stripe price ID configured for ${tier}/${interval}`); } return priceId; } export function priceIdToTier(priceId: string): SubscriptionTier | null { + if (priceId && priceId.startsWith('price_mock_')) { + if (priceId.includes('pro')) return 'PRO'; + if (priceId.includes('business')) return 'BUSINESS'; + return 'BASIC'; + } + const entries: Array<[string | undefined, SubscriptionTier]> = [ [process.env.STRIPE_PRICE_PRO_MONTHLY, 'PRO'], [process.env.STRIPE_PRICE_PRO_ANNUAL, 'PRO'], diff --git a/memento-note/lib/billing/sync-subscription-from-stripe.ts b/memento-note/lib/billing/sync-subscription-from-stripe.ts index 90ab5db..ff63445 100644 --- a/memento-note/lib/billing/sync-subscription-from-stripe.ts +++ b/memento-note/lib/billing/sync-subscription-from-stripe.ts @@ -44,8 +44,19 @@ export async function syncSubscriptionFromStripe( const status = mapStripeStatus(subscription.status); - const currentPeriodStart = new Date(((subscription as any).current_period_start as number) * 1000); - const currentPeriodEnd = new Date(((subscription as any).current_period_end as number) * 1000); + const currentPeriodStartTimestamp = + subscription.current_period_start ?? + (subscription as any).items?.data?.[0]?.current_period_start ?? + (subscription as any).start_date ?? + Math.floor(Date.now() / 1000); + + const currentPeriodEndTimestamp = + subscription.current_period_end ?? + (subscription as any).items?.data?.[0]?.current_period_end ?? + (currentPeriodStartTimestamp + 30 * 24 * 3600); + + const currentPeriodStart = new Date(currentPeriodStartTimestamp * 1000); + const currentPeriodEnd = new Date(currentPeriodEndTimestamp * 1000); await prisma.subscription.upsert({ where: { stripeSubscriptionId: subscription.id }, diff --git a/memento-note/locales/en.json b/memento-note/locales/en.json index 282f718..aceb1b3 100644 --- a/memento-note/locales/en.json +++ b/memento-note/locales/en.json @@ -3355,7 +3355,7 @@ "turnInto_taskList": "Task List", "turnInto_blockquote": "Quote", "turnInto_codeBlock": "Code Block", - "turnInto_database": "Database", + "turnInto_database": "Inline database", "copyRef": "Copy block reference", "copied": "Reference copied!", "copyRefFailed": "Could not copy block reference", @@ -3414,6 +3414,52 @@ "analyticsNoData": "No analysis data available.", "analyticsCompletion": "Completion Rate", "analyticsDistribution": "Distribution", - "analyticsTotalRows": "Total Rows" + "analyticsTotalRows": "Total Rows", + "analyticsShort": "Analytics", + "turnIntoLabel": "Inline database", + "columnAdded": "Column added!", + "columnRemoved": "Column removed", + "propertyName": "Property {{index}}", + "convertNotebookNameRequired": "Please enter a name for the notebook.", + "convertNotebookError": "Could not create the notebook.", + "convertSchemaError": "Could not enable structured view.", + "convertPropertyError": "Could not add property.", + "convertNoteError": "Could not create note.", + "convertSuccess": "Conversion complete! Linked notebook created.", + "convertGenericError": "Something went wrong.", + "echoNameRequired": "Enter a name for this row first to search for semantic connections.", + "echoSearchError": "An error occurred while searching.", + "echoNoMatch": "No matching notes containing \"{{query}}\" were found in your workspace.", + "convertToNotebook": "Convert to notebook", + "linkToNotebook": "Link to a notebook", + "convertModalTitle": "Convert to structured notebook", + "convertModalDesc": "This local table will become a real notebook. Each row becomes a note and your columns become reusable properties.", + "convertNotebookNameLabel": "New notebook name", + "convertNotebookNamePlaceholder": "e.g. My reading list, Project tracker", + "cancel": "Cancel", + "converting": "Converting…", + "createNotebook": "Create notebook", + "deleteColumn": "Delete column", + "addColumn": "Add column", + "deleteRow": "Delete row", + "semanticEcho": "Semantic resonances", + "close": "Close", + "insertCitation": "Insert link in editor", + "keywordMatch": "Keyword", + "emptyTable": "No rows in the table.", + "addRow": "Add row", + "colTypeText": "Text", + "colTypeCheckbox": "Checkbox", + "colTypeSelect": "Select", + "selectOptionsPlaceholder": "Options separated by commas", + "namePlaceholder": "Enter a name…", + "or": "or", + "createLocalDb": "Create a standalone local database", + "switchToLocalDb": "Switch to local database", + "untitled": "Untitled", + "citationInserted": "Link inserted in the editor!", + "notesLoadError": "Error loading notes", + "defaultOption1": "Option 1", + "defaultOption2": "Option 2" } } \ No newline at end of file diff --git a/memento-note/locales/fr.json b/memento-note/locales/fr.json index b6b4cd0..86efca5 100644 --- a/memento-note/locales/fr.json +++ b/memento-note/locales/fr.json @@ -3359,7 +3359,7 @@ "turnInto_taskList": "Liste de tâches", "turnInto_blockquote": "Citation", "turnInto_codeBlock": "Bloc de code", - "turnInto_database": "Base de données", + "turnInto_database": "Base de données inline", "copyRef": "Copier la référence du bloc", "copied": "Référence copiée !", "copyRefFailed": "Impossible de copier la référence du bloc", @@ -3418,6 +3418,52 @@ "analyticsNoData": "Aucune donnée d'analyse disponible.", "analyticsCompletion": "Taux de complétion", "analyticsDistribution": "Répartition", - "analyticsTotalRows": "Total des lignes" + "analyticsTotalRows": "Total des lignes", + "analyticsShort": "Analyses", + "turnIntoLabel": "Base de données inline", + "columnAdded": "Colonne ajoutée !", + "columnRemoved": "Colonne supprimée", + "propertyName": "Propriété {{index}}", + "convertNotebookNameRequired": "Veuillez entrer un nom pour le carnet.", + "convertNotebookError": "Erreur lors de la création du carnet.", + "convertSchemaError": "Erreur lors de l'activation de la structure.", + "convertPropertyError": "Erreur d'ajout de propriété.", + "convertNoteError": "Erreur de création de note.", + "convertSuccess": "Conversion réussie ! Base liée créée.", + "convertGenericError": "Une erreur est survenue.", + "echoNameRequired": "Veuillez d'abord saisir un nom pour cette ligne afin de rechercher des résonances sémantiques.", + "echoSearchError": "Une erreur est survenue lors de la recherche.", + "echoNoMatch": "Aucune note correspondante contenant « {{query}} » n'a été trouvée dans votre espace de travail.", + "convertToNotebook": "Convertir en carnet", + "linkToNotebook": "Lier à un carnet", + "convertModalTitle": "Convertir en carnet structuré", + "convertModalDesc": "Ce tableau local va être converti en carnet réel. Chaque ligne deviendra une note et vos colonnes seront configurées comme propriétés réutilisables.", + "convertNotebookNameLabel": "Nom du nouveau carnet", + "convertNotebookNamePlaceholder": "ex. Mes lectures, Suivi de projets", + "cancel": "Annuler", + "converting": "Conversion…", + "createNotebook": "Créer le carnet", + "deleteColumn": "Supprimer la colonne", + "addColumn": "Ajouter une colonne", + "deleteRow": "Supprimer la ligne", + "semanticEcho": "Résonances sémantiques", + "close": "Fermer", + "insertCitation": "Insérer le lien dans l'éditeur", + "keywordMatch": "Mot-clé", + "emptyTable": "Aucune ligne dans le tableau.", + "addRow": "Ajouter une ligne", + "colTypeText": "Texte", + "colTypeCheckbox": "Case", + "colTypeSelect": "Liste", + "selectOptionsPlaceholder": "Options séparées par des virgules", + "namePlaceholder": "Saisir un nom…", + "or": "ou", + "createLocalDb": "Créer une base locale autonome", + "switchToLocalDb": "Passer en base locale", + "untitled": "Sans titre", + "citationInserted": "Citation insérée dans l'éditeur !", + "notesLoadError": "Erreur de chargement des notes", + "defaultOption1": "Option 1", + "defaultOption2": "Option 2" } } \ No newline at end of file diff --git a/memento-note/tests/unit/billing-cancel.test.ts b/memento-note/tests/unit/billing-cancel.test.ts new file mode 100644 index 0000000..b18b919 --- /dev/null +++ b/memento-note/tests/unit/billing-cancel.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock the dependencies before importing the function +vi.mock('@/lib/prisma', () => ({ + prisma: { + subscription: { + findUnique: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock('@/lib/stripe', () => ({ + stripe: { + subscriptions: { + update: vi.fn(), + }, + }, +})); + +import { cancelSubscription } from '@/lib/billing/cancel-subscription'; +import { prisma } from '@/lib/prisma'; +import { stripe } from '@/lib/stripe'; + +describe('cancelSubscription', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return error when userId is missing', async () => { + const result = await cancelSubscription(''); + expect(result).toEqual({ success: false, error: 'User ID is required' }); + expect(prisma.subscription.findUnique).not.toHaveBeenCalled(); + }); + + it('should return error when no subscription is found in db', async () => { + vi.mocked(prisma.subscription.findUnique).mockResolvedValue(null); + + const result = await cancelSubscription('user_123'); + expect(result).toEqual({ success: false, error: 'No active subscription found' }); + expect(prisma.subscription.findUnique).toHaveBeenCalledWith({ + where: { userId: 'user_123' }, + }); + expect(stripe.subscriptions.update).not.toHaveBeenCalled(); + expect(prisma.subscription.update).not.toHaveBeenCalled(); + }); + + it('should cancel subscription in Stripe and update DB successfully', async () => { + const mockDbSub = { + userId: 'user_123', + stripeSubscriptionId: 'sub_stripe_123', + currentPeriodEnd: new Date(1702678400 * 1000), + }; + const mockStripeSub = { + id: 'sub_stripe_123', + canceled_at: 1700100000, + current_period_end: 1702678400, + }; + + vi.mocked(prisma.subscription.findUnique).mockResolvedValue(mockDbSub as any); + vi.mocked(stripe.subscriptions.update).mockResolvedValue(mockStripeSub as any); + vi.mocked(prisma.subscription.update).mockResolvedValue({} as any); + + const result = await cancelSubscription('user_123'); + + expect(result).toEqual({ success: true }); + expect(stripe.subscriptions.update).toHaveBeenCalledWith('sub_stripe_123', { + cancel_at_period_end: true, + }); + expect(prisma.subscription.update).toHaveBeenCalledWith({ + where: { userId: 'user_123' }, + data: expect.objectContaining({ + cancelAtPeriodEnd: true, + canceledAt: expect.any(Date), + currentPeriodEnd: expect.any(Date), + }), + }); + }); + + it('should support local / mock mode cancel when stripeSubscriptionId is missing', async () => { + const mockDbSub = { + userId: 'user_123', + stripeSubscriptionId: null, + }; + + vi.mocked(prisma.subscription.findUnique).mockResolvedValue(mockDbSub as any); + vi.mocked(prisma.subscription.update).mockResolvedValue({} as any); + + const result = await cancelSubscription('user_123'); + + expect(result).toEqual({ success: true }); + expect(stripe.subscriptions.update).not.toHaveBeenCalled(); + expect(prisma.subscription.update).toHaveBeenCalledWith({ + where: { userId: 'user_123' }, + data: expect.objectContaining({ + cancelAtPeriodEnd: true, + canceledAt: expect.any(Date), + }), + }); + }); + + it('should return error when Stripe API call fails', async () => { + const mockDbSub = { + userId: 'user_123', + stripeSubscriptionId: 'sub_stripe_123', + }; + + vi.mocked(prisma.subscription.findUnique).mockResolvedValue(mockDbSub as any); + vi.mocked(stripe.subscriptions.update).mockRejectedValue(new Error('Stripe connection error')); + + const result = await cancelSubscription('user_123'); + + expect(result).toEqual({ success: false, error: 'Stripe connection error' }); + expect(prisma.subscription.update).not.toHaveBeenCalled(); + }); + + it('should return error when database write fails', async () => { + const mockDbSub = { + userId: 'user_123', + stripeSubscriptionId: 'sub_stripe_123', + }; + const mockStripeSub = { + id: 'sub_stripe_123', + canceled_at: 1700100000, + current_period_end: 1702678400, + }; + + vi.mocked(prisma.subscription.findUnique).mockResolvedValue(mockDbSub as any); + vi.mocked(stripe.subscriptions.update).mockResolvedValue(mockStripeSub as any); + vi.mocked(prisma.subscription.update).mockRejectedValue(new Error('Prisma database error')); + + const result = await cancelSubscription('user_123'); + + expect(result).toEqual({ success: false, error: 'Prisma database error' }); + }); +});