fix(memoro): migrate web services + add credits balance endpoint

- server: add GET /api/v1/credits/balance (proxies to mana-credits via getBalance)
- web/creditService: rewrite to new paths (pricing, balance, retry-transcription, retry-headline)
- web/questionService: use authStore.getAccessToken() + new /api/v1/memos/:id/question path
- web/audioUploadService: fix accessToken param name for triggerTranscription

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-31 21:20:52 +02:00
parent a893b07b70
commit 8e496ff417
4 changed files with 94 additions and 333 deletions

View file

@ -4,6 +4,7 @@
import { Hono } from 'hono';
import { validateCredits, consumeCredits, COSTS } from '../lib/credits';
import { getBalance } from '@manacore/shared-hono';
export const creditRoutes = new Hono();
@ -12,6 +13,18 @@ creditRoutes.get('/pricing', (c) => {
return c.json({ costs: COSTS });
});
// GET /balance — authenticated, returns user's credit balance
creditRoutes.get('/balance', async (c) => {
const userId = c.get('userId') as string;
try {
const balance = await getBalance(userId);
return c.json({ credits: balance.balance, totalEarned: balance.totalEarned, totalSpent: balance.totalSpent });
} catch (err) {
console.error('[credits] Balance error:', err);
return c.json({ error: 'Failed to fetch balance' }, 500);
}
});
// POST /check — validate credits (requires auth via parent router)
creditRoutes.post('/check', async (c) => {
const userId = c.get('userId') as string;

View file

@ -125,7 +125,7 @@ export async function uploadAndProcessAudio({
blueprintId,
recordingLanguages,
enableDiarization,
appToken,
accessToken: appToken,
mediaType,
});

View file

@ -1,11 +1,12 @@
/**
* Credit Service for Memoro Web
* Handles credit operations and pricing
*
* Pattern adapted from memoro_app/features/credits/creditService.ts
* Handles credit operations and pricing via memoro-server (Hono/Bun).
*/
import { env } from '$lib/config/env';
import { authStore } from '$lib/stores/auth.svelte';
const SERVER_URL = () => env.server.memoroUrl.replace(/\/$/, '');
export interface CreditCheckResponse {
hasEnoughCredits: boolean;
@ -16,347 +17,156 @@ export interface CreditCheckResponse {
estimatedCostPerHour?: number;
}
export interface CreditConsumptionResponse {
success: boolean;
message: string;
creditsConsumed: number;
creditType: 'user' | 'space';
durationMinutes?: number;
}
export interface OperationCreditResponse {
success: boolean;
message: string;
creditsConsumed: number;
creditType: 'user' | 'space';
operation: string;
}
export interface PricingResponse {
operationCosts: {
TRANSCRIPTION_PER_HOUR: number;
costs: {
TRANSCRIPTION_PER_MINUTE: number;
HEADLINE_GENERATION: number;
MEMORY_CREATION: number;
BLUEPRINT_PROCESSING: number;
QUESTION_MEMO: number;
NEW_MEMORY: number;
MEMO_COMBINE: number;
MEMO_SHARING: number;
SPACE_OPERATION: number;
MEETING_RECORDING_PER_MINUTE: number;
};
transcriptionPerHour: number;
lastUpdated: string;
}
type OperationType =
| 'HEADLINE_GENERATION'
| 'MEMORY_CREATION'
| 'BLUEPRINT_PROCESSING'
| 'MEMO_SHARING'
| 'SPACE_OPERATION'
| 'QUESTION_MEMO'
| 'NEW_MEMORY'
| 'MEMO_COMBINE';
const FALLBACK_COSTS: Record<OperationType, number> = {
HEADLINE_GENERATION: 10,
MEMORY_CREATION: 10,
BLUEPRINT_PROCESSING: 5,
QUESTION_MEMO: 5,
NEW_MEMORY: 5,
MEMO_COMBINE: 5,
};
class CreditService {
private readonly memoroServiceUrl: string;
private readonly manaServiceUrl: string;
private creditUpdateCallbacks: ((creditsConsumed: number) => void)[] = [];
private cachedPricing: PricingResponse | null = null;
private pricingLastFetched: number = 0;
private readonly PRICING_CACHE_DURATION = 30 * 60 * 1000; // 30 minutes
private pricingLastFetched = 0;
private readonly PRICING_CACHE_DURATION = 30 * 60 * 1000;
constructor() {
// Use memoro service URL for all endpoints (including auth proxy)
this.memoroServiceUrl = env.middleware.memoroUrl.replace(/\/$/, '');
// manaServiceUrl now points to memoro service (auth proxy handles routing)
this.manaServiceUrl = this.memoroServiceUrl;
}
/**
* Initialize the credit service by preloading pricing
* Call this during app startup
*/
async initialize(): Promise<void> {
try {
await this.getPricing();
console.log('CreditService initialized with backend pricing');
} catch (error) {
console.warn('CreditService initialization failed, using fallback pricing:', error);
}
}
/**
* Register a callback to be notified when credits are consumed
*/
onCreditUpdate(callback: (creditsConsumed: number) => void): () => void {
this.creditUpdateCallbacks.push(callback);
// Return unsubscribe function
return () => {
const index = this.creditUpdateCallbacks.indexOf(callback);
if (index > -1) {
this.creditUpdateCallbacks.splice(index, 1);
}
const i = this.creditUpdateCallbacks.indexOf(callback);
if (i > -1) this.creditUpdateCallbacks.splice(i, 1);
};
}
/**
* Notify all registered callbacks about credit consumption
*/
private notifyCreditUpdate(creditsConsumed: number): void {
this.creditUpdateCallbacks.forEach((callback) => {
try {
callback(creditsConsumed);
} catch (error) {
console.error('Error in credit update callback:', error);
}
triggerCreditUpdate(creditsConsumed: number): void {
this.creditUpdateCallbacks.forEach((cb) => {
try { cb(creditsConsumed); } catch {}
});
}
/**
* Public method to manually trigger credit update notifications
*/
triggerCreditUpdate(creditsConsumed: number): void {
this.notifyCreditUpdate(creditsConsumed);
}
/**
* Fetch pricing information from backend with caching
*/
async getPricing(): Promise<PricingResponse> {
const now = Date.now();
// Return cached pricing if still valid
if (this.cachedPricing && now - this.pricingLastFetched < this.PRICING_CACHE_DURATION) {
return this.cachedPricing;
}
try {
const response = await fetch(`${this.memoroServiceUrl}/memoro/credits/pricing`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const response = await fetch(`${SERVER_URL()}/api/v1/credits/pricing`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const pricing = await response.json();
this.cachedPricing = pricing;
this.pricingLastFetched = now;
return pricing;
} catch (error) {
console.error('Error fetching pricing:', error);
// Fallback to cached pricing if available
if (this.cachedPricing) {
return this.cachedPricing;
}
// Ultimate fallback
if (this.cachedPricing) return this.cachedPricing;
return {
operationCosts: {
TRANSCRIPTION_PER_HOUR: 120,
costs: {
TRANSCRIPTION_PER_MINUTE: 2,
HEADLINE_GENERATION: 10,
MEMORY_CREATION: 10,
BLUEPRINT_PROCESSING: 5,
QUESTION_MEMO: 5,
NEW_MEMORY: 5,
MEMO_COMBINE: 5,
MEMO_SHARING: 1,
SPACE_OPERATION: 2,
MEETING_RECORDING_PER_MINUTE: 2,
},
transcriptionPerHour: 120,
lastUpdated: new Date().toISOString(),
};
}
}
/**
* Get user credits directly from mana-core-middleware
*/
async getUserCredits(appToken: string): Promise<{ credits: number } | null> {
async getUserCredits(token: string): Promise<{ credits: number } | null> {
try {
console.log(
'[CreditService] Fetching user credits from:',
`${this.manaServiceUrl}/auth/credits`
);
if (!appToken) {
console.error('[CreditService] No authentication token available for credits fetch');
throw new Error('No authentication token available');
}
const response = await fetch(`${this.manaServiceUrl}/auth/credits`, {
method: 'GET',
headers: {
Authorization: `Bearer ${appToken}`,
'Content-Type': 'application/json',
},
if (!token) throw new Error('No authentication token available');
const response = await fetch(`${SERVER_URL()}/api/v1/credits/balance`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
});
console.log('[CreditService] Credits response status:', response.status);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error('[CreditService] Credits fetch error:', errorData);
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log('[CreditService] Credits data received:', data);
// Handle wrapped response structure from backend
if (data.data && typeof data.data.credits === 'number') {
return { credits: data.data.credits };
}
// Fallback to direct structure if already in correct format
return data;
if (!response.ok) return null;
return await response.json();
} catch (error) {
console.error('[CreditService] Error fetching user credits:', error);
return null;
}
}
/**
* Get estimated cost for operations using backend pricing
*/
async getOperationCost(operation: OperationType): Promise<number> {
try {
const pricing = await this.getPricing();
return pricing.operationCosts[operation];
} catch (error) {
console.error('Error getting operation cost:', error);
// Fallback to hardcoded costs
const fallbackCosts: Record<OperationType, number> = {
HEADLINE_GENERATION: 10,
MEMORY_CREATION: 10,
BLUEPRINT_PROCESSING: 5,
MEMO_SHARING: 1,
SPACE_OPERATION: 2,
QUESTION_MEMO: 5,
NEW_MEMORY: 5,
MEMO_COMBINE: 5,
};
return fallbackCosts[operation];
return pricing.costs[operation] ?? FALLBACK_COSTS[operation];
} catch {
return FALLBACK_COSTS[operation];
}
}
/**
* Calculate cost for memo combination based on number of memos
*/
async calculateMemoCombineCost(memoCount: number): Promise<number> {
const costPerMemo = await this.getOperationCost('MEMO_COMBINE');
return memoCount * costPerMemo;
}
/**
* Synchronous version for immediate UI display (uses cached values)
*/
getOperationCostSync(operation: OperationType): number {
if (this.cachedPricing) {
return this.cachedPricing.operationCosts[operation];
}
if (this.cachedPricing) return this.cachedPricing.costs[operation] ?? FALLBACK_COSTS[operation];
return FALLBACK_COSTS[operation];
}
// Fallback to hardcoded costs if no cache
const fallbackCosts: Record<OperationType, number> = {
HEADLINE_GENERATION: 10,
MEMORY_CREATION: 10,
BLUEPRINT_PROCESSING: 5,
MEMO_SHARING: 1,
SPACE_OPERATION: 2,
QUESTION_MEMO: 5,
NEW_MEMORY: 5,
MEMO_COMBINE: 5,
};
return fallbackCosts[operation];
async calculateMemoCombineCost(memoCount: number): Promise<number> {
return memoCount * (await this.getOperationCost('MEMO_COMBINE'));
}
calculateMemoCombineCostSync(memoCount: number): number {
return memoCount * this.getOperationCostSync('MEMO_COMBINE');
}
/**
* Retry transcription for a failed memo using the reprocess-memo endpoint
*/
async retryTranscription(
memoId: string,
appToken: string
): Promise<{ success: boolean; message: string }> {
try {
if (!appToken) {
throw new Error('No authentication token available');
}
const response = await fetch(`${this.memoroServiceUrl}/memoro/reprocess-memo`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${appToken}`,
},
body: JSON.stringify({ memoId }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
return {
success: true,
message: result.message || 'Memo reprocessing started successfully',
};
} catch (error) {
console.error('Error reprocessing memo:', error);
throw error;
async retryTranscription(memoId: string, token: string): Promise<{ success: boolean; message: string }> {
if (!token) throw new Error('No authentication token available');
const response = await fetch(`${SERVER_URL()}/api/v1/memos/${memoId}/retry-transcription`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
});
if (!response.ok) {
const d = await response.json().catch(() => ({}));
throw new Error(d.error || `HTTP ${response.status}`);
}
return { success: true, message: 'Memo reprocessing started successfully' };
}
/**
* Retry headline generation for a failed memo
*/
async retryHeadline(
memoId: string,
appToken: string
): Promise<{ success: boolean; message: string }> {
try {
if (!appToken) {
throw new Error('No authentication token available');
}
const response = await fetch(`${this.memoroServiceUrl}/memoro/retry-headline`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${appToken}`,
},
body: JSON.stringify({ memoId }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
return {
success: true,
message: result.message || 'Headline generation retry initiated successfully',
};
} catch (error) {
console.error('Error retrying headline generation:', error);
throw error;
async retryHeadline(memoId: string, token: string): Promise<{ success: boolean; message: string }> {
if (!token) throw new Error('No authentication token available');
const response = await fetch(`${SERVER_URL()}/api/v1/memos/${memoId}/retry-headline`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
});
if (!response.ok) {
const d = await response.json().catch(() => ({}));
throw new Error(d.error || `HTTP ${response.status}`);
}
return { success: true, message: 'Headline generation retry initiated successfully' };
}
}
// Export singleton instance
export const creditService = new CreditService();

View file

@ -1,14 +1,17 @@
/**
* Question Service for memoro-web
* Handles Q&A functionality for memos
* Handles Q&A functionality for memos via memoro-server (Hono/Bun).
*/
import { env } from '$lib/config/env';
import { tokenManager } from './tokenManager';
import { authStore } from '$lib/stores/auth.svelte';
import { createAuthClient } from '$lib/supabaseClient';
const SERVER_URL = () => env.server.memoroUrl.replace(/\/$/, '');
export interface QuestionResult {
success: boolean;
answer?: string;
memoryId?: string;
error?: string;
creditsConsumed?: number;
@ -22,113 +25,50 @@ export interface Memory {
}
class QuestionService {
/**
* Ask a question about a memo
* This calls the memoro middleware service to generate an AI answer
*/
async askQuestion(memoId: string, question: string): Promise<QuestionResult> {
if (!memoId || !question.trim()) {
return {
success: false,
error: 'Invalid memo ID or question',
};
return { success: false, error: 'Invalid memo ID or question' };
}
try {
// Get a valid token
const token = await tokenManager.getValidToken();
const token = await authStore.getAccessToken();
if (!token) {
return {
success: false,
error: 'Nicht authentifiziert. Bitte melden Sie sich erneut an.',
};
return { success: false, error: 'Nicht authentifiziert. Bitte melden Sie sich erneut an.' };
}
// Get the memoro service URL
const memoroServiceUrl = env.middleware.memoroUrl?.replace(/\/$/, '');
if (!memoroServiceUrl) {
return {
success: false,
error: 'Memoro service URL nicht konfiguriert',
};
}
// Call the memoro service
const response = await fetch(`${memoroServiceUrl}/memoro/question-memo`, {
const response = await fetch(`${SERVER_URL()}/api/v1/memos/${memoId}/question`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
memo_id: memoId,
question: question.trim(),
}),
body: JSON.stringify({ question: question.trim() }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
// Handle specific error codes
if (response.status === 402) {
return {
success: false,
error: 'Nicht genügend Mana. Bitte laden Sie Ihr Konto auf.',
};
return { success: false, error: 'Nicht genügend Mana. Bitte laden Sie Ihr Konto auf.' };
}
if (response.status === 401) {
return {
success: false,
error: 'Sitzung abgelaufen. Bitte melden Sie sich erneut an.',
};
return { success: false, error: 'Sitzung abgelaufen. Bitte melden Sie sich erneut an.' };
}
return {
success: false,
error: errorData.message || `Fehler: ${response.status} ${response.statusText}`,
};
const d = await response.json().catch(() => ({}));
return { success: false, error: d.error || `Fehler: ${response.status}` };
}
const data = await response.json();
if (data?.success && data?.memory_id) {
return {
success: true,
memoryId: data.memory_id,
creditsConsumed: data.creditsConsumed,
};
}
return {
success: false,
error: data?.error || 'Unbekannter Fehler bei der Verarbeitung',
};
return { success: true, answer: data.answer, creditsConsumed: data.creditsConsumed };
} catch (error) {
console.error('Error asking question:', error);
// Check for network errors
if (error instanceof TypeError && error.message.includes('fetch')) {
return {
success: false,
error: 'Netzwerkfehler. Bitte überprüfen Sie Ihre Internetverbindung.',
};
return { success: false, error: 'Netzwerkfehler. Bitte überprüfen Sie Ihre Internetverbindung.' };
}
return {
success: false,
error: error instanceof Error ? error.message : 'Unbekannter Fehler',
};
return { success: false, error: error instanceof Error ? error.message : 'Unbekannter Fehler' };
}
}
/**
* Load memories for a memo
*/
async loadMemories(memoId: string): Promise<Memory[]> {
try {
const supabase = await createAuthClient();
const { data, error } = await supabase
.from('memories')
.select('id, title, content, metadata')
@ -140,7 +80,6 @@ class QuestionService {
console.error('Error loading memories:', error);
return [];
}
return data || [];
} catch (error) {
console.error('Error loading memories:', error);
@ -149,5 +88,4 @@ class QuestionService {
}
}
// Export singleton instance
export const questionService = new QuestionService();