managarten/apps-archived/memoro/apps/mobile/features/credits/creditService.ts
Till-JS 61d181fbc2 chore: archive inactive projects to apps-archived/
Move inactive projects out of active workspace:
- bauntown (community website)
- maerchenzauber (AI story generation)
- memoro (voice memo app)
- news (news aggregation)
- nutriphi (nutrition tracking)
- reader (reading app)
- uload (URL shortener)
- wisekeep (AI wisdom extraction)

Update CLAUDE.md documentation:
- Add presi to active projects
- Document archived projects section
- Update workspace configuration

Archived apps can be re-activated by moving back to apps/

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 07:03:59 +01:00

374 lines
9.8 KiB
TypeScript

/**
* Service for handling credit operations from the frontend
*/
export interface CreditCheckResponse {
hasEnoughCredits: boolean;
currentCredits: number;
requiredCredits: number;
creditType: 'user' | 'space';
durationMinutes?: number;
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;
HEADLINE_GENERATION: number;
MEMORY_CREATION: number;
BLUEPRINT_PROCESSING: number;
QUESTION_MEMO: number;
NEW_MEMORY: number;
MEMO_COMBINE: number;
MEMO_SHARING: number;
SPACE_OPERATION: number;
};
transcriptionPerHour: number;
lastUpdated: string;
}
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
constructor() {
// Use memoro service URL for all endpoints (including auth proxy)
this.memoroServiceUrl =
process.env.EXPO_PUBLIC_MEMORO_MIDDLEWARE_URL || 'http://localhost:3001';
this.memoroServiceUrl = this.memoroServiceUrl.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) {
this.creditUpdateCallbacks.push(callback);
// Return unsubscribe function
return () => {
const index = this.creditUpdateCallbacks.indexOf(callback);
if (index > -1) {
this.creditUpdateCallbacks.splice(index, 1);
}
};
}
/**
* Notify all registered callbacks about credit consumption
*/
private notifyCreditUpdate(creditsConsumed: number) {
this.creditUpdateCallbacks.forEach((callback) => {
try {
callback(creditsConsumed);
} catch (error) {
console.error('Error in credit update callback:', error);
}
});
}
/**
* Public method to manually trigger credit update notifications
* Use this when credits are consumed outside of the creditService methods
*/
triggerCreditUpdate(creditsConsumed: number) {
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 pricing = await response.json();
this.cachedPricing = pricing;
this.pricingLastFetched = now;
return pricing;
} catch (error) {
console.error('Error fetching pricing:', error);
// Fallback to hardcoded pricing if backend fails
if (this.cachedPricing) {
return this.cachedPricing;
}
// Ultimate fallback
return {
operationCosts: {
TRANSCRIPTION_PER_HOUR: 120,
HEADLINE_GENERATION: 10,
MEMORY_CREATION: 10,
BLUEPRINT_PROCESSING: 5,
QUESTION_MEMO: 5,
NEW_MEMORY: 5,
MEMO_COMBINE: 5,
MEMO_SHARING: 1,
SPACE_OPERATION: 2,
},
transcriptionPerHour: 120,
lastUpdated: new Date().toISOString(),
};
}
}
/**
* Get user credits directly from mana-core-middleware
*/
async getUserCredits(): Promise<{ credits: number } | null> {
try {
console.log(
'[CreditService] Fetching user credits from:',
`${this.manaServiceUrl}/auth/credits`
);
const { tokenManager } = await import('~/features/auth/services/tokenManager');
const appToken = await tokenManager.getValidToken();
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',
},
});
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;
} catch (error) {
console.error('[CreditService] Error fetching user credits:', error);
return null;
}
}
/**
* Get estimated cost for operations using backend pricing
*/
async getOperationCost(
operation:
| 'HEADLINE_GENERATION'
| 'MEMORY_CREATION'
| 'BLUEPRINT_PROCESSING'
| 'MEMO_SHARING'
| 'SPACE_OPERATION'
| 'QUESTION_MEMO'
| 'NEW_MEMORY'
| 'MEMO_COMBINE'
): 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 = {
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];
}
}
/**
* 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 versions for immediate UI display (uses cached values)
*/
getOperationCostSync(
operation:
| 'HEADLINE_GENERATION'
| 'MEMORY_CREATION'
| 'BLUEPRINT_PROCESSING'
| 'MEMO_SHARING'
| 'SPACE_OPERATION'
| 'QUESTION_MEMO'
| 'NEW_MEMORY'
| 'MEMO_COMBINE'
): number {
if (this.cachedPricing) {
return this.cachedPricing.operationCosts[operation];
}
// Fallback to hardcoded costs if no cache
const fallbackCosts = {
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];
}
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): Promise<{ success: boolean; message: string }> {
try {
const { tokenManager } = await import('~/features/auth/services/tokenManager');
const appToken = await tokenManager.getValidToken();
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;
}
}
/**
* Retry headline generation for a failed memo
*/
async retryHeadline(memoId: string): Promise<{ success: boolean; message: string }> {
try {
const { tokenManager } = await import('~/features/auth/services/tokenManager');
const appToken = await tokenManager.getValidToken();
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;
}
}
}
// Export singleton instance
export const creditService = new CreditService();