mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
7552c351c0
commit
49df3ead09
28 changed files with 1874 additions and 18 deletions
56
apps/taktik/apps/web/src/lib/components/ConfirmDialog.svelte
Normal file
56
apps/taktik/apps/web/src/lib/components/ConfirmDialog.svelte
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
let {
|
||||
visible = false,
|
||||
title = '',
|
||||
message = '',
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
destructive = true,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
message?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
destructive?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
class="mx-4 w-full max-w-sm rounded-2xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6 shadow-xl"
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-[hsl(var(--foreground))]">{title}</h3>
|
||||
{#if message}
|
||||
<p class="mt-2 text-sm text-[hsl(var(--muted-foreground))]">{message}</p>
|
||||
{/if}
|
||||
<div class="mt-5 flex gap-2">
|
||||
<button
|
||||
onclick={onCancel}
|
||||
class="flex-1 rounded-lg border border-[hsl(var(--border))] py-2.5 text-sm text-[hsl(var(--muted-foreground))] transition-colors hover:text-[hsl(var(--foreground))]"
|
||||
>
|
||||
{cancelLabel || $_('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onclick={onConfirm}
|
||||
class="flex-1 rounded-lg py-2.5 text-sm font-medium transition-colors {destructive
|
||||
? 'bg-red-500 text-white hover:bg-red-600'
|
||||
: 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))] hover:opacity-90'}"
|
||||
>
|
||||
{confirmLabel || $_('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -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<typeof setTimeout> | null = null;
|
||||
|
||||
function autoSave(updates: Record<string, unknown>) {
|
||||
|
|
@ -74,7 +77,12 @@
|
|||
}
|
||||
|
||||
async function handleDelete() {
|
||||
showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
await timeEntryCollection.delete(entry.id);
|
||||
showDeleteConfirm = false;
|
||||
onCollapse?.();
|
||||
}
|
||||
|
||||
|
|
@ -197,3 +205,11 @@
|
|||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
visible={showDeleteConfirm}
|
||||
title={$_('common.delete')}
|
||||
message={$_('entry.deleteConfirm')}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => (showDeleteConfirm = false)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<LocalTimeEntry | null>(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;
|
||||
|
|
|
|||
86
apps/taktik/apps/web/src/lib/utils/rounding.test.ts
Normal file
86
apps/taktik/apps/web/src/lib/utils/rounding.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
36
apps/taktik/apps/web/src/lib/utils/rounding.ts
Normal file
36
apps/taktik/apps/web/src/lib/utils/rounding.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string | null>(null);
|
||||
let showArchived = $state(false);
|
||||
let deleteConfirmId = $state<string | null>(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 @@
|
|||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
visible={deleteConfirmId !== null}
|
||||
title={$_('common.delete')}
|
||||
message={$_('client.deleteConfirm')}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => (deleteConfirmId = null)}
|
||||
/>
|
||||
|
|
|
|||
160
apps/taktik/apps/web/src/routes/(app)/clients/[id]/+page.svelte
Normal file
160
apps/taktik/apps/web/src/routes/(app)/clients/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { getContext } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { clientCollection } from '$lib/data/local-store';
|
||||
import {
|
||||
getTotalDuration,
|
||||
getBillableDuration,
|
||||
formatDurationCompact,
|
||||
formatDurationDecimal,
|
||||
} from '$lib/data/queries';
|
||||
import EntryList from '$lib/components/EntryList.svelte';
|
||||
import type { Project, Client, TimeEntry } from '@taktik/shared';
|
||||
|
||||
const allClients = getContext<{ value: Client[] }>('clients');
|
||||
const allProjects = getContext<{ value: Project[] }>('projects');
|
||||
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
|
||||
|
||||
let clientId = $derived($page.params.id);
|
||||
|
||||
let client = $derived(allClients.value.find((c) => c.id === clientId));
|
||||
let clientProjects = $derived(
|
||||
allProjects.value.filter((p) => p.clientId === clientId).sort((a, b) => a.order - b.order)
|
||||
);
|
||||
let clientEntries = $derived(
|
||||
allTimeEntries.value
|
||||
.filter((e) => e.clientId === clientId && !e.isRunning)
|
||||
.sort((a, b) => b.date.localeCompare(a.date))
|
||||
);
|
||||
|
||||
let totalDuration = $derived(getTotalDuration(clientEntries));
|
||||
let billableDuration = $derived(getBillableDuration(clientEntries));
|
||||
|
||||
function getProjectHours(projectId: string): number {
|
||||
return getTotalDuration(clientEntries.filter((e) => e.projectId === projectId));
|
||||
}
|
||||
|
||||
let billingValue = $derived(() => {
|
||||
if (!client?.billingRate) return null;
|
||||
return (billableDuration / 3600) * client.billingRate.amount;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{client?.name || 'Kunde'} | Taktik</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if !client}
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
<p class="text-[hsl(var(--muted-foreground))]">Kunde nicht gefunden.</p>
|
||||
<a href="/clients" class="mt-4 text-sm text-[hsl(var(--primary))]">{$_('common.back')}</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<!-- Back + Header -->
|
||||
<div>
|
||||
<a
|
||||
href="/clients"
|
||||
class="mb-3 inline-flex items-center gap-1 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
{$_('nav.clients')}
|
||||
</a>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="flex h-14 w-14 items-center justify-center rounded-xl text-xl font-bold text-white"
|
||||
style="background-color: {client.color}"
|
||||
>
|
||||
{client.shortCode || client.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{client.name}</h1>
|
||||
<p class="text-sm text-[hsl(var(--muted-foreground))]">
|
||||
{#if client.shortCode}{client.shortCode} ·
|
||||
{/if}
|
||||
{#if client.email}{client.email} ·
|
||||
{/if}
|
||||
{#if client.billingRate}
|
||||
{client.billingRate.amount} {client.billingRate.currency}/h
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
|
||||
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('report.totalHours')}</p>
|
||||
<p class="duration-display mt-1 text-xl font-bold text-[hsl(var(--foreground))]">
|
||||
{formatDurationDecimal(totalDuration)}h
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
|
||||
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('report.billableHours')}</p>
|
||||
<p class="duration-display mt-1 text-xl font-bold text-[hsl(var(--primary))]">
|
||||
{formatDurationDecimal(billableDuration)}h
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
|
||||
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('nav.projects')}</p>
|
||||
<p class="mt-1 text-xl font-bold text-[hsl(var(--foreground))]">{clientProjects.length}</p>
|
||||
</div>
|
||||
{#if billingValue() !== null}
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
|
||||
<p class="text-xs text-[hsl(var(--muted-foreground))]">Wert</p>
|
||||
<p class="mt-1 text-xl font-bold text-[hsl(var(--primary))]">
|
||||
{billingValue()!.toFixed(0)}
|
||||
{client.billingRate!.currency}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Projects -->
|
||||
{#if clientProjects.length > 0}
|
||||
<div>
|
||||
<h2 class="mb-3 text-sm font-medium text-[hsl(var(--muted-foreground))]">
|
||||
{$_('nav.projects')}
|
||||
</h2>
|
||||
<div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each clientProjects as proj}
|
||||
{@const hours = getProjectHours(proj.id)}
|
||||
<a
|
||||
href="/projects/{proj.id}"
|
||||
class="entry-item flex items-center gap-3 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4"
|
||||
>
|
||||
<div class="h-3 w-3 rounded-full" style="background-color: {proj.color}"></div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-[hsl(var(--foreground))]">{proj.name}</p>
|
||||
{#if proj.isBillable}
|
||||
<span class="text-xs text-[hsl(var(--primary))]">{$_('project.billable')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="duration-display text-sm font-medium text-[hsl(var(--foreground))]">
|
||||
{formatDurationCompact(hours)}
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Entries -->
|
||||
<div>
|
||||
<h2 class="mb-3 text-sm font-medium text-[hsl(var(--muted-foreground))]">
|
||||
{$_('nav.entries')} ({formatDurationCompact(totalDuration)})
|
||||
</h2>
|
||||
<EntryList entries={clientEntries} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -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<string | null>(null);
|
||||
let showArchived = $state(false);
|
||||
let deleteConfirmId = $state<string | null>(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 @@
|
|||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
visible={deleteConfirmId !== null}
|
||||
title={$_('common.delete')}
|
||||
message={$_('project.deleteConfirm')}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => (deleteConfirmId = null)}
|
||||
/>
|
||||
|
|
|
|||
330
apps/taktik/apps/web/src/routes/(app)/projects/[id]/+page.svelte
Normal file
330
apps/taktik/apps/web/src/routes/(app)/projects/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getContext } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { projectCollection } from '$lib/data/local-store';
|
||||
import {
|
||||
getTotalDuration,
|
||||
getBillableDuration,
|
||||
formatDurationCompact,
|
||||
formatDurationDecimal,
|
||||
} from '$lib/data/queries';
|
||||
import EntryList from '$lib/components/EntryList.svelte';
|
||||
import type { Project, Client, TimeEntry } from '@taktik/shared';
|
||||
import { PROJECT_COLORS } from '@taktik/shared/constants';
|
||||
|
||||
const allProjects = getContext<{ value: Project[] }>('projects');
|
||||
const allClients = getContext<{ value: Client[] }>('clients');
|
||||
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
|
||||
|
||||
let projectId = $derived($page.params.id);
|
||||
|
||||
let project = $derived(allProjects.value.find((p) => p.id === projectId));
|
||||
let client = $derived(
|
||||
project?.clientId ? allClients.value.find((c) => c.id === project!.clientId) : undefined
|
||||
);
|
||||
let projectEntries = $derived(
|
||||
allTimeEntries.value
|
||||
.filter((e) => e.projectId === projectId && !e.isRunning)
|
||||
.sort((a, b) => b.date.localeCompare(a.date))
|
||||
);
|
||||
|
||||
let totalDuration = $derived(getTotalDuration(projectEntries));
|
||||
let billableDuration = $derived(getBillableDuration(projectEntries));
|
||||
|
||||
let budgetPercent = $derived(() => {
|
||||
if (!project?.budget || project.budget.type !== 'hours') return null;
|
||||
const hoursLogged = totalDuration / 3600;
|
||||
return Math.min(100, Math.round((hoursLogged / project.budget.amount) * 100));
|
||||
});
|
||||
|
||||
let budgetHoursUsed = $derived(totalDuration / 3600);
|
||||
let budgetHoursTotal = $derived(project?.budget?.type === 'hours' ? project.budget.amount : null);
|
||||
|
||||
// Edit state
|
||||
let isEditing = $state(false);
|
||||
let editName = $state('');
|
||||
let editDescription = $state('');
|
||||
let editClientId = $state('');
|
||||
let editColor = $state('');
|
||||
let editIsBillable = $state(false);
|
||||
let editBudgetHours = $state(0);
|
||||
let editRateAmount = $state(0);
|
||||
|
||||
function startEditing() {
|
||||
if (!project) return;
|
||||
isEditing = true;
|
||||
editName = project.name;
|
||||
editDescription = project.description ?? '';
|
||||
editClientId = project.clientId ?? '';
|
||||
editColor = project.color;
|
||||
editIsBillable = project.isBillable;
|
||||
editBudgetHours = project.budget?.type === 'hours' ? project.budget.amount : 0;
|
||||
editRateAmount = project.billingRate?.amount ?? 0;
|
||||
}
|
||||
|
||||
let debounce: ReturnType<typeof setTimeout> | null = null;
|
||||
function save(updates: Record<string, unknown>) {
|
||||
const id = projectId;
|
||||
if (!id) return;
|
||||
if (debounce) clearTimeout(debounce);
|
||||
debounce = setTimeout(() => {
|
||||
projectCollection.update(id, updates);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function handleArchive() {
|
||||
const id = projectId;
|
||||
if (!project || !id) return;
|
||||
await projectCollection.update(id, { isArchived: !project.isArchived });
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{project?.name || 'Projekt'} | Taktik</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if !project}
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
<p class="text-[hsl(var(--muted-foreground))]">Projekt nicht gefunden.</p>
|
||||
<a href="/projects" class="mt-4 text-sm text-[hsl(var(--primary))]">{$_('common.back')}</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<!-- Back + Header -->
|
||||
<div>
|
||||
<a
|
||||
href="/projects"
|
||||
class="mb-3 inline-flex items-center gap-1 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
{$_('nav.projects')}
|
||||
</a>
|
||||
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-4 w-4 rounded-full" style="background-color: {project.color}"></div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{project.name}</h1>
|
||||
<p class="text-sm text-[hsl(var(--muted-foreground))]">
|
||||
{client?.name || $_('project.internal')}
|
||||
{#if project.isBillable}
|
||||
<span
|
||||
class="ml-2 rounded bg-[hsl(var(--primary)/0.1)] px-1.5 py-0.5 text-xs text-[hsl(var(--primary))]"
|
||||
>
|
||||
{$_('project.billable')}
|
||||
</span>
|
||||
{/if}
|
||||
{#if project.isArchived}
|
||||
<span
|
||||
class="ml-2 rounded bg-[hsl(var(--muted))] px-1.5 py-0.5 text-xs text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
{$_('project.archived')}
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => (isEditing ? (isEditing = false) : startEditing())}
|
||||
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
|
||||
>
|
||||
{isEditing ? $_('common.close') : $_('common.edit')}
|
||||
</button>
|
||||
<button
|
||||
onclick={handleArchive}
|
||||
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-sm text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
{project.isArchived ? $_('common.unarchive') : $_('common.archive')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Form -->
|
||||
{#if isEditing}
|
||||
<div
|
||||
class="rounded-xl border border-[hsl(var(--primary)/0.3)] bg-[hsl(var(--card))] p-4 space-y-3"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
oninput={(e) => {
|
||||
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"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editDescription}
|
||||
oninput={(e) => {
|
||||
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))]"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
value={editClientId}
|
||||
onchange={(e) => {
|
||||
editClientId = (e.target as HTMLSelectElement).value;
|
||||
save({ clientId: editClientId || null });
|
||||
}}
|
||||
class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">{$_('project.internal')}</option>
|
||||
{#each allClients.value.filter((c) => !c.isArchived) as c}
|
||||
<option value={c.id}>{c.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<label
|
||||
class="flex items-center gap-2 rounded-lg border border-[hsl(var(--border))] px-3 py-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editIsBillable}
|
||||
onchange={() => {
|
||||
editIsBillable = !editIsBillable;
|
||||
save({ isBillable: editIsBillable });
|
||||
}}
|
||||
class="accent-[hsl(var(--primary))]"
|
||||
/>
|
||||
{$_('project.billable')}
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<label class="text-xs text-[hsl(var(--muted-foreground))]"
|
||||
>{$_('project.budget')} (h):</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
value={editBudgetHours}
|
||||
min="0"
|
||||
oninput={(e) => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<label class="text-xs text-[hsl(var(--muted-foreground))]">Rate:</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editRateAmount}
|
||||
min="0"
|
||||
step="5"
|
||||
oninput={(e) => {
|
||||
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"
|
||||
/>
|
||||
<span class="text-xs text-[hsl(var(--muted-foreground))]">/h</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each PROJECT_COLORS as color}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
editColor = color;
|
||||
save({ color });
|
||||
}}
|
||||
class="h-5 w-5 rounded-full border-2 {editColor === color
|
||||
? 'border-white scale-110'
|
||||
: 'border-transparent'}"
|
||||
style="background-color: {color}"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
|
||||
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('report.totalHours')}</p>
|
||||
<p class="duration-display mt-1 text-xl font-bold text-[hsl(var(--foreground))]">
|
||||
{formatDurationDecimal(totalDuration)}h
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
|
||||
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('report.billableHours')}</p>
|
||||
<p class="duration-display mt-1 text-xl font-bold text-[hsl(var(--primary))]">
|
||||
{formatDurationDecimal(billableDuration)}h
|
||||
</p>
|
||||
</div>
|
||||
{#if budgetHoursTotal}
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
|
||||
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('project.budget')}</p>
|
||||
<p class="duration-display mt-1 text-xl font-bold text-[hsl(var(--foreground))]">
|
||||
{budgetHoursUsed.toFixed(1)} / {budgetHoursTotal}h
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
|
||||
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('nav.entries')}</p>
|
||||
<p class="mt-1 text-xl font-bold text-[hsl(var(--foreground))]">{projectEntries.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Budget Progress -->
|
||||
{#if budgetPercent() !== null}
|
||||
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-[hsl(var(--muted-foreground))]">{$_('project.budget')}</span>
|
||||
<span class="font-medium text-[hsl(var(--foreground))]">{budgetPercent()}%</span>
|
||||
</div>
|
||||
<div class="mt-2 h-2.5 rounded-full bg-[hsl(var(--muted))]">
|
||||
<div
|
||||
class="h-full rounded-full transition-all {budgetPercent()! > 90
|
||||
? 'bg-red-500'
|
||||
: budgetPercent()! > 75
|
||||
? 'bg-amber-500'
|
||||
: 'bg-[hsl(var(--primary))]'}"
|
||||
style="width: {budgetPercent()}%"
|
||||
></div>
|
||||
</div>
|
||||
{#if project.billingRate}
|
||||
<p class="mt-2 text-xs text-[hsl(var(--muted-foreground))]">
|
||||
{project.billingRate.amount}
|
||||
{project.billingRate.currency}/h · Wert: {(
|
||||
(billableDuration / 3600) *
|
||||
project.billingRate.amount
|
||||
).toFixed(0)}
|
||||
{project.billingRate.currency}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Entries -->
|
||||
<div>
|
||||
<h2 class="mb-3 text-sm font-medium text-[hsl(var(--muted-foreground))]">
|
||||
{$_('nav.entries')} ({formatDurationCompact(totalDuration)})
|
||||
</h2>
|
||||
<EntryList entries={projectEntries} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
57
packages/mana-core-nestjs-integration/package.json
Normal file
57
packages/mana-core-nestjs-integration/package.json
Normal file
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
);
|
||||
|
|
@ -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';
|
||||
|
|
@ -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);
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
176
packages/mana-core-nestjs-integration/src/guards/auth.guard.ts
Normal file
176
packages/mana-core-nestjs-integration/src/guards/auth.guard.ts
Normal file
|
|
@ -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<boolean> {
|
||||
// Check if route is marked as public
|
||||
if (this.reflector) {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(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<string>('NODE_ENV') === 'development' ||
|
||||
process.env.NODE_ENV === 'development';
|
||||
const bypassAuth =
|
||||
this.configService?.get<string>('DEV_BYPASS_AUTH') === 'true' ||
|
||||
process.env.DEV_BYPASS_AUTH === 'true';
|
||||
return isDev && bypassAuth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get development user data
|
||||
*/
|
||||
private getDevUser() {
|
||||
const devUserId =
|
||||
this.configService?.get<string>('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<any> {
|
||||
const authUrl =
|
||||
this.configService?.get<string>('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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { AuthGuard } from './auth.guard';
|
||||
export { OptionalAuthGuard } from './optional-auth.guard';
|
||||
|
|
@ -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<boolean> {
|
||||
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<any | null> {
|
||||
const authUrl =
|
||||
this.configService?.get<string>('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;
|
||||
}
|
||||
}
|
||||
53
packages/mana-core-nestjs-integration/src/index.ts
Normal file
53
packages/mana-core-nestjs-integration/src/index.ts
Normal file
|
|
@ -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';
|
||||
|
|
@ -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<Observable<any>> {
|
||||
const config = this.reflector.get<CreditOperationConfig>(
|
||||
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<string, any> {
|
||||
const metadata: Record<string, any> = {
|
||||
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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { CreditInterceptor } from './credit.interceptor';
|
||||
|
|
@ -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> | ManaCoreModuleOptions;
|
||||
}
|
||||
|
||||
export interface ManaCoreModuleAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
|
||||
useExisting?: Type<ManaCoreOptionsFactory>;
|
||||
useClass?: Type<ManaCoreOptionsFactory>;
|
||||
useFactory?: (...args: any[]) => Promise<ManaCoreModuleOptions> | ManaCoreModuleOptions;
|
||||
inject?: any[];
|
||||
}
|
||||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string>('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<string>('MANA_CREDITS_URL') ||
|
||||
process.env.MANA_CREDITS_URL ||
|
||||
this.getAuthUrl()
|
||||
);
|
||||
}
|
||||
|
||||
private getServiceKey(): string {
|
||||
return (
|
||||
this.options?.serviceKey ||
|
||||
this.configService?.get<string>('MANA_CORE_SERVICE_KEY') ||
|
||||
process.env.MANA_CORE_SERVICE_KEY ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
private getAppId(): string {
|
||||
return (
|
||||
this.options?.appId || this.configService?.get<string>('APP_ID') || process.env.APP_ID || ''
|
||||
);
|
||||
}
|
||||
|
||||
async validateCredits(
|
||||
userId: string,
|
||||
operation: string,
|
||||
requiredAmount: number
|
||||
): Promise<CreditValidationResult> {
|
||||
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<CreditBalance> {
|
||||
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<string, any>,
|
||||
creditSource?: { type: 'personal' } | { type: 'guild'; guildId: string }
|
||||
): Promise<boolean> {
|
||||
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<string, any>
|
||||
): Promise<boolean> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
21
packages/mana-core-nestjs-integration/tsconfig.json
Normal file
21
packages/mana-core-nestjs-integration/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue