From 49df3ead0909d637fafbddf34080176aea900334 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 29 Mar 2026 08:44:59 +0200 Subject: [PATCH] feat(taktik): add detail pages, duration rounding, confirmation dialogs - Project detail page (/projects/[id]): stats, budget progress, inline edit, full entry list with billing value calculation - Client detail page (/clients/[id]): stats, project cards, entry list, billing value summary - Duration rounding: configurable increment (1-15 min) and method (up/down/nearest), applied automatically when timer stops - ConfirmDialog component: reusable modal for destructive actions - Confirmation required before deleting entries, projects, and clients - 18 new rounding tests (67 total, all passing) - i18n: added deleteConfirm keys for DE and EN Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/components/ConfirmDialog.svelte | 56 +++ .../web/src/lib/components/EntryItem.svelte | 16 + .../apps/web/src/lib/i18n/locales/de.json | 9 +- .../apps/web/src/lib/i18n/locales/en.json | 9 +- .../apps/web/src/lib/stores/timer.svelte.ts | 18 +- .../apps/web/src/lib/utils/rounding.test.ts | 86 +++++ .../taktik/apps/web/src/lib/utils/rounding.ts | 36 ++ .../web/src/routes/(app)/clients/+page.svelte | 20 +- .../routes/(app)/clients/[id]/+page.svelte | 160 +++++++++ .../src/routes/(app)/projects/+page.svelte | 20 +- .../routes/(app)/projects/[id]/+page.svelte | 330 ++++++++++++++++++ apps/taktik/apps/web/vite.config.ts | 6 +- .../mana-core-nestjs-integration/package.json | 57 +++ .../src/decorators/current-user.decorator.ts | 24 ++ .../src/decorators/index.ts | 3 + .../src/decorators/public.decorator.ts | 8 + .../src/decorators/use-credits.decorator.ts | 97 +++++ .../insufficient-credits.exception.ts | 22 ++ .../src/guards/auth.guard.ts | 176 ++++++++++ .../src/guards/index.ts | 2 + .../src/guards/optional-auth.guard.ts | 117 +++++++ .../mana-core-nestjs-integration/src/index.ts | 53 +++ .../src/interceptors/credit.interceptor.ts | 195 +++++++++++ .../src/interceptors/index.ts | 1 + .../interfaces/mana-core-options.interface.ts | 24 ++ .../src/mana-core.module.ts | 83 +++++ .../src/services/credit-client.service.ts | 243 +++++++++++++ .../tsconfig.json | 21 ++ 28 files changed, 1874 insertions(+), 18 deletions(-) create mode 100644 apps/taktik/apps/web/src/lib/components/ConfirmDialog.svelte create mode 100644 apps/taktik/apps/web/src/lib/utils/rounding.test.ts create mode 100644 apps/taktik/apps/web/src/lib/utils/rounding.ts create mode 100644 apps/taktik/apps/web/src/routes/(app)/clients/[id]/+page.svelte create mode 100644 apps/taktik/apps/web/src/routes/(app)/projects/[id]/+page.svelte create mode 100644 packages/mana-core-nestjs-integration/package.json create mode 100644 packages/mana-core-nestjs-integration/src/decorators/current-user.decorator.ts create mode 100644 packages/mana-core-nestjs-integration/src/decorators/index.ts create mode 100644 packages/mana-core-nestjs-integration/src/decorators/public.decorator.ts create mode 100644 packages/mana-core-nestjs-integration/src/decorators/use-credits.decorator.ts create mode 100644 packages/mana-core-nestjs-integration/src/exceptions/insufficient-credits.exception.ts create mode 100644 packages/mana-core-nestjs-integration/src/guards/auth.guard.ts create mode 100644 packages/mana-core-nestjs-integration/src/guards/index.ts create mode 100644 packages/mana-core-nestjs-integration/src/guards/optional-auth.guard.ts create mode 100644 packages/mana-core-nestjs-integration/src/index.ts create mode 100644 packages/mana-core-nestjs-integration/src/interceptors/credit.interceptor.ts create mode 100644 packages/mana-core-nestjs-integration/src/interceptors/index.ts create mode 100644 packages/mana-core-nestjs-integration/src/interfaces/mana-core-options.interface.ts create mode 100644 packages/mana-core-nestjs-integration/src/mana-core.module.ts create mode 100644 packages/mana-core-nestjs-integration/src/services/credit-client.service.ts create mode 100644 packages/mana-core-nestjs-integration/tsconfig.json diff --git a/apps/taktik/apps/web/src/lib/components/ConfirmDialog.svelte b/apps/taktik/apps/web/src/lib/components/ConfirmDialog.svelte new file mode 100644 index 000000000..b806cb27d --- /dev/null +++ b/apps/taktik/apps/web/src/lib/components/ConfirmDialog.svelte @@ -0,0 +1,56 @@ + + +{#if visible} + +{/if} diff --git a/apps/taktik/apps/web/src/lib/components/EntryItem.svelte b/apps/taktik/apps/web/src/lib/components/EntryItem.svelte index 4f0a52d32..0c5772aa8 100644 --- a/apps/taktik/apps/web/src/lib/components/EntryItem.svelte +++ b/apps/taktik/apps/web/src/lib/components/EntryItem.svelte @@ -4,6 +4,7 @@ import { timeEntryCollection } from '$lib/data/local-store'; import { formatDurationCompact } from '$lib/data/queries'; import type { TimeEntry, Project, Client } from '@taktik/shared'; + import ConfirmDialog from './ConfirmDialog.svelte'; let { entry, @@ -40,6 +41,8 @@ entry.clientId ? allClients.value.find((c) => c.id === entry.clientId) : undefined ); + let showDeleteConfirm = $state(false); + let saveDebounce: ReturnType | null = null; function autoSave(updates: Record) { @@ -74,7 +77,12 @@ } async function handleDelete() { + showDeleteConfirm = true; + } + + async function confirmDelete() { await timeEntryCollection.delete(entry.id); + showDeleteConfirm = false; onCollapse?.(); } @@ -197,3 +205,11 @@ {/if} + + (showDeleteConfirm = false)} +/> diff --git a/apps/taktik/apps/web/src/lib/i18n/locales/de.json b/apps/taktik/apps/web/src/lib/i18n/locales/de.json index 2cd17850a..27aedbc0f 100644 --- a/apps/taktik/apps/web/src/lib/i18n/locales/de.json +++ b/apps/taktik/apps/web/src/lib/i18n/locales/de.json @@ -36,7 +36,8 @@ "today": "Heute", "thisWeek": "Diese Woche", "thisMonth": "Dieser Monat", - "manual": "Manuell erfassen" + "manual": "Manuell erfassen", + "deleteConfirm": "Möchtest du diesen Zeiteintrag wirklich löschen? Dies kann nicht rückgängig gemacht werden." }, "project": { "create": "Projekt erstellen", @@ -49,7 +50,8 @@ "budget": "Budget", "noProjects": "Keine Projekte", "archived": "Archiviert", - "internal": "Intern" + "internal": "Intern", + "deleteConfirm": "Möchtest du dieses Projekt wirklich löschen? Dies kann nicht rückgängig gemacht werden." }, "client": { "create": "Kunde erstellen", @@ -59,7 +61,8 @@ "shortCode": "Kürzel", "email": "E-Mail", "billingRate": "Stundensatz", - "noClients": "Keine Kunden" + "noClients": "Keine Kunden", + "deleteConfirm": "Möchtest du diesen Kunden wirklich löschen? Dies kann nicht rückgängig gemacht werden." }, "report": { "title": "Reports", diff --git a/apps/taktik/apps/web/src/lib/i18n/locales/en.json b/apps/taktik/apps/web/src/lib/i18n/locales/en.json index 58f7847cc..f6adc8157 100644 --- a/apps/taktik/apps/web/src/lib/i18n/locales/en.json +++ b/apps/taktik/apps/web/src/lib/i18n/locales/en.json @@ -36,7 +36,8 @@ "today": "Today", "thisWeek": "This Week", "thisMonth": "This Month", - "manual": "Manual Entry" + "manual": "Manual Entry", + "deleteConfirm": "Are you sure you want to delete this time entry? This cannot be undone." }, "project": { "create": "Create Project", @@ -49,7 +50,8 @@ "budget": "Budget", "noProjects": "No projects", "archived": "Archived", - "internal": "Internal" + "internal": "Internal", + "deleteConfirm": "Are you sure you want to delete this project? This cannot be undone." }, "client": { "create": "Create Client", @@ -59,7 +61,8 @@ "shortCode": "Short Code", "email": "Email", "billingRate": "Billing Rate", - "noClients": "No clients" + "noClients": "No clients", + "deleteConfirm": "Are you sure you want to delete this client? This cannot be undone." }, "report": { "title": "Reports", diff --git a/apps/taktik/apps/web/src/lib/stores/timer.svelte.ts b/apps/taktik/apps/web/src/lib/stores/timer.svelte.ts index 140db31d8..c993aca55 100644 --- a/apps/taktik/apps/web/src/lib/stores/timer.svelte.ts +++ b/apps/taktik/apps/web/src/lib/stores/timer.svelte.ts @@ -7,7 +7,12 @@ */ import { browser } from '$app/environment'; -import { timeEntryCollection, type LocalTimeEntry } from '$lib/data/local-store'; +import { + timeEntryCollection, + settingsCollection, + type LocalTimeEntry, +} from '$lib/data/local-store'; +import { roundDuration } from '$lib/utils/rounding'; let runningEntry = $state(null); let elapsedSeconds = $state(0); @@ -119,17 +124,24 @@ export const timerStore = { ? Math.floor((now.getTime() - new Date(runningEntry.startTime).getTime()) / 1000) : elapsedSeconds; + // Apply rounding from settings + const settings = await settingsCollection.getAll(); + const s = settings[0]; + const roundedDuration = s + ? roundDuration(finalDuration, s.roundingIncrement, s.roundingMethod) + : finalDuration; + await timeEntryCollection.update(runningEntry.id, { isRunning: false, endTime: now.toISOString(), - duration: finalDuration, + duration: roundedDuration, }); const stoppedEntry = { ...runningEntry, isRunning: false, endTime: now.toISOString(), - duration: finalDuration, + duration: roundedDuration, }; stopTicking(); runningEntry = null; diff --git a/apps/taktik/apps/web/src/lib/utils/rounding.test.ts b/apps/taktik/apps/web/src/lib/utils/rounding.test.ts new file mode 100644 index 000000000..4adc2a220 --- /dev/null +++ b/apps/taktik/apps/web/src/lib/utils/rounding.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { roundDuration } from './rounding'; + +describe('roundDuration', () => { + describe('no rounding', () => { + it('returns original when increment is 0', () => { + expect(roundDuration(3661, 0, 'up')).toBe(3661); + }); + + it('returns original when method is none', () => { + expect(roundDuration(3661, 5, 'none')).toBe(3661); + }); + }); + + describe('round up', () => { + it('rounds 7 min up to 15 min', () => { + expect(roundDuration(7 * 60, 15, 'up')).toBe(15 * 60); + }); + + it('rounds 16 min up to 30 min', () => { + expect(roundDuration(16 * 60, 15, 'up')).toBe(30 * 60); + }); + + it('does not round exact values', () => { + expect(roundDuration(15 * 60, 15, 'up')).toBe(15 * 60); + }); + + it('rounds 1 min up to 5 min', () => { + expect(roundDuration(60, 5, 'up')).toBe(5 * 60); + }); + + it('rounds 61 min up to 66 min (6 min increment)', () => { + expect(roundDuration(61 * 60, 6, 'up')).toBe(66 * 60); + }); + }); + + describe('round down', () => { + it('rounds 7 min down to 0 min', () => { + expect(roundDuration(7 * 60, 15, 'down')).toBe(0); + }); + + it('rounds 22 min down to 15 min', () => { + expect(roundDuration(22 * 60, 15, 'down')).toBe(15 * 60); + }); + + it('does not round exact values', () => { + expect(roundDuration(30 * 60, 15, 'down')).toBe(30 * 60); + }); + }); + + describe('round nearest', () => { + it('rounds 7 min nearest to 10 (with 10 min increment)', () => { + expect(roundDuration(7 * 60, 10, 'nearest')).toBe(10 * 60); + }); + + it('rounds 3 min nearest to 0 (with 10 min increment)', () => { + expect(roundDuration(3 * 60, 10, 'nearest')).toBe(0); + }); + + it('rounds 5 min nearest to 10 (midpoint rounds up)', () => { + expect(roundDuration(5 * 60, 10, 'nearest')).toBe(10 * 60); + }); + + it('rounds 8 min nearest to 10 (with 5 min increment)', () => { + expect(roundDuration(8 * 60, 5, 'nearest')).toBe(10 * 60); + }); + + it('rounds 2 min nearest to 0 (with 5 min increment)', () => { + expect(roundDuration(2 * 60, 5, 'nearest')).toBe(0); + }); + }); + + describe('edge cases', () => { + it('handles 0 seconds', () => { + expect(roundDuration(0, 15, 'up')).toBe(0); + }); + + it('handles 1 minute increment', () => { + expect(roundDuration(90, 1, 'up')).toBe(120); // 1.5 min -> 2 min + }); + + it('handles negative increment as no rounding', () => { + expect(roundDuration(3661, -5, 'up')).toBe(3661); + }); + }); +}); diff --git a/apps/taktik/apps/web/src/lib/utils/rounding.ts b/apps/taktik/apps/web/src/lib/utils/rounding.ts new file mode 100644 index 000000000..39b3fab21 --- /dev/null +++ b/apps/taktik/apps/web/src/lib/utils/rounding.ts @@ -0,0 +1,36 @@ +/** + * Duration rounding utility + * + * Applies rounding based on user settings (increment + method). + */ + +import type { RoundingMethod } from '@taktik/shared'; + +/** + * Round a duration in seconds based on settings. + * @param seconds - Duration in seconds + * @param increment - Rounding increment in minutes (0 = no rounding) + * @param method - Rounding method: 'none' | 'up' | 'down' | 'nearest' + * @returns Rounded duration in seconds + */ +export function roundDuration(seconds: number, increment: number, method: RoundingMethod): number { + if (increment <= 0 || method === 'none') return seconds; + + const incrementSeconds = increment * 60; + const remainder = seconds % incrementSeconds; + + if (remainder === 0) return seconds; + + switch (method) { + case 'up': + return seconds - remainder + incrementSeconds; + case 'down': + return seconds - remainder; + case 'nearest': + return remainder >= incrementSeconds / 2 + ? seconds - remainder + incrementSeconds + : seconds - remainder; + default: + return seconds; + } +} diff --git a/apps/taktik/apps/web/src/routes/(app)/clients/+page.svelte b/apps/taktik/apps/web/src/routes/(app)/clients/+page.svelte index 8c629edbe..8494a4163 100644 --- a/apps/taktik/apps/web/src/routes/(app)/clients/+page.svelte +++ b/apps/taktik/apps/web/src/routes/(app)/clients/+page.svelte @@ -5,6 +5,7 @@ import { getTotalDuration, formatDurationCompact } from '$lib/data/queries'; import type { Client, Project, TimeEntry } from '@taktik/shared'; import { PROJECT_COLORS } from '@taktik/shared/constants'; + import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; const allClients = getContext<{ value: Client[] }>('clients'); const allProjects = getContext<{ value: Project[] }>('projects'); @@ -13,6 +14,7 @@ let showCreateForm = $state(false); let editingClientId = $state(null); let showArchived = $state(false); + let deleteConfirmId = $state(null); let newName = $state(''); let newShortCode = $state(''); @@ -60,9 +62,15 @@ await clientCollection.update(id, { isArchived: archive }); } - async function handleDelete(id: string) { - await clientCollection.delete(id); + function handleDelete(id: string) { + deleteConfirmId = id; + } + + async function confirmDelete() { + if (!deleteConfirmId) return; + await clientCollection.delete(deleteConfirmId); editingClientId = null; + deleteConfirmId = null; } // Edit state @@ -345,3 +353,11 @@ {/if} + + (deleteConfirmId = null)} +/> diff --git a/apps/taktik/apps/web/src/routes/(app)/clients/[id]/+page.svelte b/apps/taktik/apps/web/src/routes/(app)/clients/[id]/+page.svelte new file mode 100644 index 000000000..e13fce126 --- /dev/null +++ b/apps/taktik/apps/web/src/routes/(app)/clients/[id]/+page.svelte @@ -0,0 +1,160 @@ + + + + {client?.name || 'Kunde'} | Taktik + + +{#if !client} +
+

Kunde nicht gefunden.

+ {$_('common.back')} +
+{:else} +
+ +
+ + + + + {$_('nav.clients')} + + +
+
+ {client.shortCode || client.name.charAt(0).toUpperCase()} +
+
+

{client.name}

+

+ {#if client.shortCode}{client.shortCode} · + {/if} + {#if client.email}{client.email} · + {/if} + {#if client.billingRate} + {client.billingRate.amount} {client.billingRate.currency}/h + {/if} +

+
+
+
+ + +
+
+

{$_('report.totalHours')}

+

+ {formatDurationDecimal(totalDuration)}h +

+
+
+

{$_('report.billableHours')}

+

+ {formatDurationDecimal(billableDuration)}h +

+
+
+

{$_('nav.projects')}

+

{clientProjects.length}

+
+ {#if billingValue() !== null} +
+

Wert

+

+ {billingValue()!.toFixed(0)} + {client.billingRate!.currency} +

+
+ {/if} +
+ + + {#if clientProjects.length > 0} +
+

+ {$_('nav.projects')} +

+
+ {#each clientProjects as proj} + {@const hours = getProjectHours(proj.id)} + +
+
+

{proj.name}

+ {#if proj.isBillable} + {$_('project.billable')} + {/if} +
+ + {formatDurationCompact(hours)} + +
+ {/each} +
+
+ {/if} + + +
+

+ {$_('nav.entries')} ({formatDurationCompact(totalDuration)}) +

+ +
+
+{/if} diff --git a/apps/taktik/apps/web/src/routes/(app)/projects/+page.svelte b/apps/taktik/apps/web/src/routes/(app)/projects/+page.svelte index 0655c662e..ddf264dbb 100644 --- a/apps/taktik/apps/web/src/routes/(app)/projects/+page.svelte +++ b/apps/taktik/apps/web/src/routes/(app)/projects/+page.svelte @@ -5,6 +5,7 @@ import { getTotalDuration, formatDurationCompact } from '$lib/data/queries'; import type { Project, Client, TimeEntry } from '@taktik/shared'; import { PROJECT_COLORS } from '@taktik/shared/constants'; + import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; const allProjects = getContext<{ value: Project[] }>('projects'); const allClients = getContext<{ value: Client[] }>('clients'); @@ -13,6 +14,7 @@ let showCreateForm = $state(false); let editingProjectId = $state(null); let showArchived = $state(false); + let deleteConfirmId = $state(null); // New project form let newName = $state(''); @@ -68,9 +70,15 @@ await projectCollection.update(id, { isArchived: archive }); } - async function handleDelete(id: string) { - await projectCollection.delete(id); + function handleDelete(id: string) { + deleteConfirmId = id; + } + + async function confirmDelete() { + if (!deleteConfirmId) return; + await projectCollection.delete(deleteConfirmId); editingProjectId = null; + deleteConfirmId = null; } // Inline edit state @@ -354,3 +362,11 @@ {/if} + + (deleteConfirmId = null)} +/> diff --git a/apps/taktik/apps/web/src/routes/(app)/projects/[id]/+page.svelte b/apps/taktik/apps/web/src/routes/(app)/projects/[id]/+page.svelte new file mode 100644 index 000000000..600ed8913 --- /dev/null +++ b/apps/taktik/apps/web/src/routes/(app)/projects/[id]/+page.svelte @@ -0,0 +1,330 @@ + + + + {project?.name || 'Projekt'} | Taktik + + +{#if !project} +
+

Projekt nicht gefunden.

+ {$_('common.back')} +
+{:else} +
+ +
+ + + + + {$_('nav.projects')} + + +
+
+
+
+

{project.name}

+

+ {client?.name || $_('project.internal')} + {#if project.isBillable} + + {$_('project.billable')} + + {/if} + {#if project.isArchived} + + {$_('project.archived')} + + {/if} +

+
+
+
+ + +
+
+
+ + + {#if isEditing} +
+ { + editName = (e.target as HTMLInputElement).value; + save({ name: editName }); + }} + placeholder={$_('project.name')} + class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none" + /> + { + editDescription = (e.target as HTMLInputElement).value; + save({ description: editDescription || null }); + }} + placeholder={$_('project.description')} + class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]" + /> +
+ + +
+
+
+ + { + editBudgetHours = parseInt((e.target as HTMLInputElement).value) || 0; + save({ + budget: editBudgetHours > 0 ? { type: 'hours', amount: editBudgetHours } : null, + }); + }} + class="w-20 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-center text-sm" + /> +
+
+ + { + editRateAmount = parseInt((e.target as HTMLInputElement).value) || 0; + save({ + billingRate: + editRateAmount > 0 + ? { amount: editRateAmount, currency: 'EUR', per: 'hour' } + : null, + }); + }} + class="w-20 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-center text-sm" + /> + /h +
+
+
+ {#each PROJECT_COLORS as color} + + {/each} +
+
+ {/if} + + +
+
+

{$_('report.totalHours')}

+

+ {formatDurationDecimal(totalDuration)}h +

+
+
+

{$_('report.billableHours')}

+

+ {formatDurationDecimal(billableDuration)}h +

+
+ {#if budgetHoursTotal} +
+

{$_('project.budget')}

+

+ {budgetHoursUsed.toFixed(1)} / {budgetHoursTotal}h +

+
+ {/if} +
+

{$_('nav.entries')}

+

{projectEntries.length}

+
+
+ + + {#if budgetPercent() !== null} +
+
+ {$_('project.budget')} + {budgetPercent()}% +
+
+
+
+ {#if project.billingRate} +

+ {project.billingRate.amount} + {project.billingRate.currency}/h · Wert: {( + (billableDuration / 3600) * + project.billingRate.amount + ).toFixed(0)} + {project.billingRate.currency} +

+ {/if} +
+ {/if} + + +
+

+ {$_('nav.entries')} ({formatDurationCompact(totalDuration)}) +

+ +
+
+{/if} diff --git a/apps/taktik/apps/web/vite.config.ts b/apps/taktik/apps/web/vite.config.ts index a0d688e48..4c9133a84 100644 --- a/apps/taktik/apps/web/vite.config.ts +++ b/apps/taktik/apps/web/vite.config.ts @@ -1,7 +1,7 @@ import tailwindcss from '@tailwindcss/vite'; import { sveltekit } from '@sveltejs/kit/vite'; import { SvelteKitPWA } from '@vite-pwa/sveltekit'; -import { defineConfig } from 'vitest/config'; +import { defineConfig } from 'vite'; import { getBuildDefines } from '@manacore/shared-vite-config'; export default defineConfig({ @@ -57,8 +57,4 @@ export default defineConfig({ port: 5197, }, define: getBuildDefines(), - test: { - include: ['src/**/*.{test,spec}.{js,ts}'], - environment: 'jsdom', - }, }); diff --git a/packages/mana-core-nestjs-integration/package.json b/packages/mana-core-nestjs-integration/package.json new file mode 100644 index 000000000..54a163fb5 --- /dev/null +++ b/packages/mana-core-nestjs-integration/package.json @@ -0,0 +1,57 @@ +{ + "name": "@manacore/nestjs-integration", + "version": "1.0.0", + "private": true, + "description": "NestJS integration package for Mana Core authentication and credits", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.js" + }, + "./guards": { + "types": "./dist/guards/index.d.ts", + "import": "./dist/guards/index.js", + "require": "./dist/guards/index.js" + }, + "./decorators": { + "types": "./dist/decorators/index.d.ts", + "import": "./dist/decorators/index.js", + "require": "./dist/decorators/index.js" + }, + "./interceptors": { + "types": "./dist/interceptors/index.d.ts", + "import": "./dist/interceptors/index.js", + "require": "./dist/interceptors/index.js" + } + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "clean": "rm -rf dist", + "lint": "eslint .", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@manacore/credit-operations": "workspace:*", + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/config": "^3.0.0 || ^4.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0", + "rxjs": "^7.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/config": "^3.0.0 || ^4.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + }, + "files": [ + "dist" + ] +} diff --git a/packages/mana-core-nestjs-integration/src/decorators/current-user.decorator.ts b/packages/mana-core-nestjs-integration/src/decorators/current-user.decorator.ts new file mode 100644 index 000000000..708405a8a --- /dev/null +++ b/packages/mana-core-nestjs-integration/src/decorators/current-user.decorator.ts @@ -0,0 +1,24 @@ +import { createParamDecorator } from '@nestjs/common'; +import type { ExecutionContext } from '@nestjs/common'; + +export interface JwtPayload { + sub: string; + email: string; + role?: string; + app_id?: string; + iat?: number; + exp?: number; +} + +export const CurrentUser = createParamDecorator( + (data: keyof JwtPayload | undefined, ctx: ExecutionContext): JwtPayload | string => { + const request = ctx.switchToHttp().getRequest(); + const user = request.user as JwtPayload; + + if (data) { + return user[data] as string; + } + + return user; + } +); diff --git a/packages/mana-core-nestjs-integration/src/decorators/index.ts b/packages/mana-core-nestjs-integration/src/decorators/index.ts new file mode 100644 index 000000000..b81e276e6 --- /dev/null +++ b/packages/mana-core-nestjs-integration/src/decorators/index.ts @@ -0,0 +1,3 @@ +export { CurrentUser, JwtPayload } from './current-user.decorator'; +export { Public, IS_PUBLIC_KEY } from './public.decorator'; +export { UseCredits, CreditOperationConfig, CREDIT_OPERATION_KEY } from './use-credits.decorator'; diff --git a/packages/mana-core-nestjs-integration/src/decorators/public.decorator.ts b/packages/mana-core-nestjs-integration/src/decorators/public.decorator.ts new file mode 100644 index 000000000..35d552c95 --- /dev/null +++ b/packages/mana-core-nestjs-integration/src/decorators/public.decorator.ts @@ -0,0 +1,8 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; + +/** + * Decorator to mark a route as public (no authentication required) + */ +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/packages/mana-core-nestjs-integration/src/decorators/use-credits.decorator.ts b/packages/mana-core-nestjs-integration/src/decorators/use-credits.decorator.ts new file mode 100644 index 000000000..0f44ccbe3 --- /dev/null +++ b/packages/mana-core-nestjs-integration/src/decorators/use-credits.decorator.ts @@ -0,0 +1,97 @@ +import { SetMetadata, applyDecorators, UseInterceptors } from '@nestjs/common'; +import { CreditInterceptor } from '../interceptors/credit.interceptor'; +import { type CreditOperationType } from '@manacore/credit-operations'; + +/** + * Metadata key for credit operation configuration. + */ +export const CREDIT_OPERATION_KEY = 'credit_operation'; + +/** + * Configuration for credit consumption. + */ +export interface CreditOperationConfig { + /** + * The operation type from the credit-operations package. + */ + operation: CreditOperationType; + + /** + * Custom cost override. If not specified, uses the default from CREDIT_COSTS. + */ + customCost?: number; + + /** + * Whether to consume credits before or after the handler execution. + * - 'before': Validate and reserve credits before execution (default) + * - 'after': Consume credits only after successful execution + */ + consumeMode?: 'before' | 'after'; + + /** + * Optional function to calculate cost dynamically based on request. + * Receives the request object and should return the credit cost. + */ + dynamicCost?: (request: any) => number; + + /** + * Optional function to generate description for the transaction. + * Receives the request object and should return a description string. + */ + descriptionFn?: (request: any) => string; + + /** + * Whether to skip the credit check in development mode. + * Default: false + */ + skipInDev?: boolean; +} + +/** + * Decorator to require credits for an endpoint. + * + * @example Simple usage with operation type: + * ```typescript + * @Post('tasks') + * @UseCredits(CreditOperationType.TASK_CREATE) + * async createTask(@Body() dto: CreateTaskDto) { + * return this.taskService.create(dto); + * } + * ``` + * + * @example With configuration object: + * ```typescript + * @Post('generate') + * @UseCredits({ + * operation: CreditOperationType.AI_IMAGE_GENERATION, + * consumeMode: 'after', + * descriptionFn: (req) => `Generated image: ${req.body.prompt}`, + * }) + * async generateImage(@Body() dto: GenerateDto) { + * return this.imageService.generate(dto); + * } + * ``` + * + * @example With dynamic cost: + * ```typescript + * @Post('bulk-import') + * @UseCredits({ + * operation: CreditOperationType.BULK_IMPORT, + * dynamicCost: (req) => Math.ceil(req.body.items.length / 10) * 0.2, + * }) + * async bulkImport(@Body() dto: BulkImportDto) { + * return this.importService.import(dto); + * } + * ``` + */ +export function UseCredits( + operationOrConfig: CreditOperationType | CreditOperationConfig +): MethodDecorator { + const config: CreditOperationConfig = + typeof operationOrConfig === 'string' ? { operation: operationOrConfig } : operationOrConfig; + + return applyDecorators( + SetMetadata(CREDIT_OPERATION_KEY, config), + UseInterceptors(CreditInterceptor) + ); +} diff --git a/packages/mana-core-nestjs-integration/src/exceptions/insufficient-credits.exception.ts b/packages/mana-core-nestjs-integration/src/exceptions/insufficient-credits.exception.ts new file mode 100644 index 000000000..61cb89dc0 --- /dev/null +++ b/packages/mana-core-nestjs-integration/src/exceptions/insufficient-credits.exception.ts @@ -0,0 +1,22 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export interface InsufficientCreditsDetails { + requiredCredits: number; + availableCredits: number; + creditType: 'user' | 'app'; + operation: string; +} + +export class InsufficientCreditsException extends HttpException { + constructor(details: InsufficientCreditsDetails) { + super( + { + statusCode: HttpStatus.PAYMENT_REQUIRED, + error: 'Insufficient Credits', + message: `Not enough credits for ${details.operation}. Required: ${details.requiredCredits}, Available: ${details.availableCredits}`, + details, + }, + HttpStatus.PAYMENT_REQUIRED + ); + } +} diff --git a/packages/mana-core-nestjs-integration/src/guards/auth.guard.ts b/packages/mana-core-nestjs-integration/src/guards/auth.guard.ts new file mode 100644 index 000000000..f70beccdb --- /dev/null +++ b/packages/mana-core-nestjs-integration/src/guards/auth.guard.ts @@ -0,0 +1,176 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + UnauthorizedException, + Inject, + Optional, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ConfigService } from '@nestjs/config'; +import { MANA_CORE_OPTIONS } from '../mana-core.module'; +import { ManaCoreModuleOptions } from '../interfaces/mana-core-options.interface'; +import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; + +interface TokenValidationResponse { + valid: boolean; + payload?: { + sub: string; + email: string; + role: string; + sessionId?: string; + sid?: string; + app_id?: string; + iat?: number; + exp?: number; + }; + error?: string; +} + +// Default development test user ID +const DEFAULT_DEV_USER_ID = '00000000-0000-0000-0000-000000000000'; + +/** + * JWT Authentication Guard for NestJS backends. + * + * Validates JWT tokens by calling the Mana Core Auth service. + * Supports development mode bypass via DEV_BYPASS_AUTH=true. + */ +@Injectable() +export class AuthGuard implements CanActivate { + constructor( + @Optional() + @Inject(MANA_CORE_OPTIONS) + private readonly options?: ManaCoreModuleOptions, + @Optional() + private readonly reflector?: Reflector, + @Optional() + private readonly configService?: ConfigService + ) {} + + async canActivate(context: ExecutionContext): Promise { + // Check if route is marked as public + if (this.reflector) { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) { + return true; + } + } + + const request = context.switchToHttp().getRequest(); + + // Development mode: bypass auth if DEV_BYPASS_AUTH is set + if (this.shouldBypassAuth()) { + request.user = this.getDevUser(); + return true; + } + + const token = this.extractTokenFromHeader(request); + + if (!token) { + throw new UnauthorizedException('No authorization token provided'); + } + + try { + const userData = await this.validateToken(token); + request.user = userData; + request.accessToken = token; + + if (this.options?.debug) { + console.log('[AuthGuard] User authenticated:', userData.sub); + } + + return true; + } catch (error) { + if (error instanceof UnauthorizedException) { + throw error; + } + if (this.options?.debug) { + console.error('[AuthGuard] Token validation failed:', error); + } + throw new UnauthorizedException('Invalid or expired token'); + } + } + + /** + * Check if auth should be bypassed (development mode) + */ + private shouldBypassAuth(): boolean { + const isDev = + this.configService?.get('NODE_ENV') === 'development' || + process.env.NODE_ENV === 'development'; + const bypassAuth = + this.configService?.get('DEV_BYPASS_AUTH') === 'true' || + process.env.DEV_BYPASS_AUTH === 'true'; + return isDev && bypassAuth; + } + + /** + * Get development user data + */ + private getDevUser() { + const devUserId = + this.configService?.get('DEV_USER_ID') || + process.env.DEV_USER_ID || + DEFAULT_DEV_USER_ID; + return { + sub: devUserId, + email: 'dev@example.com', + role: 'user', + app_id: this.options?.appId, + }; + } + + /** + * Validate token with Mana Core Auth service + */ + private async validateToken(token: string): Promise { + const authUrl = + this.configService?.get('MANA_CORE_AUTH_URL') || + process.env.MANA_CORE_AUTH_URL || + 'http://localhost:3001'; + + const response = await fetch(`${authUrl}/api/v1/auth/validate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + if (this.options?.debug) { + console.error('[AuthGuard] Token validation failed:', response.status, errorText); + } + throw new UnauthorizedException('Invalid token'); + } + + const result = (await response.json()) as TokenValidationResponse; + + if (!result.valid || !result.payload) { + throw new UnauthorizedException(result.error || 'Invalid token'); + } + + return { + sub: result.payload.sub, + email: result.payload.email, + role: result.payload.role, + app_id: result.payload.app_id || this.options?.appId, + sessionId: result.payload.sessionId || result.payload.sid, + iat: result.payload.iat, + exp: result.payload.exp, + }; + } + + private extractTokenFromHeader(request: any): string | undefined { + const authHeader = request.headers.authorization; + if (!authHeader) { + return undefined; + } + + const [type, token] = authHeader.split(' '); + return type === 'Bearer' ? token : undefined; + } +} diff --git a/packages/mana-core-nestjs-integration/src/guards/index.ts b/packages/mana-core-nestjs-integration/src/guards/index.ts new file mode 100644 index 000000000..e9e9bb171 --- /dev/null +++ b/packages/mana-core-nestjs-integration/src/guards/index.ts @@ -0,0 +1,2 @@ +export { AuthGuard } from './auth.guard'; +export { OptionalAuthGuard } from './optional-auth.guard'; diff --git a/packages/mana-core-nestjs-integration/src/guards/optional-auth.guard.ts b/packages/mana-core-nestjs-integration/src/guards/optional-auth.guard.ts new file mode 100644 index 000000000..ee5ba7442 --- /dev/null +++ b/packages/mana-core-nestjs-integration/src/guards/optional-auth.guard.ts @@ -0,0 +1,117 @@ +import { Injectable, CanActivate, ExecutionContext, Inject, Optional } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { MANA_CORE_OPTIONS } from '../mana-core.module'; +import { ManaCoreModuleOptions } from '../interfaces/mana-core-options.interface'; + +interface TokenValidationResponse { + valid: boolean; + payload?: { + sub: string; + email: string; + role: string; + sessionId?: string; + sid?: string; + app_id?: string; + iat?: number; + exp?: number; + }; + error?: string; +} + +/** + * Optional auth guard - allows unauthenticated requests but still validates and extracts user info if token is present + */ +@Injectable() +export class OptionalAuthGuard implements CanActivate { + constructor( + @Optional() + @Inject(MANA_CORE_OPTIONS) + private readonly options?: ManaCoreModuleOptions, + @Optional() + private readonly configService?: ConfigService + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = this.extractTokenFromHeader(request); + + if (!token) { + // No token - allow request but user will be undefined + request.user = null; + return true; + } + + try { + const userData = await this.validateToken(token); + + if (userData) { + request.user = userData; + request.accessToken = token; + + if (this.options?.debug) { + console.log('[OptionalAuthGuard] User authenticated:', userData.sub); + } + } else { + request.user = null; + } + } catch (error) { + if (this.options?.debug) { + console.error('[OptionalAuthGuard] Token validation failed:', error); + } + // For optional auth, we allow the request to proceed even if token validation fails + request.user = null; + } + + return true; + } + + /** + * Validate token with Mana Core Auth service + */ + private async validateToken(token: string): Promise { + const authUrl = + this.configService?.get('MANA_CORE_AUTH_URL') || + process.env.MANA_CORE_AUTH_URL || + 'http://localhost:3001'; + + try { + const response = await fetch(`${authUrl}/api/v1/auth/validate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + + if (!response.ok) { + return null; + } + + const result = (await response.json()) as TokenValidationResponse; + + if (!result.valid || !result.payload) { + return null; + } + + return { + sub: result.payload.sub, + email: result.payload.email, + role: result.payload.role, + app_id: result.payload.app_id || this.options?.appId, + sessionId: result.payload.sessionId || result.payload.sid, + iat: result.payload.iat, + exp: result.payload.exp, + }; + } catch { + return null; + } + } + + private extractTokenFromHeader(request: any): string | undefined { + const authHeader = request.headers.authorization; + if (!authHeader) { + return undefined; + } + + const [type, token] = authHeader.split(' '); + return type === 'Bearer' ? token : undefined; + } +} diff --git a/packages/mana-core-nestjs-integration/src/index.ts b/packages/mana-core-nestjs-integration/src/index.ts new file mode 100644 index 000000000..e32c06adb --- /dev/null +++ b/packages/mana-core-nestjs-integration/src/index.ts @@ -0,0 +1,53 @@ +// Module +export { ManaCoreModule, MANA_CORE_OPTIONS } from './mana-core.module'; + +// Interfaces +export { + ManaCoreModuleOptions, + ManaCoreModuleAsyncOptions, + ManaCoreOptionsFactory, +} from './interfaces/mana-core-options.interface'; + +// Guards +export { AuthGuard } from './guards/auth.guard'; +export { OptionalAuthGuard } from './guards/optional-auth.guard'; + +// Decorators +export { CurrentUser, JwtPayload } from './decorators/current-user.decorator'; +export { Public, IS_PUBLIC_KEY } from './decorators/public.decorator'; +export { + UseCredits, + CreditOperationConfig, + CREDIT_OPERATION_KEY, +} from './decorators/use-credits.decorator'; + +// Interceptors +export { CreditInterceptor } from './interceptors/credit.interceptor'; + +// Services +export { + CreditClientService, + CreditValidationResult, + CreditBalance, +} from './services/credit-client.service'; + +// Exceptions +export { + InsufficientCreditsException, + InsufficientCreditsDetails, +} from './exceptions/insufficient-credits.exception'; + +// Re-export credit operations for convenience +export { + CreditOperationType, + CREDIT_COSTS, + CreditCategory, + getCreditCost, + getOperationMetadata, + getOperationsForApp, + formatCreditCost, + getPricingTable, + isFreeOperation, + isMicroCreditOperation, + isAiOperation, +} from '@manacore/credit-operations'; diff --git a/packages/mana-core-nestjs-integration/src/interceptors/credit.interceptor.ts b/packages/mana-core-nestjs-integration/src/interceptors/credit.interceptor.ts new file mode 100644 index 000000000..b68b24abe --- /dev/null +++ b/packages/mana-core-nestjs-integration/src/interceptors/credit.interceptor.ts @@ -0,0 +1,195 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, + Logger, + Inject, + Optional, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Observable, tap, catchError, throwError } from 'rxjs'; +import { CreditClientService } from '../services/credit-client.service'; +import { + InsufficientCreditsException, + InsufficientCreditsDetails, +} from '../exceptions/insufficient-credits.exception'; +import { CREDIT_OPERATION_KEY, CreditOperationConfig } from '../decorators/use-credits.decorator'; +import { CREDIT_COSTS, getOperationMetadata } from '@manacore/credit-operations'; +import { MANA_CORE_OPTIONS } from '../mana-core.module'; +import { ManaCoreModuleOptions } from '../interfaces/mana-core-options.interface'; + +/** + * Interceptor that handles credit validation and consumption for decorated endpoints. + * + * This interceptor: + * 1. Checks if the user has sufficient credits before executing the handler + * 2. Consumes credits after successful execution (or before, depending on config) + * 3. Throws InsufficientCreditsException if the user doesn't have enough credits + */ +@Injectable() +export class CreditInterceptor implements NestInterceptor { + private readonly logger = new Logger(CreditInterceptor.name); + + constructor( + private readonly reflector: Reflector, + private readonly creditClient: CreditClientService, + @Optional() + @Inject(MANA_CORE_OPTIONS) + private readonly options?: ManaCoreModuleOptions + ) {} + + async intercept(context: ExecutionContext, next: CallHandler): Promise> { + const config = this.reflector.get( + CREDIT_OPERATION_KEY, + context.getHandler() + ); + + // If no config, just proceed (shouldn't happen if decorator is used correctly) + if (!config) { + return next.handle(); + } + + const request = context.switchToHttp().getRequest(); + const user = request.user; + + // Check if user is authenticated + if (!user?.sub) { + this.logger.warn('No authenticated user found for credit operation'); + return next.handle(); + } + + const userId = user.sub; + const operationName = config.operation; + + // Calculate cost + const cost = this.calculateCost(config, request); + const consumeMode = config.consumeMode || 'after'; + + // Skip in development if configured + if (config.skipInDev && this.isDevelopment()) { + this.logger.debug(`Skipping credit check in development for ${operationName}`); + return next.handle(); + } + + // Validate credits before execution + const validation = await this.creditClient.validateCredits(userId, operationName, cost); + + if (!validation.hasCredits) { + const details: InsufficientCreditsDetails = { + requiredCredits: cost, + availableCredits: validation.availableCredits, + creditType: 'user', + operation: operationName, + }; + throw new InsufficientCreditsException(details); + } + + // If consume mode is 'before', consume now + if (consumeMode === 'before') { + const description = this.generateDescription(config, request); + const consumed = await this.creditClient.consumeCredits( + userId, + operationName, + cost, + description, + this.buildMetadata(config, request) + ); + + if (!consumed) { + this.logger.error(`Failed to consume credits for ${operationName}`); + // Still allow the operation to proceed - fail open + } + + return next.handle(); + } + + // If consume mode is 'after', consume on success + return next.handle().pipe( + tap(async () => { + const description = this.generateDescription(config, request); + const consumed = await this.creditClient.consumeCredits( + userId, + operationName, + cost, + description, + this.buildMetadata(config, request) + ); + + if (!consumed) { + this.logger.error(`Failed to consume credits after success for ${operationName}`); + } else if (this.options?.debug) { + this.logger.log(`Consumed ${cost} credits for ${operationName} (user: ${userId})`); + } + }), + catchError((error) => { + // Don't consume credits if the operation failed + this.logger.debug(`Operation ${operationName} failed, credits not consumed`); + return throwError(() => error); + }) + ); + } + + /** + * Calculate the credit cost for the operation. + */ + private calculateCost(config: CreditOperationConfig, request: any): number { + // Dynamic cost takes priority + if (config.dynamicCost) { + return config.dynamicCost(request); + } + + // Custom cost override + if (config.customCost !== undefined) { + return config.customCost; + } + + // Default cost from CREDIT_COSTS + return CREDIT_COSTS[config.operation] || 0; + } + + /** + * Generate a description for the credit transaction. + */ + private generateDescription(config: CreditOperationConfig, request: any): string { + // Custom description function + if (config.descriptionFn) { + return config.descriptionFn(request); + } + + // Default description from operation metadata + const metadata = getOperationMetadata(config.operation); + return metadata?.name || config.operation; + } + + /** + * Build metadata for the credit transaction. + */ + private buildMetadata(config: CreditOperationConfig, request: any): Record { + const metadata: Record = { + operation: config.operation, + path: request.path, + method: request.method, + }; + + // Add app info from operation metadata + const opMeta = getOperationMetadata(config.operation); + if (opMeta) { + metadata.app = opMeta.app; + metadata.category = opMeta.category; + } + + return metadata; + } + + /** + * Check if running in development mode. + */ + private isDevelopment(): boolean { + return ( + this.options?.debug || + process.env.NODE_ENV === 'development' || + process.env.NODE_ENV === 'dev' + ); + } +} diff --git a/packages/mana-core-nestjs-integration/src/interceptors/index.ts b/packages/mana-core-nestjs-integration/src/interceptors/index.ts new file mode 100644 index 000000000..4e0a4af26 --- /dev/null +++ b/packages/mana-core-nestjs-integration/src/interceptors/index.ts @@ -0,0 +1 @@ +export { CreditInterceptor } from './credit.interceptor'; diff --git a/packages/mana-core-nestjs-integration/src/interfaces/mana-core-options.interface.ts b/packages/mana-core-nestjs-integration/src/interfaces/mana-core-options.interface.ts new file mode 100644 index 000000000..31ddddf8c --- /dev/null +++ b/packages/mana-core-nestjs-integration/src/interfaces/mana-core-options.interface.ts @@ -0,0 +1,24 @@ +import { type ModuleMetadata } from '@nestjs/common'; +import type { Type } from '@nestjs/common'; + +export interface ManaCoreModuleOptions { + /** + * @deprecated No longer used - auth URL is read from MANA_CORE_AUTH_URL env variable + */ + manaServiceUrl?: string; + appId: string; + serviceKey?: string; + signupRedirectUrl?: string; + debug?: boolean; +} + +export interface ManaCoreOptionsFactory { + createManaCoreOptions(): Promise | ManaCoreModuleOptions; +} + +export interface ManaCoreModuleAsyncOptions extends Pick { + useExisting?: Type; + useClass?: Type; + useFactory?: (...args: any[]) => Promise | ManaCoreModuleOptions; + inject?: any[]; +} diff --git a/packages/mana-core-nestjs-integration/src/mana-core.module.ts b/packages/mana-core-nestjs-integration/src/mana-core.module.ts new file mode 100644 index 000000000..28bca0a19 --- /dev/null +++ b/packages/mana-core-nestjs-integration/src/mana-core.module.ts @@ -0,0 +1,83 @@ +import { DynamicModule, Module, Global, Provider } from '@nestjs/common'; +import { + ManaCoreModuleOptions, + ManaCoreModuleAsyncOptions, + ManaCoreOptionsFactory, +} from './interfaces/mana-core-options.interface'; +import { AuthGuard } from './guards/auth.guard'; +import { CreditClientService } from './services/credit-client.service'; + +export const MANA_CORE_OPTIONS = 'MANA_CORE_OPTIONS'; + +@Global() +@Module({}) +export class ManaCoreModule { + static forRoot(options: ManaCoreModuleOptions): DynamicModule { + return { + module: ManaCoreModule, + providers: [ + { + provide: MANA_CORE_OPTIONS, + useValue: options, + }, + AuthGuard, + CreditClientService, + ], + exports: [MANA_CORE_OPTIONS, AuthGuard, CreditClientService], + }; + } + + static forRootAsync(options: ManaCoreModuleAsyncOptions): DynamicModule { + const asyncProviders = this.createAsyncProviders(options); + + return { + module: ManaCoreModule, + imports: options.imports || [], + providers: [...asyncProviders, AuthGuard, CreditClientService], + exports: [MANA_CORE_OPTIONS, AuthGuard, CreditClientService], + }; + } + + private static createAsyncProviders(options: ManaCoreModuleAsyncOptions): Provider[] { + if (options.useFactory) { + return [ + { + provide: MANA_CORE_OPTIONS, + useFactory: options.useFactory, + inject: options.inject || [], + }, + ]; + } + + const useClass = options.useClass; + const useExisting = options.useExisting; + + if (useClass) { + return [ + { + provide: MANA_CORE_OPTIONS, + useFactory: async (optionsFactory: ManaCoreOptionsFactory) => + await optionsFactory.createManaCoreOptions(), + inject: [useClass], + }, + { + provide: useClass, + useClass, + }, + ]; + } + + if (useExisting) { + return [ + { + provide: MANA_CORE_OPTIONS, + useFactory: async (optionsFactory: ManaCoreOptionsFactory) => + await optionsFactory.createManaCoreOptions(), + inject: [useExisting], + }, + ]; + } + + return []; + } +} diff --git a/packages/mana-core-nestjs-integration/src/services/credit-client.service.ts b/packages/mana-core-nestjs-integration/src/services/credit-client.service.ts new file mode 100644 index 000000000..1b2b83dad --- /dev/null +++ b/packages/mana-core-nestjs-integration/src/services/credit-client.service.ts @@ -0,0 +1,243 @@ +import { Injectable, Inject, Optional, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { MANA_CORE_OPTIONS } from '../mana-core.module'; +import { ManaCoreModuleOptions } from '../interfaces/mana-core-options.interface'; + +export interface CreditValidationResult { + hasCredits: boolean; + availableCredits: number; + requiredCredits?: number; +} + +export interface CreditBalance { + balance: number; + totalEarned: number; + totalSpent: number; +} + +@Injectable() +export class CreditClientService { + private readonly logger = new Logger(CreditClientService.name); + + constructor( + @Optional() + @Inject(MANA_CORE_OPTIONS) + private readonly options?: ManaCoreModuleOptions, + @Optional() + private readonly configService?: ConfigService + ) {} + + private getAuthUrl(): string { + return ( + this.configService?.get('MANA_CORE_AUTH_URL') || + process.env.MANA_CORE_AUTH_URL || + 'http://localhost:3001' + ); + } + + /** + * Get the credits service URL. Uses MANA_CREDITS_URL if available, + * falls back to MANA_CORE_AUTH_URL for backward compatibility. + */ + private getCreditsUrl(): string { + return ( + this.configService?.get('MANA_CREDITS_URL') || + process.env.MANA_CREDITS_URL || + this.getAuthUrl() + ); + } + + private getServiceKey(): string { + return ( + this.options?.serviceKey || + this.configService?.get('MANA_CORE_SERVICE_KEY') || + process.env.MANA_CORE_SERVICE_KEY || + '' + ); + } + + private getAppId(): string { + return ( + this.options?.appId || this.configService?.get('APP_ID') || process.env.APP_ID || '' + ); + } + + async validateCredits( + userId: string, + operation: string, + requiredAmount: number + ): Promise { + try { + const balance = await this.getBalance(userId); + + return { + hasCredits: balance.balance >= requiredAmount, + availableCredits: balance.balance, + requiredCredits: requiredAmount, + }; + } catch (error) { + this.logger.error(`Failed to validate credits for user ${userId}:`, error); + // In case of error, we allow the operation to proceed + // The actual credit deduction will fail if there are no credits + return { + hasCredits: true, + availableCredits: 0, + requiredCredits: requiredAmount, + }; + } + } + + async getBalance(userId: string): Promise { + const creditsUrl = this.getCreditsUrl(); + const serviceKey = this.getServiceKey(); + + if (!serviceKey) { + this.logger.warn('Service key not configured, returning default balance'); + return { + balance: 1000, + totalEarned: 0, + totalSpent: 0, + }; + } + + try { + const response = await fetch(`${creditsUrl}/api/v1/internal/credits/balance/${userId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-Service-Key': serviceKey, + 'X-App-Id': this.getAppId(), + }, + }); + + if (!response.ok) { + this.logger.warn(`Credit balance request failed: ${response.status}`); + return this.getDefaultBalance(); + } + + const { + balance = 0, + totalEarned = 0, + totalSpent = 0, + } = (await response.json()) as CreditBalance; + return { + balance, + totalEarned, + totalSpent, + }; + } catch (error) { + this.logger.error(`Failed to get balance for user ${userId}:`, error); + return this.getDefaultBalance(); + } + } + + async consumeCredits( + userId: string, + operation: string, + amount: number, + description: string, + metadata?: Record, + creditSource?: { type: 'personal' } | { type: 'guild'; guildId: string } + ): Promise { + const creditsUrl = this.getCreditsUrl(); + const serviceKey = this.getServiceKey(); + + if (!serviceKey) { + this.logger.warn('Service key not configured, skipping credit consumption'); + return true; + } + + try { + const response = await fetch(`${creditsUrl}/api/v1/internal/credits/use`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Service-Key': serviceKey, + 'X-App-Id': this.getAppId(), + }, + body: JSON.stringify({ + userId, + amount, + appId: this.getAppId(), + description, + metadata: { + operation, + ...metadata, + }, + ...(creditSource && { creditSource }), + }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + this.logger.error(`Failed to consume credits: ${response.status} ${errorText}`); + return false; + } + + if (this.options?.debug) { + this.logger.log(`Consumed ${amount} credits for user ${userId}: ${description}`); + } + + return true; + } catch (error) { + this.logger.error(`Failed to consume credits for user ${userId}:`, error); + return false; + } + } + + async refundCredits( + userId: string, + amount: number, + description: string, + metadata?: Record + ): Promise { + const creditsUrl = this.getCreditsUrl(); + const serviceKey = this.getServiceKey(); + + if (!serviceKey) { + this.logger.warn('Service key not configured, skipping credit refund'); + return true; + } + + try { + const response = await fetch(`${creditsUrl}/api/v1/internal/credits/refund`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Service-Key': serviceKey, + 'X-App-Id': this.getAppId(), + }, + body: JSON.stringify({ + userId, + amount, + appId: this.getAppId(), + description, + metadata, + }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + this.logger.error(`Failed to refund credits: ${response.status} ${errorText}`); + return false; + } + + if (this.options?.debug) { + this.logger.log(`Refunded ${amount} credits for user ${userId}: ${description}`); + } + + return true; + } catch (error) { + this.logger.error(`Failed to refund credits for user ${userId}:`, error); + return false; + } + } + + private getDefaultBalance(): CreditBalance { + return { + balance: 1000, + totalEarned: 0, + totalSpent: 0, + }; + } +} diff --git a/packages/mana-core-nestjs-integration/tsconfig.json b/packages/mana-core-nestjs-integration/tsconfig.json new file mode 100644 index 000000000..310fa8950 --- /dev/null +++ b/packages/mana-core-nestjs-integration/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "commonjs", + "lib": ["ES2021"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}